# 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](/editor/custom-ui/comments) when the document should contain a Word comment thread instead.

## Keep storage and paint separate [#keep-storage-and-paint-separate]

Neither surface replaces the other:

| Responsibility                          | Public surface                 | Survives export                   |
| --------------------------------------- | ------------------------------ | --------------------------------- |
| Finding ID, namespace, and JSON payload | `doc.metadata`                 | Yes                               |
| Current anchored range                  | `doc.metadata.resolve({ id })` | Recomputed from the stored anchor |
| Visible class over the range            | `ctx.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 [#build-the-workflow]

Create controls for attaching and withdrawing one finding:

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

```css
.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:

```ts
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 [#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 [#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.

> **Collaborative documents need an application-owned refresh (warning)**
>
> `onMutation()` observes local commits. A remote apply from another client does not currently reach an extension
> mutation handler, so a finding can stay painted over text another client edited, or stay visible after another client
> removes it, until a local mutation triggers the next refresh. Until a public remote signal exists, refresh from
> whatever your application already knows about collaboration activity, or restrict this workflow to single-client
> editing.


## Handle failures without inventing positions [#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.

> **A document swap is a different race from an edit (warning)**
>
> `expectedRevision` covers edits to the same document. It does not cover `replaceFile()` landing during the write's own
> await, because the replacement carries its own revision sequence and the request commits against it. If your
> application replaces documents while review actions are in flight, block the swap until pending writes settle. The
> example does not swap documents, so its exposure is the window an embedding application introduces.


> **Attaching rewrites the runs it wraps (warning)**
>
> Anchoring splits the containing `w:r` into up to three runs and rebuilds them, and the rebuilt runs carry no `w:rPr`.
> Attaching over formatted text therefore drops that run's bold, font, language, and character-style properties. A range
> spanning two runs is rejected outright with `TARGET_NOT_FOUND`, so the single-paragraph rule above is necessary but
> not sufficient. Until anchoring preserves and spans existing runs, use this pattern on plain body text, and treat a
> selection whose formatting matters as unsupported rather than assuming the round trip is lossless.


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` [#migrate-from-editorstatedoc]

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.

> **Verification target (success)**
>
> Select text in one paragraph and attach the finding. The selected text should gain the highlight class, the list
> should show the caller-owned ID, and the status should report success. Withdraw the finding. Its metadata row and
> visible highlight should both disappear. After exporting and reopening the DOCX, activate the extension and refresh
> the metadata list to repaint the stored findings.


Continue with [Selection and viewport](/editor/custom-ui/selection-and-viewport) for public target geometry, or [Custom commands](/editor/custom-ui/custom-commands) to expose review actions through the shared command catalog.
