# Build an application-owned context menu

> Replace SuperDoc's menu while keeping actions connected to document entities and selections.



Add application actions to the [built-in context menu](/editor/built-in-ui/context-menus) with `ui.contextMenu.sections`
when its interaction and presentation fit your product. Continue here only when your application needs to render the
entire menu.

Start from [Custom UI setup](/editor/custom-ui/controller-setup). Download the sample to `public/contract.docx`:

[Download the tracked-changes sample](/fixtures/tracked-changes.docx): Tracked insertions and deletions · DOCX


## Add the menu surface [#add-the-menu-surface]

Vanilla adds the menu beside its Editor container. Replace `index.html` with:

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

<script type="module" src="/src/main.ts"></script>

```

## Connect the menu to the document [#connect-the-menu-to-the-document]

Replace the setup guide's Editor code:

**Vanilla — `src/main.ts`**

```ts
import { SuperDoc } from 'superdoc';
import type { UIConfig } from 'superdoc';
import type { BorrowedSuperDocUI, CommandExecutionResult } from 'superdoc/ui';
import 'superdoc/style.css';

const editorUi = { contextMenu: false } satisfies UIConfig;

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 changeTarget: ChangeTarget | null = null;
let decisionPending = false;
// Each open menu gets its own id, and dismissal retires it, so a decision
// that settles late closes only the menu that started it and is still open.
let menuId = 0;
let stopSelectionObserver: (() => void) | 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) => {
  // Any dismissal retires the current menu, so an action still in flight
  // cannot close it later or move focus away from wherever the user went.
  menuId += 1;
  menu.hidden = true;
  changeTarget = null;
  stopSelectionObserver?.();
  stopSelectionObserver = null;
  if (restoreEditorFocus) superdoc.focus();
};

const syncDecisionButtons = () => {
  acceptButton.disabled = !changeTarget || decisionPending;
  rejectButton.disabled = !changeTarget || decisionPending;
};

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;

  const context = ui.contextMenu.contextAt(point);
  const trackedChange = context.entities.find((entity) => entity.type === 'trackedChange');
  changeTarget = trackedChange
    ? { id: trackedChange.id, ...(trackedChange.story === undefined ? {} : { story: trackedChange.story }) }
    : null;

  menuId += 1;
  syncDecisionButtons();
  // The selection can still be settling when the menu opens. Follow it while
  // the menu is open so Copy enables once it is ready.
  stopSelectionObserver?.();
  stopSelectionObserver = ui.selection.observe((selection) => {
    copyButton.disabled = selection.status !== 'ready' || selection.empty || selection.quotedText.length === 0;
  });

  positionMenu(point);
  const firstAvailable = menuItems.find((item) => !item.disabled);
  (firstAvailable ?? menu).focus();
};

const decideChange = async (decision: 'accept' | 'reject', success: string) => {
  if (!ui || !changeTarget || decisionPending) return;
  const startedFrom = menuId;
  decisionPending = true;
  syncDecisionButtons();
  try {
    const result =
      decision === 'accept'
        ? await ui.trackChanges.acceptAsync(changeTarget)
        : await ui.trackChanges.rejectAsync(changeTarget);
    status.textContent = describeResult(result, success);
  } finally {
    decisionPending = false;
    syncDecisionButtons();
  }
  if (menuId === startedFrom) closeMenu(true);
};

const copySelection = async () => {
  const selection = ui?.selection.getSnapshot();
  const text = selection?.status === 'ready' && !selection.empty ? selection.quotedText : '';
  if (!text) return;
  const startedFrom = menuId;

  try {
    await navigator.clipboard.writeText(text);
    status.textContent = 'Selection copied.';
  } catch {
    status.textContent = 'The browser did not allow clipboard access.';
  }
  if (menuId === startedFrom) 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('accept', 'Change accepted.');
const handleReject = () => void decideChange('reject', 'Change rejected.');
const handleCopy = () => void copySelection();

const superdoc = new SuperDoc({
  selector: editorHost,
  document: '/contract.docx',
  documentMode: 'suggesting',
  ui: editorUi,
  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();
});

```

**React — `src/App.tsx`**

```tsx
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react';
import { SuperDocEditor } from '@superdoc/react';
import type { SuperDoc, UIConfig } from 'superdoc';
import type { CommandExecutionResult } from 'superdoc/ui';
import { SuperDocUIProvider, useSetSuperDoc, useSuperDocSelection, useSuperDocUI } from 'superdoc/ui/react';
import '@superdoc/react/style.css';

const editorUi = { contextMenu: false } satisfies UIConfig;

type ChangeTarget = { id: string; story?: unknown };
type MenuState = {
  id: number;
  x: number;
  y: number;
  changeTarget: ChangeTarget | null;
};

export default function App() {
  return (
    <SuperDocUIProvider>
      <ContextMenuEditor />
    </SuperDocUIProvider>
  );
}

function ContextMenuEditor() {
  const ui = useSuperDocUI();
  // The selection can still be settling when the menu opens. Subscribing keeps
  // Copy in step with the live selection instead of the snapshot at open time.
  const selection = useSuperDocSelection();
  const setSuperDoc = useSetSuperDoc();
  const superdocRef = useRef<SuperDoc | null>(null);
  const menuRef = useRef<HTMLDivElement | null>(null);
  const menuIdRef = useRef(0);
  const [menu, setMenu] = useState<MenuState | null>(null);
  const [decisionPending, setDecisionPending] = useState(false);
  const [status, setStatus] = useState('Right-click the document or press Shift+F10.');

  const selectedText = selection.status === 'ready' && !selection.empty ? selection.quotedText : '';

  function closeMenu(restoreEditorFocus: boolean) {
    // Any dismissal retires the current menu, so an action still in flight
    // cannot close it later or move focus away from wherever the user went.
    menuIdRef.current += 1;
    setMenu(null);
    if (restoreEditorFocus) requestAnimationFrame(() => superdocRef.current?.focus());
  }

  function openMenu(point: { x: number; y: number }) {
    if (!ui) return;

    const context = ui.contextMenu.contextAt(point);
    const trackedChange = context.entities.find((entity) => entity.type === 'trackedChange');

    menuIdRef.current += 1;
    setMenu({
      id: menuIdRef.current,
      x: context.point?.x ?? point.x,
      y: context.point?.y ?? point.y,
      changeTarget: trackedChange
        ? {
            id: trackedChange.id,
            ...(trackedChange.story === undefined ? {} : { story: trackedChange.story }),
          }
        : null,
    });
  }

  useLayoutEffect(() => {
    const element = menuRef.current;
    if (!menu || !element) return;

    const bounds = element.getBoundingClientRect();
    const edge = 8;
    element.style.left = `${Math.max(edge, Math.min(menu.x, window.innerWidth - bounds.width - edge))}px`;
    element.style.top = `${Math.max(edge, Math.min(menu.y, window.innerHeight - bounds.height - edge))}px`;
    (element.querySelector<HTMLButtonElement>('button:not(:disabled)') ?? element).focus();
  }, [menu]);

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

    const handleOutsidePointer = (event: PointerEvent) => {
      if (!menuRef.current?.contains(event.target as Node)) closeMenu(false);
    };
    const handleViewportChange = () => closeMenu(false);

    document.addEventListener('pointerdown', handleOutsidePointer);
    document.addEventListener('scroll', handleViewportChange, true);
    window.addEventListener('resize', handleViewportChange);
    return () => {
      document.removeEventListener('pointerdown', handleOutsidePointer);
      document.removeEventListener('scroll', handleViewportChange, true);
      window.removeEventListener('resize', handleViewportChange);
    };
  }, [menu]);

  function report(result: CommandExecutionResult, success: string) {
    if (result === false) setStatus('That action is unavailable.');
    else if (result === true || result.success) setStatus(success);
    else setStatus(result.failure.message);
  }

  async function decideChange(decision: 'accept' | 'reject') {
    if (!ui || !menu?.changeTarget || decisionPending) return;
    // Only the menu that started this decision may close when it settles. The
    // user can dismiss it, or open another one, before the mutation lands.
    const menuId = menu.id;
    setDecisionPending(true);
    try {
      const result =
        decision === 'accept'
          ? await ui.trackChanges.acceptAsync(menu.changeTarget)
          : await ui.trackChanges.rejectAsync(menu.changeTarget);
      report(result, decision === 'accept' ? 'Change accepted.' : 'Change rejected.');
    } finally {
      setDecisionPending(false);
    }
    if (menuIdRef.current === menuId) closeMenu(true);
  }

  async function copySelection() {
    if (!selectedText) return;
    const menuId = menu?.id;
    try {
      await navigator.clipboard.writeText(selectedText);
      setStatus('Selection copied.');
    } catch {
      setStatus('The browser did not allow clipboard access.');
    }
    if (menuIdRef.current === menuId) closeMenu(true);
  }

  // SuperDocEditor renders its toolbar beside the editor container. Only
  // events from inside the editor open the document menu; the toolbar and any
  // other application chrome keep their own context behaviour.
  function isInsideEditor(target: EventTarget | null) {
    return target instanceof Element && target.closest('.superdoc-editor-container') !== null;
  }

  function handleContextMenu(event: ReactMouseEvent<HTMLElement>) {
    if (!isInsideEditor(event.target)) return;
    event.preventDefault();
    openMenu({ x: event.clientX, y: event.clientY });
  }

  function handleContextMenuKey(event: ReactKeyboardEvent<HTMLElement>) {
    const requested = event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10');
    if (!requested || !ui || !isInsideEditor(event.target)) 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 });
  }

  function handleMenuKey(event: ReactKeyboardEvent<HTMLDivElement>) {
    if (event.key === 'Escape' || event.key === 'Tab') {
      closeMenu(event.key === 'Escape');
      return;
    }
    if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;

    event.preventDefault();
    const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? []);
    const current = items.indexOf(document.activeElement as HTMLButtonElement);
    const direction = event.key === 'ArrowDown' ? 1 : -1;
    items[(current + direction + items.length) % items.length]?.focus();
  }

  return (
    <>
      <section onContextMenu={handleContextMenu} onKeyDownCapture={handleContextMenuKey}>
        <SuperDocEditor
          document='/contract.docx'
          documentMode='suggesting'
          onContentError={({ error }) => console.error('SuperDoc could not open the document.', error)}
          onException={({ error }) => console.error('SuperDoc could not open the document.', error)}
          onReady={({ superdoc }) => {
            superdocRef.current = superdoc;
            setSuperDoc(superdoc);
          }}
          ui={editorUi}
        />
      </section>

      {menu && (
        <div
          aria-label='Document actions'
          onKeyDown={handleMenuKey}
          ref={menuRef}
          role='menu'
          style={{ left: menu.x, position: 'fixed', top: menu.y, zIndex: 10 }}
          tabIndex={-1}
        >
          <button
            disabled={!menu.changeTarget || decisionPending}
            onClick={() => void decideChange('accept')}
            role='menuitem'
            type='button'
          >
            Accept change
          </button>
          <button
            disabled={!menu.changeTarget || decisionPending}
            onClick={() => void decideChange('reject')}
            role='menuitem'
            type='button'
          >
            Reject change
          </button>
          <button disabled={!selectedText} onClick={() => void copySelection()} role='menuitem' type='button'>
            Copy selected text
          </button>
        </div>
      )}

      <p aria-live='polite' role='status'>
        {status}
      </p>
    </>
  );
}

```


`ui: { contextMenu: false }` hides SuperDoc's menu. It does not disable `superdoc.ui.contextMenu`.

Pass the browser event's `clientX` and `clientY` to `ui.contextMenu.contextAt()`. Its `point` positions the menu,
`entities` identifies document objects under the pointer, and `selection` carries the current selection. The example
enables tracked-change decisions only over a tracked change and Copy only when selected text is ready.

`contextAt()` returns the current snapshot immediately. Check `selection.status === 'ready'` before using selected text;
`pending` and `stale` can carry text from an earlier selection.

## Own the complete interaction [#own-the-complete-interaction]

The browser's `contextmenu` event covers pointer input. The Context Menu key and `Shift+F10` use
`ui.selection.getAnchorRect()` to position the same surface from the keyboard.

Once the menu opens, your application owns focus, arrow keys, Escape, outside-click dismissal, and repositioning or
dismissal after scroll and resize. It also owns action feedback. Await the action result before reporting success.

`contextAt()` resolves supported entities and selection state, not an arbitrary insertion position under plain text.
Use [Selection and position](/editor/custom-ui/selection-and-viewport) when another custom surface needs document targets
or painted geometry.

## Verify the menu [#verify-the-menu]

Right-click a tracked change and accept or reject it. Select text, then press `Shift+F10` and copy it. Right-click
ordinary text without a selection and confirm that unsupported actions stay disabled.

Build a [tracked-change panel](/editor/custom-ui/tracked-changes) when reviewers also need a complete queue. Otherwise,
return to the [Custom UI overview](/editor/custom-ui/overview).
