Skip to content

Commit 9b4d092

Browse files
committed
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.
1 parent 21c0a8c commit 9b4d092

6 files changed

Lines changed: 224 additions & 0 deletions

File tree

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

Lines changed: 21 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,24 @@ 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+
// `document.elementFromPoint` is the only entry point — guard
1576+
// SSR / non-browser stubs explicitly so the call doesn't throw
1577+
// in test environments without a global `document`.
1578+
if (typeof document === 'undefined' || typeof document.elementFromPoint !== 'function') {
1579+
return [];
1580+
}
1581+
const startEl = document.elementFromPoint(input.x, input.y);
1582+
return collectEntityHitsFromChain(startEl);
1583+
},
15631584
};
15641585

15651586
// ---- 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
@@ -119,6 +119,8 @@ export type {
119119
TrackChangesSlice,
120120

121121
// Viewport
122+
ViewportEntityAtInput,
123+
ViewportEntityHit,
122124
ViewportGetRectInput,
123125
ViewportHandle,
124126
ViewportRect,

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,4 +1369,48 @@ export interface ViewportHandle {
13691369
scrollIntoView(
13701370
input: import('@superdoc/document-api').ScrollIntoViewInput,
13711371
): Promise<import('@superdoc/document-api').ScrollIntoViewOutput>;
1372+
/**
1373+
* Look up entities painted under a viewport coordinate. Used by
1374+
* right-click menus and hover tooltips to ask "what's at this point?"
1375+
* without consumers reading `data-track-change-id` /
1376+
* `data-comment-ids` off the painted DOM themselves — the
1377+
* data-attribute layout is an implementation detail of the painter
1378+
* that consumers shouldn't depend on.
1379+
*
1380+
* Returns an ordered array of {@link ViewportEntityHit}, innermost
1381+
* first. A point can sit inside several entities at once (a tracked
1382+
* change inside a comment highlight, for example) — every match is
1383+
* surfaced, not just the topmost. Empty array when the point isn't
1384+
* over any painted entity, when called outside a browser, or when no
1385+
* editor is mounted.
1386+
*
1387+
* Today the supported entity types are `comment` and `trackedChange`.
1388+
* `link`, `image`, and `tableCell` are reserved for follow-ups —
1389+
* adding them is purely additive (new union members), so callers can
1390+
* `switch` on `hit.type` and the default branch remains forward
1391+
* compatible.
1392+
*/
1393+
entityAt(input: ViewportEntityAtInput): ViewportEntityHit[];
1394+
}
1395+
1396+
/**
1397+
* Input shape for {@link ViewportHandle.entityAt}. Coordinates are
1398+
* viewport-relative (the same space `MouseEvent.clientX` /
1399+
* `clientY` produce, and the same space {@link ViewportRect} reports
1400+
* back), so a `contextmenu` handler can pass `event.clientX` /
1401+
* `event.clientY` directly.
1402+
*/
1403+
export interface ViewportEntityAtInput {
1404+
x: number;
1405+
y: number;
13721406
}
1407+
1408+
/**
1409+
* One hit returned by {@link ViewportHandle.entityAt}.
1410+
*
1411+
* The union is intentionally narrow today (`comment` /
1412+
* `trackedChange`); other entity types land via additive union
1413+
* members so a `switch` on `hit.type` with a default branch stays
1414+
* forward compatible.
1415+
*/
1416+
export type ViewportEntityHit = { type: 'comment'; id: string } | { type: 'trackedChange'; id: string };

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,15 @@ describe('ui.viewport.scrollIntoView', () => {
310310
ui.destroy();
311311
});
312312
});
313+
314+
describe('ui.viewport.entityAt — input validation', () => {
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+
});

0 commit comments

Comments
 (0)