Skip to content

Commit cd51a9c

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

4 files changed

Lines changed: 70 additions & 5 deletions

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1572,13 +1572,21 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
15721572
// surfaces a typed `EntityHit[]` consumers can switch on.
15731573
entityAt(input: ViewportEntityAtInput): ViewportEntityHit[] {
15741574
if (!input || typeof input.x !== 'number' || typeof input.y !== 'number') return [];
1575-
// `document.elementFromPoint` is the only entry point — guard
1575+
// `document.elementFromPoint` is the only entry point. Guard
15761576
// SSR / non-browser stubs explicitly so the call doesn't throw
15771577
// in test environments without a global `document`.
15781578
if (typeof document === 'undefined' || typeof document.elementFromPoint !== 'function') {
15791579
return [];
15801580
}
1581+
// Scope the lookup to this controller's editor: a page mounting
1582+
// two SuperDoc instances would otherwise have one's entityAt
1583+
// return ids from the other's painted DOM. A null host (no
1584+
// editor mounted, post-destroy, SSR test stub) returns [].
1585+
const editor = resolveHostEditor(superdoc);
1586+
const host = editor?.presentationEditor?.visibleHost;
1587+
if (!host) return [];
15811588
const startEl = document.elementFromPoint(input.x, input.y);
1589+
if (!startEl || !host.contains(startEl)) return [];
15821590
return collectEntityHitsFromChain(startEl);
15831591
},
15841592
};

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

Lines changed: 17 additions & 3 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

@@ -1373,19 +1381,25 @@ export interface ViewportHandle {
13731381
* Look up entities painted under a viewport coordinate. Used by
13741382
* right-click menus and hover tooltips to ask "what's at this point?"
13751383
* without consumers reading `data-track-change-id` /
1376-
* `data-comment-ids` off the painted DOM themselves the
1384+
* `data-comment-ids` off the painted DOM themselves; the
13771385
* data-attribute layout is an implementation detail of the painter
13781386
* that consumers shouldn't depend on.
13791387
*
13801388
* Returns an ordered array of {@link ViewportEntityHit}, innermost
13811389
* first. A point can sit inside several entities at once (a tracked
1382-
* change inside a comment highlight, for example) every match is
1390+
* change inside a comment highlight, for example); every match is
13831391
* surfaced, not just the topmost. Empty array when the point isn't
13841392
* over any painted entity, when called outside a browser, or when no
13851393
* editor is mounted.
13861394
*
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+
*
13871401
* Today the supported entity types are `comment` and `trackedChange`.
1388-
* `link`, `image`, and `tableCell` are reserved for follow-ups
1402+
* `link`, `image`, and `tableCell` are reserved for follow-ups;
13891403
* adding them is purely additive (new union members), so callers can
13901404
* `switch` on `hit.type` and the default branch remains forward
13911405
* compatible.

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ describe('ui.viewport.scrollIntoView', () => {
311311
});
312312
});
313313

314-
describe('ui.viewport.entityAt — input validation', () => {
314+
describe('ui.viewport.entityAt — host scoping', () => {
315315
it('returns [] for invalid input (missing or non-numeric coordinates)', () => {
316316
const { superdoc } = makeStubs();
317317
const ui = createSuperDocUI({ superdoc });
@@ -321,4 +321,44 @@ describe('ui.viewport.entityAt — input validation', () => {
321321

322322
ui.destroy();
323323
});
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+
});
324364
});

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)