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 first. This guide uses the same controller lifecycle.
Add the application surface
Place the overlay inside a positioned shell around the Editor. The textarea makes the focus change easy to verify.
<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 to your app's public directory as contract.docx, or use another DOCX.
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.
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
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
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:
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.
A point over ordinary text carries no entities, 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 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().
Coming from editor.view 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
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
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.
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
- Select text in the document.
- Confirm that the overlay shows the selected text near its painted range.
- Type in the review-note textarea.
- Scroll or resize the Editor and confirm that the overlay follows the target.
- Choose Restore selection and confirm that the document selection returns.
Continue with Build a custom comments UI to use the same capture for an anchored comment.