> ## Documentation Index
> Fetch the complete documentation index at: https://superdoc-caio-pizzol-sd-docs-snippet-typecheck-2.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom commands

> Register your own commands by id. Drive AI actions, clause inserts, and business logic alongside built-ins.

`ui.commands.register({ id, execute, getState })` puts your command on the same surface as built-ins. Bind to it with `useSuperDocCommand(id)` exactly like a bold button. Override built-ins when you need to. Recompute state when something outside the editor changes.

## Register a command

```tsx theme={null}
import { useEffect } from 'react';
import { useSuperDocUI } from 'superdoc/ui/react';

export function RegisterClauseCommand() {
  const ui = useSuperDocUI();

  useEffect(() => {
    if (!ui) return;

    const reg = ui.commands.register({
      id: 'company.insertClause',
      execute: ({ payload, superdoc }) => {
        // Run any logic. Return a boolean for the toolbar.
        console.log('inserting clause', payload);
        return true;
      },
      getState: ({ state }) => ({
        disabled: state.selection.empty,
      }),
    });

    return () => reg.unregister();
  }, [ui]);

  return null;
}
```

The returned registration cleans itself up on `unregister()`. Always tear down in the effect's cleanup so unmounting the component removes the command.

## Bind a button to it

Same hook as built-ins.

```tsx theme={null}
function InsertClauseButton() {
  const ui = useSuperDocUI();
  const cmd = useSuperDocCommand('company.insertClause');

  return (
    <button
      disabled={cmd.disabled}
      onClick={() => ui?.commands.get('company.insertClause')?.execute({ clauseId: 'nda' })}
    >
      Insert NDA
    </button>
  );
}
```

See the [configurable toolbar example](https://github.com/superdoc-dev/superdoc/tree/main/examples/editor/custom-ui/configurable-toolbar) for a runnable vanilla version that registers an `example.insertClause` command alongside the built-in bold / italic / underline buttons.

## Naming

Use a namespace to avoid colliding with future built-ins. `company.insertClause`, `acme.aiRewrite`, `support.translate`. Bare names like `'rewrite'` will warn if SuperDoc later adds a built-in with the same id.

## getState

`getState` runs on every snapshot rebuild. Keep it cheap. The argument is the full controller state, so you can read `state.selection`, `state.documentMode`, `state.comments` without subscribing yourself.

```ts theme={null}
getState: ({ state }) => ({
  active: state.selection.activeMarks.includes('aiHighlight'),
  disabled: state.selection.empty || state.documentMode === 'viewing',
  value: state.selection.quotedText,
})
```

`getState` is sync. Async work belongs in `execute`. If app state outside the editor changes (auth flip, quota tick), call `reg.invalidate()` to re-run `getState`.

```ts theme={null}
const reg = ui.commands.register({ ... });

function onPermissionsChange() {
  reg.invalidate();
}
```

## execute

`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<boolean>`); the engine doesn't see the work.

```ts theme={null}
ui.commands.register<{ clauseId: string }>({
  id: 'company.openClauseLibrary',
  execute: ({ payload }) => {
    if (!payload) return false;
    openClauseModal(payload.clauseId);
    return true;
  },
});
```

The arguments:

| Field      | Meaning                                                                                                                                                                                                           |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `payload`  | Whatever the caller passed to `commands.get(id).execute(payload)`. Typed via `register<TPayload>(...)`.                                                                                                           |
| `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 theme={null}
execute: async ({ payload }) => {
  const result = await postReviewRequest(payload);
  return result.ok;
}
```

### Mutating the document from a custom command

Custom commands that need to insert, replace, or format content reach the Document API through the host instance today. Use the public `ui.selection` slice for positional shapes; pull `superdoc.activeEditor` for the doc-API call. The reference demo's `InsertClauseButton` shows the full pattern.

```ts theme={null}
ui.commands.register<{ clauseId: string }>({
  id: 'company.insertClause',
  getState: ({ state }) => ({
    disabled: !state.ready || state.selection.target === null,
  }),
  execute: ({ payload, editor }) => {
    if (!payload || !editor) return false;
    const clause = clauseLibrary.find((c) => c.id === payload.clauseId);
    if (!clause) return false;

    const selectionTarget = ui.selection.getSnapshot().selectionTarget;
    if (!selectionTarget) return false;

    const receipt = editor.doc?.insert?.({
      value: clause.body,
      type: 'text',
      target: selectionTarget,
    });
    return receipt?.success === true;
  },
});
```

Two things make the snippet safe to copy:

* The positional target comes from `ui.selection.getSnapshot().selectionTarget` (a public `SelectionTarget` shape that `editor.doc.insert` accepts). Don't pass `selection.current().target`. That's a `TextTarget`, the wrong shape for `insert`.
* `editor` is typed and resolved late-bound by the controller. It tracks header / footer / footnote focus the same way every other `ui.*` mutation does, so a custom command run from a header-focused composer hits the right story.

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 theme={null}
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 theme={null}
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 theme={null}
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 theme={null}
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.

<Warning>
  Override fully replaces the built-in's `execute`. There is no "call original" delegation today: if you override `'bold'`, the only thing that happens when a user clicks the Bold button is whatever your `execute` does. Add behavior; don't trust the original to still run.
</Warning>

Two ways to reach the same toolbar effect:

```ts theme={null}
// Pattern A: register a sibling, dispatch both. Built-in stays untouched.
ui.commands.register({
  id: 'company.boldTelemetry',
  execute: () => {
    track('bold-pressed');
    return true;
  },
});

// In your Bold button handler:
ui.commands.get('company.boldTelemetry')?.execute();
ui.commands.get('bold')?.execute();
```

Pattern A is the right choice for almost every case. Once you override, every surface routes through your handler: `useSuperDocCommand('bold')`, `ui.commands.get('bold')?.execute()`, `ui.toolbar.execute('bold')`.

<Warning>
  **Replacing a built-in entirely is an advanced escape hatch.** The Document API is the recommended way to mutate the document; if your override needs to actually toggle a mark, you have to reach into the underlying engine's chain commands, which aren't part of the public typed surface. Use this only when you genuinely need to gate the built-in (audit, RBAC, "ask before formatting") and you understand that the built-in's behavior is now your responsibility to maintain. Most teams should pick Pattern A.
</Warning>

## Trade-offs

* `getState` runs on every snapshot rebuild. Keep it pure and fast.
* `execute` errors are caught and logged; they don't crash the toolbar.
* `unregister()` is idempotent. Calling twice is safe.
* The lifecycle is component-scoped. Hold the registration for as long as the command should be available.
