Skip to content

Commit 0cc2446

Browse files
authored
docs(custom-ui): cover the controller surface shipped in SD-2936 stack (#3173)
* docs(custom-ui): cover the controller surface shipped in SD-2936 stack Brings the Custom UI docs up to parity with what's shipped on superdoc/ui (SD-2943, SD-2954, SD-2945, SD-2944). - New editor/custom-ui/context-menu.mdx walks the right-click flow: disableContextMenu, contextAt({ x, y }), getContextMenuItems(context), invoke(), predicate examples for entity/selection/click-point. - selection-and-viewport.mdx expanded for seven previously undocumented methods: ui.selection.restore (with reason table), getRects, getAnchorRect, ui.viewport.getHost, entityAt, positionAt, contextAt. - custom-commands.mdx: execute now { payload, superdoc, editor, context }; shortcut, contextMenu contribution, commands.has, commands.require documented. - api-reference.mdx: new methods + ContextMenuContribution, ContextMenuWhenInput, ContextMenuItem with invoke, SelectionAnchorRectOptions, SelectionRestoreResult, ViewportEntityHit, ViewportPositionHit, ViewportContext. - overview.mdx: Card for context-menu plus three-surfaces recipe. - Built-in context-menu page corrects disableContextMenu semantics and points to the new custom-ui flow. - navigation.mdx reframed around ui.comments.scrollTo / trackChanges.scrollTo / viewport.scrollIntoView at the top. - comments and track-changes cross-link to theming. - packages/superdoc/AGENTS.md gains a Custom UI (React) section. * docs(custom-ui): correct API shapes flagged in review Four accuracy fixes against the shipped surface: - packages/superdoc/AGENTS.md: comments.create takes { target, text }, not { target, content }. Updated both the table row and the programmatic-access example so AI agents copying this guidance don't hit a schema rejection. - track-changes.mdx: theming variables ship as --sd-tracked-changes-* (e.g. --sd-tracked-changes-insert-background, --sd-tracked-changes-delete-border), not --sd-track-change-*. Cited the concrete prefixes a customizer will look for. - context-menu.mdx: ui.commands has no public execute(id) method. Documented the actual dispatch shapes: ui.commands.get(id)?.execute(...), ui.commands.require(id).execute(...), and ui.toolbar.execute(id, payload) for built-ins. - navigation.mdx: trackChanges.next() / previous() advance activeId and return the new id but don't move the viewport on their own; pair them with scrollTo to navigate. Narrowed the story-aware claim to trackChanges.scrollTo only; comments.scrollTo is body-scoped today. Brand-voice pass on the new content: tightened the context-menu lede to shorter sentences, replaced one em-dash range glyph in the keyboard-syntax line with "through". * docs(custom-ui): drop redundant context-menu lede The page now jumps from the frontmatter description straight into Quick start. The previous prose-then-code lede duplicated the work the description and Quick start already do (orient + show the shape), and the developer-register pattern in brand voice is "show first, tell second" — the code in Quick start IS the lede on a how-to page like this one.
1 parent c895165 commit 0cc2446

11 files changed

Lines changed: 609 additions & 29 deletions

File tree

apps/docs/docs.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,9 @@
102102
"editor/custom-ui/toolbar-and-commands",
103103
"editor/custom-ui/custom-commands",
104104
"editor/custom-ui/comments",
105-
"editor/custom-ui/selection-and-viewport",
106105
"editor/custom-ui/track-changes",
106+
"editor/custom-ui/context-menu",
107+
"editor/custom-ui/selection-and-viewport",
107108
"editor/custom-ui/document-control",
108109
"editor/custom-ui/navigation",
109110
"editor/custom-ui/api-reference"

apps/docs/editor/built-in-ui/context-menu.mdx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
---
2-
title: Context Menu
2+
title: Context menu
33
keywords: "context menu, right-click menu, custom commands"
44
---
55

6-
A contextual command menu triggered by right-clicking. Shows relevant actions based on cursor position and document state.
6+
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.
7+
8+
<Note>
9+
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.
10+
</Note>
711

812
## Quick start
913

10-
The context menu is **enabled by default**. Right-click anywhere in the document to open it.
14+
The built-in menu is **enabled by default**. Right-click anywhere in the document to open it.
1115

12-
To disable it:
16+
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:
1317

1418
```javascript
1519
new SuperDoc({
@@ -34,7 +38,7 @@ new SuperDoc({
3438
```
3539

3640
<ParamField path="disableContextMenu" type="boolean" default="false">
37-
Top-level option to disable the context menu entirely
41+
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.
3842
</ParamField>
3943

4044
<ParamField path="modules.contextMenu.includeDefaultItems" type="boolean" default="true">

apps/docs/editor/custom-ui/api-reference.mdx

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,16 +115,32 @@ Built-in dispatch and custom-command registration.
115115

116116
```ts
117117
ui.commands.get('bold')?.execute(); // dynamic dispatch
118+
ui.commands.has('company.aiRewrite'); // is this id registered?
119+
ui.commands.require('company.insertClause'); // get or throw
118120
ui.commands.bold.execute(); // typed per-built-in handle
119121
ui.commands.bold.observe(({ active, disabled }) => { ... });
120122

121123
const reg = ui.commands.register({ // custom command
122124
id: 'company.insertClause',
123-
execute: ({ payload, superdoc, editor }) => true,
125+
shortcut: 'Mod-Shift-C', // optional keyboard binding
126+
execute: ({ payload, superdoc, editor, context }) => true,
124127
getState: ({ state }) => ({ disabled: state.selection.empty }),
128+
contextMenu: { // optional right-click contribution
129+
label: 'Insert clause here',
130+
group: 'review',
131+
when: ({ entities, position, insideSelection }) =>
132+
entities.length === 0 && position !== null && insideSelection !== true,
133+
},
125134
});
126135
reg.invalidate();
127136
reg.unregister();
137+
138+
// Right-click menu items returned from getContextMenuItems carry invoke()
139+
// closures that fire execute({ context }) with the bundle bound.
140+
const items = ui.commands.getContextMenuItems(
141+
ui.viewport.contextAt({ x: event.clientX, y: event.clientY }),
142+
);
143+
items[0]?.invoke?.();
128144
```
129145

130146
### `ui.comments`
@@ -173,12 +189,18 @@ await ui.document.replaceFile(file);
173189

174190
### `ui.selection`
175191

176-
Live slice and capture.
192+
Live slice, capture, restore, painted geometry.
177193

178194
```ts
179195
ui.selection.getSnapshot();
180196
ui.selection.subscribe(({ snapshot }) => {});
181-
const captured = ui.selection.capture(); // frozen, holds across focus changes
197+
const captured = ui.selection.capture(); // frozen, holds across focus changes
198+
const restore = ui.selection.restore(captured); // { success, reason? }
199+
200+
ui.selection.getRects(); // ViewportRect[]
201+
ui.selection.getRects(captured); // rects of a captured selection
202+
ui.selection.getAnchorRect({ placement: 'start' }); // single rect for popovers
203+
ui.selection.getAnchorRect({ placement: 'union' }, captured);
182204
```
183205

184206
### `ui.viewport`
@@ -188,6 +210,11 @@ Geometry. Browser-only.
188210
```ts
189211
ui.viewport.getRect({ target: { kind: 'entity', entityType: 'comment', entityId } });
190212
await ui.viewport.scrollIntoView({ target, block: 'center', behavior: 'smooth' });
213+
214+
ui.viewport.getHost(); // painted host element | null
215+
ui.viewport.entityAt({ x, y }); // ViewportEntityHit[]
216+
ui.viewport.positionAt({ x, y }); // ViewportPositionHit | null
217+
ui.viewport.contextAt({ x, y }); // ViewportContext (always returns)
191218
```
192219

193220
### `ui.toolbar`
@@ -234,7 +261,15 @@ Imported from `superdoc/ui`.
234261
| `UIToolbarCommandState` | Per-command state shape. |
235262
| `CustomCommandRegistration` | Input to `ui.commands.register`. |
236263
| `CustomCommandRegistrationResult` | Return from `ui.commands.register`. |
264+
| `ContextMenuContribution` | The `contextMenu` field on a registration. |
265+
| `ContextMenuWhenInput` | Argument to `contextMenu.when({ entities, selection, point?, position?, insideSelection? })`. |
266+
| `ContextMenuItem` | Item returned from `ui.commands.getContextMenuItems(input)`. Carries `invoke()` when produced from a `ViewportContext` bundle. |
267+
| `SelectionAnchorRectOptions` | `{ placement: 'start' \| 'end' \| 'union' }`. |
268+
| `SelectionRestoreResult` | `{ success: true } \| { success: false, reason }`. |
237269
| `ScrollIntoViewInput` | Input to `ui.viewport.scrollIntoView`. |
238-
| `ViewportRect` | Plain value rectangle returned by `ui.viewport.getRect`. |
270+
| `ViewportRect` | Plain value rectangle. |
271+
| `ViewportEntityHit` | `{ type: 'comment' \| 'trackedChange', id }`. |
272+
| `ViewportPositionHit` | `{ point: SelectionPoint, target: SelectionTarget }`. |
273+
| `ViewportContext` | Bundle returned from `ui.viewport.contextAt({ x, y })`. |
239274

240275
For the source of truth, the types ship with the `superdoc` package and are exported alongside the runtime values.

apps/docs/editor/custom-ui/comments.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ function ReplyComposer({ parent }: { parent: { id: string } }) {
187187

188188
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.
189189

190+
## Theming
191+
192+
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.
193+
190194
## Trade-offs
191195

192196
- `useSuperDocComments` returns a memoized snapshot. Re-renders happen only when items, total, or activeIds change.
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
---
2+
title: 'Custom right-click menu'
3+
sidebarTitle: 'Context menu'
4+
description: 'Suppress the built-in menu, render your own with the controller bundle, dispatch with the click target bound to your handler.'
5+
---
6+
7+
## Quick start
8+
9+
Three pieces: a registration that contributes an item, a `contextmenu` listener that opens the menu, and a `<SuperDocEditor disableContextMenu>` to keep the built-in out of the way.
10+
11+
```tsx
12+
import { useEffect, useState } from 'react';
13+
import type { ContextMenuItem } from 'superdoc/ui';
14+
import { useSuperDocUI } from 'superdoc/ui/react';
15+
16+
export function ContextMenu() {
17+
const ui = useSuperDocUI();
18+
const [open, setOpen] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
19+
20+
useEffect(() => {
21+
if (!ui) return;
22+
const onContextMenu = (event: MouseEvent) => {
23+
const host = ui.viewport.getHost();
24+
if (!host || !(event.target instanceof Node) || !host.contains(event.target)) return;
25+
26+
const context = ui.viewport.contextAt({ x: event.clientX, y: event.clientY });
27+
const items = ui.commands.getContextMenuItems(context);
28+
if (items.length === 0) return; // browser native menu falls through
29+
30+
event.preventDefault();
31+
setOpen({ x: event.clientX, y: event.clientY, items });
32+
};
33+
document.addEventListener('contextmenu', onContextMenu);
34+
return () => document.removeEventListener('contextmenu', onContextMenu);
35+
}, [ui]);
36+
37+
if (!open) return null;
38+
return (
39+
<div className="context-menu" style={{ position: 'fixed', left: open.x, top: open.y }}>
40+
{open.items.map((item) => (
41+
<button key={item.id} onClick={() => { item.invoke?.(); setOpen(null); }}>
42+
{item.label}
43+
</button>
44+
))}
45+
</div>
46+
);
47+
}
48+
```
49+
50+
Suppress the built-in menu so your own takes over:
51+
52+
```tsx
53+
<SuperDocEditor document={file} disableContextMenu onReady={onReady} />
54+
```
55+
56+
`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.
57+
58+
## The bundle
59+
60+
`ui.viewport.contextAt({ x, y })` always returns an object, never `null`. Empty defaults make destructuring safe.
61+
62+
| Field | Type | Meaning |
63+
|---|---|---|
64+
| `point` | `{ x, y }` | Echoes the input. Useful for anchoring floating UI. |
65+
| `entities` | `ViewportEntityHit[]` | Tracked changes / comments under the click, innermost first. Empty when none. |
66+
| `position` | `ViewportPositionHit \| null` | Resolved caret position at the click. `null` when the click is outside the painted host. |
67+
| `selection` | `SelectionSlice` | Mirrors the live `state.selection` slice. |
68+
| `insideSelection` | `boolean` | True when the click lands inside the rects the live selection currently paints. |
69+
70+
`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.
71+
72+
```ts
73+
const context = ui.viewport.contextAt({ x: 100, y: 200 });
74+
// context.point { x: 100, y: 200 }
75+
// context.entities [{ type: 'trackedChange', id: 'tc-7' }, ...]
76+
// context.position { point: { kind: 'text', blockId, offset, story? }, target }
77+
// context.selection { empty, target, selectionTarget, activeMarks, ... }
78+
// context.insideSelection true | false
79+
```
80+
81+
## Contribute an item
82+
83+
Add a `contextMenu` field to your registration. The `when` predicate filters on the same bundle the handler will receive.
84+
85+
```tsx
86+
ui.commands.register({
87+
id: 'demo.acceptSuggestion',
88+
execute: ({ context }) => {
89+
const id = context?.entities.find((e) => e.type === 'trackedChange')?.id;
90+
if (!id) return false;
91+
ui.trackChanges.accept(id);
92+
return true;
93+
},
94+
contextMenu: {
95+
label: 'Accept suggestion',
96+
group: 'review',
97+
order: 0,
98+
when: ({ entities }) => entities.some((e) => e.type === 'trackedChange'),
99+
},
100+
});
101+
```
102+
103+
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.
104+
105+
### Predicate examples
106+
107+
The bundle's optional fields make scope rules direct.
108+
109+
```ts
110+
// Entity-scoped: accept / reject / resolve
111+
when: ({ entities }) => entities.some((e) => e.type === 'trackedChange'),
112+
113+
// Selection-scoped: copy / comment, only when click is inside the selection
114+
when: ({ selection, insideSelection }) =>
115+
!selection.empty && insideSelection === true,
116+
117+
// Point-scoped: insert at the click, only on plain caret-only text
118+
when: ({ entities, position, insideSelection }) =>
119+
entities.length === 0 && position !== null && insideSelection !== true,
120+
```
121+
122+
The predicate sees `entities`, `selection`, `point`, `position`, `insideSelection`. Old predicates that only destructure `{ entities, selection }` keep working.
123+
124+
## item.invoke()
125+
126+
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.
127+
128+
```tsx
129+
<button onClick={() => item.invoke?.()}>{item.label}</button>
130+
```
131+
132+
Inside `execute`, the same bundle the predicate filtered on is available as `context`:
133+
134+
```ts
135+
execute: ({ payload, superdoc, editor, context }) => {
136+
// context.position?.target is the collapsed SelectionTarget at the click
137+
// context.entities is the entity list under the click
138+
// context.selection is the live selection at the time the menu opened
139+
// context.insideSelection is the hit-test result
140+
return true;
141+
}
142+
```
143+
144+
`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.
145+
146+
## Falling through to the native menu
147+
148+
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.
149+
150+
If you'd rather suppress the native menu in the empty case too, call `event.preventDefault()` regardless of items length and render nothing.
151+
152+
## Worked example
153+
154+
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.
155+
156+
<CodeGroup>
157+
158+
```tsx Usage
159+
const accept = ui.commands.register({
160+
id: 'demo.acceptSuggestion',
161+
execute: ({ context }) => {
162+
const id = context?.entities.find((e) => e.type === 'trackedChange')?.id;
163+
if (!id) return false;
164+
ui.trackChanges.accept(id);
165+
return true;
166+
},
167+
contextMenu: {
168+
label: 'Accept suggestion',
169+
group: 'review',
170+
when: ({ entities }) => entities.some((e) => e.type === 'trackedChange'),
171+
},
172+
});
173+
174+
const copy = ui.commands.register({
175+
id: 'demo.copy',
176+
execute: ({ context }) => {
177+
const text = context?.selection.quotedText ?? '';
178+
if (text) navigator.clipboard.writeText(text).catch(() => {});
179+
return true;
180+
},
181+
contextMenu: {
182+
label: 'Copy',
183+
group: 'clipboard',
184+
when: ({ selection, insideSelection }) =>
185+
!selection.empty && insideSelection === true,
186+
},
187+
});
188+
189+
const insertHere = ui.commands.register({
190+
id: 'demo.insertClauseHere',
191+
execute: ({ context, editor }) => {
192+
const target = context?.position?.target;
193+
if (!target || !editor?.doc?.insert) return false;
194+
const receipt = editor.doc.insert({
195+
value: 'Standard clause text.',
196+
type: 'text',
197+
target,
198+
});
199+
return receipt?.success === true;
200+
},
201+
contextMenu: {
202+
label: 'Insert clause here',
203+
group: 'review',
204+
order: 10,
205+
when: ({ entities, position, insideSelection }) =>
206+
entities.length === 0 && position !== null && insideSelection !== true,
207+
},
208+
});
209+
```
210+
211+
```tsx Full Example
212+
import { useEffect } from 'react';
213+
import { SuperDocUIProvider, useSuperDocUI } from 'superdoc/ui/react';
214+
215+
function Registrations() {
216+
const ui = useSuperDocUI();
217+
useEffect(() => {
218+
if (!ui) return;
219+
const reg = ui.commands.register({
220+
id: 'demo.insertClauseHere',
221+
execute: ({ context, editor }) => {
222+
const target = context?.position?.target;
223+
if (!target || !editor?.doc?.insert) return false;
224+
const receipt = editor.doc.insert({
225+
value: 'Standard clause text.',
226+
type: 'text',
227+
target,
228+
});
229+
return receipt?.success === true;
230+
},
231+
contextMenu: {
232+
label: 'Insert clause here',
233+
group: 'review',
234+
when: ({ entities, position, insideSelection }) =>
235+
entities.length === 0 && position !== null && insideSelection !== true,
236+
},
237+
});
238+
return () => reg.unregister();
239+
}, [ui]);
240+
return null;
241+
}
242+
243+
export function App() {
244+
return (
245+
<SuperDocUIProvider>
246+
<Registrations />
247+
</SuperDocUIProvider>
248+
);
249+
}
250+
```
251+
252+
</CodeGroup>
253+
254+
## Trade-offs
255+
256+
- 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.
257+
- `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.
258+
- 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".
259+
- `position` is `null` when the click is outside the painted host. Predicates that act on the click point should check `position !== null` first.

0 commit comments

Comments
 (0)