Skip to content

feat: add range decoration layers (registerRangeDecorations, RangeDecorationPlugin) - #3203

Open
christianhg wants to merge 2 commits into
mainfrom
feat/range-decorations-v2
Open

feat: add range decoration layers (registerRangeDecorations, RangeDecorationPlugin)#3203
christianhg wants to merge 2 commits into
mainfrom
feat/range-decorations-v2

Conversation

@christianhg

@christianhg christianhg commented Aug 31, 2026

Copy link
Copy Markdown
Member

What

The rangeDecorations prop on PortableTextEditable is a single mount-level entry, so independent subsystems (comments, presence, diff overlays) cannot contribute decorations without owning the one prop and hand-merging arrays. This PR adds registered decoration layers, @beta throughout.

A decoration is an id, a range, and a render function:

const decoration = defineRangeDecoration({
  id: comment.id, // identity within its layer; duplicates throw
  range: comment.range, // NonNullable<EditorSelection>
  render: ({children, isFirst, isLast}) => <mark>{children}</mark>,
})

render wraps the decorated text; a range crossing marks, blocks, or other decorations renders as several fragments, and isFirst/isLast mark the ones with the range's start and end (each unique per decoration, so one-time chrome renders once).

A layer is a batch of decorations registered together:

const layer = editor.registerRangeDecorations({rangeDecorations, on})

Layers compose: the legacy prop renders outermost, then layers in registration order, array order within a layer, overlaps nesting first-outermost.

The layer stays in sync through full-set replacement, reconciled by id:

layer.update(nextDecorations)

An unchanged decoration keeps its live, edit-adjusted position (the diff runs against what was last supplied, never the tracked position, so a stale resupply cannot snap a decoration back). A changed range re-points; an absent id unregisters. A decoration whose content an edit destroys dies with a tombstone and revives on a changed range or by leaving the array for one update.

The layer reports what edits did to its decorations through on, receiving one batched array per settled change:

type RangeDecorationEvent =
  | {type: 'moved'; rangeDecoration; previousRange; newRange; origin: 'local' | 'remote'}
  | {type: 'content-changed'; rangeDecoration; range; origin: 'local' | 'remote'}
  | {type: 'lost'; rangeDecoration; previousRange; origin: 'local' | 'remote'}

moved and content-changed are orthogonal (typing before a range moves it; a same-length edit inside changes content without moving it; typing inside does both); a dying decoration gets lost only.

The range a decoration was configured with goes stale the moment anyone types above it; the engine tracks where each decoration actually sits. layer.current is that answer, for UI outside the editable that positions or describes itself by a decoration's present location (a popover anchored to a comment, a panel row showing "b0 4–7", a presence list):

layer.current // ReadonlyArray<{id, range}>, live and edit-adjusted

It is a read of this editor's live state, ahead of whatever the on handler has persisted; rendering the decorations themselves never needs it (render handles that), and events never need it (they carry their own ranges). If no UI reads positions, current is simply never touched.

In React, one hook owns the lifecycle and one subscribes to positions:

const layer = useRangeDecorationLayer({rangeDecorations, on})
const positions = useRangeDecorations(layer) // re-renders at the settled boundary

useRangeDecorationLayer is registerRangeDecorations managed by React: it calls it on mount, flows a changed rangeDecorations array through update, and unregisters on unmount. It returns the same layer handle, stable for the component's lifetime, so on may be inline and the handle is safe to pass to other components.

Together they carry a full consumer, a comments layer, in one component:

function CommentsLayer(props: {comments: Array<Comment>; store: CommentStore}) {
  const layer = useRangeDecorationLayer({
    rangeDecorations: useMemo(
      () =>
        props.comments.map((comment) =>
          defineRangeDecoration({
            id: comment.id,
            range: comment.range,
            render: ({children}) => <mark>{children}</mark>,
          }),
        ),
      [props.comments],
    ),
    on: (events) => {
      for (const event of events) {
        if (event.origin !== 'local') continue
        if (event.type === 'moved') props.store.move(event.rangeDecoration.id, event.newRange)
        if (event.type === 'content-changed') props.store.refreshSnippet(event.rangeDecoration.id)
        if (event.type === 'lost') props.store.orphan(event.rangeDecoration.id)
      }
    },
  })
  const positions = useRangeDecorations(layer)

  return <CommentsPanel comments={props.comments} positions={positions} />
}

Editing above a comment moves its highlight and delivers moved; the store persists the new anchor once per settled change. Editing the commented text delivers content-changed; the store refreshes its snippet. Deleting it delivers lost; the panel shows the comment as orphaned, and resupplying it with a new range revives it. The panel's positions tick live as anyone types.

RangeDecorationPlugin is the hook in component form, for provider-level composition next to BehaviorPlugin. The legacy rangeDecorations prop keeps working unchanged as one layer; the new surface never references the legacy type, so both remain removable together later.

Design notes

Events deliver on a trailing microtask per layer, the editor's settled boundary, with listener error isolation (pinned: a throwing handler cannot break a sibling layer). Delivery replicates the relay's batch semantics on a private channel rather than riding editor.on; ordering relative to the mutation event is unspecified. Mixed-origin bursts report each concern's last contributing operation.

useRangeDecorationLayer returns a never-null facade; registration lands in an effect behind it (current reads empty before). The hook owns a hook-created layer's contents: handle updates are superseded by the hook's next sync (pinned by a child racing the owner's registration and losing). layer.current keeps its reference when positions are deep-equal and populates for layers registered before the editor is ready (pinned).

Decoration chrome that is not document text must be CSS generated content or contentEditable={false}: injected DOM text desyncs caret mapping (reproduced; both safe patterns verified on Chromium and Firefox).

The playground dogfoods the surface as two modules: comments (raw handle + hooks, origin-discriminated write-back, undo revives orphaned comments) and presence (plugin, focus-point carets in plugin-sdk-value's shape); the schema's comment annotation is renamed footnote to free the word.

One behavioral fix rides along with its own changeset: two PortableTextEditables under one provider no longer rewrite each other's prop decorations, and unmounting an editable removes only its own contribution (pinned).

Per-operation cost is the same complexity class as the legacy machine (transform plus intersection test per decoration per operation) with a higher constant; unmeasured at large decoration counts, flagged for the presence migration.

@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
portable-text-editor-documentation Ready Ready Preview Sep 3, 2026 9:46am UTC
portable-text-example-basic Ready Ready Preview Sep 3, 2026 9:46am UTC
portable-text-playground Ready Ready Preview Sep 3, 2026 9:46am UTC

Request Review

@changeset-bot

changeset-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a97d124

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@portabletext/editor Minor
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Bundle Stats

Warning

2 significant changes.

@portabletext/editor

🔴 @portabletext/editor (export)
Gzip: 259.5 KB, up 5.8 KB (2.3%)
Raw: 1.11 MB, up 23.4 KB (2.1%)
Import: 71 ms, up 1 ms (1.4%)

🔴 @portabletext/editor/plugins (export)
Gzip: 3.8 KB, up 2.0 KB (113.0%)
Raw: 11.3 KB, up 6.2 KB (121.9%)
Import: 8 ms, up 1 ms (15.0%)

All scenario measurements (7)

🗺️ @portabletext/editor / @portabletext/editor · @portabletext/editor / @portabletext/editor/behaviors · @portabletext/editor / @portabletext/editor/plugins · @portabletext/editor / @portabletext/editor/selectors · @portabletext/editor / @portabletext/editor/traversal · @portabletext/editor / @portabletext/editor/utils · @portabletext/markdown / @portabletext/markdown · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
🔴 @portabletext/editor / @portabletext/editor export 1.11 MB / 259.5 KB +5.8 KB, +2.3% 71 ms +1 ms, +1.4%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +1.1%
🔴 @portabletext/editor / @portabletext/editor/plugins export 11.3 KB / 3.8 KB +2.0 KB, +113.0% 8 ms +1 ms, +15.0%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.7 KB / 21.3 KB -7 B, -0.0% 8 ms -0 ms, -1.1%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.7 KB / 10.8 KB -6 B, -0.1% 6 ms -0 ms, -3.2%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.1 KB / 8.7 KB None 6 ms -0 ms, -3.0%
⚪ @portabletext/markdown / @portabletext/markdown export 272.2 KB / 79.6 KB None 41 ms +2 ms, +4.5%

Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a97d124. Configure here.

)
})
})
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwaway probe test committed

Low Severity

A file labeled THROWAWAY PROBE was committed. It is a one-off investigation of editor.send({type: 'focus'}) after an outside button steals focus, not a pinned suite for the range-decoration surface, and it uses a as never schema stub that does not match the other editor tests.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a97d124. Configure here.

})
})
}
}, [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dialog close focus runs too early

Low Severity

FocusOnCloseSentinel schedules focusOnClose from effect cleanup. React Strict Mode unmounts and remounts on first mount, so the deferred callback still runs while the dialog is open and can steal focus from the auto-focused comment field.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a97d124. Configure here.

…ngeDecorationLayer`, `RangeDecorationPlugin`)

The `rangeDecorations` prop on `PortableTextEditable` was the only way
to draw range decorations, so independent subsystems (comments,
presence, diff overlays) could not contribute decorations without
owning the one prop.
`editor.registerRangeDecorations({rangeDecorations, on})` registers an
independent layer and returns a handle `{update, unregister, current}`;
`useRangeDecorationLayer` is the React binding over it,
`RangeDecorationPlugin` is the hook in component form,
`defineRangeDecoration` is the typed constructor, and
`useRangeDecorations(layer)` subscribes to the layer's live positions.

The per-editable `rangeDecorationsMachine` moves to `EditorProvider`
and keys its context by source: each editable's prop is one source,
each registration another. Sources flatten prop-first, then
registration order, array order within a layer, nesting
first-outermost. Registered decorations reconcile by `id` against the
previously supplied configs, never the tracked position, so a stale
resupply cannot snap a decoration back; a decoration whose content an
edit destroys dies with a tombstone and revives on a changed `range`
or by leaving the array for one `update` and returning.

Layer events deliver batched at the settled boundary: per operation
the machine transforms each range (`transformRange`) and tests content
intersection (`operationTouchesRange`); a per-layer accumulator
flushes on a trailing microtask (the relay's `batch: true` pattern),
merging a burst into at most one `moved` (batch-start vs batch-end
range), one `content-changed`, or a `lost` that discards the others,
with per-layer listener error isolation. `layer.current` recomputes at
the same boundary and keeps its reference when positions are
deep-equal. `render.leaf.tsx` discriminates registered decorations
from the legacy prop by source kind; registered decorations render
through a plain-called `render({children, isFirst, isLast})`, the
fragment flags resolved once per decoration in `getTextDecorations`.

`useRangeDecorationLayer` returns a stable facade handle created once
per component lifetime; registration happens in an effect behind it
(`current` reads empty before), a changed `rangeDecorations` array
reconciles in place, the layer unregisters on unmount, and `on` wraps
in `useEffectEvent` so inline handlers never re-register. The hook
owns a hook-created layer's contents: `update` calls on the handle
are superseded by the hook's next sync, pinned by a child updating
the handle before the owner's registration effect and losing to the
hook's array.

One narrow behavioral fix rides along (own changeset): two
`PortableTextEditable`s under one provider previously rewrote each
other's `decoratedRanges` on every prop update; each editable's prop
is now its own source, and unmounting an editable removes only its
contribution. The legacy `RangeDecoration` type, its
`PropsWithChildren` component signature, the payload-equality guard,
and per-decoration `onMoved` semantics are otherwise unchanged.
…on layers

Comments: a toolbar button captures the selection and a text, stored in
the playground machine as shared data. Each editor registers one layer
through the raw `editor.registerRangeDecorations` handle; `moved` and
`lost` events write back only for `origin === 'local'`, so the editor
that made the edit is the single writer and the same edit arriving at
siblings as remote patches is not reported twice. The Inspector gains a
Comments tab (text, status, compact range, remove); a muted per-editor
line shows live positions via `useRangeDecorations(layer)`. Orphaned
comments revive when their content returns: each comment stores the
covered text at creation, and a value-change effect re-slices the
stored range and reactivates on an exact match, so undoing the deleting
edit restores the comment (leave-then-return revive).

Presence: each editor reports its selection to the playground machine;
sibling editors render `RangeDecorationPlugin` with one collapsed
decoration at each remote focus point, following `plugin-sdk-value`'s
presence shape (caret at focus, `contentEditable={false}` line plus
dot, hash-stable color per editor id, no blues so the tint cannot be
confused with the native selection). Selection churn flows through the
plugin's prop into `update()`, exercising the re-point path.

Decoration chrome stays out of the document's DOM text: styled wrapper
spans and `contentEditable={false}` elements only, since DOM text
injected by `render` desyncs DOM offsets from model offsets and breaks
caret navigation.

Dialog-close focus hand-off lives in the `Dialog` primitive: a
`focusOnClose` prop, implemented as an unmount sentinel that schedules
the hand-off one frame after react-aria's restore-to-trigger (react-
aria has no restore-target option, react-spectrum#9876; the sentinel
goes when it ships one). The comment dialog hands focus to the editor
on close; the hand-off is fully effective once the editor reports
focus honestly (the `focus` verification fix), since a stale
`editor.focused` otherwise swallows the send.

Two riders: the schema's `comment` annotation is renamed to `footnote`
(all schema variants, toolbar, previews, `annotationNode`) so
annotation-overlap testing keeps working without two things named
"comment", and the ad-hoc "add range decoration" toolbar button and its
machine plumbing are removed, superseded by the comments module.
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