Custom UI

Preserve selections and position UI

Keep a document selection after application controls take focus and anchor custom UI to painted document geometry.

Custom composers, floating toolbars, and review panels often move focus away from the Editor. Use ui.selection to preserve the document target and ui.viewport to position application UI without reading the rendered DOM.

Complete Custom UI controller setup first. This guide uses the same controller lifecycle.

Add the application surface

Place the overlay inside a positioned shell around the Editor. The textarea makes the focus change easy to verify.

<div id="editor-shell" style="position: relative">
  <div id="selection-overlay" hidden style="position: absolute; z-index: 2">
    <span id="selection-preview"></span>
    <button id="restore-selection" type="button">Restore selection</button>
  </div>
  <div id="editor" style="height: 70vh"></div>
</div>

<label>
  Review note
  <textarea id="review-note" placeholder="Move focus here after selecting document text"></textarea>
</label>
<p id="selection-status" role="status">Select text in the document.</p>

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

Copy the tracked-changes fixture to your app's public directory as contract.docx, or use another DOCX.

Capture a document selection

Observe the selection after the Editor is ready. When it becomes non-empty, capture() freezes its document targets, selected text, active marks, and related comment or tracked-change IDs.

import { SuperDoc } from 'superdoc';
import type { SelectionCapture, SelectionSlice } from 'superdoc/ui';
import 'superdoc/style.css';

const editorShell = document.querySelector<HTMLDivElement>('#editor-shell');
const overlay = document.querySelector<HTMLDivElement>('#selection-overlay');
const preview = document.querySelector<HTMLSpanElement>('#selection-preview');
const restoreButton = document.querySelector<HTMLButtonElement>('#restore-selection');
const status = document.querySelector<HTMLParagraphElement>('#selection-status');

if (!editorShell || !overlay || !preview || !restoreButton || !status) {
  throw new Error('The selection UI is incomplete.');
}

let capture: SelectionCapture | null = null;
let stopSelection: (() => void) | null = null;
let stopViewport: (() => void) | null = null;
let removeRestoreHandler: (() => void) | null = null;

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
  onReady: ({ superdoc: readySuperDoc }) => {
    const ui = readySuperDoc.ui;

    const positionOverlay = () => {
      const target = capture?.selectionTarget ?? capture?.target;
      if (!target) {
        overlay.hidden = true;
        return;
      }

      const geometry = ui.viewport.getRect({ target, relativeTo: editorShell });
      if (!geometry.found || !geometry.rect) {
        overlay.hidden = true;
        status.textContent = geometry.reason ?? 'The selection is not currently painted.';
        return;
      }

      overlay.hidden = false;
      const overlayHeight = overlay.offsetHeight;
      overlay.style.left = `${geometry.rect.left}px`;
      overlay.style.top = `${Math.max(0, geometry.rect.top - overlayHeight - 8)}px`;
    };

    const renderSelection = (selection: SelectionSlice) => {
      if (selection.empty) return;

      capture = ui.selection.capture();
      if (!capture) return;

      preview.textContent = capture.quotedText;
      status.textContent = `Captured “${capture.quotedText}”. Move focus, then restore it.`;
      positionOverlay();
    };

    const restoreSelection = () => {
      if (!capture) return;

      const result = ui.selection.restore(capture);
      status.textContent = result.success ? 'Selection restored.' : `Restore failed: ${result.reason ?? 'unknown'}`;
    };

    renderSelection(ui.selection.getSnapshot());
    stopSelection = ui.selection.observe(renderSelection);
    stopViewport = ui.viewport.observe(positionOverlay);
    restoreButton.addEventListener('click', restoreSelection);
    removeRestoreHandler = () => restoreButton.removeEventListener('click', restoreSelection);
  },
});

window.addEventListener('beforeunload', () => {
  stopSelection?.();
  stopViewport?.();
  removeRestoreHandler?.();
  superdoc.destroy();
});

The capture is not a DOM range. It contains document addresses that can survive focus changes and layout updates.

The example preserves the latest non-empty selection automatically. A contextual action can instead call capture() when the reader opens a menu or starts a specific workflow.

Position from painted geometry

ui.viewport.getRect() resolves the captured target against the current painted layout. Passing relativeTo: editorShell returns coordinates for the overlay's containing block.

Geometry can change when the Editor scrolls, zooms, resizes, paginates, or mounts a virtualized page. ui.viewport.observe() tells the application to measure again.

Do not cache rectangle coordinates as document identity. Keep the captured target and resolve fresh geometry when the viewport changes.

Resolve entities under a point

ui.viewport.entityAt() answers the opposite question: which document entities are painted under a screen point. Coordinates are MouseEvent clientX and clientY space, so a pointer handler can pass its own event through:

editorShell.addEventListener('pointerdown', (event) => {
  const hits = ui.viewport.entityAt({ x: event.clientX, y: event.clientY });
  const control = hits.find((hit) => hit.type === 'contentControl');
  if (control) console.info('content control under pointer', control.id);
});

It returns a ViewportEntityHit[] ordered innermost first. Every hit carries a type and an id.

The hits are tracked changes, comments, and content controls. A point over ordinary text carries none of those, so an empty array is the normal answer, not an error. Branch on what you find; do not treat [] as a failed lookup.

story is not a general field on a hit. Only tracked-change hits carry it, and only when the change is painted outside the body, in a footnote, endnote, header, footer, or textbox. It exists because one tracked-change id can repeat across stories, so story names the occurrence actually under the point. Comment and content-control hits are returned normally in those stories but carry only type and id, so do not write story-sensitive handling for them. Use ui.trackChanges.getAt() when you need the full tracked-change row rather than the hit.

Pass the object form. The legacy positional form entityAt(x, y) fails closed and returns null, because the addresses it produced are not resolvable by getRect().

Coming from editor.view coordinates

posAtCoords() and coordsAtPos() are inverse operations, and v2 answers them on different surfaces.

Screen point to content is entityAt(), above. It is a partial replacement: it resolves the entities painted under a point, not an arbitrary document position. v2 ships no general point-to-position resolver, so a v1 caller that mapped a click to a numeric offset and then did arithmetic on it has no equivalent, and the calculation needs rebuilding around targets.

Content to screen rectangle is getRect(), above. It takes a public target rather than a numeric position, so resolve one from capture() or a query result first.

Restore and handle failure

ui.selection.restore() reapplies the captured target through the public host selection controller. It returns success: false with a stable reason when the Editor is not ready, the target cannot be resolved, the document is read-only, or the host cannot apply selections.

Restoring a selection is optional. Comment creation and other target-based workflows can operate directly on the capture without moving browser focus back to the Editor.

Apply a selection target directly

ui.selection.apply() moves the document selection to a SelectionTarget you already hold, without going through a capture. Use it when the target came from the live selection or from a Document API query rather than from this page's capture workflow.

const info = ui.selection.current();
if (info?.selectionTarget) {
  const result = ui.selection.apply(info.selectionTarget);
  if (!result.ok) console.error(`Could not select: ${result.reason}`);
}

Read selectionTarget, not target. target is a TextTarget describing text segments; selectionTarget is the selection envelope that write APIs and apply() consume.

apply() returns { ok, reason? } and never throws. It fails closed with the same stable reasons as restore() when the Editor is not ready, the target cannot be resolved, the document is read-only, or the host cannot apply selections. restore() is the capture-based form of the same operation.

Coming from v1, this replaces editor.view.dispatch(tr.setSelection(...)). Pass a target, not a transaction. There is no ProseMirror position to hand it.

Verify the interaction

  1. Select text in the document.
  2. Confirm that the overlay shows the selected text near its painted range.
  3. Type in the review-note textarea.
  4. Scroll or resize the Editor and confirm that the overlay follows the target.
  5. Choose Restore selection and confirm that the document selection returns.

Continue with Build a custom comments UI to use the same capture for an anchored comment.

On this page