Skip to content

Commit 2ef73ff

Browse files
authored
feat(ui): add ui.viewport.entityAt for typed point-to-entity lookup (SD-2936) (#3139)
* feat(ui): add ui.viewport.entityAt for typed point-to-entity lookup (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. * fix(ui): scope entityAt to mounted host + publish new types (SD-2936) 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. * fix(ui): reach the DOM document via globalThis in entityAt (SD-2936)
1 parent 02fe90b commit 2ef73ff

7 files changed

Lines changed: 292 additions & 0 deletions

File tree

packages/super-editor/src/ui/create-super-doc-ui.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ScrollIntoViewOutput,
1515
TrackChangesListResult,
1616
} from '@superdoc/document-api';
17+
import { collectEntityHitsFromChain } from './entity-at.js';
1718
import { shallowEqual } from './equality.js';
1819
import { scrollRangeIntoView } from './scroll-into-view.js';
1920
import { getSelectionAnchorRect, getSelectionRects } from './selection-rects.js';
@@ -44,6 +45,8 @@ import type {
4445
ToolbarHandle,
4546
ToolbarSnapshotSlice,
4647
UIToolbarCommandState,
48+
ViewportEntityAtInput,
49+
ViewportEntityHit,
4750
ViewportGetRectInput,
4851
ViewportHandle,
4952
ViewportRect,
@@ -1560,6 +1563,35 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
15601563
async scrollIntoView(input: ScrollIntoViewInput): Promise<ScrollIntoViewOutput> {
15611564
return runScrollIntoView(input);
15621565
},
1566+
1567+
// The painter stamps `data-track-change-id` and `data-comment-ids`
1568+
// on each painted run; reading them back is what consumers were
1569+
// doing imperatively from `event.target.closest(...)` in
1570+
// contextmenu handlers. Centralizing the lookup here keeps the
1571+
// attribute names an implementation detail of the painter and
1572+
// surfaces a typed `EntityHit[]` consumers can switch on.
1573+
entityAt(input: ViewportEntityAtInput): ViewportEntityHit[] {
1574+
if (!input || typeof input.x !== 'number' || typeof input.y !== 'number') return [];
1575+
// The DOM `document` is reached through `globalThis.document`
1576+
// because the local `document: DocumentHandle` declared below
1577+
// would otherwise shadow it for type-checking. Guard SSR /
1578+
// non-browser stubs explicitly so the call doesn't throw in
1579+
// test environments without a global `document`.
1580+
const dom = (globalThis as { document?: Document }).document;
1581+
if (!dom || typeof dom.elementFromPoint !== 'function') {
1582+
return [];
1583+
}
1584+
// Scope the lookup to this controller's editor: a page mounting
1585+
// two SuperDoc instances would otherwise have one's entityAt
1586+
// return ids from the other's painted DOM. A null host (no
1587+
// editor mounted, post-destroy, SSR test stub) returns [].
1588+
const editor = resolveHostEditor(superdoc);
1589+
const host = editor?.presentationEditor?.visibleHost;
1590+
if (!host) return [];
1591+
const startEl = dom.elementFromPoint(input.x, input.y);
1592+
if (!startEl || !host.contains(startEl)) return [];
1593+
return collectEntityHitsFromChain(startEl);
1594+
},
15631595
};
15641596

15651597
// ---- ui.selection ------------------------------------------------------
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { collectEntityHitsFromChain } from './entity-at.js';
4+
5+
/**
6+
* Build a chain of nested HTMLElements with the dataset stamps the
7+
* painter would set. `layers[0]` becomes the innermost element;
8+
* subsequent layers wrap it. Returns the innermost element — the one
9+
* `document.elementFromPoint` would normally return for a click on
10+
* the innermost painted run.
11+
*/
12+
function buildPaintedChain(layers: Array<{ trackChangeId?: string; commentIds?: string }>): HTMLElement {
13+
const innerLayer = layers[0]!;
14+
const inner = document.createElement('span');
15+
if (innerLayer.trackChangeId) inner.dataset.trackChangeId = innerLayer.trackChangeId;
16+
if (innerLayer.commentIds) inner.dataset.commentIds = innerLayer.commentIds;
17+
18+
let outer: HTMLElement = inner;
19+
for (let i = 1; i < layers.length; i += 1) {
20+
const layer = layers[i]!;
21+
const wrapper = document.createElement('span');
22+
if (layer.trackChangeId) wrapper.dataset.trackChangeId = layer.trackChangeId;
23+
if (layer.commentIds) wrapper.dataset.commentIds = layer.commentIds;
24+
wrapper.appendChild(outer);
25+
outer = wrapper;
26+
}
27+
document.body.appendChild(outer);
28+
return inner;
29+
}
30+
31+
describe('collectEntityHitsFromChain', () => {
32+
it('returns hits for tracked-change and comment data attributes, innermost first', () => {
33+
const inner = buildPaintedChain([{ trackChangeId: 'tc-1' }, { commentIds: 'c-1' }]);
34+
35+
expect(collectEntityHitsFromChain(inner)).toEqual([
36+
{ type: 'trackedChange', id: 'tc-1' },
37+
{ type: 'comment', id: 'c-1' },
38+
]);
39+
});
40+
41+
it('expands comma-separated comment ids into one hit per id', () => {
42+
const inner = buildPaintedChain([{ commentIds: 'c-1,c-2,c-3' }]);
43+
44+
expect(collectEntityHitsFromChain(inner)).toEqual([
45+
{ type: 'comment', id: 'c-1' },
46+
{ type: 'comment', id: 'c-2' },
47+
{ type: 'comment', id: 'c-3' },
48+
]);
49+
});
50+
51+
it('deduplicates the same id when it appears multiple times in the chain', () => {
52+
const inner = buildPaintedChain([{ commentIds: 'c-1' }, { commentIds: 'c-1' }]);
53+
54+
expect(collectEntityHitsFromChain(inner)).toEqual([{ type: 'comment', id: 'c-1' }]);
55+
});
56+
57+
it('combines trackedChange + comment + outer comment in document order (innermost → outermost)', () => {
58+
const inner = buildPaintedChain([{ trackChangeId: 'tc-1' }, { commentIds: 'c-inner' }, { commentIds: 'c-outer' }]);
59+
60+
expect(collectEntityHitsFromChain(inner)).toEqual([
61+
{ type: 'trackedChange', id: 'tc-1' },
62+
{ type: 'comment', id: 'c-inner' },
63+
{ type: 'comment', id: 'c-outer' },
64+
]);
65+
});
66+
67+
it('returns [] when the chain has no painted entities', () => {
68+
const inner = buildPaintedChain([{}]);
69+
70+
expect(collectEntityHitsFromChain(inner)).toEqual([]);
71+
});
72+
73+
it('returns [] for null or non-Element starts', () => {
74+
expect(collectEntityHitsFromChain(null)).toEqual([]);
75+
expect(collectEntityHitsFromChain({} as never)).toEqual([]);
76+
});
77+
78+
it('skips empty ids in a malformed comma list', () => {
79+
const inner = buildPaintedChain([{ commentIds: ',c-1,,c-2,' }]);
80+
81+
expect(collectEntityHitsFromChain(inner)).toEqual([
82+
{ type: 'comment', id: 'c-1' },
83+
{ type: 'comment', id: 'c-2' },
84+
]);
85+
});
86+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Walk a painted-DOM element chain (innermost → outermost) and
3+
* collect entity hits for `ui.viewport.entityAt`.
4+
*
5+
* Pure function — takes a starting element, returns the hits. The
6+
* `document.elementFromPoint` lookup that produces the starting
7+
* element lives in the controller; this helper is what makes the
8+
* data-attribute walk testable without stubbing globals.
9+
*/
10+
11+
import type { ViewportEntityHit } from './types.js';
12+
13+
/**
14+
* Read painted entities off `el` and every ancestor up to the document
15+
* root. Innermost-first ordering: a tracked change inside a comment
16+
* highlight returns `[{ trackedChange }, { comment }]`, matching what
17+
* a switch on `hits[0]` expects when picking the most specific entity.
18+
*
19+
* Returns `[]` for null / non-Element starts. Uses duck-typed
20+
* `getAttribute` access so it works under any DOM implementation
21+
* (happy-dom, jsdom, real browser) without an `instanceof` check that
22+
* could fail across realms.
23+
*/
24+
export function collectEntityHitsFromChain(start: Element | null): ViewportEntityHit[] {
25+
if (!start || typeof (start as { getAttribute?: unknown }).getAttribute !== 'function') {
26+
return [];
27+
}
28+
29+
const hits: ViewportEntityHit[] = [];
30+
const seen = new Set<string>();
31+
let el: Element | null = start;
32+
while (el) {
33+
const node = el as { getAttribute(name: string): string | null };
34+
const trackChangeId = node.getAttribute('data-track-change-id');
35+
if (trackChangeId) {
36+
const key = `trackedChange:${trackChangeId}`;
37+
if (!seen.has(key)) {
38+
seen.add(key);
39+
hits.push({ type: 'trackedChange', id: trackChangeId });
40+
}
41+
}
42+
const commentIds = node.getAttribute('data-comment-ids');
43+
if (commentIds) {
44+
// The painter stamps overlapping comments as a comma-separated
45+
// list — surface each id as its own hit so a "Resolve this
46+
// comment" item in a context menu can target the right one.
47+
for (const id of commentIds.split(',')) {
48+
if (!id) continue;
49+
const key = `comment:${id}`;
50+
if (!seen.has(key)) {
51+
seen.add(key);
52+
hits.push({ type: 'comment', id });
53+
}
54+
}
55+
}
56+
el = el.parentElement;
57+
}
58+
return hits;
59+
}

packages/super-editor/src/ui/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ export type {
120120
TrackChangesSlice,
121121

122122
// Viewport
123+
ViewportEntityAtInput,
124+
ViewportEntityHit,
123125
ViewportGetRectInput,
124126
ViewportHandle,
125127
ViewportRect,

packages/super-editor/src/ui/types.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ export interface SuperDocEditorLike {
169169
width: number;
170170
height: number;
171171
}>;
172+
/**
173+
* Painted-DOM host element. `ui.viewport.entityAt` reads it to
174+
* confirm the hit returned by `document.elementFromPoint` lives
175+
* inside this controller's editor — without that scope check, a
176+
* page mounting two SuperDoc instances would return entity ids
177+
* from the wrong instance.
178+
*/
179+
visibleHost?: HTMLElement;
172180
} | null;
173181
}
174182

@@ -1369,4 +1377,54 @@ export interface ViewportHandle {
13691377
scrollIntoView(
13701378
input: import('@superdoc/document-api').ScrollIntoViewInput,
13711379
): Promise<import('@superdoc/document-api').ScrollIntoViewOutput>;
1380+
/**
1381+
* Look up entities painted under a viewport coordinate. Used by
1382+
* right-click menus and hover tooltips to ask "what's at this point?"
1383+
* without consumers reading `data-track-change-id` /
1384+
* `data-comment-ids` off the painted DOM themselves; the
1385+
* data-attribute layout is an implementation detail of the painter
1386+
* that consumers shouldn't depend on.
1387+
*
1388+
* Returns an ordered array of {@link ViewportEntityHit}, innermost
1389+
* first. A point can sit inside several entities at once (a tracked
1390+
* change inside a comment highlight, for example); every match is
1391+
* surfaced, not just the topmost. Empty array when the point isn't
1392+
* over any painted entity, when called outside a browser, or when no
1393+
* editor is mounted.
1394+
*
1395+
* Scoped to the controller's own editor: hits are only returned when
1396+
* the point lands inside this editor's painted host. A page mounting
1397+
* two SuperDoc instances therefore can't have one controller return
1398+
* ids from the other's DOM, and post-destroy calls return `[]`
1399+
* rather than stale ids from cached painted nodes.
1400+
*
1401+
* Today the supported entity types are `comment` and `trackedChange`.
1402+
* `link`, `image`, and `tableCell` are reserved for follow-ups;
1403+
* adding them is purely additive (new union members), so callers can
1404+
* `switch` on `hit.type` and the default branch remains forward
1405+
* compatible.
1406+
*/
1407+
entityAt(input: ViewportEntityAtInput): ViewportEntityHit[];
13721408
}
1409+
1410+
/**
1411+
* Input shape for {@link ViewportHandle.entityAt}. Coordinates are
1412+
* viewport-relative (the same space `MouseEvent.clientX` /
1413+
* `clientY` produce, and the same space {@link ViewportRect} reports
1414+
* back), so a `contextmenu` handler can pass `event.clientX` /
1415+
* `event.clientY` directly.
1416+
*/
1417+
export interface ViewportEntityAtInput {
1418+
x: number;
1419+
y: number;
1420+
}
1421+
1422+
/**
1423+
* One hit returned by {@link ViewportHandle.entityAt}.
1424+
*
1425+
* The union is intentionally narrow today (`comment` /
1426+
* `trackedChange`); other entity types land via additive union
1427+
* members so a `switch` on `hit.type` with a default branch stays
1428+
* forward compatible.
1429+
*/
1430+
export type ViewportEntityHit = { type: 'comment'; id: string } | { type: 'trackedChange'; id: string };

packages/super-editor/src/ui/viewport.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,55 @@ describe('ui.viewport.scrollIntoView', () => {
310310
ui.destroy();
311311
});
312312
});
313+
314+
describe('ui.viewport.entityAt — host scoping', () => {
315+
it('returns [] for invalid input (missing or non-numeric coordinates)', () => {
316+
const { superdoc } = makeStubs();
317+
const ui = createSuperDocUI({ superdoc });
318+
319+
expect(ui.viewport.entityAt({} as never)).toEqual([]);
320+
expect(ui.viewport.entityAt({ x: 'a', y: 0 } as never)).toEqual([]);
321+
322+
ui.destroy();
323+
});
324+
325+
it('returns [] when no editor is mounted (no presentationEditor.visibleHost)', () => {
326+
const { superdoc } = makeStubs();
327+
// Stub editor has no `visibleHost` on its presentationEditor —
328+
// simulating SSR / non-paginated mounts and post-destroy state.
329+
const ui = createSuperDocUI({ superdoc });
330+
331+
expect(ui.viewport.entityAt({ x: 10, y: 10 })).toEqual([]);
332+
333+
ui.destroy();
334+
});
335+
336+
it('returns [] when the hit element is outside the controller`s painted host', () => {
337+
const { superdoc } = makeStubs();
338+
// Mount a fake host on the stub presentation editor and put the
339+
// "hit" element OUTSIDE that host — the equivalent of a second
340+
// SuperDoc instance painting the cursor target.
341+
const host = document.createElement('div');
342+
document.body.appendChild(host);
343+
(
344+
superdoc.activeEditor as unknown as { presentationEditor: { visibleHost: HTMLElement } }
345+
).presentationEditor.visibleHost = host;
346+
347+
const outside = document.createElement('span');
348+
outside.dataset.commentIds = 'c-foreign';
349+
document.body.appendChild(outside);
350+
351+
const docAny = document as unknown as { elementFromPoint?: (x: number, y: number) => Element | null };
352+
const original = docAny.elementFromPoint;
353+
docAny.elementFromPoint = () => outside;
354+
355+
const ui = createSuperDocUI({ superdoc });
356+
expect(ui.viewport.entityAt({ x: 0, y: 0 })).toEqual([]);
357+
358+
if (original) docAny.elementFromPoint = original;
359+
else delete docAny.elementFromPoint;
360+
outside.remove();
361+
host.remove();
362+
ui.destroy();
363+
});
364+
});

packages/superdoc/src/ui.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export {
2323
type Receipt,
2424
type ScrollIntoViewInput,
2525
type ScrollIntoViewOutput,
26+
type SelectionAnchorRectOptions,
2627
type SelectionCapture,
2728
type SelectionHandle,
2829
type SelectionInfo,
@@ -50,6 +51,8 @@ export {
5051
type TrackChangesSlice,
5152
type TrackedChangeAddress,
5253
type UIToolbarCommandState,
54+
type ViewportEntityAtInput,
55+
type ViewportEntityHit,
5356
type ViewportGetRectInput,
5457
type ViewportHandle,
5558
type ViewportRect,

0 commit comments

Comments
 (0)