Skip to main content

Documentation Index

Fetch the complete documentation index at: https://superdoc-caio-pizzol-sd-3084-align-tracked-change-ids.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

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

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.
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 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.
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.
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.
ui.commands.register<{ clauseId: string }>({
  id: 'company.openClauseLibrary',
  execute: ({ payload }) => {
    if (!payload) return false;
    openClauseModal(payload.clauseId);
    return true;
  },
});
The arguments:
FieldMeaning
payloadWhatever the caller passed to commands.get(id).execute(payload). Typed via register<TPayload>(...).
superdocThe host SuperDoc instance. Useful when you need superdoc.config or other host-only fields.
editorThe currently routed editor. Tracks header / footer / footnote focus, so editor.doc.* calls land on the right surface. null until ready.
contextThe ViewportContext bundle when the command was invoked from a right-click menu via ContextMenuItem.invoke(). undefined for direct dispatch. See Custom right-click menu.
Async work is fine. The runtime awaits internally.
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.
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.
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:
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 }).
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'),
  },
});
FieldTypeMeaning
labelstringItem text. Required.
groupstringSort key. Built-in groups: format, clipboard, review, comment, link. Customs land after, in registration order. Default 'custom'.
ordernumberSort order within a group. Default 0.
when(input) => booleanFilter 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 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.
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.
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.
Two ways to reach the same toolbar effect:
// 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').
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.

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.