Skip to content

feat: emit behavior events from editor.on#2764

Draft
christianhg wants to merge 1 commit into
mainfrom
feat/editor-on-behavior-event
Draft

feat: emit behavior events from editor.on#2764
christianhg wants to merge 1 commit into
mainfrom
feat/editor-on-behavior-event

Conversation

@christianhg

@christianhg christianhg commented Jun 9, 2026

Copy link
Copy Markdown
Member

Today editor.on exposes a fixed set of events (patch, mutation, selection, plus lifecycle). The behavior events that flow through the chain — insert.text, drag.dragover, serialize, select, etc. — have no public subscription surface. A plugin author who wants to derive state from "the user is dragging over this position" can't do it cleanly: either they reach into engine internals, or they request a new RenderProps field.

This PR widens EditorEmittedEvent to include BehaviorEvent. Behavior events are emitted from the editor machine and forwarded through the relay, so consumers subscribe by name:

editor.on('drag.dragover', (event) => {
  // event.position, event.dragOrigin
})

editor.on('insert.text', (event) => {
  // event.text
})

editor.on('*', (event) => {
  // sees behavior events alongside the existing emitted events
})

editor.on(type, listener) is typed-narrowed via xstate's existing channel overload, so call sites get the right event shape without event.type === '...' guards. EventListenerPlugin sees behavior events too.

Pre- vs post-handler tap

The tap fires once per behavior event, before the behavior chain runs. Observers see user intent regardless of whether a high-priority handler decides to swallow the event.

Post-handler would miss intercepted events, which is the wrong default for plugin authors building providers. Example: a dropPosition provider needs to track the latest drag.dragover even when behavior.core.dnd ends up swallowing the dragover to set its own internal state — the provider's view of "where the user is dragging" shouldn't depend on which handler wins.

A consequence of this placement: raise(event) calls inside an action body do not re-enter the handle behavior event action, so they're invisible to editor.on. Only externally-dispatched and sendBack-routed behavior events reach subscribers. That preserves the "observe intent" semantic (subscribers see the events users sent, not decomposition machinery).

Removed: deprecated 'unset' emitted event

The deprecated 'unset' emitted event collided with the BehaviorEvent variant of the same name and is removed. It was @deprecated in favor of 'patch'. No consumers in this repo's downstream apps (sanity-io/sanity, sanity-io/canvas) subscribe to it.

Downstream impact

Studio (sanity-io/sanity): Three EventListenerPlugin consumers — PortableTextInput, StringInputPortableText, CommentInput. All use exhaustive switch (event.type) with empty default:, so unknown event types are silently ignored. No runtime break and no TS break (the widened union still type-checks against an empty default). A real consequence worth flagging: these handlers now fire on every insert.text keystroke and drag.dragover mousemove, each hitting the switch and falling through to default. Sub-millisecond per event but a real hot-path cost on the most-used PT input. Worth measuring post-merge if anything feels sluggish.

Canvas (sanity-io/canvas): Five editor.on subscriptions — all subscribe by specific type ('selection', 'blurred', 'value changed'). Unaffected. No exposure to the 'unset' deprecation removal.

Open questions

  • The operation channel: editor.on('patch', …) carries Sanity patches. A editor.on('operation', …) channel carrying engine operations (insert_node, unset, set, etc.) would let plugins derive state like listIndexMap and blockIndexMap outside the engine without re-deriving from value. Follow-up.
  • Naming: drag.dragover mirrors the DOM event name verbatim. Verbose but explicit. Worth a sanity check before locking the @public surface.

Spec: /specs/shared-state-primitive.md v5.

@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview, Comment Jun 9, 2026 10:18am
portable-text-example-basic Ready Ready Preview, Comment Jun 9, 2026 10:18am
portable-text-playground Ready Ready Preview, Comment Jun 9, 2026 10:18am

Request Review

@changeset-bot

changeset-bot Bot commented Jun 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a8a1787

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

This PR includes changesets to release 12 packages
Name Type
@portabletext/editor Minor
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule 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 Jun 9, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (a43a0fc6)

@portabletext/editor

Metric Value vs main (a43a0fc)
Internal (raw) 777.7 KB +40 B, +0.0%
Internal (gzip) 147.8 KB -11 B, -0.0%
Bundled (raw) 1.38 MB +40 B, +0.0%
Bundled (gzip) 309.7 KB -13 B, -0.0%
Import time 98ms +1ms, +0.6%

@portabletext/editor/behaviors

Metric Value vs main (a43a0fc)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms -0ms, -1.5%

@portabletext/editor/plugins

Metric Value vs main (a43a0fc)
Internal (raw) 2.7 KB -
Internal (gzip) 894 B -
Bundled (raw) 2.5 KB -
Bundled (gzip) 827 B -
Import time 7ms +0ms, +0.7%

@portabletext/editor/selectors

Metric Value vs main (a43a0fc)
Internal (raw) 79.3 KB -
Internal (gzip) 14.5 KB -
Bundled (raw) 74.8 KB -
Bundled (gzip) 13.4 KB -
Import time 8ms -0ms, -0.6%

@portabletext/editor/traversal

Metric Value vs main (a43a0fc)
Internal (raw) 25.4 KB -
Internal (gzip) 5.0 KB -
Bundled (raw) 25.4 KB -
Bundled (gzip) 5.0 KB -
Import time 6ms +0ms, +0.5%

@portabletext/editor/utils

Metric Value vs main (a43a0fc)
Internal (raw) 28.7 KB -
Internal (gzip) 6.0 KB -
Bundled (raw) 26.7 KB -
Bundled (gzip) 5.7 KB -
Import time 6ms -0ms, -1.0%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./utils · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @portabletext/markdown

Compared against main (a43a0fc6)

Metric Value vs main (a43a0fc)
Internal (raw) 53.0 KB -
Internal (gzip) 9.6 KB -
Bundled (raw) 347.6 KB -
Bundled (gzip) 96.0 KB -
Import time 38ms -3ms, -6.5%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

Subscribe to behavior events flowing through the chain by name:

    editor.on('drag.dragover', (event) => { ... })
    editor.on('insert.text', (event) => { ... })

`editor.on('*', listener)` and `EventListenerPlugin` see them
alongside the existing emitted events.

The tap fires once per behavior event, before the chain runs.
Observers see user intent regardless of whether a high-priority
handler decides to swallow it.
@christianhg christianhg force-pushed the feat/editor-on-behavior-event branch from ee668de to a8a1787 Compare June 9, 2026 10:16
christianhg added a commit that referenced this pull request Jun 9, 2026
…tor/plugins

Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:

- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
  Used inside renderBlock or container render callbacks to know where to
  paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
  paint it themselves while keeping the engine's visual identity.
- DropPosition — type.

The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).

The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
christianhg added a commit that referenced this pull request Jun 9, 2026
…tor/plugins

Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:

- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
  Used inside renderBlock or container render callbacks to know where to
  paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
  paint it themselves while keeping the engine's visual identity.
- DropPosition — type.

The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).

The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
christianhg added a commit that referenced this pull request Jun 9, 2026
…tor/plugins

Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:

- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
  Used inside renderBlock or container render callbacks to know where to
  paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
  paint it themselves while keeping the engine's visual identity.
- DropPosition — type.

The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).

The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
christianhg added a commit that referenced this pull request Jun 9, 2026
…tor/plugins

Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:

- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
  Used inside renderBlock or container render callbacks to know where to
  paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
  paint it themselves while keeping the engine's visual identity.
- DropPosition — type.

The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).

The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
christianhg added a commit that referenced this pull request Jun 9, 2026
…tor/plugins

Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:

- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
  Used inside renderBlock or container render callbacks to know where to
  paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
  paint it themselves while keeping the engine's visual identity.
- DropPosition — type.

The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).

The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
christianhg added a commit that referenced this pull request Jun 9, 2026
…tor/plugins

Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:

- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
  Used inside renderBlock or container render callbacks to know where to
  paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
  paint it themselves while keeping the engine's visual identity.
- DropPosition — type.

The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).

The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
christianhg added a commit that referenced this pull request Jun 9, 2026
…tor/plugins

Promotes the engine's drop-position machinery to a small public surface
under @portabletext/editor/plugins:

- useDropPosition(path) — path-scoped, returns 'start' | 'end' | undefined.
  Used inside renderBlock or container render callbacks to know where to
  paint a drop indicator.
- DropIndicator — the default visual; export for consumers who want to
  paint it themselves while keeping the engine's visual identity.
- DropPosition — type.

The engine continues to paint the default indicator via the existing
renderBlock and renderListItem paths, so there's no breaking change for
consumers relying on the implicit affordance. The structural change is
small: state moves from a hook called inside Editable.tsx into a
DropPositionProvider mounted by EditorProvider; Editable.tsx reads the
context via useDropPosition() (no-arg overload).

The plugin is built on top of editor.on('drag.dragover', …) from #2764 —
same publication surface any future drag-aware plugin would use.
Comment on lines 130 to +134
case 'value changed':
listener(event)
break
default:
// Behavior events (`insert`, `insert.text`, `drag.dragover`,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This new default: doing listener(event) matches every named case above it that also does listener(event); break. The whole switch can collapse to a single listener(event) call (drop the wrapping switch (event.type) { ... }). The named cases were a guard back when the relay's union didn't include behavior events; with BehaviorEvent now in EditorEmittedEvent they're noise.

Optional: keep the switch with default: listener(event) only, drop the 12 case branches.

Comment on lines +291 to 301
case 'patches':
// PatchesEvent is an editor-machine internal event; not a behavior
// event and not forwarded to the relay.
break
default:
// Behavior events are emitted from the editor machine's
// `handle behavior event` action before the chain runs. Forward to
// the relay so `editor.on(behaviorEventType, ...)` fires.
config.relayActor.send(event as EditorEmittedEvent)
}
})

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The comment frames this as "PatchesEvent is internal" but the load-bearing reason we filter is that the relay's EditorEmittedEvent doesn't include PatchesEvent — sending it would be a type error at runtime. Worth rewording:

case 'patches':
  // The relay's EditorEmittedEvent doesn't include PatchesEvent — drop it.
  break

Same for the default-case comment two lines below: the examples list (insert, insert.text, drag.dragover, serialize) is filler. The type union is the documentation. Could shrink to one line.

const behaviors = [...context.behaviors.values()].map(
(config) => config.behavior,
)
enqueue.emit(event.behaviorEvent)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

enqueue.emit(event.behaviorEvent) runs before the try/catch around performEvent. That's the right semantic (observe intent regardless of swallowing — also captured in the PR body), but the placement is load-bearing and worth pinning with a one-liner so a future move-into-the-try doesn't quietly flip the semantic:

// Emit before performEvent so subscribers see the event even if the chain throws or swallows it.
enqueue.emit(event.behaviorEvent)

describe('editor.on behavior event', () => {
test('editor.on fires per behavior event by name', async () => {
const onInsertText = vi.fn()
const keyGenerator = createTestKeyGenerator()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Three test-quality nits per /behavior conventions:

  1. expect(observed).toContain('tap:?') on line 81 weakens the assertion. Should be deep equality on the full array:
expect(observed).toEqual(['tap:?'])
  1. Tests 1, 2, and 3 share the same createTestEditor setup with initialValue and the same select event. Worth hoisting a setup() helper or sharing fixtures so the test bodies surface only what differs (the editor.on subscription + the send + the assertion).

  2. vi.waitFor polls until the assertion passes. enqueue.emit and the relay forward synchronously, so once editor.send returns the event has already been emitted — these can assert synchronously without waitFor. Tighter test, faster execution, and surfaces async surprises if they ever appear.

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