feat: add editor.registerRangeDecorations and RangeDecorationsPlugin - #3193
christianhg wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Bundle Stats✅ No significant changes. All scenario measurements (7)🗺️
Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time. |
🦋 Changeset detectedLatest commit: 04cd25a 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 |
f423f42 to
4945dcb
Compare
4945dcb to
9b3e262
Compare
9b3e262 to
fb73870
Compare
Range decorations had one entry: the `rangeDecorations` prop on
`PortableTextEditable`, so a plugin could not contribute decorations
at all, and each `PortableTextEditable` spawned its own
`rangeDecorationsMachine` writing shared `editorEngine.decoratedRanges`,
so two editables under one provider cleared each other's decorations
on every update and operation.
The machine now lives once per editor: spawned in
`createInternalEditor`, started with the provider, handed to editables
through an internal context. Context holds sources (layers), each
`{sourceKey, kind, rangeDecorations, decoratedRanges, on}`; `decorate`
reads the flattened result with prop sources sorted before registered
sources (prop decorations wrap outermost regardless of JSX effect
order). The legacy prop becomes one source per editable (keyed by
`useId`), keeping the payload-equality guard scoped to the prop path;
an equal resupply skips the `decorate.fn` reassignment, so the
decorate pass stays free for that case. Editable unmount removes its
source instead of leaving stale ranges behind.
Registered configs are their own `@beta` type,
`RegistrableRangeDecoration` ({id, range, component}), so the legacy
`RangeDecoration` stays untouched (`payload`, `onMoved`, and the
`selection` vocabulary stay on the prop path only). Movement flows
through a registration-level `on` handler receiving the
`RangeDecorationEvent` union ({type: 'moved', rangeDecoration,
newRange, origin}), fixed at registration so handler identity never
participates in reconciliation. `registerRangeDecorations` validates
unique `id`s synchronously and returns `{update, unregister}`.
Registered sources reconcile by `id`: an unchanged range keeps the
live (possibly moved) position while adopting a new `component`
reference; a removed `id` unregisters; a decoration killed by an edit
is tombstoned under its live-at-death range, and a subsequent
`update` revives it when the incoming range differs from both
the previous config and the tombstone (a redundant resupply or a
moved-event echo stays dead and emits nothing); omitting the `id`
from an `update` clears its tombstone, so drop-and-re-add is the
explicit same-range revive lever.
`RangeDecorationsPlugin` assumes a stable `rangeDecorations`
reference, mirroring `BehaviorPlugin`: it registers an empty layer,
pushes data through `update`, and wraps `on` in `useEffectEvent`, so
an inline handler never re-registers the layer and events always
reach the latest one.
Fragment-aware rendering rides on the same machinery:
`splitDecorationsByChild` stamps each per-child clip with whether it
holds the decoration's true document-wide edge, `getTextDecorations`
resolves per-leaf `isFirst`/`isLast` in a finalization pass (unique
per decoration), and `RenderLeaf` passes both to the component via
`RangeDecorationRenderProps`. The default `Object.assign` merge path
does not receive the bookkeeping fields.
Pinned by browser suites (including the machine scenarios, which the
vitest config runs in the browser project) plus unit and type tests:
registration rendering and cross-source nesting order (red on
arrival-order flattening), `id` reconciliation including the new-
component case (red under payload equality) and the moved-then-killed
resurrection case (red on the tombstone-only comparison), per-layer
`on` event routing across two registrations, fragment edges across
mark boundaries, blocks, and overlaps, and two editables under one
provider (red on per-editable actors).
fb73870 to
04cd25a
Compare
|
Superseded by #3203, which implements the final v2 surface (layer handle with |
What
Range decorations have exactly one entry point: the
rangeDecorationsprop onPortableTextEditable. A plugin cannot draw a decoration at all, so everything that wants one (SDK presence, SDK comments, anything downstream) has to own or wrap the editable and splice its decorations into the caller's array. This PR gives the editor a multi-source decoration channel:The raw API is
editor.registerRangeDecorations({rangeDecorations, on}), returning{update, unregister}; the plugin component is sugar over it, mirroringBehaviorPluginoverregisterBehavior, including its stability contract forrangeDecorations(onmay be inline: the plugin wraps it inuseEffectEventand always calls the latest handler). Each registration is an independent layer:updatereplaces that layer's set, reconciled byid, and the layer keeps its stacking position across updates. Rendered order is defined: prop decorations first (outermost), then registrations in the order they were made; within a layer, array order. A pinning test mounts the plugin before the editable in JSX to prove order does not depend on effect timing.Registered decorations have their own
@betaconfig type,RegistrableRangeDecoration({id, range, component}), rather than reusing the legacyRangeDecoration: the new surface outlives the prop, so it gets the right names (range, not a second meaning of "selection"; no untypedpayload, data closes over the component), and the legacy@publictype stays untouched apart from a compatible render-props widening. Movement is reported per layer, not per decoration: the registration'sonhandler receivesRangeDecorationEvent({type: 'moved', rangeDecoration, newRange, origin}, a union with room to grow), matching how every known consumer already works (one handler switching on identity) and keeping handler identity out of reconciliation entirely.updatereconciles byid: an unchangedrangekeeps the decoration's live position while adopting a newcomponentreference (the case the prop's equality guard structurally cannot handle, pinned red under those semantics), a removedidunregisters, and a decoration destroyed by an edit stays dead through redundant updates, reviving on a deliberate re-anchor: a changedrange, or omitting theidfor oneupdateand re-adding it (pinned red against both naive variants: resurrection and event re-fire, plus the same-range revive). Decoration components also receiveisFirst/isLast, true for the fragments containing the decoration's start and end, so one-time chrome (a caret, a badge) renders once even when the range fragments across mark boundaries, blocks, or overlapping decorations; each case has a DOM-structure test.Design notes
Moving the store to the provider is what makes multiple sources possible, and it fixes a live bug on the way: each
PortableTextEditableused to spawn its own decorations actor writing the shared engine state, so two editables under oneEditorProvidercleared each other's decorations on every update. One actor per editor ends that (pinned red on the old wiring). Editable unmount now removes its decorations instead of leaving them stale; equal prop resupplies stay free (thedecoratefunction identity is stable across them, pinned at the machine level).Duplicate
ids within one registration throw synchronously in the consumer's call stack; across registrations they are scoped and legal. Consumers holdingRangeDecoration[]migrate with one map ({selection, payload, ...rest}to{...rest, id, range: selection}plus oneonhandler replacing the per-decorationonMovedclosures); no conversion helper is exported, since it could not decide whereidcomes from.Not covered:
prioritybetween layers (registration order is the contract; a priority field is additive later), attribute-only and widget decorations (additive union members later), and coalescing adjacent same-decoration fragments (spiked separately).