Custom UI

Build contextual table controls

Enable application-owned table actions from the current cell and explain why an action is unavailable.

Table commands depend on the current Editor selection. Use ui.tables to read the active table context and ui.commands to run actions against it.

Complete Custom UI controller setup first. This example expects /contract.docx to contain a table.

Add the controls

Create two table actions, context output, status output, and an 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>
  <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

Read the Editor's controller, 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');

if (!tablePosition || !addRowButton || !deleteRowButton || !tableStatus) {
  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 }) => {
    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);
    };
  },
});

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 table identity from painted DOM attributes.

Verify the workflow by placing the caret in a table, adding a row, deleting a row, and exporting the DOCX. Outside the table, both buttons should disable with a contextual reason.

Use the Document API tables reference when application code already has an explicit table target or needs an operation that is not selection-driven.

On this page