Custom UI

Build selection-aware table controls

Enable application-owned table actions only when the current selection resolves to a table cell.

A DOCX table does not require custom UI. Build these controls only when your application needs actions outside the document that follow the active cell, such as Add row in an application toolbar. Use ui.tables to read that selection context and ui.commands to run actions against it.

If your application already has an explicit table target, use the Document API tables reference instead.

This standalone Vanilla recipe uses the project from Build your first custom control. Save this document as public/contract.docx:

Download the table sampleStatement of work with a milestone tableDOCX

Add the controls

Replace the contents of <body> in index.html with the table actions, download button, and Editor container:

<aside aria-labelledby="table-heading">
  <h2 id="table-heading">Table controls</h2>
  <p id="table-position" role="status">Place the caret in a table.</p>
  <button id="add-row" type="button" disabled>Add row below</button>
  <button id="delete-row" type="button" disabled>Delete row</button>
  <button id="download" type="button" disabled>Download DOCX</button>
  <p id="table-status" role="status"></p>
</aside>

<div id="editor" style="height: 70vh"></div>

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

Bind actions to table context

Replace src/main.ts. The example reads superdoc.ui after the Editor is ready:

import { SuperDoc } from 'superdoc';
import type { CommandExecutionResult } from 'superdoc/ui';
import 'superdoc/style.css';

const tablePosition = document.querySelector<HTMLParagraphElement>('#table-position');
const addRowButton = document.querySelector<HTMLButtonElement>('#add-row');
const deleteRowButton = document.querySelector<HTMLButtonElement>('#delete-row');
const tableStatus = document.querySelector<HTMLParagraphElement>('#table-status');
const downloadButton = document.querySelector<HTMLButtonElement>('#download');

if (!tablePosition || !addRowButton || !deleteRowButton || !tableStatus || !downloadButton) {
  throw new Error('The table controls are incomplete.');
}

let stopAddRow: (() => void) | null = null;
let stopDeleteRow: (() => void) | null = null;
let removeHandlers: (() => void) | null = null;

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
  onReady: ({ superdoc: readySuperDoc }) => {
    downloadButton.disabled = false;
    const ui = readySuperDoc.ui;
    const addRow = ui.commands.get('table-add-row-after');
    const deleteRow = ui.commands.get('table-delete-row');

    const render = () => {
      const context = ui.tables.getContext();
      const addState = addRow.getState();
      const deleteState = deleteRow.getState();

      tablePosition.textContent = context.inTable
        ? `Row ${(context.rowIndex ?? 0) + 1}, column ${(context.columnIndex ?? 0) + 1}`
        : 'Place the caret in a table.';
      addRowButton.disabled = !addState.enabled;
      deleteRowButton.disabled = !deleteState.enabled;
      tableStatus.textContent = addState.reason ?? deleteState.reason ?? '';
    };

    const report = (result: CommandExecutionResult, successMessage: string) => {
      if (result === false) {
        tableStatus.textContent = 'The table action is unavailable.';
        return;
      }
      if (typeof result === 'object' && !result.success) {
        tableStatus.textContent = result.failure.message;
        return;
      }
      tableStatus.textContent = successMessage;
    };

    const insertRow = async () => report(await addRow.executeAsync(), 'Row added.');
    const removeRow = async () => report(await deleteRow.executeAsync(), 'Row deleted.');

    stopAddRow = addRow.observe(render);
    stopDeleteRow = deleteRow.observe(render);
    addRowButton.addEventListener('click', insertRow);
    deleteRowButton.addEventListener('click', removeRow);

    removeHandlers = () => {
      addRowButton.removeEventListener('click', insertRow);
      deleteRowButton.removeEventListener('click', removeRow);
    };
  },
});

downloadButton.addEventListener('click', async () => {
  downloadButton.disabled = true;
  try {
    const file = await superdoc.export({ triggerDownload: true });
    tableStatus.textContent = file ? 'DOCX downloaded.' : 'The document could not be exported.';
  } catch (error) {
    console.error('Could not export the document.', error);
    tableStatus.textContent = 'Could not export the document. Try again.';
  } finally {
    downloadButton.disabled = false;
  }
});

window.addEventListener('beforeunload', () => {
  stopAddRow?.();
  stopDeleteRow?.();
  removeHandlers?.();
  superdoc.destroy();
});

The example does not construct table locators. The controller resolves the enclosing table, row, column, and cell from the live selection. It then builds the Document API input for each command.

Render command state, not assumptions

Each table command reports whether it is supported and enabled. Outside a table, contextual actions fail closed with table-context-unavailable. In viewing mode, mutations report document-readonly.

Use getState() for the initial render and observe() for later selection, mode, and capability changes. Still inspect the result from executeAsync(). Context can change between rendering a button and clicking it.

The routed table command family includes inserting or deleting rows and columns, deleting a table, merging cells, splitting a cell, and removing borders. Add only the controls your workflow needs. Do not render every catalog command as a toolbar.

Keep context tied to selection

ui.tables.getContext() returns the current table ID, row and column indices, cell ID, and dimensions when the host can resolve them. Treat that snapshot as presentation state. Do not cache it as a durable mutation target or derive Document API mutation targets from painted DOM attributes.

Identify painted rows for overlays

Each painted .superdoc-table-fragment exposes an opaque data-table-id. Each painted cell exposes data-row-index, the decimal, zero-based index of its logical owner row. Together with the editor/document instance, these identify a row for hover outlines and hit testing. Compare table IDs as strings; do not decode them or parse data-layout-block-ref.

The V2 browser currently exposes this painted cell surface for body tables, including nested body tables. Other stories do not yet expose the same surface. Pass the mount element belonging exclusively to one editor as editorRoot; a shared ancestor containing several editors can combine unrelated rows with equal IDs.

Resolve the painted rectangle at the pointer, then collect cells owned by its nearest table. Inspect rectangles rather than relying on elementFromPoint().closest(): decorative cells may ignore pointer events, and nested text may be painted outside its nested cell's DOM subtree. Reverse DOM order gives nested cells priority over their outer cell:

function paintedRowAtPoint(editorRoot: HTMLElement, clientX: number, clientY: number) {
  const cells = Array.from(editorRoot.querySelectorAll<HTMLElement>('[data-row-index]'));
  const cell = cells.reverse().find((candidate) => {
    const rect = candidate.getBoundingClientRect();
    return clientX >= rect.left && clientX < rect.right && clientY >= rect.top && clientY < rect.bottom;
  });
  const table = cell?.closest<HTMLElement>('[data-table-id]');
  if (!cell || !table) return;
  const pieces = cells.filter(
    (candidate) =>
      candidate.dataset.rowIndex === cell.dataset.rowIndex &&
      candidate.closest<HTMLElement>('[data-table-id]')?.dataset.tableId === table.dataset.tableId,
  );
  return {
    tableId: table.dataset.tableId,
    rowIndex: cell.dataset.rowIndex,
    rectangles: pieces.map((piece) => piece.getBoundingClientRect()),
  };
}

One row can have several painted pieces across pages. Split rows and repeated headers keep their original row index. A vertically merged cell, including its page continuation, belongs to its starting row throughout its rectangle; a horizontal span remains one cell. Nested tables have their own table IDs, so use the nearest enclosing table when grouping cells. There is no required row wrapper. These markers preserve the separate accessibility table and focus behavior. Call this lookup for points within the editor's visible viewport; keep host overlays pointer-events: none so they preserve editor interaction.

The table ID is scoped to one mounted editor/document and story. Row mapping survives scrolling, page unmount/remount, repaint, repagination, and text or formatting edits that preserve table structure and source ownership. DOM elements can be replaced on any paint; only mounted page content has markers. Reacquire markers after structural edits, including inserting or removing blocks before a table, table/row insertion, deletion, reorder, merge or split, document replacement, save/reopen, or Word round trips. IDs are not synchronization identifiers across peers.

data-table-id is an overlay identifier, not a Document API table.nodeId or mutation locator. An optional data-source-node-id does not change that contract. Use ui.tables or Document API discovery for commands. Durable table addresses across structural edits and reopen are tracked separately in SD-5000; these markers do not provide durable row addresses.

Verify the controls

Place the caret in the milestone table, add a row, delete a row, and choose Download DOCX. Outside the table, both row buttons should disable with a contextual reason.

Use the Document API tables reference for explicit targets or operations that are not selection-driven. Return to the Custom UI overview to choose another workflow.

On this page