Custom UI

Build durable review highlights

Anchor application-owned review data to document text and paint the current ranges through a render-only extension.

Build a durable review highlight from two public v2 surfaces. Anchored metadata stores the finding's identity and payload in the DOCX. An extension visual paints the finding while the document is open.

Use this pattern for verification results, risk markers, provenance, and other application-owned review data. Use comments when the document should contain a Word comment thread instead.

Keep storage and paint separate

Neither surface replaces the other:

ResponsibilityPublic surfaceSurvives export
Finding ID, namespace, and JSON payloaddoc.metadataYes
Current anchored rangedoc.metadata.resolve({ id })Recomputed from the stored anchor
Visible class over the rangectx.visuals.highlight()No

A metadata entry intentionally renders nothing. A visual layer intentionally writes nothing to the DOCX. Combining them gives the application durable identity without encoding interface styling into the document.

Build the workflow

Create controls for attaching and withdrawing one finding:

<div id="editor"></div>

<div aria-label="Review findings" role="group">
  <button id="attach-finding" disabled type="button">Attach finding</button>
  <button id="withdraw-finding" disabled type="button">Withdraw finding</button>
</div>

<p id="review-status" aria-live="polite">Select text in one paragraph.</p>
<ul id="finding-list" aria-label="Stored findings"></ul>

Keep the highlight class paint-only. Layout properties such as padding, margin, display, position, and font size are rejected because a visual must not change document geometry.

.review-finding-highlight {
  background: #fff1a8;
  box-shadow: inset 0 -1px #b78103;
}

The complete example uses a caller-owned ID. It reads the current selection, attaches metadata, resolves every current anchor, and replaces the visual layer's complete target set:

import { defineSuperDocExtension, SuperDoc } from 'superdoc';
import type { SuperDocVisualTarget } from 'superdoc';
import type { SelectionTarget } from 'superdoc/ui';
import 'superdoc/style.css';
import './review-highlights.css';

const editorHost = document.querySelector<HTMLElement>('#editor');
const attachButton = document.querySelector<HTMLButtonElement>('#attach-finding');
const withdrawButton = document.querySelector<HTMLButtonElement>('#withdraw-finding');
const status = document.querySelector<HTMLParagraphElement>('#review-status');
const findingList = document.querySelector<HTMLUListElement>('#finding-list');

if (!editorHost || !attachButton || !withdrawButton || !status || !findingList) {
  throw new Error('The review-highlight controls are incomplete.');
}

const NAMESPACE = 'urn:example:review-findings:1';
const FINDING_ID = 'finding-1';

type VisualStore = {
  read: () => readonly SuperDocVisualTarget[];
  replace: (targets: readonly SuperDocVisualTarget[]) => void;
  subscribe: (listener: () => void) => () => void;
};

const createVisualStore = (): VisualStore => {
  let targets: readonly SuperDocVisualTarget[] = [];
  const listeners = new Set<() => void>();

  return {
    read: () => targets,
    replace(nextTargets) {
      targets = nextTargets;
      for (const listener of listeners) listener();
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
};

const visualStore = createVisualStore();
let superdoc: SuperDoc | null = null;
let stopSelection: (() => void) | null = null;
let refreshSequence = 0;
// Bumped whenever the extension activates against a new source. A mutation
// captures it before its first await and re-checks before writing: the facade
// it holds follows the host into the replacement document, so a continuation
// that resumes after a swap would otherwise write the previous document's
// intent into the new one.
let sourceGeneration = 0;

// Tracks whether this namespace currently owns the finding, so a pending write
// can restore Withdraw without a refresh. `null` means the last refresh failed
// and the document's metadata state is unknown.
let findingOwnedHere = false;

const renderRows = (rows: Array<{ id: string; anchorStatus: string }>, idTaken: boolean | null) => {
  findingList.replaceChildren();
  for (const row of rows) {
    const item = document.createElement('li');
    item.textContent = `${row.id} · ${row.anchorStatus}`;
    findingList.append(item);
  }
  // Withdraw follows the rows this namespace owns. Attach follows whether the
  // id is taken anywhere in the document, which is the rule `attach()` applies.
  // Both come from the listed metadata rather than from the last click, so an
  // opened DOCX that already carries the finding starts in the correct state.
  //
  // A null `idTaken` means the refresh failed. Neither control can be trusted
  // then, so both fail closed until a complete refresh succeeds: reporting "no
  // rows, id free" would enable Attach for an id that may already exist.
  findingAttached = idTaken !== false;
  findingOwnedHere = idTaken === null ? false : rows.some((row) => row.id === FINDING_ID);
  withdrawButton.disabled = mutationPending || !findingOwnedHere;
  updateAttachButton();
};

const refreshHighlights = async () => {
  const sequence = ++refreshSequence;
  const doc = superdoc?.activeEditor?.doc;
  if (!doc) {
    visualStore.replace([]);
    // No document, so no metadata state to report. Unknown rather than empty:
    // attaching is impossible here anyway, and claiming the id is free would be
    // a statement about a document that is not open.
    renderRows([], null);
    return;
  }

  try {
    // Rows and highlights stay scoped to this namespace, but the id check does
    // not: `attach()` rejects an id that exists under any namespace, so gating
    // on the filtered list alone would leave Attach enabled for an id another
    // namespace already holds.
    const [listed, everything] = await Promise.all([
      doc.metadata.list({ namespace: NAMESPACE }),
      doc.metadata.list(),
    ]);
    const rows = await Promise.all(
      listed.items.map(async (item) => ({
        id: item.id,
        anchorStatus: item.anchorStatus,
        resolved: await doc.metadata.resolve({ id: item.id }),
      })),
    );
    if (sequence !== refreshSequence) return;

    visualStore.replace(
      rows.flatMap((row) => {
        if (row.resolved === null) return [];
        // Map to the visual layer's own address shape rather than handing it a
        // Document API `SelectionTarget`. The two are structurally similar but
        // not interchangeable, and the paint path reads a single block range.
        //
        // `SelectionPoint` is a union, so narrow to its text variant: a
        // node-edge endpoint carries no offset and cannot be painted.
        const { start, end } = row.resolved.target;
        if (start.kind !== 'text' || end.kind !== 'text') return [];
        return [{ kind: 'text' as const, blockId: start.blockId, range: { start: start.offset, end: end.offset } }];
      }),
    );
    renderRows(rows, everything.items.some((item) => item.id === FINDING_ID));
  } catch (error) {
    if (sequence !== refreshSequence) return;
    visualStore.replace([]);
    // The refresh failed, so the document's metadata state is unknown. Fail
    // closed rather than reporting an empty document with a free id.
    renderRows([], null);
    status.textContent = error instanceof Error ? error.message : String(error);
  }
};

const reviewHighlightExtension = defineSuperDocExtension({
  id: 'example.reviewHighlights',
  activate(ctx) {
    const layer = ctx.visuals.highlight('findings', {
      className: 'review-finding-highlight',
      scope: 'text',
    });
    ctx.disposables.add(layer);

    // Targets in the store were resolved against whichever document was open
    // when they were stored. This activation may be for a different one, and a
    // block id from the previous document can collide with a block in the new
    // one, which would paint a finding over unrelated text.
    //
    // A refresh started against a previous document can still be awaiting its
    // list or resolve calls. Bumping the sequence retires those in-flight
    // passes: they hold the old document facade, and their results would
    // otherwise land after this clear and repaint targets from that document.
    //
    // Clear the panel and controls too, not just the paint. The refresh below
    // is asynchronous and can fail, and until it lands the rows would describe
    // the previous document while Attach and Withdraw acted on its state.
    //
    // Publish the unknown state, not an empty one: at this point the document
    // may already carry the finding, and the refresh that would tell us has not
    // run. `false` would claim the id is free and let a selection made during
    // that read enable Attach for an id that already exists.
    refreshSequence += 1;
    sourceGeneration += 1;
    visualStore.replace([]);
    layer.replace([]);
    renderRows([], null);

    const paint = () => layer.replace(visualStore.read());
    const stopVisualStore = visualStore.subscribe(paint);

    // Every refresh re-resolves every stored finding, so a keystroke burst
    // would run that O(N) walk once per mutation. Coalesce to one refresh per
    // frame, and hold the frame open until the refresh settles: clearing it
    // when the callback fires would start a fresh list + N resolves every frame
    // while the previous one is still in flight. `refreshSequence` discards
    // those stale results but never cancels their requests, so they would pile
    // up into exactly the load this coalescing exists to avoid.
    let refreshHandle: number | null = null;
    let refreshInFlight = false;
    let refreshQueued = false;

    const runRefresh = async () => {
      refreshInFlight = true;
      try {
        await refreshHighlights();
      } finally {
        refreshInFlight = false;
        if (refreshQueued) {
          refreshQueued = false;
          scheduleRefresh();
        }
      }
    };

    function scheduleRefresh() {
      // At most one queued follow-up: mutations arriving during a refresh only
      // need one more pass once it lands, not one per mutation.
      if (refreshInFlight) {
        refreshQueued = true;
        return;
      }
      if (refreshHandle !== null) return;
      refreshHandle = requestAnimationFrame(() => {
        refreshHandle = null;
        void runRefresh();
      });
    }

    const cancelRefresh = () => {
      if (refreshHandle !== null) cancelAnimationFrame(refreshHandle);
      refreshHandle = null;
      refreshQueued = false;
    };

    return [
      { dispose: stopVisualStore },
      { dispose: cancelRefresh },
      // Resolve against this document rather than repainting the cleared store.
      // If source completion never arrives, nothing stale is painted meanwhile.
      ctx.onReady(() => void refreshHighlights()),
      ctx.onSourceComplete(() => void refreshHighlights()),
      ctx.onMutation({ affects: ['text', 'block'] }, () => {
        layer.invalidate();
        scheduleRefresh();
      }),
    ];
  },
});

// `attach()` accepts only a non-empty text range inside one body paragraph.
// nodeEdge endpoints and cross-paragraph spans cannot be represented as the
// hidden inline SDT that carries the anchor, and the adapter resolves the
// paragraph against document.xml, so a header, footer, note, or textbox block
// id is not found and the call fails with TARGET_NOT_FOUND. `capture()` still
// returns a `selectionTarget` in every one of those cases, so enabling on its
// presence alone offers an action that predictably fails.
const isAttachableTarget = (target: SelectionTarget | null | undefined): target is SelectionTarget => {
  if (!target) return false;
  if (target.start.kind !== 'text' || target.end.kind !== 'text') return false;
  if (target.start.blockId !== target.end.blockId) return false;
  if (target.start.offset === target.end.offset) return false;
  // A selection crossing deletion-side tracked text carries tracked-space
  // offsets. `attach()` resolves those endpoints against tracked text, then
  // the anchor rewrite searches visible `<w:t>` runs only, so the same numbers
  // mean different positions: the call either fails with TARGET_NOT_FOUND or
  // wraps a different visible range and stores a durable finding on the wrong
  // text. Reject until anchoring translates coordinate spaces.
  if (target.coordinateSpace === 'tracked') return false;
  // Body is the default, so an omitted story is body. Any named story other
  // than body is outside what the adapter can anchor today.
  const story = target.story ?? target.start.story;
  return story === undefined || story.storyType === 'body';
};

// The example manages one fixed finding id, and metadata ids are unique
// document-wide: `attach()` throws INVALID_INPUT when the id is already
// anchored, including when the opened DOCX already carried it. Attaching is
// therefore only available while that id is absent.
let findingAttached = false;

// Both mutations are async against a worker-backed Document API. Without a
// shared pending flag, a second click before the first settles launches a
// concurrent write with the same fixed id: one succeeds, the other reports
// INVALID_INPUT, and the status ends up claiming failure for a finding that
// does exist. Hold both controls disabled until the write and its refresh
// settle, then let the refreshed metadata decide their real state.
let mutationPending = false;

const setMutationPending = (pending: boolean) => {
  mutationPending = pending;
  updateAttachButton();
  // Withdraw is otherwise owned by renderRows(). Restore it from the last known
  // rows when pending clears, rather than only forcing it on: a failed write
  // does not refresh, so leaving it disabled would strand the control while its
  // finding row is still on screen.
  withdrawButton.disabled = pending || !findingOwnedHere;
};

// A capture is only trustworthy once its read has settled. While a re-read is
// in flight the slice reports `pending` or `stale` and still carries the
// PREVIOUS range, so acting on it would anchor the finding to text the user no
// longer has selected.
const readReadyTarget = (): SelectionTarget | null => {
  const capture = superdoc?.ui.selection.capture();
  if (!capture || capture.status !== 'ready') return null;
  return capture.selectionTarget ?? null;
};

const updateAttachButton = () => {
  attachButton.disabled = mutationPending || findingAttached || !isAttachableTarget(readReadyTarget());
};

const attachFinding = async () => {
  if (mutationPending) return;
  const generation = sourceGeneration;
  const doc = superdoc?.activeEditor?.doc;
  // Re-read at click time, not from the state that enabled the button: the
  // selection can have moved since, and a mid-flight read must not be used.
  const target = readReadyTarget();
  if (!doc || !isAttachableTarget(target)) return;

  setMutationPending(true);
  try {
    // `attach()` also rejects a range overlapping another entry's anchor, and
    // an anchor is invisible in the document, so a reader cannot see why a
    // selection is unavailable. The button state cannot cover this: it depends
    // on the selection and needs an async read. Preflight instead, and say
    // which entry is in the way rather than surfacing a bare INVALID_TARGET.
    const overlapping = await doc.metadata.list({ within: target });
    // The document may have been replaced while that read was in flight. The
    // target came from the previous source, and a block id can collide in the
    // new one, so a resumed continuation would anchor to unrelated text.
    //
    // This covers the swap; `expectedRevision` below covers an edit to the
    // same document. Neither covers a swap landing during the write's own
    // await, because the replacement carries its own revision sequence.
    if (generation !== sourceGeneration) return;
    const blocking = overlapping.items.find((item) => item.id !== FINDING_ID);
    if (blocking) {
      status.textContent = `That range already carries metadata (${blocking.id}). Select text outside it.`;
      return;
    }

    // Guard the write on the revision the preflight read evaluated. Without it
    // an edit landing between the two applies this target to a document that
    // has moved on. The generation check above covers a document swap; this
    // covers an edit to the same document.
    const result = await doc.metadata.attach(
      {
        id: FINDING_ID,
        namespace: NAMESPACE,
        target,
        payload: {
          kind: 'verification',
          summary: 'Check this statement against the source material.',
        },
      },
      { expectedRevision: overlapping.evaluatedRevision },
    );
    if (!result.success) {
      status.textContent = result.failure.message;
      return;
    }

    status.textContent = `Attached ${result.id}.`;
    await refreshHighlights();
    superdoc?.focus();
  } catch (error) {
    status.textContent = error instanceof Error ? error.message : String(error);
  } finally {
    setMutationPending(false);
  }
};

const withdrawFinding = async () => {
  if (mutationPending) return;
  const generation = sourceGeneration;
  const doc = superdoc?.activeEditor?.doc;
  if (!doc) return;

  setMutationPending(true);
  try {
    // `remove()` identifies its target by id alone, and the id space is
    // document-wide. If the entry this example owned was withdrawn elsewhere
    // and the id reused under another namespace, removing by id would delete
    // that other application's metadata and anchor. The button state comes
    // from the last refresh and remote changes are not observed, so re-read
    // ownership here and pass the revision that read evaluated to the write:
    // an edit landing in between fails the guard rather than removing a record
    // this check never saw.
    const current = await doc.metadata.list({ namespace: NAMESPACE });
    if (generation !== sourceGeneration) return;
    if (!current.items.some((item) => item.id === FINDING_ID)) {
      status.textContent = `${FINDING_ID} is no longer owned by ${NAMESPACE}. Refreshing instead of removing it.`;
      await refreshHighlights();
      return;
    }

    const result = await doc.metadata.remove(
      { id: FINDING_ID },
      { expectedRevision: current.evaluatedRevision },
    );
    if (!result.success) {
      status.textContent = result.failure.message;
      return;
    }

    status.textContent = `Withdrew ${result.id}.`;
    await refreshHighlights();
    superdoc?.focus();
  } catch (error) {
    status.textContent = error instanceof Error ? error.message : String(error);
  } finally {
    setMutationPending(false);
  }
};

superdoc = new SuperDoc({
  selector: editorHost,
  document: '/contract.docx',
  extensions: [reviewHighlightExtension],
  onReady: ({ superdoc: readySuperDoc }) => {
    superdoc = readySuperDoc;
    stopSelection = readySuperDoc.ui.selection.observe(updateAttachButton);
    updateAttachButton();
    void refreshHighlights();
  },
});

attachButton.addEventListener('click', attachFinding);
withdrawButton.addEventListener('click', withdrawFinding);

window.addEventListener('beforeunload', () => {
  attachButton.removeEventListener('click', attachFinding);
  withdrawButton.removeEventListener('click', withdrawFinding);
  stopSelection?.();
  superdoc?.destroy();
});

Copy a DOCX to your app's public directory as contract.docx. Select text in one paragraph before choosing Attach finding.

Refresh from current anchors

Do not keep the selection target used by metadata.attach(). That target is a snapshot from attachment time. Document edits can move the anchor.

The example calls metadata.resolve({ id }) whenever it rebuilds the layer. An unresolved entry remains durable metadata but contributes no visual target. Your interface can report that entry as orphaned instead of highlighting stale coordinates.

Use replace(), not add(), when your array is the complete set of findings. replace() removes visuals for withdrawn or orphaned findings. add() would leave those old targets painted.

Repaint at each lifecycle boundary

The example refreshes after five events:

  1. A successful metadata attachment.
  2. A successful withdrawal.
  3. A document mutation that may move an anchor.
  4. A new document source completing inside the same SuperDoc instance.
  5. Extension activation, resolving against whichever document is now open.

Metadata attachment is not a text mutation, so an onMutation() listener alone does not repaint a newly attached finding. Refresh explicitly after the receipt succeeds.

Each refresh re-resolves every stored finding, so the example coalesces mutation-driven refreshes into one per animation frame rather than running that walk on every keystroke. It also filters to affects: ['text', 'block'], since comment and tracked-change mutations do not move a metadata anchor.

Hold that frame open until the refresh settles. On a document with enough findings for one pass to outlast a frame, releasing it when the callback fires would start a fresh metadata.list() and its N metadata.resolve() calls every frame while the previous pass is still running. A sequence guard discards the stale results but never cancels the requests, so they accumulate into the load the coalescing exists to prevent. The example keeps an in-flight marker and queues at most one follow-up, so mutations arriving mid-refresh cost one more pass rather than one per mutation.

Clear the store and the layer when the extension activates. Targets held from an earlier document were resolved against that document's blocks, and a block id can collide with a different block in the replacement, which paints a finding over unrelated text. The example drops them on activation and resolves again from the current source, so a source completion that never arrives leaves nothing painted rather than leaving stale highlights indefinitely.

The extension owns the visual handle and its subscriptions. Destroying SuperDoc disposes the visual layer. If your application replaces the complete SuperDoc instance, create the extension with the replacement and refresh after its document is ready.

Handle failures without inventing positions

Anchored metadata currently accepts non-empty text ranges within one body paragraph, and rejects a range that overlaps another entry's anchor. Anchors are invisible in the document, so a reader cannot tell why a selection is unavailable. The example preflights with metadata.list({ within: target }) and names the entry in the way, rather than surfacing a bare INVALID_TARGET. Check the attachment receipt regardless before adding a finding to application state.

Gate the attach control on the same rule rather than on the presence of a target. capture() returns a selectionTarget for a collapsed caret, for a selection spanning paragraphs, and for a selection inside a header, footer, note, or textbox. attach() rejects all of them: the first two cannot be represented as a single hidden inline SDT, and the adapter resolves the paragraph against document.xml, so a non-body block id fails with TARGET_NOT_FOUND. The example requires text endpoints, one shared block id, a non-empty range, and the body story.

Reject tracked-space targets too. A selection crossing deletion-side tracked text carries coordinateSpace: 'tracked', and those offsets count characters the anchor rewrite cannot see, since it searches visible w:t runs only. The same numbers therefore mean different positions on each side: the call either fails or stores a durable finding over the wrong text. Check coordinateSpace before writing, not only before painting.

Metadata ids are unique document-wide. attach() rejects an id that already exists under any namespace, and it checks that before considering the namespace at all, so the example lists twice: namespace-filtered for the rows it displays and highlights, and unfiltered to decide whether the id is free. Withdraw follows the rows this namespace owns; Attach follows whether the id is taken anywhere. Both come from the listed metadata rather than from which control was clicked last, so a DOCX opened with the finding already present starts in the correct state.

That check narrows the failure, it does not remove it. attach() applies a second uniqueness rule the public API cannot see: an anchor SDT whose payload record was lost, through an external edit of the storage part, still occupies its id while appearing in no list() result. A fixed id can therefore collide with an orphan nobody can enumerate. Treat the attach receipt as the authority and surface its failure, as the example does, and prefer caller-generated unique ids over a fixed one in real integrations.

Serialize the writes. The browser Document API is worker-backed, so two clicks before the first settles issue two concurrent attachments with the same id: one wins, the other returns INVALID_INPUT, and the status ends up reporting failure for a finding that exists. The example holds both controls disabled from the moment a write starts until its refresh settles.

Generation-scope them as well. Both mutations await a read before writing, and the doc facade follows the host into a replacement document, so a continuation that resumes after replaceFile() would carry the previous document's intent into the new one: withdrawing its same-id finding, or anchoring to a block id that collides. The example captures a source generation before the first await and abandons the write when it no longer matches. Retiring reads is not enough, because the write is the part that does damage.

Pass the read's revision to the write. Every discovery result carries evaluatedRevision, and every mutation accepts expectedRevision through its options, so the example hands the preflight's revision to attach() and the ownership read's revision to remove(). An edit landing between the read and the write then fails the guard instead of applying a target the document has moved past.

Clear the panel and controls on activation as well, not just the painted targets. The refresh that repopulates them is asynchronous and can fail, and until it lands the rows would describe the previous document while the controls acted on its state.

remove() takes an id and nothing else, so it will delete whatever currently holds that id. If your entry was withdrawn elsewhere and the id reused under another namespace, removing by id alone destroys that other application's metadata. Re-read ownership immediately before removing and fail closed when the id is no longer yours, as the example does. That narrows the window rather than closing it: the API has no transactional guard on the operation, so treat a document with several metadata writers as a case needing coordination outside SuperDoc.

Do not rebuild a failed anchor from numeric ProseMirror positions or renderer DOM attributes. Keep the durable entry visible in your review panel, mark it as unresolved, and let the reviewer withdraw or re-anchor it.

Migrate from editor.state.doc

Do not translate ProseMirror mark traversal node for node. First identify what the mark represented:

  • Store application-owned identity and payload with doc.metadata.attach().
  • Read entries with metadata.list() and metadata.get().
  • Resolve the current public target with metadata.resolve().
  • Paint that target with ctx.visuals.highlight().

Replace the v1 Extensions helper and editorExtensions configuration with an extension created by defineSuperDocExtension() and passed through the v2 extensions configuration.

There is no public ProseMirror document tree in v2. A workflow that depends on arbitrary nodes, marks, or numeric document positions must be redesigned around Document API addresses and targets.

Continue with Selection and viewport for public target geometry, or Custom commands to expose review actions through the shared command catalog.

On this page