feat: add range decoration layers (registerRangeDecorations, RangeDecorationPlugin) - #3203
feat: add range decoration layers (registerRangeDecorations, RangeDecorationPlugin)#3203christianhg wants to merge 2 commits into
registerRangeDecorations, RangeDecorationPlugin)#3203Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: a97d124 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
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 |
Bundle StatsWarning 2 significant changes. @portabletext/editor🔴 🔴 All scenario measurements (7)🗺️
Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time. |
0b61f4d to
6710e3e
Compare
6710e3e to
120e5f2
Compare
120e5f2 to
a97d124
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ 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.
| ) | ||
| }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit a97d124. Configure here.
| }) | ||
| }) | ||
| } | ||
| }, []) |
There was a problem hiding this comment.
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.
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.
a97d124 to
d6556db
Compare


What
The
rangeDecorationsprop onPortableTextEditableis 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,@betathroughout.A decoration is an id, a range, and a render function:
renderwraps the decorated text; a range crossing marks, blocks, or other decorations renders as several fragments, andisFirst/isLastmark 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:
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: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
rangere-points; an absentidunregisters. A decoration whose content an edit destroys dies with a tombstone and revives on a changedrangeor 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:movedandcontent-changedare orthogonal (typing before a range moves it; a same-length edit inside changes content without moving it; typing inside does both); a dying decoration getslostonly.The
rangea decoration was configured with goes stale the moment anyone types above it; the engine tracks where each decoration actually sits.layer.currentis 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):It is a read of this editor's live state, ahead of whatever the
onhandler has persisted; rendering the decorations themselves never needs it (renderhandles that), and events never need it (they carry their own ranges). If no UI reads positions,currentis simply never touched.In React, one hook owns the lifecycle and one subscribes to positions:
useRangeDecorationLayerisregisterRangeDecorationsmanaged by React: it calls it on mount, flows a changedrangeDecorationsarray throughupdate, and unregisters on unmount. It returns the same layer handle, stable for the component's lifetime, soonmay be inline and the handle is safe to pass to other components.Together they carry a full consumer, a comments layer, in one component:
Editing above a comment moves its highlight and delivers
moved; the store persists the new anchor once per settled change. Editing the commented text deliverscontent-changed; the store refreshes its snippet. Deleting it deliverslost; 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.RangeDecorationPluginis the hook in component form, for provider-level composition next toBehaviorPlugin. The legacyrangeDecorationsprop 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 themutationevent is unspecified. Mixed-origin bursts report each concern's last contributing operation.useRangeDecorationLayerreturns a never-null facade; registration lands in an effect behind it (currentreads empty before). The hook owns a hook-created layer's contents: handleupdates are superseded by the hook's next sync (pinned by a child racing the owner's registration and losing).layer.currentkeeps 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'scommentannotation is renamedfootnoteto 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.