|
| 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