Skip to content

feat(plugin-range-decorations): add the range decorations toolkit - #3203

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

feat(plugin-range-decorations): add the range decorations toolkit#3203
christianhg wants to merge 2 commits into
feat/range-decoration-primitivefrom
feat/range-decorations-v2

Conversation

@christianhg

@christianhg christianhg commented Aug 31, 2026

Copy link
Copy Markdown
Member

What

The consumer toolkit over the range decoration primitive (#3226), as its own package so the interpreted semantics (event vocabulary, batching, reads, React bindings) evolve on plugin versioning while core's contract stays mechanical facts. Stacked on #3226; merges after it, and @portabletext/plugin-range-decorations@1.0.0 hand-publishes from this branch once the released editor carries the primitive.

import {useRangeDecorationLayer, useRangeDecorations, defineRangeDecoration} from '@portabletext/plugin-range-decorations'

function CommentsLayer(props: {comments: Array<Comment>; store: CommentStore}) {
  const layer = useRangeDecorationLayer({
    rangeDecorations: useMemo(
      () => props.comments.map((comment) => defineRangeDecoration({...})),
      [props.comments],
    ),
    on: (events) => {
      // one batched call per settled change:
      // 'moved' {previousRange, newRange} | 'content-changed' {range} | 'lost' {previousRange}
      for (const event of events) {
        if (event.origin !== 'local') continue // siblings see the edit as remote; one writer
        ...
      }
    },
  })
  const positions = useRangeDecorations(layer) // live reads; re-renders at the settled boundary
  return <CommentsPanel comments={props.comments} positions={positions} />
}

createRangeDecorationLayer(editor, config) is the same layer without React ({update, unregister, current}); useRangeDecorationLayer is its facade hook (stable never-null handle, registration in an effect behind it, the hook owns a hook-created layer's contents); RangeDecorationPlugin is the hook in component form; RangeDecorationWidget is the safe inline anchor for decoration chrome that is not document text (zero-width contentEditable={false}, consumer chrome only, document children stay siblings).

The toolkit folds the primitive's per-operation mappings into the vocabulary: newRange: null becomes lost, reference-changed ranges become moved, contentTouched becomes content-changed, flushed once per settled change with at most one event per decoration per concern, moved before content-changed, lost discarding the others, net-zero movement suppressed, batch order following the layer's array order, listener errors isolated per layer. Event payloads resolve their rangeDecoration at accumulation time, so a same-tick update cannot swap a newer config into an older event (pinned). current is reference-stable when positions are deep-equal and populates for layers created before the editor is ready. Every one of these contracts is pinned in the package's own suite: the vocabulary tests moved here from core with assertions unchanged.

The playground demo

Both packages dogfooded end to end: a comments module (layer factory + hooks, origin-discriminated write-back where only the editor that made the edit persists, live snippets refreshed by content-changed, undo revives orphaned comments) and a presence module (focus-point carets rendered through RangeDecorationWidget in plugin-sdk-value's shape). The Inspector gains a Comments tab and a Decorations tab listing every layer's live positions; the Decorations tab is the only subscriber to presence positions, so the presence owner never re-renders on movement, which is the two-hook split demonstrated. Dialog-close focus hand-off lives in the playground Dialog primitive (focusOnClose, scheduled after react-aria's restore-to-trigger; react-aria lacks a restore-target seam, tracked upstream). The schema's comment annotation is renamed footnote to free the word.

No changeset: new packages hand-publish v1.0.0 before their PR merges.


Note

Medium Risk
New public plugin API and non-trivial event batching/reconciliation logic; playground-only state machine changes, but the package will be consumed for collaboration features tied to editor selection and remote edits.

Overview
Introduces @portabletext/plugin-range-decorations, a consumer layer on top of editor.registerRangeDecorations that composes independent decoration stacks (comments, presence, highlights) with batched moved / content-changed / lost events, stable current positions, and React APIs (useRangeDecorationLayer, useRangeDecorations, RangeDecorationPlugin, RangeDecorationWidget).

The playground drops the old PortableTextEditable rangeDecorations prop and demo toolbar button in favor of shared comments and presence plugins wired through the playground machine (comment list, selection sync, layer registration for the inspector). Settings add Collaboration toggles; the inspector gains Comments and Decorations tabs. Schema comment annotations are renamed footnote so “comment” refers to range-based collaboration, not Portable Text marks.

Reviewed by Cursor Bugbot for commit 40f570a. Bugbot is set up for automated code reviews on this repo. Configure here.

@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 4, 2026 9:49am UTC
portable-text-example-basic Ready Ready Preview Sep 4, 2026 9:49am UTC
portable-text-playground Ready Ready Preview Sep 4, 2026 9:49am UTC

Request Review

@changeset-bot

changeset-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 40f570a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Bundle Stats

✅ No significant changes.

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.10 MB / 256.5 KB None 62 ms +1 ms, +1.0%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +3.7%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 6 ms -0 ms, -2.3%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.7 KB / 21.3 KB None 7 ms -0 ms, -2.2%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.7 KB / 10.8 KB None 5 ms -0 ms, -0.9%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.1 KB / 8.7 KB None 5 ms -0 ms, -1.7%
⚪ @portabletext/markdown / @portabletext/markdown export 272.2 KB / 79.6 KB None 34 ms +1 ms, +1.5%

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

Comment thread packages/editor/tests/probe-focus-event.test.tsx Outdated
Comment thread apps/playground/src/primitives/dialog.tsx
@christianhg
christianhg force-pushed the feat/range-decorations-v2 branch from cd1f44c to 3eed8f7 Compare September 3, 2026 14:47
@christianhg
christianhg force-pushed the feat/range-decorations-v2 branch from 3eed8f7 to 5299052 Compare September 3, 2026 14:59
@christianhg
christianhg force-pushed the feat/range-decorations-v2 branch from 5299052 to 709847a Compare September 4, 2026 06:30
@christianhg
christianhg force-pushed the feat/range-decorations-v2 branch from 709847a to 32f6712 Compare September 4, 2026 06:41
@christianhg
christianhg force-pushed the feat/range-decorations-v2 branch from 32f6712 to 06025d5 Compare September 4, 2026 06:48
@christianhg
christianhg changed the base branch from main to feat/range-decoration-primitive September 4, 2026 07:06
@christianhg christianhg changed the title feat: add range decoration layers (registerRangeDecorations, RangeDecorationPlugin) feat(plugin-range-decorations): add the range decorations toolkit Sep 4, 2026
The consumer vocabulary over the core primitive, in its own package so
the interpreted semantics evolve on plugin versioning while core
freezes only mechanical facts. `createRangeDecorationLayer(editor,
{rangeDecorations, on})` wraps `editor.registerRangeDecorations` and
returns a layer `{update, unregister, current}`: a per-layer
accumulator folds the primitive's per-operation mappings
(`newRange: null` becomes `lost`, reference-changed ranges become
`moved`, `contentTouched` becomes `content-changed`) and flushes once
per settled change on a trailing microtask, at most one event per
decoration per concern, `moved` before `content-changed`, `lost`
discarding the others, batch order following the layer's array order,
net-zero movement suppressed, listener errors isolated per layer.
Event payloads resolve their `rangeDecoration` at accumulation time,
so a same-tick `update` cannot swap a newer config into an older
event.

`current` is the read surface: live positions recomputed at the flush
boundary and after `update`, reference-stable when positions are
deep-equal, populated for layers created before the editor is ready
by re-reading on the editor's `ready` event.
`useRangeDecorationLayer` is the React binding (a stable facade
handle; registration in an effect behind it; the hook owns a
hook-created layer's contents), `useRangeDecorations` subscribes a
component to `current`, `RangeDecorationPlugin` is the hook in
component form, `defineRangeDecoration` is the typed constructor, and
`RangeDecorationWidget` is the safe inline anchor for decoration
chrome that is not document text.

The package consumes only public editor surface: mappings and
`getDecorations()` reads replace the actor-snapshot access the
in-core draft used, `isDeepEqual` and the duplicate-id assert are
duplicated with keep-in-sync pointers, and the vocabulary test suites
move here with assertions unchanged. No changeset: new packages
hand-publish v1.0.0 before the PR merges.
…on layers

Comments: a toolbar button captures the selection and a text, stored in
the playground machine as shared data. Each editor owns one layer via
`useRangeDecorationLayer`; `moved`, `content-changed`, 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. `content-changed`
re-slices the covered text at the event's edit-adjusted range and
refreshes the comment's live snippet; the Inspector's Comments tab
shows the snippet with an "edited" badge when it no longer matches the
creation snapshot, plus status, compact range, and remove. A muted
per-editor line shows live positions via `useRangeDecorations(layer)`.
Orphaned comments revive when their content returns: undoing the
deleting edit restores the exact snapshot text at the stored range,
and a value-change effect reactivates the comment (leave-then-return
revive), resetting the snippet.

Presence: each editor reports its selection to the playground machine;
sibling editors own a layer via `useRangeDecorationLayer` with one
collapsed decoration at each remote focus point, following
`plugin-sdk-value`'s presence shape (caret at focus, hash-stable color
per editor id, no blues so the tint cannot be confused with the native
selection). The caret renders through `RangeDecorationWidget` with the
document `children` as a sibling: nesting them inside the caret's
`contentEditable={false}` element put the block's leading zero-width
leaf into non-editable DOM, parked the local caret there, and swallowed
Enter at the block start (no `beforeinput` fires from a non-editable
position). Selection churn flows through the hook into `update()`,
exercising the re-point path.

The Inspector's Decorations tab lists every layer's live state: both
modules register their layer handles in the playground machine, and
one `useRangeDecorations` subscriber per layer renders ids and
edit-adjusted ranges ticking live. The tab is the only subscriber to
the presence layer's positions; the presence owner never re-renders on
movement, which is the two-hook split doing its job.

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 sentinel cancels its pending frames on
remount: StrictMode rehearses mount-unmount-mount when the dialog
opens, and an uncancelled rehearsal fires the hand-off into the
just-opened dialog, stranding the document selection in the editable
while the autofocused input holds focus (ring, no caret, dead
typing). The comment dialog hands focus to the editor on close.

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.

@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 1 potential issue.

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 40f570a. Configure here.

editorId: props.editorRef.id,
selection,
})
}, [props.playgroundRef, props.editorRef.id, selection])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale presence caret after unmount

Medium Severity

The presence effect reports selection but never clears it on unmount. Toggling Presence off stops further updates and unregisters this editor’s layer, while sibling editors keep rendering a frozen caret from the last stored selection.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 40f570a. Configure here.

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