# 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 [#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](/editor/built-in-ui/links-and-context-menus) covers the first path. Continue here when the application renders the menu itself.

## Build the menu [#build-the-menu]

This example replaces the built-in menu with tracked-change decisions and a selection-aware copy action:

```html
<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>

```

```ts
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:

* `point` is where the application should place its floating surface.
* `entities` lists public entities painted under that point, such as tracked changes, comments, and content controls.
* `selection` carries 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 [#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 [#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.

> **Verification target (success)**
>
> Right-click a tracked change. Accept and Reject should be available, and either action should update the document.
> Select text and open the menu with `Shift+F10`; Copy should be available. Right-click ordinary unselected text; the
> menu may open, but actions without a supported target should remain disabled.


Continue with [Selection and viewport](/editor/custom-ui/selection-and-viewport) for public target geometry, or [Tracked changes](/editor/custom-ui/tracked-changes) for a complete review panel.
