Skip to content

feat(ui): add ui.viewport.entityAt for typed point-to-entity lookup (SD-2936)#3139

Open
caio-pizzol wants to merge 5 commits intomainfrom
caio/sd-2936-entity-at
Open

feat(ui): add ui.viewport.entityAt for typed point-to-entity lookup (SD-2936)#3139
caio-pizzol wants to merge 5 commits intomainfrom
caio/sd-2936-entity-at

Conversation

@caio-pizzol
Copy link
Copy Markdown
Contributor

Adds ui.viewport.entityAt({ x, y }) so right-click menus, hover tooltips, and any UI that asks "what's painted under this point?" stop reading data-track-change-id and data-comment-ids off event.target.closest(...) themselves. The painter's data-attribute layout is an implementation detail consumers shouldn't depend on, and a closest() walk silently picks the innermost match — overlapping entities (a tracked change inside a comment) drop on the floor.

entityAt returns ViewportEntityHit[] in innermost-first order, so a switch on hits[0] picks the most specific entity while consumers that want the full chain get it. Today the union has comment and trackedChange; link, image, and tableCell are reserved as additive members so a default branch in a switch stays forward-compatible.

The DOM walk lives in a pure helper (collectEntityHitsFromChain) so it tests cleanly with constructed elements; the controller method composes the helper with document.elementFromPoint. happy-dom in this repo doesn't ship elementFromPoint, and per-realm prototype mutation didn't survive between the test and source files in this setup, so factoring the walk lets us test the interesting logic without that footgun.

Stacks on caio/sd-2936-selection-rects (PR #3134) — second of five PRs against SD-2936.

Verified: pnpm exec vitest run src/ui → 213 passed (14 files); types and lint pass via lefthook.

Custom-UI consumers building floating selection toolbars currently fall
back to window.getSelection().getRangeAt(0).getBoundingClientRect(),
which reads from the offscreen ProseMirror DOM and returns coordinates
that don't match what the painter shows on screen. The painted
selection rect already exists internally on PresentationEditor; this
just lifts it onto the public controller surface so consumers stop
reaching past useSuperDocHost() through an untyped cast.

ui.selection.getRects(capture?) returns viewport-relative ViewportRect[]
for the live selection (or a captured one). ui.selection.getAnchorRect(
options?, capture?) returns a single rect with placement: 'start' (the
default — Word/GDocs bubble menu placement), 'end', or 'union' (bounding
rect across all line rects). Both return [] / null when there's no
addressable selection or when the editor stub has no presentation
layer.

The capture path resolves the captured TextTarget via the existing
resolveTextTarget adapter helper, then calls
presentationEditor.getRangeRects(from, to). Multi-segment captures
collapse to a single (firstSegment.start, lastSegment.end) range to
match how the doc-api represents the selection internally.

Tests cover the live path (rects mapping, missing presentation layer,
exception swallowing), each placement mode, and the captured-selection
shapes.
…ET (SD-2936)

Review feedback on the captured-selection rect path:

1. Captures whose target.story is non-body now early-return [] with
   the limitation documented on SelectionHandle.getRects. The full fix
   requires a getRangeRectsForStory(from, to, story) primitive on
   PresentationEditor that doesn't yet exist publicly — until that
   lands, captured rects on header / footer / footnote / endnote
   surfaces silently failed (resolveTextTarget against the host's body
   doc returns null for those blockIds). Body-only matches the same
   posture as scroll-into-view's text-anchored path. Live-selection
   rects still work on every surface because PresentationEditor
   .getSelectionRects() routes through getActiveEditor() internally.

2. resolveTextTarget re-throws AMBIGUOUS_TARGET (two blocks sharing an
   id) so callers can log the precise diagnostic. The bare catch was
   swallowing this. Now surfaces it via console.warn before returning [].

3. Trimmed the multi-segment comment to the rationale only — the first
   two sentences paraphrased the immediate next line, which the comment
   policy bans.

Tests cover the non-body story rejection (returns [] without calling
getRangeRects).
…SD-2936)

The previous patch documented a body-only limitation; this is the
actual fix. getRects(capture) and getAnchorRect(options, capture) now
take both the host editor (for the presentation layer's getRangeRects)
and the routed editor (for resolveTextTarget against the captured
block ids).

For captures taken in a non-body story (header, footer, footnote,
endnote), the routed editor at call time owns the PM document those
block ids belong to. Resolving against it produces the right positions,
which then flow through presentationEditor.getRangeRects to land on
the right surface. The previous resolveHostEditor-only path resolved
non-body block ids against the body PM doc and silently returned [].

The remaining limitation: when focus has moved to a sidebar / composer
by call time, the routed editor falls back to the body and the
captured non-body block ids no longer resolve there. The function
returns [] gracefully in that case rather than misclassifying. Fully
cross-surface captures need a story-keyed editor lookup on
PresentationEditor that doesn't yet exist publicly — that's a
follow-up.
…SD-2936)

Right-click menus, hover tooltips, and any UI that asks "what's under
this point?" today read `data-track-change-id` and `data-comment-ids`
off `event.target.closest(...)` themselves. The attribute layout is
an implementation detail of the painter that consumers shouldn't
depend on, and the closest() walk makes id collisions silent (a
trackedChange inside a comment highlight surfaces only the innermost
hit).

ui.viewport.entityAt({ x, y }) takes viewport coordinates (matching
`MouseEvent.clientX/clientY` and `ViewportRect`) and returns
`ViewportEntityHit[]` — every painted entity in the chain, innermost
first. Supports `comment` and `trackedChange` today; `link`, `image`,
and `tableCell` are reserved as additive union members so consumers
switching on `hit.type` with a default branch stay forward-compatible.

The DOM walk is a pure helper (`collectEntityHitsFromChain`) so it's
testable without stubbing `document.elementFromPoint` (happy-dom in
this repo doesn't ship the method natively, and per-realm prototype
mutation didn't survive between the test and source files). The
controller method composes the helper with `elementFromPoint`.

Stacks on top of caio/sd-2936-selection-rects.
@caio-pizzol caio-pizzol requested a review from a team as a code owner May 5, 2026 00:23
@linear
Copy link
Copy Markdown

linear Bot commented May 5, 2026

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b4d09218a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1578 to +1582
if (typeof document === 'undefined' || typeof document.elementFromPoint !== 'function') {
return [];
}
const startEl = document.elementFromPoint(input.x, input.y);
return collectEntityHitsFromChain(startEl);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope entityAt to mounted host editor

entityAt reads from global document.elementFromPoint without verifying this ui instance has a mounted host editor, so it can return hits from unrelated DOM (another SuperDoc instance or stale painted nodes) instead of this document. That breaks the contract documented in ViewportHandle.entityAt (“empty array … when no editor is mounted”) and can route context-menu actions using IDs that do not belong to the active document.

Useful? React with 👍 / 👎.

Base automatically changed from caio/sd-2936-selection-rects to main May 5, 2026 00:28
Two review issues from PR #3139:

1. entityAt previously called document.elementFromPoint globally and
   walked all ancestors with no check that the controller had a
   mounted editor or that the hit landed inside this instance's
   painted DOM. A page mounting two SuperDoc instances would have
   one's entityAt return ids from the other; post-destroy calls
   would return stale ids from cached painted nodes. Now resolves
   the host editor via resolveHostEditor, reads
   presentationEditor.visibleHost (newly added to the structural
   type), and returns [] when the host is missing or the hit
   element isn't inside it.

2. The published `superdoc/ui` declaration barrel at
   packages/superdoc/src/ui.d.ts didn't list the new public types,
   so `import type { ViewportEntityHit, ViewportEntityAtInput } from
   'superdoc/ui'` failed for consumers. Same gap existed for
   SelectionAnchorRectOptions from PR #3134. Added all three.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant