Repository navigation
[6.x] Autocomplete editor - #15055
Draft
jasonvarga wants to merge 40 commits into
Draft
jasonvarga wants to merge 40 commits into
jasonvarga wants to merge 40 commits into
Conversation
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>
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>
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 & 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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:The only escaping is a literal
[[, stored as\[[, plus any backslashes directly before a[[, which are doubled.enable_line_breaksstill 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
loadOptionsarrive 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:
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()ausing:callback: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. Passreplace()the output ofaugment()orparseMarkdown(), 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 as5 * 2is stored as5 \* 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 aValue, 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.