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' }); }); ```