Application-owned context menus
Replace the built-in menu with document-aware actions over public SuperDoc UI context.
Use an application-owned context menu when your product needs to control the entire menu surface. SuperDoc keeps rendering and editing the DOCX. Your code decides which actions make sense for the entity or selection under the pointer.
Choose how much of the menu to own
Start with the smaller integration:
| You need | Use |
|---|---|
| Add application actions to SuperDoc's menu | ui.contextMenu.customItems in the SuperDoc configuration |
| Replace the complete menu surface | ui: { contextMenu: false } and superdoc.ui.viewport.contextAt() |
The built-in links and context-menu guide covers the first path. Continue here when the application renders the menu itself.
Build the menu
This example replaces the built-in menu with tracked-change decisions and a selection-aware copy action:
<div id="editor"></div>
<div id="document-menu" aria-label="Document actions" hidden role="menu" tabindex="-1">
<button data-menu-action="accept" role="menuitem" type="button">Accept change</button>
<button data-menu-action="reject" role="menuitem" type="button">Reject change</button>
<button data-menu-action="copy" role="menuitem" type="button">Copy selected text</button>
</div>
<p id="menu-status" aria-live="polite">Right-click the document or press Shift+F10.</p>
import { SuperDoc } from 'superdoc';
import type { BorrowedSuperDocUI, CommandExecutionResult, ViewportContext } from 'superdoc/ui';
import 'superdoc/style.css';
const editorHost = document.querySelector<HTMLElement>('#editor');
const menu = document.querySelector<HTMLElement>('#document-menu');
const acceptButton = document.querySelector<HTMLButtonElement>('[data-menu-action="accept"]');
const rejectButton = document.querySelector<HTMLButtonElement>('[data-menu-action="reject"]');
const copyButton = document.querySelector<HTMLButtonElement>('[data-menu-action="copy"]');
const status = document.querySelector<HTMLParagraphElement>('#menu-status');
if (!editorHost || !menu || !acceptButton || !rejectButton || !copyButton || !status) {
throw new Error('The custom context-menu controls are incomplete.');
}
type ChangeTarget = { id: string; story?: unknown };
let ui: BorrowedSuperDocUI | null = null;
let context: ViewportContext | null = null;
let changeTarget: ChangeTarget | null = null;
const menuItems = [acceptButton, rejectButton, copyButton];
const describeResult = (result: CommandExecutionResult, success: string): string => {
if (result === false) return 'That action is unavailable.';
if (typeof result === 'object' && !result.success) return result.failure.message;
return success;
};
const closeMenu = (restoreEditorFocus: boolean) => {
menu.hidden = true;
context = null;
changeTarget = null;
if (restoreEditorFocus) superdoc.focus();
};
const positionMenu = ({ x, y }: { x: number; y: number }) => {
menu.style.position = 'fixed';
menu.style.left = '0px';
menu.style.top = '0px';
menu.style.zIndex = '10';
menu.hidden = false;
const bounds = menu.getBoundingClientRect();
const edge = 8;
menu.style.left = `${Math.max(edge, Math.min(x, window.innerWidth - bounds.width - edge))}px`;
menu.style.top = `${Math.max(edge, Math.min(y, window.innerHeight - bounds.height - edge))}px`;
};
const openMenu = (point: { x: number; y: number }) => {
if (!ui) return;
context = ui.viewport.contextAt(point);
const trackedChange = context.entities.find((entity) => entity.type === 'trackedChange');
changeTarget = trackedChange
? { id: trackedChange.id, ...(trackedChange.story === undefined ? {} : { story: trackedChange.story }) }
: null;
acceptButton.disabled = !changeTarget;
rejectButton.disabled = !changeTarget;
// `contextAt()` returns the best-known selection immediately. While a re-read
// is in flight the slice reports `pending` or `stale` and still carries the
// previous range, so enabling Copy on `quotedText` alone would let a quick
// right-click after a selection change copy the old text. Require `ready`.
copyButton.disabled =
context.selection.status !== 'ready' || context.selection.empty || context.selection.quotedText.length === 0;
positionMenu(point);
const firstAvailable = menuItems.find((item) => !item.disabled);
(firstAvailable ?? menu).focus();
};
const decideChange = async (command: 'acceptChange' | 'rejectChange', success: string) => {
if (!ui || !changeTarget) return;
const result = await ui.commands.executeAsync(command, changeTarget);
status.textContent = describeResult(result, success);
closeMenu(true);
};
const copySelection = async () => {
const text = context?.selection.quotedText;
if (!text) return;
try {
await navigator.clipboard.writeText(text);
status.textContent = 'Selection copied.';
} catch {
status.textContent = 'The browser did not allow clipboard access.';
}
closeMenu(true);
};
const handleContextMenu = (event: MouseEvent) => {
event.preventDefault();
openMenu({ x: event.clientX, y: event.clientY });
};
const handleContextMenuKey = (event: KeyboardEvent) => {
const requested = event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10');
if (!requested || !ui) return;
const anchor = ui.selection.getAnchorRect({ placement: 'center' });
if (!anchor) return;
event.preventDefault();
openMenu({ x: (anchor.left + anchor.right) / 2, y: (anchor.top + anchor.bottom) / 2 });
};
const handleMenuKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' || event.key === 'Tab') {
closeMenu(event.key === 'Escape');
return;
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
const available = menuItems.filter((item) => !item.disabled);
const current = available.indexOf(document.activeElement as HTMLButtonElement);
const direction = event.key === 'ArrowDown' ? 1 : -1;
available[(current + direction + available.length) % available.length]?.focus();
};
const handleOutsidePointer = (event: PointerEvent) => {
if (!menu.hidden && !menu.contains(event.target as Node)) closeMenu(false);
};
const handleViewportChange = () => {
if (!menu.hidden) closeMenu(false);
};
const handleAccept = () => void decideChange('acceptChange', 'Change accepted.');
const handleReject = () => void decideChange('rejectChange', 'Change rejected.');
const handleCopy = () => void copySelection();
const superdoc = new SuperDoc({
selector: editorHost,
document: '/contract.docx',
documentMode: 'suggesting',
ui: { contextMenu: false },
onReady: ({ superdoc: readySuperDoc }) => {
ui = readySuperDoc.ui;
},
});
acceptButton.addEventListener('click', handleAccept);
rejectButton.addEventListener('click', handleReject);
copyButton.addEventListener('click', handleCopy);
editorHost.addEventListener('contextmenu', handleContextMenu);
editorHost.addEventListener('keydown', handleContextMenuKey, true);
menu.addEventListener('keydown', handleMenuKey);
document.addEventListener('pointerdown', handleOutsidePointer);
document.addEventListener('scroll', handleViewportChange, true);
window.addEventListener('resize', handleViewportChange);
window.addEventListener('beforeunload', () => {
acceptButton.removeEventListener('click', handleAccept);
rejectButton.removeEventListener('click', handleReject);
copyButton.removeEventListener('click', handleCopy);
editorHost.removeEventListener('contextmenu', handleContextMenu);
editorHost.removeEventListener('keydown', handleContextMenuKey, true);
menu.removeEventListener('keydown', handleMenuKey);
document.removeEventListener('pointerdown', handleOutsidePointer);
document.removeEventListener('scroll', handleViewportChange, true);
window.removeEventListener('resize', handleViewportChange);
superdoc.destroy();
});
The contextmenu handler passes clientX and clientY directly to contextAt(). The returned context keeps three different concerns separate:
pointis where the application should place its floating surface.entitieslists public entities painted under that point, such as tracked changes, comments, and content controls.selectioncarries the current public selection state and selected text.
The example enables Accept and Reject only when entities contains a tracked change. It enables Copy only when the selection carries text and selection.status is ready. A right-click over ordinary text can therefore open the menu with unavailable actions. That is a valid context, not a failed lookup.
contextAt() never blocks: it returns the best-known selection immediately, and status reports whether that read has settled. pending means the first read is still in flight, stale means a re-read is running after a selection or document change, and both still carry the previous range. Acting on a stale slice is how a menu opened straight after a selection change copies the text the user selected before. Gate any action that consumes selected text on ready, or observe the slice until the new read settles.
Keep pointer and keyboard paths equivalent
Pointer users open the menu with the browser's contextmenu event. Keyboard users use the Context Menu key or Shift+F10; the example places that menu at the current selection anchor from ui.selection.getAnchorRect().
Once open, the application owns focus, arrow-key movement, Escape, outside clicks, scrolling, and resize dismissal. It also owns action feedback. Await command execution and report false or an unsuccessful receipt instead of announcing that the document changed.
Do not reconstruct a document position
contextAt() resolves entities and selection state. It does not resolve an arbitrary insertion point under plain text. context.position is currently null in v2.
If a v1 menu used editor.view.posAtCoords() only to identify comments, tracked changes, or content controls, use contextAt().entities. If it used a numeric position for text insertion or position arithmetic, there is no direct v2 equivalent. Rebuild the workflow around a public selection or Document API target instead of reading renderer internals.
Continue with Selection and viewport for public target geometry, or Tracked changes for a complete review panel.