Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 9 additions & 5 deletions apps/docs/editor/built-in-ui/context-menu.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Note>
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.
</Note>

## 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({
Expand All @@ -34,7 +38,7 @@ new SuperDoc({
```

<ParamField path="disableContextMenu" type="boolean" default="false">
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.
</ParamField>

<ParamField path="modules.contextMenu.includeDefaultItems" type="boolean" default="true">
Expand Down
43 changes: 39 additions & 4 deletions apps/docs/editor/custom-ui/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand All @@ -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`
Expand Down Expand Up @@ -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.
4 changes: 4 additions & 0 deletions apps/docs/editor/custom-ui/comments.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
259 changes: 259 additions & 0 deletions apps/docs/editor/custom-ui/context-menu.mdx
Original file line number Diff line number Diff line change
@@ -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 `<SuperDocEditor disableContextMenu>` 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 (
<div className="context-menu" style={{ position: 'fixed', left: open.x, top: open.y }}>
{open.items.map((item) => (
<button key={item.id} onClick={() => { item.invoke?.(); setOpen(null); }}>
{item.label}
</button>
))}
</div>
);
}
```

Suppress the built-in menu so your own takes over:

```tsx
<SuperDocEditor document={file} disableContextMenu onReady={onReady} />
```

`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
<button onClick={() => item.invoke?.()}>{item.label}</button>
```

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.

<CodeGroup>

```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 (
<SuperDocUIProvider>
<Registrations />
</SuperDocUIProvider>
);
}
```

</CodeGroup>

## 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.
Loading
Loading