Skip to content

Commit 21c0a8c

Browse files
committed
fix(ui): resolve captured-selection rects against the routed editor (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.
1 parent 128495a commit 21c0a8c

4 files changed

Lines changed: 71 additions & 76 deletions

File tree

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

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,19 +1601,37 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
16011601
if (!slice.target && !slice.selectionTarget) return null;
16021602
return deepFreeze(deepClone(slice));
16031603
},
1604-
// Painted-selection rects route through the host editor.
1605-
// PresentationEditor lives at the host, and the live-selection rect
1606-
// the painter knows about reflects the routed editor's PM selection
1607-
// already, so calling against the host returns the right answer for
1608-
// body / header / footer / note alike.
1604+
// Painted-selection rects need both editors:
1605+
//
1606+
// - The host editor owns the presentation layer (the rect engine
1607+
// lives there). The live path also flows through it because
1608+
// `presentationEditor.getSelectionRects()` calls `getActiveEditor()`
1609+
// internally and dispatches to the routed surface.
1610+
// - The routed editor owns the PM document that captured block ids
1611+
// belong to. For body captures the two editors are the same; for
1612+
// captures taken while editing a header / footer / footnote /
1613+
// endnote, the routed editor is the story editor and the host
1614+
// editor's PM doc would silently fail to resolve those ids.
1615+
//
1616+
// When focus has moved to a sidebar / composer by call time, the
1617+
// routed editor falls back to the body, and a non-body capture's
1618+
// block ids won't resolve there. The helper returns [] gracefully
1619+
// in that case (rather than wrong rects from another surface).
16091620
getRects(capture) {
1610-
const editor = resolveHostEditor(superdoc);
1611-
return getSelectionRects(editor as unknown as Parameters<typeof getSelectionRects>[0], capture);
1621+
const hostEditor = resolveHostEditor(superdoc);
1622+
const routedEditor = resolveRoutedEditor(superdoc);
1623+
return getSelectionRects(
1624+
hostEditor as unknown as Parameters<typeof getSelectionRects>[0],
1625+
routedEditor as unknown as Parameters<typeof getSelectionRects>[1],
1626+
capture,
1627+
);
16121628
},
16131629
getAnchorRect(options, capture) {
1614-
const editor = resolveHostEditor(superdoc);
1630+
const hostEditor = resolveHostEditor(superdoc);
1631+
const routedEditor = resolveRoutedEditor(superdoc);
16151632
return getSelectionAnchorRect(
1616-
editor as unknown as Parameters<typeof getSelectionAnchorRect>[0],
1633+
hostEditor as unknown as Parameters<typeof getSelectionAnchorRect>[0],
1634+
routedEditor as unknown as Parameters<typeof getSelectionAnchorRect>[1],
16171635
options,
16181636
capture,
16191637
);

packages/super-editor/src/ui/selection-rects.test.ts

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -200,30 +200,6 @@ describe('ui.selection.getRects — captured selection', () => {
200200
expect(ui.selection.getRects(fakeCapture)).toEqual([]);
201201
});
202202

203-
it('returns [] for a capture whose target lives in a non-body story (body-only limitation)', () => {
204-
const { superdoc, mocks } = makeStubs({
205-
rangeRects: [{ pageIndex: 0, left: 10, top: 10, right: 50, bottom: 30, width: 40, height: 20 }],
206-
});
207-
const ui = createSuperDocUI({ superdoc });
208-
209-
const headerCapture = Object.freeze({
210-
empty: false,
211-
target: {
212-
kind: 'text',
213-
story: 'header',
214-
segments: [{ blockId: 'b1', range: { start: 0, end: 4 } }],
215-
},
216-
selectionTarget: null,
217-
activeMarks: [],
218-
activeCommentIds: [],
219-
activeChangeIds: [],
220-
quotedText: 'test',
221-
}) as never;
222-
223-
expect(ui.selection.getRects(headerCapture)).toEqual([]);
224-
expect(mocks.getRangeRects).not.toHaveBeenCalled();
225-
});
226-
227203
it('returns [] when getRangeRects is missing on the presentation stub', () => {
228204
const { superdoc, editor } = makeStubs();
229205
(editor as { presentationEditor: unknown }).presentationEditor = {

packages/super-editor/src/ui/selection-rects.ts

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,45 +21,33 @@ interface RawRangeRect {
2121
height: number;
2222
}
2323

24-
// Captures whose target lives in a non-body story (header, footer,
25-
// footnote, endnote) need a story-aware rect resolver on
26-
// `PresentationEditor` that doesn't yet exist publicly. The live path
27-
// works for non-body selections because `presentationEditor
28-
// .getSelectionRects()` calls `getActiveEditor()` internally and
29-
// dispatches to the right surface; the captured path can't, because by
30-
// the time the consumer is asking for rects, focus has often moved away
31-
// (composer textarea, sidebar, modal) and the active surface is back on
32-
// body. Until a follow-up surfaces a `getRangeRectsForStory(from, to,
33-
// story)` primitive, captures referencing non-body stories return [].
34-
// Same posture as `scroll-into-view`'s text-anchored path.
35-
function captureIsBodyOnly(capture: SelectionCapture): boolean {
36-
const story = (capture.target as { story?: unknown } | null)?.story;
37-
if (story === undefined || story === null) return true;
38-
if (typeof story === 'string') return story === 'body';
39-
if (typeof story === 'object') {
40-
const kind = (story as { kind?: unknown }).kind;
41-
return kind === undefined || kind === 'body';
42-
}
43-
return true;
44-
}
45-
4624
/**
4725
* Resolve the painted rects of the current selection, or of a captured
4826
* one when `capture` is provided. Empty array when the editor has no
4927
* presentation layer (SSR / non-paginated mounts), no current selection,
5028
* or a stale capture whose target no longer resolves.
5129
*
52-
* Captures referencing non-body stories return [] — the underlying
53-
* story-aware rect resolver is a follow-up (body-only matches the same
54-
* posture as `scroll-into-view`'s text-anchored path).
30+
* Two editors are accepted because the capture path needs both:
31+
* - `hostEditor` owns the presentation layer (`getRangeRects`).
32+
* - `routedEditor` owns the PM document that captured block ids belong
33+
* to. For body-only captures these are the same instance; for
34+
* captures taken while editing a header / footer / footnote /
35+
* endnote, the routed editor is the story editor.
36+
*
37+
* The live path uses only `hostEditor.presentationEditor.getSelectionRects()`,
38+
* which routes through its internal `getActiveEditor()` and works on
39+
* every surface.
5540
*/
56-
export function getSelectionRects(editor: Editor | null, capture?: SelectionCapture | null): ViewportRect[] {
57-
const presentation = editor?.presentationEditor;
41+
export function getSelectionRects(
42+
hostEditor: Editor | null,
43+
routedEditor: Editor | null,
44+
capture?: SelectionCapture | null,
45+
): ViewportRect[] {
46+
const presentation = hostEditor?.presentationEditor;
5847
if (!presentation) return [];
5948

6049
if (capture) {
61-
if (!captureIsBodyOnly(capture)) return [];
62-
return getCapturedSelectionRects(editor!, capture);
50+
return getCapturedSelectionRects(hostEditor!, routedEditor ?? hostEditor!, capture);
6351
}
6452

6553
if (typeof presentation.getSelectionRects !== 'function') return [];
@@ -76,11 +64,12 @@ export function getSelectionRects(editor: Editor | null, capture?: SelectionCapt
7664
* `null` when the selection produces no painted rects.
7765
*/
7866
export function getSelectionAnchorRect(
79-
editor: Editor | null,
67+
hostEditor: Editor | null,
68+
routedEditor: Editor | null,
8069
options?: SelectionAnchorRectOptions,
8170
capture?: SelectionCapture | null,
8271
): ViewportRect | null {
83-
const rects = getSelectionRects(editor, capture);
72+
const rects = getSelectionRects(hostEditor, routedEditor, capture);
8473
if (rects.length === 0) return null;
8574

8675
const placement = options?.placement ?? 'start';
@@ -89,8 +78,12 @@ export function getSelectionAnchorRect(
8978
return rects[0]!;
9079
}
9180

92-
function getCapturedSelectionRects(editor: Editor, capture: SelectionCapture): ViewportRect[] {
93-
const presentation = editor.presentationEditor;
81+
function getCapturedSelectionRects(
82+
hostEditor: Editor,
83+
routedEditor: Editor,
84+
capture: SelectionCapture,
85+
): ViewportRect[] {
86+
const presentation = hostEditor.presentationEditor;
9487
if (!presentation || typeof presentation.getRangeRects !== 'function') return [];
9588

9689
const segments = capture.target?.segments;
@@ -102,15 +95,22 @@ function getCapturedSelectionRects(editor: Editor, capture: SelectionCapture): V
10295
const first = segments[0]!;
10396
const last = segments[segments.length - 1]!;
10497

98+
// Resolve block ids against the routed editor so captures taken in
99+
// header / footer / footnote / endnote stories still resolve while
100+
// the user remains in that story (the routed editor is the one whose
101+
// PM document those block ids belong to). When focus has moved
102+
// elsewhere by call time, the routed editor falls back to the body,
103+
// and a non-body capture's block ids won't resolve there — the
104+
// function returns [] gracefully.
105105
let fromResolved: { from: number; to: number } | null = null;
106106
let toResolved: { from: number; to: number } | null = null;
107107
try {
108-
fromResolved = resolveTextTarget(editor, {
108+
fromResolved = resolveTextTarget(routedEditor, {
109109
kind: 'text',
110110
blockId: first.blockId,
111111
range: first.range,
112112
});
113-
toResolved = resolveTextTarget(editor, {
113+
toResolved = resolveTextTarget(routedEditor, {
114114
kind: 'text',
115115
blockId: last.blockId,
116116
range: last.range,

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -799,14 +799,15 @@ export interface SelectionHandle {
799799
* footer, footnote, endnote — because `PresentationEditor` routes
800800
* selection-rect lookups through its currently active editor.
801801
*
802-
* The captured path is body-only today: captures whose target lives
803-
* in a non-body story return `[]`. Story-aware rect resolution for
804-
* captures requires a `getRangeRectsForStory(from, to, story)`
805-
* primitive on PresentationEditor that doesn't yet exist; until that
806-
* lands, consumers building floating UI on header / footer / note
807-
* surfaces should query rects from the live selection (no capture)
808-
* before focus moves elsewhere. Same posture as
809-
* `ui.viewport.scrollIntoView` for text-anchored targets.
802+
* The captured path resolves block ids against the currently routed
803+
* editor, so captures taken in a non-body story still produce the
804+
* right rects while the user remains in that story (the common case
805+
* for a bubble menu or composer that opens a sidebar). When focus
806+
* has moved to a different story (or the body) by call time, the
807+
* captured block ids no longer resolve and the call returns `[]`
808+
* rather than rects from the wrong surface — fully cross-surface
809+
* captured rects need a story-keyed lookup that doesn't yet exist
810+
* publicly on `PresentationEditor`.
810811
*/
811812
getRects(capture?: SelectionCapture | null): ViewportRect[];
812813
/**

0 commit comments

Comments
 (0)