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.
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, and citations. It does not return an arbitrary text
position. Use Context menu 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
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:
import type { DocumentApi, ContentControlInfo } from '@superdoc/document-api';
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
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:
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
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. 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():
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.
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().