Skip to content

Suggest mode: inline suggestions on the #78218 marker primitive#79282

Draft
adamsilverstein wants to merge 29 commits into
try/inline-suggestions-basefrom
try/inline-suggestions-phase2
Draft

Suggest mode: inline suggestions on the #78218 marker primitive#79282
adamsilverstein wants to merge 29 commits into
try/inline-suggestions-basefrom
try/inline-suggestions-phase2

Conversation

@adamsilverstein

Copy link
Copy Markdown
Member

What

Builds the inline-suggestion layer of Suggest mode on top of the reusable inline-<mark> marker primitive from #78218 (Option B: suggested inline changes live as marked text in block content, not in an ephemeral overlay). This is the implementation of the plan in docs/superpowers/plans/2026-06-16-suggest-mode-inline-markers.md and supersedes the bespoke inline-formats.js diff layer from #77869.

It resolves Riad's "brittle before/after baseline" objection (#73411) and structurally dissolves the multi-author inline collision (#79220): each inline suggestion is an independent, author-tagged <mark> resolved on read via findMarkerRange, never a stored offset.

Stacking

What's here

  • Shared inline-markers/ primitive extracted from Notes (findMarkerRange, wrapInlineMarker, readInlineSelection, readInlineCaret, reconcileMarkerRemoval, useAnnotateRanges); Notes refactored to consume it with no behavior change.
  • core/suggestion marker format (<mark class="wp-suggestion" data-suggestion-id data-suggestion-type="del|add" data-author>), runtime-decorated via the annotations API, with a type-aware PHP render-strip (del keeps text/drops wrapper; add drops wrapper + text until accepted).
  • Edit-driven creation (no toolbar buttons — in Suggest mode every edit is a suggestion):
    • Deletion: select + Delete → del marker (beforeinput interception).
    • Addition: type → add marker; contiguous typing grows one marker.
    • Type-over: del on the selection + add for the replacement.
    • Paste (single line): add marker (handled on the paste event ahead of the editor's paste pipeline).
  • Accept/reject transforms (acceptInlineDeletion/rejectInlineDeletion/acceptInlineAddition/rejectInlineAddition) wired through provider.js.

Still on the overlay/diff path by design

Inline formatting changes (bold/italic/link) change a property of existing text, not add/remove it, so they keep using the existing overlay diff (inline-formats.js). Collapsed-cursor single-char deletion and IME composition are follow-ups.

Testing

  • Unit: the primitive + operations (offset resolution, accept/reject symmetry, marker growth).
  • PHP: render_block strip (del keeps text, add drops text, wrappers removed, raw/REST retains markers).
  • Verified live in wp-env: create del/add/type-over/paste suggestions, persistence across save, front-end strip, decoration.

🤖 Generated with Claude Code

@github-actions github-actions Bot added [Package] Editor /packages/editor [Package] Block editor /packages/block-editor labels Jun 17, 2026
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

Size Change: +4.33 kB (+0.06%)

Total Size: 7.52 MB

📦 View Changed
Filename Size Change
build/scripts/editor/index.min.js 490 kB +3.84 kB (+0.79%)
build/styles/block-editor/content-rtl.css 6.04 kB +120 B (+2.03%)
build/styles/block-editor/content-rtl.min.css 4.59 kB +123 B (+2.75%)
build/styles/block-editor/content.css 6.03 kB +116 B (+1.96%)
build/styles/block-editor/content.min.css 4.59 kB +122 B (+2.73%)

compressed-size-action

…77869)

The formatting-change suggestion path (bold/italic/link) still uses the
overlay diff renderer from the now-closed #77869. It is reused here by the
inline-suggestion layer; the add/delete/replace text paths use in-content
markers instead.
Generalize the inline-note marker helpers (#78218) into a format-agnostic
primitive under editor/components/inline-markers/ so both Notes and the
upcoming inline Suggestions can anchor ranges to edit-surviving <mark>
markers and derive offsets on read.

- findMarkerRange: parameterize findNoteRange over format type, id
  attribute, and an optional quick-reject token. The single
  offset-resolution point and the intended CRDT swap point.
- wrapInlineMarker / readInlineSelection / reconcileMarkerRemoval /
  useAnnotateRanges: generalize the wrap, selection-read, removal-decision,
  and annotation-decoration helpers; parameterize the annotation source.

Refactor Notes to consume the primitive with no behavior change: the
Notes-specific _wp_note_selection meta fallback stays in the collab-sidebar
layer, so the primitive itself derives strictly from the in-content marker
(no stored offsets). All 90 existing collab-sidebar tests pass unchanged;
24 standalone primitive tests added.

Part of Phase 0 of the inline-suggestions plan.
Build the inline-suggestion marker layer on the shared inline-markers
primitive (Option B: suggested inline changes live as marked text in block
content).

- core/suggestion rich-text format, serializing as
  <mark class="wp-suggestion" data-suggestion-id data-suggestion-type
  data-author>. The distinct wp-suggestion class never collides with
  wp-note or a user/core/text-color <mark>; data-author makes per-author
  attribution structural. findSuggestionRange resolves a marker's live
  range by id via the primitive (offsets derived on read).
- useAnnotateSuggestions decorates ranges under the core-suggestion
  annotation source, independent of Notes' core-note source.
- A type-aware render_block strip
  (gutenberg_strip_inline_suggestion_markers): a deletion unwraps but keeps
  its text (removed only on accept); an addition removes wrapper *and*
  text, so un-accepted proposed content never reaches the public front end.
  Overlapping byte ranges are merged so a deletion nested in an addition
  can't corrupt offsets. Raw post_content keeps the markers for reload.

The format edit is inert for now; the interactive suggest-delete/add entry
points and overlay wiring come with the suggest-mode integration phases.

Tests: 6 format unit tests; 13 PHP strip cases (del keeps text, add drops
text, wrappers removed, unrelated/note marks untouched, del-in-add and
add-in-del nesting). Part of Phase 1 of the inline-suggestions plan.
Register the core/suggestion marker format so rich-text can round-trip a
suggestion <mark> in block content and the annotations API can decorate it.
Idempotent and guarded against duplicate registration the same way core/note
is, using the rich-text store's getFormatType selector. Phase 1 defined the
format but never registered it, so nothing could yet round-trip or decorate a
marker; this is the foundation the Phase 2 wiring builds on.
Call registerSuggestionFormat() once when the editor provider module loads,
alongside registerSuggestionOverlayFilter(). The format is intent-agnostic and
idempotent, so registering it globally is safe and makes suggestion markers
available to rich-text and the annotations API throughout the editor.
Add SuggestionAnnotations, the suggestion-mode counterpart to Notes'
useAnnotateBlocks. It reads the post's unresolved suggestion threads, re-derives
each core/suggestion marker's live range from the linked block's current content
via findSuggestionRange (offsets are never stored, so a marker survives
unrelated edits elsewhere in the block), and decorates the ranges through the
annotations API. Mounted in the editor provider so markers are visible to
everyone viewing the post, in any intent. The pure suggestionAnnotations helper
is unit-tested. Adds a minimal canvas style keyed off the marker's
data-suggestion-type; full author tinting and Revisions-aligned visuals follow
in the plan's Phase 5.

Phase 1 left the format unregistered with no decoration consumer; this is the
decoration half of Phase 2 Slice A (registration landed in 649b29b).
Pure RichTextData transforms for resolving an inline deletion suggestion
against a block attribute:

- acceptInlineDeletion: remove both the marked text and its marker (the
  proposed removal becomes permanent).
- rejectInlineDeletion: drop only the marker, keeping the text (the
  existing content is preserved).

Both resolve the marker's range from the in-content marker on read via
findSuggestionRange, so they stay correct after unrelated edits elsewhere
in the value, and both no-op on non-rich-text values or a missing marker.
These are the value-level half of Phase 2; the provider apply/reject
wiring and the suggest-deletion action build on them.

9 unit tests, including the marker-shifted-by-an-unrelated-edit case.
Wire the inline-suggestion op type into the provider's apply/reject paths,
sitting alongside the attribute-set and structural branches:

- findInlineOp locates an inline-suggestion op in a payload.
- applySuggestion resolves the marker by comment id and rewrites the one
  marked attribute: a deletion drops the marked text with its marker, an
  addition unwraps the marker so the text becomes permanent. The write
  bypasses the suggest-mode interceptor and rolls the attribute back if the
  status save fails.
- rejectSuggestion restores the pre-suggestion content: a deletion keeps
  the text and drops the marker, an addition removes the proposed text.
  Resolves the target block via the same metadata.noteId scan apply uses.

Inline ops carry no stored offsets; the range is re-derived from the
in-content marker on read (acceptInlineDeletion/rejectInlineDeletion). Part
of Phase 2; the suggest-deletion action that creates these markers follows.

3 findInlineOp unit tests; all 234 suggestion-mode + inline-suggestion
tests pass.
Complete the inline-deletion loop with the creation entry point. In the
suggest intent, a non-collapsed rich-text selection shows a Suggest
deletion control (the core/suggestion format's edit). Clicking it persists
a note-type suggestion carrying an inline-suggestion op, then wraps the
selected range in a <mark class="wp-suggestion" data-suggestion-type="del">
keyed by the new comment id. The wrap is written straight to the block
attribute with an interceptor bypass so the marker lands in content (where
it syncs and survives reload) instead of the overlay.

- registerSuggestionFormat now accepts an optional edit, so the generic
  inline-suggestions primitive stays UI-free while suggest mode supplies
  the marker-creating control.
- INLINE_OP_TYPE is exported for the control to build the op.
- buildSuggestionMarkerAttributes pins the marker attribute contract
  (id/type/author) in one tested place.

Completes Phase 2 (deletions): create wraps a del marker, decoration shows
it, apply removes text+marker, reject keeps text. 236 unit tests pass; the
toolbar control itself needs e2e/manual verification in a running editor.
The suggestion-annotation ranges were memoized on [threads,
getBlockAttributes], but getBlockAttributes is a stable selector
reference, so the memo only recomputed when the thread list changed -
never when block content changed. The suggest-delete flow creates the
comment first (threads update, marker not yet in content -> no range)
and then writes the marker into content (content changes, threads do
not -> memo never reruns), so the decoration did not appear until an
unrelated threads update or a remount (toggling the code editor).

Re-derive the ranges inside useSelect via a reactive signature so they
recompute when a tracked block's content changes, mirroring the
moveSignature pattern in overlay-context.js. Notes is unaffected: it
keeps a stored-offset fallback, while suggestion markers are
content-only and must be re-derived reactively.
…button

In Suggest mode every ordinary edit is already a suggestion, so deleting
selected text should mark it as a proposed deletion - not require a
special control. The Phase 2 'Suggest deletion' toolbar button was the
wrong user flow.

Remove the button (SuggestDeletionFormatEdit) and register the
core/suggestion format inert. Add SuggestionDeletionKeyboard: a
capture-phase keydown interceptor (mounted in Suggest mode) that, on
Backspace/Delete over a non-collapsed selection inside a single
rich-text attribute, prevents the removal and wraps the range in an
in-content <mark data-suggestion-type="del"> marker keyed to a fresh
suggestion note. Capture phase + preventDefault makes RichText's own
delete handler bail (it checks event.defaultPrevented) and blocks the
browser's contentEditable removal.

The marker format, operations, accept/reject, decoration, and PHP strip
are unchanged - only the creation trigger moved. Collapsed-cursor
deletion and typing-over-selection still fall through to the existing
path; they are handled when additions land (Phase 3).
Additions are the mirror of deletions: accepting an addition keeps the
proposed text and drops the marker (== rejecting a deletion); rejecting
an addition removes the text with its marker (== accepting a deletion).
Factor the two underlying transforms into removeMarkedRange and
unwrapMarker primitives and expose four clearly-named operations
(accept/reject x deletion/addition) plus insertInlineAddition, a pure,
id-agnostic helper that inserts proposed text wrapped in an add marker
(collapsed range = caret insert, non-collapsed = type-over).

Rewire the provider's inline apply/reject to the explicit addition
functions instead of inverting the deletion ones, so the add path reads
as what it does. Behaviour is unchanged; add unit tests covering the
addition transforms, the add/del symmetry, and insertInlineAddition
(insert, type-over, append, reversibility).

This completes the accept/reject/strip half of additions. The creation
trigger (typing in Suggest mode -> add marker, with run coalescing) is
the remaining net-new piece and reuses the deletion interception path.
Verified in wp-env that the keydown approach let partial-selection
deletes fall through: the marker never landed in content and the edit
went into the old overlay path instead (text removed, suggest-mode-only,
gone in edit mode). Browsers apply a selection delete through
beforeinput, where preventDefault reliably cancels the edit; a keydown
preventDefault does not.

Listen for beforeinput (capture) on every candidate document (top doc +
same-origin iframes, so the canvas iframe is covered), match any delete
input type over a non-collapsed inline selection in a contentEditable,
cancel it, and wrap the range in the in-content del marker. Confirmed:
the <mark class="wp-suggestion" data-suggestion-type="del"> now lands
in content and renders its strikethrough in both suggest and edit mode.
…kers

The runtime annotation decoration nests a <mark class="annotation-text">
inside each inline suggestion marker. Suggestion threads are note-type
comments, so NoteHighlightStyles tints that inner mark with the author's
color by id, painting a translucent highlight box whose rounded edges
read as stray vertical bars around the run and any trailing space. The
marker's own strikethrough/underline is the intended visual, so the box
is redundant; neutralize it with an !important rule that beats the
per-thread id-specificity rule. Per-author tinting returns on the marker
itself in the Phase 5 visual work.
In Suggest mode, typing now lands proposed text as an in-content
core/suggestion add marker rather than as permanent content, mirroring
the deletion trigger. A new SuggestionAdditionKeyboard intercepts
beforeinput insertText (capture phase) at a collapsed caret, cancels the
native insertion, and writes the marked text itself, advancing the caret
via selectionChange.

A contiguous run of typing grows a single marker: the first character
opens the suggestion note (async; characters typed in that window buffer
and flush together once the id resolves), and each subsequent character
re-stamps the whole marker span so it stays one <mark> rather than
fragmenting per keystroke (growInlineAddition). readInlineCaret reads a
collapsed caret from selection state (readInlineSelection rejects one).

Type-over (typing with a selection), IME composition, and paste are left
to the existing path for now. Verified live in wp-env: typing produces a
single add marker keyed to one note, rendered underlined/green, caret
tracking correctly.
Typing with a non-collapsed selection in Suggest mode now proposes
deleting the selected text (a del marker) and adding the typed
replacement (an add run at the selection end), as two independent
notes, instead of falling through to the overlay diff path. The
addition keyboard reads the caret with readInlineCaret (collapsed or
range): a range opens a deletion note, wraps the selection, then opens
an addition note and inserts the replacement right after it (wrapping
the selection in the del marker leaves the text length unchanged, so
the selection end is still the insertion point).

An early mid-creation guard buffers characters typed while either note
request is in flight, regardless of selection state, so a fast type-over
flushes cleanly once the ids land. Verified live: type-over a word
produces adjacent del+add markers keyed to two notes.
A simple single-line paste in Suggest mode now becomes an add-marker
suggestion like typed text. Paste is handled on the `paste` event in
capture phase, ahead of the editor's own paste pipeline, and cancelled
there (stopImmediatePropagation) — intercepting it at `beforeinput`
`insertFromPaste` instead raced with the editor's paste handler and left
an orphaned note while the marker write was clobbered. Multi-line /
block-level paste is left to the editor's pipeline (no preventDefault).

Typing, type-over, and paste now share one `insertText` router (buffer
in-flight / grow contiguous / start a new run) over a `beginInsertion`
helper that opens the note(s) and writes the marker(s), removing the
duplicated del+add and single-add sequences. Verified live: paste yields
one add marker keyed to one note, and continued typing grows the same
marker.
Backspace and Delete at a caret (no selection) in Suggest mode now mark
a character for deletion instead of removing it. Backspace marks the
character before the caret and moves left; Delete marks the one after
and holds the caret. Repeating in the same direction grows a single del
marker (re-wrapping the whole span keeps it one <mark>); the first
keystroke opens the note and repeats during that window are counted and
applied on flush. A target character already inside a suggestion marker,
and word/line deletes, fall through to the default path.

This closes the last inline-text gap: typing, selection-delete,
type-over, paste, and now single-character delete all produce markers,
so the overlay diff path is reached only by formatting changes. Verified
live: Backspace grows one del marker leftward; Delete marks forward as
its own run.
Inline suggestion markers now carry their author's color. A new
SuggestionAuthorColors component injects one
`.wp-suggestion[data-author="N"]{--suggestion-author-color:<color>}`
rule per distinct author with an inline-suggestion thread, via
useStyleOverride so it reaches the iframed canvas. The marker rules in
content-suggestion.scss already consume that custom property (with the
semantic red/green as the fallback), so the strikethrough/underline keeps
conveying delete-vs-add while the color conveys who — the Google-Docs
model, and the #77869 Q1 authorship-follows-the-mark request.

The tint is keyed off the marker's own data-author attribute, so it
survives reload and a reviewer's view and no longer depends on a block
also carrying a structural suggestion (which was the only thing setting
the custom property before). Verified live: a clean deletion with no
structural marker tints to the author color (#D94145 for user 1).
Update the suggestions architecture doc for Option B: inline text
changes (typing, delete, type-over, paste, collapsed-cursor delete) now
live as anchored core/suggestion <mark> markers in block content,
re-resolved on read via findMarkerRange, rather than whole-attribute
overlay diffs. Adds an Inline suggestion markers section (marker shape,
edit-driven creation table, decoration, type-aware PHP strip, accept/
reject, per-author tint), notes the overlay path now serves only
non-text attribute and inline-formatting changes, lists the new files
and the inline-markers/ + inline-suggestions/ directories, and updates
the Overview and the resolved sub-attribute-anchoring limitation.
Rewrite the add/delete golden-path e2e tests for the Option B marker
model and add type-over and collapsed-cursor-delete cases. Typing and
deleting now produce in-content core/suggestion <mark> markers
(visible immediately, no blur), so the tests assert
mark.wp-suggestion[data-suggestion-type] and that the proposed text
round-trips through the serialized post. The style (bold) golden path is
unchanged — inline formatting still uses the overlay <ins>/<del> diff.
Mark phases 0-4 + Phase 5 tinting + Phase 6 docs/e2e complete; note the
deferred follow-ups (formatting/IME/multi-line paste as markers, Revisions
visual alignment, inverse marker-removal cleanup, undo/RTC e2e).
@adamsilverstein adamsilverstein force-pushed the try/inline-suggestions-phase2 branch from d42e861 to 64fe549 Compare June 18, 2026 06:18
… Format

Typing an addition (or deleting a selection) in Suggest mode produces an
'inline-suggestion' operation. Unlike an 'attribute-set' op it carries no
before/after text — the proposed words live in the in-content marker, so
the payload only records the op type and suggestion kind. summarizeOperations
had no branch for it and fell through to the generic attribute handler,
labelling the sidebar note 'Format: content' instead of describing the edit.

Resolve the marker's text from the linked block's live content (the same
way the canvas decoration derives the marker's range) and feed it into the
summary so the note reads like a Google Docs review note: Add: "new text"
or Delete: "old text".

- inline-markers: add findMarkerText alongside findMarkerRange, sharing a
  single parse via parseMarkerValue/rangeInRecord.
- inline-suggestions: add findSuggestionText wrapper.
- suggestion-actions: resolve inline-suggestion text reactively (so the
  summary tracks the marker as contiguous typing grows it) before handing
  enriched operations to the pure summarizeOperations.
- summarizeOperations: render inline-suggestion ops as Add:/Delete:, falling
  back to the attribute label only when the marker can no longer be resolved.

Adds unit coverage for findMarkerText and the new summary branch, plus an
e2e test reproducing the report: type before a word in Suggest mode and
confirm the note summarizes 'Add: new text' rather than 'Format: content'.

(cherry picked from commit 6c7f6f2)
Port the Option B assertion updates and the auto-save race fix from the
combined branch. The 'auto-saves a content edit as a suggestion' test still
carried the pre-Option-B expectation that a typed addition stays out of the
serialized post, then waited for a /wp/v2/comments POST. Under the inline-
marker model typing routes through SuggestionAdditionKeyboard, which bypasses
the overlay interceptor: the addition round-trips inside a wp-suggestion add
marker and its note POST fires during typing, so waitForResponse races it.

Assert the race-free way instead: the in-content add marker is only written
once its note id resolves, so a populated data-suggestion-id proves the
suggestion persisted. Mirrors the golden-path inline tests.
…t Format:

Typing only whitespace in Suggest mode produces an inline-suggestion op
whose resolved text is all spaces. summarizeOperations required that text
to survive a trim() and then ran it through ellipsize(), which collapses
and trims whitespace — so a pure-whitespace edit resolved to an empty
quote and fell through to the generic "Format: content" label.

Render the marker text verbatim via a new clampText() helper that caps
length without collapsing whitespace, and only fall back to the attribute
label when no text resolves (non-string or empty op.text, i.e. the marker
was edited away). A single added space now reads Add: "   ".

Covered by a unit case in suggestion-summary and an e2e regression in
suggestion-mode.spec.js.
@adamsilverstein

Copy link
Copy Markdown
Member Author

Fixed in ab2ba9ff5a6: a whitespace-only inline addition (e.g. a typed space) summarized as Format: content instead of Add: " ". The inline-suggestion branch of summarizeOperations required the resolved text to survive a trim() and then ellipsize()d it (collapsing whitespace), so pure-whitespace edits became empty and fell back to the attribute label. Now rendered verbatim via a clampText() helper, falling back only when no text resolves. Unit + e2e coverage added.

adamsilverstein added a commit that referenced this pull request Jun 24, 2026
…tent

Clicking the default block appender inserts an unmodified default
paragraph via insertDefaultBlock. In Suggest mode that empty block
incorrectly became an Insert block suggestion on its own. Guard the
store interceptor's new-block branch with isUnmodifiedDefaultBlock so
the insertion is only recorded once the block has content, and skip
without recording a snapshot so a later fire re-enters the branch.

Ports the fix already present on the combined branch (#78994) and the
structural stack (#77971) to the inline-suggestions base so #79282
matches. Adds the matching e2e regression test.
…s-phase2

Cascades the trunk catch-up and the empty-inserted-block guard up from
the base branch. Conflict resolutions mirror the combined branch
(#78994):

- lib/compat/wordpress-7.1/block-suggestions.php: union of the base's
  suggestion meta registration and this branch's inline-marker
  render-strip (meta + strip).
- lib/load.php: add the 7.1 comment controller require.
- editor CHANGELOG: keep the suggest-mode New Features and Bug Fixes
  entries above trunk's released 14.49.0 / 14.48.1 headers.
- provider.js: point the payload-size doc reference at
  block-suggestions.php (where the meta now lives).

After this merge the suggestion feature footprint is identical to the
combined branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Block editor /packages/block-editor [Package] Editor /packages/editor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant