Skip to content

Commit 950b8e5

Browse files
committed
fix: bubble positioning for tcs in headers or footers
1 parent 42abfe6 commit 950b8e5

2 files changed

Lines changed: 208 additions & 18 deletions

File tree

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1692,17 +1692,12 @@ export class PresentationEditor extends EventEmitter {
16921692
return this.getRangeRects(selection.from, selection.to, relativeTo);
16931693
}
16941694

1695-
/**
1696-
* Convert an arbitrary document range into layout-based bounding rects.
1697-
*
1698-
* @param from - Start position in the ProseMirror document
1699-
* @param to - End position in the ProseMirror document
1700-
* @param relativeTo - Optional HTMLElement for coordinate reference. If provided, returns coordinates
1701-
* relative to this element's bounding rect. If omitted, returns absolute viewport
1702-
* coordinates relative to the selection overlay.
1703-
* @returns Array of rects, each containing pageIndex and position data (left, top, right, bottom, width, height)
1704-
*/
1705-
getRangeRects(from: number, to: number, relativeTo?: HTMLElement): RangeRect[] {
1695+
#computeRangeRects(
1696+
from: number,
1697+
to: number,
1698+
relativeTo?: HTMLElement,
1699+
options: { forceBodySurface?: boolean } = {},
1700+
): RangeRect[] {
17061701
if (!this.#selectionOverlay) return [];
17071702
if (!Number.isFinite(from) || !Number.isFinite(to)) return [];
17081703

@@ -1720,11 +1715,13 @@ export class PresentationEditor extends EventEmitter {
17201715
let usedDomRects = false;
17211716
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
17221717
const activeNoteSession = this.#getActiveNoteStorySession();
1718+
const useHeaderFooterSurface = !options.forceBodySurface && sessionMode !== 'body';
1719+
const useNoteSurface = !options.forceBodySurface && activeNoteSession != null;
17231720
const layoutRectSource = () => {
1724-
if (sessionMode !== 'body') {
1721+
if (useHeaderFooterSurface) {
17251722
return this.#computeHeaderFooterSelectionRects(start, end);
17261723
}
1727-
if (activeNoteSession) {
1724+
if (useNoteSurface) {
17281725
return this.#computeNoteSelectionRects(start, end);
17291726
}
17301727
const domRects = this.#computeSelectionRectsFromDom(start, end);
@@ -1751,7 +1748,7 @@ export class PresentationEditor extends EventEmitter {
17511748
let domCaretStart: { pageIndex: number; x: number; y: number } | null = null;
17521749
let domCaretEnd: { pageIndex: number; x: number; y: number } | null = null;
17531750
const pageDelta: Record<number, { dx: number; dy: number }> = {};
1754-
if (!usedDomRects && !activeNoteSession) {
1751+
if (!usedDomRects && !useNoteSurface) {
17551752
// Geometry fallback path: apply a small DOM-based delta to reduce drift.
17561753
try {
17571754
domCaretStart = this.#computeDomCaretPageLocal(start);
@@ -1772,8 +1769,8 @@ export class PresentationEditor extends EventEmitter {
17721769
}
17731770

17741771
const pageHeight = this.#getBodyPageHeight();
1775-
const pageGap = sessionMode === 'body' ? (this.#layoutState.layout?.pageGap ?? 0) : 0;
1776-
const finalRects = rawRects
1772+
const pageGap = useHeaderFooterSurface || !this.#layoutState.layout ? 0 : (this.#layoutState.layout.pageGap ?? 0);
1773+
return rawRects
17771774
.map((rect: LayoutRect, idx: number, allRects: LayoutRect[]) => {
17781775
let adjustedX = rect.x;
17791776
let adjustedY = rect.y;
@@ -1817,8 +1814,20 @@ export class PresentationEditor extends EventEmitter {
18171814
};
18181815
})
18191816
.filter((rect: RangeRect | null): rect is RangeRect => Boolean(rect));
1817+
}
18201818

1821-
return finalRects;
1819+
/**
1820+
* Convert an arbitrary document range into layout-based bounding rects.
1821+
*
1822+
* @param from - Start position in the ProseMirror document
1823+
* @param to - End position in the ProseMirror document
1824+
* @param relativeTo - Optional HTMLElement for coordinate reference. If provided, returns coordinates
1825+
* relative to this element's bounding rect. If omitted, returns absolute viewport
1826+
* coordinates relative to the selection overlay.
1827+
* @returns Array of rects, each containing pageIndex and position data (left, top, right, bottom, width, height)
1828+
*/
1829+
getRangeRects(from: number, to: number, relativeTo?: HTMLElement): RangeRect[] {
1830+
return this.#computeRangeRects(from, to, relativeTo);
18221831
}
18231832

18241833
/**
@@ -1853,6 +1862,42 @@ export class PresentationEditor extends EventEmitter {
18531862
};
18541863
}
18551864

1865+
#getThreadSelectionBounds(
1866+
data: { storyKey?: unknown; start?: unknown; end?: unknown; pos?: unknown },
1867+
relativeTo: HTMLElement | undefined,
1868+
): {
1869+
bounds: { top: number; left: number; bottom: number; right: number; width: number; height: number };
1870+
rects: RangeRect[];
1871+
pageIndex: number;
1872+
} | null {
1873+
const start = Number.isFinite(data.start ?? data.pos) ? Number(data.start ?? data.pos) : undefined;
1874+
const end = Number.isFinite(data.end) ? Number(data.end) : start;
1875+
if (!Number.isFinite(start) || !Number.isFinite(end)) {
1876+
return null;
1877+
}
1878+
1879+
const storyKey = typeof data.storyKey === 'string' ? data.storyKey : null;
1880+
const rects =
1881+
storyKey === BODY_STORY_KEY
1882+
? this.#computeRangeRects(start!, end!, relativeTo, { forceBodySurface: true })
1883+
: this.getRangeRects(start!, end!, relativeTo);
1884+
1885+
if (!rects.length) {
1886+
return null;
1887+
}
1888+
1889+
const bounds = this.#aggregateLayoutBounds(rects);
1890+
if (!bounds) {
1891+
return null;
1892+
}
1893+
1894+
return {
1895+
rects,
1896+
bounds,
1897+
pageIndex: rects[0]?.pageIndex ?? 0,
1898+
};
1899+
}
1900+
18561901
/**
18571902
* Remap comment positions to layout coordinates with bounds and rects.
18581903
* Takes a positions object with threadIds as keys and position data as values.
@@ -1925,7 +1970,7 @@ export class PresentationEditor extends EventEmitter {
19251970
return;
19261971
}
19271972

1928-
const layoutRange = this.getSelectionBounds(start!, end!, relativeTo);
1973+
const layoutRange = this.#getThreadSelectionBounds(data, relativeTo);
19291974
if (!layoutRange) {
19301975
remapped[threadId] = data;
19311976
return;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import path from 'node:path';
2+
import { fileURLToPath } from 'node:url';
3+
import type { Locator, Page } from '@playwright/test';
4+
import { test, expect } from '../../fixtures/superdoc.js';
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
const DOC_PATH = path.resolve(
8+
__dirname,
9+
'../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx',
10+
);
11+
12+
test.use({
13+
config: {
14+
toolbar: 'full',
15+
comments: 'panel',
16+
trackChanges: true,
17+
documentMode: 'suggesting',
18+
useHiddenHostForStoryParts: true,
19+
showCaret: true,
20+
showSelection: true,
21+
},
22+
});
23+
24+
type TrackedChangePosition = {
25+
key: string;
26+
top: number;
27+
left: number;
28+
pageIndex: number | null;
29+
};
30+
31+
function getFootnoteLocator(page: Page, noteId: string): Locator {
32+
return page.locator(`[data-block-id^="footnote-${noteId}-"]`).first();
33+
}
34+
35+
async function getBodyTrackedChangePosition(page: Page): Promise<TrackedChangePosition | null> {
36+
return page.evaluate(() => {
37+
const positions = (window as any).superdoc?.commentsStore?.editorCommentPositions ?? {};
38+
for (const [key, entry] of Object.entries(positions)) {
39+
if (!key.startsWith('tc::body::')) {
40+
continue;
41+
}
42+
43+
const bounds = (entry as { bounds?: { top?: unknown; left?: unknown } }).bounds;
44+
if (!bounds || !Number.isFinite(bounds.top) || !Number.isFinite(bounds.left)) {
45+
continue;
46+
}
47+
48+
const pageIndex = (entry as { pageIndex?: unknown }).pageIndex;
49+
return {
50+
key,
51+
top: Number(bounds.top),
52+
left: Number(bounds.left),
53+
pageIndex: Number.isFinite(pageIndex) ? Number(pageIndex) : null,
54+
};
55+
}
56+
57+
return null;
58+
});
59+
}
60+
61+
async function getTrackedChangePositionByKey(page: Page, key: string): Promise<TrackedChangePosition | null> {
62+
return page.evaluate((targetKey: string) => {
63+
const entry = (window as any).superdoc?.commentsStore?.editorCommentPositions?.[targetKey];
64+
const bounds = entry?.bounds;
65+
if (!bounds || !Number.isFinite(bounds.top) || !Number.isFinite(bounds.left)) {
66+
return null;
67+
}
68+
69+
const pageIndex = entry?.pageIndex;
70+
return {
71+
key: targetKey,
72+
top: Number(bounds.top),
73+
left: Number(bounds.left),
74+
pageIndex: Number.isFinite(pageIndex) ? Number(pageIndex) : null,
75+
};
76+
}, key);
77+
}
78+
79+
async function activateFootnote(
80+
superdoc: { page: Page; waitForStable: (ms?: number) => Promise<void> },
81+
noteId: string,
82+
) {
83+
const footnote = getFootnoteLocator(superdoc.page, noteId);
84+
await footnote.scrollIntoViewIfNeeded();
85+
await footnote.waitFor({ state: 'visible', timeout: 15_000 });
86+
87+
const box = await footnote.boundingBox();
88+
expect(box).toBeTruthy();
89+
90+
await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2);
91+
await superdoc.waitForStable();
92+
93+
await expect
94+
.poll(() =>
95+
superdoc.page.evaluate(() => {
96+
const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.();
97+
return session?.locator?.storyType ?? null;
98+
}),
99+
)
100+
.toBe('footnote');
101+
102+
return footnote;
103+
}
104+
105+
test('body tracked-change anchors stay in body space while editing a footnote in suggesting mode', async ({
106+
superdoc,
107+
browserName,
108+
}) => {
109+
test.skip(
110+
browserName === 'firefox',
111+
'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.',
112+
);
113+
114+
await superdoc.loadDocument(DOC_PATH);
115+
await superdoc.waitForStable();
116+
117+
const bodyLine = superdoc.page.locator('.superdoc-line', { hasText: 'Simple text1 with footnotes' }).first();
118+
await bodyLine.waitFor({ state: 'visible', timeout: 15_000 });
119+
120+
const lineBox = await bodyLine.boundingBox();
121+
expect(lineBox).toBeTruthy();
122+
123+
await superdoc.page.mouse.click(lineBox!.x + 12, lineBox!.y + lineBox!.height / 2);
124+
await superdoc.page.keyboard.insertText('BODYFIX ');
125+
await superdoc.waitForStable();
126+
127+
await expect.poll(() => getBodyTrackedChangePosition(superdoc.page)).not.toBeNull();
128+
const before = await getBodyTrackedChangePosition(superdoc.page);
129+
expect(before).toBeTruthy();
130+
131+
const footnote = await activateFootnote(superdoc, '1');
132+
await expect(footnote).toContainText('This is a simple footnote');
133+
134+
await superdoc.page.keyboard.press('End');
135+
await superdoc.page.keyboard.insertText('NOTEFIX');
136+
await superdoc.waitForStable();
137+
138+
await expect.poll(() => getTrackedChangePositionByKey(superdoc.page, before!.key)).not.toBeNull();
139+
const after = await getTrackedChangePositionByKey(superdoc.page, before!.key);
140+
expect(after).toBeTruthy();
141+
142+
expect(after!.pageIndex).toBe(before!.pageIndex);
143+
expect(Math.abs(after!.top - before!.top)).toBeLessThanOrEqual(40);
144+
expect(Math.abs(after!.left - before!.left)).toBeLessThanOrEqual(40);
145+
});

0 commit comments

Comments
 (0)