# Selection and position reference

> Choose document targets, resolve their geometry, and identify supported entities under a pointer.



Use this reference when your custom interface needs explicit selections or pointer hit-testing. For a complete
selection-anchored interaction, start with [Build an AI prompt menu](/editor/custom-ui/selection-and-viewport).

## Choose the target source [#choose-the-target-source]

Use the method that matches where your interface's target comes from:

| Need                                                 | Method                                                |
| ---------------------------------------------------- | ----------------------------------------------------- |
| Preserve selected context after focus moves          | `selection.capture()` and `selection.restore()`       |
| Read the live caret or selection                     | `selection.current()` or `selection.observe()`        |
| Select an explicit `SelectionTarget`                 | `selection.apply(selectionTarget)`                    |
| Position UI from the live selection                  | `selection.getAnchorRect()` or `selection.getRects()` |
| Position UI from a capture or query result           | `viewport.getRect({ target })`                        |
| Identify supported document entities under a pointer | `viewport.entityAt({ x, y })`                         |

`entityAt()` identifies comments, tracked changes, content controls, citations, and highlights created with `superdoc.ui.decorations`. It does not return an arbitrary text
position. Use [Context menu](/editor/custom-ui/context-menus) for a complete pointer and keyboard interaction.

`selection.apply()` takes a `SelectionTarget` — the explicit start/end form. `selection.current()` carries both shapes:
`selectionTarget` is that form, and `target` is a `TextTarget` for geometry. Either works for `viewport.getRect()`, which
allows the geometry fallback `selectionTarget ?? target`, but only `selectionTarget` is accepted by
`apply()`. Read it directly and handle `null` rather than reusing that geometry fallback.

## Find controls intersecting selected text [#find-controls-intersecting-selected-text]

`selection.current()` does not include a content-control collection. For a nonempty text selection, combine its document-ordered `selectionTarget` with `contentControls.listInRange()`, then filter the block-scoped candidates by their exact selection endpoints. This includes enclosing/nested controls and excludes adjacent controls that only touch a selection boundary. The helper paginates the candidate results and keeps story identities separate:

```ts
import type { DocumentApi, ContentControlInfo } from 'superdoc/ui';

type Awaitable<T> = T | Promise<T>;
type SelectionDocument = {
  selection: {
    current(
      ...args: Parameters<DocumentApi['selection']['current']>
    ): Awaitable<ReturnType<DocumentApi['selection']['current']>>;
  };
  contentControls: {
    listInRange(
      ...args: Parameters<DocumentApi['contentControls']['listInRange']>
    ): Awaitable<ReturnType<DocumentApi['contentControls']['listInRange']>>;
  };
};

/** Nonempty text selections, using selection.current's document-ordered endpoints. */
export async function selectedContentControls(doc: SelectionDocument): Promise<ContentControlInfo[]> {
  const current = await doc.selection.current();
  const range = current.selectionTarget;
  if (current.empty || !range || range.start.kind !== 'text' || range.end.kind !== 'text') return [];
  const selectedStart = range.start;
  const selectedEnd = range.end;
  const items: ContentControlInfo[] = [];
  for (let offset = 0; ;) {
    const page = await doc.contentControls.listInRange({
      startBlockId: selectedStart.blockId,
      endBlockId: selectedEnd.blockId,
      offset,
      limit: 100,
    });
    items.push(...page.items);
    offset += page.items.length;
    if (offset >= page.total || page.items.length === 0) break;
  }
  return items.filter((control) => {
    const span = control.selectionTarget;
    if (!span || span.start.kind !== 'text' || span.end.kind !== 'text') return false;
    const selectedStory = range.story ?? selectedStart.story;
    const controlStory = span.story ?? span.start.story;
    const key = (story: typeof selectedStory) =>
      !story || story.storyType === 'body' ? 'body' : JSON.stringify(story);
    if (key(selectedStory) !== key(controlStory)) return false;
    // listInRange is block-scoped. Exclude controls touching only a boundary;
    // adjacent controls in the same paragraph are not selection intersections.
    if (span.end.blockId === selectedStart.blockId && span.end.offset <= selectedStart.offset) return false;
    if (span.start.blockId === selectedEnd.blockId && span.start.offset >= selectedEnd.offset) return false;
    return true;
  });
}

```

Collapsed carets and non-text/table selections return an empty list in this helper; use `viewport.entityAt()` for pointer hits. A headless session has no live UI selection and returns its documented collapsed default. Pass an explicit text range instead when building a headless workflow.

Temporary lock changes can separately use `contentControls.setLockMode({ target, lockMode, addToHistory: false })` in local sessions. Keep the substantive content edit in history and restore the original lock mode. This history-neutral alternative does not group execution or layout; use an atomic plan for a complete multi-control operation.

## Read table and cell context [#read-table-and-cell-context]

In the V2 browser editor, `doc.selection.current()` and
`ui.viewport.contextAt({ x, y }).selection` expose `tableSelection` with opaque
table and cell IDs, inclusive logical row/column bounds, and each cell's spans.
`selectionMode` distinguishes character selection (`text`) from whole-cell
selection (`cells`). Character selection includes its containing cells, including
endpoint cells, while preserving its exact text targets. Only whole-cell intent
can produce `type: 'table'`.

Merged, ragged, and nested tables return exact cell owners. Vertical merge
continuations appear through their owner once. `isRectangular` says whether those
owners fill the bounding range. A nonrectangular range can include unselected
cells inside its bounds, so use `cells` to identify actual membership.

For a context menu, await a fresh bounded context read at the click:

```ts
editorShell.addEventListener('contextmenu', async (event) => {
  event.preventDefault();
  const context = await ui.tables.getContextAsync();
  if (context.status !== 'ready' || !context.tableSelection) return;
  const selectedCellIds = context.tableSelection.cells.map((cell) => cell.nodeId);
  openTableMenu({ x: event.clientX, y: event.clientY, selectedCellIds });
});
```

`ui.tables.getContext()` returns the cached snapshot with `status`, `cellRange`,
and `tableSelection`. Pending or stale snapshots carry no previous cell IDs.
The async method resolves current context without reading the full document or
extracting cell text. Use `selection.current({ includeText: true })` when you also
need selected text. Whole-cell text is joined by tabs; character text retains
its character offsets.

Table metadata does not change the target types accepted by editing operations.
To capture whole-cell OOXML, construct a `tableCells` target only when
`selectionMode === 'cells'` and `isRectangular === true`; text and nonrectangular
metadata are rejected as rectangular capture targets. For character selection,
use its `selectionTarget`.

## 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`. Content-control hits can also carry their `tag` and `scope`.

The hits are tracked changes, comments, content controls, and citations. 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.

For citation cards, attach the listener to the public host and match the hit against the citation list. The hit id is the same id as the matching list item. Treat it as opaque and compare it only for equality. Your application supplies `openCitationCard()`:

```ts
const host = ui.viewport.getHost();

const onClick = async (event: MouseEvent) => {
  const hit = ui.viewport.entityAt({ x: event.clientX, y: event.clientY }).find((entity) => entity.type === 'citation');
  if (!hit) return;

  const citations = await Promise.resolve(superdoc.activeEditor?.doc.citations.list());
  const citation = citations?.items.find((item) => item.id === hit.id);
  if (citation) openCitationCard(citation);
};

host?.addEventListener('click', onClick);
// When this custom UI unmounts:
host?.removeEventListener('click', onClick);
```

Do not recover citation identity with `event.target.closest(...)`; the painter DOM is not a public API.

Anchored metadata uses a hidden content control rather than its own hit type, so its record ID arrives as the content-control hit's `tag`. A record with that ID existing does not prove the pointer hit its anchor: an ordinary control can carry a colliding tag. Compare the hit control's `selectionTarget` with `doc.metadata.resolve({ id: hit.tag })` before treating the hit as application metadata.

That comparison narrows the risk without settling it. Content-control hits carry no story, both lookups resolve against the main document part, and painted ids are unique only within that part, so a control in a header, footer, note, or textbox that reuses a body anchor's id and tag passes every check. For documents your own application produced the collision is unlikely; for externally authored files, treat a match as unverified and confirm through your own records. See [Store application data in DOCX](/document-api/application-data).

`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 no `story`, 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()`.

## Coordinate spaces and viewport changes [#coordinate-spaces-and-viewport-changes]

`ui.viewport.getRect({ target })` returns painted text rectangles in browser client coordinates, the same coordinate
space as `MouseEvent.clientX` and `clientY`. Each rectangle includes a zero-based `pageIndex`.
Pass your application-owned overlay container as `relativeTo` to subtract that element's current bounding-box origin:

```ts
const result = superdoc.ui.viewport.getRect({ target, relativeTo: overlayContainer });
if (result.found) {
  const first = result.rects[0];
  overlay.style.left = `${first.left}px`;
  overlay.style.top = `${first.bottom}px`;
}
```

These are coordinates relative to the element's border box, not its scroll-content coordinates. If your overlay lives
inside a scrolling content layer, account for that layer's scroll and border offsets in your application's positioning
code. To convert a returned element-relative point back to browser client space, add the current
`relativeTo.getBoundingClientRect().left` and `.top`. Resolve the rectangles again after the container moves or scrolls.

Use `ui.viewport.observe(reposition)` to re-read geometry after scrolling, zoom, resize, layout and repaint changes.
The callback is an invalidation signal coalesced to one per animation frame; it does not carry affected pages or a
scroll-position snapshot. Unsubscribe when the overlay is removed. Use `ui.zoom.getSnapshot()` and `ui.zoom.set(percent)`
to read and change zoom. Use the awaited `ui.viewport.scrollIntoView({ target })` to reveal a document range; check its
`success` result.

The older `superdoc.activeEditor.pageMetrics` facade also exposes page snapshots, subscriptions and `scrollToPage`.
It remains typed as `unknown` on the editor facade, so consumers must validate its shape and recheck it when upgrading.
Its page `viewport.xPx` and `viewport.yPx` describe zoomed layout-stage offsets; they are not browser client coordinates
and do not include the host's client origin or scroll transform. Do not combine them with pointer coordinates as if the
origins were the same. `scrollToPage` returns a boolean indicating whether navigation was accepted, not a promise that
scrolling and painting have finished. The typed UI API does not currently provide visible-page snapshots, a
page-plus-offset scroll restore operation, or a page-local/window coordinate conversion pair.
