Skip to content

[6.x] Autocomplete editor - #15055

Draft
jasonvarga wants to merge 40 commits into
6.xfrom
autocomplete-editor
Draft

jasonvarga wants to merge 40 commits into
6.xfrom
autocomplete-editor

Conversation

@jasonvarga

@jasonvarga jasonvarga commented Jul 22, 2026 •

Copy link
Copy Markdown
Member

This adds a simple editor component and fieldtype that includes support for autocomplete mentions.

This will allow us to add configuration fields that make more sense for client facing areas, rather than expecting Antlers to be used.

For example in Forms 2, you'll be able to insert field references within email content.

It's not selectable in the field picker. It's intended for config-facing fields defined in PHP, where the available options can be generated from context, such as a form's own fields.

CleanShot.2026-07-22.at.15.09.58.mp4

How the value is stored

By default, a markdown string with mentions as [[ handle ]] tokens.

email_body: |-
  ## Thanks for getting in touch

  Hi [[ first_name ]],

  We received your message and will reply to [[ email ]] within **two business days**.

Square brackets are markdown special characters, so a literal [[ ]] that an author types is stored escaped, as \[\[ first\_name \]\], and can't be mistaken for a real mention. Text that would otherwise be read as block syntax at the start of a line (- x, # x, 1. x, ---, deep indentation) is escaped too, so it reloads as the text that was typed. The editor itself still speaks ProseMirror. Conversion happens in the fieldtype, at the boundary.

Plain text

Markdown is awkward for simple fields like an email subject, so it can be turned off with markdown: false. The editor is then always inline with no toolbar or formatting, and the value is stored as typed:

subject: 'Hi [[ first_name ]], thanks for your *order*'

The only escaping is a literal [[, stored as \[[, plus any backslashes directly before a [[, which are doubled. enable_line_breaks still applies, and line breaks are stored as newlines.

Option values

Mention values are limited to letters, numbers, underscores, hyphens and periods, which is what the token grammar matches. A static option outside that throws when the field is preloaded. Options from loadOptions arrive at runtime, so invalid ones are dropped with a console warning.

A mention whose option no longer exists, such as a deleted form field, is shown struck through in red.

Resolving mentions

The fieldtype deliberately doesn't resolve them. The values a mention stands for are runtime data it has no access to at augmentation time, so that's the consumer's job:

Mentions::replace($content, ['first_name' => 'John']);

The map doubles as the allowlist, and an unknown key resolves to an empty string. Values are substituted verbatim. Only the consumer knows what context the result ends up in, so escaping belongs to it as well.

To combine mentions with a template pass such as Antlers, give replace() a using: callback:

Mentions::replace($content, $values, using: fn (string $template) => (string) Antlers::parse($template, $data));

Neither order is safe on its own. Parsing first lets submitted values be read as mentions (or have their backslashes eaten), while resolving first turns them into template source. With using:, mentions become inert placeholders, the callback receives the template, and the values are put back afterwards. The callback never sees a value.

The field doesn't offer Antlers itself, so using: is how to run it. Pass replace() the output of augment() or parseMarkdown(), before any Antlers parsing.

Augmentation

In markdown mode, augment() parses the markdown to HTML. Without it the escaping the serializer applies would leak to the front end: text typed as 5 * 2 is stored as 5 \* 2. Inline fields unwrap the single wrapping paragraph so they don't emit a block-level tag.

Mention tokens are left intact for the consumer to resolve. An author's literal [[ is encoded as [[, as are backslashes directly before a token (\), so they can't be confused with a real mention once the markdown escaping is gone. Both render the same in a browser.

parseMarkdown() is public so a consumer holding a raw config string, rather than a Value, gets the same output.

In plain text mode, augment() returns the stored string as is.

Not included

No Forms wiring. This ships the fieldtype and the primitives; Forms 2 consumes them separately.

jasonvarga and others added 14 commits July 22, 2026 12:13
Verified @floating-ui/dom is importable via the bundle already (a
reka-ui dependency), so the AutocompleteEditor suggestion popup can
use it directly without pulling in tippy.js or falling back to
manual posToDOMRect positioning.

Co-Authored-By: Claude <noreply@anthropic.com>
Reusable TipTap mention extension storing both value and label attrs,
with a baked-in suggestion renderer that drives SuggestionList.vue and
positions it via the suggestion utility's built-in floating-ui mount.
App-specifics (char, items) are passed through .configure() so Bard can
reuse it later.

Co-Authored-By: Claude <noreply@anthropic.com>
Build the AutocompleteEditor.vue primitive (TipTap block editor with
conditional bold/italic/heading/list extensions, wired to the shared
Autocomplete mention extension) plus a generic EditorToolbarButton.vue
and AutocompleteEditorToolbar.vue. Export the primitive from the ui index.

Co-Authored-By: Claude <noreply@anthropic.com>
Use DocumentInline (single paragraph) when inline, DocumentBlock
otherwise, dropping block nodes/toolbar in inline mode. Gate line
breaks on enableLineBreaks: swallow Enter when false, hard break
(inline) or paragraph (block) when true, always passing Ctrl/Cmd+Enter
through for form submission.

Co-Authored-By: Claude <noreply@anthropic.com>
Cover default+toolbar, inline, inline+enableLineBreaks, readOnly, and
an autocomplete demo with a pre-populated mention chip. Includes an
interaction test verifying @-select-via-Enter works in inline mode
with enableLineBreaks disabled (the suggestion popup's keydown
handling runs before the Enter-swallowing keymap extension since it's
registered earlier in the extensions list).

Co-Authored-By: Claude <noreply@anthropic.com>
Non-selectable fieldtype for storing ProseMirror content with @-mention
autocompletion, backed by the AutocompleteEditor UI primitive. Options
are stored as key/value config (like Checkboxes) and normalized to
{value,label} pairs via preload().

Co-Authored-By: Claude <noreply@anthropic.com>
package-lock.json had @tiptap/vue-3 pinned at 3.22.3 alongside
@tiptap/core at 3.28.0. vue-3 imports cancelPositionCheck and
schedulePositionCheck from core, which don't exist in 3.28.0,
breaking a fresh install build.

Bump all @tiptap/* packages to ^3.28.0 and regenerate the lockfile
so every package resolves to the same 3.28.0 release.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Cover selectable(), config defaults/overrides, process/preProcess
round-tripping for block and inline modes, broken node filtering,
and preload() options normalization.

Co-Authored-By: Claude <noreply@anthropic.com>
Cover toolbar rendering per buttons config, inline hiding the
toolbar, Enter/line-break behavior in inline and block modes,
mention node insertion, update:modelValue emitting the PM content
array, and suggestion item filtering.

Also add AutocompleteEditor to Package.test.js's ui export parity
list, which was missed when the component was exported.

Co-Authored-By: Claude <noreply@anthropic.com>
Was implemented but never added to the fieldtypes registry, so
Statamic couldn't resolve it (FieldtypeNotFoundException) when used
in a blueprint.

Co-Authored-By: Claude <noreply@anthropic.com>
- enable_line_breaks only gates Enter in inline mode; block mode
  always splits into a new paragraph on Enter, matching Bard. The
  config field is now conditional on inline, like Bard's equivalent.
- Focus ring now wraps the content area (below the toolbar) with
  padding inside it, instead of hugging the unpadded ProseMirror
  element with padding rendered outside the ring.
- Toolbar background is gray to match Bard's fixed toolbar instead
  of blending into the white field background.

Co-Authored-By: Claude <noreply@anthropic.com>
Add a Vue NodeView for the mention node using VueNodeViewRenderer, pointing
at a new MentionBadge component that wraps the label in the existing Badge
UI component. Schema/attrs and getJSON output are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
- MentionBadge: vertically center the inline badge with surrounding
  text using inline-flex + align-middle instead of sitting on the
  line's baseline.
- AutocompleteEditor: when the toolbar is visible, square off the top
  corners of the content's focus ring so it meets the toolbar's bottom
  edge cleanly, while keeping the full 4-sided ring (matching Bard).
  Inline mode (no toolbar) keeps the full rounded ring.

Co-Authored-By: Claude <noreply@anthropic.com>
jasonvarga and others added 15 commits August 6, 2026 16:18
Regenerated package-lock.json to resolve the conflict; the @tiptap/*
stack lands uniformly on 3.29.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Labels are denormalised config duplicated into every saved mention, so
renaming an option left existing mentions rendering the old text.

Strip the label when processing and hydrate it from the configured
options when preprocessing, so the label always reflects the current
config. Mentions whose option no longer exists fall back to rendering
their value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mentions serialize as {{ value }}. Two edge cases required fixes:
literal {{ }} typed by an author must not round-trip into a mention
(handled by escaping remaining braces after serialize, since the
mention node emits a private sentinel instead of the real token), and
default two-space hard breaks don't survive trailing-whitespace
stripping (handled by a backslash break override).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The primitive still speaks ProseMirror (its public modelValue contract
via @statamic/cms is unchanged); conversion to/from the stored
markdown string happens in the fieldtype wrapper. Mention labels
aren't stored, so they're re-resolved from the configured options
every time content is loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The value is now a markdown string handled entirely on the JS side,
so all the ProseMirror wrap/unwrap and mention label
hydration/stripping machinery is no longer needed. Largely reverts
315be8d.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A value that the tokenizer can't match would otherwise leave raw
sentinel bytes in the saved markdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sentinel is a storage concern of the fieldtype, not a property of the
mention node, and its output is only valid alongside the escaping pass in
markdown.js. Declaring it there keeps the extension reusable and leaves
the primitive's imports free of markdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The markdown lexer throws on anything that isn't a string, which took
down the whole publish form when a field still held an old value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bard scales its editor padding with a container query rather than using
a fixed value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Remove the blanket last-child margin reset so block mode retains
trailing space to match Bard. Add a mode:inline root class and scope
inline mode's ProseMirror padding/line-height to match the standard
text input size instead of inheriting block padding, with a min-height
so it can still grow with line breaks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mentions serialized as {{ value }}, which collides with Antlers and,
because braces aren't markdown special characters, gave the serializer
no way to tell an author's literal braces from ours. That needed a NUL
sentinel and a post-serialize escaping pass to work around.

Square brackets are escaped by the built-in text serializer, so an
author's literal is stored as \[\[ foo \]\] and contains no bracket
pair at all, while a mention emitted by renderMarkdown bypasses the
escaper entirely. The distinction now falls out for free, so both the
sentinel and the escaping pass are gone and the conversion helpers
collapse to plain serialize/parse calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolution is deliberately the consumer's job rather than the
fieldtype's: the values a mention stands for are runtime data the
fieldtype has no access to at augmentation time. The consumer owns the
key => value map, and that map doubles as the allowlist, so an unknown
key resolves to an empty string.

Substituted values aren't escaped. Whatever context the result ends up
in decides that, so it belongs to the consumer too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stored value is a markdown string, so leaving it unaugmented meant
the escaping the serializer applies leaked to the front end: text an
author typed as 5 * 2 is stored as 5 \* 2, and <name> is stored as an
HTML entity. Parsing the markdown is what makes the stored value
readable, whether or not any formatting buttons are enabled.

Inline fields unwrap the single wrapping paragraph so they don't emit a
block-level tag, but fall back to leaving the markup alone when the
content is more than one paragraph.

parseMarkdown is public so a consumer holding a raw config string
rather than a Value can get the same HTML, and augment delegates to it
so the two paths can't drift. Mention tokens are left intact for the
consumer to resolve.

Also declares the antlers config field. It already worked, since
Fieldtype::config merges raw field config regardless, but nothing
advertised it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jasonvarga and others added 11 commits August 11, 2026 13:49
A mention pointing at a deleted field rendered as an ordinary badge, so there was nothing to distinguish it from a working one.

Render it struck through in red instead, showing the raw value since there is no option to take a label from.

Nothing is judged missing when options are loaded on demand, since that list is never complete and every mention would look deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The serializer escapes inline syntax but not markers that only mean something at the start of a line, so plain text like "- x", "# x", "1. x" or "---" reloaded as a list, heading or thematic break, and four spaces of indentation was dropped as a code block. That was easy to hit in inline mode, where the schema has no lists or headings to begin with.

Paragraphs now escape line-leading block markers at their start and after each hard break, following what CommonMark would actually parse there, so text like "1.5 apples", "-5°C" and "#hashtag" is left alone. Leading indentation of four or more columns can't be backslash-escaped, so its first character is written as an entity and turned back into whitespace on load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An author's literal "[[ x ]]" is stored escaped, which is what tells it apart from a real mention, but parsing the markdown removed the escaping. In the augmented HTML the two were identical, so a consumer calling Mentions::replace() after augmenting would substitute the author's literal too.

Real tokens are now swapped for alphanumeric sentinels before the markdown is parsed, any "[[" left in the HTML is encoded, and the sentinels are swapped back for the original tokens. Consumers still resolve mentions on the augmented HTML after Antlers has run, so submitted values are never parsed as markdown or templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mention tokens only match values made of letters, numbers, underscores, hyphens and periods, but option values weren't held to that. A value like "a b" serialized as a token that reloaded as plain text and never resolved, and one containing "]]" split the token.

Static options with a value outside the grammar now throw when the field is preloaded, naming the offending value. Options from loadOptions arrive at runtime, so they're dropped from the suggestions with a console warning instead, and the suggestion list refuses to insert one. Option values are sent to the client as strings so numeric keys still pass the check. The grammar is defined once on each side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Storing markdown is awkward for simple fields like an email subject. Typed characters like "*", "#" and "\" were backslash-escaped in the stored value, and augmenting produced HTML, so "Tom & Jerry" came out as "Tom &amp; Jerry".

A new markdown config, on by default, can be turned off to store plain text instead. The editor is then always inline with no toolbar or formatting, line breaks are stored as newlines when enabled, and augmenting returns the stored string untouched. The only escaping is a typed "[[", stored as "\[[" so it can't become a mention, and any backslashes directly before a "[[", which are doubled.

Mentions::replace() now resolves both formats. An odd run of backslashes before "[[" is an escaped literal and comes out as a visible "[[", while an even run is halved and the token resolves. Augmented markdown never reaches that path because backslashes before a token are now encoded as entities alongside literal brackets, so it resolves exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Consumers were told to parse Antlers before resolving mentions so submitted values can't become template source, but then replace() scans the Antlers output, which includes those submitted values. A submitted "[[ secret ]]" resolved to another field's value, unknown handles in it were blanked, and backslashes before "[[" were eaten. Resolving first instead is template injection, so neither order is safe.

Mentions::replace() now accepts a using callback. Mentions become per-call random placeholders, the callback receives that template, and the values are swapped in afterwards, so the callback never sees a value. Escapes and unknown handles resolve as before, and without a callback nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With antlers enabled, the field is parsed by Antlers whenever it's rendered, and anything resolving mentions afterwards would scan Antlers output that includes submitted values. That's the unsafe order the using callback on Mentions::replace() exists to avoid, and consumers decide whether Antlers runs through that callback anyway, so the field shouldn't offer it in the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A consumer holding a raw string has no field, but parseMarkdown() read inline from the field config, so getting inline output meant building a throwaway Field first. It now takes inline as an argument instead, which pairs naturally with the static Mentions::replace().

augment() also defaults markdown to on when there's no field to supply config defaults, so a bare fieldtype instance no longer returns the raw markdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rendering a markdown value meant parsing it, escaping every value for HTML by hand, and then resolving mentions, and forgetting the escaping fails silently. renderHtml() does all three. Values are HTML-escaped with their line breaks kept, since markdown output is always HTML, and Htmlable values are inserted as-is for consumers whose values are already HTML. It takes inline and using like parseMarkdown() and Mentions::replace(), which stays the raw, context-free primitive.

The first parameter of both methods is named $markdown, so the expected storage format is clear at the call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant