Register an application command
Define one application action, then reuse its behavior and state from more than one control.
A normal function is enough when one control owns an action. Register a custom command when two or more controls need the same execution path and enabled state.
Try one command from two controls
- Place the caret at the end of a sentence.
- Choose Insert clause. The status identifies the button and confirms the insertion.
- Move the caret, then press Control-Shift-Y. The status identifies the shortcut and confirms the same action.
Both controls execute application.insertClause. The button observes the registered command's state; neither control
owns a separate insertion function.
Choose a function or a command
| Your action | Use |
|---|---|
| Runs from one application control | A normal function |
| Runs from multiple controls with shared state | A registered application command |
| Already exists in SuperDoc, such as Bold or Undo | Its existing handle under superdoc.ui |
Do not wrap a built-in SuperDoc command in a custom command. Reuse its existing handle so every control reads the same state and result.
Build the same command
Complete Build your first custom control first. Continue with its /sample.docx
and built-in toolbar.
Add the application action beside the Editor:
<div id="command-demo">
<div id="toolbar"></div>
<div aria-label="Application actions" role="toolbar">
<button aria-keyshortcuts="Control+Shift+Y" id="insert-clause" type="button" disabled>
Insert clause
</button>
<kbd>Control-Shift-Y</kbd>
</div>
<output id="command-status" aria-live="polite">Place the caret in the document.</output>
<div id="editor" style="height: 70vh"></div>
</div>
<script type="module" src="/src/main.ts"></script>
Register the command, then connect both triggers to its typed handle:
import { SuperDoc } from 'superdoc';
import type { CommandExecutionResult, CustomCommandHandle } from 'superdoc/ui';
import 'superdoc/style.css';
type InsertClausePayload = Readonly<{
text: string;
trigger: 'button' | 'shortcut';
}>;
const commandDemo = document.querySelector<HTMLDivElement>('#command-demo');
const insertClause = document.querySelector<HTMLButtonElement>('#insert-clause');
const status = document.querySelector<HTMLOutputElement>('#command-status');
if (!commandDemo || !insertClause || !status) throw new Error('The custom command controls are incomplete.');
const clauseText = ' This agreement is governed by the laws of California.';
let command: CustomCommandHandle<InsertClausePayload> | null = null;
let unregisterCommand: (() => void) | null = null;
let stopCommandState: (() => void) | null = null;
let removeHandlers: (() => void) | null = null;
let pending = false;
const report = (result: CommandExecutionResult, trigger: InsertClausePayload['trigger']) => {
if (result === false) {
status.textContent = 'The command could not run.';
} else if (typeof result === 'object' && !result.success) {
status.textContent = result.failure?.message ?? 'The clause was not inserted.';
} else {
status.textContent = `${trigger === 'button' ? 'Button' : 'Shortcut'} inserted the clause.`;
}
};
const render = () => {
const state = command?.getState();
insertClause.disabled = pending || !state?.enabled;
insertClause.title = state?.reason ?? 'Insert standard clause';
};
const run = async (trigger: InsertClausePayload['trigger']) => {
if (!command || pending) return;
if (!command.getState().enabled) {
status.textContent = 'Place the caret in the document first.';
return;
}
pending = true;
render();
try {
report(await command.executeAsync({ text: clauseText, trigger }), trigger);
} catch (error) {
status.textContent = error instanceof Error ? error.message : 'The clause was not inserted.';
} finally {
pending = false;
render();
}
};
const superdoc = new SuperDoc({
selector: '#editor',
document: '/sample.docx',
ui: { toolbar: { container: '#toolbar' } },
onReady: ({ superdoc: readySuperDoc }) => {
stopCommandState?.();
removeHandlers?.();
unregisterCommand?.();
pending = false;
const ui = readySuperDoc.ui;
const registration = ui.commands.register<InsertClausePayload>({
id: 'application.insertClause',
shortcut: 'Ctrl-Shift-Y',
getState: ({ state, selection, documentMode }) => {
const settled = state.ready && selection.status === 'ready';
// `status: 'ready'` only means the selection read settled. Before the reader places a
// caret both targets are null, and `insertText()` then has no insertion point.
// Insert at a non-collapsed range degrades to a replace, which would delete the
// reader's selection, so require a collapsed caret rather than any target.
const hasCaret = Boolean(selection.selectionTarget ?? selection.target) && selection.empty;
return {
enabled: settled && hasCaret && documentMode !== 'viewing',
active: false,
supported: true,
reason: !settled
? 'not-ready'
: !hasCaret
? 'selection-required'
: documentMode === 'viewing'
? 'document-readonly'
: undefined,
};
},
execute: ({ payload, insertText }) => (payload?.text ? insertText(payload.text) : false),
});
command = registration.handle;
const preserveSelection = (event: MouseEvent) => event.preventDefault();
const runButton = () => void run('button');
const runShortcut = (event: KeyboardEvent) => {
if (!event.composedPath().includes(commandDemo)) return;
if (event.repeat) return;
// Match the advertised character. Alt is excluded, so `event.key` is not an AltGr
// composition here, and `event.code` would match a US physical position instead.
// An IME can emit a matching keydown mid-composition; ignore those.
if (event.isComposing) return;
if (!event.ctrlKey || !event.shiftKey || event.altKey || event.metaKey || event.key.toLowerCase() !== 'y') return;
event.preventDefault();
void run('shortcut');
};
render();
stopCommandState = command.observe(render);
unregisterCommand = registration.unregister;
insertClause.addEventListener('mousedown', preserveSelection);
insertClause.addEventListener('click', runButton);
window.addEventListener('keydown', runShortcut, true);
removeHandlers = () => {
insertClause.removeEventListener('mousedown', preserveSelection);
insertClause.removeEventListener('click', runButton);
window.removeEventListener('keydown', runShortcut, true);
};
},
});
window.addEventListener('beforeunload', () => {
stopCommandState?.();
removeHandlers?.();
unregisterCommand?.();
superdoc.destroy();
});
The registration defines the shared contract:
getState()derives whether the action is available from the live Editor state.execute()owns the insertion and receives the typed payload.shortcutrecords the intended key binding. Your application still owns the keyboard listener and conflict policy.observe()keeps the button synchronized as the Editor state changes.executeAsync()returns the settled result for either trigger.
getState() describes availability; it does not block custom-command execution. Check handle.getState().enabled in
every trigger before calling executeAsync(). Scope global keyboard listeners to the Editor experience so they do not
take over the rest of your application.
Custom commands do not create an authorization boundary. Checking enabled in each trigger keeps the interface honest,
but a caller can invoke the handle directly. Your backend must still enforce document access, identity, persistence, and
collaboration permissions.
Verify the shared action
Run the project and place the caret in /sample.docx. Insert the clause once with the button and once with the shortcut.
Confirm that both paths insert the same text and that the output identifies the trigger that ran. Move focus outside the
Editor experience and confirm that the shortcut no longer runs.
Keep custom commands focused on one user intent. Use context.insertText() for text insertion, call another command
through context.executeAsync(), or use context.doc for Document API operations. Do not reach into Editor state, view
objects, or DOM offsets.
Custom commands are optional. Return to the Custom UI overview to choose another surface for your application.