diff --git a/apps/docs/docs.json b/apps/docs/docs.json
index 888e0d1569..35d6fc57e1 100644
--- a/apps/docs/docs.json
+++ b/apps/docs/docs.json
@@ -102,8 +102,9 @@
"editor/custom-ui/toolbar-and-commands",
"editor/custom-ui/custom-commands",
"editor/custom-ui/comments",
- "editor/custom-ui/selection-and-viewport",
"editor/custom-ui/track-changes",
+ "editor/custom-ui/context-menu",
+ "editor/custom-ui/selection-and-viewport",
"editor/custom-ui/document-control",
"editor/custom-ui/navigation",
"editor/custom-ui/api-reference"
diff --git a/apps/docs/editor/built-in-ui/context-menu.mdx b/apps/docs/editor/built-in-ui/context-menu.mdx
index a7df9491ed..ff02870967 100644
--- a/apps/docs/editor/built-in-ui/context-menu.mdx
+++ b/apps/docs/editor/built-in-ui/context-menu.mdx
@@ -1,15 +1,19 @@
---
-title: Context Menu
+title: Context menu
keywords: "context menu, right-click menu, custom commands"
---
-A contextual command menu triggered by right-clicking. Shows relevant actions based on cursor position and document state.
+The built-in right-click menu. Shows relevant actions based on cursor position and document state. Configure it via `modules.contextMenu`, or disable it and render your own.
+
+
+Building a fully custom right-click menu? See [Custom right-click menu](/editor/custom-ui/context-menu) for the controller-driven flow: `ui.viewport.contextAt({ x, y })` returns one bundle, registrations contribute items via `register({ contextMenu: { when } })`, and `item.invoke()` dispatches with the bundle bound. The page below covers the built-in module only.
+
## Quick start
-The context menu is **enabled by default**. Right-click anywhere in the document to open it.
+The built-in menu is **enabled by default**. Right-click anywhere in the document to open it.
-To disable it:
+To turn off the built-in menu, set `disableContextMenu: true`. This switches off SuperDoc's own menu and lets the browser's native right-click menu (Copy / Paste / Inspect) appear, or lets your own custom `contextmenu` listener take over:
```javascript
new SuperDoc({
@@ -34,7 +38,7 @@ new SuperDoc({
```
- Top-level option to disable the context menu entirely
+ Top-level option that disables the built-in menu and lets the browser's native right-click menu (or your own custom `contextmenu` listener) appear instead. Pair with the [custom right-click menu](/editor/custom-ui/context-menu) flow when you're rendering your own.
diff --git a/apps/docs/editor/custom-ui/api-reference.mdx b/apps/docs/editor/custom-ui/api-reference.mdx
index b2a72028ef..b77ad697c4 100644
--- a/apps/docs/editor/custom-ui/api-reference.mdx
+++ b/apps/docs/editor/custom-ui/api-reference.mdx
@@ -115,16 +115,32 @@ Built-in dispatch and custom-command registration.
```ts
ui.commands.get('bold')?.execute(); // dynamic dispatch
+ui.commands.has('company.aiRewrite'); // is this id registered?
+ui.commands.require('company.insertClause'); // get or throw
ui.commands.bold.execute(); // typed per-built-in handle
ui.commands.bold.observe(({ active, disabled }) => { ... });
const reg = ui.commands.register({ // custom command
id: 'company.insertClause',
- execute: ({ payload, superdoc, editor }) => true,
+ shortcut: 'Mod-Shift-C', // optional keyboard binding
+ execute: ({ payload, superdoc, editor, context }) => true,
getState: ({ state }) => ({ disabled: state.selection.empty }),
+ contextMenu: { // optional right-click contribution
+ label: 'Insert clause here',
+ group: 'review',
+ when: ({ entities, position, insideSelection }) =>
+ entities.length === 0 && position !== null && insideSelection !== true,
+ },
});
reg.invalidate();
reg.unregister();
+
+// Right-click menu items returned from getContextMenuItems carry invoke()
+// closures that fire execute({ context }) with the bundle bound.
+const items = ui.commands.getContextMenuItems(
+ ui.viewport.contextAt({ x: event.clientX, y: event.clientY }),
+);
+items[0]?.invoke?.();
```
### `ui.comments`
@@ -173,12 +189,18 @@ await ui.document.replaceFile(file);
### `ui.selection`
-Live slice and capture.
+Live slice, capture, restore, painted geometry.
```ts
ui.selection.getSnapshot();
ui.selection.subscribe(({ snapshot }) => {});
-const captured = ui.selection.capture(); // frozen, holds across focus changes
+const captured = ui.selection.capture(); // frozen, holds across focus changes
+const restore = ui.selection.restore(captured); // { success, reason? }
+
+ui.selection.getRects(); // ViewportRect[]
+ui.selection.getRects(captured); // rects of a captured selection
+ui.selection.getAnchorRect({ placement: 'start' }); // single rect for popovers
+ui.selection.getAnchorRect({ placement: 'union' }, captured);
```
### `ui.viewport`
@@ -188,6 +210,11 @@ Geometry. Browser-only.
```ts
ui.viewport.getRect({ target: { kind: 'entity', entityType: 'comment', entityId } });
await ui.viewport.scrollIntoView({ target, block: 'center', behavior: 'smooth' });
+
+ui.viewport.getHost(); // painted host element | null
+ui.viewport.entityAt({ x, y }); // ViewportEntityHit[]
+ui.viewport.positionAt({ x, y }); // ViewportPositionHit | null
+ui.viewport.contextAt({ x, y }); // ViewportContext (always returns)
```
### `ui.toolbar`
@@ -234,7 +261,15 @@ Imported from `superdoc/ui`.
| `UIToolbarCommandState` | Per-command state shape. |
| `CustomCommandRegistration` | Input to `ui.commands.register`. |
| `CustomCommandRegistrationResult` | Return from `ui.commands.register`. |
+| `ContextMenuContribution` | The `contextMenu` field on a registration. |
+| `ContextMenuWhenInput` | Argument to `contextMenu.when({ entities, selection, point?, position?, insideSelection? })`. |
+| `ContextMenuItem` | Item returned from `ui.commands.getContextMenuItems(input)`. Carries `invoke()` when produced from a `ViewportContext` bundle. |
+| `SelectionAnchorRectOptions` | `{ placement: 'start' \| 'end' \| 'union' }`. |
+| `SelectionRestoreResult` | `{ success: true } \| { success: false, reason }`. |
| `ScrollIntoViewInput` | Input to `ui.viewport.scrollIntoView`. |
-| `ViewportRect` | Plain value rectangle returned by `ui.viewport.getRect`. |
+| `ViewportRect` | Plain value rectangle. |
+| `ViewportEntityHit` | `{ type: 'comment' \| 'trackedChange', id }`. |
+| `ViewportPositionHit` | `{ point: SelectionPoint, target: SelectionTarget }`. |
+| `ViewportContext` | Bundle returned from `ui.viewport.contextAt({ x, y })`. |
For the source of truth, the types ship with the `superdoc` package and are exported alongside the runtime values.
diff --git a/apps/docs/editor/custom-ui/comments.mdx b/apps/docs/editor/custom-ui/comments.mdx
index f395a0a7e6..54ad352cc1 100644
--- a/apps/docs/editor/custom-ui/comments.mdx
+++ b/apps/docs/editor/custom-ui/comments.mdx
@@ -187,6 +187,10 @@ function ReplyComposer({ parent }: { parent: { id: string } }) {
The next snapshot from `useSuperDocComments()` includes the reply, threaded under the parent via `parentCommentId`. The reference demo's `ActivitySidebar` ships this pattern with focus management and Ctrl/Cmd+Enter to post.
+## Theming
+
+Comment cards, body text, timestamps, and active states are themable via `--sd-ui-comments-*` CSS variables. See [Theming overview](/editor/theming/overview) and [Custom themes](/editor/theming/custom-themes) for the full token list.
+
## Trade-offs
- `useSuperDocComments` returns a memoized snapshot. Re-renders happen only when items, total, or activeIds change.
diff --git a/apps/docs/editor/custom-ui/context-menu.mdx b/apps/docs/editor/custom-ui/context-menu.mdx
new file mode 100644
index 0000000000..3d609f4c1d
--- /dev/null
+++ b/apps/docs/editor/custom-ui/context-menu.mdx
@@ -0,0 +1,259 @@
+---
+title: 'Custom right-click menu'
+sidebarTitle: 'Context menu'
+description: 'Suppress the built-in menu, render your own with the controller bundle, dispatch with the click target bound to your handler.'
+---
+
+## Quick start
+
+Three pieces: a registration that contributes an item, a `contextmenu` listener that opens the menu, and a `` to keep the built-in out of the way.
+
+```tsx
+import { useEffect, useState } from 'react';
+import type { ContextMenuItem } from 'superdoc/ui';
+import { useSuperDocUI } from 'superdoc/ui/react';
+
+export function ContextMenu() {
+ const ui = useSuperDocUI();
+ const [open, setOpen] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
+
+ useEffect(() => {
+ if (!ui) return;
+ const onContextMenu = (event: MouseEvent) => {
+ const host = ui.viewport.getHost();
+ if (!host || !(event.target instanceof Node) || !host.contains(event.target)) return;
+
+ const context = ui.viewport.contextAt({ x: event.clientX, y: event.clientY });
+ const items = ui.commands.getContextMenuItems(context);
+ if (items.length === 0) return; // browser native menu falls through
+
+ event.preventDefault();
+ setOpen({ x: event.clientX, y: event.clientY, items });
+ };
+ document.addEventListener('contextmenu', onContextMenu);
+ return () => document.removeEventListener('contextmenu', onContextMenu);
+ }, [ui]);
+
+ if (!open) return null;
+ return (
+
+ {open.items.map((item) => (
+
+ ))}
+
+ );
+}
+```
+
+Suppress the built-in menu so your own takes over:
+
+```tsx
+
+```
+
+`disableContextMenu` switches off SuperDoc's own menu UI and lets the browser's native `contextmenu` event proceed. When `getContextMenuItems(context)` returns nothing for a click, the listener returns without `preventDefault` and the browser native menu falls through (Copy / Paste / Inspect). No dead right-click.
+
+## The bundle
+
+`ui.viewport.contextAt({ x, y })` always returns an object, never `null`. Empty defaults make destructuring safe.
+
+| Field | Type | Meaning |
+|---|---|---|
+| `point` | `{ x, y }` | Echoes the input. Useful for anchoring floating UI. |
+| `entities` | `ViewportEntityHit[]` | Tracked changes / comments under the click, innermost first. Empty when none. |
+| `position` | `ViewportPositionHit \| null` | Resolved caret position at the click. `null` when the click is outside the painted host. |
+| `selection` | `SelectionSlice` | Mirrors the live `state.selection` slice. |
+| `insideSelection` | `boolean` | True when the click lands inside the rects the live selection currently paints. |
+
+`position.target` is a collapsed `SelectionTarget` at the click, story-aware when the click landed inside a header / footer / footnote. Pass it straight to `editor.doc.insert` for "Paste here" / "Insert clause here" actions.
+
+```ts
+const context = ui.viewport.contextAt({ x: 100, y: 200 });
+// context.point { x: 100, y: 200 }
+// context.entities [{ type: 'trackedChange', id: 'tc-7' }, ...]
+// context.position { point: { kind: 'text', blockId, offset, story? }, target }
+// context.selection { empty, target, selectionTarget, activeMarks, ... }
+// context.insideSelection true | false
+```
+
+## Contribute an item
+
+Add a `contextMenu` field to your registration. The `when` predicate filters on the same bundle the handler will receive.
+
+```tsx
+ui.commands.register({
+ id: 'demo.acceptSuggestion',
+ execute: ({ context }) => {
+ const id = context?.entities.find((e) => e.type === 'trackedChange')?.id;
+ if (!id) return false;
+ ui.trackChanges.accept(id);
+ return true;
+ },
+ contextMenu: {
+ label: 'Accept suggestion',
+ group: 'review',
+ order: 0,
+ when: ({ entities }) => entities.some((e) => e.type === 'trackedChange'),
+ },
+});
+```
+
+Each contribution is grouped (built-ins are `format`, `clipboard`, `review`, `comment`, `link`, then customs in registration order). Items inside a group sort by `order`. Predicates that throw are caught and the item is hidden for that menu.
+
+### Predicate examples
+
+The bundle's optional fields make scope rules direct.
+
+```ts
+// Entity-scoped: accept / reject / resolve
+when: ({ entities }) => entities.some((e) => e.type === 'trackedChange'),
+
+// Selection-scoped: copy / comment, only when click is inside the selection
+when: ({ selection, insideSelection }) =>
+ !selection.empty && insideSelection === true,
+
+// Point-scoped: insert at the click, only on plain caret-only text
+when: ({ entities, position, insideSelection }) =>
+ entities.length === 0 && position !== null && insideSelection !== true,
+```
+
+The predicate sees `entities`, `selection`, `point`, `position`, `insideSelection`. Old predicates that only destructure `{ entities, selection }` keep working.
+
+## item.invoke()
+
+Items returned from `getContextMenuItems(context)` carry an `invoke()` closure that fires the registered `execute` with the bundle bound to `context`. Your menu component dispatches without re-threading the click target through a payload.
+
+```tsx
+
+```
+
+Inside `execute`, the same bundle the predicate filtered on is available as `context`:
+
+```ts
+execute: ({ payload, superdoc, editor, context }) => {
+ // context.position?.target is the collapsed SelectionTarget at the click
+ // context.entities is the entity list under the click
+ // context.selection is the live selection at the time the menu opened
+ // context.insideSelection is the hit-test result
+ return true;
+}
+```
+
+`context` is `undefined` when the command is dispatched directly (`ui.commands.get(id)?.execute(payload)`, `ui.commands.require(id).execute(...)`, or `ui.toolbar.execute(id, payload)` for built-ins). Handlers that only depend on `payload` keep working unchanged.
+
+## Falling through to the native menu
+
+When `getContextMenuItems(context)` returns no items, your listener returns early without calling `event.preventDefault()`. The browser shows its native menu (Copy / Paste / Inspect) instead of producing a dead right-click. This relies on `disableContextMenu: true` on the editor: with the built-in menu suppressed, no other listener swallows the event.
+
+If you'd rather suppress the native menu in the empty case too, call `event.preventDefault()` regardless of items length and render nothing.
+
+## Worked example
+
+The reference workspace at [`demos/custom-ui`](https://github.com/superdoc-dev/superdoc/tree/main/demos/custom-ui) wires the full pattern end-to-end. The four registrations below mirror the demo's `ContextMenuRegistrations.tsx`. They cover the three subjects the menu can act on: an entity, the selection, or the click point.
+
+
+
+```tsx Usage
+const accept = ui.commands.register({
+ id: 'demo.acceptSuggestion',
+ execute: ({ context }) => {
+ const id = context?.entities.find((e) => e.type === 'trackedChange')?.id;
+ if (!id) return false;
+ ui.trackChanges.accept(id);
+ return true;
+ },
+ contextMenu: {
+ label: 'Accept suggestion',
+ group: 'review',
+ when: ({ entities }) => entities.some((e) => e.type === 'trackedChange'),
+ },
+});
+
+const copy = ui.commands.register({
+ id: 'demo.copy',
+ execute: ({ context }) => {
+ const text = context?.selection.quotedText ?? '';
+ if (text) navigator.clipboard.writeText(text).catch(() => {});
+ return true;
+ },
+ contextMenu: {
+ label: 'Copy',
+ group: 'clipboard',
+ when: ({ selection, insideSelection }) =>
+ !selection.empty && insideSelection === true,
+ },
+});
+
+const insertHere = ui.commands.register({
+ id: 'demo.insertClauseHere',
+ execute: ({ context, editor }) => {
+ const target = context?.position?.target;
+ if (!target || !editor?.doc?.insert) return false;
+ const receipt = editor.doc.insert({
+ value: 'Standard clause text.',
+ type: 'text',
+ target,
+ });
+ return receipt?.success === true;
+ },
+ contextMenu: {
+ label: 'Insert clause here',
+ group: 'review',
+ order: 10,
+ when: ({ entities, position, insideSelection }) =>
+ entities.length === 0 && position !== null && insideSelection !== true,
+ },
+});
+```
+
+```tsx Full Example
+import { useEffect } from 'react';
+import { SuperDocUIProvider, useSuperDocUI } from 'superdoc/ui/react';
+
+function Registrations() {
+ const ui = useSuperDocUI();
+ useEffect(() => {
+ if (!ui) return;
+ const reg = ui.commands.register({
+ id: 'demo.insertClauseHere',
+ execute: ({ context, editor }) => {
+ const target = context?.position?.target;
+ if (!target || !editor?.doc?.insert) return false;
+ const receipt = editor.doc.insert({
+ value: 'Standard clause text.',
+ type: 'text',
+ target,
+ });
+ return receipt?.success === true;
+ },
+ contextMenu: {
+ label: 'Insert clause here',
+ group: 'review',
+ when: ({ entities, position, insideSelection }) =>
+ entities.length === 0 && position !== null && insideSelection !== true,
+ },
+ });
+ return () => reg.unregister();
+ }, [ui]);
+ return null;
+}
+
+export function App() {
+ return (
+
+
+
+ );
+}
+```
+
+
+
+## Trade-offs
+
+- The bundle is computed once when the menu opens. If your registration's `execute` runs much later (popover, multi-step picker), `context.selection` reflects the open-time selection, not the current one. Re-read `ui.selection.getSnapshot()` when you need fresh selection.
+- `item.invoke?.()` is `undefined` for items returned from the legacy `getContextMenuItems({ entities })` shape. Always call as `item.invoke?.()`. The full bundle path always populates it.
+- Scope your `contextmenu` listener to `ui.viewport.getHost()`. An empty bundle alone isn't a scope signal: it can mean "outside the editor" or "inside plain text with no selection and no entities".
+- `position` is `null` when the click is outside the painted host. Predicates that act on the click point should check `position !== null` first.
diff --git a/apps/docs/editor/custom-ui/custom-commands.mdx b/apps/docs/editor/custom-ui/custom-commands.mdx
index 04b64c20d4..105dc36e92 100644
--- a/apps/docs/editor/custom-ui/custom-commands.mdx
+++ b/apps/docs/editor/custom-ui/custom-commands.mdx
@@ -87,7 +87,7 @@ function onPermissionsChange() {
## execute
-`execute` receives `{ payload, superdoc }`. The cleanest custom commands are additive: they call your services, open your modals, fire telemetry, and don't mutate the document. The runtime cares about the return value (`boolean` or `Promise`); the engine doesn't see the work.
+`execute` receives `{ payload, superdoc, editor, context }`. The cleanest custom commands are additive: they call your services, open your modals, fire telemetry, and don't mutate the document. The runtime cares about the return value (`boolean` or `Promise`); the engine doesn't see the work.
```ts
ui.commands.register<{ clauseId: string }>({
@@ -100,6 +100,15 @@ ui.commands.register<{ clauseId: string }>({
});
```
+The arguments:
+
+| Field | Meaning |
+|---|---|
+| `payload` | Whatever the caller passed to `commands.get(id).execute(payload)`. Typed via `register(...)`. |
+| `superdoc` | The host SuperDoc instance. Useful when you need `superdoc.config` or other host-only fields. |
+| `editor` | The currently routed editor. Tracks header / footer / footnote focus, so `editor.doc.*` calls land on the right surface. `null` until ready. |
+| `context` | The `ViewportContext` bundle when the command was invoked from a right-click menu via `ContextMenuItem.invoke()`. `undefined` for direct dispatch. See [Custom right-click menu](/editor/custom-ui/context-menu). |
+
Async work is fine. The runtime awaits internally.
```ts
@@ -144,6 +153,85 @@ Two things make the snippet safe to copy:
The reference demo's `InsertClauseButton` ships this exact pattern.
+## Keyboard shortcut
+
+Add `shortcut: 'Mod-Shift-K'` to bind a key combo. The controller installs one bubble-phase listener scoped to the painted host, matches the combo, and dispatches `execute` through the same path the toolbar button uses. No per-command keymap wiring.
+
+```ts
+ui.commands.register<{ clauseId?: string }>({
+ id: 'company.insertClause',
+ shortcut: 'Mod-Shift-C',
+ execute: ({ payload }) => {
+ if (!payload) {
+ openClausePicker();
+ return true;
+ }
+ insertClause(payload.clauseId);
+ return true;
+ },
+});
+```
+
+Format follows ProseMirror keymap syntax: `Mod` (Ctrl on Windows / Linux, Cmd on macOS), `Shift`, `Alt`, `Ctrl`, then a key (`A` through `Z`, `0` through `9`, `Enter`, `Space`, etc.). Examples: `'Mod-K'`, `'Mod-Shift-C'`, `'Alt-Enter'`.
+
+The keyboard path doesn't consult `getState`. If your toolbar button is disabled, the shortcut still fires unless you mirror the gate inside `execute`:
+
+```ts
+execute: ({ payload, superdoc }) => {
+ const live = ui.selection.getSnapshot();
+ const disabled = superdoc.config?.documentMode === 'viewing' || live.target === null;
+ if (disabled) return false;
+ // ... do the work
+ return true;
+},
+```
+
+Two custom commands declaring the same shortcut warn at registration time; the most recent registration wins.
+
+## Contribute to the right-click menu
+
+Add a `contextMenu` field to surface the command in your custom right-click menu. The `when` predicate filters on the bundle from `ui.viewport.contextAt({ x, y })`.
+
+```ts
+ui.commands.register({
+ id: 'demo.acceptSuggestion',
+ execute: ({ context }) => {
+ const id = context?.entities.find((e) => e.type === 'trackedChange')?.id;
+ if (!id) return false;
+ ui.trackChanges.accept(id);
+ return true;
+ },
+ contextMenu: {
+ label: 'Accept suggestion',
+ group: 'review',
+ order: 0,
+ when: ({ entities }) => entities.some((e) => e.type === 'trackedChange'),
+ },
+});
+```
+
+| Field | Type | Meaning |
+|---|---|---|
+| `label` | `string` | Item text. Required. |
+| `group` | `string` | Sort key. Built-in groups: `format`, `clipboard`, `review`, `comment`, `link`. Customs land after, in registration order. Default `'custom'`. |
+| `order` | `number` | Sort order within a group. Default `0`. |
+| `when` | `(input) => boolean` | Filter on `{ entities, selection, point?, position?, insideSelection? }`. Returns `true` to show the item. |
+
+Items returned from `ui.commands.getContextMenuItems(context)` carry an `invoke()` closure that fires `execute({ context })` with the bundle bound. Your menu component dispatches with one call, no payload threading. See [Custom right-click menu](/editor/custom-ui/context-menu) for the full pattern.
+
+## Look up commands by id
+
+`ui.commands.get(id)` returns a typed handle. `ui.commands.has(id)` reports whether the id is registered. `ui.commands.require(id)` returns the handle or throws when missing, useful when the registration is set up earlier in your component tree and the lookup site can rely on it.
+
+```ts
+if (ui.commands.has('company.aiRewrite')) {
+ ui.commands.get('company.aiRewrite')?.execute({ tone: 'concise' });
+}
+
+const insert = ui.commands.require('company.insertClause');
+insert.execute({ clauseId: 'nda' });
+```
+
## Override a built-in
Pass `override: true` to deliberately replace a built-in. Without it, registrations colliding with a built-in id are refused with a console warning.
diff --git a/apps/docs/editor/custom-ui/navigation.mdx b/apps/docs/editor/custom-ui/navigation.mdx
index d685892d94..b74b42e149 100644
--- a/apps/docs/editor/custom-ui/navigation.mdx
+++ b/apps/docs/editor/custom-ui/navigation.mdx
@@ -1,15 +1,37 @@
---
-title: Stable navigation after edits
-sidebarTitle: Stable navigation
-description: "Navigate to document elements reliably: during edits and across sessions."
+title: Navigation
+sidebarTitle: Navigation
+description: "Scroll comments and tracked changes into view. Navigate by element id. Track nodes through edits."
---
-SuperDoc has two navigation approaches depending on your use case:
+The controller exposes navigation helpers for the everyday cases (scroll a comment or a tracked change into view, step through review). For raw element-id navigation and cross-session tracking, the host SuperDoc and the editor expose `scrollToElement` and `PositionTracker`.
-| Approach | Use case | Stability |
-|----------|----------|-----------|
-| `scrollToElement(id)` | Navigate to any element by its ID | Cross-session (for DOCX-imported content) |
-| `PositionTracker` | Track nodes that move during edits | Within a single session |
+## Comments and tracked changes
+
+`ui.comments.scrollTo(id)` and `ui.trackChanges.scrollTo(id)` land on a specific entity. `ui.trackChanges.next()` and `previous()` advance `activeId` and return the new id, but they don't move the viewport on their own. Pair them with `scrollTo` to navigate.
+
+```ts
+ui.comments.scrollTo('c-123');
+
+const nextId = ui.trackChanges.next();
+if (nextId) ui.trackChanges.scrollTo(nextId);
+```
+
+`ui.trackChanges.scrollTo` is story-aware: a tracked change in a header, footer, or footnote activates the right surface before alignment. `ui.comments.scrollTo` is body-scoped today; non-body comment scroll lands in a follow-up.
+
+## Generic viewport scroll
+
+`ui.viewport.scrollIntoView` accepts the same `target` shapes the doc-API uses (entity address, text address, text target). Pair it with `block` and `behavior`:
+
+```ts
+await ui.viewport.scrollIntoView({
+ target: { kind: 'entity', entityType: 'comment', entityId: 'c-123' },
+ block: 'center',
+ behavior: 'smooth',
+});
+```
+
+See [Selection and viewport](/editor/custom-ui/selection-and-viewport#scroll-an-entity-into-view) for the full target list and the `not-mounted` / `not-ready` reasons.
## Navigate by element ID
@@ -61,7 +83,8 @@ function goToLink(link) {
## Best practices
-- Use `scrollToElement` when you have an element ID from `doc.extract()` or the Document API.
-- Use `PositionTracker` when you need to follow nodes that move during edits.
+- Use `ui.comments.scrollTo` / `ui.trackChanges.scrollTo` for the everyday cases. They're the shortest path and they activate non-body surfaces correctly.
+- Use `superdoc.scrollToElement` when you have a stable element id from `doc.extract()` or the Document API (RAG citations, cross-references, search hits).
+- Use `editor.positionTracker` when you need to follow a node that moves during edits within the current session.
- For cross-session use, store `nodeId` values (not `sdBlockId`: those regenerate on each open).
-- Handle missing targets gracefully: both APIs return `false` if the element no longer exists.
+- Handle missing targets gracefully: every API above returns `false` if the element no longer exists.
diff --git a/apps/docs/editor/custom-ui/overview.mdx b/apps/docs/editor/custom-ui/overview.mdx
index 20dc0a493b..a67478ec74 100644
--- a/apps/docs/editor/custom-ui/overview.mdx
+++ b/apps/docs/editor/custom-ui/overview.mdx
@@ -59,8 +59,11 @@ One hook. One button. Subscribes only to that command's state. Re-renders only w
Custom tracked-change review panel. Accept, reject, navigate.
+
+ Custom context menu wired through `contextAt` and `getContextMenuItems(context)`.
+
- Read the selection. Scroll an entity into view. Capture selections that survive focus changes.
+ Read the selection. Scroll an entity into view. Capture and restore selections that survive focus changes. Resolve a click point to a caret.
Switch between editing and suggesting. Export to DOCX. Replace the open file.
@@ -69,4 +72,18 @@ One hook. One button. Subscribes only to that command's state. Re-renders only w
The [API reference](/editor/custom-ui/api-reference) lists every hook and handle in one place.
-Want to see it composed end-to-end? The [reference workspace on GitHub](https://github.com/superdoc-dev/superdoc/tree/main/demos/custom-ui) ships a full app: toolbar, custom command, comments sidebar with reply threads, tracked-change review, selection capture, document export: built on the surfaces above.
+## Three surfaces, three subjects
+
+Custom-UI apps tend to land on the same shape: a toolbar, a floating bubble menu, a right-click menu. Each one answers a different "what's the subject of this action?" question. Keep them strictly separated.
+
+| Surface | Subject | Belongs here |
+|---|---|---|
+| **Toolbar** | The **document** | Mode toggle, Export, Import, Undo / Redo. Persistent controls that don't depend on a selection or a click target. |
+| **Floating bubble menu** | The **selection** | Bold, Italic, Comment on selection. Format-on-selection actions where the user's eyes stay on the work. |
+| **Right-click context menu** | The **clicked target** | Accept / Reject (on a tracked change), Resolve (on a comment), Copy / Comment (when the click is *inside* the selection), Insert clause here (on plain text). |
+
+The controller surfaces this split directly. The toolbar reads `state.selection` to gate format buttons. The bubble menu anchors via `ui.selection.getAnchorRect({ placement: 'start' })`. The context menu opens against `ui.viewport.contextAt({ x, y })` and dispatches via `item.invoke()`. Same controller, same hooks, three jobs.
+
+## Worked example
+
+The [reference workspace on GitHub](https://github.com/superdoc-dev/superdoc/tree/main/demos/custom-ui) ships a full app on these surfaces: toolbar, custom command with keyboard shortcut, floating bubble menu, right-click context menu, comments sidebar with reply threads, tracked-change review, selection capture / restore, DOCX export and reimport.
diff --git a/apps/docs/editor/custom-ui/selection-and-viewport.mdx b/apps/docs/editor/custom-ui/selection-and-viewport.mdx
index 0a01c2defd..df4260521c 100644
--- a/apps/docs/editor/custom-ui/selection-and-viewport.mdx
+++ b/apps/docs/editor/custom-ui/selection-and-viewport.mdx
@@ -74,6 +74,131 @@ export function LinkComposer({ onPosted }: { onPosted(): void }) {
See the [selection capture example](https://github.com/superdoc-dev/superdoc/tree/main/examples/editor/custom-ui/selection-capture) for a runnable vanilla version showing why a captured selection survives composer focus changes.
+## Restore the visible selection
+
+`ui.selection.restore(capture)` puts the visible selection back where the capture was taken. Call it after the composer submits so the user keeps their place.
+
+```ts
+const result = ui.selection.restore(captured);
+if (!result.success) {
+ // 'stale' | 'missing-target' | 'read-only' | 'not-ready'
+ console.warn('restore failed:', result.reason);
+}
+```
+
+The capture carries a story locator (body / header / footer / footnote / endnote). Restore short-circuits with `'stale'` when the captured story is no longer the active surface (the user switched between header and body between capture and restore), so the cursor never lands on the wrong surface.
+
+| `reason` | Meaning |
+|---|---|
+| `'stale'` | The captured target no longer resolves (collaborator edit, surface switch). |
+| `'missing-target'` | The capture had no positional target (collapsed cursor at a non-addressable point). |
+| `'read-only'` | Editor is in `viewing` mode and won't accept selection mutations. |
+| `'not-ready'` | Editor or layout hasn't bootstrapped yet. |
+
+## Anchor a floating bubble menu
+
+`ui.selection.getAnchorRect(options, capture?)` returns one viewport-relative rect for positioning a popover over the selection. Don't reach for `window.getSelection().getRangeAt(0).getBoundingClientRect()`: that reads from the offscreen ProseMirror DOM and lands the popover in the wrong place. The painted DOM is separate.
+
+```tsx
+import { useEffect, useState } from 'react';
+import type { ViewportRect } from 'superdoc/ui';
+import { useSuperDocSelection, useSuperDocUI } from 'superdoc/ui/react';
+
+export function SelectionPopover() {
+ const ui = useSuperDocUI();
+ const selection = useSuperDocSelection();
+ const [rect, setRect] = useState(null);
+
+ useEffect(() => {
+ if (!ui || selection.empty) {
+ setRect(null);
+ return;
+ }
+ const update = () => setRect(ui.selection.getAnchorRect({ placement: 'start' }));
+ update();
+ window.addEventListener('scroll', update, true);
+ window.addEventListener('resize', update);
+ return () => {
+ window.removeEventListener('scroll', update, true);
+ window.removeEventListener('resize', update);
+ };
+ }, [ui, selection.empty, selection.target, selection.quotedText]);
+
+ if (!rect) return null;
+ return (
+
+ Bold / Italic / Comment
+
+ );
+}
+```
+
+`placement` accepts `'start'` (top of the first rect), `'end'` (bottom of the last rect), or `'union'` (the bounding rect across all line rects). Pass an optional second argument (a `SelectionCapture`) to anchor against a captured selection instead of the live one.
+
+`ui.selection.getRects(capture?)` returns every painted rect for the current or captured selection. Use it for AABB hit-testing, custom highlight overlays, or "is this point inside the selection?" gates.
+
+```ts
+const rects = ui.selection.getRects();
+// [{ top, left, width, height, pageIndex }, ...]
+```
+
+## Resolve a click to a caret position
+
+`ui.viewport.positionAt({ x, y })` returns the caret position the editor would place if the user clicked at `(x, y)`. Use it for "Paste here" or "Insert clause here" actions where the click point is the subject, not the prior selection.
+
+```ts
+const hit = ui.viewport.positionAt({ x: event.clientX, y: event.clientY });
+if (hit) {
+ // hit.point is a SelectionPoint at the click
+ // hit.target is a collapsed SelectionTarget at the same position
+ editor.doc.insert({ value: 'note', type: 'text', target: hit.target });
+}
+```
+
+Returns `null` when the click is outside the painted host or the editor isn't mounted. The resolved point carries a story locator when the click landed inside a header / footer / footnote, so passing the target straight to `editor.doc.insert` routes to the right surface.
+
+## Read entities under a point
+
+`ui.viewport.entityAt({ x, y })` returns the comments and tracked changes under a point, innermost first. Replaces walking the DOM for `data-comment-ids` / `data-track-change-id` attributes.
+
+```ts
+const entities = ui.viewport.entityAt({ x: event.clientX, y: event.clientY });
+// [{ type: 'trackedChange', id: 'tc-7' }, { type: 'comment', id: 'c-3' }]
+```
+
+Returns `[]` when no entity is under the point. Coordinates are in the same space as `MouseEvent.clientX` / `clientY`.
+
+## Get the painted host
+
+`ui.viewport.getHost()` returns the painted host element so you can scope listeners with `host.contains(target)` instead of a CSS class on a wrapper you happen to control.
+
+```ts
+const host = ui.viewport.getHost();
+document.addEventListener('contextmenu', (event) => {
+ if (!host || !(event.target instanceof Node) || !host.contains(event.target)) return;
+ // Click is inside the editor: open your custom menu, run hit-tests, etc.
+});
+```
+
+The painted host is separate from the offscreen ProseMirror DOM. Reach for `getHost()` when you need the element a `MouseEvent.target` would resolve to inside the editor.
+
+## The right-click bundle
+
+`ui.viewport.contextAt({ x, y })` composes the four primitives above into one bundle. Use it for custom right-click menus where the same shape filters items and runs handlers. See [Custom right-click menu](/editor/custom-ui/context-menu) for the full pattern.
+
+```ts
+const context = ui.viewport.contextAt({ x: event.clientX, y: event.clientY });
+// {
+// point: { x, y },
+// entities, // ViewportEntityHit[]
+// position, // ViewportPositionHit | null
+// selection, // SelectionSlice
+// insideSelection, // boolean
+// }
+```
+
+Always returns a bundle (no `null`). Inner fields carry the absent-case defaults each primitive defines.
+
## Scroll an entity into view
```ts
@@ -120,5 +245,6 @@ Rects are plain values in viewport coordinates. Multi-page or multi-line targets
## Trade-offs
- The selection slice updates on every editor change. Subscribe via the typed hook, not via raw `ui.select(...)`, so the controller can dedupe by shallow equality.
-- `getRect` is synchronous and DOM-driven. Text-anchored targets are deferred until story-aware text resolution lands.
+- `getRect`, `getRects`, `getAnchorRect`, `entityAt`, and `positionAt` are synchronous and DOM-driven. Run them inside a `useLayoutEffect` if you need them to settle before paint. Text-anchored `getRect` targets are deferred until story-aware text resolution lands.
- `scrollIntoView` honors `behavior: 'smooth'` for body content. Non-body entities (header / footer / footnote / endnote) snap to view because story activation has to mount the surface synchronously before alignment. If smooth animation across non-body entities matters, scroll the editor surface yourself first with `behavior: 'smooth'`, then call `scrollIntoView` to land the entity.
+- `contextAt({ x, y })` always returns a bundle. Non-numeric coords coerce to `(0, 0)`, which is a real point and may sit inside the painted host. Pass real coordinates if you want the result to reflect a specific click.
diff --git a/apps/docs/editor/custom-ui/track-changes.mdx b/apps/docs/editor/custom-ui/track-changes.mdx
index 2ab5b3d290..e614f4f44e 100644
--- a/apps/docs/editor/custom-ui/track-changes.mdx
+++ b/apps/docs/editor/custom-ui/track-changes.mdx
@@ -114,6 +114,10 @@ function ActiveAwareList() {
`TrackChangesItem` is `{ id, change }`. The `change` shape mirrors `editor.doc.trackChanges.list()`: `type`, `author`, `authorEmail`, `excerpt`, `address`, etc.
+## Theming
+
+Insertion and deletion highlights are themable via `--sd-tracked-changes-*` CSS variables (`--sd-tracked-changes-insert-background`, `--sd-tracked-changes-delete-border`, etc.). See [Theming overview](/editor/theming/overview) and [Custom themes](/editor/theming/custom-themes) for the full token list.
+
## Trade-offs
- `acceptAll` and `rejectAll` apply across every story. To scope to body only, call `accept` / `reject` per id.
diff --git a/packages/superdoc/AGENTS.md b/packages/superdoc/AGENTS.md
index 110f29dacd..4009880891 100644
--- a/packages/superdoc/AGENTS.md
+++ b/packages/superdoc/AGENTS.md
@@ -193,13 +193,32 @@ Docs: https://docs.superdoc.dev/document-engine/overview
| Import DOCX | Pass URL, File, or Blob to `document` option |
| Export DOCX | `const blob = await superdoc.export({ isFinalDoc: true })` |
| Track changes | Set `documentMode: 'suggesting'` or use SDK with `defaultChangeMode: 'tracked'` |
-| Add comments | Use Document API: `editor.doc.comments.create({ target, content: 'text' })` |
+| Add comments (programmatic) | Use Document API: `editor.doc.comments.create({ target, text: 'comment body' })` |
| Find and replace | Use Document API: `editor.doc.query.match(...)` then `editor.doc.replace(...)` |
-| Format text | Use Document API: `editor.doc.format.bold(...)`, `.italic(...)`, etc. |
+| Format text (programmatic) | Use Document API: `editor.doc.format.bold(...)`, `.italic(...)`, etc. |
| Real-time collab | Configure `modules.collaboration` with a Yjs provider |
-| Custom toolbar | Use `modules.toolbar.customButtons` array |
+| Custom built-in toolbar | Use `modules.toolbar.customButtons` array |
+| **Build custom React UI** | Use `superdoc/ui/react`. See "Custom UI" below. |
| Listen to events | `superdoc.on('ready', ({ superdoc }) => { ... })` |
+### Custom UI (React)
+
+Wrap your app in ``, mount the editor with `onReady` calling `useSetSuperDoc()`, then drive your toolbar / sidebar / right-click menu through the `superdoc/ui/react` hooks. The controller provides typed slices for selection, comments, tracked changes, document mode, and a `commands.register({...})` surface for custom actions with keyboard shortcuts and right-click contributions.
+
+```javascript
+import { SuperDocUIProvider, useSuperDocUI, useSuperDocCommand, useSuperDocSelection } from 'superdoc/ui/react';
+
+function BoldButton() {
+ const ui = useSuperDocUI();
+ const bold = useSuperDocCommand('bold');
+ return ;
+}
+```
+
+Reach for the controller when building custom UI. Reach for the Document API (`editor.doc.*`) for programmatic mutations from outside the UI (AI agents, server flows, scripts). The two layers compose: `editor` is reachable from inside command `execute` callbacks via `({ editor })`.
+
+Full reference: https://docs.superdoc.dev/editor/custom-ui/overview. Worked example: https://github.com/superdoc-dev/superdoc/tree/main/demos/custom-ui.
+
### Programmatic access (Document API)
For reading and mutating documents programmatically, use the Document API (`editor.doc`). It provides 300+ stable operations. Direct access to ProseMirror internals (`editor.state`, `editor.view`) and editor commands (`editor.commands`) is deprecated and will be removed.
@@ -213,7 +232,7 @@ superdoc.on('editorCreate', ({ editor }) => {
editor.doc.replace({ target: result.items[0].target, text: 'Globex' });
// Add a comment
- editor.doc.comments.create({ target: result.items[0].target, content: 'Updated name' });
+ editor.doc.comments.create({ target: result.items[0].target, text: 'Updated name' });
});
```