# 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](/editor/custom-ui/controller-setup) first. This guide uses the same controller lifecycle.

## Add the application surface [#add-the-application-surface]

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

```html
<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](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or use another DOCX.

## Capture a document selection [#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.

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

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

```ts
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 [#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](/editor/custom-ui/comments) to use the same capture for an anchored comment.
