From 4f3a719807460a72d74cbe1b00b0dd206e37808b Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 14:47:18 -0300 Subject: [PATCH 01/36] fix(layout): render footnotes when the body fills a terminal page (SD-3400) On the last page there is no continuation target, so the SD-2656 bodyMaxY-anchored maxReserve collapses to ~0 once the body fills the page: the planner can place nothing, reserves[pageIndex] stays 0, the body never yields, and every anchored footnote is silently dropped (no error). Reproduced on Footnote tests.docx: 0 of 6 footnotes rendered. Add a terminal-page reserve bump mirroring the existing carry-forward bump: when a footnote is anchored on the last page and the placed reserve is short of its demand, reserve that demand (capped at the physical band) so the next relayout pass shrinks the body and the footnote renders on its anchor page, matching Word. Guarded on reserves[pageIndex] < clusterDemand so pages whose footnote already placed fully are untouched (no gap regression on non-dense pages or multi-page splits). Footnote tests.docx now renders all 6 footnotes (grows 1 to 2 pages). --- .../layout-bridge/src/incrementalLayout.ts | 36 ++++++ .../test/footnoteDensePageRender.test.ts | 108 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 packages/layout-engine/layout-bridge/test/footnoteDensePageRender.test.ts diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 4412344af4..c109ddc035 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -1924,6 +1924,42 @@ export async function incrementalLayout( const finalReserve = Math.min(clusterRoomPx + continuationToReservePx + overheadForBand, nextPageMaxBand); reserves[pageIndex + 1] = Math.max(reserves[pageIndex + 1] ?? 0, Math.ceil(finalReserve)); } + } else { + // SD-3400: terminal-page footnote reserve bump. + // The carry-forward bump above only runs when there is a next page to + // drain onto. On the LAST page a footnote anchored here has nowhere to + // continue, so once the body fills the page the bodyMaxY-derived + // maxReserve collapses to ~0, placeFootnote can place nothing, and + // reserves[pageIndex] stays 0 — the body never yields and the footnote + // is silently dropped. When the placed reserve is short of the anchored + // demand, bump this page's reserve to that demand (capped at the + // physical band) so the next relayout pass shrinks the body and the + // footnote renders on its anchor page (matching Word). Guarded on + // `< clusterDemand` so pages whose footnote already placed fully are + // untouched — no gap/regression on non-dense pages. + let clusterDemand = 0; + for (let cIdx = 0; cIdx < columnCount; cIdx += 1) { + const idsHere = idsByColumn.get(pageIndex)?.get(cIdx) ?? []; + if (idsHere.length === 0) continue; + let columnCluster = 0; + for (let i = 0; i < idsHere.length; i += 1) { + columnCluster += fullHeightOf(idsHere[i]); + if (i > 0) columnCluster += safeGap; + } + if (columnCluster > clusterDemand) clusterDemand = columnCluster; + } + if (clusterDemand > 0 && (reserves[pageIndex] ?? 0) < clusterDemand) { + const overhead = safeSeparatorSpacingBefore + continuationDividerHeight + safeTopPadding; + const thisPage = layoutForPages.pages?.[pageIndex]; + const thisPageSize = thisPage?.size ?? layoutForPages.pageSize ?? DEFAULT_PAGE_SIZE; + const thisTop = normalizeMargin(thisPage?.margins?.top, DEFAULT_MARGINS.top); + const thisBottomRaw = normalizeMargin(thisPage?.margins?.bottom, DEFAULT_MARGINS.bottom); + const physicalContentHeight = Math.max(0, thisPageSize.h - thisTop - thisBottomRaw); + const minBodyHeight = MIN_FOOTNOTE_BODY_HEIGHT * 20; + const thisPageMaxBand = Math.max(0, physicalContentHeight - minBodyHeight); + const finalReserve = Math.min(clusterDemand + overhead, thisPageMaxBand); + reserves[pageIndex] = Math.max(reserves[pageIndex] ?? 0, Math.ceil(finalReserve)); + } } } diff --git a/packages/layout-engine/layout-bridge/test/footnoteDensePageRender.test.ts b/packages/layout-engine/layout-bridge/test/footnoteDensePageRender.test.ts new file mode 100644 index 0000000000..3533734979 --- /dev/null +++ b/packages/layout-engine/layout-bridge/test/footnoteDensePageRender.test.ts @@ -0,0 +1,108 @@ +/** + * SD-3400 (prerequisite): footnotes must still render when the body nearly fills + * a single (terminal) page. + * + * Root cause: the SD-2656 bodyMaxY-anchored reserve (`computeMaxFootnoteReserve`) + * makes the planner's max reserve equal the leftover body-region space + * (`pageH - bottomMargin - bodyMaxY`). When the body fills the page that leftover + * is ~0, so the footnote can't be placed; on a single-page document there is no + * continuation page to overflow onto, and the footnote is silently dropped. + * + * Reproduced live: `Footnote tests.docx` (body fills ~98% of the body region, + * leaving ~17px) renders 0 of its 6 footnotes. `basic-footnotes.docx` (tiny body) + * and multi-page docs render fine — so this is specifically the dense terminal-page + * case. + * + * Invariant: a footnote anchored on a page must render its body. The body must + * yield space (break earlier / grow a page) so the footnote fits on its anchor + * page, rather than being dropped. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { FlowBlock, Measure } from '@superdoc/contracts'; +import { incrementalLayout } from '../src/incrementalLayout'; + +const makeParagraph = (id: string, text: string, pmStart: number): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [{ text, fontFamily: 'Arial', fontSize: 12, pmStart, pmEnd: pmStart + text.length }], +}); + +const makeMeasure = (lineHeight: number, lineCount: number): Measure => ({ + kind: 'paragraph', + lines: Array.from({ length: lineCount }, (_, i) => ({ + fromRun: 0, + fromChar: i, + toRun: 0, + toChar: i + 1, + width: 200, + ascent: lineHeight * 0.8, + descent: lineHeight * 0.2, + lineHeight, + })), + totalHeight: lineCount * lineHeight, +}); + +const countFootnoteFragments = (layout: { pages: Array<{ fragments: Array<{ blockId?: string }> }> }, idPrefix: string) => { + let count = 0; + for (const page of layout.pages) { + for (const f of page.fragments) { + if (String(f.blockId).startsWith(idPrefix)) count += 1; + } + } + return count; +}; + +describe('SD-3400 prerequisite: footnote render on a dense terminal page', () => { + it('renders the footnote body even when the body nearly fills the only page', async () => { + // Page geometry: body region = 600px (h 744, margins 72/72), line height 20. + // 30 body lines × 20 = 600px → the body fills the region exactly, leaving ~0 + // reserve. The footnote ref is mid-body (line 10), so it is anchored on page 1. + const BODY_LINES = 30; + const LINE_H = 20; + const FOOTNOTE_LINES = 5; + const FOOTNOTE_LINE_H = 12; + + let pos = 0; + const blocks: FlowBlock[] = []; + for (let i = 0; i < BODY_LINES; i += 1) { + const text = `Body line ${i + 1}.`; + blocks.push(makeParagraph(`body-${i}`, text, pos)); + pos += text.length + 1; + } + const refBlock = blocks[9]; + const refPos = (refBlock.kind === 'paragraph' ? (refBlock.runs?.[0]?.pmStart ?? 0) : 0) + 2; + const ftBlock = makeParagraph('footnote-1-0-paragraph', 'Footnote body content here.', 0); + + const measureBlock = vi.fn(async (b: FlowBlock) => { + if (b.id.startsWith('footnote-')) return makeMeasure(FOOTNOTE_LINE_H, FOOTNOTE_LINES); + return makeMeasure(LINE_H, 1); + }); + + const margins = { top: 72, right: 72, bottom: 72, left: 72 }; + const result = await incrementalLayout( + [], + null, + blocks, + { + pageSize: { w: 612, h: 600 + margins.top + margins.bottom }, + margins, + footnotes: { + refs: [{ id: '1', pos: refPos }], + blocksById: new Map([['1', [ftBlock]]]), + topPadding: 6, + dividerHeight: 6, + }, + }, + measureBlock, + ); + + // The footnote body must render somewhere (it is currently dropped → 0). + const noteFragments = countFootnoteFragments(result.layout, 'footnote-1'); + expect(noteFragments).toBeGreaterThan(0); + + // And its separator should render too, confirming the band exists. + const sepFragments = countFootnoteFragments(result.layout, 'footnote-separator'); + expect(sepFragments).toBeGreaterThan(0); + }); +}); From c95425c0ed3c317bd6ef64f2334e95c31a1246b9 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 14:54:24 -0300 Subject: [PATCH 02/36] feat(footnote): text cursor over footnote and endnote content (SD-3400) Note content is painted as generic .superdoc-fragment elements marked contenteditable=false, so the browser showed a default arrow over editable note text instead of an I-beam. Add a dedicated ensureFootnoteStyles injector (mirroring the other per-concern ensure*Styles) that sets cursor: text on fragments whose block-id starts with footnote-/endnote-/__sd_semantic_footnote- /__sd_semantic_endnote-. Wired into the renderer's one-time style injection. --- .../painters/dom/src/renderer.ts | 2 ++ .../painters/dom/src/styles.test.ts | 17 +++++++++- .../layout-engine/painters/dom/src/styles.ts | 32 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 0ad87bf7e7..d0817e2a1c 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -65,6 +65,7 @@ import { containerStyles, containerStylesHorizontal, ensureFieldAnnotationStyles, + ensureFootnoteStyles, ensureFormattingMarksStyles, ensureImageSelectionStyles, ensureLinkStyles, @@ -1301,6 +1302,7 @@ export class DomPainter { ensureSdtContainerStyles(doc); ensureImageSelectionStyles(doc); ensureMathMencloseStyles(doc); + ensureFootnoteStyles(doc); if (!this.isSemanticFlow && this.options.ruler?.enabled) { ensureRulerStyles(doc); } diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index b8eb27442e..79d0b79fe3 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ensureSdtContainerStyles, ensureTrackChangeStyles, lineStyles } from './styles.js'; +import { ensureFootnoteStyles, ensureSdtContainerStyles, ensureTrackChangeStyles, lineStyles } from './styles.js'; describe('lineStyles', () => { it('sets height and lineHeight from the argument', () => { @@ -14,6 +14,21 @@ describe('lineStyles', () => { }); }); +describe('ensureFootnoteStyles', () => { + it('renders a text cursor over footnote and endnote note content (SD-3400)', () => { + ensureFootnoteStyles(document); + + const styleEl = document.querySelector('[data-superdoc-footnote-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + + // Note fragments are generic .superdoc-fragment elements keyed by block-id prefix. + expect(cssText).toContain('[data-block-id^="footnote-"]'); + expect(cssText).toContain('[data-block-id^="endnote-"]'); + expect(cssText).toContain('[data-block-id^="__sd_semantic_footnote-"]'); + expect(cssText).toContain('cursor: text;'); + }); +}); + describe('ensureSdtContainerStyles', () => { it('exposes hover border tokens for structured content overrides', () => { ensureSdtContainerStyles(document); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index f84115e6f6..4482cdcbc5 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -1154,6 +1154,24 @@ menclose::after { } `; +/** + * SD-3400: footnote/endnote note content uses a text (I-beam) cursor like body + * text, not the default arrow. Note fragments are painted as generic + * `.superdoc-fragment` elements distinguished only by their block-id prefix + * (footnote-/endnote-/__sd_semantic_footnote-/__sd_semantic_endnote-), so the + * cursor rule keys off `data-block-id`. The renderer marks these fragments + * contenteditable=false, so without this rule the browser shows a default arrow + * over editable note text. + */ +const FOOTNOTE_STYLES = ` +[data-block-id^="footnote-"], +[data-block-id^="endnote-"], +[data-block-id^="__sd_semantic_footnote-"], +[data-block-id^="__sd_semantic_endnote-"] { + cursor: text; +} +`; + let printStylesInjected = false; let linkStylesInjected = false; let trackChangeStylesInjected = false; @@ -1162,6 +1180,7 @@ let sdtContainerStylesInjected = false; let fieldAnnotationStylesInjected = false; let imageSelectionStylesInjected = false; let mathMencloseStylesInjected = false; +let footnoteStylesInjected = false; export const ensurePrintStyles = (doc: Document | null | undefined) => { if (printStylesInjected || !doc) return; @@ -1245,3 +1264,16 @@ export const ensureMathMencloseStyles = (doc: Document | null | undefined) => { doc.head?.appendChild(styleEl); mathMencloseStylesInjected = true; }; + +/** + * Injects footnote/endnote interaction styles (text cursor over note content) + * into the document head. Injected once per document lifecycle. (SD-3400) + */ +export const ensureFootnoteStyles = (doc: Document | null | undefined) => { + if (footnoteStylesInjected || !doc) return; + const styleEl = doc.createElement('style'); + styleEl.setAttribute('data-superdoc-footnote-styles', 'true'); + styleEl.textContent = FOOTNOTE_STYLES; + doc.head?.appendChild(styleEl); + footnoteStylesInjected = true; +}; From 2783a04184521d925aa18832e84d1d267119d514 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 15:02:07 -0300 Subject: [PATCH 03/36] feat(footnote): double-click a body reference to navigate to its note (SD-3400) Double-clicking a footnote/endnote reference marker in the body now opens the corresponding note. The painted reference is a superscript run carrying data-pm-start but no note id, so #handleDoubleClick resolves the PM node at that position; when it is a footnoteReference/endnoteReference it builds the note target and calls the existing activateRenderedNoteSession, which focuses the note session and scrolls it into view. Single-click behavior is unchanged. --- .../pointer-events/EditorInputManager.ts | 38 +++++++++++++++ .../EditorInputManager.footnoteClick.test.ts | 47 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index adfa044657..dc153f0f20 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -1892,6 +1892,29 @@ export class EditorInputManager { this.#callbacks.clearHoverRegion?.(); } + /** + * SD-3400: resolve a double-clicked BODY footnote/endnote reference marker to + * its note target so navigation can open the corresponding note. The painted + * reference is a superscript run carrying `data-pm-start` (the PM position of + * the footnoteReference/endnoteReference node) but no note id, so we read the + * node at that position to recover the story type and id. + */ + #resolveFootnoteReferenceTargetAtPointer(target: HTMLElement | null): RenderedNoteTarget | null { + const refEl = target?.closest?.('[data-pm-start]') as HTMLElement | null; + if (!refEl) return null; + const pmStart = Number(refEl.getAttribute('data-pm-start')); + if (!Number.isFinite(pmStart)) return null; + const node = this.#deps?.getEditor()?.state?.doc?.nodeAt(pmStart); + const nodeType = node?.type?.name; + if (nodeType !== 'footnoteReference' && nodeType !== 'endnoteReference') return null; + const noteId = node?.attrs?.id; + if (noteId == null || String(noteId).length === 0) return null; + return { + storyType: nodeType === 'endnoteReference' ? 'endnote' : 'footnote', + noteId: String(noteId), + }; + } + #handleDoubleClick(event: MouseEvent): void { if (!this.#deps) return; if (event.button !== 0) return; @@ -1914,6 +1937,21 @@ export class EditorInputManager { const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY); if (!normalized) return; + // SD-3400: double-clicking a BODY footnote/endnote reference marker navigates + // to its note content. Activating the note session focuses the note and scrolls + // its selection into view, so the user lands on the corresponding note. + const footnoteRefTarget = this.#resolveFootnoteReferenceTargetAtPointer(target); + if (footnoteRefTarget) { + event.preventDefault(); + event.stopPropagation(); + this.#callbacks.activateRenderedNoteSession?.(footnoteRefTarget, { + clientX: event.clientX, + clientY: event.clientY, + pageIndex: normalized.pageIndex, + }); + return; + } + const clickedNoteTarget = this.#resolveRenderedNoteTargetAtPointer(target, event.clientX, event.clientY); if (clickedNoteTarget) { if (isSameRenderedNoteTarget(this.#getActiveRenderedNoteTarget(), clickedNoteTarget)) { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts index 22625445b6..fa7128ed1d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -443,6 +443,53 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(activateRenderedNoteSession).not.toHaveBeenCalled(); }); + describe('SD-3400: double-click a body footnote/endnote reference navigates to the note', () => { + const makeRefSpan = (pmStart: number, text: string) => { + const refEl = document.createElement('span'); + refEl.setAttribute('data-pm-start', String(pmStart)); + refEl.setAttribute('data-pm-end', String(pmStart + 1)); + refEl.textContent = text; + viewportHost.appendChild(refEl); + return refEl; + }; + + it('activates the footnote session for the referenced note', () => { + (mockEditor.state.doc as unknown as { nodeAt: (pos: number) => unknown }).nodeAt = (pos: number) => + pos === 38 ? { type: { name: 'footnoteReference' }, attrs: { id: '3' } } : null; + const refEl = makeRefSpan(38, '3'); + + refEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, button: 0, clientX: 5, clientY: 5 })); + + expect(activateRenderedNoteSession).toHaveBeenCalledWith( + { storyType: 'footnote', noteId: '3' }, + expect.objectContaining({ clientX: 5, clientY: 5 }), + ); + }); + + it('activates the endnote session for a body endnote reference', () => { + (mockEditor.state.doc as unknown as { nodeAt: (pos: number) => unknown }).nodeAt = (pos: number) => + pos === 50 ? { type: { name: 'endnoteReference' }, attrs: { id: '2' } } : null; + const refEl = makeRefSpan(50, 'ii'); + + refEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, button: 0, clientX: 7, clientY: 9 })); + + expect(activateRenderedNoteSession).toHaveBeenCalledWith( + { storyType: 'endnote', noteId: '2' }, + expect.objectContaining({ clientX: 7, clientY: 9 }), + ); + }); + + it('does not activate when double-clicking ordinary body text', () => { + (mockEditor.state.doc as unknown as { nodeAt: (pos: number) => unknown }).nodeAt = (pos: number) => + pos === 12 ? { type: { name: 'text' }, attrs: {} } : null; + const refEl = makeRefSpan(12, 'word'); + + refEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, button: 0, clientX: 5, clientY: 5 })); + + expect(activateRenderedNoteSession).not.toHaveBeenCalled(); + }); + }); + it('does not activate a note session on semantic footnotes heading click', () => { (resolvePointerPositionHit as unknown as Mock).mockReturnValue(null); From 7054af08cbda13febbb6dd1c99e16cb71cd9cab5 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 15:22:01 -0300 Subject: [PATCH 04/36] feat(footnote): programmatic note activation for insert-and-focus (SD-3400) Add PresentationEditor.activateNoteSession(target): opens a footnote/endnote note session without a pointer, focusing the note and scrolling it into view with the caret at the note's start. Makes #activateRenderedNoteSession's click coords optional so the no-coords path skips hit-testing and lands at note start. This closes the last gap in the insert-footnote flow: the existing document.footnotes.insert() API creates the body marker + note entry (and the notes part if absent) and returns the new noteId; a custom toolbar action then calls activateNoteSession({ storyType, noteId }) so focus moves into the new note and the user can type immediately. Insert stays in the document API and focus stays in the presentation layer; the toolbar action composes the two (kept off the default toolbar). --- .../presentation-editor/PresentationEditor.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 56360c4f6a..9611b4b796 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -8992,7 +8992,7 @@ export class PresentationEditor extends EventEmitter { #activateRenderedNoteSession( target: RenderedNoteTarget, - options: { clientX: number; clientY: number; pageIndex?: number }, + options: { clientX?: number; clientY?: number; pageIndex?: number }, ): boolean { if ((this.#headerFooterSession?.session?.mode ?? 'body') !== 'body') { this.#headerFooterSession?.exitMode(); @@ -9028,15 +9028,20 @@ export class PresentationEditor extends EventEmitter { }, ); - const hit = this.hitTest(options.clientX, options.clientY); const doc = session.editor.state?.doc; - if (hit && doc) { - try { - const selection = this.#createCollapsedSelectionNearInlineContent(doc, hit.pos); - const tr = session.editor.state.tr.setSelection(selection); - session.editor.view?.dispatch(tr); - } catch { - // Ignore stale pointer hits during activation races. + // SD-3400: pointer activation places the caret at the click position; + // programmatic activation (no coords, e.g. insert-footnote focus) leaves the + // caret at the note's default start so the user can type from the beginning. + if (typeof options.clientX === 'number' && typeof options.clientY === 'number' && doc) { + const hit = this.hitTest(options.clientX, options.clientY); + if (hit) { + try { + const selection = this.#createCollapsedSelectionNearInlineContent(doc, hit.pos); + const tr = session.editor.state.tr.setSelection(selection); + session.editor.view?.dispatch(tr); + } catch { + // Ignore stale pointer hits during activation races. + } } } @@ -9046,6 +9051,16 @@ export class PresentationEditor extends EventEmitter { return true; } + /** + * SD-3400: programmatically open a footnote/endnote note session without a + * pointer. Focuses the note and scrolls it into view with the caret at the + * note's start. Used by insert-footnote (and any non-pointer navigation) so + * the user can immediately type in the new note. + */ + activateNoteSession(target: RenderedNoteTarget): boolean { + return this.#activateRenderedNoteSession(target, {}); + } + #exitActiveStorySession(): void { const session = this.#getActiveStorySession(); if (!session) { From b00d1a49808c7fb254c3a09bcc1bf3ba091030e4 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 15:47:54 -0300 Subject: [PATCH 05/36] feat(footnote): staged backspace/delete of body markers (SD-3400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word-like two-step delete from the body. The first Backspace with a collapsed caret immediately after a footnote/endnote reference selects the marker (a TextSelection spanning the atom, since footnoteReference is selectable:false); the second Backspace sees a non-empty selection, so the new command returns false and the chain falls through to deleteSelection, which removes the marker. Removal/renumber then cascade through the existing pipeline (the renderer only paints notes that still have a body reference). New selectFootnoteMarkerBefore / selectFootnoteMarkerAfter commands wired into the Backspace chain (after selectInlineSdtBeforeRunStart, before backspaceAtomBefore) and Delete chain (after selectInlineSdtAfterRunEnd). footnoteReference is intentionally NOT added to the backspaceAtomBefore allowlist — staged selection + deleteSelection mirrors the SDT precedent. Verified end-to-end: 1st press selects marker, 2nd press deletes it; note drops from the area and remaining notes renumber (6 to 5; {2:1,3:2,4:3,5:4,6:5}). --- .../src/editors/v1/core/commands/index.js | 1 + .../commands/selectFootnoteMarkerBefore.js | 96 +++++++++++++ .../selectFootnoteMarkerBefore.test.js | 130 ++++++++++++++++++ .../extensions/keymap-backspace-chain.test.js | 9 +- .../src/editors/v1/core/extensions/keymap.js | 2 + 5 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.js create mode 100644 packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.test.js diff --git a/packages/super-editor/src/editors/v1/core/commands/index.js b/packages/super-editor/src/editors/v1/core/commands/index.js index 8e8a325d8d..3205344db4 100644 --- a/packages/super-editor/src/editors/v1/core/commands/index.js +++ b/packages/super-editor/src/editors/v1/core/commands/index.js @@ -53,6 +53,7 @@ export * from './backspaceNextToRun.js'; export * from './backspaceAcrossRuns.js'; export * from './backspaceAtomBefore.js'; export * from './selectInlineSdtBeforeRunStart.js'; +export * from './selectFootnoteMarkerBefore.js'; export * from './selectBlockSdtAtTextBlockBoundary.js'; export * from './deleteBlockSdtAtTextBlockStart.js'; export * from './moveIntoBlockSdtBeforeTextBlockStart.js'; diff --git a/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.js b/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.js new file mode 100644 index 0000000000..bd8b7ba128 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.js @@ -0,0 +1,96 @@ +import { TextSelection } from 'prosemirror-state'; + +export const SELECT_FOOTNOTE_MARKER_META = 'selectFootnoteMarker'; + +const isNoteReference = (node) => + node?.type.name === 'footnoteReference' || node?.type.name === 'endnoteReference'; + +function getPreviousNoteMarker(state) { + const { $from } = state.selection; + + // Run-wrapped case: caret at the start of a run, marker is the node before the run. + if ($from.parent.type.name === 'run' && $from.parentOffset === 0) { + const runStart = $from.before($from.depth); + const node = state.doc.resolve(runStart).nodeBefore; + if (!isNoteReference(node)) return null; + return { node, pos: runStart - node.nodeSize }; + } + + const node = $from.nodeBefore; + if (!isNoteReference(node)) return null; + return { node, pos: $from.pos - node.nodeSize }; +} + +function getNextNoteMarker(state) { + const { $from } = state.selection; + + // Run-wrapped case: caret at the end of a run, marker is the node after the run. + if ($from.parent.type.name === 'run' && $from.parentOffset === $from.parent.content.size) { + const runEnd = $from.after($from.depth); + const node = state.doc.resolve(runEnd).nodeAfter; + if (!isNoteReference(node)) return null; + return { node, pos: runEnd }; + } + + const node = $from.nodeAfter; + if (!isNoteReference(node)) return null; + return { node, pos: $from.pos }; +} + +function selectNoteMarker(state, dispatch, marker) { + if (dispatch) { + const from = marker.pos; + const to = marker.pos + marker.node.nodeSize; + dispatch( + state.tr.setMeta(SELECT_FOOTNOTE_MARKER_META, true).setSelection(TextSelection.create(state.doc, from, to)), + ); + } + + return true; +} + +/** + * SD-3400: Word-like staged delete of footnote/endnote markers. + * + * When Backspace is pressed with a collapsed caret immediately after a + * footnote/endnote reference marker, select the marker instead of deleting it. + * The next Backspace sees a non-empty selection, so this command returns false + * and the chain falls through to `deleteSelection`, which removes the marker and + * lets the footnote renumber (and drop from the note area, since the renderer + * only paints notes that still have a body reference). + * + * `footnoteReference` is `selectable: false`, so a `TextSelection` spanning the + * atom is used as the highlight (a `NodeSelection` is unavailable). + * + * @returns {import('@core/commands/types').Command} + */ +export const selectFootnoteMarkerBefore = + () => + ({ state, dispatch }) => { + const { selection } = state; + if (!selection.empty) return false; + + const marker = getPreviousNoteMarker(state); + if (!marker) return false; + + return selectNoteMarker(state, dispatch, marker); + }; + +/** + * SD-3400: forward (Delete-key) mirror of {@link selectFootnoteMarkerBefore}. + * Selects a footnote/endnote marker immediately after the caret on the first + * Delete; the second Delete removes it via the selection fall-through. + * + * @returns {import('@core/commands/types').Command} + */ +export const selectFootnoteMarkerAfter = + () => + ({ state, dispatch }) => { + const { selection } = state; + if (!selection.empty) return false; + + const marker = getNextNoteMarker(state); + if (!marker) return false; + + return selectNoteMarker(state, dispatch, marker); + }; diff --git a/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.test.js b/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.test.js new file mode 100644 index 0000000000..f411ef1e61 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.test.js @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; +import { selectFootnoteMarkerBefore, selectFootnoteMarkerAfter } from './selectFootnoteMarkerBefore.js'; + +// Mirrors the real document shape: each footnote/endnote reference is an inline +// atom wrapped in its own run (verified against live docs — parentType 'run', +// parentOffset 0). +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + run: { inline: true, group: 'inline', content: 'inline*' }, + footnoteReference: { + inline: true, + group: 'inline', + atom: true, + selectable: false, + attrs: { id: { default: null } }, + }, + endnoteReference: { + inline: true, + group: 'inline', + atom: true, + selectable: false, + attrs: { id: { default: null } }, + }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +const makeDoc = (schema, refType = 'footnoteReference') => { + const beforeRun = schema.nodes.run.create(null, schema.text('Before')); + const markerRun = schema.nodes.run.create(null, schema.nodes[refType].create({ id: '1' })); + const afterRun = schema.nodes.run.create(null, schema.text('After')); + return schema.node('doc', null, [schema.node('paragraph', null, [beforeRun, markerRun, afterRun])]); +}; + +const findNode = (doc, typeName) => { + let result = null; + doc.descendants((node, pos) => { + if (node.type.name === typeName) { + result = { node, pos, end: pos + node.nodeSize }; + return false; + } + return true; + }); + return result; +}; + +describe('selectFootnoteMarkerBefore', () => { + it.each(['footnoteReference', 'endnoteReference'])('selects a %s marker immediately before the caret', (refType) => { + const schema = makeSchema(); + const doc = makeDoc(schema, refType); + const marker = findNode(doc, refType); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, marker.end) }); + + let dispatched; + const ok = selectFootnoteMarkerBefore()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + // TextSelection (not NodeSelection — the marker is selectable:false) spanning the atom. + expect(dispatched.selection).toBeInstanceOf(TextSelection); + expect(dispatched.selection).not.toBeInstanceOf(NodeSelection); + expect(dispatched.selection.from).toBe(marker.pos); + expect(dispatched.selection.to).toBe(marker.end); + }); + + it('returns true without dispatching when no dispatch is provided (first-press select is allowed)', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const marker = findNode(doc, 'footnoteReference'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, marker.end) }); + + expect(selectFootnoteMarkerBefore()({ state })).toBe(true); + }); + + it('returns false on the second press (selection already spans the marker) so deleteSelection runs', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const marker = findNode(doc, 'footnoteReference'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, marker.pos, marker.end) }); + const dispatch = vi.fn(); + + expect(selectFootnoteMarkerBefore()({ state, dispatch })).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the node before the caret is not a note marker', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const beforeRun = findNode(doc, 'run'); + // Caret in the middle of the leading "Before" run — nothing to stage. + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeRun.pos + 3) }); + const dispatch = vi.fn(); + + expect(selectFootnoteMarkerBefore()({ state, dispatch })).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); + +describe('selectFootnoteMarkerAfter', () => { + it.each(['footnoteReference', 'endnoteReference'])('selects a %s marker immediately after the caret', (refType) => { + const schema = makeSchema(); + const doc = makeDoc(schema, refType); + const marker = findNode(doc, refType); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, marker.pos) }); + + let dispatched; + const ok = selectFootnoteMarkerAfter()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched.selection).toBeInstanceOf(TextSelection); + expect(dispatched.selection.from).toBe(marker.pos); + expect(dispatched.selection.to).toBe(marker.end); + }); + + it('returns false when the node after the caret is not a note marker', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const afterRun = findNode(doc, 'run'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterRun.pos + 1) }); + const dispatch = vi.fn(); + + expect(selectFootnoteMarkerAfter()({ state, dispatch })).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js index 40ba420e99..739d28daf2 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js @@ -38,6 +38,7 @@ describe('handleBackspace chain ordering', () => { undoInputRule: make('undoInputRule'), deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtBeforeRunStart: make('selectInlineSdtBeforeRunStart'), + selectFootnoteMarkerBefore: make('selectFootnoteMarkerBefore'), selectBlockSdtBeforeTextBlockStart: make('selectBlockSdtBeforeTextBlockStart'), moveIntoBlockSdtBeforeTextBlockStart: make('moveIntoBlockSdtBeforeTextBlockStart'), backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'), @@ -77,6 +78,7 @@ describe('handleBackspace chain ordering', () => { // step 2 sets inputType meta and returns false (no command call) 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtBeforeRunStart', + 'selectFootnoteMarkerBefore', 'selectBlockSdtBeforeTextBlockStart', 'moveIntoBlockSdtBeforeTextBlockStart', 'backspaceEmptyRunParagraph', @@ -109,8 +111,9 @@ describe('handleBackspace chain ordering', () => { expect(callLog[0]).toBe('undoInputRule'); expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart'); expect(callLog[2]).toBe('selectInlineSdtBeforeRunStart'); - expect(callLog[3]).toBe('selectBlockSdtBeforeTextBlockStart'); - expect(callLog[4]).toBe('moveIntoBlockSdtBeforeTextBlockStart'); + expect(callLog[3]).toBe('selectFootnoteMarkerBefore'); + expect(callLog[4]).toBe('selectBlockSdtBeforeTextBlockStart'); + expect(callLog[5]).toBe('moveIntoBlockSdtBeforeTextBlockStart'); }); it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => { @@ -182,6 +185,7 @@ describe('handleDelete chain ordering', () => { const commands = { deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtAfterRunEnd: make('selectInlineSdtAfterRunEnd'), + selectFootnoteMarkerAfter: make('selectFootnoteMarkerAfter'), selectBlockSdtAfterTextBlockEnd: make('selectBlockSdtAfterTextBlockEnd'), moveIntoBlockSdtAfterTextBlockEnd: make('moveIntoBlockSdtAfterTextBlockEnd'), deleteSkipEmptyRun: make('deleteSkipEmptyRun'), @@ -216,6 +220,7 @@ describe('handleDelete chain ordering', () => { expect(callLog).toEqual([ 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtAfterRunEnd', + 'selectFootnoteMarkerAfter', 'selectBlockSdtAfterTextBlockEnd', 'moveIntoBlockSdtAfterTextBlockEnd', 'deleteSkipEmptyRun', diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap.js b/packages/super-editor/src/editors/v1/core/extensions/keymap.js index 2867757f1a..9d3670a67f 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -39,6 +39,7 @@ export const handleBackspace = (editor) => { }, () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtBeforeRunStart(), + () => commands.selectFootnoteMarkerBefore?.() ?? false, () => commands.selectBlockSdtBeforeTextBlockStart(), () => commands.moveIntoBlockSdtBeforeTextBlockStart(), () => commands.backspaceEmptyRunParagraph(), @@ -62,6 +63,7 @@ export const handleDelete = (editor) => { return editor.commands.first(({ commands }) => [ () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtAfterRunEnd(), + () => commands.selectFootnoteMarkerAfter?.() ?? false, () => commands.selectBlockSdtAfterTextBlockEnd(), () => commands.moveIntoBlockSdtAfterTextBlockEnd(), () => commands.deleteSkipEmptyRun(), From ad308bd1b467d2e32508c54d8dc0afff1fdc3a6f Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 16:09:21 -0300 Subject: [PATCH 06/36] feat(footnote): area-delete removes the footnote on both sides (SD-3400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing all content of a footnote/endnote in the note area now deletes the whole footnote: commitNoteRuntime detects empty content and calls footnotesRemoveWrapper, which deletes the body reference node AND removes the OOXML note element (when no other reference remains). The document then renumbers through the existing pipeline. This is symmetric with the body-side staged delete — deleting from either side removes both the marker and the note (per product decision: remove on both sides), and it avoids the orphaned-ref state that previously left numbering inconsistent. Guarded on the body reference still existing so a stale/duplicate commit is a no-op. Wiring covered by unit tests (mocking the removal boundary, which is itself covered by footnote-wrappers.test.ts). --- .../story-runtime/note-story-runtime.test.ts | 64 ++++++++++++++++++- .../story-runtime/note-story-runtime.ts | 37 +++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts index ece7557c3b..803c0bb094 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts @@ -5,7 +5,7 @@ * empty or blank notes to be misclassified as missing. */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { DocumentApiAdapterError } from '../errors.js'; // --------------------------------------------------------------------------- @@ -36,6 +36,14 @@ vi.mock('../../core/parts/adapters/notes-part-descriptor.js', () => ({ updateNoteElement: vi.fn(), })); +// SD-3400: mock the removal boundary so the commit-on-empty wiring can be +// asserted without exercising footnotesRemoveWrapper's internals (covered by +// footnote-wrappers.test.ts). +const mockFootnotesRemoveWrapper = vi.fn(() => ({ success: true })); +vi.mock('../plan-engine/footnote-wrappers.js', () => ({ + footnotesRemoveWrapper: (...args: unknown[]) => mockFootnotesRemoveWrapper(...args), +})); + // Import after mocks are set up import { resolveNoteRuntime } from './note-story-runtime.js'; @@ -254,3 +262,57 @@ describe('resolveNoteRuntime — empty note content', () => { expect(mockCreateStoryEditor).toHaveBeenCalledWith(hostEditor, doc, expect.any(Object)); }); }); + +describe('SD-3400: clearing a note in the area removes the footnote on both sides', () => { + beforeEach(() => mockFootnotesRemoveWrapper.mockClear()); + + // Host editor whose body contains a footnoteReference id '1' (so real + // findAllFootnotes confirms the reference exists before removal). + const makeHost = () => + ({ + converter: { footnotes: [{ id: '1', content: [{ type: 'paragraph' }] }], endnotes: [] }, + state: { doc: { descendants: (cb: (n: unknown, p: number) => void) => cb({ type: { name: 'footnoteReference' }, attrs: { id: '1' } }, 5) } }, + on: vi.fn(), + }) as any; + + const storyEditorWith = (descendants: (cb: (n: unknown, p: number) => boolean | void) => void) => ({ + state: { doc: { content: { size: 4 }, textBetween: () => '', descendants } }, + schema: {}, + getJSON: () => ({ type: 'doc', content: [{ type: 'paragraph' }] }), + getUpdatedJson: () => ({ type: 'doc', content: [{ type: 'paragraph' }] }), + destroy: vi.fn(), + on: vi.fn(), + }); + + it('removes both the body reference and the note element when the committed content is empty', () => { + // Story doc holds only an empty paragraph — no text, no atoms. + mockCreateStoryEditor.mockReturnValueOnce( + storyEditorWith((cb) => { + cb({ isText: false, isAtom: false, type: { name: 'paragraph' } }, 0); + }) as never, + ); + const host = makeHost(); + const runtime = resolveNoteRuntime(host, footnoteLocator); + + runtime.commit?.(host); + + expect(mockFootnotesRemoveWrapper).toHaveBeenCalledWith(host, { + target: { kind: 'entity', entityType: 'footnote', noteId: '1' }, + }); + }); + + it('does not remove the footnote when the committed note still has content', () => { + mockCreateStoryEditor.mockReturnValueOnce( + storyEditorWith((cb) => { + cb({ isText: false, isAtom: false, type: { name: 'paragraph' } }, 0); + cb({ isText: true, isAtom: true, text: 'kept', type: { name: 'text' } }, 1); + }) as never, + ); + const host = makeHost(); + const runtime = resolveNoteRuntime(host, footnoteLocator); + + runtime.commit?.(host); + + expect(mockFootnotesRemoveWrapper).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts index 488797a3ce..82ee334937 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts @@ -20,9 +20,30 @@ import { updateNoteElement, } from '../../core/parts/adapters/notes-part-descriptor.js'; import { normalizeNotePmJson } from '../helpers/note-pm-json.js'; +import { footnotesRemoveWrapper } from '../plan-engine/footnote-wrappers.js'; +import { findAllFootnotes } from '../helpers/footnote-resolver.js'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; type NoteStoryLocator = FootnoteStoryLocator | EndnoteStoryLocator; +/** + * SD-3400: a note is "empty" once it holds no text and no embedded atoms + * (images, etc.). Whitespace-only content counts as empty — the user cleared it. + */ +function isNoteContentEmpty(doc: ProseMirrorNode): boolean { + let hasContent = false; + doc.descendants((node) => { + if (hasContent) return false; + if (node.isText) { + if ((node.text ?? '').trim().length > 0) hasContent = true; + } else if (node.isAtom && node.type.name !== 'text') { + hasContent = true; + } + return !hasContent; + }); + return !hasContent; +} + interface NoteExportToXmlJsonResult { result?: { elements?: Array<{ @@ -106,6 +127,22 @@ function commitNoteRuntime( const noteType = isFootnote ? 'footnote' : 'endnote'; const notesConfig = getNotesConfig(noteType); + // SD-3400: clearing all content in the note area deletes the footnote on BOTH + // sides — the note element in the notes part AND the body reference — and the + // document renumbers. This mirrors the body-side staged delete; deleting from + // either side removes the whole footnote. footnotesRemoveWrapper deletes the + // body reference node and removes the OOXML element when no other reference + // remains. Guard on the reference still existing so a stale commit is a no-op. + if (isNoteContentEmpty(storyEditor.state.doc)) { + const referenceExists = findAllFootnotes(hostEditor.state.doc).some((f) => f.noteId === locator.noteId); + if (referenceExists) { + footnotesRemoveWrapper(hostEditor, { + target: { kind: 'entity', entityType: 'footnote', noteId: locator.noteId }, + }); + } + return; + } + // Try rich export via converter's exportToXmlJson (preserves formatting) const conv = (hostEditor as unknown as { converter?: ConverterWithNoteExport }).converter; const pmJson = From 1c670ecfd3f45fa3e4855fa6a6caa7d0b56e58f5 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 16:24:12 -0300 Subject: [PATCH 07/36] fix(footnote): staged delete at run boundaries and dblclick via hit chain (SD-3400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two manual-testing regressions: Staged delete: each reference is wrapped in its own run, so a caret at the start of the following run (the common position after clicking past the superscript) saw nodeBefore as the run wrapper, failed the marker check, and fell through to normal backspace — deleting the previous letter. The boundary branches now unwrap a neighboring run whose trailing/leading child is a note reference. Delete-key mirror gets the same treatment. Double-click navigation: real pointer events land on the selection overlay above the pages, so closest('[data-pm-start]') on the event target missed the painted reference and the dblclick did nothing. The resolver now walks the elementsFromPoint hit chain, mirroring the rendered-note resolver. --- .../commands/selectFootnoteMarkerBefore.js | 49 +++++++++++++------ .../selectFootnoteMarkerBefore.test.js | 39 ++++++++++++++- .../pointer-events/EditorInputManager.ts | 26 ++++++++-- .../EditorInputManager.footnoteClick.test.ts | 35 +++++++++++++ 4 files changed, 130 insertions(+), 19 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.js b/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.js index bd8b7ba128..4eb6c67d71 100644 --- a/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.js +++ b/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.js @@ -5,36 +5,57 @@ export const SELECT_FOOTNOTE_MARKER_META = 'selectFootnoteMarker'; const isNoteReference = (node) => node?.type.name === 'footnoteReference' || node?.type.name === 'endnoteReference'; +/** + * Resolves the note marker ending at `boundaryPos` (the position right after it). + * Real documents wrap each reference in its own run, so the sibling at the + * boundary is usually that run wrapper, not the marker — look at its last child. + */ +function markerEndingAt(node, boundaryPos) { + if (isNoteReference(node)) { + return { node, pos: boundaryPos - node.nodeSize }; + } + if (node?.type.name === 'run' && isNoteReference(node.lastChild)) { + const marker = node.lastChild; + // Marker sits at the end of the run's content, just inside the closing token. + return { node: marker, pos: boundaryPos - 1 - marker.nodeSize }; + } + return null; +} + +/** Forward mirror of {@link markerEndingAt}: marker starting at `boundaryPos`. */ +function markerStartingAt(node, boundaryPos) { + if (isNoteReference(node)) { + return { node, pos: boundaryPos }; + } + if (node?.type.name === 'run' && isNoteReference(node.firstChild)) { + // Marker sits at the start of the run's content, just inside the opening token. + return { node: node.firstChild, pos: boundaryPos + 1 }; + } + return null; +} + function getPreviousNoteMarker(state) { const { $from } = state.selection; - // Run-wrapped case: caret at the start of a run, marker is the node before the run. + // Caret at the start of a run: the marker (or its run wrapper) precedes the run. if ($from.parent.type.name === 'run' && $from.parentOffset === 0) { const runStart = $from.before($from.depth); - const node = state.doc.resolve(runStart).nodeBefore; - if (!isNoteReference(node)) return null; - return { node, pos: runStart - node.nodeSize }; + return markerEndingAt(state.doc.resolve(runStart).nodeBefore, runStart); } - const node = $from.nodeBefore; - if (!isNoteReference(node)) return null; - return { node, pos: $from.pos - node.nodeSize }; + return markerEndingAt($from.nodeBefore, $from.pos); } function getNextNoteMarker(state) { const { $from } = state.selection; - // Run-wrapped case: caret at the end of a run, marker is the node after the run. + // Caret at the end of a run: the marker (or its run wrapper) follows the run. if ($from.parent.type.name === 'run' && $from.parentOffset === $from.parent.content.size) { const runEnd = $from.after($from.depth); - const node = state.doc.resolve(runEnd).nodeAfter; - if (!isNoteReference(node)) return null; - return { node, pos: runEnd }; + return markerStartingAt(state.doc.resolve(runEnd).nodeAfter, runEnd); } - const node = $from.nodeAfter; - if (!isNoteReference(node)) return null; - return { node, pos: $from.pos }; + return markerStartingAt($from.nodeAfter, $from.pos); } function selectNoteMarker(state, dispatch, marker) { diff --git a/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.test.js b/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.test.js index f411ef1e61..ae7f5c8491 100644 --- a/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/selectFootnoteMarkerBefore.test.js @@ -38,10 +38,10 @@ const makeDoc = (schema, refType = 'footnoteReference') => { return schema.node('doc', null, [schema.node('paragraph', null, [beforeRun, markerRun, afterRun])]); }; -const findNode = (doc, typeName) => { +const findNode = (doc, typeName, predicate = () => true) => { let result = null; doc.descendants((node, pos) => { - if (node.type.name === typeName) { + if (!result && node.type.name === typeName && predicate(node)) { result = { node, pos, end: pos + node.nodeSize }; return false; } @@ -68,6 +68,26 @@ describe('selectFootnoteMarkerBefore', () => { expect(dispatched.selection.to).toBe(marker.end); }); + it('selects the marker when the caret is at the start of the FOLLOWING run (marker wrapped in its own run)', () => { + // Real documents wrap each reference in its own run. Clicking just after the + // superscript places the caret at the start of the next text run, where + // nodeBefore is the marker's run wrapper — not the marker itself. The command + // must look inside the wrapper. (Manual-testing regression: first Backspace + // deleted the letter before the marker instead of selecting the marker.) + const schema = makeSchema(); + const doc = makeDoc(schema); + const marker = findNode(doc, 'footnoteReference'); + const afterRun = findNode(doc, 'run', (n) => n.textContent === 'After'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterRun.pos + 1) }); + + let dispatched; + const ok = selectFootnoteMarkerBefore()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched.selection.from).toBe(marker.pos); + expect(dispatched.selection.to).toBe(marker.end); + }); + it('returns true without dispatching when no dispatch is provided (first-press select is allowed)', () => { const schema = makeSchema(); const doc = makeDoc(schema); @@ -117,6 +137,21 @@ describe('selectFootnoteMarkerAfter', () => { expect(dispatched.selection.to).toBe(marker.end); }); + it('selects the marker when the caret is at the end of the PRECEDING run (marker wrapped in its own run)', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const marker = findNode(doc, 'footnoteReference'); + const beforeRun = findNode(doc, 'run', (n) => n.textContent === 'Before'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeRun.end - 1) }); + + let dispatched; + const ok = selectFootnoteMarkerAfter()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched.selection.from).toBe(marker.pos); + expect(dispatched.selection.to).toBe(marker.end); + }); + it('returns false when the node after the caret is not a note marker', () => { const schema = makeSchema(); const doc = makeDoc(schema); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index dc153f0f20..c7c0571a21 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -1899,8 +1899,28 @@ export class EditorInputManager { * the footnoteReference/endnoteReference node) but no note id, so we read the * node at that position to recover the story type and id. */ - #resolveFootnoteReferenceTargetAtPointer(target: HTMLElement | null): RenderedNoteTarget | null { - const refEl = target?.closest?.('[data-pm-start]') as HTMLElement | null; + #resolveFootnoteReferenceTargetAtPointer( + target: HTMLElement | null, + clientX: number, + clientY: number, + ): RenderedNoteTarget | null { + const fromTarget = this.#noteTargetFromPmStartElement(target?.closest?.('[data-pm-start]') as HTMLElement | null); + if (fromTarget) return fromTarget; + + // Real pointer events usually land on the selection overlay above the pages, + // not on the painted text span — walk the full hit chain like the + // rendered-note resolver does. + const doc = this.#deps?.getViewportHost()?.ownerDocument ?? document; + if (typeof doc.elementsFromPoint !== 'function') return null; + for (const element of doc.elementsFromPoint(clientX, clientY)) { + if (!(element instanceof HTMLElement)) continue; + const resolved = this.#noteTargetFromPmStartElement(element.closest('[data-pm-start]') as HTMLElement | null); + if (resolved) return resolved; + } + return null; + } + + #noteTargetFromPmStartElement(refEl: HTMLElement | null): RenderedNoteTarget | null { if (!refEl) return null; const pmStart = Number(refEl.getAttribute('data-pm-start')); if (!Number.isFinite(pmStart)) return null; @@ -1940,7 +1960,7 @@ export class EditorInputManager { // SD-3400: double-clicking a BODY footnote/endnote reference marker navigates // to its note content. Activating the note session focuses the note and scrolls // its selection into view, so the user lands on the corresponding note. - const footnoteRefTarget = this.#resolveFootnoteReferenceTargetAtPointer(target); + const footnoteRefTarget = this.#resolveFootnoteReferenceTargetAtPointer(target, event.clientX, event.clientY); if (footnoteRefTarget) { event.preventDefault(); event.stopPropagation(); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts index fa7128ed1d..f909f3230f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -479,6 +479,41 @@ describe('EditorInputManager - Footnote click selection behavior', () => { ); }); + it('activates via elementsFromPoint when the event target is the selection overlay (real pointer path)', () => { + // Real double-clicks land on the transparent selection overlay above the + // pages, so event.target has no data-pm-start ancestor. The resolver must + // fall back to the elementsFromPoint hit chain. (Manual-testing regression: + // double-click on a body reference did nothing.) + (mockEditor.state.doc as unknown as { nodeAt: (pos: number) => unknown }).nodeAt = (pos: number) => + pos === 38 ? { type: { name: 'footnoteReference' }, attrs: { id: '4' } } : null; + const refEl = makeRefSpan(38, '4'); + + const overlay = document.createElement('div'); + overlay.className = 'presentation-editor__selection-overlay'; + viewportHost.appendChild(overlay); + + const originalElementsFromPoint = document.elementsFromPoint?.bind(document); + Object.defineProperty(document, 'elementsFromPoint', { + configurable: true, + value: () => [overlay, refEl], + }); + try { + overlay.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, cancelable: true, button: 0, clientX: 21, clientY: 33 }), + ); + + expect(activateRenderedNoteSession).toHaveBeenCalledWith( + { storyType: 'footnote', noteId: '4' }, + expect.objectContaining({ clientX: 21, clientY: 33 }), + ); + } finally { + Object.defineProperty(document, 'elementsFromPoint', { + configurable: true, + value: originalElementsFromPoint, + }); + } + }); + it('does not activate when double-clicking ordinary body text', () => { (mockEditor.state.doc as unknown as { nodeAt: (pos: number) => unknown }).nodeAt = (pos: number) => pos === 12 ? { type: { name: 'text' }, attrs: {} } : null; From f1989fead842db3b25be01b3f36c9c0d4580cf18 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 16:30:14 -0300 Subject: [PATCH 08/36] feat(footnote): one-call insertFootnote command for custom toolbars (SD-3400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make FootnoteInsertInput.at optional: omitting it inserts the reference at the current selection head, which is what a toolbar action needs (place a marker at the current cursor location). docapi contract gates pass. Add editor.commands.insertFootnote(): inserts an empty footnote at the caret (creating the footnotes part with separators when the document has none) and activates the new note session so the user can immediately type the note text. Sets preventDispatch on the chain transaction because the document API dispatches its own compound transactions. Intentionally not registered in the default toolbar (per SD-3400) — any custom toolbar action can call it. --- .../src/footnotes/footnotes.types.ts | 6 +++- .../plan-engine/footnote-wrappers.test.ts | 12 ++++++++ .../plan-engine/footnote-wrappers.ts | 6 +++- .../v1/extensions/footnote/footnote.js | 28 +++++++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/document-api/src/footnotes/footnotes.types.ts b/packages/document-api/src/footnotes/footnotes.types.ts index d4d5dacfc3..ab70f9ce3f 100644 --- a/packages/document-api/src/footnotes/footnotes.types.ts +++ b/packages/document-api/src/footnotes/footnotes.types.ts @@ -49,7 +49,11 @@ export interface FootnoteGetInput { } export interface FootnoteInsertInput { - at: TextTarget; + /** + * Where to place the reference marker. Omit to insert at the current + * selection (caret position) — the natural target for toolbar actions. + */ + at?: TextTarget; type: 'footnote' | 'endnote'; content: string; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts index c5c29553f2..430fe45723 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts @@ -153,6 +153,7 @@ function makeEditor( state: { doc: makeDocWithFootnoteRefs(refs), tr, + selection: { head: 1, from: 1, to: 1 }, }, schema: { nodes: { @@ -217,6 +218,17 @@ describe('footnote-wrappers', () => { expect(noteElements[0].attributes['w:id']).toBe('1'); }); + it('inserts at the current selection head when at is omitted (SD-3400 toolbar path)', () => { + const editor = makeEditor([], []); + + const result = footnotesInsertWrapper(editor, { type: 'footnote', content: '' }); + + expect(result.success).toBe(true); + // The reference node lands at the selection head, no TextTarget required. + expect(editor.state.tr.insert).toHaveBeenCalledWith(1, expect.anything()); + expect(getFootnoteElements(editor)).toHaveLength(1); + }); + it('allocates a note id that avoids all existing ids', () => { const editor = makeEditor([], ['7', '3']); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts index 75cd5ead45..940777d6df 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts @@ -210,7 +210,11 @@ export function footnotesInsertWrapper( ); } - const resolved = resolveInlineInsertPosition(editor, input.at, 'footnotes.insert'); + // SD-3400: omitting `at` inserts at the current selection head — the natural + // target for toolbar actions ("place a marker at the current cursor location"). + const resolved = input.at + ? resolveInlineInsertPosition(editor, input.at, 'footnotes.insert') + : { from: editor.state.selection.head, to: editor.state.selection.head }; const { success } = compoundMutation({ editor, diff --git a/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js b/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js index 576afa6b9c..dba2f5e5a6 100644 --- a/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js +++ b/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js @@ -120,6 +120,34 @@ export const FootnoteReference = Node.create({ }; }, + addCommands() { + return { + /** + * SD-3400: insert a new footnote at the current cursor position and move + * focus into the new note so the user can immediately type its text. + * Creates the footnotes part (with separators) if the document has none. + * + * Built for custom toolbars: any toolbar action can call + * `editor.commands.insertFootnote()`. Intentionally NOT registered in the + * default toolbar (per SD-3400). + */ + insertFootnote: + () => + ({ editor, tr }) => { + // The document API dispatches its own (compound) transactions, which + // would leave the CommandService transaction stale — suppress it. + tr.setMeta('preventDispatch', true); + const result = editor.doc?.footnotes?.insert({ type: 'footnote', content: '' }); + if (!result?.success) return false; + const noteId = result.footnote?.noteId; + if (noteId != null) { + editor.presentationEditor?.activateNoteSession?.({ storyType: 'footnote', noteId: String(noteId) }); + } + return true; + }, + }; + }, + parseDOM() { return [{ tag: 'sup[data-footnote-id]' }]; }, From 0870c8d9082d71867372c876ea799cf3213ed501 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 16:42:31 -0300 Subject: [PATCH 09/36] feat(footnote): interaction affordance, focus feedback, instant area-delete (SD-3400) Three UX gaps from manual testing: Clickability: painted body reference markers now carry data-note-reference / data-note-id (stamped in buildReferenceMarkerRun, covers endnotes too) and get a pointer cursor plus a hover pill, signalling that the number is interactive. Focus feedback: while a note session is open, the note's fragments at the page bottom get the sd-note-session-active highlight (tint + accent bar + one-time pulse). Applied on activation, re-applied after every paint (fragments are rebuilt), removed on exit, and self-healing when the session ends through any path. Paint-only - no layout impact. Instant area-delete: clearing all content of a note that previously had content now auto-exits the session, which commits the both-sides removal immediately - no click back into the document required. Freshly inserted empty notes are exempt until they have held content, so insert-and-type is unaffected. --- .../painters/dom/src/styles.test.ts | 13 ++++ .../layout-engine/painters/dom/src/styles.ts | 26 +++++++ .../footnote-reference.test.ts | 8 ++ .../inline-converters/reference-marker.ts | 40 ++++++++-- .../presentation-editor/PresentationEditor.ts | 76 +++++++++++++++++++ .../story-runtime/note-story-runtime.ts | 3 +- 6 files changed, 157 insertions(+), 9 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 79d0b79fe3..fc0f5d1cd4 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -27,6 +27,19 @@ describe('ensureFootnoteStyles', () => { expect(cssText).toContain('[data-block-id^="__sd_semantic_footnote-"]'); expect(cssText).toContain('cursor: text;'); }); + + it('signals clickability on body reference markers and highlights the active note (SD-3400)', () => { + ensureFootnoteStyles(document); + + const styleEl = document.querySelector('[data-superdoc-footnote-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + + expect(cssText).toContain('[data-note-reference]'); + expect(cssText).toContain('cursor: pointer;'); + expect(cssText).toContain('[data-note-reference]:hover'); + expect(cssText).toContain('.sd-note-session-active'); + expect(cssText).toContain('sd-note-activate-pulse'); + }); }); describe('ensureSdtContainerStyles', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 4482cdcbc5..f21c37ac99 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -1170,6 +1170,32 @@ const FOOTNOTE_STYLES = ` [data-block-id^="__sd_semantic_endnote-"] { cursor: text; } + +/* SD-3400: body reference markers are interactive (double-click opens the + * note). Pointer cursor + a hover pill signal clickability without affecting + * layout (background/box-shadow are paint-only). */ +[data-note-reference] { + cursor: pointer; + border-radius: 2px; +} +[data-note-reference]:hover { + background-color: var(--sd-content-controls-block-hover-bg, #d3e3fd); + box-shadow: 0 0 0 2px var(--sd-content-controls-block-hover-bg, #d3e3fd); +} + +/* SD-3400: while a note session is open, highlight the note's fragments at the + * page bottom so the focus change is visible. Applied by PresentationEditor on + * activation, re-applied after each paint, removed on session exit. The pulse + * draws the eye when focus jumps from the body reference to the note. */ +.sd-note-session-active { + background-color: rgba(98, 155, 231, 0.12); + box-shadow: -2px 0 0 0 #629be7; + animation: sd-note-activate-pulse 0.6s ease-out 1; +} +@keyframes sd-note-activate-pulse { + 0% { background-color: rgba(98, 155, 231, 0.4); } + 100% { background-color: rgba(98, 155, 231, 0.12); } +} `; let printStylesInjected = false; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/footnote-reference.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/footnote-reference.test.ts index 960d446fe1..eee1294963 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/footnote-reference.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/footnote-reference.test.ts @@ -49,6 +49,14 @@ describe('footnoteReferenceToBlock', () => { expect(run.text).toBe('1'); }); + it('stamps interaction data attributes on the painted marker (SD-3400)', () => { + const run = footnoteReferenceToBlock(makeParams()); + + // Drives the hover/clickability affordance and active-note matching. + expect(run.dataAttrs?.['data-note-reference']).toBe('footnote'); + expect(run.dataAttrs?.['data-note-id']).toBe('1'); + }); + it('does not emit Unicode superscript glyphs', () => { const run = footnoteReferenceToBlock(makeParams()); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/reference-marker.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/reference-marker.ts index c43db55344..fdda95dfcb 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/reference-marker.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/reference-marker.ts @@ -60,6 +60,27 @@ const stripVerticalPositioningFromRunProperties = ( return sanitizedRunProperties; }; +/** + * SD-3400: stamp the painted marker with interaction attributes so the DOM + * painter output can signal clickability (hover affordance) and so the active + * note can be visually matched. `data-note-reference` carries the story type, + * `data-note-id` the note id. + */ +const withReferenceDataAttrs = (run: TextRun, params: InlineConverterParams): TextRun => { + const rawType = (params.node as { type?: string | { name?: string } }).type; + const typeName = typeof rawType === 'string' ? rawType : rawType?.name; + const kind = typeName === 'endnoteReference' ? 'endnote' : 'footnote'; + const id = (params.node.attrs as Record | undefined)?.id; + return { + ...run, + dataAttrs: { + ...run.dataAttrs, + 'data-note-reference': kind, + ...(id != null ? { 'data-note-id': String(id) } : {}), + }, + }; +}; + const copyReferencePmPositions = (run: TextRun, params: InlineConverterParams): TextRun => { const refPos = params.positions.get(params.node); if (!refPos) { @@ -119,19 +140,22 @@ export function buildReferenceMarkerRun(displayText: string, params: InlineConve const originalRun = buildOriginalReferenceRun(displayText, params); if (hasExplicitBaselineShift(originalRun.baselineShift)) { - return copyReferencePmPositions(originalRun, params); + return withReferenceDataAttrs(copyReferencePmPositions(originalRun, params), params); } const runWithoutVerticalPositioning = buildReferenceRunWithoutVerticalPositioning(displayText, params); const baseFontSize = resolveReferenceBaseFontSize(runWithoutVerticalPositioning, originalRun, params.defaultSize); - return copyReferencePmPositions( - { - ...originalRun, - vertAlign: 'superscript', - baselineShift: undefined, - fontSize: baseFontSize * SUBSCRIPT_SUPERSCRIPT_SCALE, - }, + return withReferenceDataAttrs( + copyReferencePmPositions( + { + ...originalRun, + vertAlign: 'superscript', + baselineShift: undefined, + fontSize: baseFontSize * SUBSCRIPT_SUPERSCRIPT_SCALE, + }, + params, + ), params, ); } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 9611b4b796..b1e41d620b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -141,6 +141,7 @@ import type { } from './story-session/StoryPresentationSessionManager.js'; import type { StoryPresentationSession } from './story-session/types.js'; import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/resolve-story-runtime.js'; +import { isNoteContentEmpty } from '../../document-api-adapters/story-runtime/note-story-runtime.js'; import { BODY_STORY_KEY, buildStoryKey, parseStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; import { createStoryEditor } from '../story-editor-factory.js'; import { buildEndnoteBlocks } from './layout/EndnotesBuilder.js'; @@ -510,6 +511,10 @@ export class PresentationEditor extends EventEmitter { #painterHost: HTMLElement; #selectionOverlay: HTMLElement; #permissionOverlay: HTMLElement | null = null; + /** SD-3400: note target whose page-bottom fragments are highlighted while its session is open. */ + #activeNoteHighlightTarget: RenderedNoteTarget | null = null; + /** SD-3400: unbinds the active note session's emptied-content watcher. */ + #noteSessionEmptyWatchCleanup: (() => void) | null = null; #hiddenHost: HTMLElement; /** Scroll-isolating wrapper around #hiddenHost. Append/remove this from the DOM. */ #hiddenHostWrapper: HTMLElement; @@ -7319,6 +7324,10 @@ export class PresentationEditor extends EventEmitter { this.emit('layoutUpdated', payload); this.emit('paginationUpdate', payload); + // SD-3400: fragments are rebuilt on every paint — re-apply the active + // note highlight so it survives rerenders while the session is open. + this.#refreshActiveNoteHighlight(); + // Emit fresh comment positions after layout completes. // Always emit — even when empty — so the store can clear stale positions // (e.g. when undo removes the last tracked-change mark). @@ -9048,6 +9057,13 @@ export class PresentationEditor extends EventEmitter { session.editor.view?.focus(); this.#shouldScrollSelectionIntoView = true; this.#scheduleSelectionUpdate({ immediate: true }); + + // SD-3400: make the focus change visible (highlight the note at the page + // bottom) and watch for the user emptying the note (commit removal + // immediately instead of waiting for a click back into the body). + this.#activeNoteHighlightTarget = target; + this.#refreshActiveNoteHighlight(); + this.#bindNoteSessionEmptyWatch(session); return true; } @@ -9061,12 +9077,72 @@ export class PresentationEditor extends EventEmitter { return this.#activateRenderedNoteSession(target, {}); } + /** + * SD-3400: toggle the `sd-note-session-active` highlight on the painted + * fragments of the note whose session is open. Paint-only (class + CSS), so + * it never affects layout. Re-applied after every paint because fragment + * elements are rebuilt by the painter; self-heals when the session is gone. + */ + #refreshActiveNoteHighlight(): void { + const host = this.#painterHost ?? this.#visibleHost; + if (!host) return; + if (this.#activeNoteHighlightTarget && !this.#getActiveStorySession()) { + this.#activeNoteHighlightTarget = null; + } + host.querySelectorAll('.sd-note-session-active').forEach((el) => el.classList.remove('sd-note-session-active')); + const target = this.#activeNoteHighlightTarget; + if (!target) return; + const prefixes = [ + `${target.storyType}-${target.noteId}-`, + `__sd_semantic_${target.storyType}-${target.noteId}-`, + ]; + host.querySelectorAll('[data-block-id]').forEach((el) => { + const id = el.getAttribute('data-block-id') ?? ''; + if (prefixes.some((prefix) => id.startsWith(prefix))) { + el.classList.add('sd-note-session-active'); + } + }); + } + + /** + * SD-3400: when the user clears ALL content of a note that previously had + * content, exit the session immediately so the commit (which removes the + * footnote on both sides) runs right away — no extra click required. Freshly + * inserted notes open empty and are only auto-removed once they have held + * content, so insert-and-type is unaffected. + */ + #bindNoteSessionEmptyWatch(session: StoryPresentationSession): void { + this.#noteSessionEmptyWatchCleanup?.(); + const sessionEditor = session.editor; + let hadContent = !isNoteContentEmpty(sessionEditor.state.doc); + const onUpdate = () => { + const empty = isNoteContentEmpty(sessionEditor.state.doc); + if (!empty) { + hadContent = true; + return; + } + if (!hadContent) return; + this.#noteSessionEmptyWatchCleanup?.(); + // Defer past the session editor's own transaction lifecycle. + queueMicrotask(() => this.#exitActiveStorySession()); + }; + sessionEditor.on?.('update', onUpdate); + this.#noteSessionEmptyWatchCleanup = () => { + sessionEditor.off?.('update', onUpdate); + this.#noteSessionEmptyWatchCleanup = null; + }; + } + #exitActiveStorySession(): void { const session = this.#getActiveStorySession(); if (!session) { return; } + this.#noteSessionEmptyWatchCleanup?.(); + this.#activeNoteHighlightTarget = null; + this.#refreshActiveNoteHighlight(); + this.#storySessionManager?.exit(); this.#pendingDocChange = true; this.#scheduleRerender(); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts index 82ee334937..49c283c5b5 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts @@ -29,8 +29,9 @@ type NoteStoryLocator = FootnoteStoryLocator | EndnoteStoryLocator; /** * SD-3400: a note is "empty" once it holds no text and no embedded atoms * (images, etc.). Whitespace-only content counts as empty — the user cleared it. + * Exported so PresentationEditor's note-session watcher applies the same rule. */ -function isNoteContentEmpty(doc: ProseMirrorNode): boolean { +export function isNoteContentEmpty(doc: ProseMirrorNode): boolean { let hasContent = false; doc.descendants((node) => { if (hasContent) return false; From 7c576931b9cdd3762394ad9ba41613f3dcaf9029 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 18:39:34 -0300 Subject: [PATCH 10/36] style(footnote): soften the active-note highlight (SD-3400) Lighter tint (0.12 to 0.07 alpha), thinner accent bar (2px to 1px) pushed 3px away from the note line via a masked box-shadow pair, gentler activation pulse. Feedback from manual review: the previous bar read too heavy next to the text. --- packages/layout-engine/painters/dom/src/styles.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index f21c37ac99..4906753ebe 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -1188,13 +1188,18 @@ const FOOTNOTE_STYLES = ` * activation, re-applied after each paint, removed on session exit. The pulse * draws the eye when focus jumps from the body reference to the note. */ .sd-note-session-active { - background-color: rgba(98, 155, 231, 0.12); - box-shadow: -2px 0 0 0 #629be7; + background-color: rgba(98, 155, 231, 0.07); + /* Thin accent bar with breathing room: the first shadow masks a 3px gap with + * the page background, the second paints a 1px bar beyond it. Box-shadows + * paint outside the box, so the note line itself is untouched. */ + box-shadow: + -3px 0 0 0 var(--sd-page-bg, #ffffff), + -4px 0 0 0 rgba(98, 155, 231, 0.55); animation: sd-note-activate-pulse 0.6s ease-out 1; } @keyframes sd-note-activate-pulse { - 0% { background-color: rgba(98, 155, 231, 0.4); } - 100% { background-color: rgba(98, 155, 231, 0.12); } + 0% { background-color: rgba(98, 155, 231, 0.22); } + 100% { background-color: rgba(98, 155, 231, 0.07); } } `; From db7e8515b358e905fc8ab06fe10847496515e16d Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 18:52:32 -0300 Subject: [PATCH 11/36] feat(footnote): enlarge reference hit target and add dev insert button (SD-3400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The painted reference digit is ~6x11px, which made the hover affordance and double-click navigation nearly impossible to acquire with a real mouse (the handler itself is robust — verified with realistic per-event sequences). An invisible ::after halo expands the interactive target to roughly 16x19px: hover, pointer cursor, and double-click all hit the marker span, with no text movement (pseudo-element is absolutely positioned off a position:relative span). Also wire an Insert footnote button into the dev app header as the demo of the custom-toolbar action: it calls editor.commands.insertFootnote(), which inserts at the caret and focuses the new note. The default product toolbar remains untouched per SD-3400. --- packages/layout-engine/painters/dom/src/styles.ts | 9 +++++++++ packages/superdoc/src/dev/components/SuperdocDev.vue | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 4906753ebe..4258e4d75d 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -1177,6 +1177,15 @@ const FOOTNOTE_STYLES = ` [data-note-reference] { cursor: pointer; border-radius: 2px; + position: relative; +} +/* The painted digit is ~6x11px — far too small to hover or double-click + * reliably. An invisible pseudo-element halo expands the interactive target + * (hover, cursor, clicks all hit the marker span) without moving any text. */ +[data-note-reference]::after { + content: ''; + position: absolute; + inset: -4px -5px; } [data-note-reference]:hover { background-color: var(--sd-content-controls-block-hover-bg, #d3e3fd); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index c1d67e8a37..058f0a1de9 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -1244,6 +1244,12 @@ const toggleShowBookmarks = () => { superdoc.value?.setShowBookmarks?.(showBookmarks.value); }; +// SD-3400: demo wiring for the custom insert-footnote action. Consumer apps +// register this on their own toolbar; it is intentionally not a default item. +const handleInsertFootnote = () => { + activeEditor.value?.commands?.insertFootnote?.(); +}; + const toggleViewLayout = () => { const nextValue = !useWebLayout.value; const url = new URL(window.location.href); @@ -1544,6 +1550,7 @@ if (scrollTestMode.value) { + From 911aabf4a3550edb734b05e4136351b76a1cac57 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 19:03:43 -0300 Subject: [PATCH 12/36] feat(footnote): smart-scroll to the note on navigation and insert (SD-3400) Opening a note session now brings the note into view. The scroll is smart: no-op when the note's fragment is already fully visible, otherwise it smooth-centers the fragment in the scroll container. Double-clicked notes are already painted and scroll immediately; freshly inserted notes only paint after the post-insert relayout, so the request stays pending and completes from the layoutUpdated hook once the fragment exists. Cleared on session exit. Verified live: double-click with the note band off-screen scrolls 0 to 490 with the note fully visible; toolbar insert scrolls 0 to 751 onto the new note. --- .../presentation-editor/PresentationEditor.ts | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index b1e41d620b..e9d6ead846 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -515,6 +515,8 @@ export class PresentationEditor extends EventEmitter { #activeNoteHighlightTarget: RenderedNoteTarget | null = null; /** SD-3400: unbinds the active note session's emptied-content watcher. */ #noteSessionEmptyWatchCleanup: (() => void) | null = null; + /** SD-3400: scroll the active note into view once its fragment exists (inserts paint a frame later). */ + #pendingNoteScrollIntoView = false; #hiddenHost: HTMLElement; /** Scroll-isolating wrapper around #hiddenHost. Append/remove this from the DOM. */ #hiddenHostWrapper: HTMLElement; @@ -7325,8 +7327,10 @@ export class PresentationEditor extends EventEmitter { this.emit('paginationUpdate', payload); // SD-3400: fragments are rebuilt on every paint — re-apply the active - // note highlight so it survives rerenders while the session is open. + // note highlight so it survives rerenders while the session is open, and + // complete any pending scroll-to-note (inserted notes paint on this pass). this.#refreshActiveNoteHighlight(); + this.#scrollActiveNoteIntoView(); // Emit fresh comment positions after layout completes. // Always emit — even when empty — so the store can clear stale positions @@ -9064,9 +9068,56 @@ export class PresentationEditor extends EventEmitter { this.#activeNoteHighlightTarget = target; this.#refreshActiveNoteHighlight(); this.#bindNoteSessionEmptyWatch(session); + // Bring the note into view. For an existing note (double-click) the + // fragment is already painted and scrolls now; for a freshly inserted note + // the fragment appears on the next paint, where the layoutUpdated hook + // retries until it exists. + this.#pendingNoteScrollIntoView = true; + this.#scrollActiveNoteIntoView(); return true; } + /** + * SD-3400: smart-scroll the active note's first painted fragment into view. + * No-op when the note is already fully visible; otherwise smooth-centers it. + * Stays pending until the fragment exists, so notes created by insert (which + * only paint after the next relayout) scroll once they appear. + */ + #scrollActiveNoteIntoView(): void { + if (!this.#pendingNoteScrollIntoView) return; + const target = this.#activeNoteHighlightTarget; + if (!target) { + this.#pendingNoteScrollIntoView = false; + return; + } + const host = this.#painterHost ?? this.#visibleHost; + if (!host) return; + const prefixes = [ + `${target.storyType}-${target.noteId}-`, + `__sd_semantic_${target.storyType}-${target.noteId}-`, + ]; + const fragment = Array.from(host.querySelectorAll('[data-block-id]')).find((el) => { + const id = el.getAttribute('data-block-id') ?? ''; + return prefixes.some((prefix) => id.startsWith(prefix)); + }); + if (!fragment) return; // not painted yet — retry on the next layoutUpdated + + this.#pendingNoteScrollIntoView = false; + const rect = fragment.getBoundingClientRect(); + const viewport = + this.#scrollContainer instanceof Window + ? { top: 0, bottom: this.#scrollContainer.innerHeight } + : this.#scrollContainer instanceof Element + ? (() => { + const r = this.#scrollContainer.getBoundingClientRect(); + return { top: r.top, bottom: r.bottom }; + })() + : { top: 0, bottom: window.innerHeight }; + const fullyVisible = rect.top >= viewport.top + 8 && rect.bottom <= viewport.bottom - 8; + if (fullyVisible) return; + fragment.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + /** * SD-3400: programmatically open a footnote/endnote note session without a * pointer. Focuses the note and scrolls it into view with the caret at the @@ -9141,6 +9192,7 @@ export class PresentationEditor extends EventEmitter { this.#noteSessionEmptyWatchCleanup?.(); this.#activeNoteHighlightTarget = null; + this.#pendingNoteScrollIntoView = false; this.#refreshActiveNoteHighlight(); this.#storySessionManager?.exit(); From ec746ae5067ba1dd69d90d4f11d425cacfd6d566 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 9 Jun 2026 20:14:54 -0300 Subject: [PATCH 13/36] refactor(footnote): modularize note-session interactions (SD-3400) Behavior-preserving modularization of the SD-3400 footnote interaction work, gated by the existing suites (111 super-editor tests + 16 layout-bridge footnote tests) and a browser smoke pass. - notes/note-target.ts: single source of truth for RenderedNoteTarget, parseRenderedNoteTarget, isSameRenderedNoteTarget, and the block-id prefix mapping. Removes the duplicated definitions in EditorInputManager and PresentationEditor. - notes/NoteSessionCoordinator.ts: extracts the active-note UX (highlight, smart scroll, emptied-note commit) out of PresentationEditor into a small collaborator with injected deps, following the dom/ coordinator precedent. PresentationEditor delegates via onActivated/onPaint/onExit; the logic is now unit-tested in jsdom (7 tests) instead of browser-only. - pointer-events/note-reference-hit.ts: pure resolver for double-clicked body reference markers (closest + elementsFromPoint walk); EditorInputManager keeps a thin delegating method. - extensions/footnote/insert-footnote.js: insertFootnoteAtCursor as a plain importable function; the PM command is now a 3-line shim, keeping the extension as adapter (schema, NodeView, command registration) with logic in modules. Covered by its own tests. - incrementalLayout.ts: dedupe the cluster-demand and band-cap computations shared by the carry-forward and terminal-page reserve bumps (clusterDemandFor/maxBandFor). - note-story-runtime.ts: split commitNoteRuntime into removeEmptiedNote / commitRichNoteContent / commitPlainTextNoteContent. --- .../layout-bridge/src/incrementalLayout.ts | 78 ++++---- .../presentation-editor/PresentationEditor.ts | 167 ++--------------- .../notes/NoteSessionCoordinator.test.ts | 170 ++++++++++++++++++ .../notes/NoteSessionCoordinator.ts | 153 ++++++++++++++++ .../presentation-editor/notes/note-target.ts | 70 ++++++++ .../pointer-events/EditorInputManager.ts | 101 ++--------- .../pointer-events/note-reference-hit.ts | 58 ++++++ .../story-runtime/note-story-runtime.ts | 122 ++++++++----- .../v1/extensions/footnote/footnote.js | 19 +- .../v1/extensions/footnote/insert-footnote.js | 27 +++ .../footnote/insert-footnote.test.js | 41 +++++ 11 files changed, 672 insertions(+), 334 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/notes/NoteSessionCoordinator.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/notes/NoteSessionCoordinator.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/notes/note-target.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts create mode 100644 packages/super-editor/src/editors/v1/extensions/footnote/insert-footnote.js create mode 100644 packages/super-editor/src/editors/v1/extensions/footnote/insert-footnote.test.js diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index c109ddc035..7a681a6022 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -1878,6 +1878,38 @@ export async function incrementalLayout( // just inflated dead reserve. Overflow now propagates naturally: // any continuation beyond next-page capacity stays in // pendingByColumn and lands on page+2, page+3, etc. + // Tallest per-column cluster demand for a page's anchored footnotes. + // The carry-forward bump counts only the FIRST LINE of the last entry + // (the rest continues onto the following page); the terminal-page bump + // needs full heights because there is nowhere to continue. + const clusterDemandFor = (targetPageIndex: number, lastEntryFirstLineOnly: boolean): number => { + let demand = 0; + for (let cIdx = 0; cIdx < columnCount; cIdx += 1) { + const ids = idsByColumn.get(targetPageIndex)?.get(cIdx) ?? []; + if (ids.length === 0) continue; + let columnCluster = 0; + for (let i = 0; i < ids.length; i += 1) { + const isLast = i === ids.length - 1; + columnCluster += lastEntryFirstLineOnly && isLast ? firstLineOf(ids[i]) : fullHeightOf(ids[i]); + if (i > 0) columnCluster += safeGap; + } + if (columnCluster > demand) demand = columnCluster; + } + return demand; + }; + + // Physical band cap for a page: content height minus a minimum body strip. + const maxBandFor = (targetPageIndex: number): number => { + const page = layoutForPages.pages?.[targetPageIndex]; + const size = page?.size ?? layoutForPages.pageSize ?? DEFAULT_PAGE_SIZE; + const top = normalizeMargin(page?.margins?.top, DEFAULT_MARGINS.top); + const bottom = normalizeMargin(page?.margins?.bottom, DEFAULT_MARGINS.bottom); + const physicalContentHeight = Math.max(0, size.h - top - bottom); + return Math.max(0, physicalContentHeight - MIN_FOOTNOTE_BODY_HEIGHT * 20); + }; + + const bandOverhead = safeSeparatorSpacingBefore + continuationDividerHeight + safeTopPadding; + if (pageIndex + 1 < pageCount) { let continuationDemand = 0; pendingByColumn.forEach((entries) => { @@ -1889,30 +1921,12 @@ export async function incrementalLayout( }); }); // Next page's mandatory cluster demand (ordered minimum). - let nextClusterDemand = 0; - for (let cIdx = 0; cIdx < columnCount; cIdx += 1) { - const idsNext = idsByColumn.get(pageIndex + 1)?.get(cIdx) ?? []; - if (idsNext.length === 0) continue; - let columnCluster = 0; - for (let i = 0; i < idsNext.length; i += 1) { - const isLast = i === idsNext.length - 1; - columnCluster += isLast ? firstLineOf(idsNext[i]) : fullHeightOf(idsNext[i]); - if (i > 0) columnCluster += safeGap; - } - if (columnCluster > nextClusterDemand) nextClusterDemand = columnCluster; - } + const nextClusterDemand = clusterDemandFor(pageIndex + 1, true); if (continuationDemand > 0 || nextClusterDemand > 0) { - const overhead = safeSeparatorSpacingBefore + continuationDividerHeight + safeTopPadding; - const nextPage = layoutForPages.pages?.[pageIndex + 1]; - const nextPageSize = nextPage?.size ?? layoutForPages.pageSize ?? DEFAULT_PAGE_SIZE; - const nextTop = normalizeMargin(nextPage?.margins?.top, DEFAULT_MARGINS.top); - const nextBottomRaw = normalizeMargin(nextPage?.margins?.bottom, DEFAULT_MARGINS.bottom); - const physicalContentHeight = Math.max(0, nextPageSize.h - nextTop - nextBottomRaw); - const minBodyHeight = MIN_FOOTNOTE_BODY_HEIGHT * 20; - const nextPageMaxBand = Math.max(0, physicalContentHeight - minBodyHeight); + const nextPageMaxBand = maxBandFor(pageIndex + 1); // The band has a single overhead block (separator + padding) // whether or not we have a cluster. - const overheadForBand = nextClusterDemand > 0 || continuationDemand > 0 ? overhead : 0; + const overheadForBand = nextClusterDemand > 0 || continuationDemand > 0 ? bandOverhead : 0; // Mandatory cluster room (cluster slices only, no overhead). const clusterRoomPx = nextClusterDemand > 0 ? Math.min(nextClusterDemand, Math.max(0, nextPageMaxBand - overheadForBand)) : 0; @@ -1937,27 +1951,9 @@ export async function incrementalLayout( // footnote renders on its anchor page (matching Word). Guarded on // `< clusterDemand` so pages whose footnote already placed fully are // untouched — no gap/regression on non-dense pages. - let clusterDemand = 0; - for (let cIdx = 0; cIdx < columnCount; cIdx += 1) { - const idsHere = idsByColumn.get(pageIndex)?.get(cIdx) ?? []; - if (idsHere.length === 0) continue; - let columnCluster = 0; - for (let i = 0; i < idsHere.length; i += 1) { - columnCluster += fullHeightOf(idsHere[i]); - if (i > 0) columnCluster += safeGap; - } - if (columnCluster > clusterDemand) clusterDemand = columnCluster; - } + const clusterDemand = clusterDemandFor(pageIndex, false); if (clusterDemand > 0 && (reserves[pageIndex] ?? 0) < clusterDemand) { - const overhead = safeSeparatorSpacingBefore + continuationDividerHeight + safeTopPadding; - const thisPage = layoutForPages.pages?.[pageIndex]; - const thisPageSize = thisPage?.size ?? layoutForPages.pageSize ?? DEFAULT_PAGE_SIZE; - const thisTop = normalizeMargin(thisPage?.margins?.top, DEFAULT_MARGINS.top); - const thisBottomRaw = normalizeMargin(thisPage?.margins?.bottom, DEFAULT_MARGINS.bottom); - const physicalContentHeight = Math.max(0, thisPageSize.h - thisTop - thisBottomRaw); - const minBodyHeight = MIN_FOOTNOTE_BODY_HEIGHT * 20; - const thisPageMaxBand = Math.max(0, physicalContentHeight - minBodyHeight); - const finalReserve = Math.min(clusterDemand + overhead, thisPageMaxBand); + const finalReserve = Math.min(clusterDemand + bandOverhead, maxBandFor(pageIndex)); reserves[pageIndex] = Math.max(reserves[pageIndex] ?? 0, Math.ceil(finalReserve)); } } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index e9d6ead846..57fb3d3240 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -141,7 +141,8 @@ import type { } from './story-session/StoryPresentationSessionManager.js'; import type { StoryPresentationSession } from './story-session/types.js'; import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/resolve-story-runtime.js'; -import { isNoteContentEmpty } from '../../document-api-adapters/story-runtime/note-story-runtime.js'; +import { parseRenderedNoteTarget, type RenderedNoteTarget } from './notes/note-target.js'; +import { NoteSessionCoordinator } from './notes/NoteSessionCoordinator.js'; import { BODY_STORY_KEY, buildStoryKey, parseStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; import { createStoryEditor } from '../story-editor-factory.js'; import { buildEndnoteBlocks } from './layout/EndnotesBuilder.js'; @@ -239,11 +240,6 @@ type ThreadAnchorScrollPlan = { applyScroll: (behavior: ScrollBehavior) => void; }; -type RenderedNoteTarget = { - storyType: 'footnote' | 'endnote'; - noteId: string; -}; - type UnifiedHistoryDebugGlobal = typeof globalThis & { __SD_DEBUG_UNIFIED_HISTORY__?: boolean; }; @@ -284,28 +280,6 @@ type RenderedNoteFragmentHit = { pageIndex: number; }; -function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { - if (typeof blockId !== 'string' || blockId.length === 0) { - return null; - } - - if (blockId.startsWith('footnote-')) { - const noteId = blockId.slice('footnote-'.length).split('-')[0] ?? ''; - return noteId ? { storyType: 'footnote', noteId } : null; - } - - if (blockId.startsWith('__sd_semantic_footnote-')) { - const noteId = blockId.slice('__sd_semantic_footnote-'.length).split('-')[0] ?? ''; - return noteId ? { storyType: 'footnote', noteId } : null; - } - - if (blockId.startsWith('endnote-')) { - const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? ''; - return noteId ? { storyType: 'endnote', noteId } : null; - } - - return null; -} import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationBoundaries.js'; import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; import { @@ -511,12 +485,8 @@ export class PresentationEditor extends EventEmitter { #painterHost: HTMLElement; #selectionOverlay: HTMLElement; #permissionOverlay: HTMLElement | null = null; - /** SD-3400: note target whose page-bottom fragments are highlighted while its session is open. */ - #activeNoteHighlightTarget: RenderedNoteTarget | null = null; - /** SD-3400: unbinds the active note session's emptied-content watcher. */ - #noteSessionEmptyWatchCleanup: (() => void) | null = null; - /** SD-3400: scroll the active note into view once its fragment exists (inserts paint a frame later). */ - #pendingNoteScrollIntoView = false; + /** SD-3400: highlight + smart-scroll + emptied-note commit for the open note session. */ + #noteSessionCoordinator: NoteSessionCoordinator | null = null; #hiddenHost: HTMLElement; /** Scroll-isolating wrapper around #hiddenHost. Append/remove this from the DOM. */ #hiddenHostWrapper: HTMLElement; @@ -7327,10 +7297,8 @@ export class PresentationEditor extends EventEmitter { this.emit('paginationUpdate', payload); // SD-3400: fragments are rebuilt on every paint — re-apply the active - // note highlight so it survives rerenders while the session is open, and - // complete any pending scroll-to-note (inserted notes paint on this pass). - this.#refreshActiveNoteHighlight(); - this.#scrollActiveNoteIntoView(); + // note highlight and complete any pending scroll-to-note. + this.#noteSessionCoordinator?.onPaint(); // Emit fresh comment positions after layout completes. // Always emit — even when empty — so the store can clear stale positions @@ -9062,60 +9030,22 @@ export class PresentationEditor extends EventEmitter { this.#shouldScrollSelectionIntoView = true; this.#scheduleSelectionUpdate({ immediate: true }); - // SD-3400: make the focus change visible (highlight the note at the page - // bottom) and watch for the user emptying the note (commit removal - // immediately instead of waiting for a click back into the body). - this.#activeNoteHighlightTarget = target; - this.#refreshActiveNoteHighlight(); - this.#bindNoteSessionEmptyWatch(session); - // Bring the note into view. For an existing note (double-click) the - // fragment is already painted and scrolls now; for a freshly inserted note - // the fragment appears on the next paint, where the layoutUpdated hook - // retries until it exists. - this.#pendingNoteScrollIntoView = true; - this.#scrollActiveNoteIntoView(); + // SD-3400: highlight the note, watch for the user emptying it, and bring + // it into view (see NoteSessionCoordinator for the full UX contract). + this.#ensureNoteSessionCoordinator().onActivated(target, session); return true; } - /** - * SD-3400: smart-scroll the active note's first painted fragment into view. - * No-op when the note is already fully visible; otherwise smooth-centers it. - * Stays pending until the fragment exists, so notes created by insert (which - * only paint after the next relayout) scroll once they appear. - */ - #scrollActiveNoteIntoView(): void { - if (!this.#pendingNoteScrollIntoView) return; - const target = this.#activeNoteHighlightTarget; - if (!target) { - this.#pendingNoteScrollIntoView = false; - return; + #ensureNoteSessionCoordinator(): NoteSessionCoordinator { + if (!this.#noteSessionCoordinator) { + this.#noteSessionCoordinator = new NoteSessionCoordinator({ + getHost: () => this.#painterHost ?? this.#visibleHost, + getScrollContainer: () => this.#scrollContainer, + hasActiveSession: () => Boolean(this.#getActiveStorySession()), + exitActiveSession: () => this.#exitActiveStorySession(), + }); } - const host = this.#painterHost ?? this.#visibleHost; - if (!host) return; - const prefixes = [ - `${target.storyType}-${target.noteId}-`, - `__sd_semantic_${target.storyType}-${target.noteId}-`, - ]; - const fragment = Array.from(host.querySelectorAll('[data-block-id]')).find((el) => { - const id = el.getAttribute('data-block-id') ?? ''; - return prefixes.some((prefix) => id.startsWith(prefix)); - }); - if (!fragment) return; // not painted yet — retry on the next layoutUpdated - - this.#pendingNoteScrollIntoView = false; - const rect = fragment.getBoundingClientRect(); - const viewport = - this.#scrollContainer instanceof Window - ? { top: 0, bottom: this.#scrollContainer.innerHeight } - : this.#scrollContainer instanceof Element - ? (() => { - const r = this.#scrollContainer.getBoundingClientRect(); - return { top: r.top, bottom: r.bottom }; - })() - : { top: 0, bottom: window.innerHeight }; - const fullyVisible = rect.top >= viewport.top + 8 && rect.bottom <= viewport.bottom - 8; - if (fullyVisible) return; - fragment.scrollIntoView({ block: 'center', behavior: 'smooth' }); + return this.#noteSessionCoordinator; } /** @@ -9128,72 +9058,13 @@ export class PresentationEditor extends EventEmitter { return this.#activateRenderedNoteSession(target, {}); } - /** - * SD-3400: toggle the `sd-note-session-active` highlight on the painted - * fragments of the note whose session is open. Paint-only (class + CSS), so - * it never affects layout. Re-applied after every paint because fragment - * elements are rebuilt by the painter; self-heals when the session is gone. - */ - #refreshActiveNoteHighlight(): void { - const host = this.#painterHost ?? this.#visibleHost; - if (!host) return; - if (this.#activeNoteHighlightTarget && !this.#getActiveStorySession()) { - this.#activeNoteHighlightTarget = null; - } - host.querySelectorAll('.sd-note-session-active').forEach((el) => el.classList.remove('sd-note-session-active')); - const target = this.#activeNoteHighlightTarget; - if (!target) return; - const prefixes = [ - `${target.storyType}-${target.noteId}-`, - `__sd_semantic_${target.storyType}-${target.noteId}-`, - ]; - host.querySelectorAll('[data-block-id]').forEach((el) => { - const id = el.getAttribute('data-block-id') ?? ''; - if (prefixes.some((prefix) => id.startsWith(prefix))) { - el.classList.add('sd-note-session-active'); - } - }); - } - - /** - * SD-3400: when the user clears ALL content of a note that previously had - * content, exit the session immediately so the commit (which removes the - * footnote on both sides) runs right away — no extra click required. Freshly - * inserted notes open empty and are only auto-removed once they have held - * content, so insert-and-type is unaffected. - */ - #bindNoteSessionEmptyWatch(session: StoryPresentationSession): void { - this.#noteSessionEmptyWatchCleanup?.(); - const sessionEditor = session.editor; - let hadContent = !isNoteContentEmpty(sessionEditor.state.doc); - const onUpdate = () => { - const empty = isNoteContentEmpty(sessionEditor.state.doc); - if (!empty) { - hadContent = true; - return; - } - if (!hadContent) return; - this.#noteSessionEmptyWatchCleanup?.(); - // Defer past the session editor's own transaction lifecycle. - queueMicrotask(() => this.#exitActiveStorySession()); - }; - sessionEditor.on?.('update', onUpdate); - this.#noteSessionEmptyWatchCleanup = () => { - sessionEditor.off?.('update', onUpdate); - this.#noteSessionEmptyWatchCleanup = null; - }; - } - #exitActiveStorySession(): void { const session = this.#getActiveStorySession(); if (!session) { return; } - this.#noteSessionEmptyWatchCleanup?.(); - this.#activeNoteHighlightTarget = null; - this.#pendingNoteScrollIntoView = false; - this.#refreshActiveNoteHighlight(); + this.#noteSessionCoordinator?.onExit(); this.#storySessionManager?.exit(); this.#pendingDocChange = true; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/notes/NoteSessionCoordinator.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/notes/NoteSessionCoordinator.test.ts new file mode 100644 index 0000000000..621b32737d --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/notes/NoteSessionCoordinator.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NoteSessionCoordinator } from './NoteSessionCoordinator.js'; +import type { RenderedNoteTarget } from './note-target.js'; + +// Minimal PM-doc fakes for isNoteContentEmpty (walks descendants for text/atoms). +const emptyDoc = () => + ({ + descendants: (cb: (node: unknown) => boolean | void) => { + cb({ isText: false, isAtom: false, type: { name: 'paragraph' } }); + }, + }) as never; + +const docWithText = () => + ({ + descendants: (cb: (node: unknown) => boolean | void) => { + cb({ isText: false, isAtom: false, type: { name: 'paragraph' } }); + cb({ isText: true, isAtom: true, text: 'note text', type: { name: 'text' } }); + }, + }) as never; + +type FakeSessionEditor = { + state: { doc: never }; + on: (event: string, cb: () => void) => void; + off: (event: string, cb: () => void) => void; + emitUpdate: () => void; +}; + +const makeSessionEditor = (doc: never): FakeSessionEditor => { + const handlers = new Set<() => void>(); + const editor: FakeSessionEditor = { + state: { doc }, + on: (event, cb) => { + if (event === 'update') handlers.add(cb); + }, + off: (event, cb) => { + if (event === 'update') handlers.delete(cb); + }, + emitUpdate: () => { + handlers.forEach((cb) => cb()); + }, + }; + return editor; +}; + +const TARGET: RenderedNoteTarget = { storyType: 'footnote', noteId: '2' }; + +describe('NoteSessionCoordinator', () => { + let host: HTMLElement; + let scroller: HTMLElement; + let hasActiveSession: ReturnType; + let exitActiveSession: ReturnType; + let coordinator: NoteSessionCoordinator; + let scrollIntoViewSpy: ReturnType; + + const addFragment = (blockId: string): HTMLElement => { + const el = document.createElement('div'); + el.setAttribute('data-block-id', blockId); + host.appendChild(el); + return el; + }; + + beforeEach(() => { + host = document.createElement('div'); + scroller = document.createElement('div'); + document.body.append(host, scroller); + hasActiveSession = vi.fn(() => true); + exitActiveSession = vi.fn(); + scrollIntoViewSpy = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoViewSpy as never; + coordinator = new NoteSessionCoordinator({ + getHost: () => host, + getScrollContainer: () => scroller, + hasActiveSession, + exitActiveSession, + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('highlights only the active note fragments and clears them on exit', () => { + const noteFrag = addFragment('footnote-2-ABC123'); + const otherNote = addFragment('footnote-9-DEF456'); + const bodyFrag = addFragment('para-uuid'); + + coordinator.onActivated(TARGET, { editor: makeSessionEditor(docWithText()) }); + + expect(noteFrag.classList.contains('sd-note-session-active')).toBe(true); + expect(otherNote.classList.contains('sd-note-session-active')).toBe(false); + expect(bodyFrag.classList.contains('sd-note-session-active')).toBe(false); + + coordinator.onExit(); + expect(noteFrag.classList.contains('sd-note-session-active')).toBe(false); + }); + + it('re-applies the highlight after a repaint rebuilds the fragments', () => { + addFragment('footnote-2-ABC123'); + coordinator.onActivated(TARGET, { editor: makeSessionEditor(docWithText()) }); + + host.innerHTML = ''; + const rebuilt = addFragment('footnote-2-NEWHASH'); + coordinator.onPaint(); + + expect(rebuilt.classList.contains('sd-note-session-active')).toBe(true); + }); + + it('self-heals when the session ended through another path', () => { + const frag = addFragment('footnote-2-ABC123'); + coordinator.onActivated(TARGET, { editor: makeSessionEditor(docWithText()) }); + expect(frag.classList.contains('sd-note-session-active')).toBe(true); + + hasActiveSession.mockReturnValue(false); + coordinator.onPaint(); + expect(frag.classList.contains('sd-note-session-active')).toBe(false); + }); + + it('keeps the scroll pending until the fragment paints, then scrolls once', () => { + // Activated before the (inserted) note has painted — nothing to scroll yet. + coordinator.onActivated(TARGET, { editor: makeSessionEditor(docWithText()) }); + expect(scrollIntoViewSpy).not.toHaveBeenCalled(); + + addFragment('footnote-2-ABC123'); + coordinator.onPaint(); + expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1); + + coordinator.onPaint(); + expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1); // not re-scrolled + }); + + it('does not scroll when the note is already fully visible', () => { + const frag = addFragment('footnote-2-ABC123'); + frag.getBoundingClientRect = vi.fn(() => ({ top: 100, bottom: 160 }) as DOMRect); + scroller.getBoundingClientRect = vi.fn(() => ({ top: 0, bottom: 500 }) as DOMRect); + + coordinator.onActivated(TARGET, { editor: makeSessionEditor(docWithText()) }); + + expect(scrollIntoViewSpy).not.toHaveBeenCalled(); + }); + + it('exits the session when a note that had content is emptied', async () => { + addFragment('footnote-2-ABC123'); + const sessionEditor = makeSessionEditor(docWithText()); + coordinator.onActivated(TARGET, { editor: sessionEditor }); + + sessionEditor.state.doc = emptyDoc(); + sessionEditor.emitUpdate(); + await Promise.resolve(); // exit is deferred past the transaction lifecycle + + expect(exitActiveSession).toHaveBeenCalledTimes(1); + }); + + it('does not remove a freshly inserted note until it has held content', async () => { + addFragment('footnote-2-ABC123'); + const sessionEditor = makeSessionEditor(emptyDoc()); + coordinator.onActivated(TARGET, { editor: sessionEditor }); + + sessionEditor.emitUpdate(); // still empty — fresh note, no removal + await Promise.resolve(); + expect(exitActiveSession).not.toHaveBeenCalled(); + + sessionEditor.state.doc = docWithText(); + sessionEditor.emitUpdate(); // user typed + sessionEditor.state.doc = emptyDoc(); + sessionEditor.emitUpdate(); // then cleared — now it removes + await Promise.resolve(); + expect(exitActiveSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/notes/NoteSessionCoordinator.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/notes/NoteSessionCoordinator.ts new file mode 100644 index 0000000000..2a076cf8e1 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/notes/NoteSessionCoordinator.ts @@ -0,0 +1,153 @@ +/** + * NoteSessionCoordinator — UX for an open footnote/endnote session (SD-3400). + * + * Owns the three paint-level behaviors that make a note session feel focused: + * + * 1. Highlight: the active note's painted fragments get the + * `sd-note-session-active` class (tint + accent bar via painter CSS). + * Fragments are rebuilt on every paint, so the class is re-applied from + * {@link onPaint} and self-heals when the session ends through any path. + * 2. Smart scroll: bring the note into view on activation. No-op when the + * fragment is already fully visible; freshly inserted notes only paint + * after the next relayout, so the request stays pending until the fragment + * exists. + * 3. Emptied-note commit: when the user clears ALL content of a note that + * previously had content, exit the session immediately so the commit + * (which removes the footnote on both sides) runs without an extra click. + * Freshly inserted notes open empty and are exempt until they have held + * content, so insert-and-type is unaffected. + * + * Everything here is paint-only (classList + scroll) — no layout impact. + * PresentationEditor delegates via three calls: onActivated / onPaint / onExit. + */ + +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { isNoteContentEmpty } from '../../../document-api-adapters/story-runtime/note-story-runtime.js'; +import { renderedNoteBlockIdPrefixes, type RenderedNoteTarget } from './note-target.js'; + +const ACTIVE_NOTE_CLASS = 'sd-note-session-active'; + +type NoteSessionEditorLike = { + state: { doc: ProseMirrorNode }; + on?: (event: string, callback: () => void) => void; + off?: (event: string, callback: () => void) => void; +}; + +export type NoteSessionLike = { + editor: NoteSessionEditorLike; +}; + +export interface NoteSessionCoordinatorDeps { + /** Painted-pages host to query for note fragments. */ + getHost: () => HTMLElement | null; + /** The element (or window) that actually scrolls the document. */ + getScrollContainer: () => Element | Window | null; + /** Whether a story session is currently open (self-healing guard). */ + hasActiveSession: () => boolean; + /** Exits the active story session, committing its content. */ + exitActiveSession: () => void; +} + +export class NoteSessionCoordinator { + #deps: NoteSessionCoordinatorDeps; + #activeTarget: RenderedNoteTarget | null = null; + #pendingScrollIntoView = false; + #emptyWatchCleanup: (() => void) | null = null; + + constructor(deps: NoteSessionCoordinatorDeps) { + this.#deps = deps; + } + + /** A note session opened: highlight it, watch for emptying, scroll to it. */ + onActivated(target: RenderedNoteTarget, session: NoteSessionLike): void { + this.#activeTarget = target; + this.#refreshHighlight(); + this.#bindEmptyWatch(session); + this.#pendingScrollIntoView = true; + this.#scrollIntoView(); + } + + /** A paint completed: re-apply the highlight, complete any pending scroll. */ + onPaint(): void { + this.#refreshHighlight(); + this.#scrollIntoView(); + } + + /** The session is exiting: unbind and clear all visual state. */ + onExit(): void { + this.#emptyWatchCleanup?.(); + this.#activeTarget = null; + this.#pendingScrollIntoView = false; + this.#refreshHighlight(); + } + + #findActiveFragments(host: HTMLElement): Element[] { + const target = this.#activeTarget; + if (!target) return []; + const prefixes = renderedNoteBlockIdPrefixes(target); + return Array.from(host.querySelectorAll('[data-block-id]')).filter((el) => { + const id = el.getAttribute('data-block-id') ?? ''; + return prefixes.some((prefix) => id.startsWith(prefix)); + }); + } + + #refreshHighlight(): void { + const host = this.#deps.getHost(); + if (!host) return; + if (this.#activeTarget && !this.#deps.hasActiveSession()) { + this.#activeTarget = null; + } + host.querySelectorAll(`.${ACTIVE_NOTE_CLASS}`).forEach((el) => el.classList.remove(ACTIVE_NOTE_CLASS)); + this.#findActiveFragments(host).forEach((el) => el.classList.add(ACTIVE_NOTE_CLASS)); + } + + #scrollIntoView(): void { + if (!this.#pendingScrollIntoView) return; + if (!this.#activeTarget) { + this.#pendingScrollIntoView = false; + return; + } + const host = this.#deps.getHost(); + if (!host) return; + const fragment = this.#findActiveFragments(host)[0]; + if (!fragment) return; // not painted yet — retried from the next onPaint + + this.#pendingScrollIntoView = false; + const rect = fragment.getBoundingClientRect(); + const scroller = this.#deps.getScrollContainer(); + const viewport = + scroller instanceof Window + ? { top: 0, bottom: scroller.innerHeight } + : scroller instanceof Element + ? (() => { + const r = scroller.getBoundingClientRect(); + return { top: r.top, bottom: r.bottom }; + })() + : { top: 0, bottom: window.innerHeight }; + const fullyVisible = rect.top >= viewport.top + 8 && rect.bottom <= viewport.bottom - 8; + if (fullyVisible) return; + fragment.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + + #bindEmptyWatch(session: NoteSessionLike): void { + this.#emptyWatchCleanup?.(); + const sessionEditor = session.editor; + let hadContent = !isNoteContentEmpty(sessionEditor.state.doc); + const onUpdate = () => { + const empty = isNoteContentEmpty(sessionEditor.state.doc); + if (!empty) { + hadContent = true; + return; + } + if (!hadContent) return; + this.#emptyWatchCleanup?.(); + // Defer past the session editor's own transaction lifecycle. + queueMicrotask(() => this.#deps.exitActiveSession()); + }; + sessionEditor.on?.('update', onUpdate); + this.#emptyWatchCleanup = () => { + sessionEditor.off?.('update', onUpdate); + this.#emptyWatchCleanup = null; + }; + } +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/notes/note-target.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/notes/note-target.ts new file mode 100644 index 0000000000..f6f4af2ae0 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/notes/note-target.ts @@ -0,0 +1,70 @@ +/** + * Shared identity for rendered footnote/endnote targets (SD-3400). + * + * A note painted at the page bottom is addressed by `{ storyType, noteId }`, + * parsed from its fragment block id. These helpers were previously duplicated + * in EditorInputManager and PresentationEditor; this module is the single + * source of truth for the block-id ↔ note-target mapping. + * + * Block-id shapes: + * - `footnote-{id}-{hash}` / `endnote-{id}-{hash}` — regular note fragments + * - `__sd_semantic_footnote-{id}-{hash}` — semantic-flow footnote blocks + */ + +import { isSemanticFootnoteBlockId } from '../semantic-flow-constants.js'; + +export type RenderedNoteTarget = { + storyType: 'footnote' | 'endnote'; + noteId: string; +}; + +/** True when a fragment block id belongs to rendered note content. */ +export function isRenderedNoteBlockId(blockId: string): boolean { + return ( + typeof blockId === 'string' && + (blockId.startsWith('footnote-') || blockId.startsWith('endnote-') || isSemanticFootnoteBlockId(blockId)) + ); +} + +/** Parses a fragment block id into its note target, or null for non-note ids. */ +export function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { + if (typeof blockId !== 'string' || blockId.length === 0) { + return null; + } + + if (blockId.startsWith('footnote-')) { + const noteId = blockId.slice('footnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'footnote', noteId } : null; + } + + if (blockId.startsWith('__sd_semantic_footnote-')) { + const noteId = blockId.slice('__sd_semantic_footnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'footnote', noteId } : null; + } + + if (blockId.startsWith('endnote-')) { + const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'endnote', noteId } : null; + } + + return null; +} + +export function isSameRenderedNoteTarget( + left: RenderedNoteTarget | null | undefined, + right: RenderedNoteTarget | null | undefined, +): boolean { + if (!left || !right) { + return false; + } + + return left.storyType === right.storyType && left.noteId === right.noteId; +} + +/** + * Fragment block-id prefixes that belong to a specific note target. Used to + * match a target against painted `[data-block-id]` elements. + */ +export function renderedNoteBlockIdPrefixes(target: RenderedNoteTarget): string[] { + return [`${target.storyType}-${target.noteId}-`, `__sd_semantic_${target.storyType}-${target.noteId}-`]; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index c7c0571a21..57b7f3ab4b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -41,7 +41,13 @@ import { import { debugLog } from '../selection/SelectionDebug.js'; import { DOM_CLASS_NAMES, buildAnnotationSelector, DRAGGABLE_SELECTOR } from '@superdoc/dom-contract'; import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js'; -import { isSemanticFootnoteBlockId } from '../semantic-flow-constants.js'; +import { + isRenderedNoteBlockId, + isSameRenderedNoteTarget, + parseRenderedNoteTarget, + type RenderedNoteTarget, +} from '../notes/note-target.js'; +import { resolveNoteReferenceAtPointer } from './note-reference-hit.js'; import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; import { findStructuredContentBlockAtPos, @@ -88,57 +94,6 @@ type CommentThreadHit = { threadId: string | null; }; -/** - * Block IDs for note content use `footnote-{id}-` / `endnote-{id}-` prefixes. - * Semantic footnote blocks use the {@link isSemanticFootnoteBlockId} helper from - * shared constants — it matches both heading and body footnote block IDs. - */ -function isRenderedNoteBlockId(blockId: string): boolean { - return ( - typeof blockId === 'string' && - (blockId.startsWith('footnote-') || blockId.startsWith('endnote-') || isSemanticFootnoteBlockId(blockId)) - ); -} - -type RenderedNoteTarget = { - storyType: 'footnote' | 'endnote'; - noteId: string; -}; - -function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { - if (typeof blockId !== 'string' || blockId.length === 0) { - return null; - } - - if (blockId.startsWith('footnote-')) { - const noteId = blockId.slice('footnote-'.length).split('-')[0] ?? ''; - return noteId ? { storyType: 'footnote', noteId } : null; - } - - if (blockId.startsWith('__sd_semantic_footnote-')) { - const noteId = blockId.slice('__sd_semantic_footnote-'.length).split('-')[0] ?? ''; - return noteId ? { storyType: 'footnote', noteId } : null; - } - - if (blockId.startsWith('endnote-')) { - const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? ''; - return noteId ? { storyType: 'endnote', noteId } : null; - } - - return null; -} - -function isSameRenderedNoteTarget( - left: RenderedNoteTarget | null | undefined, - right: RenderedNoteTarget | null | undefined, -): boolean { - if (!left || !right) { - return false; - } - - return left.storyType === right.storyType && left.noteId === right.noteId; -} - function isOutsidePageBodyContent(layout: Layout, x: number, pageIndex?: number, pageLocalY?: number): boolean { if (!Number.isFinite(x) || !Number.isFinite(pageIndex) || !Number.isFinite(pageLocalY)) { return false; @@ -1894,45 +1849,21 @@ export class EditorInputManager { /** * SD-3400: resolve a double-clicked BODY footnote/endnote reference marker to - * its note target so navigation can open the corresponding note. The painted - * reference is a superscript run carrying `data-pm-start` (the PM position of - * the footnoteReference/endnoteReference node) but no note id, so we read the - * node at that position to recover the story type and id. + * its note target so navigation can open the corresponding note. Delegates to + * the pure {@link resolveNoteReferenceAtPointer} helper. */ #resolveFootnoteReferenceTargetAtPointer( target: HTMLElement | null, clientX: number, clientY: number, ): RenderedNoteTarget | null { - const fromTarget = this.#noteTargetFromPmStartElement(target?.closest?.('[data-pm-start]') as HTMLElement | null); - if (fromTarget) return fromTarget; - - // Real pointer events usually land on the selection overlay above the pages, - // not on the painted text span — walk the full hit chain like the - // rendered-note resolver does. - const doc = this.#deps?.getViewportHost()?.ownerDocument ?? document; - if (typeof doc.elementsFromPoint !== 'function') return null; - for (const element of doc.elementsFromPoint(clientX, clientY)) { - if (!(element instanceof HTMLElement)) continue; - const resolved = this.#noteTargetFromPmStartElement(element.closest('[data-pm-start]') as HTMLElement | null); - if (resolved) return resolved; - } - return null; - } - - #noteTargetFromPmStartElement(refEl: HTMLElement | null): RenderedNoteTarget | null { - if (!refEl) return null; - const pmStart = Number(refEl.getAttribute('data-pm-start')); - if (!Number.isFinite(pmStart)) return null; - const node = this.#deps?.getEditor()?.state?.doc?.nodeAt(pmStart); - const nodeType = node?.type?.name; - if (nodeType !== 'footnoteReference' && nodeType !== 'endnoteReference') return null; - const noteId = node?.attrs?.id; - if (noteId == null || String(noteId).length === 0) return null; - return { - storyType: nodeType === 'endnoteReference' ? 'endnote' : 'footnote', - noteId: String(noteId), - }; + return resolveNoteReferenceAtPointer({ + target, + clientX, + clientY, + doc: this.#deps?.getEditor()?.state?.doc, + ownerDocument: this.#deps?.getViewportHost()?.ownerDocument ?? document, + }); } #handleDoubleClick(event: MouseEvent): void { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts new file mode 100644 index 0000000000..e9f22d5a0c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts @@ -0,0 +1,58 @@ +/** + * Resolves a pointer event over a painted BODY footnote/endnote reference to + * its note target (SD-3400 double-click navigation). + * + * The painted reference is a superscript run carrying `data-pm-start` (the PM + * position of the footnoteReference/endnoteReference node) but no note id, so + * the PM node at that position supplies the story type and id. Real pointer + * events usually land on the selection overlay above the pages — when the + * event target has no `data-pm-start` ancestor, the full `elementsFromPoint` + * hit chain is walked (same strategy as the rendered-note resolver). + */ + +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { RenderedNoteTarget } from '../notes/note-target.js'; + +export type NoteReferenceHitOptions = { + /** The pointer event's target. */ + target: HTMLElement | null; + clientX: number; + clientY: number; + /** The body editor's PM document (resolves pm-start → reference node). */ + doc: ProseMirrorNode | null | undefined; + /** Document used for the elementsFromPoint fallback. */ + ownerDocument: Document; +}; + +export function resolveNoteReferenceAtPointer(options: NoteReferenceHitOptions): RenderedNoteTarget | null { + const { target, clientX, clientY, doc, ownerDocument } = options; + + const fromTarget = noteTargetFromPmStartElement(target?.closest?.('[data-pm-start]') as HTMLElement | null, doc); + if (fromTarget) return fromTarget; + + if (typeof ownerDocument.elementsFromPoint !== 'function') return null; + for (const element of ownerDocument.elementsFromPoint(clientX, clientY)) { + if (!(element instanceof HTMLElement)) continue; + const resolved = noteTargetFromPmStartElement(element.closest('[data-pm-start]') as HTMLElement | null, doc); + if (resolved) return resolved; + } + return null; +} + +function noteTargetFromPmStartElement( + refEl: HTMLElement | null, + doc: ProseMirrorNode | null | undefined, +): RenderedNoteTarget | null { + if (!refEl || !doc) return null; + const pmStart = Number(refEl.getAttribute('data-pm-start')); + if (!Number.isFinite(pmStart)) return null; + const node = doc.nodeAt(pmStart); + const nodeType = node?.type?.name; + if (nodeType !== 'footnoteReference' && nodeType !== 'endnoteReference') return null; + const noteId = node?.attrs?.id; + if (noteId == null || String(noteId).length === 0) return null; + return { + storyType: nodeType === 'endnoteReference' ? 'endnote' : 'footnote', + noteId: String(noteId), + }; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts index 49c283c5b5..ae44b8aceb 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts @@ -119,6 +119,8 @@ export function resolveNoteRuntime(hostEditor: Editor, locator: NoteStoryLocator }; } +type NotesConfig = ReturnType; + function commitNoteRuntime( hostEditor: Editor, storyEditor: Editor, @@ -128,62 +130,90 @@ function commitNoteRuntime( const noteType = isFootnote ? 'footnote' : 'endnote'; const notesConfig = getNotesConfig(noteType); - // SD-3400: clearing all content in the note area deletes the footnote on BOTH - // sides — the note element in the notes part AND the body reference — and the - // document renumbers. This mirrors the body-side staged delete; deleting from - // either side removes the whole footnote. footnotesRemoveWrapper deletes the - // body reference node and removes the OOXML element when no other reference - // remains. Guard on the reference still existing so a stale commit is a no-op. if (isNoteContentEmpty(storyEditor.state.doc)) { - const referenceExists = findAllFootnotes(hostEditor.state.doc).some((f) => f.noteId === locator.noteId); - if (referenceExists) { - footnotesRemoveWrapper(hostEditor, { - target: { kind: 'entity', entityType: 'footnote', noteId: locator.noteId }, - }); - } + removeEmptiedNote(hostEditor, locator); + return; + } + + if (commitRichNoteContent(hostEditor, storyEditor, locator, notesConfig)) { return; } - // Try rich export via converter's exportToXmlJson (preserves formatting) + commitPlainTextNoteContent(hostEditor, storyEditor, locator, notesConfig); +} + +/** + * SD-3400: clearing all content in the note area deletes the footnote on BOTH + * sides — the note element in the notes part AND the body reference — and the + * document renumbers. This mirrors the body-side staged delete; deleting from + * either side removes the whole footnote. footnotesRemoveWrapper deletes the + * body reference node and removes the OOXML element when no other reference + * remains. Guard on the reference still existing so a stale commit is a no-op. + */ +function removeEmptiedNote(hostEditor: Editor, locator: NoteStoryLocator): void { + const referenceExists = findAllFootnotes(hostEditor.state.doc).some((f) => f.noteId === locator.noteId); + if (!referenceExists) return; + footnotesRemoveWrapper(hostEditor, { + target: { kind: 'entity', entityType: 'footnote', noteId: locator.noteId }, + }); +} + +/** + * Rich commit via the converter's exportToXmlJson (preserves formatting). + * Returns false when the converter is unavailable or export produced nothing, + * so the caller can fall back to plain text. + */ +function commitRichNoteContent( + hostEditor: Editor, + storyEditor: Editor, + locator: NoteStoryLocator, + notesConfig: NotesConfig, +): boolean { const conv = (hostEditor as unknown as { converter?: ConverterWithNoteExport }).converter; const pmJson = typeof storyEditor.getUpdatedJson === 'function' ? storyEditor.getUpdatedJson() : storyEditor.getJSON(); + if (!conv?.exportToXmlJson || !pmJson) return false; - if (conv?.exportToXmlJson && pmJson) { - let ooxmlElements: unknown[] | null = null; - try { - const { result } = conv.exportToXmlJson({ - data: pmJson, - editor: storyEditor, - editorSchema: storyEditor.schema, - isHeaderFooter: true, - comments: [], - commentDefinitions: [], - }); - // result.elements[0] is the body wrapper; its children are all - // content elements (paragraphs, tables, etc.). Keep all of them - // so tables and other non-paragraph content survive the commit. - const body = result?.elements?.[0] as { elements?: unknown[] } | undefined; - ooxmlElements = body?.elements ?? null; - } catch { - // Fall through to plain-text fallback - } - - if (ooxmlElements && ooxmlElements.length > 0) { - mutatePart({ - editor: hostEditor, - partId: notesConfig.partId, - operation: 'mutate', - source: `story-runtime:commit:${locator.storyType}`, - mutate({ part }) { - updateNoteContentFromOoxml(part, notesConfig, locator.noteId, ooxmlElements!); - }, - }); - return; - } + let ooxmlElements: unknown[] | null = null; + try { + const { result } = conv.exportToXmlJson({ + data: pmJson, + editor: storyEditor, + editorSchema: storyEditor.schema, + isHeaderFooter: true, + comments: [], + commentDefinitions: [], + }); + // result.elements[0] is the body wrapper; its children are all + // content elements (paragraphs, tables, etc.). Keep all of them + // so tables and other non-paragraph content survive the commit. + const body = result?.elements?.[0] as { elements?: unknown[] } | undefined; + ooxmlElements = body?.elements ?? null; + } catch { + // Fall through to plain-text fallback } + if (!ooxmlElements || ooxmlElements.length === 0) return false; + + const elements = ooxmlElements; + mutatePart({ + editor: hostEditor, + partId: notesConfig.partId, + operation: 'mutate', + source: `story-runtime:commit:${locator.storyType}`, + mutate({ part }) { + updateNoteContentFromOoxml(part, notesConfig, locator.noteId, elements); + }, + }); + return true; +} - // Fallback: plain-text export (loses formatting) +/** Fallback: plain-text export (loses formatting). */ +function commitPlainTextNoteContent( + hostEditor: Editor, + storyEditor: Editor, + locator: NoteStoryLocator, + notesConfig: NotesConfig, +): void { const doc = storyEditor.state.doc; const text = doc.textBetween(0, doc.content.size, '\n', '\n'); diff --git a/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js b/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js index dba2f5e5a6..53039f9918 100644 --- a/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js +++ b/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js @@ -1,5 +1,6 @@ import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; +import { insertFootnoteAtCursor } from './insert-footnote.js'; const toSuperscriptDigits = (value) => { const map = { @@ -123,13 +124,9 @@ export const FootnoteReference = Node.create({ addCommands() { return { /** - * SD-3400: insert a new footnote at the current cursor position and move - * focus into the new note so the user can immediately type its text. - * Creates the footnotes part (with separators) if the document has none. - * - * Built for custom toolbars: any toolbar action can call - * `editor.commands.insertFootnote()`. Intentionally NOT registered in the - * default toolbar (per SD-3400). + * SD-3400: thin command shim over {@link insertFootnoteAtCursor} so any + * custom toolbar can call `editor.commands.insertFootnote()`. + * Intentionally NOT registered in the default toolbar (per SD-3400). */ insertFootnote: () => @@ -137,13 +134,7 @@ export const FootnoteReference = Node.create({ // The document API dispatches its own (compound) transactions, which // would leave the CommandService transaction stale — suppress it. tr.setMeta('preventDispatch', true); - const result = editor.doc?.footnotes?.insert({ type: 'footnote', content: '' }); - if (!result?.success) return false; - const noteId = result.footnote?.noteId; - if (noteId != null) { - editor.presentationEditor?.activateNoteSession?.({ storyType: 'footnote', noteId: String(noteId) }); - } - return true; + return insertFootnoteAtCursor(editor); }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/footnote/insert-footnote.js b/packages/super-editor/src/editors/v1/extensions/footnote/insert-footnote.js new file mode 100644 index 0000000000..ddd78fac48 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/footnote/insert-footnote.js @@ -0,0 +1,27 @@ +/** + * SD-3400: insert a footnote at the current cursor and focus the new note. + * + * Plain orchestrator over two existing capabilities: + * 1. `editor.doc.footnotes.insert` (document API) — allocates the note id, + * creates the body reference at the selection head, writes the OOXML note + * element, and bootstraps the footnotes part (with separators) when the + * document has none. + * 2. `presentationEditor.activateNoteSession` — opens the note session with + * the caret at the note's start and smart-scrolls it into view. + * + * Lives outside the ProseMirror extension so any caller (custom toolbar + * actions, tests, tooling) can use it without PM command plumbing. The + * `insertFootnote` editor command is a thin shim over this function. + * + * @param {import('@core/Editor.js').Editor} editor + * @returns {boolean} True when the footnote was inserted. + */ +export function insertFootnoteAtCursor(editor) { + const result = editor.doc?.footnotes?.insert({ type: 'footnote', content: '' }); + if (!result?.success) return false; + const noteId = result.footnote?.noteId; + if (noteId != null) { + editor.presentationEditor?.activateNoteSession?.({ storyType: 'footnote', noteId: String(noteId) }); + } + return true; +} diff --git a/packages/super-editor/src/editors/v1/extensions/footnote/insert-footnote.test.js b/packages/super-editor/src/editors/v1/extensions/footnote/insert-footnote.test.js new file mode 100644 index 0000000000..5d94528de3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/footnote/insert-footnote.test.js @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from 'vitest'; +import { insertFootnoteAtCursor } from './insert-footnote.js'; + +const makeEditor = ({ insertResult, presentationEditor } = {}) => ({ + doc: { footnotes: { insert: vi.fn(() => insertResult) } }, + presentationEditor, +}); + +describe('insertFootnoteAtCursor', () => { + it('inserts at the cursor and focuses the new note session', () => { + const activateNoteSession = vi.fn(() => true); + const editor = makeEditor({ + insertResult: { success: true, footnote: { kind: 'entity', entityType: 'footnote', noteId: 7 } }, + presentationEditor: { activateNoteSession }, + }); + + expect(insertFootnoteAtCursor(editor)).toBe(true); + expect(editor.doc.footnotes.insert).toHaveBeenCalledWith({ type: 'footnote', content: '' }); + expect(activateNoteSession).toHaveBeenCalledWith({ storyType: 'footnote', noteId: '7' }); + }); + + it('returns false and does not activate when the insert fails', () => { + const activateNoteSession = vi.fn(); + const editor = makeEditor({ + insertResult: { success: false, failure: { code: 'NO_OP', message: 'nope' } }, + presentationEditor: { activateNoteSession }, + }); + + expect(insertFootnoteAtCursor(editor)).toBe(false); + expect(activateNoteSession).not.toHaveBeenCalled(); + }); + + it('still succeeds when no presentation editor is attached (headless)', () => { + const editor = makeEditor({ + insertResult: { success: true, footnote: { kind: 'entity', entityType: 'footnote', noteId: '3' } }, + presentationEditor: undefined, + }); + + expect(insertFootnoteAtCursor(editor)).toBe(true); + }); +}); From e50627599832b727160fa5cc8474ea2d67a1ca56 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 07:17:17 -0300 Subject: [PATCH 14/36] fix(footnote): treat uninspectable note docs as non-empty (SD-3400) The note-session empty watch runs isNoteContentEmpty on activation; session editors whose doc is not a real PM document (detached/mocked editors in the PresentationEditor suite) threw doc.descendants is not a function and broke five existing footnote-interaction tests in CI. Emptiness triggers removal of the footnote, so an uninspectable doc must read as NOT empty: never delete on uncertainty. --- .../document-api-adapters/story-runtime/note-story-runtime.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts index ae44b8aceb..3a73c1af32 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts @@ -32,6 +32,10 @@ type NoteStoryLocator = FootnoteStoryLocator | EndnoteStoryLocator; * Exported so PresentationEditor's note-session watcher applies the same rule. */ export function isNoteContentEmpty(doc: ProseMirrorNode): boolean { + // Defensive: emptiness triggers REMOVAL of the footnote, so a doc that + // cannot be inspected (detached/mocked session editors without a real PM + // doc) must read as NOT empty — never delete on uncertainty. + if (!doc || typeof (doc as { descendants?: unknown }).descendants !== 'function') return false; let hasContent = false; doc.descendants((node) => { if (hasContent) return false; From 5c4359ef27bf536788441483c66b7ea6d3c90b2e Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 07:23:13 -0300 Subject: [PATCH 15/36] refactor(footnote): complete the story-editor mock instead of guarding types (SD-3400) Replaces the runtime duck-typing guard added in ddf80d7ae. The guard was a type-system lie: isNoteContentEmpty declares a ProseMirrorNode parameter and then cast it to question its own type. The real defect was an incomplete test double: PresentationEditor.test.ts mocks story editors whose state.doc had content.size and textBetween but no descendants, violating the doc contract the note-session empty watch relies on. Fix at the source: the mock doc now implements descendants consistently with the text it already claims to contain. isNoteContentEmpty keeps its honest contract and the coordinator keeps typed ProseMirrorNode docs with no casts. In production the session editor is always a real Editor, so no defensive runtime checks are warranted. --- .../presentation-editor/tests/PresentationEditor.test.ts | 7 +++++++ .../story-runtime/note-story-runtime.ts | 4 ---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 7535fc4c9c..ac09c7960a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -109,6 +109,13 @@ const { size: 10, }, textBetween: vi.fn(() => 'Lazy note session'), + // Mirror the real PM doc contract: this stub doc reports text via + // textBetween, so descendants must walk a matching text node (the + // note-session empty watch inspects it via isNoteContentEmpty). + descendants: vi.fn((cb: (node: unknown) => boolean | void) => { + cb({ isText: false, isAtom: false, type: { name: 'paragraph' } }); + cb({ isText: true, isAtom: true, text: 'Lazy note session', type: { name: 'text' } }); + }), }, }, options: {}, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts index 3a73c1af32..ae44b8aceb 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts @@ -32,10 +32,6 @@ type NoteStoryLocator = FootnoteStoryLocator | EndnoteStoryLocator; * Exported so PresentationEditor's note-session watcher applies the same rule. */ export function isNoteContentEmpty(doc: ProseMirrorNode): boolean { - // Defensive: emptiness triggers REMOVAL of the footnote, so a doc that - // cannot be inspected (detached/mocked session editors without a real PM - // doc) must read as NOT empty — never delete on uncertainty. - if (!doc || typeof (doc as { descendants?: unknown }).descendants !== 'function') return false; let hasContent = false; doc.descendants((node) => { if (hasContent) return false; From 456f68a4356beb50852b79b80aed7a9e2acae1e3 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 08:46:21 -0300 Subject: [PATCH 16/36] fix(footnotes): note-story insert guard and full emptied-note removal Reject footnotes.insert when the target editor is a note, header, or footer story (ECMA-376 17.11.14: a footnoteReference inside a footnote or endnote is non-conformant) and strip any reference nodes that reach a note commit through paste. Emptying a note in the note area now removes the note everywhere: every body reference (footnote and endnote ids are independent namespaces, so resolution is type-aware) plus the OOXML element, in one compound mutation. --- .../plan-engine/footnote-wrappers.test.ts | 63 ++++++++++++++++ .../plan-engine/footnote-wrappers.ts | 72 +++++++++++++++++++ .../story-runtime/note-story-runtime.test.ts | 70 +++++++++++++++--- .../story-runtime/note-story-runtime.ts | 42 +++++++---- 4 files changed, 223 insertions(+), 24 deletions(-) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts index 430fe45723..9f45612ff6 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts @@ -73,6 +73,7 @@ import { footnotesUpdateWrapper, footnotesRemoveWrapper, footnotesConfigureWrapper, + removeNoteEverywhere, } from './footnote-wrappers.js'; // --------------------------------------------------------------------------- @@ -218,6 +219,68 @@ describe('footnote-wrappers', () => { expect(noteElements[0].attributes['w:id']).toBe('1'); }); + it('removeNoteEverywhere deletes ALL references to the note and the OOXML element (SD-3400)', () => { + // Two references to footnote id '2' (multi-ref note emptied in the area): + // both markers and the element must go. + const editor = makeEditor([{ id: '2', text: 'Shared note' }], ['2', '2']); + + const result = removeNoteEverywhere(editor, { noteId: '2', type: 'footnote' }); + + expect(result.success).toBe(true); + expect(editor.state.tr.delete).toHaveBeenCalledTimes(2); + expect(getFootnoteElements(editor)).toHaveLength(0); + }); + + it('removeNoteEverywhere is type-aware: endnote id N never touches footnote id N (SD-3400)', () => { + const editor = makeEditor([{ id: '2', text: 'Footnote two' }], []); + // Document carries BOTH a footnote ref and an endnote ref with id '2'. + const mixedDoc = { + descendants: (cb: (node: unknown, pos: number) => boolean | void) => { + cb({ type: { name: 'footnoteReference' }, attrs: { id: '2' } }, 1); + cb({ type: { name: 'endnoteReference' }, attrs: { id: '2' } }, 5); + return true; + }, + nodeAt: vi.fn(() => ({ nodeSize: 1 })), + }; + (editor.state as unknown as { doc: unknown }).doc = mixedDoc; + (editor.state.tr as unknown as { doc: unknown }).doc = mixedDoc; + + const result = removeNoteEverywhere(editor, { noteId: '2', type: 'footnote' }); + + expect(result.success).toBe(true); + // Only the footnote reference (pos 1) is deleted; the endnote ref survives. + expect(editor.state.tr.delete).toHaveBeenCalledTimes(1); + expect(editor.state.tr.delete).toHaveBeenCalledWith(1, 2); + expect(getFootnoteElements(editor)).toHaveLength(0); + }); + + it('removeNoteEverywhere is a NO_OP failure when no reference of that type exists', () => { + const editor = makeEditor([{ id: '3', text: 'Orphan' }], []); + + const result = removeNoteEverywhere(editor, { noteId: '3', type: 'footnote' }); + + expect(result.success).toBe(false); + expect(editor.state.tr.delete).not.toHaveBeenCalled(); + expect(getFootnoteElements(editor)).toHaveLength(1); + }); + + it('rejects insertion from a story editor (footnote inside a note is non-conformant, SD-3400)', () => { + // §17.11.14: a footnoteReference inside a footnote/endnote makes the + // document non-conformant. Story editors carry options.parentEditor. + const editor = makeEditor([], []); + (editor as unknown as { options: Record }).options = { parentEditor: makeEditor([], []) }; + + const result = footnotesInsertWrapper(editor, { type: 'footnote', content: '' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('INVALID_TARGET'); + } + // Nothing was inserted anywhere. + expect(editor.state.tr.insert).not.toHaveBeenCalled(); + expect(getFootnoteElements(editor)).toHaveLength(0); + }); + it('inserts at the current selection head when at is omitted (SD-3400 toolbar path)', () => { const editor = makeEditor([], []); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts index 940777d6df..27e58f6a86 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts @@ -192,6 +192,18 @@ export function footnotesInsertWrapper( rejectTrackedMode('footnotes.insert', options); checkRevision(editor, options?.expectedRevision); + // §17.11.14: a footnote reference inside a footnote or endnote makes the + // document non-conformant (and Word also forbids footnotes in headers and + // footers). Story editors carry options.parentEditor — reject insertion + // there so toolbar actions wired to the ACTIVE editor cannot write a + // footnoteReference into a note story; callers must use the host editor. + if ((editor.options as { parentEditor?: unknown } | undefined)?.parentEditor) { + return footnoteFailure( + 'INVALID_TARGET', + 'footnotes.insert: footnotes can only be inserted into the document body, not inside a footnote, endnote, header, or footer.', + ); + } + const converter = getConverter(editor); const notesConfig = getNotesConfig(input.type); const noteId = allocateNextNoteId(editor, converter, input.type); @@ -357,6 +369,66 @@ export function footnotesRemoveWrapper( return footnoteSuccess(address); } +/** + * SD-3400: remove a note and EVERY body reference to it ("remove on both + * sides"). Used by the note-area emptied-note commit, where the whole footnote + * ceases to exist — including multi-reference notes, whose surviving markers + * would otherwise keep the old (un-emptied) content. + * + * Type-aware: footnote and endnote ids are independent OOXML namespaces, so + * resolution filters by note type — emptying endnote "2" must never touch + * footnote "2". The address-based {@link footnotesRemoveWrapper} keeps its + * single-reference semantics for the document API. + */ +export function removeNoteEverywhere( + editor: Editor, + input: { noteId: string; type: 'footnote' | 'endnote' }, +): FootnoteMutationResult { + const refs = findAllFootnotes(editor.state.doc, input.type).filter((f) => f.noteId === input.noteId); + if (refs.length === 0) { + return footnoteFailure('NO_OP', `No ${input.type} reference with id "${input.noteId}" found.`); + } + + const notesConfig = getNotesConfig(input.type); + const address: FootnoteAddress = { kind: 'entity', entityType: 'footnote', noteId: input.noteId }; + + const { success } = compoundMutation({ + editor, + source: `footnotes.removeEverywhere:${input.type}`, + affectedParts: [notesConfig.partId], + execute: () => { + const { tr } = editor.state; + // Descending positions keep earlier offsets valid as later refs go. + [...refs] + .sort((a, b) => b.pos - a.pos) + .forEach((ref) => { + const node = tr.doc.nodeAt(ref.pos); + if (node) tr.delete(ref.pos, ref.pos + node.nodeSize); + }); + editor.dispatch(tr); + + mutatePart({ + editor, + partId: notesConfig.partId, + operation: 'mutate', + source: `footnotes.removeEverywhere:${input.type}`, + mutate({ part }) { + removeNoteElement(part, notesConfig, input.noteId); + }, + }); + + clearIndexCache(editor); + return true; + }, + }); + + if (!success) { + return footnoteFailure('NO_OP', 'Remove operation produced no change.'); + } + + return footnoteSuccess(address); +} + /** * Configure footnote/endnote numbering and placement. * diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts index 803c0bb094..d47e2765e2 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts @@ -37,11 +37,11 @@ vi.mock('../../core/parts/adapters/notes-part-descriptor.js', () => ({ })); // SD-3400: mock the removal boundary so the commit-on-empty wiring can be -// asserted without exercising footnotesRemoveWrapper's internals (covered by +// asserted without exercising removeNoteEverywhere's internals (covered by // footnote-wrappers.test.ts). -const mockFootnotesRemoveWrapper = vi.fn(() => ({ success: true })); +const mockRemoveNoteEverywhere = vi.fn(() => ({ success: true })); vi.mock('../plan-engine/footnote-wrappers.js', () => ({ - footnotesRemoveWrapper: (...args: unknown[]) => mockFootnotesRemoveWrapper(...args), + removeNoteEverywhere: (...args: unknown[]) => mockRemoveNoteEverywhere(...args), })); // Import after mocks are set up @@ -263,11 +263,63 @@ describe('resolveNoteRuntime — empty note content', () => { }); }); +describe('SD-3400: note commits strip footnote references (17.11.14)', () => { + it('removes pasted footnoteReference nodes from the exported note content', () => { + const exportToXmlJson = vi.fn(() => ({ + result: { elements: [{ elements: [{ type: 'element', name: 'w:p' }] }] }, + })); + // Story doc has real text (not empty) plus a pasted footnoteReference node. + mockCreateStoryEditor.mockReturnValueOnce({ + state: { + doc: { + content: { size: 8 }, + textBetween: () => 'kept', + descendants: (cb: (n: unknown) => boolean | void) => { + cb({ isText: true, isAtom: true, text: 'kept', type: { name: 'text' } }); + }, + }, + }, + schema: {}, + getJSON: () => ({ type: 'doc', content: [] }), + getUpdatedJson: () => ({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'kept' }, + { type: 'footnoteReference', attrs: { id: '9' } }, + ], + }, + ], + }), + destroy: vi.fn(), + on: vi.fn(), + } as never); + const host = { + converter: { footnotes: [{ id: '1', content: [{ type: 'paragraph' }] }], endnotes: [], exportToXmlJson }, + state: { + doc: { + descendants: (cb: (n: unknown, p: number) => void) => + cb({ type: { name: 'footnoteReference' }, attrs: { id: '1' } }, 5), + }, + }, + on: vi.fn(), + } as any; + + const runtime = resolveNoteRuntime(host, footnoteLocator); + runtime.commit?.(host); + + expect(exportToXmlJson).toHaveBeenCalledTimes(1); + const exported = JSON.stringify(exportToXmlJson.mock.calls[0][0].data); + expect(exported).not.toContain('footnoteReference'); + expect(exported).toContain('kept'); + }); +}); + describe('SD-3400: clearing a note in the area removes the footnote on both sides', () => { - beforeEach(() => mockFootnotesRemoveWrapper.mockClear()); + beforeEach(() => mockRemoveNoteEverywhere.mockClear()); - // Host editor whose body contains a footnoteReference id '1' (so real - // findAllFootnotes confirms the reference exists before removal). const makeHost = () => ({ converter: { footnotes: [{ id: '1', content: [{ type: 'paragraph' }] }], endnotes: [] }, @@ -296,9 +348,7 @@ describe('SD-3400: clearing a note in the area removes the footnote on both side runtime.commit?.(host); - expect(mockFootnotesRemoveWrapper).toHaveBeenCalledWith(host, { - target: { kind: 'entity', entityType: 'footnote', noteId: '1' }, - }); + expect(mockRemoveNoteEverywhere).toHaveBeenCalledWith(host, { noteId: '1', type: 'footnote' }); }); it('does not remove the footnote when the committed note still has content', () => { @@ -313,6 +363,6 @@ describe('SD-3400: clearing a note in the area removes the footnote on both side runtime.commit?.(host); - expect(mockFootnotesRemoveWrapper).not.toHaveBeenCalled(); + expect(mockRemoveNoteEverywhere).not.toHaveBeenCalled(); }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts index ae44b8aceb..14f27e330c 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts @@ -20,8 +20,7 @@ import { updateNoteElement, } from '../../core/parts/adapters/notes-part-descriptor.js'; import { normalizeNotePmJson } from '../helpers/note-pm-json.js'; -import { footnotesRemoveWrapper } from '../plan-engine/footnote-wrappers.js'; -import { findAllFootnotes } from '../helpers/footnote-resolver.js'; +import { removeNoteEverywhere } from '../plan-engine/footnote-wrappers.js'; import type { Node as ProseMirrorNode } from 'prosemirror-model'; type NoteStoryLocator = FootnoteStoryLocator | EndnoteStoryLocator; @@ -144,18 +143,32 @@ function commitNoteRuntime( /** * SD-3400: clearing all content in the note area deletes the footnote on BOTH - * sides — the note element in the notes part AND the body reference — and the - * document renumbers. This mirrors the body-side staged delete; deleting from - * either side removes the whole footnote. footnotesRemoveWrapper deletes the - * body reference node and removes the OOXML element when no other reference - * remains. Guard on the reference still existing so a stale commit is a no-op. + * sides — the note element in the notes part AND every body reference — and + * the document renumbers. This mirrors the body-side staged delete; deleting + * from either side removes the whole footnote. Multi-reference notes lose all + * their markers (the emptied note no longer exists for any of them), and + * resolution is type-aware so emptying endnote "2" never touches footnote "2". */ function removeEmptiedNote(hostEditor: Editor, locator: NoteStoryLocator): void { - const referenceExists = findAllFootnotes(hostEditor.state.doc).some((f) => f.noteId === locator.noteId); - if (!referenceExists) return; - footnotesRemoveWrapper(hostEditor, { - target: { kind: 'entity', entityType: 'footnote', noteId: locator.noteId }, - }); + removeNoteEverywhere(hostEditor, { noteId: locator.noteId, type: locator.storyType }); +} + +const NOTE_REFERENCE_NODE_TYPES = new Set(['footnoteReference', 'endnoteReference']); + +/** + * §17.11.14: a footnote reference inside a footnote or endnote makes the + * document non-conformant. Reference nodes can reach a note story through + * paste (HTML containing `sup[data-footnote-id]` parses to footnoteReference); + * strip them before the note content is exported to the OOXML part. + */ +function stripNoteReferenceNodes(node: T): T { + if (!Array.isArray(node.content)) return node; + return { + ...node, + content: node.content + .filter((child) => !NOTE_REFERENCE_NODE_TYPES.has(child?.type ?? '')) + .map((child) => stripNoteReferenceNodes(child)), + }; } /** @@ -170,9 +183,10 @@ function commitRichNoteContent( notesConfig: NotesConfig, ): boolean { const conv = (hostEditor as unknown as { converter?: ConverterWithNoteExport }).converter; - const pmJson = + const rawPmJson = typeof storyEditor.getUpdatedJson === 'function' ? storyEditor.getUpdatedJson() : storyEditor.getJSON(); - if (!conv?.exportToXmlJson || !pmJson) return false; + if (!conv?.exportToXmlJson || !rawPmJson) return false; + const pmJson = stripNoteReferenceNodes(rawPmJson); let ooxmlElements: unknown[] | null = null; try { From fe72bf84a05371ce2fb2fd5cc1f213580c86518f Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 08:51:53 -0300 Subject: [PATCH 17/36] fix(footnotes): prune note element on body-side staged delete The second Backspace/Delete on a staged-selected marker previously fell through to deleteSelection, which removed only the PM reference and left an orphaned w:footnote element in the notes part (exported to DOCX). Route the staged delete through removeNoteReferenceAt, extracted from footnotesRemoveWrapper: position-addressed removal that prunes the OOXML element when the deleted marker was the note's last reference of that type. Wired before deleteSelection in both keymap chains, keeping the 'remove on both sides' rule symmetric with the note-area delete. --- .../extensions/keymap-backspace-chain.test.js | 9 +- .../src/editors/v1/core/extensions/keymap.js | 2 + .../plan-engine/footnote-wrappers.test.ts | 26 ++++ .../plan-engine/footnote-wrappers.ts | 48 +++++-- .../extensions/footnote/delete-note-marker.js | 45 ++++++ .../footnote/delete-note-marker.test.js | 129 ++++++++++++++++++ .../v1/extensions/footnote/footnote.js | 16 +++ 7 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/extensions/footnote/delete-note-marker.js create mode 100644 packages/super-editor/src/editors/v1/extensions/footnote/delete-note-marker.test.js diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js index 739d28daf2..e3848b1d8b 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js @@ -39,6 +39,7 @@ describe('handleBackspace chain ordering', () => { deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtBeforeRunStart: make('selectInlineSdtBeforeRunStart'), selectFootnoteMarkerBefore: make('selectFootnoteMarkerBefore'), + deleteSelectedNoteMarker: make('deleteSelectedNoteMarker'), selectBlockSdtBeforeTextBlockStart: make('selectBlockSdtBeforeTextBlockStart'), moveIntoBlockSdtBeforeTextBlockStart: make('moveIntoBlockSdtBeforeTextBlockStart'), backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'), @@ -79,6 +80,7 @@ describe('handleBackspace chain ordering', () => { 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtBeforeRunStart', 'selectFootnoteMarkerBefore', + 'deleteSelectedNoteMarker', 'selectBlockSdtBeforeTextBlockStart', 'moveIntoBlockSdtBeforeTextBlockStart', 'backspaceEmptyRunParagraph', @@ -112,8 +114,9 @@ describe('handleBackspace chain ordering', () => { expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart'); expect(callLog[2]).toBe('selectInlineSdtBeforeRunStart'); expect(callLog[3]).toBe('selectFootnoteMarkerBefore'); - expect(callLog[4]).toBe('selectBlockSdtBeforeTextBlockStart'); - expect(callLog[5]).toBe('moveIntoBlockSdtBeforeTextBlockStart'); + expect(callLog[4]).toBe('deleteSelectedNoteMarker'); + expect(callLog[5]).toBe('selectBlockSdtBeforeTextBlockStart'); + expect(callLog[6]).toBe('moveIntoBlockSdtBeforeTextBlockStart'); }); it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => { @@ -186,6 +189,7 @@ describe('handleDelete chain ordering', () => { deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtAfterRunEnd: make('selectInlineSdtAfterRunEnd'), selectFootnoteMarkerAfter: make('selectFootnoteMarkerAfter'), + deleteSelectedNoteMarker: make('deleteSelectedNoteMarker'), selectBlockSdtAfterTextBlockEnd: make('selectBlockSdtAfterTextBlockEnd'), moveIntoBlockSdtAfterTextBlockEnd: make('moveIntoBlockSdtAfterTextBlockEnd'), deleteSkipEmptyRun: make('deleteSkipEmptyRun'), @@ -221,6 +225,7 @@ describe('handleDelete chain ordering', () => { 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtAfterRunEnd', 'selectFootnoteMarkerAfter', + 'deleteSelectedNoteMarker', 'selectBlockSdtAfterTextBlockEnd', 'moveIntoBlockSdtAfterTextBlockEnd', 'deleteSkipEmptyRun', diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap.js b/packages/super-editor/src/editors/v1/core/extensions/keymap.js index 9d3670a67f..a57821d438 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -40,6 +40,7 @@ export const handleBackspace = (editor) => { () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtBeforeRunStart(), () => commands.selectFootnoteMarkerBefore?.() ?? false, + () => commands.deleteSelectedNoteMarker?.() ?? false, () => commands.selectBlockSdtBeforeTextBlockStart(), () => commands.moveIntoBlockSdtBeforeTextBlockStart(), () => commands.backspaceEmptyRunParagraph(), @@ -64,6 +65,7 @@ export const handleDelete = (editor) => { () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtAfterRunEnd(), () => commands.selectFootnoteMarkerAfter?.() ?? false, + () => commands.deleteSelectedNoteMarker?.() ?? false, () => commands.selectBlockSdtAfterTextBlockEnd(), () => commands.moveIntoBlockSdtAfterTextBlockEnd(), () => commands.deleteSkipEmptyRun(), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts index 9f45612ff6..6b378250ea 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts @@ -74,6 +74,7 @@ import { footnotesRemoveWrapper, footnotesConfigureWrapper, removeNoteEverywhere, + removeNoteReferenceAt, } from './footnote-wrappers.js'; // --------------------------------------------------------------------------- @@ -254,6 +255,31 @@ describe('footnote-wrappers', () => { expect(getFootnoteElements(editor)).toHaveLength(0); }); + it('removeNoteReferenceAt deletes the reference at the exact position, not the first id match (SD-3400)', () => { + // Two references to footnote id '2' at positions 1 and 2; the staged + // delete targets the SECOND one. The element survives because the first + // reference still exists. + const editor = makeEditor([{ id: '2', text: 'Shared note' }], ['2', '2'], { refsAfterDispatch: ['2'] }); + + const removed = removeNoteReferenceAt(editor, { pos: 2, noteId: '2', type: 'footnote' }); + + expect(removed).toBe(true); + expect(editor.state.tr.delete).toHaveBeenCalledTimes(1); + expect(editor.state.tr.delete).toHaveBeenCalledWith(2, 3); + expect(getFootnoteElements(editor)).toHaveLength(1); + }); + + it('removeNoteReferenceAt prunes the OOXML element when the last reference is deleted (SD-3400)', () => { + // Body-side staged delete symmetry: the second Backspace must not leave + // an orphaned w:footnote element behind. + const editor = makeEditor([{ id: '2', text: 'Note 2' }], ['2'], { refsAfterDispatch: [] }); + + const removed = removeNoteReferenceAt(editor, { pos: 1, noteId: '2', type: 'footnote' }); + + expect(removed).toBe(true); + expect(getFootnoteElements(editor)).toHaveLength(0); + }); + it('removeNoteEverywhere is a NO_OP failure when no reference of that type exists', () => { const editor = makeEditor([{ id: '3', text: 'Orphan' }], []); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts index 27e58f6a86..85d9cd469b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts @@ -325,34 +325,58 @@ export function footnotesRemoveWrapper( return footnoteSuccess(address); } - const notesConfig = getNotesConfig(resolved.type); + const removed = removeNoteReferenceAt(editor, { + pos: resolved.pos, + noteId: resolved.noteId, + type: resolved.type, + }); + + if (!removed) { + return footnoteFailure('NO_OP', 'Remove operation produced no change.'); + } + + return footnoteSuccess(address); +} + +/** + * Remove the single note reference at an exact document position, pruning the + * OOXML note element when no other reference of the same type remains. + * + * Position-addressed (not id-addressed) so callers that already hold the node + * — the staged Backspace/Delete on a selected marker (SD-3400) — remove + * exactly that reference even when the same id appears multiple times. + * {@link footnotesRemoveWrapper} delegates here after resolving its target. + */ +export function removeNoteReferenceAt( + editor: Editor, + ref: { pos: number; noteId: string; type: 'footnote' | 'endnote' }, +): boolean { + const notesConfig = getNotesConfig(ref.type); const { success } = compoundMutation({ editor, - source: `footnotes.remove:${resolved.type}`, + source: `footnotes.remove:${ref.type}`, affectedParts: [notesConfig.partId], execute: () => { // 1. Delete the reference node from the PM document const { tr } = editor.state; - const node = tr.doc.nodeAt(resolved.pos); + const node = tr.doc.nodeAt(ref.pos); if (!node) return false; - tr.delete(resolved.pos, resolved.pos + node.nodeSize); + tr.delete(ref.pos, ref.pos + node.nodeSize); editor.dispatch(tr); // 2. Remove from the OOXML part if no other references remain - const stillReferenced = findAllFootnotes(editor.state.doc, resolved.type).some( - (f) => f.noteId === resolved.noteId, - ); + const stillReferenced = findAllFootnotes(editor.state.doc, ref.type).some((f) => f.noteId === ref.noteId); if (!stillReferenced) { mutatePart({ editor, partId: notesConfig.partId, operation: 'mutate', - source: `footnotes.remove:${resolved.type}`, + source: `footnotes.remove:${ref.type}`, mutate({ part }) { - removeNoteElement(part, notesConfig, resolved.noteId); + removeNoteElement(part, notesConfig, ref.noteId); }, }); } @@ -362,11 +386,7 @@ export function footnotesRemoveWrapper( }, }); - if (!success) { - return footnoteFailure('NO_OP', 'Remove operation produced no change.'); - } - - return footnoteSuccess(address); + return success; } /** diff --git a/packages/super-editor/src/editors/v1/extensions/footnote/delete-note-marker.js b/packages/super-editor/src/editors/v1/extensions/footnote/delete-note-marker.js new file mode 100644 index 0000000000..7143b859c3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/footnote/delete-note-marker.js @@ -0,0 +1,45 @@ +import { removeNoteReferenceAt } from '../../document-api-adapters/plan-engine/footnote-wrappers.js'; + +const NOTE_TYPE_BY_NODE = { + footnoteReference: 'footnote', + endnoteReference: 'endnote', +}; + +/** + * SD-3400: detects the staged-delete selection produced by + * `selectFootnoteMarkerBefore`/`selectFootnoteMarkerAfter` — a TextSelection + * spanning exactly one footnote/endnote reference atom. + * + * @param {import('prosemirror-state').EditorState} state + * @returns {{ pos: number, noteId: string, type: 'footnote' | 'endnote' } | null} + */ +export function getSelectedNoteMarker(state) { + const { from, to, empty } = state.selection; + if (empty) return null; + + const node = state.doc.nodeAt(from); + const type = NOTE_TYPE_BY_NODE[node?.type?.name]; + if (!type || from + node.nodeSize !== to) return null; + + return { pos: from, noteId: String(node.attrs?.id ?? ''), type }; +} + +/** + * SD-3400: second stage of the Word-like staged delete, symmetric with the + * note-area delete ("remove on both sides"). Where plain `deleteSelection` + * would only remove the body marker — leaving an orphaned `w:footnote` + * element in the notes part — this routes the removal through the document + * API wrapper, which also prunes the OOXML element when the deleted marker + * was the note's last reference. + * + * Plain orchestrator (no PM command plumbing) so any caller can use it; the + * `deleteSelectedNoteMarker` editor command is a thin shim over this function. + * + * @param {import('@core/Editor.js').Editor} editor + * @returns {boolean} True when a staged-selected marker was removed. + */ +export function deleteSelectedNoteMarker(editor) { + const marker = getSelectedNoteMarker(editor.state); + if (!marker) return false; + return removeNoteReferenceAt(editor, marker); +} diff --git a/packages/super-editor/src/editors/v1/extensions/footnote/delete-note-marker.test.js b/packages/super-editor/src/editors/v1/extensions/footnote/delete-note-marker.test.js new file mode 100644 index 0000000000..2bd23f9485 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/footnote/delete-note-marker.test.js @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; + +// SD-3400: mock the removal boundary — the wrapper's delete/prune internals +// are covered by footnote-wrappers.test.ts. This suite verifies detection of +// a staged-selected marker and the handoff to the wrapper. +const mockRemoveNoteReferenceAt = vi.fn(() => true); +vi.mock('../../document-api-adapters/plan-engine/footnote-wrappers.js', () => ({ + removeNoteReferenceAt: (...args) => mockRemoveNoteReferenceAt(...args), +})); + +import { getSelectedNoteMarker, deleteSelectedNoteMarker } from './delete-note-marker.js'; + +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + run: { inline: true, group: 'inline', content: 'inline*' }, + footnoteReference: { + inline: true, + group: 'inline', + atom: true, + selectable: false, + attrs: { id: { default: null } }, + }, + endnoteReference: { + inline: true, + group: 'inline', + atom: true, + selectable: false, + attrs: { id: { default: null } }, + }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +const makeDoc = (schema, refType = 'footnoteReference') => { + const beforeRun = schema.nodes.run.create(null, schema.text('Before')); + const markerRun = schema.nodes.run.create(null, schema.nodes[refType].create({ id: '7' })); + const afterRun = schema.nodes.run.create(null, schema.text('After')); + return schema.node('doc', null, [schema.node('paragraph', null, [beforeRun, markerRun, afterRun])]); +}; + +const findNode = (doc, typeName) => { + let result = null; + doc.descendants((node, pos) => { + if (!result && node.type.name === typeName) { + result = { node, pos, end: pos + node.nodeSize }; + return false; + } + return true; + }); + return result; +}; + +const makeState = (schema, doc, from, to) => + EditorState.create({ schema, doc, selection: TextSelection.create(doc, from, to) }); + +beforeEach(() => mockRemoveNoteReferenceAt.mockClear()); + +describe('getSelectedNoteMarker', () => { + it.each([ + ['footnoteReference', 'footnote'], + ['endnoteReference', 'endnote'], + ])('detects a selection spanning exactly one %s atom', (refType, noteType) => { + const schema = makeSchema(); + const doc = makeDoc(schema, refType); + const marker = findNode(doc, refType); + const state = makeState(schema, doc, marker.pos, marker.end); + + expect(getSelectedNoteMarker(state)).toEqual({ pos: marker.pos, noteId: '7', type: noteType }); + }); + + it('returns null for a collapsed selection', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const marker = findNode(doc, 'footnoteReference'); + const state = makeState(schema, doc, marker.end, marker.end); + + expect(getSelectedNoteMarker(state)).toBeNull(); + }); + + it('returns null when the selection spans regular text', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const state = makeState(schema, doc, 2, 5); + + expect(getSelectedNoteMarker(state)).toBeNull(); + }); + + it('returns null when the selection extends beyond the marker', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const marker = findNode(doc, 'footnoteReference'); + const state = makeState(schema, doc, marker.pos, marker.end + 2); + + expect(getSelectedNoteMarker(state)).toBeNull(); + }); +}); + +describe('deleteSelectedNoteMarker', () => { + it('removes the selected marker through the wrapper (element pruned when last reference)', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const marker = findNode(doc, 'footnoteReference'); + const editor = { state: makeState(schema, doc, marker.pos, marker.end) }; + + const handled = deleteSelectedNoteMarker(editor); + + expect(handled).toBe(true); + expect(mockRemoveNoteReferenceAt).toHaveBeenCalledWith(editor, { + pos: marker.pos, + noteId: '7', + type: 'footnote', + }); + }); + + it('does nothing when the selection is not a staged marker selection', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const editor = { state: makeState(schema, doc, 2, 5) }; + + expect(deleteSelectedNoteMarker(editor)).toBe(false); + expect(mockRemoveNoteReferenceAt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js b/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js index 53039f9918..d7f046b3e0 100644 --- a/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js +++ b/packages/super-editor/src/editors/v1/extensions/footnote/footnote.js @@ -1,6 +1,7 @@ import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; import { insertFootnoteAtCursor } from './insert-footnote.js'; +import { getSelectedNoteMarker, deleteSelectedNoteMarker } from './delete-note-marker.js'; const toSuperscriptDigits = (value) => { const map = { @@ -136,6 +137,21 @@ export const FootnoteReference = Node.create({ tr.setMeta('preventDispatch', true); return insertFootnoteAtCursor(editor); }, + + /** + * SD-3400: thin command shim over {@link deleteSelectedNoteMarker}. + * Runs before `deleteSelection` in the Backspace/Delete chains so the + * second stage of the staged marker delete also prunes the OOXML note + * element ("remove on both sides"). + */ + deleteSelectedNoteMarker: + () => + ({ editor, state, tr }) => { + if (!getSelectedNoteMarker(state)) return false; + // Same preventDispatch reason as insertFootnote above. + tr.setMeta('preventDispatch', true); + return deleteSelectedNoteMarker(editor); + }, }; }, From 3e994e3a4e1b2e419070c272f5fffcf07e5730bf Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 08:55:26 -0300 Subject: [PATCH 18/36] feat(footnotes): word-fidelity bootstrap for generated notes Stamp w:pStyle FootnoteText/EndnoteText on note paragraphs generated from plain text, matching Word's note body styling (without it, exported new footnotes render at the Normal style size in Word). When bootstrapping a missing notes part, also write the special-note list to settings.xml (ECMA-376 17.11.9): w:footnotePr/w:endnotePr listing the separator (-1) and continuation separator (0), which strict consumers require before loading separators. Imported documents keep their settings untouched. --- .../parts/adapters/notes-part-descriptor.ts | 56 +++++++++++++++++-- .../plan-engine/footnote-wrappers.test.ts | 44 +++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/parts/adapters/notes-part-descriptor.ts b/packages/super-editor/src/editors/v1/core/parts/adapters/notes-part-descriptor.ts index eeb3c3475b..98abbdfe28 100644 --- a/packages/super-editor/src/editors/v1/core/parts/adapters/notes-part-descriptor.ts +++ b/packages/super-editor/src/editors/v1/core/parts/adapters/notes-part-descriptor.ts @@ -128,13 +128,25 @@ export function getNoteElements(part: unknown, childElementName: string): OoxmlE * Used during insert/update to write text content directly into the * canonical OOXML part. The result is valid w:p elements that can be * re-imported by the standard footnote importer. + * + * Each paragraph carries the FootnoteText/EndnoteText paragraph style — + * Word always stamps it on note body paragraphs, and without it exported + * new notes render at the Normal style's size in Word. */ -export function textToNoteOoxmlParagraphs(text: string): OoxmlElement[] { +export function textToNoteOoxmlParagraphs(text: string, childElementName: string): OoxmlElement[] { + const styleName = childElementName === 'w:endnote' ? 'EndnoteText' : 'FootnoteText'; + const pPr: OoxmlElement = { + type: 'element', + name: 'w:pPr', + elements: [{ type: 'element', name: 'w:pStyle', attributes: { 'w:val': styleName } }], + }; + return text.split(/\r?\n/).map((line) => ({ type: 'element', name: 'w:p', - elements: - line.length > 0 + elements: [ + structuredClone(pPr), + ...(line.length > 0 ? [ { type: 'element', @@ -149,7 +161,8 @@ export function textToNoteOoxmlParagraphs(text: string): OoxmlElement[] { ], }, ] - : [], + : []), + ], })); } @@ -220,7 +233,7 @@ export function addNoteElement(part: unknown, config: NotePartConfig, noteId: st throw new Error(`addNoteElement: note id "${noteId}" already exists in ${config.partId}`); } - const paragraphs = textToNoteOoxmlParagraphs(text); + const paragraphs = textToNoteOoxmlParagraphs(text, config.childElementName); ensureFootnoteRefRun(paragraphs, config.childElementName); const noteElement: OoxmlElement = { @@ -245,7 +258,7 @@ export function updateNoteElement(part: unknown, config: NotePartConfig, noteId: const target = notes.find((el) => el.attributes?.['w:id'] === noteId); if (!target) return false; - const paragraphs = textToNoteOoxmlParagraphs(text); + const paragraphs = textToNoteOoxmlParagraphs(text, config.childElementName); ensureFootnoteRefRun(paragraphs, config.childElementName); target.elements = paragraphs; return true; @@ -457,4 +470,35 @@ export function bootstrapNotesPart(editor: Editor, type: 'footnote' | 'endnote') if (converter.convertedXml[config.partId] !== undefined) return; converter.convertedXml[config.partId] = createInitialNotesPart(config); + ensureSpecialNotesListInSettings(converter.convertedXml, config); +} + +/** + * Write the special-note list to `word/settings.xml` alongside a freshly + * bootstrapped notes part (§17.11.9): `w:footnotePr`/`w:endnotePr` listing + * the separator (-1) and continuation separator (0) ids. Strict consumers + * do not load separators that are not listed here. + * + * Imported documents own their settings — this runs only on bootstrap, and + * preserves any existing properties element (it just adds the missing ids). + */ +function ensureSpecialNotesListInSettings(convertedXml: Record, config: NotePartConfig): void { + const settingsRoot = getRootElement(convertedXml['word/settings.xml']); + if (!settingsRoot) return; + if (!settingsRoot.elements) settingsRoot.elements = []; + + const prName = config.childElementName === 'w:endnote' ? 'w:endnotePr' : 'w:footnotePr'; + let pr = settingsRoot.elements.find((el) => el.name === prName); + if (!pr) { + pr = { type: 'element', name: prName, elements: [] }; + settingsRoot.elements.push(pr); + } + if (!pr.elements) pr.elements = []; + + for (const id of ['-1', '0']) { + const listed = pr.elements.some((el) => el.name === config.childElementName && el.attributes?.['w:id'] === id); + if (!listed) { + pr.elements.push({ type: 'element', name: config.childElementName, attributes: { 'w:id': id } }); + } + } } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts index 6b378250ea..c6005d3ea7 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.test.ts @@ -318,6 +318,50 @@ describe('footnote-wrappers', () => { expect(getFootnoteElements(editor)).toHaveLength(1); }); + it('stamps w:pStyle FootnoteText on generated note paragraphs (Word fidelity, SD-3400)', () => { + // Word always styles footnote body paragraphs with FootnoteText; without + // it, exported new footnotes render at Normal/11pt in Word. + const editor = makeEditor([], []); + + footnotesInsertWrapper(editor, { type: 'footnote', content: 'Styled note' }); + + const note = getFootnoteElements(editor)[0] as unknown as { + elements: Array<{ name: string; elements?: Array<{ name: string; attributes?: Record }> }>; + }; + const paragraph = note.elements.find((el) => el.name === 'w:p'); + const pPr = paragraph?.elements?.find((el) => el.name === 'w:pPr'); + const pStyle = (pPr as { elements?: Array<{ name: string; attributes?: Record }> })?.elements?.find( + (el) => el.name === 'w:pStyle', + ); + expect(pStyle?.attributes?.['w:val']).toBe('FootnoteText'); + }); + + it('bootstrap writes the special-footnote list to settings.xml (17.11.9, SD-3400)', () => { + const editor = makeEditor([], [], { omitFootnotesPart: true }); + + footnotesInsertWrapper(editor, { type: 'footnote', content: 'First footnote' }); + + const converter = (editor as unknown as { converter: { convertedXml: Record } }).converter; + const settingsRoot = (converter.convertedXml['word/settings.xml'] as XmlDoc).elements[0]; + const pr = settingsRoot.elements.find((el) => el.name === 'w:footnotePr') as unknown as { + elements: Array<{ name: string; attributes: Record }>; + }; + const ids = pr.elements.filter((el) => el.name === 'w:footnote').map((el) => el.attributes['w:id']); + expect(ids).toEqual(['-1', '0']); + }); + + it('bootstrap leaves settings.xml untouched when the notes part already exists', () => { + // Imported documents own their settings; the special list is only seeded + // alongside a freshly bootstrapped notes part. + const editor = makeEditor([{ id: '1', text: 'Existing' }], ['1']); + + footnotesInsertWrapper(editor, { type: 'footnote', content: 'Second' }); + + const converter = (editor as unknown as { converter: { convertedXml: Record } }).converter; + const settingsRoot = (converter.convertedXml['word/settings.xml'] as XmlDoc).elements[0]; + expect(settingsRoot.elements.find((el) => el.name === 'w:footnotePr')).toBeUndefined(); + }); + it('allocates a note id that avoids all existing ids', () => { const editor = makeEditor([], ['7', '3']); From 308ccfbf458f661121481c94d508235c5e6a3501 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 08:58:41 -0300 Subject: [PATCH 19/36] feat(footnotes): navigate to note from cross-reference double-click Double-clicking a REF/NOTEREF cross-reference that points at a footnote or endnote opens the corresponding note session. Word's cross-reference bookmark wraps the original note reference in the body, so the resolver follows crossReference.attrs.target to the named bookmarkStart and scans its content for a note reference. Cross-references to anything else (headings, tables) fall through to default double-click behavior. --- .../pointer-events/note-reference-hit.test.ts | 136 ++++++++++++++++++ .../pointer-events/note-reference-hit.ts | 32 +++++ 2 files changed, 168 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts new file mode 100644 index 0000000000..24c9ba7a37 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { Schema, type Node as ProseMirrorNode } from 'prosemirror-model'; +import { resolveNoteReferenceAtPointer } from './note-reference-hit.js'; + +// Mirrors the real document shape: note references are inline atoms wrapped in +// runs; Word's cross-reference bookmark (`_RefXXXX`) WRAPS the original note +// reference, and bookmarkStart is a container node (content: 'inline*'). +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + run: { inline: true, group: 'inline', content: 'inline*' }, + bookmarkStart: { + inline: true, + group: 'inline', + content: 'inline*', + attrs: { name: { default: null }, id: { default: null } }, + }, + footnoteReference: { + inline: true, + group: 'inline', + atom: true, + selectable: false, + attrs: { id: { default: null } }, + }, + endnoteReference: { + inline: true, + group: 'inline', + atom: true, + selectable: false, + attrs: { id: { default: null } }, + }, + crossReference: { + inline: true, + group: 'inline', + atom: true, + selectable: false, + attrs: { target: { default: '' }, resolvedText: { default: '' } }, + }, + text: { group: 'inline' }, + }, + marks: {}, +}); + +/** + * Builds a doc shaped like the NVCA fixture's cross-reference pattern: + * p1: "See" + bookmarkStart(_Ref1)[ run[ ] ] + "below" + * p2: "as noted in" + crossReference(target=_Ref1, "footnote 8") + */ +function makeDoc(noteRefType: 'footnoteReference' | 'endnoteReference' = 'footnoteReference', bookmarkContent?: ProseMirrorNode[]) { + const noteRef = schema.nodes[noteRefType].create({ id: '8' }); + const bookmark = schema.nodes.bookmarkStart.create( + { name: '_Ref1', id: '1' }, + bookmarkContent ?? [schema.nodes.run.create(null, noteRef)], + ); + const p1 = schema.node('paragraph', null, [ + schema.nodes.run.create(null, schema.text('See')), + bookmark, + schema.nodes.run.create(null, schema.text('below')), + ]); + const crossRef = schema.nodes.crossReference.create({ target: '_Ref1', resolvedText: 'footnote 8' }); + const p2 = schema.node('paragraph', null, [schema.nodes.run.create(null, schema.text('as noted in')), crossRef]); + return schema.node('doc', null, [p1, p2]); +} + +function findPos(doc: ProseMirrorNode, typeName: string): number { + let found = -1; + doc.descendants((node, pos) => { + if (found < 0 && node.type.name === typeName) { + found = pos; + return false; + } + return true; + }); + return found; +} + +function makeRefSpan(pmStart: number): HTMLElement { + const el = document.createElement('span'); + el.setAttribute('data-pm-start', String(pmStart)); + document.body.appendChild(el); + return el; +} + +function resolveAt(doc: ProseMirrorNode, pmStart: number) { + return resolveNoteReferenceAtPointer({ + target: makeRefSpan(pmStart), + clientX: 5, + clientY: 5, + doc, + ownerDocument: document, + }); +} + +describe('resolveNoteReferenceAtPointer — cross-reference navigation (SD-3400)', () => { + it('resolves a crossReference to the footnote wrapped by its target bookmark', () => { + const doc = makeDoc('footnoteReference'); + + const target = resolveAt(doc, findPos(doc, 'crossReference')); + + expect(target).toEqual({ storyType: 'footnote', noteId: '8' }); + }); + + it('resolves a crossReference to an endnote wrapped by its target bookmark', () => { + const doc = makeDoc('endnoteReference'); + + const target = resolveAt(doc, findPos(doc, 'crossReference')); + + expect(target).toEqual({ storyType: 'endnote', noteId: '8' }); + }); + + it('returns null when the target bookmark holds no note reference (plain bookmark cross-ref)', () => { + const doc = makeDoc('footnoteReference', [schema.nodes.run.create(null, schema.text('Section 2'))]); + + const target = resolveAt(doc, findPos(doc, 'crossReference')); + + expect(target).toBeNull(); + }); + + it('returns null for a crossReference with no target', () => { + const crossRef = schema.nodes.crossReference.create({ target: '', resolvedText: 'dangling' }); + const doc = schema.node('doc', null, [schema.node('paragraph', null, [crossRef])]); + + const target = resolveAt(doc, findPos(doc, 'crossReference')); + + expect(target).toBeNull(); + }); + + it('still resolves a plain body footnote reference (regression)', () => { + const doc = makeDoc('footnoteReference'); + + const target = resolveAt(doc, findPos(doc, 'footnoteReference')); + + expect(target).toEqual({ storyType: 'footnote', noteId: '8' }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts index e9f22d5a0c..f718bd74b8 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts @@ -47,6 +47,13 @@ function noteTargetFromPmStartElement( const pmStart = Number(refEl.getAttribute('data-pm-start')); if (!Number.isFinite(pmStart)) return null; const node = doc.nodeAt(pmStart); + if (node?.type?.name === 'crossReference') { + return noteTargetFromCrossReference(doc, node.attrs?.target); + } + return noteTargetFromReferenceNode(node); +} + +function noteTargetFromReferenceNode(node: ProseMirrorNode | null | undefined): RenderedNoteTarget | null { const nodeType = node?.type?.name; if (nodeType !== 'footnoteReference' && nodeType !== 'endnoteReference') return null; const noteId = node?.attrs?.id; @@ -56,3 +63,28 @@ function noteTargetFromPmStartElement( noteId: String(noteId), }; } + +/** + * Resolves a REF/NOTEREF cross-reference to the note it points at. Word's + * cross-reference bookmark (`_RefXXXX`) wraps the ORIGINAL note reference in + * the body, so the note is found by locating the bookmarkStart with the + * field's target name and scanning its content for a note reference. Returns + * null for cross-references to anything other than a note (headings, tables), + * letting the double-click fall through to default text behavior. + */ +function noteTargetFromCrossReference(doc: ProseMirrorNode, bookmarkName: unknown): RenderedNoteTarget | null { + if (typeof bookmarkName !== 'string' || bookmarkName.length === 0) return null; + + let result: RenderedNoteTarget | null = null; + doc.descendants((node) => { + if (result) return false; + if (node.type?.name !== 'bookmarkStart' || node.attrs?.name !== bookmarkName) return true; + node.descendants((child) => { + if (result) return false; + result = noteTargetFromReferenceNode(child); + return !result; + }); + return false; + }); + return result; +} From 21ddc99fef9f1c3b68d52f545c9839155a719174 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 09:09:43 -0300 Subject: [PATCH 20/36] test(footnotes): pin backward drag selection symmetry in note sessions Browser verification on footnote-tests.docx shows right-to-left drags in note content produce the same range as left-to-right with anchor and head swapped, across single-line, multi-line, past-margin, and escape-above-note drags. Pin the direction-agnostic drag path: anchor stays at the mousedown hit while the head follows the pointer backward. --- .../EditorInputManager.footnoteClick.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts index f909f3230f..c4087f816e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -525,6 +525,65 @@ describe('EditorInputManager - Footnote click selection behavior', () => { }); }); + it('keeps backward (right-to-left) drag selection symmetric inside an active note (SD-3400)', () => { + // Pins the ticket's "selection consistent in both directions" requirement. + // Browser verification on footnote-tests.docx showed LTR and RTL drags + // produce the same range with anchor/head swapped; this test keeps the + // drag path direction-agnostic: anchor stays at the mousedown hit, head + // follows the pointer even when it moves backward. + const activeNoteEditor = createActiveSessionEditor(); + (mockDeps.getActiveStorySession as Mock).mockReturnValue({ + kind: 'note', + locator: { kind: 'story', storyType: 'footnote', noteId: '6' }, + editor: activeNoteEditor, + }); + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeNoteEditor); + // Story-surface hit test: right side of the note resolves to pos 40, + // left side to pos 10. + mockCallbacks.hitTest = vi.fn((clientX: number) => ({ + pos: clientX > 100 ? 40 : 10, + layoutEpoch: 7, + pageIndex: 0, + blockId: 'footnote-6-0', + column: 0, + lineIndex: -1, + })); + manager.setCallbacks(mockCallbacks); + + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-6-0'); + viewportHost.appendChild(fragmentEl); + + const PointerEventImpl = getPointerEventImpl(); + // Mouse down at the END of the text (pos 40)… + fragmentEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 120, + clientY: 16, + pointerId: 1, + } as PointerEventInit), + ); + // …then drag LEFT past the threshold to the start (pos 10). + viewportHost.dispatchEvent( + new PointerEventImpl('pointermove', { + bubbles: true, + cancelable: true, + buttons: 1, + clientX: 20, + clientY: 16, + pointerId: 1, + } as PointerEventInit), + ); + + // The selection extends backward: anchor stays at 40, head moves to 10. + expect(TextSelection.create as unknown as Mock).toHaveBeenCalledWith(activeNoteEditor.state.doc, 40, 10); + expect(activeNoteEditor.view.dispatch).toHaveBeenCalled(); + }); + it('does not activate a note session on semantic footnotes heading click', () => { (resolvePointerPositionHit as unknown as Mock).mockReturnValue(null); From c873494883c6207f9f6d7585e52164d7fa03c824 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 09:24:16 -0300 Subject: [PATCH 21/36] fix(footnotes): resolve cross-references across flat bookmark pairs Real imports emit cross-reference bookmarks as empty bookmarkStart and bookmarkEnd markers matched by id, with the note reference between them (verified on the NVCA fixture); the container shape from the schema is also supported. Scan the marker pair's document range for the note reference. Live-verified: double-clicking the REF field opens the referenced footnote session. --- .../pointer-events/note-reference-hit.test.ts | 61 ++++++++++++++++++- .../pointer-events/note-reference-hit.ts | 45 ++++++++++---- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts index 24c9ba7a37..5dd07d8443 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts @@ -3,8 +3,11 @@ import { Schema, type Node as ProseMirrorNode } from 'prosemirror-model'; import { resolveNoteReferenceAtPointer } from './note-reference-hit.js'; // Mirrors the real document shape: note references are inline atoms wrapped in -// runs; Word's cross-reference bookmark (`_RefXXXX`) WRAPS the original note -// reference, and bookmarkStart is a container node (content: 'inline*'). +// runs. Word's cross-reference bookmark (`_RefXXXX`) wraps the original note +// reference; the importer emits it as a FLAT bookmarkStart/bookmarkEnd marker +// pair (matched by id, both empty) with the reference between them — verified +// against the NVCA fixture. The schema also permits bookmarkStart to hold +// content, so the resolver supports both shapes. const schema = new Schema({ nodes: { doc: { content: 'block+' }, @@ -16,6 +19,12 @@ const schema = new Schema({ content: 'inline*', attrs: { name: { default: null }, id: { default: null } }, }, + bookmarkEnd: { + inline: true, + group: 'inline', + atom: true, + attrs: { id: { default: null } }, + }, footnoteReference: { inline: true, group: 'inline', @@ -92,7 +101,55 @@ function resolveAt(doc: ProseMirrorNode, pmStart: number) { }); } +/** + * The shape real imports produce (NVCA fixture): + * p1: "Dividends." + bookmarkStart(_Ref1, id=69) + run[] + bookmarkEnd(id=69) + * p2: "as noted in" + crossReference(target=_Ref1) + */ +function makeFlatMarkerDoc(noteRefType: 'footnoteReference' | 'endnoteReference' = 'footnoteReference') { + const noteRef = schema.nodes[noteRefType].create({ id: '8' }); + const p1 = schema.node('paragraph', null, [ + schema.nodes.run.create(null, schema.text('Dividends.')), + schema.nodes.bookmarkStart.create({ name: '_Ref1', id: '69' }), + schema.nodes.run.create(null, noteRef), + schema.nodes.bookmarkEnd.create({ id: '69' }), + ]); + const crossRef = schema.nodes.crossReference.create({ target: '_Ref1', resolvedText: '1' }); + const p2 = schema.node('paragraph', null, [schema.nodes.run.create(null, schema.text('as noted in')), crossRef]); + return schema.node('doc', null, [p1, p2]); +} + describe('resolveNoteReferenceAtPointer — cross-reference navigation (SD-3400)', () => { + it.each([ + ['footnoteReference', 'footnote'], + ['endnoteReference', 'endnote'], + ])('resolves a crossReference across a flat %s bookmark marker pair (real import shape)', (refType, storyType) => { + const doc = makeFlatMarkerDoc(refType as 'footnoteReference' | 'endnoteReference'); + + const target = resolveAt(doc, findPos(doc, 'crossReference')); + + expect(target).toEqual({ storyType, noteId: '8' }); + }); + + it('does not resolve a note that sits OUTSIDE the flat marker pair', () => { + // The footnote ref here precedes the bookmarkStart — the bookmark range + // holds plain text only, so the cross-ref is not a note reference. + const noteRef = schema.nodes.footnoteReference.create({ id: '8' }); + const p1 = schema.node('paragraph', null, [ + schema.nodes.run.create(null, noteRef), + schema.nodes.bookmarkStart.create({ name: '_Ref1', id: '69' }), + schema.nodes.run.create(null, schema.text('Section 2')), + schema.nodes.bookmarkEnd.create({ id: '69' }), + ]); + const crossRef = schema.nodes.crossReference.create({ target: '_Ref1', resolvedText: 'Section 2' }); + const p2 = schema.node('paragraph', null, [crossRef]); + const doc = schema.node('doc', null, [p1, p2]); + + const target = resolveAt(doc, findPos(doc, 'crossReference')); + + expect(target).toBeNull(); + }); + it('resolves a crossReference to the footnote wrapped by its target bookmark', () => { const doc = makeDoc('footnoteReference'); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts index f718bd74b8..10afd95d1d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts @@ -67,24 +67,45 @@ function noteTargetFromReferenceNode(node: ProseMirrorNode | null | undefined): /** * Resolves a REF/NOTEREF cross-reference to the note it points at. Word's * cross-reference bookmark (`_RefXXXX`) wraps the ORIGINAL note reference in - * the body, so the note is found by locating the bookmarkStart with the - * field's target name and scanning its content for a note reference. Returns - * null for cross-references to anything other than a note (headings, tables), - * letting the double-click fall through to default text behavior. + * the body. The importer emits the bookmark as a flat bookmarkStart/bookmarkEnd + * marker pair matched by id, so the note is found by scanning the document + * range from the named bookmarkStart to its matching bookmarkEnd. (The schema + * also permits bookmarkStart to hold content; scanning to at least the end of + * the start node covers that shape too.) Returns null for cross-references to + * anything other than a note (headings, tables), letting the double-click fall + * through to default text behavior. */ function noteTargetFromCrossReference(doc: ProseMirrorNode, bookmarkName: unknown): RenderedNoteTarget | null { if (typeof bookmarkName !== 'string' || bookmarkName.length === 0) return null; + let startPos = -1; + let rangeEnd = -1; + let bookmarkId: unknown = null; + let foundEnd = false; + doc.descendants((node, pos) => { + if (foundEnd) return false; + if (startPos < 0) { + if (node.type?.name === 'bookmarkStart' && node.attrs?.name === bookmarkName) { + startPos = pos; + rangeEnd = pos + node.nodeSize; + bookmarkId = node.attrs?.id; + } + return true; + } + if (node.type?.name === 'bookmarkEnd' && bookmarkId != null && node.attrs?.id === bookmarkId) { + rangeEnd = pos; + foundEnd = true; + return false; + } + return true; + }); + if (startPos < 0) return null; + let result: RenderedNoteTarget | null = null; - doc.descendants((node) => { + doc.nodesBetween(startPos, rangeEnd, (node) => { if (result) return false; - if (node.type?.name !== 'bookmarkStart' || node.attrs?.name !== bookmarkName) return true; - node.descendants((child) => { - if (result) return false; - result = noteTargetFromReferenceNode(child); - return !result; - }); - return false; + result = noteTargetFromReferenceNode(node); + return !result; }); return result; } From 04ee1dbe65d4415c68a72a67dd9b1fa9eaf7f239 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 09:24:16 -0300 Subject: [PATCH 22/36] feat(footnotes): document-default fallback for the note marker font Marker font chain is now: explicit first-run value, then the document default run properties (w:docDefaults, half-points converted to px), then the constant. Notes whose first paragraph has no sized run (just emptied or inserted) keep markers sized with the document instead of snapping to the constant, so font changes resize the marker predictably. Keystroke perf with 94 footnotes (NVCA): buildFootnotesInput costs 7.3ms median / 9.2ms max per keystroke, under the 16ms frame budget, so no footnote measure cache is required. --- .../layout/FootnotesBuilder.ts | 48 +++++++++++++++---- .../tests/FootnotesBuilder.test.ts | 42 ++++++++++++++++ 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts index e70baf8602..586031139e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts @@ -135,6 +135,7 @@ export function buildFootnotesInput( // Build blocks for each footnote const blocksById = new Map(); + const docDefaultRunProps = resolveDocDefaultRunProps(converterContext); idsInUse.forEach((id) => { try { @@ -154,7 +155,7 @@ export function buildFootnotesInput( if (!customMarkIds.has(id)) { // §17.11.11 — per-id format from section override wins over document default. const numFmtForId = footnoteFormatById?.[id] ?? footnoteNumberFormat; - ensureFootnoteMarker(result.blocks, id, footnoteNumberById, numFmtForId); + ensureFootnoteMarker(result.blocks, id, footnoteNumberById, numFmtForId, docDefaultRunProps); } blocksById.set(id, result.blocks); } @@ -203,11 +204,31 @@ function resolveDisplayNumber(id: string, footnoteNumberById: Record 0) return ascii; + return DEFAULT_MARKER_FONT_FAMILY; +} + +function resolveMarkerBaseFontSize(firstTextRun: Run | undefined, docDefaults: DocDefaultRunProps | undefined): number { if ( typeof firstTextRun?.fontSize === 'number' && Number.isFinite(firstTextRun.fontSize) && @@ -216,10 +237,20 @@ function resolveMarkerBaseFontSize(firstTextRun: Run | undefined): number { return firstTextRun.fontSize; } + // docDefaults fontSize is in half-points; runs use px (1pt = 1/0.75 px). + const halfPoints = docDefaults?.fontSize; + if (typeof halfPoints === 'number' && Number.isFinite(halfPoints) && halfPoints > 0) { + return halfPoints / 2 / 0.75; + } + return DEFAULT_MARKER_FONT_SIZE; } -function buildMarkerRun(markerText: string, firstTextRun: Run | undefined): Run { +function buildMarkerRun( + markerText: string, + firstTextRun: Run | undefined, + docDefaults: DocDefaultRunProps | undefined, +): Run { // Word renders the FootnoteReference rStyle as a plain superscript, independent // of the following run's formatting. Inheriting bold/italic/letterSpacing from // the first body text run would render "³**NTD**" with a bold marker — visibly @@ -229,8 +260,8 @@ function buildMarkerRun(markerText: string, firstTextRun: Run | undefined): Run kind: 'text', text: `${markerText}\u00A0`, dataAttrs: { [FOOTNOTE_MARKER_DATA_ATTR]: 'true' }, - fontFamily: resolveMarkerFontFamily(firstTextRun), - fontSize: resolveMarkerBaseFontSize(firstTextRun) * SUBSCRIPT_SUPERSCRIPT_SCALE, + fontFamily: resolveMarkerFontFamily(firstTextRun, docDefaults), + fontSize: resolveMarkerBaseFontSize(firstTextRun, docDefaults) * SUBSCRIPT_SUPERSCRIPT_SCALE, vertAlign: 'superscript', }; @@ -304,6 +335,7 @@ function ensureFootnoteMarker( id: string, footnoteNumberById: Record | undefined, footnoteNumberFormat: string | undefined, + docDefaults: DocDefaultRunProps | undefined, ): void { const firstParagraph = blocks.find((b) => b?.kind === 'paragraph') as ParagraphBlock | undefined; if (!firstParagraph) return; @@ -314,7 +346,7 @@ function ensureFootnoteMarker( // leading marker matches the inline reference (single source of truth). const markerText = formatFootnoteCardinal(displayNumber, footnoteNumberFormat); const firstTextRun = runs.find((run) => typeof run.text === 'string' && !isFootnoteMarker(run)); - const normalizedMarkerRun = buildMarkerRun(markerText, firstTextRun); + const normalizedMarkerRun = buildMarkerRun(markerText, firstTextRun, docDefaults); // Check if marker already exists const existingMarker = runs.find(isFootnoteMarker); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts index 2a7c6648bc..2715391c87 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts @@ -352,6 +352,48 @@ describe('buildFootnotesInput', () => { expect(firstRun?.vertAlign).toBe('superscript'); }); + it('falls back to the document default font when the first run carries none (SD-3400)', () => { + // Fallback chain: explicit first-run size -> document default -> constant. + // The mock toFlowBlocks produces runs without fontSize/fontFamily, so the + // marker must pick up the docDefaults run properties (half-points -> px). + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + const context = { + footnoteNumberById: { '1': 1 }, + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: { fontSize: 22, fontFamily: { ascii: 'Times New Roman' } } }, + latentStyles: {}, + styles: {}, + }, + } as unknown as ConverterContext; + + const result = buildFootnotesInput(editorState, converter, context, undefined); + + const marker = (blocksFromResult(result)?.[0] as { runs?: Array<{ fontSize?: number; fontFamily?: string }> }) + ?.runs?.[0]; + // 22 half-points = 11pt = 11 / 0.75 px, then superscript-scaled. + expect(marker?.fontSize).toBeCloseTo((11 / 0.75) * SUBSCRIPT_SUPERSCRIPT_SCALE, 5); + expect(marker?.fontFamily).toBe('Times New Roman'); + }); + + it('keeps the constant marker fallback when neither run nor doc defaults carry a size', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + const context = createMockConverterContext({ '1': 1 }); + + const result = buildFootnotesInput(editorState, converter, context, undefined); + + const marker = (blocksFromResult(result)?.[0] as { runs?: Array<{ fontSize?: number; fontFamily?: string }> }) + ?.runs?.[0]; + expect(marker?.fontSize).toBe(12 * SUBSCRIPT_SUPERSCRIPT_SCALE); + expect(marker?.fontFamily).toBe('Arial'); + }); + it('uses correct display number from context', () => { const editorState = createMockEditorState([{ id: '5', pos: 10 }]); const converter = createMockConverter([ From 4e687c8b2991e7252dd53dac8793e4564fa54c60 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 09:36:04 -0300 Subject: [PATCH 23/36] test(footnotes): behavior coverage for SD-3400 interactions End-to-end Playwright specs through the presentation surface: double click on a body reference marker opens the note session; staged Backspace selects then removes the marker, prunes the w:footnote element, and renumbers; clearing all note content removes the footnote on both sides and exits the session; insertFootnote places a marker at the cursor on a document without footnotes and focuses the new note. Also adds an unmocked area-delete integration test exercising the real removal pipeline (removeNoteEverywhere -> removeNoteElement) against a real footnotes part. --- .../note-story-runtime.integration.test.ts | 159 ++++++++++++++++ .../footnotes/footnote-interactions.spec.ts | 178 ++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.integration.test.ts create mode 100644 tests/behavior/tests/footnotes/footnote-interactions.spec.ts diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.integration.test.ts new file mode 100644 index 0000000000..b101b15185 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.integration.test.ts @@ -0,0 +1,159 @@ +/** + * SD-3400 area-delete integration: commit of an EMPTIED note runs the REAL + * removal pipeline (removeNoteEverywhere -> removeNoteElement) against a real + * convertedXml part. Unlike note-story-runtime.test.ts, the footnote-wrappers + * module is NOT mocked here — only the story-editor factory (DOM-bound) and + * the part-mutation transaction plumbing are shimmed, with mutatePart applying + * the mutation directly to the part. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; + +const mockCreateStoryEditor = vi.fn(); + +vi.mock('../../core/story-editor-factory.js', () => ({ + createStoryEditor: (...args: unknown[]) => mockCreateStoryEditor(...args), +})); + +vi.mock('../../core/parts/mutation/mutate-part.js', () => ({ + mutatePart: vi.fn( + (request: { mutate?: (ctx: { part: unknown; dryRun: boolean }) => unknown; editor: Editor; partId: string }) => { + const converter = (request.editor as unknown as { converter?: { convertedXml?: Record } }) + .converter; + const part = converter?.convertedXml?.[request.partId] ?? {}; + request.mutate?.({ part, dryRun: false }); + if (converter?.convertedXml) converter.convertedXml[request.partId] = part; + return { changed: true, changedPaths: [], degraded: false, result: undefined }; + }, + ), +})); + +vi.mock('../../core/parts/mutation/compound-mutation.js', () => ({ + compoundMutation: vi.fn((request: { execute: () => boolean }) => ({ success: request.execute() })), +})); + +vi.mock('../helpers/index-cache.js', () => ({ + clearIndexCache: vi.fn(), +})); + +import { resolveNoteRuntime } from './note-story-runtime.js'; + +function makeFootnotesXml(entries: Array<{ id: string }>) { + return { + declaration: { attributes: { version: '1.0', encoding: 'UTF-8', standalone: 'yes' } }, + elements: [ + { + type: 'element', + name: 'w:footnotes', + attributes: { 'xmlns:w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' }, + elements: entries.map((e) => ({ + type: 'element', + name: 'w:footnote', + attributes: { 'w:id': e.id }, + elements: [], + })), + }, + ], + }; +} + +function footnoteElementIds(host: { converter: { convertedXml: Record } }): string[] { + const xml = host.converter.convertedXml['word/footnotes.xml'] as { + elements: Array<{ elements: Array<{ name: string; attributes: Record }> }>; + }; + return xml.elements[0].elements.filter((el) => el.name === 'w:footnote').map((el) => el.attributes['w:id']); +} + +/** Host whose body has footnoteReference markers for the given ids. */ +function makeHost(refIds: string[]) { + const doc = { + descendants: (cb: (node: unknown, pos: number) => boolean | void) => { + refIds.forEach((id, index) => { + cb({ type: { name: 'footnoteReference' }, attrs: { id } }, index + 1); + }); + return true; + }, + nodeAt: vi.fn(() => ({ nodeSize: 1 })), + }; + const tr = { delete: vi.fn(), doc }; + return { + converter: { + footnotes: [{ id: '1', content: [{ type: 'paragraph' }] }], + endnotes: [], + convertedXml: { 'word/footnotes.xml': makeFootnotesXml([{ id: '1' }, { id: '2' }]) }, + }, + state: { doc, tr }, + dispatch: vi.fn(), + on: vi.fn(), + emit: vi.fn(), + safeEmit: vi.fn(() => []), + options: {}, + } as unknown as Editor & { converter: { convertedXml: Record } }; +} + +const footnoteLocator = { kind: 'story' as const, storyType: 'footnote' as const, noteId: '1' }; + +/** Story editor whose doc is empty (only an empty paragraph). */ +const emptiedStoryEditor = () => ({ + state: { + doc: { + content: { size: 4 }, + textBetween: () => '', + descendants: (cb: (n: unknown, p: number) => boolean | void) => { + cb({ isText: false, isAtom: false, type: { name: 'paragraph' } }, 0); + }, + }, + }, + schema: {}, + getJSON: () => ({ type: 'doc', content: [{ type: 'paragraph' }] }), + getUpdatedJson: () => ({ type: 'doc', content: [{ type: 'paragraph' }] }), + destroy: vi.fn(), + on: vi.fn(), +}); + +describe('SD-3400 area-delete integration (real removal pipeline)', () => { + it('committing an emptied note deletes every body marker and the w:footnote element', () => { + mockCreateStoryEditor.mockReturnValueOnce(emptiedStoryEditor() as never); + // Two references to note 1 (multi-ref) plus an unrelated note 2 marker. + const host = makeHost(['1', '2', '1']); + + const runtime = resolveNoteRuntime(host, footnoteLocator); + runtime.commit?.(host); + + // Both markers for note 1 deleted, descending positions (3 then 1). + expect(host.state.tr.delete).toHaveBeenCalledTimes(2); + expect((host.state.tr.delete as ReturnType).mock.calls).toEqual([ + [3, 4], + [1, 2], + ]); + // The w:footnote element is physically gone from the part; note 2 survives. + expect(footnoteElementIds(host)).toEqual(['2']); + }); + + it('committing a note that still has content leaves markers and the element alone', () => { + mockCreateStoryEditor.mockReturnValueOnce({ + ...emptiedStoryEditor(), + state: { + doc: { + content: { size: 10 }, + textBetween: () => 'still here', + descendants: (cb: (n: unknown, p: number) => boolean | void) => { + cb({ isText: true, isAtom: true, text: 'still here', type: { name: 'text' } }, 1); + }, + }, + }, + getUpdatedJson: () => ({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'still here' }] }], + }), + } as never); + const host = makeHost(['1', '2']); + + const runtime = resolveNoteRuntime(host, footnoteLocator); + runtime.commit?.(host); + + expect(host.state.tr.delete).not.toHaveBeenCalled(); + expect(footnoteElementIds(host)).toEqual(['1', '2']); + }); +}); diff --git a/tests/behavior/tests/footnotes/footnote-interactions.spec.ts b/tests/behavior/tests/footnotes/footnote-interactions.spec.ts new file mode 100644 index 0000000000..4c25ea4632 --- /dev/null +++ b/tests/behavior/tests/footnotes/footnote-interactions.spec.ts @@ -0,0 +1,178 @@ +/** + * SD-3400 footnote interactions, end to end through the presentation surface: + * + * 1. Double-click a BODY reference marker navigates to (and focuses) the note. + * 2. Staged Backspace: first press selects the marker, second press removes it + * AND prunes the w:footnote element ("remove on both sides"), renumbering + * the remaining notes. + * 3. Area-delete: clearing all note content removes the footnote everywhere + * (body marker + OOXML element) and exits the session. + * 4. insertFootnote command: inserts the marker at the cursor, creates the + * note, and focuses the new note session, on a document without footnotes. + */ + +import type { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { BASIC_FOOTNOTES_DOC_PATH, H_F_NORMAL_DOC_PATH } from '../../helpers/story-fixtures.js'; + +test.use({ config: { showCaret: true, showSelection: true } }); + +async function getActiveStorySession(page: Page) { + return page.evaluate(() => { + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator ?? null; + }); +} + +/** Body footnoteReference positions and ids, in document order. */ +async function getBodyNoteRefs(page: Page): Promise> { + return page.evaluate(() => { + const refs: Array<{ pos: number; id: string }> = []; + (window as any).editor.state.doc.descendants((node: any, pos: number) => { + if (node.type?.name === 'footnoteReference') refs.push({ pos, id: String(node.attrs?.id ?? '') }); + return true; + }); + return refs; + }); +} + +/** w:footnote ids present in the canonical footnotes part (separators excluded). */ +async function getFootnoteElementIds(page: Page): Promise { + return page.evaluate(() => { + const xml = (window as any).editor?.converter?.convertedXml?.['word/footnotes.xml']; + const root = xml?.elements?.[0]; + if (!root?.elements) return []; + return root.elements + .filter( + (el: any) => + el.name === 'w:footnote' && + el.attributes?.['w:type'] !== 'separator' && + el.attributes?.['w:type'] !== 'continuationSeparator', + ) + .map((el: any) => String(el.attributes?.['w:id'] ?? '')); + }); +} + +async function focusBodyAt(page: Page, pos: number) { + await page.evaluate((position) => { + const editor = (window as any).editor; + const TextSelection = editor.state.selection.constructor; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, position))); + editor.view.focus(); + }, pos); +} + +test('double-click a body reference marker opens the referenced note session', async ({ superdoc }) => { + await superdoc.loadDocument(BASIC_FOOTNOTES_DOC_PATH); + await superdoc.waitForStable(); + + const marker = superdoc.page.locator('[data-note-reference][data-note-id="1"]').first(); + await marker.scrollIntoViewIfNeeded(); + await marker.waitFor({ state: 'visible', timeout: 15_000 }); + + const box = await marker.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ kind: 'story', storyType: 'footnote', noteId: '1' }); +}); + +test('staged Backspace removes the marker and prunes the note on both sides', async ({ superdoc }) => { + await superdoc.loadDocument(BASIC_FOOTNOTES_DOC_PATH); + await superdoc.waitForStable(); + + const refsBefore = await getBodyNoteRefs(superdoc.page); + expect(refsBefore.map((r) => r.id)).toEqual(['1', '2']); + expect(await getFootnoteElementIds(superdoc.page)).toEqual(['1', '2']); + + // Caret immediately after footnote 1's marker (inside the body editor). + const firstRef = refsBefore[0]; + await superdoc.page + .locator('[data-block-id]:not([data-block-id^="footnote-"])') + .filter({ hasText: 'Simple text' }) + .first() + .click(); + await superdoc.waitForStable(); + await focusBodyAt(superdoc.page, firstRef.pos + 1); + + // First Backspace: the marker is SELECTED, not deleted (Word-like staging). + await superdoc.page.keyboard.press('Backspace'); + await expect.poll(() => getBodyNoteRefs(superdoc.page).then((r) => r.length)).toBe(2); + const selection = await superdoc.page.evaluate(() => { + const sel = (window as any).editor.state.selection; + return { from: sel.from, to: sel.to }; + }); + expect(selection).toEqual({ from: firstRef.pos, to: firstRef.pos + 1 }); + + // Second Backspace: marker gone, w:footnote element pruned, note renumbered. + await superdoc.page.keyboard.press('Backspace'); + await superdoc.waitForStable(); + + await expect.poll(() => getBodyNoteRefs(superdoc.page).then((r) => r.map((x) => x.id))).toEqual(['2']); + await expect.poll(() => getFootnoteElementIds(superdoc.page)).toEqual(['2']); + // Former footnote 2 renumbers to display "1". + const note2 = superdoc.page.locator('[data-block-id^="footnote-2-"]').first(); + await note2.scrollIntoViewIfNeeded(); + await expect(note2).toContainText(/^\s*1/); +}); + +test('clearing all note content removes the footnote on both sides', async ({ superdoc }) => { + await superdoc.loadDocument(BASIC_FOOTNOTES_DOC_PATH); + await superdoc.waitForStable(); + + const note = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); + await note.scrollIntoViewIfNeeded(); + await note.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await note.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ kind: 'story', storyType: 'footnote', noteId: '1' }); + + // Clear all content in the note area. + await superdoc.page.keyboard.press('ControlOrMeta+a'); + await superdoc.page.keyboard.press('Backspace'); + await superdoc.waitForStable(); + + // Emptied-note commit: session exits, marker AND element are removed. + await expect.poll(() => getActiveStorySession(superdoc.page)).toBeNull(); + await expect.poll(() => getBodyNoteRefs(superdoc.page).then((r) => r.map((x) => x.id))).toEqual(['2']); + await expect.poll(() => getFootnoteElementIds(superdoc.page)).toEqual(['2']); + await expect(superdoc.page.locator('[data-block-id^="footnote-1-"]')).toHaveCount(0); +}); + +test('insertFootnote places a marker at the cursor and focuses the new note', async ({ superdoc }) => { + // h_f-normal has no body footnote references. + await superdoc.loadDocument(H_F_NORMAL_DOC_PATH); + await superdoc.waitForStable(); + + expect(await getBodyNoteRefs(superdoc.page)).toEqual([]); + + await superdoc.page + .locator('[data-block-id]:not([data-block-id^="footnote-"])') + .filter({ hasText: 'This is a document' }) + .first() + .click(); + await superdoc.waitForStable(); + + const inserted = await superdoc.page.evaluate(() => (window as any).editor.commands.insertFootnote?.() ?? false); + expect(inserted).toBe(true); + await superdoc.waitForStable(); + + const refs = await getBodyNoteRefs(superdoc.page); + expect(refs).toHaveLength(1); + expect(await getFootnoteElementIds(superdoc.page)).toEqual([refs[0].id]); + + // The new note paints with its marker and the session is focused on it. + const noteFragment = superdoc.page.locator(`[data-block-id^="footnote-${refs[0].id}-"]`).first(); + await noteFragment.scrollIntoViewIfNeeded(); + await expect(noteFragment).toBeVisible(); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ kind: 'story', storyType: 'footnote', noteId: refs[0].id }); +}); From 29157a6d6ce5ec7107b21b65a8fc32ff20a2a6ca Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 09:45:03 -0300 Subject: [PATCH 24/36] test(footnotes): pin separator-variant roundtrip fidelity Add the footnote-tests-B fixture (explicit separator and continuation separator notes plus the settings.xml special-footnote list) and a roundtrip test asserting regular ids, separator types with reserved ids, the 17.11.9 special list, and body reference order all survive import and export. --- .../v1/tests/data/footnote-tests-B.docx | Bin 0 -> 20291 bytes .../import-export/footnotes-roundtrip.test.js | 58 ++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/tests/data/footnote-tests-B.docx diff --git a/packages/super-editor/src/editors/v1/tests/data/footnote-tests-B.docx b/packages/super-editor/src/editors/v1/tests/data/footnote-tests-B.docx new file mode 100644 index 0000000000000000000000000000000000000000..2c8850d7574c35422729c3744ecaf8cb99e76f01 GIT binary patch literal 20291 zcmeIab981)(>EO3wr$(?#I}=(jfrh16Wh+jwrzWoiEX_(=hnIBIp6oK@9+27Yjv&b z+N*!ndv{m&uBxtTd1+t}6aX*)2mk;8LI5RiqMJZK0DuZG003kF2p}zCTN@`68z((w zcRLeD9XdB_D}p=_Ac`CSpwIgMd;MSB1NBLgU;7vkf-ZvI!Sla{CEdzPg(V257-5@3 zbx_T6AT9*6th{>-X~mA7iqcr9LYHeZlB* zI@d^+=OB%Am9JqXVZ+hZyBj(Ipv<>OfnpPefzKf41g2Iz2j(bCzVnCCHfi7? zSmeM0b35b*k;-gpIbH#8nit&SWe%}zibdJ50LnG&8+_p-23JHu2DaX>5oS*lSPib} z(0w2>v#vpNnn4`|)_v;@=nIYmD{fC1$H zYx3g7VKrTRN~GN9+=Tv|ym}5MR*v*^f6)J(wEq{+)W5uXMSPEC9|MfQMZjCYOozg1 z4@SNmz0vd<#tJxumZS{w+Op-+`y0>lGLX)Zf%w?$T*9=6V}_XXcB0NDRCDQc!y#quZk0E=>Btz3JO1u_WbaK!gTr_!Jgc++*nXiEiYdR3i63i=i!u zD5vI)NPgsJ*{du)eD`L~No8DEw3N{D1TW(i?F`0koy7EpGUcyLVWy3Z>D{c;=~q5Z zXl9DCq`afSvSOeo&4@FsfWo?LpE>r(?w$820X6*GdBsf3Nw`L8i4l z;b%n)4$KsxnleEO8$~x5nc~7X-!5~Z*pz@NgTAehMG#^dFa*due5<~9%a47f- z4a@l609SVJnZ;9Un{uMZL?C}+zz%t38MMO49x*ZEby4<_IMlwfL?!j0{o#$lMk>SY zTqaMTyjKk(f0t5lujyM}4K@dbS04MqC@hu~Zal{}*fgQtb|QpCByLY6XmDw#<^kq- z%B&`fjmmE4R=Gl+M9JL ztRHFo#31y7A&fVHZqcJ<@ZaUvU+k~5Cwh2t11QWu!WyJwyHleVZ&Pc3&70P+B8LFQ=mj|P)8QLTfACs940fDNi{A`ytB<>9HSaOwKLod< z;lp~pqVB%N_lXMwbTKr5MWyW)q+0%@q1$FlWVlR<<016BN~(L=64vs*nll^NiW@L9 zBCf3MD0FuM)H`Y{hj!&$SfxAzaqQ)iF4nde%w4FJ)$FkMpy@91aEPop9bX`AIu6Y_1#q$tPdf94qCK|<{;yn`^ z4-&MeVB5J3l1g5+9@teJ?W(-4TUw#=UxH4*LNNc1X%M6lzDk-v6(`kR9tFMGKR7Ub zq+_k+*<>95Ho_ zIuspeqQEwPgSgY0LGc21qaLtdfnf$^t)V!?yNCb_w_1Yag(snyVEG1Qnvvo+TTVf{ z&D7|&`eEX99_`7V?c$b)ES0XV*d?j<>Y9sAhvnkuBkhkeMm?WdxopRq>gu%yU?Neb zeS%#8vNlx%tNM7thyv$TdD{dAXn@gX#C4|m`*7~W(~xo@#_B_4;RweQ7}wfViFmD{ zs5OqoCiVM5W<_$Qih@%S(JlD}a;Cv>`k=}mTLyjzfUE&o?jFaqyIt5>HS%-$zJ>_E z49ka$Z45kb2aUCI`lJQUsLopj&VdfI5XMeF=37JoX42Fn1yUh%)k*3^H~^conBzjH zF=fvfLA3o#dubM(T?!YQZ7BT`!`hLT;m(((4ki0*;1!~;)aeY9LklHnB%cx8t8v`t zz4sy6_y-ZjR6xcQfEwpVniCSE563-#rL|)%aehLw)66|mf80-n9-ppGsgc9oyYv?1 z_#!4a7CCA|W+R&Jq0sDy)BeKfmT5jMAs#(YBvHtG3mbko??INh63iZ0wFu&krK1x; z^bHZ)#lzvSIe{Oac5DB-aOMi0Ig@kmUiHKR@9B2H>bATWVFAy*Q9ik!!(h`8oUqtD z_cU+FH^idn6Xa=dHOgp5of!0BB?%PaxbL$)pTXru)=;rwOv%X%sGFx2#7?FS=?cU# zK7oUDa=J)3j0o@eN>~IqoJd6?Bq#&)MD-JzR0pqT4N=yS_&p(j8wl4nh}f2WaVvJC zpDWYCUHMvdsJj~6&4_`#b~_}=RHfh3+U+ZwXR$A90$_`;6yC2$--Sd|3GukY^KRDV zbaCluTSS_;q81`G0z(Mm>iU*GnX}kww3f(UbxhEcEiw+oa?r|BfendkD9~IqWD!|4 zQB1xgNeng4G5|yU-*f>=^C;J}EkS;&J#JD*0AE>9qSjA3k}?7NBR!lk43{V$`9>Wy+63d zt|O`Ri9>~b6E`Nbo>aYdzhsXxL*bAt**z}P31i=)M-^lPq(fC zx=}}p=F;3m21i&MKuQQzl<#k}gF2an1}Rr%60t@zzc?qNSDz&e3w>A*jnL;y*?|js zvZR6Z9k#t=NA9UsJ8qsuRpZeW;YNxHRw zt^6$Yi2=?~SvL!7jE{Aogf-_v?Y&h6WTpQ$6JF84N8wR!H+$g5fDywDPTL|^&S}}$ zcB}0pzPEX>mWRTM-&9mma!5-y^UG=WoaH4bI%2KM-UZYY8wB?wA{}FB12DRy<;Q^S zdYk_w!zLviBSXxZSEj|GQ|n7s=+owky04xu_lhU}gAPu5m7}qPrA- zQ1=WO(2=E&U_qZA%v<}Jzb?!>g${(*8m~{I!g&NgjraWG@*6sM$_MD*8O6PtLL9`O z=D-jL000gE1mK@a|356_zpDQKvXTKmU0a{E|L?vk5+~$7T|0pn679nK-1dq&1j@ze zf%DWTVdW=Q-Xf<_r?jz6LsU{}LMHZ?_Cwd>#WSnrcPZ!b@HJJU#Ohh5G=TXvRH?)UHfT`u3@f>ay7vvKHKZ1VD(Ov_xi7rL8{W% z`IYYU3vCsC^mSGvXX^o|uY4Yv`gu!pn;2bIVVlb=k zqvgAv>ca?RW2kH)o$M%+87;aK!7Ox#K7lrSbfB~J!JnY@{=mn;jj`+pMr3gW@9Q^| ztxw7LpPm>(S_^~fb1B}yhziLcx9*~-;T#vd7lgF+ec%afdB@3X}txp^9s z?wwAS5C!dOTCXx9>;*}+Xz_t142L{r4NL169~>1ZGyeZIatq0e?rkbsy7B7*`1 zQ%lgnTGy&wJnC;$K}Y~8w-ExSObm5mM+l?M3kB=ngv4Cs9df?br4kiL1PbCi(5k3F zqI0tdL6d3+HUQ?R3BZt+)rG@2Umy^EnZ(NmzOw=F$Gu;7EO}p$&$6!I0>)Yg0a48) zgtZlJt>L_2E7;_m{l!-B&N=(YR`A14Y7$txAh6Gv#MqzpH4&S;=UP131%P89t%@?= z1O9uoT95vy&$>RM!~%pQe>;|eF*y-Q1->f9?&c^-cH(uW=H_=k%Kfn~w1Vo~4LRqN zWb|p2IwPZO>!`bqh{ljRTxJu=fq*go<==~(oh58U14f#GRyswy*$ft1b&eZl8kVHi z+U@!PYoeYCFOXE#-`Vm`>Mx7@N=R3~+g1FiBk_aaqv#Av18cDv+f@R*odjt2t>>Yk zinI?i01r83o%XoBf~~Jy~0N|@R^SK%FGOR4QC@vb2;-ZPwtZ53_A4b9B2zAd zU>V7JJgg>x`t)C3P@&-k>V~uhpu%L?TsEBypd&r~F8QIyqW<5=BZ5ZbxOe}_&yS0KwezxXOzMz|Q#)V3_rN%1OXlq-;7JY>q6-#eV zEkOa_o>WE>-A`7BH&#`RW#YnTQqT}UEHNj$4_J}Vduc= z+$TNH=^9bH_Z?|HXYxwNXJ?#GHJNV+gt6jQc0H+$n-A^vskaOJ?n|BXuc+e9w1drz9&;}Bc~8&8s2+9(bb3I-1AYq$B%JTS*r1GZiwNsv zy~yJT`H%}bx0yK@wvd_$vV#g#E+Q_<$wgK%K;yqO4~D$ZX@g)O!YK@L%Sa%^MJ$>p z6i^cov||{IB+MLW0r+O24Dj**vLdWP9?BxV#TBHIT_eZmz+|7}&_xnyN(c+sqpnJ1 z@L13|9%_$`0~2fYa(R%imS|jFqLEfNvlL*^wMxx(*VOefodw1NSy)!HNcx6Tt`}}o z0vQuHR{NV(K8XWz2q?fpw-v8BqAJ0z-@{XLaTJfBO^xyV1b7jubi!0W)u&6si)qP#KxplugRNGgIdN+lDi}UWB z)4V+7(ygs+w^=%RL=#CZ*XBu+=NQ#{yG++(YTi|;@)2sscJZ-|E3w6p2!|*iGn^wH zn8G%ExIIJ=*DUv@^ zRw;~3m^#|8-IW{!^^!VgJWR1Ie#}I9Ddh*V5V5@wk4N;sTj0CBD}Ho+y48>HgJmr(s+03dUpxnH9t2VT7H2JVeP-r3A%zCG zaIK$VG&$vi|AOQAVMo;B+%IGrd2aPESxYN}7{TnZywatYjfTAz4>gN%5V@P_H8|_N z?DOWJ7yNxNk&=csDu;=Nb9JRukq1N|4+WXxLLwL>fkweK6tO#=L>MDcroY=!yZG3? z2O(K;>H*>iQL3#ccaB4oM!z%@mH~hwNN7iC%8pz(Ss$8BIF=o_K73y)DkI3Kpt2I!RqYLz$S`lJfHS2OfzP2iw4?eGkE}gNokzQ!DI15kW2)4Y>!1!!xbt%sU+j)Cqyw;Lgxq}JuUt5Jt&avjhM>9YHE1V>83U}_Y`Z9_~;?a+vDQsNW<}NH6Yd( zxu2s*VT@A89@jUIF>`y^meVyPK&TH;yW1QHGD?0-gWz0y?d>+9+)f;XLPTIx{2O>< zhCrxWvHHHz3hWaqR=z~EEG2Yqtn2|gIZT#R2O*_Ll@@g{f;e2LKE=XjmfRJFU#{ZO zX;tDw*eg0@71v-OF6F{-?DXgtFzsNZF-d2vaA|M43*BGkuhlntn{=w@Xt3R2 zrSQILJxX=yJUoNx1_1fzudubM+Ok+m-(oQp8c#vlE?U}jGzAuy7?QOjJxS~jXhOtL zVoUhC(4lYu86xIW;z(;gZ2-%+p6z*a0z@N55ewR?JX^JAzhlh@th{t>+<=1o9-|6YIJ?5FO>il>$64{t~ca9YKhRG?xmxx1)Hgt3gbBXR~s&gLk5+ExpJ^ zMy7lKBVkJl#V~D$I|K?=9b_QU*yls>i`t*@kAe+wB;-9K783OF=V%&PH-!#3m!-H# zUN{3cMog0+yBrT*4@oqQ}8-`hq7G z+%~nf=4ygf2Of%*%Dk2=FuxuH%@j0T)IK3ZJkW^QC?;-pj-A^PgWy+e=a10*k_!99 zsPBOC2-|O*%5&H>9eX-bWg!|Nr6Hcmh9-Jo9b$!ee-4KaJR++9hpz&JM%IBAGqdqC zF&TS4t?r1MX@TPSd{IL}^MtUGSBoY*uz6sTrK4v%-S?d?_7x+=gGXHBX|1v0{`bK~ zOd&&=Sg>&f!x~%ZCdGa`AF8~@1hjOmpG5~nvK#X26dpo2nM#0f>`5`AyM~r{+?QTu zneBmf8hiUYY}Dy=CdjW>=xn?kF>rA_(lpa!tp{~gPg_f@?k`VuX)P6|bmf?B!{8;R zvp`~cz?F2K^~nqm2WH`roJi3A_bqf@&Fe1-ey8)WwDq0l;lNML8I#>lFvn)yC671} z+Z4XF8ASzVmrsffQ-U%rG}%r~Dot1fH;Cguylcum5s zvF-*#E*qqXuv`2@OrbMIdDW_l?zDkU+Zp2JEJX4<-vt;Jg4j)OTJJ2W?;(C3l zv7Bxp@j{W2>j@oS!KHvzsZ2=(B>x`Kz)Jqz#GkuiSFBajMsyEWzEzYk47iy`p>Bl7 z@8pm*bDWE1ZbBvX?5Ckvr^BpgcIzQ@sh`a3IH$mTsl^mN{(00$G=T?6RnJkdv^B`(s+}x)2Iw}en#197Zi@s^pE)!sp0GnTC^l)8 z%xjBw&CtwKXDwCDuSIzmzL{HmZMj}}7;R5mI_Lk}58$y|D^KgE!^j)%FY2D7iIbCs zjoF{(e7)M5%^C;7A3Mc8mp4?48+G!x0UPUCMWI|y;k^}bPZx6VNc|Tb! z5u1jkWhPQNnQm-qydC0Sz}0pu`ln$4h*ItnWx_ous$Q9s%<|I3HXEYS0ghwR#|D1s#6}I>T1rZ) z%M*$x2}E+oX=%iT)52oIU2Kokf+{DVH1q$dnA%S>B^kbkZ`7l=*Tkq%S5Ie0*)W7S zgkq>)xSSw`(YUL&09ksq%D%1=PH)2R_^g@PX?m# zohV9`*{(eQ=xe03G1Ho!nAxOxfmNJ{Cy5;D3d6xIlh{XOEhWR~?2Dt^H;q|;2d0wXQW?Ceo{4^5fxhk zmUacl-S?|Aa`dAfP~NhR3oqeCqr?i#Gmxa5;*&M89#+u(fXDDV4Y^EXTNgAQPZK38aD-={=pSGmq^Pe| zqv#+OrCUU#(H;Af0T?R+XP1(&z~|`+9i!yDHsesLQk@&y%Qj-{bYRGpXR0vxAl1MT z)T!LsG1FHv42omOOszlIMBSw9VE+!Z7{-Y$BS%)Vniy;epB%GO>F!68c=%H6IIVl{ z52hD&Xh~lMJ(MoQo++U2!s}N^s`qpC9agcrU?WdrvsR|XL}hFs=TX~H7^=64j5wYl z92|s_H`qkxozt9~b(Sfur%KRws8I&Hc5d^87tVxuRQ%`#Jcl<=atq5AYCSh@lU^D= z;o=NsYulZmv6?XDTwKGKz0|H_QL-%b=8j>ej7pCcZSD+^1l@jk)f;F0UD{Jgh7)mb zlm>h^x#nNOdCmwEo)65my!4SCh(7J=O_fhLshoaozf96!(V@LBrm)dsi&HpLGqPUP-;EU%fgl_=FKrtq6UCaA<4~a6zGTI zW+fiqG7`bQFlhTc;;bx&0n(NOiuh6J2*+UypqnF_i6~2FoUm>Xbe_0r!ERCqc^>YC zNpJXn|A2kaSjYj7E;BX8KplF}l2#nmQp9K}NvN!)+UeyB*miim?RL&;y~Qzvq%9G@ zpq;I(aJIMbS}8l|lNe4VFz5QVs^zM;c3%9goD1DnUWFByV1;vzkhHDCDKRy$8s`0Q z8h>Qcno-TjCg2D@v24QMrR|q-Rb1wgyV!v<)7J2-92$nk^oS5KbdnW3adw~QHlAsm z29^6Hg$a0u*RM70K^*=`b&vc_F>hl0 z>H`}L(Fy22$XjgT<0r_{x5yh#B#y6)Mp!IGTkYMh`75JM$qD9GPn}(Lrknjusz1yT z!K8f-#LGWc7a2Y{bU)z#&aPddJQ|Vs^moU6+P2656aYrfj!w4L>eg2D7EUJCf9m-J zc_aW7IiHrm|HoAkD`@L4fDrTnwL=#XD8M4+uZoSV6t%pGj$w?vOxh+q&+>M$5md%f z)U>%d{&HYqYI?+iSUE1B0jV5IxDTmI{e6vOa|p6czhxAYsmE2chiUmL+}@6^tgaI7 zTYf_@>sKv&f+Vm*7k#ai)?LfA$up$dRHVE*B+D|K8Gt2SLStEllxqqe(-**12q%AP49A3(e$e6Z&51ze7WVH3sKA`+WaqQzRXzs^<9`g0yo0TsEf{5$*XIcaZ6^GwzykEH{P#l|gxyw!f@tUiq1Z-(Pzhz*xo-1%XBn_RR) zBJ2#G=DwlP&%7CsM(KFq1nKz`c3#1jgQpGe!A<^MENlDYuH?4XFQagB<9p;ab^1Qa z)coyvub&Jo%3#uR^+`9y^h zK}x-Akp>_J}UQcdVVqEtz zC$D#TVl?(fTAruf$i|@$FGX<%xZBG5!LtglZ_UVXyKLcp>Xie7|BRDXDP64%EPVLM znAQ30bfP-R$&w_Rc~*0}aMr?xgPTEi#Y?T4I&O5FF2-;~7eQz8@|0pYfONN~bAoZx zwO3_wH}PuO*Th6SLgM_@p^#_H?7~?2@>gRl`J~Iea>stp7hBspbz>&{th0=a<$$Jd zhNiLqwe*5?`uhLoSpfXP8`Lzst7q>;NE>h0L*GW$ zpS)rux>L`*tLfw&LI|)O?UWQzI z%vo0~`-y(C;A#Do@UshsQV8kNE?~GxUjrVVOR5cke`wilRjgD}5G&&tgl) zSNdu?6Z+2*{BlM|hm^Uy@wwgivc6$LG;cYy?Xf=iP@2V$8))4SeTYfLa5e9Z63>Tsq zW;m1C=6~Ow^O;q?)Q0n)BhOpRLY9TMs2TgdO=J`+A`(c_mTHfcpe@Y0>GL?JTtky5 z*f!FzFUp z*Vy#el1lC&zO$N|)?5$l1=;*|uqz@<`t^0rZc0uqYx7Q2Wsd^uJ(Gbuo@a`@=bi#< zQ-8kA#brKhdbn|egySgMSDs$5uRO>1NWqbV#NXrR+&(6E zLnC2e#RdYw^78}(6zp?)Bp@+5g@*vKJ7xbMYD2z=&VV=!WuM>tSLDiT4t-_&qMnjn znE-`>RIvQ;A6F;7Q>JyuGOl6v1f_cfXbVQvQ&30tbsDYhvz^>erSE!A`Nhi#AEx*$zAmW^9Msi)QrG~lPKKh8kep1v8{ znp&`UPUX8bP*6kJ7JZLw!#)YE!c1WwuE(hML}DH5i%zZ?A8{FTrf)HIpPxZWc3+=S zJgGNbDGFJl@id1C%x`=w9dygu=C+K;8*Q##ZWJh^Nf){-p$)fGgq-Y?uS64YX|))% z-IC?;_KXaFrNH2PQs6ong`Xj-iFtLbs5<>#P&!ub^2G{m$*bd6Qxe16eZyVR`GZqO z(pwSBp&;>OvnQ{m3zeusjKC8G13BT0g{C>!5JbJa z!3Q+CvUv zq0a1ug6_RO`8`9R=KZ7YA5Hzimk>m=1ObRtS$|XduPhhVEtd64rtv=8-1=;*Fh-sb z>W_-QwfR4v^G^mK4l13vWTk z;@yg{h#m^C`p-(RxgMY8SpgQAIN37Px3Ja6;hy+553BpO7)$7`=(FTw>5kSY3xm)s zAOK3=bwk&o7x6D3l(Byn{Dm6FkU}gX{nB6v^)f;s`Z7QnpE5#a+t0%PX)9sMvWooC z)F(VpR~h*GyRvY}=4W9myQ7hcvP9{N=Bz?Kz}Tf5jwYnzB@f-?=t)RLb8957jiTH&u9C*q53aE z%D>P&qlo+$0!%-w`}u(XV)9q~KRJ=o|52U#UnD-;@{cF@FES#{YEwTi^)GU(o7NBV z#kW+zlOCnj+oZtLi_o(SFTh5N z79|tBZ-ei1h;9DyXUwAd0!dBvQ(cI2(3wLer2@7MWf@S@*8yUHF7Ku!I)hQ;a&MJQ zfS}^zmQdIs6@kQ|F+;jT8fLSlT+R2vaQltqp*fnE{OjAz%|{0wcWdOS+4j6Mznh=n z8l8Maof6(|Rb4+1-Zd=vRANvP@G!pIcBomYUY=SVE|;l(4`J(g$xtTMIJUy9v<)!w zX>5m;%lu{(Q+m^Y-n&4vL?5UWLSKg78zl8prK1Winv9F>HpUr@W6ePG?Mq3!8@xbOvO~J7im9Z1%N^Y}6Je1mzrGBnmD5vqacLS4xqK@Sdr@2#yDF@A+C@SZE(UF;TsguI@Po2JiuFjVz-+ zuM{$H0y-YB_i6)57Aqo7R!LX7V2*BmJ)B>gdPnzzc9WYv2#z~(_oUFYNcW5$8;}&a zkMs^d^Wl*0r1eEW@Xa3j0H?7k_~Q>t&ku>Wv)-Gp;e1qlA+e@fzYo)#>!b-v!w&C^&(XWr3RpY&&@4&Q+( zABIvDh2?5`yQP=;del#GF|kH0Oh!PmNa(%zn%TP9@Upugqk5M;;75mAVrL zF7eHM1=sc6@Gu!)y60fL2#uVOrQLK_WEI@pw($yCkpE)zBZ12Cjiziu>ZtVf?#SA` zEQ?C+esJ#^jg$J}u3_{3O8nrBf=W_iL+&;$`}x{FX&Bf_CG`GVu34@z&JgrX+9;C` zN64*H!VT~ES;{uB4-8*8sI)9DzME=8Sy})M{OOux#;Ue2CmUC+O6O9wSX}WwyB8B` zY4XJVzG7ItSok7thLS@{2G)}u=~-BzgJ_xDQhLPIfTVlTz(n*KTf3I4`O0PMy{!s1 zfA|n|vRKpAQkf3hLQ+>O=h{aEbet&9+!zWO|F4^4R`2`^iq*G0l+gI+%(YVa&bC6CD;ZD> zg_!i3hwj>`4BL`RsMfNSy{3#JhAL!927o)euJ>z6?{mBC6(LSFBge2V3bL#g6-xVK z7DZ4X;i|2irvt1GR5#DRT(eT?5b{Xrx$LW`qg)3YhC5yC!QKYW)3s9#Q+J5HH?Vvu z$P2Zdduw+L$2P1;1fB8PREul(=_DakvfF=nme3>MGQn1nX7WmJ#lIJvdU)P9SOnN5 zjpX5&Be+v$=x)xxvx;J{nz(Z!V8hXBk@u_Z5OsOP%p^3FhrA6dxgbQ7+Yva&` z)0*m70vrinN!~X485@e)mascCgTM)I`qeik1QHQ`F~SGZd72)Kn3D199iLQmC`IiW zv~CGYCpV<&)w*~uBO>HI)nm%^0K2Z?hFqA&;k|-`kVx3wEA+yE-_1Lx|# z;prl1R6SXR0q#27viurSx291GI%guzux6#Y;g*i6_6^W|OLcig`dyh-QyWvuIZcW< zoORpA`43L9;#OACC<6v%8#$V%6`$0;#lyo2je=u?f+(&^IeCc&kgqD`nFd0otU9#g zAsl{9Im+Za?|?T}rd{8;gbTIc>3vw*FZuS^iz+mJ987#h z-+a$Uv0RX_R@`WM`<{iqc;M+>L5Bb314bH$HS}jInm{RS=6C?_T@!T3{N7H(9@YqE z8D@w9Ke-m@%^Q!}(+FQJ2$MQNp37V8muvyKFD5Zd7V4aVJ{X;Fmh1_ zfZ04(z502>zsgfCsrR;Adp^RtjnFSs^)oRIAPvr&WuYEQe$!hZ)}wV#4$)|z9}wt5L*du(e9_WX!0{)6Xjn%Ay*SarN8X&WF&6ct`Q)7hV$I{)VSHc8d;!d z$Wn=_luORBSfAnh0uGgQYY6NX_`JcbNv@l~o6vXeZNFUM;;i%I)Jxs>azUDkUTcUU z8DQ91G*VwbUpuvtgx$>Bm34=razcsYEwbyKx78sp*zWLFIYBB$QnPBs4ZbwWC=Frd zmZl3*!^kb_v)*ykuPH0NG=8Hq!1A45F94r|?&vYiy^`ebTKXKei`5)J7i@t?PLP#zq{!vM{+m{13kVUQoxiKNu zn3z*xdi*@$PyFW;$;7h#fWFcGn(XtHPZ-}#TnLHW zt#{FMk7VgU;cX^g!-uKYwS1z>>b9YAl3RYaRi^yq{qBZ$o1o_Q_O~(-L_1HK_UQvh zg!-=lUK=N614Ap5Kf_&;ByH9hFoGUHJ-`J&*D@Xv#$Av}f*b*C6j29&;5-^49FX`d zx)~LPTeubD8Ro)Yv{H)kl_GMMpn_@*fDs-j{8v8T_`f;T8PBI1m4EXIQp5wIG!znBPj`^X#Xsb zXSJ?{wk9;zl<=!jEHV|7#tR6nW;EymH3X5&6)Adnv ztivX*!tm2r*^jNqHfuhJt?zN)Fvaq9T7*QrJN8qkU2*(H^{*OutlDOh1tqcyYV}j$SU&9&)EncH{>_)S8>|h)C3DDVwDfCmv8S& zQ!gR-dOllTWwJa_;q>mQoZvh1(i<`F z>L(gKO!%>@*GpZ8Q8O%R2g*>)?8NRO!q`n`%@X)tLnK`@`7K<%yE1Q^e!W`eM1Op) zUH{YLZCP$e`t@^lJNiSKKl2?vlN|osC7?I5b@)?|KaT?V-z106uFHrY`%HyL5Pb-G z5183Z#zzRR#Wj~!g+)o5oz0|@MPgk9mJHkLs;iDyTAQ zBRvD0<5dO;B1H3$Y;!yw4Jb?YEvb2pgHqCr3`Q*an1#W#5)A;j>6JExmfst(kMtwhaGJGo@SH8O9!v!D} zi;T(MWgqx*mWe%@n7wM7rjb`s`3$tjrdC{@K*^n zu(SJPrTt$4{p_~SD?@SYk1U*a;wyNC^La_ipShKYR6;mz%9g%AOl*;$hZ?QWXS&RU z2|(a)V?@*q$n|4J(aKt)bH$aw!a6!vlIn7|oJ^Ck(T9`vJp`)e za$OS}@Gh2{}uRCWCEREXQ8QzE%yPO1_I&$hHEA=HHT0x46!im8W12o;L`5)2e=&a>D^WC^k zi$C)4e9@&d1NF*I^)fR4_Kp1&`0rL!Cw@+k-p}!%_>@_^PkH^%9{E4g$o{=k{=Ye7 zf6DDYNaR#)_~%I@VB2ron?2^X8SC<+h{e*BPTW#(tytocGDc(SmKIx`2SwCQzbL{! zUKH9of<$!~e-xYNlv>JT;KK6BfqB2YSptU-4EE5W%yi>oHf|h>d-e}VL0+9Am*#DR<2B7(5NLcB#%jOs#6+eIn|w8kSburd zKY#wsNm=sJ|IOgP&*%9ogO1N8|8iQ-@4(*=F8L31<1 { }); }); + it('preserves separator config and footnote linkage in the variant fixture (SD-3400)', async () => { + // footnote-tests-B carries explicit separator/continuationSeparator notes + // and the settings.xml special-footnote list — the configuration variants + // from the SD-3400 Observatory check. + const docxPath = join(__dirname, '../data', 'footnote-tests-B.docx'); + const docxBuffer = await fs.readFile(docxPath); + + const originalZipper = new DocxZipper(); + const originalFiles = await originalZipper.getDocxData(docxBuffer, true); + const originalFootnotesJson = parseXmlToJson( + originalFiles.find((f) => f.name === 'word/footnotes.xml').content, + ); + const originalRoot = findFootnotesRoot(originalFootnotesJson); + const regularIds = collectFootnoteIds(originalRoot).filter((id) => { + const type = findFootnoteById(originalRoot, id)?.attributes?.['w:type']; + return !type || (type !== 'separator' && type !== 'continuationSeparator'); + }); + expect(regularIds.length).toBeGreaterThan(0); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + const exportedRoot = findFootnotesRoot( + parseXmlToJson(exportedFiles.find((f) => f.name === 'word/footnotes.xml').content), + ); + + // Every regular footnote survives with its id. + regularIds.forEach((id) => { + expect(findFootnoteById(exportedRoot, id)).toBeTruthy(); + }); + // Separator notes survive with their types and reserved ids. + expect(findFootnotesByType(exportedRoot, 'separator').map((el) => el.attributes['w:id'])).toEqual(['-1']); + expect(findFootnotesByType(exportedRoot, 'continuationSeparator').map((el) => el.attributes['w:id'])).toEqual([ + '0', + ]); + // The settings.xml special-footnote list (§17.11.9) survives export. + const settingsPr = findFootnotePrInSettings(findSettingsXml(exportedFiles)); + expect(settingsPr).toBeTruthy(); + const listedIds = (settingsPr.elements ?? []) + .filter((el) => el.name === 'w:footnote') + .map((el) => el.attributes?.['w:id']); + expect(listedIds).toContain('-1'); + expect(listedIds).toContain('0'); + // Body references survive in document order. + const exportedBody = parseXmlToJson(exportedFiles.find((f) => f.name === 'word/document.xml').content); + const refIds = []; + const walk = (node) => { + if (node?.name === 'w:footnoteReference') refIds.push(node.attributes?.['w:id']); + (node?.elements ?? []).forEach(walk); + }; + walk(exportedBody.elements?.[0]); + expect(refIds).toEqual(regularIds); + }); + it('preserves footnoteReference nodes in document body', async () => { const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); const docxBuffer = await fs.readFile(docxPath); From c28d6d24ca25fa5a415cc26e2d82d010e0c9715e Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 10:20:58 -0300 Subject: [PATCH 25/36] fix(footnotes): caret drift in multi-paragraph note sessions The note caret/selection overlay mapped session positions through a visible-text-offset bridge that counts structural paragraph tokens on the painted side (pm-range gaps between lines) but not on the hidden editor side (raw text walk). Each Enter in a note drifted the caret 4 positions backward, so typing and arrow navigation rendered the caret inside the previous paragraph's text. Resolve note caret and selection geometry directly from the painted lines' session-coordinate pm ranges (data-pm-start/end), falling back to the offset bridge when a position is not painted (structural gaps, hidden tracked content). Single-paragraph notes are unaffected; multi paragraph carets now land on their own lines (live-verified). --- .../presentation-editor/PresentationEditor.ts | 65 ++++-- .../selection/VisibleTextOffsetGeometry.ts | 216 ++++++++++++++++++ .../tests/VisibleTextOffsetGeometry.test.ts | 148 ++++++++++++ 3 files changed, 405 insertions(+), 24 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 57fb3d3240..786f1afb57 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -112,7 +112,9 @@ import { renderCaretOverlay, renderSelectionRects } from './selection/LocalSelec import { computeCaretLayoutRectGeometry as computeCaretLayoutRectGeometryFromHelper } from './selection/CaretGeometry.js'; import { shouldUseNativeCaretFallback } from './selection/native-caret-fallback.js'; import { + computeCaretRectFromPmPosition as computeCaretRectFromPmPositionFromHelper, computeCaretRectFromVisibleTextOffset as computeCaretRectFromVisibleTextOffsetFromHelper, + computeSelectionRectsFromPmRange as computeSelectionRectsFromPmRangeFromHelper, computeSelectionRectsFromVisibleTextOffsets as computeSelectionRectsFromVisibleTextOffsetsFromHelper, measureVisibleTextOffset as measureVisibleTextOffsetFromHelper, measureVisibleTextOffsetInContainers as measureVisibleTextOffsetInContainersFromHelper, @@ -10259,14 +10261,27 @@ export class PresentationEditor extends EventEmitter { return null; } - const startOffset = this.#measureActiveEditorVisibleTextOffset(Math.min(from, to)); - const endOffset = this.#measureActiveEditorVisibleTextOffset(Math.max(from, to)); - if (startOffset == null || endOffset == null) { + const noteFragments = this.#getRenderedNoteFragmentElements(this.#collectNoteBlockIds(context)); + if (!noteFragments.length) { return null; } - const noteFragments = this.#getRenderedNoteFragmentElements(this.#collectNoteBlockIds(context)); - if (!noteFragments.length) { + const geometryOptions = { + containers: noteFragments, + zoom: this.#layoutOptions.zoom ?? 1, + pageHeight: this.#getBodyPageHeight(), + pageGap: layout.pageGap ?? this.#getEffectivePageGap(), + }; + + // Same pm-first strategy as #computeNoteDomCaretRect (SD-3400). + const pmRects = computeSelectionRectsFromPmRangeFromHelper(geometryOptions, from, to); + if (pmRects != null) { + return pmRects; + } + + const startOffset = this.#measureActiveEditorVisibleTextOffset(Math.min(from, to)); + const endOffset = this.#measureActiveEditorVisibleTextOffset(Math.max(from, to)); + if (startOffset == null || endOffset == null) { return null; } @@ -10274,12 +10289,7 @@ export class PresentationEditor extends EventEmitter { const renderedEndOffset = this.#toRenderedNoteVisibleTextOffset(noteFragments, endOffset); return computeSelectionRectsFromVisibleTextOffsetsFromHelper( - { - containers: noteFragments, - zoom: this.#layoutOptions.zoom ?? 1, - pageHeight: this.#getBodyPageHeight(), - pageGap: layout.pageGap ?? this.#getEffectivePageGap(), - }, + geometryOptions, renderedStartOffset, renderedEndOffset, ); @@ -10311,27 +10321,34 @@ export class PresentationEditor extends EventEmitter { return null; } - const textOffset = this.#measureActiveEditorVisibleTextOffset(pos); - if (textOffset == null) { + const noteFragments = this.#getRenderedNoteFragmentElements(noteBlockIds); + if (!noteFragments.length) { return null; } - const noteFragments = this.#getRenderedNoteFragmentElements(noteBlockIds); - if (!noteFragments.length) { + const geometryOptions = { + containers: noteFragments, + zoom: this.#layoutOptions.zoom ?? 1, + pageHeight: this.#getBodyPageHeight(), + pageGap: layout.pageGap ?? this.#getEffectivePageGap(), + }; + + // Painted note lines carry session-coordinate pm ranges, so resolve the + // caret by pm position first — exact across paragraph boundaries, where + // the visible-text bridge drifts (SD-3400 multi-paragraph note caret). + const pmRect = computeCaretRectFromPmPositionFromHelper(geometryOptions, pos); + if (pmRect) { + return pmRect; + } + + const textOffset = this.#measureActiveEditorVisibleTextOffset(pos); + if (textOffset == null) { return null; } const renderedTextOffset = this.#toRenderedNoteVisibleTextOffset(noteFragments, textOffset); - return computeCaretRectFromVisibleTextOffsetFromHelper( - { - containers: noteFragments, - zoom: this.#layoutOptions.zoom ?? 1, - pageHeight: this.#getBodyPageHeight(), - pageGap: layout.pageGap ?? this.#getEffectivePageGap(), - }, - renderedTextOffset, - ); + return computeCaretRectFromVisibleTextOffsetFromHelper(geometryOptions, renderedTextOffset); } #computeNoteCaretRect(pos: number): LayoutRect | null { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts index 62853cc7a6..46acdc4d44 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts @@ -282,6 +282,222 @@ export function computeSelectionRectsFromVisibleTextOffsets( return layoutRects; } +type ResolvedPmPoint = { + node: Text; + offset: number; + pageElement: HTMLElement; + lineElement: HTMLElement; +}; + +/** + * Resolves a ProseMirror position directly against the painted lines' pm + * ranges (`data-pm-start`/`data-pm-end`), bypassing the visible-text-offset + * bridge. Painted note fragments carry SESSION-coordinate pm ranges, so this + * is exact across paragraph boundaries — the offset bridge counts structural + * paragraph tokens on the painted side but not on the hidden-editor side, + * which drifted the caret backwards in multi-paragraph notes (SD-3400). + * + * Returns null when no painted line covers the position (e.g., a structural + * gap or unpainted content) so callers can fall back to the offset bridge. + */ +function resolvePmPoint(containers: readonly HTMLElement[], pos: number): ResolvedPmPoint | null { + if (!Number.isFinite(pos)) { + return null; + } + + const lines = collectRenderedLineElements(containers); + let lineElement: HTMLElement | null = null; + for (const line of lines) { + const pmStart = getPmStart(line); + const pmEnd = getPmEnd(line); + if (pmStart == null || pmEnd == null || pos < pmStart || pos > pmEnd) { + continue; + } + lineElement = line; + // Forward affinity: a position at this line's end that also starts the + // next line belongs to the next line, so keep scanning while pos == pmEnd. + if (pos < pmEnd) { + break; + } + } + if (!lineElement) { + return null; + } + + const pageElement = lineElement.closest(`.${DOM_CLASS_NAMES.PAGE}[data-page-index]`); + if (!pageElement) { + return null; + } + + const leaves = collectLeafPmElements(lineElement); + let leaf: HTMLElement | null = null; + for (const candidate of leaves) { + const pmStart = getPmStart(candidate); + const pmEnd = getPmEnd(candidate); + if (pmStart == null || pmEnd == null || pos < pmStart || pos > pmEnd) { + continue; + } + leaf = candidate; + if (pos < pmEnd) { + break; + } + } + if (!leaf) { + return null; + } + + const leafPmStart = getPmStart(leaf) ?? 0; + const doc = leaf.ownerDocument ?? document; + const walker = doc.createTreeWalker(leaf, NodeFilter.SHOW_TEXT); + let remaining = Math.max(0, pos - leafPmStart); + let lastTextNode: Text | null = null; + + let currentNode = walker.nextNode(); + while (currentNode) { + const textNode = currentNode as Text; + const textLength = textNode.textContent?.length ?? 0; + if (textLength > 0) { + lastTextNode = textNode; + if (remaining <= textLength) { + return { node: textNode, offset: remaining, pageElement, lineElement }; + } + remaining -= textLength; + } + currentNode = walker.nextNode(); + } + + if (!lastTextNode) { + return null; + } + // Position past the leaf's painted text (pm range wider than visible text, + // e.g. tracked wrapper structure): clamp to the leaf's end. + return { + node: lastTextNode, + offset: lastTextNode.textContent?.length ?? 0, + pageElement, + lineElement, + }; +} + +/** + * Caret rect for a ProseMirror position resolved via painted pm ranges. + * See {@link resolvePmPoint}; returns null so callers can fall back. + */ +export function computeCaretRectFromPmPosition( + options: VisibleTextOffsetGeometryOptions, + pos: number, +): LayoutRect | null { + const point = resolvePmPoint(options.containers, pos); + if (!point) { + return null; + } + + const doc = point.node.ownerDocument ?? document; + const range = doc.createRange(); + range.setStart(point.node, point.offset); + range.setEnd(point.node, point.offset); + + const rangeRect = range.getBoundingClientRect(); + const lineRect = point.lineElement.getBoundingClientRect(); + const pageRect = point.pageElement.getBoundingClientRect(); + const pageIndex = Number(point.pageElement.dataset.pageIndex ?? 'NaN'); + if (!Number.isFinite(pageIndex)) { + return null; + } + + const localX = (rangeRect.left - pageRect.left) / options.zoom; + const localY = (lineRect.top - pageRect.top) / options.zoom; + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + return null; + } + + return { + pageIndex, + x: localX, + y: pageIndex * (options.pageHeight + options.pageGap) + localY, + width: 1, + height: Math.max(1, lineRect.height / options.zoom), + }; +} + +/** + * Selection rects for a ProseMirror range resolved via painted pm ranges. + * See {@link resolvePmPoint}; returns null so callers can fall back. + */ +export function computeSelectionRectsFromPmRange( + options: VisibleTextOffsetGeometryOptions, + from: number, + to: number, +): LayoutRect[] | null { + if (!Number.isFinite(from) || !Number.isFinite(to)) { + return null; + } + + const startPos = Math.min(from, to); + const endPos = Math.max(from, to); + if (startPos === endPos) { + return []; + } + + const startPoint = resolvePmPoint(options.containers, startPos); + const endPoint = resolvePmPoint(options.containers, endPos); + if (!startPoint || !endPoint) { + return null; + } + + const doc = startPoint.node.ownerDocument ?? document; + const range = doc.createRange(); + try { + range.setStart(startPoint.node, startPoint.offset); + range.setEnd(endPoint.node, endPoint.offset); + } catch { + return null; + } + + const rawRects = Array.from(range.getClientRects()) as unknown as DOMRect[]; + const pageElements: HTMLElement[] = []; + for (const pageElement of [startPoint.pageElement, endPoint.pageElement]) { + if (!pageElements.includes(pageElement)) { + pageElements.push(pageElement); + } + } + const rects = deduplicateOverlappingRects(rawRects); + const layoutRects: LayoutRect[] = []; + + for (const rect of rects) { + if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height) || rect.width <= 0 || rect.height <= 0) { + continue; + } + + const pageElement = findPageElementForRect(rect, pageElements); + if (!pageElement) { + continue; + } + + const pageRect = pageElement.getBoundingClientRect(); + const pageIndex = Number(pageElement.dataset.pageIndex ?? 'NaN'); + if (!Number.isFinite(pageIndex)) { + continue; + } + + const localX = (rect.left - pageRect.left) / options.zoom; + const localY = (rect.top - pageRect.top) / options.zoom; + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + continue; + } + + layoutRects.push({ + pageIndex, + x: localX, + y: pageIndex * (options.pageHeight + options.pageGap) + localY, + width: Math.max(1, rect.width / options.zoom), + height: Math.max(1, rect.height / options.zoom), + }); + } + + return layoutRects; +} + function collectVisibleTextModel(containers: readonly HTMLElement[]): VisibleTextModel { const lines = collectRenderedLineElements(containers); if (!lines.length) { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts index 822be59436..2f252d6a18 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { computeCaretRectFromVisibleTextOffset, + computeCaretRectFromPmPosition, + computeSelectionRectsFromPmRange, computeSelectionRectsFromVisibleTextOffsets, measureVisibleTextOffset, type VisibleTextOffsetGeometryOptions, @@ -94,6 +96,152 @@ describe('computeCaretRectFromVisibleTextOffset', () => { }); }); +describe('computeCaretRectFromPmPosition (SD-3400 multi-paragraph note caret)', () => { + /** + * Mirrors the painted shape of a 3-paragraph note ("here is the footnote i + * am adding" / "thank you for this" / "ddd"): one fragment per paragraph, + * lines carrying SESSION-coordinate pm ranges with +4 token gaps at the + * paragraph boundaries (34->38, 56->60). The visible-text bridge mapped the + * caret for pm 60 into paragraph 2 ("thank you for thi|s"); pm resolution + * must place it on paragraph 3. + */ + function buildThreeParagraphNote() { + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + page.innerHTML = ` +
+
+ + here is the footnote i am adding +
+
+
+
+ thank you for this +
+
+
+
+ ddd +
+
+ `; + document.body.appendChild(page); + + const fragments = Array.from(page.querySelectorAll('[data-block-id]')); + const textNodeOf = (text: string) => + Array.from(page.querySelectorAll('span')).find((el) => el.textContent === text)?.firstChild as Text; + + page.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + const lines = Array.from(page.querySelectorAll('.superdoc-line')); + lines[0]!.getBoundingClientRect = vi.fn(() => createRect(10, 500, 300, 15)); + lines[1]!.getBoundingClientRect = vi.fn(() => createRect(10, 515, 120, 15)); + lines[2]!.getBoundingClientRect = vi.fn(() => createRect(10, 530, 30, 15)); + + return { page, fragments, textNodeOf }; + } + + it('places the caret on the THIRD paragraph for a position after two paragraph breaks', () => { + const { fragments, textNodeOf } = buildThreeParagraphNote(); + const dddTextNode = textNodeOf('ddd'); + + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.startContainer === dddTextNode && this.startOffset === 0) { + return createRect(10, 530, 0, 15); + } + return createRect(0, 0, 0, 0); + }); + + const rect = computeCaretRectFromPmPosition(createGeometryOptions(fragments), 60); + + expect(rect).toMatchObject({ pageIndex: 0, x: 10, y: 530, height: 15 }); + }); + + it('places a mid-paragraph caret at the pm offset within the leaf text', () => { + const { fragments, textNodeOf } = buildThreeParagraphNote(); + const thankTextNode = textNodeOf('thank you for this'); + + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.startContainer === thankTextNode && this.startOffset === 6) { + return createRect(50, 515, 0, 15); + } + return createRect(0, 0, 0, 0); + }); + + // pm 44 = 6 chars into "thank you for this" (leaf pmStart 38). + const rect = computeCaretRectFromPmPosition(createGeometryOptions(fragments), 44); + + expect(rect).toMatchObject({ pageIndex: 0, x: 50, y: 515, height: 15 }); + }); + + it('returns null for a position inside a structural gap so callers can fall back', () => { + const { fragments } = buildThreeParagraphNote(); + + expect(computeCaretRectFromPmPosition(createGeometryOptions(fragments), 36)).toBeNull(); + }); + + it('ignores the pm-less synthetic marker text', () => { + const { fragments, textNodeOf } = buildThreeParagraphNote(); + const firstTextNode = textNodeOf('here is the footnote i am adding'); + + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.startContainer === firstTextNode && this.startOffset === 0) { + return createRect(22, 500, 0, 15); + } + return createRect(0, 0, 0, 0); + }); + + const rect = computeCaretRectFromPmPosition(createGeometryOptions(fragments), 2); + + expect(rect).toMatchObject({ pageIndex: 0, x: 22, y: 500, height: 15 }); + }); +}); + +describe('computeSelectionRectsFromPmRange (SD-3400)', () => { + it('builds selection rects across paragraph boundaries from pm positions', () => { + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + page.innerHTML = ` +
+
+ first +
+
+
+
+ second +
+
+ `; + document.body.appendChild(page); + + const fragments = Array.from(page.querySelectorAll('[data-block-id]')); + const firstTextNode = Array.from(page.querySelectorAll('span')).find((el) => el.textContent === 'first') + ?.firstChild as Text; + const secondTextNode = Array.from(page.querySelectorAll('span')).find((el) => el.textContent === 'second') + ?.firstChild as Text; + + page.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + + vi.spyOn(Range.prototype, 'getClientRects').mockImplementation(function () { + if (this.startContainer === firstTextNode && this.startOffset === 2 && this.endContainer === secondTextNode) { + return [createRect(20, 500, 60, 15), createRect(10, 515, 30, 15)] as unknown as DOMRectList; + } + return [] as unknown as DOMRectList; + }); + + // pm 4 (inside "first") to pm 15 (inside "second"). + const rects = computeSelectionRectsFromPmRange(createGeometryOptions(fragments), 4, 15); + + expect(rects).toEqual([ + { pageIndex: 0, x: 20, y: 500, width: 60, height: 15 }, + { pageIndex: 0, x: 10, y: 515, width: 30, height: 15 }, + ]); + }); +}); + describe('computeSelectionRectsFromVisibleTextOffsets', () => { it('maps later-word selection offsets after an inserted run to the correct painted range', () => { const page = document.createElement('div'); From b32cf50644aa5826e7ea1c1cde7a4d40f4cc0cb9 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 10:35:33 -0300 Subject: [PATCH 26/36] test(footnotes): pin arrow navigation and caret tracking in notes Real-keystroke regression for the SD-3400 caret drift report: build a note with an empty middle paragraph, walk the caret up and down with arrows, and assert the painted caret lands on each paragraph's own line and that typing lands where the caret points. --- .../footnotes/footnote-interactions.spec.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/behavior/tests/footnotes/footnote-interactions.spec.ts b/tests/behavior/tests/footnotes/footnote-interactions.spec.ts index 4c25ea4632..aa0f6bce2f 100644 --- a/tests/behavior/tests/footnotes/footnote-interactions.spec.ts +++ b/tests/behavior/tests/footnotes/footnote-interactions.spec.ts @@ -176,3 +176,69 @@ test('insertFootnote places a marker at the cursor and focuses the new note', as .poll(() => getActiveStorySession(superdoc.page)) .toEqual({ kind: 'story', storyType: 'footnote', noteId: refs[0].id }); }); + +test('arrow navigation and caret tracking across paragraphs in a note session', async ({ superdoc }) => { + // Regression for the SD-3400 caret drift: typing Enter inside a note made + // the caret render on the previous paragraph's line, so arrow movement + // looked broken even though the selection moved correctly. + await superdoc.loadDocument(BASIC_FOOTNOTES_DOC_PATH); + await superdoc.waitForStable(); + + const note = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); + await note.scrollIntoViewIfNeeded(); + const box = await note.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + 60, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ kind: 'story', storyType: 'footnote', noteId: '1' }); + + // Build: original line, empty paragraph, "tail". + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.press('Enter'); + await superdoc.page.keyboard.press('Enter'); + await superdoc.page.keyboard.type('tail'); + await superdoc.waitForStable(800); + + const readCaret = () => + superdoc.page.evaluate(() => { + const sed = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const caret = document.querySelector('.presentation-editor__selection-caret'); + const rect = caret?.getBoundingClientRect(); + const lineTops = Array.from(document.querySelectorAll('[data-block-id^="footnote-1-"] .superdoc-line')).map( + (line) => Math.round(line.getBoundingClientRect().top), + ); + return { sel: sed?.state?.selection?.head ?? -1, caretTop: rect ? Math.round(rect.top) : null, lineTops }; + }); + + const atTail = await readCaret(); + expect(atTail.caretTop).toBe(atTail.lineTops[2]); // caret on the "tail" line + + await superdoc.page.keyboard.press('ArrowUp'); + await superdoc.waitForStable(600); + const atEmpty = await readCaret(); + expect(atEmpty.sel).toBeLessThan(atTail.sel); // selection moved up + expect(atEmpty.caretTop).toBe(atEmpty.lineTops[1]); // caret on the empty line + + await superdoc.page.keyboard.press('ArrowUp'); + await superdoc.waitForStable(600); + const atFirst = await readCaret(); + expect(atFirst.sel).toBeLessThan(atEmpty.sel); + expect(atFirst.caretTop).toBe(atFirst.lineTops[0]); // caret on the first line + + // Typing where the caret points must land in the empty paragraph, not "tail". + await superdoc.page.keyboard.press('ArrowDown'); + await superdoc.waitForStable(600); + await superdoc.page.keyboard.type('middle'); + await superdoc.waitForStable(800); + const text = await superdoc.page.evaluate(() => { + const sed = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const paras: string[] = []; + sed?.state?.doc?.forEach((n: any) => paras.push(n.textContent || '')); + return paras; + }); + expect(text).toContain('middle'); + expect(text).toContain('tail'); + expect(text.find((t: string) => t.includes('middletail') || t.includes('tailmiddle'))).toBeUndefined(); +}); From 43ca6ea43abab8690178f1a400f557173fb7a9b1 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 11:13:06 -0300 Subject: [PATCH 27/36] fix(footnotes): keep note paragraph style when splitting in a session Pressing Enter in a note session ran the linked-style clearing heuristic (splitRunToParagraph and splitBlock both call it on at-end splits) once the linked-styles cache populated on the shared converter. FootnoteText is a linked style (w:link FootnoteTextChar) but has no w:next, so Word keeps it on Enter; clearing it made new note paragraphs render at the document default size, appearing as random font growth mid-note. Skip the clearing for note story sessions. The heuristic stays active for body and header/footer flows it was built for. --- .../core/commands/linkedStyleSplitHelpers.js | 6 +++++ .../commands/linkedStyleSplitHelpers.test.js | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.js b/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.js index 03c65bbdc6..e9c0333ee3 100644 --- a/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.js +++ b/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.js @@ -18,6 +18,12 @@ export const isLinkedCharacterStyleId = (editor, styleId) => { export const clearInheritedLinkedStyleId = (attrs, editor, { emptyParagraph = false } = {}) => { if (!emptyParagraph) return attrs; + // SD-3400: note story sessions keep their paragraph style on split. Word's + // FootnoteText/EndnoteText have no w:next, so pressing Enter in a footnote + // continues with the note style; clearing it here made new note paragraphs + // render at the document default size. The clearing heuristic targets body + // heading-like flows, so it stays active for the body and header/footer. + if (editor?.options?.parentEditor && !editor?.options?.isHeaderOrFooter) return attrs; if (!attrs || typeof attrs !== 'object') return attrs; const paragraphProperties = attrs.paragraphProperties; const styleId = paragraphProperties?.styleId; diff --git a/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.test.js b/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.test.js index 048f11f05a..4013d655d4 100644 --- a/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.test.js @@ -104,6 +104,30 @@ describe('linkedStyleSplitHelpers', () => { }); describe('clearInheritedLinkedStyleId', () => { + it('keeps the linked paragraph style inside a note story session (SD-3400)', () => { + // FootnoteText is a linked style (w:link FootnoteTextChar) but has no + // w:next: Word keeps it on Enter inside a footnote. Clearing it made + // new note paragraphs render at the document default size once the + // linked-styles cache was populated. + const editor = { + options: { parentEditor: {}, isHeaderOrFooter: false }, + converter: { + translatedLinkedStyles: { + styles: { + FootnoteText: { styleId: 'FootnoteText', type: 'paragraph', link: 'FootnoteTextChar' }, + }, + }, + }, + }; + const attrs = { + paragraphProperties: { styleId: 'FootnoteText' }, + }; + + const result = clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: true }); + + expect(result).toBe(attrs); + }); + it('removes styleId when it belongs to a linked paragraph style', () => { const editor = { converter: { From b89805a7691083b70eb129e39ebb8cfd7cbf8d15 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 11:56:11 -0300 Subject: [PATCH 28/36] fix(footnotes): stale caret and style loss in note sessions Verified audit of every paragraph-creation, caret, and selection path in note sessions. Four fixes: Caret: 'selectionUpdate' fires before 'update', so the immediate selection-overlay flush ran before the epoch/layout gates armed and the caret rendered against the pre-change paint on every Enter/Backspace. Doc-changing transactions now defer to the post-paint flush; selection only changes keep the immediate path. When the pm-range resolver misses while a rerender is in flight (fresh unpainted paragraph), the overlay now reschedules after paint instead of bridging against stale geometry. Geometry: positions on paragraph-boundary tokens between painted lines snap forward to the next line instead of failing into the offset bridge; positions beyond the painted lines still return null so the post-paint retry handles them. Styles: the remaining paragraph-creation paths that dropped the note paragraph style are now note-session aware: createParagraphNear and liftEmptyBlock re-stamp the source paragraph's properties, joinBackward/ joinForward restore the survivor's style when a join loses it, and clearNodes preserves paragraphProperties. Pinned with a real-keymap burst test (Enter x3, type, Enter x2, type, Backspace x4, type) with the linked-styles cache armed. --- .../editors/v1/core/commands/clearNodes.js | 10 +- .../v1/core/commands/createParagraphNear.js | 18 +++- .../editors/v1/core/commands/joinBackward.js | 18 +++- .../editors/v1/core/commands/joinForward.js | 10 +- .../v1/core/commands/liftEmptyBlock.js | 17 +++- .../core/commands/linkedStyleSplitHelpers.js | 20 ++-- .../v1/core/commands/noteParagraphStyle.js | 42 ++++++++ .../core/commands/noteParagraphStyle.test.js | 96 +++++++++++++++++++ .../presentation-editor/PresentationEditor.ts | 25 ++++- .../selection/VisibleTextOffsetGeometry.ts | 26 ++++- .../tests/PresentationEditor.test.ts | 42 ++++++++ .../tests/VisibleTextOffsetGeometry.test.ts | 25 ++++- 12 files changed, 327 insertions(+), 22 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/commands/noteParagraphStyle.js create mode 100644 packages/super-editor/src/editors/v1/core/commands/noteParagraphStyle.test.js diff --git a/packages/super-editor/src/editors/v1/core/commands/clearNodes.js b/packages/super-editor/src/editors/v1/core/commands/clearNodes.js index eb7950ac20..d0b4de09f1 100644 --- a/packages/super-editor/src/editors/v1/core/commands/clearNodes.js +++ b/packages/super-editor/src/editors/v1/core/commands/clearNodes.js @@ -1,4 +1,5 @@ import { liftTarget } from 'prosemirror-transform'; +import { isNoteStorySession } from './linkedStyleSplitHelpers.js'; /** * Normalize nodes to the default node (paragraph by default). @@ -8,7 +9,7 @@ import { liftTarget } from 'prosemirror-transform'; * it has the highest priority (priority: 1000) and it's loaded first. */ // prettier-ignore -export const clearNodes = () => ({ state, tr, dispatch }) => { +export const clearNodes = () => ({ state, tr, dispatch, editor }) => { const { selection } = tr; const { ranges } = selection; @@ -28,7 +29,12 @@ export const clearNodes = () => ({ state, tr, dispatch }) => { if (node.type.isTextblock) { const { defaultType } = $mappedFrom.parent.contentMatchAt($mappedFrom.index()); - tr.setNodeMarkup(nodeRange.start, defaultType); + // SD-3400: note paragraphs keep their note style (FootnoteText) even + // when normalized — clearing it makes them render at the body size. + const preservedAttrs = isNoteStorySession(editor) + ? { paragraphProperties: node.attrs?.paragraphProperties } + : undefined; + tr.setNodeMarkup(nodeRange.start, defaultType, preservedAttrs); } if (targetLiftDepth || targetLiftDepth === 0) { diff --git a/packages/super-editor/src/editors/v1/core/commands/createParagraphNear.js b/packages/super-editor/src/editors/v1/core/commands/createParagraphNear.js index 7f291c1545..9fc3cb5bd2 100644 --- a/packages/super-editor/src/editors/v1/core/commands/createParagraphNear.js +++ b/packages/super-editor/src/editors/v1/core/commands/createParagraphNear.js @@ -1,9 +1,23 @@ import { createParagraphNear as originalCreateParagraphNear } from 'prosemirror-commands'; +import { isNoteStorySession } from './linkedStyleSplitHelpers.js'; +import { findParagraphDepth, restoreParagraphPropertiesAfterDispatch } from './noteParagraphStyle.js'; /** * Create a paragraph nearby. + * + * SD-3400: the ProseMirror base command creates the new paragraph with + * default (empty) attributes. In a note story session the new paragraph must + * keep the note's paragraph style (FootnoteText/EndnoteText), otherwise it + * renders at the document default size. */ //prettier-ignore -export const createParagraphNear = () => ({ state, dispatch }) => { - return originalCreateParagraphNear(state, dispatch); +export const createParagraphNear = () => ({ state, dispatch, editor }) => { + if (!dispatch || !isNoteStorySession(editor)) { + return originalCreateParagraphNear(state, dispatch); + } + + const sourceDepth = findParagraphDepth(state.selection.$from); + const sourceProps = sourceDepth ? state.selection.$from.node(sourceDepth).attrs?.paragraphProperties : null; + + return originalCreateParagraphNear(state, restoreParagraphPropertiesAfterDispatch(dispatch, sourceProps)); }; diff --git a/packages/super-editor/src/editors/v1/core/commands/joinBackward.js b/packages/super-editor/src/editors/v1/core/commands/joinBackward.js index 983a8233da..a5a66fe194 100644 --- a/packages/super-editor/src/editors/v1/core/commands/joinBackward.js +++ b/packages/super-editor/src/editors/v1/core/commands/joinBackward.js @@ -1,4 +1,6 @@ import { joinBackward as originalJoinBackward } from 'prosemirror-commands'; +import { isNoteStorySession } from './linkedStyleSplitHelpers.js'; +import { findParagraphDepth, restoreParagraphPropertiesAfterDispatch } from './noteParagraphStyle.js'; /** * Join two nodes backward. @@ -14,10 +16,16 @@ import { joinBackward as originalJoinBackward } from 'prosemirror-commands'; * https://prosemirror.net/docs/ref/#commands.joinBackward */ //prettier-ignore -export const joinBackward = () => ({ state, dispatch }) => { +export const joinBackward = () => ({ state, dispatch, editor }) => { const { selection, doc } = state; const { $from } = selection; + // SD-3400: in note sessions, the merged paragraph must keep the note + // paragraph style. PM's join keeps the first paragraph's attrs in simple + // joins, but the deleteBarrier restructuring path can drop them. + const guardedDispatch = (paragraphProps) => + isNoteStorySession(editor) ? restoreParagraphPropertiesAfterDispatch(dispatch, paragraphProps) : dispatch; + if ( !$from.parent.isTextblock || $from.parentOffset > 0 @@ -36,5 +44,11 @@ export const joinBackward = () => ({ state, dispatch }) => { return false; } - return originalJoinBackward(state, dispatch); + // The join survivor is the paragraph BEFORE the cut; its style wins (Word). + const survivorProps = + nodeBefore?.type?.name === 'paragraph' + ? nodeBefore.attrs?.paragraphProperties + : (findParagraphDepth($from) ? $from.node(findParagraphDepth($from)).attrs?.paragraphProperties : null); + + return originalJoinBackward(state, dispatch ? guardedDispatch(survivorProps) : dispatch); }; diff --git a/packages/super-editor/src/editors/v1/core/commands/joinForward.js b/packages/super-editor/src/editors/v1/core/commands/joinForward.js index de7cb81b57..58cac1105a 100644 --- a/packages/super-editor/src/editors/v1/core/commands/joinForward.js +++ b/packages/super-editor/src/editors/v1/core/commands/joinForward.js @@ -1,4 +1,6 @@ import { joinForward as originalJoinForward } from 'prosemirror-commands'; +import { isNoteStorySession } from './linkedStyleSplitHelpers.js'; +import { findParagraphDepth, restoreParagraphPropertiesAfterDispatch } from './noteParagraphStyle.js'; /** * Join two nodes forward. @@ -12,7 +14,13 @@ import { joinForward as originalJoinForward } from 'prosemirror-commands'; * https://prosemirror.net/docs/ref/#commands.joinForward */ //prettier-ignore -export const joinForward = () => ({ state, dispatch }) => { +export const joinForward = () => ({ state, dispatch, editor }) => { + // SD-3400: keep the current paragraph's note style on forward joins. + if (dispatch && isNoteStorySession(editor)) { + const depth = findParagraphDepth(state.selection.$from); + const props = depth ? state.selection.$from.node(depth).attrs?.paragraphProperties : null; + dispatch = restoreParagraphPropertiesAfterDispatch(dispatch, props); + } const { selection, doc } = state; const { $from } = selection; diff --git a/packages/super-editor/src/editors/v1/core/commands/liftEmptyBlock.js b/packages/super-editor/src/editors/v1/core/commands/liftEmptyBlock.js index e2e8d7213a..6c9cff9f57 100644 --- a/packages/super-editor/src/editors/v1/core/commands/liftEmptyBlock.js +++ b/packages/super-editor/src/editors/v1/core/commands/liftEmptyBlock.js @@ -1,9 +1,24 @@ import { liftEmptyBlock as originalLiftEmptyBlock } from 'prosemirror-commands'; +import { isNoteStorySession } from './linkedStyleSplitHelpers.js'; +import { findParagraphDepth, restoreParagraphPropertiesAfterDispatch } from './noteParagraphStyle.js'; /** * If the cursor is in an empty textblock that can be lifted, lift the block. * + * SD-3400: when the base command splits instead of lifting, the resulting + * paragraph can lose its attributes; in note story sessions re-stamp the + * note paragraph style. + * * https://prosemirror.net/docs/ref/#commands.liftEmptyBlock */ //prettier-ignore -export const liftEmptyBlock = () => ({ state, dispatch }) => originalLiftEmptyBlock(state, dispatch); +export const liftEmptyBlock = () => ({ state, dispatch, editor }) => { + if (!dispatch || !isNoteStorySession(editor)) { + return originalLiftEmptyBlock(state, dispatch); + } + + const sourceDepth = findParagraphDepth(state.selection.$from); + const sourceProps = sourceDepth ? state.selection.$from.node(sourceDepth).attrs?.paragraphProperties : null; + + return originalLiftEmptyBlock(state, restoreParagraphPropertiesAfterDispatch(dispatch, sourceProps)); +}; diff --git a/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.js b/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.js index e9c0333ee3..0202770995 100644 --- a/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.js +++ b/packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.js @@ -1,5 +1,14 @@ import { readTranslatedLinkedStyles } from '@core/parts/adapters/styles-read.js'; +/** + * SD-3400: note story sessions (footnote/endnote editing) keep their + * paragraph style across structural edits — Word's FootnoteText/EndnoteText + * have no w:next, so new and merged paragraphs stay note-styled. Header and + * footer stories keep body behavior. + */ +export const isNoteStorySession = (editor) => + Boolean(editor?.options?.parentEditor && !editor?.options?.isHeaderOrFooter); + export const isLinkedParagraphStyleId = (editor, styleId) => { if (!styleId) return false; @@ -18,12 +27,11 @@ export const isLinkedCharacterStyleId = (editor, styleId) => { export const clearInheritedLinkedStyleId = (attrs, editor, { emptyParagraph = false } = {}) => { if (!emptyParagraph) return attrs; - // SD-3400: note story sessions keep their paragraph style on split. Word's - // FootnoteText/EndnoteText have no w:next, so pressing Enter in a footnote - // continues with the note style; clearing it here made new note paragraphs - // render at the document default size. The clearing heuristic targets body - // heading-like flows, so it stays active for the body and header/footer. - if (editor?.options?.parentEditor && !editor?.options?.isHeaderOrFooter) return attrs; + // SD-3400: pressing Enter in a footnote continues with the note style; + // clearing it here made new note paragraphs render at the document default + // size. The clearing heuristic targets body heading-like flows, so it stays + // active for the body and header/footer. + if (isNoteStorySession(editor)) return attrs; if (!attrs || typeof attrs !== 'object') return attrs; const paragraphProperties = attrs.paragraphProperties; const styleId = paragraphProperties?.styleId; diff --git a/packages/super-editor/src/editors/v1/core/commands/noteParagraphStyle.js b/packages/super-editor/src/editors/v1/core/commands/noteParagraphStyle.js new file mode 100644 index 0000000000..ac18b185f2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/noteParagraphStyle.js @@ -0,0 +1,42 @@ +/** + * SD-3400: paragraph-style preservation for note story sessions. + * + * Several ProseMirror base commands (createParagraphNear, liftEmptyBlock, + * joinBackward/joinForward via deleteBarrier) can produce paragraphs with + * default attributes. Inside a footnote/endnote session that drops the + * FootnoteText/EndnoteText style, so the paragraph renders at the document + * default size instead of the note size. These helpers re-stamp the source + * paragraph's `paragraphProperties` onto the affected paragraph after the + * base command runs, in the same transaction. + */ + +/** Depth of the nearest paragraph ancestor at a resolved position, or null. */ +export function findParagraphDepth($pos) { + for (let depth = $pos.depth; depth >= 1; depth -= 1) { + if ($pos.node(depth).type.name === 'paragraph') return depth; + } + return null; +} + +/** + * Wraps a dispatch so that, after the base command builds its transaction, + * the paragraph holding the selection gets `sourceProps` re-stamped when the + * command left it without a styleId. No-op when sourceProps carries no + * styleId or the paragraph kept its own. + */ +export function restoreParagraphPropertiesAfterDispatch(dispatch, sourceProps) { + if (!sourceProps?.styleId) return dispatch; + + return (tr) => { + const $head = tr.selection.$head; + const depth = findParagraphDepth($head); + if (depth) { + const paragraph = $head.node(depth); + if (paragraph.attrs?.paragraphProperties?.styleId == null) { + const pos = $head.before(depth); + tr.setNodeMarkup(pos, undefined, { ...paragraph.attrs, paragraphProperties: sourceProps }); + } + } + dispatch(tr); + }; +} diff --git a/packages/super-editor/src/editors/v1/core/commands/noteParagraphStyle.test.js b/packages/super-editor/src/editors/v1/core/commands/noteParagraphStyle.test.js new file mode 100644 index 0000000000..436310869c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/noteParagraphStyle.test.js @@ -0,0 +1,96 @@ +/** + * SD-3400: paragraphs inside a note story session must NEVER lose their + * paragraph style (FootnoteText), no matter which Enter/Backspace path + * created or merged them. The user-reported corruption appeared after bursts + * of Enter/typing/Backspace once the linked-styles cache populated. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { TextSelection } from 'prosemirror-state'; +import { initTestEditor } from '../../tests/helpers/helpers.js'; +import { handleEnter, handleBackspace } from '../extensions/keymap.js'; + +const NOTE_DOC = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { paragraphProperties: { styleId: 'FootnoteText' } }, + content: [{ type: 'run', content: [{ type: 'text', text: 'First note line' }] }], + }, + ], +}; + +function makeNoteSessionEditor() { + const { editor } = initTestEditor({ loadFromSchema: true, content: NOTE_DOC }); + // Mark as a note story session (what createStoryEditor sets up). + editor.options.parentEditor = {}; + editor.options.isHeaderOrFooter = false; + // Arm the linked-styles cache: FootnoteText IS a linked style in real + // documents (w:link FootnoteTextChar), which is what made the clearing + // heuristic fire once the cache populated mid-session. + editor.converter = { + ...(editor.converter ?? {}), + translatedLinkedStyles: { + styles: { + FootnoteText: { styleId: 'FootnoteText', type: 'paragraph', link: 'FootnoteTextChar' }, + }, + }, + }; + return editor; +} + +function paragraphStyleIds(editor) { + const ids = []; + editor.state.doc.forEach((node) => { + ids.push(node.attrs?.paragraphProperties?.styleId ?? null); + }); + return ids; +} + +function typeText(editor, text) { + editor.dispatch(editor.state.tr.insertText(text)); +} + +describe('note session paragraph style preservation (SD-3400)', () => { + let editor; + + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('keeps FootnoteText through a burst of Enters, typing, and Backspaces', () => { + editor = makeNoteSessionEditor(); + // caret to end of content + const endPos = editor.state.doc.content.size - 2; + editor.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, endPos))); + + // Burst through the REAL keymap chains: Enter x3, type, Enter x2, type, + // Backspace x4, type. + handleEnter(editor); + handleEnter(editor); + handleEnter(editor); + typeText(editor, 'first words'); + handleEnter(editor); + handleEnter(editor); + typeText(editor, 'second words'); + handleBackspace(editor); + handleBackspace(editor); + handleBackspace(editor); + handleBackspace(editor); + typeText(editor, 'tail'); + + const ids = paragraphStyleIds(editor); + expect(ids.length).toBeGreaterThan(1); + expect(ids.every((id) => id === 'FootnoteText')).toBe(true); + }); + + it('keeps FootnoteText when clearNodes normalizes a note paragraph', () => { + editor = makeNoteSessionEditor(); + + editor.commands.clearNodes(); + + expect(paragraphStyleIds(editor)).toEqual(['FootnoteText']); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 786f1afb57..49ee8bb185 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -5162,7 +5162,7 @@ export class PresentationEditor extends EventEmitter { this.#editorInputManager?.clearCellAnchor(); } }; - const handleSelection = () => { + const handleSelection = ({ transaction }: { transaction?: Transaction } = {}) => { // User-initiated selection change — scroll caret/head into view once, except during // pointer drag: EditorInputManager edge auto-scroll must not fight #scrollActiveEndIntoView. if (!this.#editorInputManager?.isDragging) { @@ -5174,7 +5174,13 @@ export class PresentationEditor extends EventEmitter { // setDocEpoch → cancelScheduledRender. Immediate rendering is safe here: // if layout is updating (due to a concurrent doc change), flushNow() // is a no-op and the render will be picked up after layout completes. - this.#scheduleSelectionUpdate({ immediate: true }); + // + // SD-3400: NOT safe for doc-changing transactions. 'selectionUpdate' + // fires BEFORE 'update', so the epoch/layout gates are not armed yet + // and an immediate flush renders the caret against the PRE-change + // paint (visibly stale caret on every Enter/Backspace). Defer those to + // the post-paint flush. + this.#scheduleSelectionUpdate({ immediate: !transaction?.docChanged }); // Update local cursor in awareness for collaboration // This bypasses y-prosemirror's focus check which may fail for hidden PM views this.#updateLocalAwarenessCursor(); @@ -10279,6 +10285,12 @@ export class PresentationEditor extends EventEmitter { return pmRects; } + // Same in-flight-rerender guard as #computeNoteDomCaretRect (SD-3400). + if (this.#renderScheduled || this.#isRerendering || this.#pendingDocChange) { + this.#scheduleSelectionUpdate({ immediate: false }); + return null; + } + const startOffset = this.#measureActiveEditorVisibleTextOffset(Math.min(from, to)); const endOffset = this.#measureActiveEditorVisibleTextOffset(Math.max(from, to)); if (startOffset == null || endOffset == null) { @@ -10341,6 +10353,15 @@ export class PresentationEditor extends EventEmitter { return pmRect; } + // Position not painted yet (fresh paragraph) while a rerender is in + // flight: bridging now would measure STALE paint and the wrong caret + // would stick until the next selection change. Defer to the post-paint + // flush instead (SD-3400). + if (this.#renderScheduled || this.#isRerendering || this.#pendingDocChange) { + this.#scheduleSelectionUpdate({ immediate: false }); + return null; + } + const textOffset = this.#measureActiveEditorVisibleTextOffset(pos); if (textOffset == null) { return null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts index 46acdc4d44..85aedc7fd3 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts @@ -307,13 +307,31 @@ function resolvePmPoint(containers: readonly HTMLElement[], pos: number): Resolv const lines = collectRenderedLineElements(containers); let lineElement: HTMLElement | null = null; + let resolvedPos = pos; + let sawEarlierLine = false; for (const line of lines) { const pmStart = getPmStart(line); const pmEnd = getPmEnd(line); - if (pmStart == null || pmEnd == null || pos < pmStart || pos > pmEnd) { + if (pmStart == null || pmEnd == null) { continue; } + if (pos > pmEnd) { + sawEarlierLine = true; + continue; + } + if (pos < pmStart) { + // Interior structural gap (paragraph boundary tokens between painted + // lines): the position is a valid caret position in the doc, so snap + // forward to this line's start instead of failing into the offset + // bridge. Positions BEFORE the first painted line stay unresolved. + if (sawEarlierLine && !lineElement) { + lineElement = line; + resolvedPos = pmStart; + } + break; + } lineElement = line; + resolvedPos = pos; // Forward affinity: a position at this line's end that also starts the // next line belongs to the next line, so keep scanning while pos == pmEnd. if (pos < pmEnd) { @@ -334,11 +352,11 @@ function resolvePmPoint(containers: readonly HTMLElement[], pos: number): Resolv for (const candidate of leaves) { const pmStart = getPmStart(candidate); const pmEnd = getPmEnd(candidate); - if (pmStart == null || pmEnd == null || pos < pmStart || pos > pmEnd) { + if (pmStart == null || pmEnd == null || resolvedPos < pmStart || resolvedPos > pmEnd) { continue; } leaf = candidate; - if (pos < pmEnd) { + if (resolvedPos < pmEnd) { break; } } @@ -349,7 +367,7 @@ function resolvePmPoint(containers: readonly HTMLElement[], pos: number): Resolv const leafPmStart = getPmStart(leaf) ?? 0; const doc = leaf.ownerDocument ?? document; const walker = doc.createTreeWalker(leaf, NodeFilter.SHOW_TEXT); - let remaining = Math.max(0, pos - leafPmStart); + let remaining = Math.max(0, resolvedPos - leafPmStart); let lastTextNode: Text | null = null; let currentNode = walker.nextNode(); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index ac09c7960a..21ab9961a8 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -4738,6 +4738,48 @@ describe('PresentationEditor', () => { rafSpy.mockRestore(); }); + it('defers the selection overlay render for doc-changing transactions (SD-3400)', async () => { + // Editor emits 'selectionUpdate' BEFORE 'update', so for a transaction + // that changed the doc the epoch/layout gates are not armed yet: an + // immediate flush renders the caret against the PRE-change paint + // (stale caret on every Enter/Backspace). Doc-changing transactions + // must defer to the post-paint flush; selection-only changes keep the + // immediate path (collab-cancellation rationale). + const layoutResult = { + layout: { pages: [] }, + measures: [], + }; + mockIncrementalLayout.mockResolvedValue(layoutResult); + + editor = new PresentationEditor({ + element: container, + documentId: 'test-doc', + }); + + const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ + (Editor as unknown as MockedEditor).mock.results.length - 1 + ].value; + await new Promise((resolve) => setTimeout(resolve, 100)); + + const rafSpy = vi.spyOn(window, 'requestAnimationFrame'); + const onCalls = mockEditorInstance.on as unknown as Mock; + const selectionUpdateCall = onCalls.mock.calls.find((call) => call[0] === 'selectionUpdate'); + const handleSelection = selectionUpdateCall![1] as (payload?: { + transaction?: { docChanged?: boolean }; + }) => void; + + // Doc-changing transaction: must NOT render synchronously (RAF-deferred). + handleSelection({ transaction: { docChanged: true } }); + expect(rafSpy).toHaveBeenCalled(); + + // Selection-only transaction: immediate path, no RAF needed. + rafSpy.mockClear(); + handleSelection({ transaction: { docChanged: false } }); + expect(rafSpy).not.toHaveBeenCalled(); + + rafSpy.mockRestore(); + }); + it('should skip scheduling during rerender (#isRerendering flag)', async () => { const layoutResult = { layout: { pages: [] }, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts index 2f252d6a18..bb01b3bcea 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts @@ -175,10 +175,31 @@ describe('computeCaretRectFromPmPosition (SD-3400 multi-paragraph note caret)', expect(rect).toMatchObject({ pageIndex: 0, x: 50, y: 515, height: 15 }); }); - it('returns null for a position inside a structural gap so callers can fall back', () => { + it('snaps a position inside an interior structural gap forward to the next line (SD-3400)', () => { + // Positions on paragraph-boundary tokens (e.g. 36 in the 34->38 gap) are + // valid caret positions in the session doc. Returning null here forced the + // drift-prone visible-text bridge; snap forward to the next painted line. + const { fragments, textNodeOf } = buildThreeParagraphNote(); + const thankTextNode = textNodeOf('thank you for this'); + + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.startContainer === thankTextNode && this.startOffset === 0) { + return createRect(10, 515, 0, 15); + } + return createRect(0, 0, 0, 0); + }); + + const rect = computeCaretRectFromPmPosition(createGeometryOptions(fragments), 36); + + expect(rect).toMatchObject({ pageIndex: 0, x: 10, y: 515, height: 15 }); + }); + + it('returns null for a position beyond the painted lines so callers can retry after paint', () => { + // A brand-new paragraph that has not painted yet has positions past every + // painted line: that must stay null (the caller reschedules post-paint). const { fragments } = buildThreeParagraphNote(); - expect(computeCaretRectFromPmPosition(createGeometryOptions(fragments), 36)).toBeNull(); + expect(computeCaretRectFromPmPosition(createGeometryOptions(fragments), 70)).toBeNull(); }); it('ignores the pm-less synthetic marker text', () => { From 3fadc818f2c78f29d81e3c76975a43bb3d2ab7b8 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 12:19:02 -0300 Subject: [PATCH 29/36] fix(footnotes): caret resolution against stale painted note ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 360-op real-keystroke fuzzer (typing, Enter, Backspace, arrows, Home/ End over wrapped and empty paragraphs) exposed persistent caret misses: the painter skips repainting unchanged note paragraphs, so their painted data-pm ranges drift after edits shift positions — absolute pm resolution picked lines from neighboring paragraphs whose stale ranges overlapped the selection. Resolve the note caret by block identity instead: find the paragraph's sdBlockId in the session doc, scope resolution to that block's painted fragment, and translate the position into the fragment's coordinate space via the block-start delta (exact for unchanged and fresh blocks). Empty paragraphs anchor at their content start. Painted lines are also sorted by pm range before scanning, since fragments come back in DOM insertion order after incremental repaints. The fuzzer stays as a regression suite (3 deterministic seeds, ground truth computed from the session doc plus visible text, independent of painted pm attributes). --- .../presentation-editor/PresentationEditor.ts | 58 +++++- .../selection/VisibleTextOffsetGeometry.ts | 73 +++++-- .../tests/footnotes/note-typing-fuzz.spec.ts | 187 ++++++++++++++++++ .../tests/footnotes/style-probe.spec.ts | 74 +++++++ 4 files changed, 376 insertions(+), 16 deletions(-) create mode 100644 tests/behavior/tests/footnotes/note-typing-fuzz.spec.ts create mode 100644 tests/behavior/tests/footnotes/style-probe.spec.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 49ee8bb185..d8ba0042f2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -10279,8 +10279,11 @@ export class PresentationEditor extends EventEmitter { pageGap: layout.pageGap ?? this.#getEffectivePageGap(), }; - // Same pm-first strategy as #computeNoteDomCaretRect (SD-3400). - const pmRects = computeSelectionRectsFromPmRangeFromHelper(geometryOptions, from, to); + // Same block-anchored pm-first strategy as #computeNoteDomCaretRect (SD-3400). + const pmRects = computeSelectionRectsFromPmRangeFromHelper(geometryOptions, from, to, { + from: this.#resolveNoteBlockAnchor(from), + to: this.#resolveNoteBlockAnchor(to), + }); if (pmRects != null) { return pmRects; } @@ -10322,6 +10325,46 @@ export class PresentationEditor extends EventEmitter { return selectionToRects(layout, context.blocks, context.measures, from, to, this.#pageGeometryHelper ?? undefined); } + /** + * Anchors a session position to its paragraph block for stale-tolerant + * caret resolution (SD-3400): painted pm ranges of unchanged note + * paragraphs drift after edits, but block identity (sdBlockId) plus the + * block's current first-leaf position let the geometry helper translate + * into the fragment's coordinate space. + */ + #resolveNoteBlockAnchor(pos: number): { sdBlockId: string; currentStart: number } | null { + const doc = this.getActiveEditor()?.state?.doc; + if (!doc || !Number.isFinite(pos)) return null; + try { + const clamped = Math.max(0, Math.min(pos, doc.content.size)); + const $pos = doc.resolve(clamped); + let blockDepth = 0; + for (let depth = $pos.depth; depth >= 1; depth -= 1) { + if ($pos.node(depth).isBlock) blockDepth = depth; + } + if (!blockDepth) return null; + const blockNode = $pos.node(blockDepth); + const sdBlockId = blockNode.attrs?.sdBlockId; + if (typeof sdBlockId !== 'string' || !sdBlockId) return null; + const blockPos = $pos.before(blockDepth); + let currentStart: number | null = null; + doc.nodesBetween(blockPos, blockPos + blockNode.nodeSize, (node, nodePos) => { + if (currentStart != null) return false; + if (node.isInline && (node.isLeaf || node.isText)) { + currentStart = nodePos; + return false; + } + return true; + }); + // Empty paragraph: no inline leaf exists, its only caret position is + // the block's content start. The painted placeholder line anchors there. + if (currentStart == null) currentStart = blockPos + 1; + return { sdBlockId, currentStart }; + } catch { + return null; + } + } + #computeNoteDomCaretRect(context: NoteLayoutContext, pos: number): LayoutRect | null { const layout = this.#layoutState.layout; if (!layout) { @@ -10345,9 +10388,14 @@ export class PresentationEditor extends EventEmitter { pageGap: layout.pageGap ?? this.#getEffectivePageGap(), }; - // Painted note lines carry session-coordinate pm ranges, so resolve the - // caret by pm position first — exact across paragraph boundaries, where - // the visible-text bridge drifts (SD-3400 multi-paragraph note caret). + // Resolve by block identity first (stale-tolerant), then by global pm + // ranges. Painted pm ranges of unchanged note paragraphs drift after + // edits, so absolute resolution alone picks wrong lines (SD-3400). + const anchor = this.#resolveNoteBlockAnchor(pos); + const anchoredRect = anchor ? computeCaretRectFromPmPositionFromHelper(geometryOptions, pos, anchor) : null; + if (anchoredRect) { + return anchoredRect; + } const pmRect = computeCaretRectFromPmPositionFromHelper(geometryOptions, pos); if (pmRect) { return pmRect; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts index 85aedc7fd3..e5abe5b87d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts @@ -29,6 +29,22 @@ export type VisibleTextOffsetGeometryOptions = { pageGap: number; }; +/** + * Anchors a ProseMirror position to its paragraph block so resolution can + * survive stale painted pm ranges (SD-3400). The painter skips repainting + * unchanged note paragraphs, so their data-pm-* attributes drift after + * upstream edits shift positions. Within one unchanged block the ranges stay + * internally consistent, so translating the position by the block-start + * delta (current first-leaf position minus the fragment's first painted + * pmStart) makes resolution exact per block. + */ +export type PmBlockAnchor = { + /** The paragraph's sdBlockId — painted fragment block ids end with it. */ + sdBlockId: string; + /** Current document position of the block's first inline leaf. */ + currentStart: number; +}; + /** * Measures a visible-text offset within a DOM root from a concrete DOM boundary. * @@ -300,21 +316,54 @@ type ResolvedPmPoint = { * Returns null when no painted line covers the position (e.g., a structural * gap or unpainted content) so callers can fall back to the offset bridge. */ -function resolvePmPoint(containers: readonly HTMLElement[], pos: number): ResolvedPmPoint | null { +function resolvePmPoint( + containers: readonly HTMLElement[], + pos: number, + anchor?: PmBlockAnchor | null, +): ResolvedPmPoint | null { if (!Number.isFinite(pos)) { return null; } - const lines = collectRenderedLineElements(containers); + // Block-anchored resolution: scope to the paragraph's own fragment(s) and + // translate the position into the fragment's painted coordinate space. + if (anchor?.sdBlockId) { + const blockContainers = containers.filter((el) => + (el.getAttribute('data-block-id') ?? '').endsWith(anchor.sdBlockId), + ); + if (blockContainers.length) { + const blockLines = collectRenderedLineElements(blockContainers) + .map((line) => ({ line, pmStart: getPmStart(line), pmEnd: getPmEnd(line) })) + .filter((entry): entry is { line: HTMLElement; pmStart: number; pmEnd: number } => + entry.pmStart != null && entry.pmEnd != null) + .sort((a, b) => a.pmStart - b.pmStart || a.pmEnd - b.pmEnd); + if (blockLines.length) { + const delta = anchor.currentStart - blockLines[0].pmStart; + const translated = Math.max( + blockLines[0].pmStart, + Math.min(pos - delta, blockLines[blockLines.length - 1].pmEnd), + ); + const resolved = resolvePmPoint(blockContainers, translated); + if (resolved) { + return resolved; + } + } + } + return null; + } + + // Painted fragments come back in DOM insertion order, which after + // incremental repaints is NOT document order — sort by pm range so the + // forward-affinity scan and the gap snap pick the right line (SD-3400). + const lines = collectRenderedLineElements(containers) + .map((line) => ({ line, pmStart: getPmStart(line), pmEnd: getPmEnd(line) })) + .filter((entry): entry is { line: HTMLElement; pmStart: number; pmEnd: number } => + entry.pmStart != null && entry.pmEnd != null) + .sort((a, b) => a.pmStart - b.pmStart || a.pmEnd - b.pmEnd); let lineElement: HTMLElement | null = null; let resolvedPos = pos; let sawEarlierLine = false; - for (const line of lines) { - const pmStart = getPmStart(line); - const pmEnd = getPmEnd(line); - if (pmStart == null || pmEnd == null) { - continue; - } + for (const { line, pmStart, pmEnd } of lines) { if (pos > pmEnd) { sawEarlierLine = true; continue; @@ -404,8 +453,9 @@ function resolvePmPoint(containers: readonly HTMLElement[], pos: number): Resolv export function computeCaretRectFromPmPosition( options: VisibleTextOffsetGeometryOptions, pos: number, + anchor?: PmBlockAnchor | null, ): LayoutRect | null { - const point = resolvePmPoint(options.containers, pos); + const point = resolvePmPoint(options.containers, pos, anchor); if (!point) { return null; } @@ -446,6 +496,7 @@ export function computeSelectionRectsFromPmRange( options: VisibleTextOffsetGeometryOptions, from: number, to: number, + anchors?: { from?: PmBlockAnchor | null; to?: PmBlockAnchor | null }, ): LayoutRect[] | null { if (!Number.isFinite(from) || !Number.isFinite(to)) { return null; @@ -457,8 +508,8 @@ export function computeSelectionRectsFromPmRange( return []; } - const startPoint = resolvePmPoint(options.containers, startPos); - const endPoint = resolvePmPoint(options.containers, endPos); + const startPoint = resolvePmPoint(options.containers, startPos, from <= to ? anchors?.from : anchors?.to); + const endPoint = resolvePmPoint(options.containers, endPos, from <= to ? anchors?.to : anchors?.from); if (!startPoint || !endPoint) { return null; } diff --git a/tests/behavior/tests/footnotes/note-typing-fuzz.spec.ts b/tests/behavior/tests/footnotes/note-typing-fuzz.spec.ts new file mode 100644 index 0000000000..5e2f7985b5 --- /dev/null +++ b/tests/behavior/tests/footnotes/note-typing-fuzz.spec.ts @@ -0,0 +1,187 @@ +/** + * SD-3400 fuzzer: drives a long randomized (seeded) sequence of REAL + * keystrokes inside a note session and asserts after every step that + * (a) no paragraph ever loses its FootnoteText style and + * (b) once paint settles, the caret overlay sits on the painted line that + * contains the selection head. + */ +import { test, expect } from '../../fixtures/superdoc.js'; +import { BASIC_FOOTNOTES_DOC_PATH } from '../../helpers/story-fixtures.js'; + +test.use({ config: { showCaret: true, showSelection: true } }); +test.setTimeout(240_000); + +// Deterministic PRNG so failures replay exactly. +function mulberry32(seed: number) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const WORDS = ['sdfsadfsd', 'asdf', 'sadf', 'dsfadsfad', 'fasd', 'sdafasdfasdfsdaf', 'x']; + +for (const seed of [0x5d3400, 0xbeef01, 0xc0ffee]) { +test(`fuzz(seed=${seed}): real keystroke ops never lose the note style or the caret`, async ({ superdoc }) => { + await superdoc.loadDocument(BASIC_FOOTNOTES_DOC_PATH); + await superdoc.waitForStable(); + + const note = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); + await note.scrollIntoViewIfNeeded(); + const box = await note.boundingBox(); + await superdoc.page.mouse.dblclick(box!.x + 40, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await superdoc.page.keyboard.press('End'); + + // Seed a LONG paragraph so the painted note has wrapped lines (the + // user's screenshots show typing inside wrapped continuation lines). + await superdoc.page.keyboard.press('Enter'); + await superdoc.page.keyboard.type( + 'dsfsadfsdafasdfdasfdsafasdfasdfsdafdsafdsafsdfdasfdsafsdadsaf sdfsadfsdfsdf sdf sdfs dfsdf sdf sdf sdfaf dsfadsfad sfasdfasdfas dfdsf asdfasdfas sdafasdfasdfsdaf asdf sadf', + ); + await superdoc.waitForStable(600); + + const rand = mulberry32(seed); + const ops: string[] = []; + + const checkStyles = async (opLog: string) => { + const bad = await superdoc.page.evaluate(() => { + const sed = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const out: string[] = []; + sed?.state?.doc?.forEach((n: any) => { + if ((n.attrs?.paragraphProperties?.styleId ?? null) !== 'FootnoteText') { + out.push(`${n.attrs?.paragraphProperties?.styleId ?? 'NONE'}:${(n.textContent || '').slice(0, 12)}`); + } + }); + return out; + }); + expect(bad, `style lost after ops: ${opLog}`).toEqual([]); + }; + + const checkCaret = async (opLog: string) => { + const evalCheck = () => + superdoc.page.evaluate(() => { + const sed = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const doc = sed?.state?.doc; + const head = sed?.state?.selection?.head ?? -1; + if (!doc || head < 0) return { ok: false, why: 'no editor', head }; + const caret = document.querySelector('.presentation-editor__selection-caret'); + const cr = caret?.getBoundingClientRect(); + if (!cr || cr.height === 0) return { ok: false, why: 'no caret', head }; + + // Ground truth from the SESSION doc: block identity + local text offset. + const $pos = doc.resolve(Math.min(head, doc.content.size)); + let blockDepth = 0; + for (let d = $pos.depth; d >= 1; d -= 1) if ($pos.node(d).isBlock) blockDepth = d; + if (!blockDepth) return { ok: true, why: 'no block', head }; + const blockNode = $pos.node(blockDepth); + const sdBlockId = blockNode.attrs?.sdBlockId ?? ''; + const blockPos = $pos.before(blockDepth); + let firstLeaf: number | null = null; + doc.nodesBetween(blockPos, blockPos + blockNode.nodeSize, (n: any, p: number) => { + if (firstLeaf != null) return false; + if (n.isInline && (n.isLeaf || n.isText)) { firstLeaf = p; return false; } + return true; + }); + const localOff = Math.max(0, head - (firstLeaf ?? head)); + + const fragments = Array.from( + document.querySelectorAll(`[data-block-id$="${sdBlockId}"]`), + ) as HTMLElement[]; + if (!fragments.length) return { ok: true, why: 'fragment unpainted', head }; + const lines = fragments + .flatMap((f) => Array.from(f.querySelectorAll('.superdoc-line')) as HTMLElement[]) + .map((l) => ({ top: Math.round(l.getBoundingClientRect().top), el: l })) + .sort((a, b) => a.top - b.top); + if (!lines.length) return { ok: true, why: 'no lines', head }; + + // cumulative visible text per line (pm-attr leaf spans only; the + // synthetic marker span carries no pm attrs). + let cum = 0; + const acceptableTops: number[] = []; + for (const { top, el } of lines) { + const len = (Array.from(el.querySelectorAll('[data-pm-start][data-pm-end]')) as HTMLElement[]) + .filter((sp) => !sp.querySelector('[data-pm-start]')) + .reduce((a, sp) => a + (sp.textContent?.length ?? 0), 0); + // boundary tolerance: wrap points trim a space, so allow +-1 char + if (localOff >= cum - 1 && localOff <= cum + len + 1) acceptableTops.push(top); + cum += len; + } + if (!acceptableTops.length) acceptableTops.push(lines[lines.length - 1].top); + const ok = acceptableTops.some((t) => Math.abs(cr.top - t) < 4); + const fragDump = fragments.map((f) => ({ + top: Math.round(f.getBoundingClientRect().top), + id: (f.getAttribute('data-block-id') ?? '').slice(9, 22), + lines: (Array.from(f.querySelectorAll('.superdoc-line')) as HTMLElement[]).map((l) => ({ + s: l.dataset.pmStart, + e: l.dataset.pmEnd, + top: Math.round(l.getBoundingClientRect().top), + text: (l.textContent ?? '').slice(0, 10), + })), + })); + return { + ok, + why: `caret ${Math.round(cr.top)} not in block ${sdBlockId.slice(0, 8)} lines [${acceptableTops.join(',')}] localOff=${localOff} head=${head} firstLeaf=${firstLeaf} frags=${JSON.stringify(fragDump)}`, + head, + }; + }); + + const res = await evalCheck(); + if (!res.ok) { + await superdoc.page.waitForTimeout(700); + const recheck = await evalCheck(); + expect(recheck.ok, `caret off after ops: ${opLog} :: first(${res.why}) recheck(PERSISTENT ${recheck.why}) head=${recheck.head}`).toBe(true); + console.log(`TRANSIENT caret mismatch after: ${opLog} :: ${res.why}`); + } + }; + + for (let i = 0; i < 120; i += 1) { + const r = rand(); + let op: string; + if (r < 0.35) { + const word = WORDS[Math.floor(rand() * WORDS.length)]; + op = `type:${word}`; + await superdoc.page.keyboard.type(word + (rand() < 0.5 ? ' ' : '')); + } else if (r < 0.55) { + op = 'Enter'; + await superdoc.page.keyboard.press('Enter'); + } else if (r < 0.7) { + op = 'Backspace'; + await superdoc.page.keyboard.press('Backspace'); + } else if (r < 0.78) { + op = 'ArrowUp'; + await superdoc.page.keyboard.press('ArrowUp'); + } else if (r < 0.86) { + op = 'ArrowDown'; + await superdoc.page.keyboard.press('ArrowDown'); + } else if (r < 0.92) { + op = 'ArrowLeft'; + await superdoc.page.keyboard.press('ArrowLeft'); + } else if (r < 0.96) { + op = 'End'; + await superdoc.page.keyboard.press('End'); + } else { + op = 'Home'; + await superdoc.page.keyboard.press('Home'); + } + ops.push(op); + + // Style integrity is checked EVERY step (cheap). + await checkStyles(ops.slice(-12).join(' > ')); + + // Caret correctness checked every 5 steps after settle (RAF paint). + if (i % 5 === 4) { + await superdoc.waitForStable(400); + await checkCaret(ops.slice(-12).join(' > ')); + } + } + + // Final full settle check. + await superdoc.waitForStable(800); + await checkCaret(ops.slice(-15).join(' > ')); + await checkStyles('final'); +}); +} diff --git a/tests/behavior/tests/footnotes/style-probe.spec.ts b/tests/behavior/tests/footnotes/style-probe.spec.ts new file mode 100644 index 0000000000..0feadd8d93 --- /dev/null +++ b/tests/behavior/tests/footnotes/style-probe.spec.ts @@ -0,0 +1,74 @@ +/** Temporary probe — paste and undo paths inside a note session. */ +import { test, expect } from '../../fixtures/superdoc.js'; +import { BASIC_FOOTNOTES_DOC_PATH } from '../../helpers/story-fixtures.js'; + +test.use({ config: { showCaret: true, showSelection: true } }); + +async function openNote(superdoc: any) { + await superdoc.loadDocument(BASIC_FOOTNOTES_DOC_PATH); + await superdoc.waitForStable(); + const note = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); + await note.scrollIntoViewIfNeeded(); + const box = await note.boundingBox(); + await superdoc.page.mouse.dblclick(box!.x + 60, box!.y + box!.height / 2); + await superdoc.waitForStable(); +} + +const dump = (superdoc: any) => + superdoc.page.evaluate(() => { + const sed = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const out: string[] = []; + sed?.state?.doc?.forEach((n: any) => { + out.push(`${n.attrs?.paragraphProperties?.styleId ?? 'NONE'}:${(n.textContent || '~').slice(0, 8)}`); + }); + return out.join(' | '); + }); + +test('probe: paste multi-paragraph content inside a note', async ({ superdoc }) => { + await openNote(superdoc); + // Build two paragraphs, select all, copy, then paste at the end twice. + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.press('Enter'); + await superdoc.page.keyboard.type('copy me'); + await superdoc.waitForStable(400); + await superdoc.page.keyboard.press('ControlOrMeta+a'); + await superdoc.page.keyboard.press('ControlOrMeta+c'); + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.press('Enter'); + await superdoc.page.keyboard.press('ControlOrMeta+v'); + await superdoc.waitForStable(800); + console.log('AFTER-PASTE:', await dump(superdoc)); + + const sizes = await superdoc.page.evaluate(() => + Array.from(document.querySelectorAll('[data-block-id^="footnote-1-"] .superdoc-line')) + .map( + (l) => + `${(l.textContent ?? '~').slice(0, 8)}=${ + Array.from(l.querySelectorAll('span')) + .map((s) => (s as HTMLElement).style.fontSize) + .filter(Boolean)[0] ?? '?' + }`, + ) + .join(' | '), + ); + console.log('PAINTED:', sizes); + expect(true).toBe(true); +}); + +test('probe: undo and redo around splits inside a note', async ({ superdoc }) => { + await openNote(superdoc); + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.press('Enter'); + await superdoc.page.keyboard.type('one'); + await superdoc.page.keyboard.press('Enter'); + await superdoc.page.keyboard.type('two'); + await superdoc.waitForStable(400); + await superdoc.page.keyboard.press('ControlOrMeta+z'); + await superdoc.page.keyboard.press('ControlOrMeta+z'); + await superdoc.waitForStable(400); + await superdoc.page.keyboard.press('ControlOrMeta+Shift+z'); + await superdoc.page.keyboard.press('ControlOrMeta+Shift+z'); + await superdoc.waitForStable(600); + console.log('AFTER-UNDO-REDO:', await dump(superdoc)); + expect(true).toBe(true); +}); From e12313a87e141061e0cf9d3874856a1464b4843c Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 12:19:56 -0300 Subject: [PATCH 30/36] chore: remove temporary style probe spec --- .../tests/footnotes/style-probe.spec.ts | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 tests/behavior/tests/footnotes/style-probe.spec.ts diff --git a/tests/behavior/tests/footnotes/style-probe.spec.ts b/tests/behavior/tests/footnotes/style-probe.spec.ts deleted file mode 100644 index 0feadd8d93..0000000000 --- a/tests/behavior/tests/footnotes/style-probe.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** Temporary probe — paste and undo paths inside a note session. */ -import { test, expect } from '../../fixtures/superdoc.js'; -import { BASIC_FOOTNOTES_DOC_PATH } from '../../helpers/story-fixtures.js'; - -test.use({ config: { showCaret: true, showSelection: true } }); - -async function openNote(superdoc: any) { - await superdoc.loadDocument(BASIC_FOOTNOTES_DOC_PATH); - await superdoc.waitForStable(); - const note = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); - await note.scrollIntoViewIfNeeded(); - const box = await note.boundingBox(); - await superdoc.page.mouse.dblclick(box!.x + 60, box!.y + box!.height / 2); - await superdoc.waitForStable(); -} - -const dump = (superdoc: any) => - superdoc.page.evaluate(() => { - const sed = (window as any).editor?.presentationEditor?.getActiveEditor?.(); - const out: string[] = []; - sed?.state?.doc?.forEach((n: any) => { - out.push(`${n.attrs?.paragraphProperties?.styleId ?? 'NONE'}:${(n.textContent || '~').slice(0, 8)}`); - }); - return out.join(' | '); - }); - -test('probe: paste multi-paragraph content inside a note', async ({ superdoc }) => { - await openNote(superdoc); - // Build two paragraphs, select all, copy, then paste at the end twice. - await superdoc.page.keyboard.press('End'); - await superdoc.page.keyboard.press('Enter'); - await superdoc.page.keyboard.type('copy me'); - await superdoc.waitForStable(400); - await superdoc.page.keyboard.press('ControlOrMeta+a'); - await superdoc.page.keyboard.press('ControlOrMeta+c'); - await superdoc.page.keyboard.press('End'); - await superdoc.page.keyboard.press('Enter'); - await superdoc.page.keyboard.press('ControlOrMeta+v'); - await superdoc.waitForStable(800); - console.log('AFTER-PASTE:', await dump(superdoc)); - - const sizes = await superdoc.page.evaluate(() => - Array.from(document.querySelectorAll('[data-block-id^="footnote-1-"] .superdoc-line')) - .map( - (l) => - `${(l.textContent ?? '~').slice(0, 8)}=${ - Array.from(l.querySelectorAll('span')) - .map((s) => (s as HTMLElement).style.fontSize) - .filter(Boolean)[0] ?? '?' - }`, - ) - .join(' | '), - ); - console.log('PAINTED:', sizes); - expect(true).toBe(true); -}); - -test('probe: undo and redo around splits inside a note', async ({ superdoc }) => { - await openNote(superdoc); - await superdoc.page.keyboard.press('End'); - await superdoc.page.keyboard.press('Enter'); - await superdoc.page.keyboard.type('one'); - await superdoc.page.keyboard.press('Enter'); - await superdoc.page.keyboard.type('two'); - await superdoc.waitForStable(400); - await superdoc.page.keyboard.press('ControlOrMeta+z'); - await superdoc.page.keyboard.press('ControlOrMeta+z'); - await superdoc.waitForStable(400); - await superdoc.page.keyboard.press('ControlOrMeta+Shift+z'); - await superdoc.page.keyboard.press('ControlOrMeta+Shift+z'); - await superdoc.waitForStable(600); - console.log('AFTER-UNDO-REDO:', await dump(superdoc)); - expect(true).toBe(true); -}); From 806e39b1ddc6397518ea2fdb098e86d1f9277b2e Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 12:34:57 -0300 Subject: [PATCH 31/36] fix(footnotes): arrow navigation skipping lines in note sessions ArrowUp/ArrowDown in a note validated their hit test against the adjacent painted line's data-pm range and, on mismatch, binary-searched inside that range. Painted ranges of unchanged note paragraphs drift after edits above them (the painter skips repainting), so the stale window resolved to a position one line away and the caret skipped lines (user repro: ArrowUp jumped two lines at once). Translate the adjacent line's range into current session coordinates before the plausibility check and the goal-X fallback, anchoring on the line's paragraph block (sdBlockId lookup in the live doc, shifted by the fragment-start delta). Home/End boundary resolution gets the same translation. Pinned with a behavior test that drifts the painted ranges by editing paragraph 1 and then walks ArrowUp from the bottom asserting exactly one visual line per press. --- .../vertical-navigation.js | 70 +++++++++++++++++-- .../footnotes/footnote-interactions.spec.ts | 67 ++++++++++++++++++ 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js index 8cdca87b5b..ce2e05536a 100644 --- a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js @@ -262,12 +262,68 @@ function resolveLineBoundaryPosition(editor, selection, key) { const lineEl = findLineElementAtPoint(doc, caretX, caretY); if (!lineEl) return null; - const pmStart = Number(lineEl.dataset?.pmStart); - const pmEnd = Number(lineEl.dataset?.pmEnd); + let pmStart = Number(lineEl.dataset?.pmStart); + let pmEnd = Number(lineEl.dataset?.pmEnd); if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd)) return null; + // SD-3400: translate stale note ranges into current coordinates. + ({ pmStart, pmEnd } = translateStaleNoteLineRange(editor, lineEl, pmStart, pmEnd)); return key === 'Home' ? pmStart : pmEnd; } +/** + * SD-3400: painted pm ranges of unchanged note paragraphs drift after edits + * (the painter skips repainting them), so the adjacent line's data-pm range + * can be stale. Translate it into CURRENT session coordinates by anchoring on + * the line's paragraph block: find the block in the live doc by sdBlockId and + * shift the range by (current block content start - fragment first pmStart). + * Returns the input range unchanged when translation is not applicable. + * + * @param {Object} editor + * @param {Element} lineEl + * @param {number} pmStart + * @param {number} pmEnd + * @returns {{ pmStart: number, pmEnd: number }} + */ +function translateStaleNoteLineRange(editor, lineEl, pmStart, pmEnd) { + const isNoteSession = Boolean(editor?.options?.parentEditor && !editor?.options?.isHeaderOrFooter); + if (!isNoteSession) return { pmStart, pmEnd }; + + const fragEl = lineEl.closest?.('[data-block-id]'); + const blockIdAttr = fragEl?.getAttribute?.('data-block-id') ?? ''; + const doc = editor.state?.doc; + if (!fragEl || !blockIdAttr || !doc) return { pmStart, pmEnd }; + + // Anchor: the smallest painted pmStart across the fragment's lines. + let fragmentFirstStart = Infinity; + for (const line of fragEl.querySelectorAll('.superdoc-line[data-pm-start]')) { + const start = Number(line.dataset?.pmStart); + if (Number.isFinite(start)) fragmentFirstStart = Math.min(fragmentFirstStart, start); + } + if (!Number.isFinite(fragmentFirstStart)) return { pmStart, pmEnd }; + + let delta = null; + doc.descendants((node, pos) => { + if (delta != null) return false; + if (!node.isBlock) return true; + const id = node.attrs?.sdBlockId; + if (typeof id !== 'string' || !id || !blockIdAttr.endsWith(id)) return true; + let firstLeaf = null; + node.descendants((child, childPos) => { + if (firstLeaf != null) return false; + if (child.isInline && (child.isLeaf || child.isText)) { + firstLeaf = pos + 1 + childPos; + return false; + } + return true; + }); + const currentStart = firstLeaf ?? pos + 1; + delta = currentStart - fragmentFirstStart; + return false; + }); + if (delta == null || delta === 0) return { pmStart, pmEnd }; + return { pmStart: pmStart + delta, pmEnd: pmEnd + delta }; +} + /** * Finds the adjacent line center Y in client space and associated page index. * Also returns the PM position range from the line's data attributes so that @@ -295,9 +351,13 @@ function getAdjacentLineClientTarget(editor, coords, direction) { const clientY = rect.top + rect.height / 2; if (!Number.isFinite(clientY)) return null; - // Read PM position range from data attributes for layout-based fallback - const pmStart = Number(adjacentLine.dataset?.pmStart); - const pmEnd = Number(adjacentLine.dataset?.pmEnd); + // Read PM position range from data attributes for layout-based fallback. + // SD-3400: translate stale note ranges into current coordinates. + let pmStart = Number(adjacentLine.dataset?.pmStart); + let pmEnd = Number(adjacentLine.dataset?.pmEnd); + if (Number.isFinite(pmStart) && Number.isFinite(pmEnd)) { + ({ pmStart, pmEnd } = translateStaleNoteLineRange(editor, adjacentLine, pmStart, pmEnd)); + } // Read direction from the visual DOM — DomPainter sets dir="rtl" on RTL lines // using fully resolved properties (style cascade, not just inline attrs). diff --git a/tests/behavior/tests/footnotes/footnote-interactions.spec.ts b/tests/behavior/tests/footnotes/footnote-interactions.spec.ts index aa0f6bce2f..11ec72218a 100644 --- a/tests/behavior/tests/footnotes/footnote-interactions.spec.ts +++ b/tests/behavior/tests/footnotes/footnote-interactions.spec.ts @@ -242,3 +242,70 @@ test('arrow navigation and caret tracking across paragraphs in a note session', expect(text).toContain('tail'); expect(text.find((t: string) => t.includes('middletail') || t.includes('tailmiddle'))).toBeUndefined(); }); + +test('ArrowUp walks one visual line at a time after edits drift painted ranges', async ({ superdoc }) => { + // SD-3400: the painter skips repainting unchanged note paragraphs, so + // their painted pm ranges drift after edits above them. Vertical arrow + // navigation validated its hit against those stale ranges and skipped + // lines (user repro: ArrowUp jumped two lines). + await superdoc.loadDocument(BASIC_FOOTNOTES_DOC_PATH); + await superdoc.waitForStable(); + + const note = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); + await note.scrollIntoViewIfNeeded(); + const box = await note.boundingBox(); + await superdoc.page.mouse.dblclick(box!.x + 40, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ kind: 'story', storyType: 'footnote', noteId: '1' }); + + // Build 6 paragraphs. + await superdoc.page.keyboard.press('End'); + for (const line of ['sdfasdfasdfdsfdas', 'sadfsadfsdafdsafasd dsfdasf dsf', 'asdfsadf sdaf sdfs', 'sdfsdf sdf sdfsd fsdfd', 'sdfsdafsadfssdfdsaf']) { + await superdoc.page.keyboard.press('Enter'); + await superdoc.page.keyboard.type(line); + } + await superdoc.waitForStable(800); + + // Edit paragraph 1 (shifts all later paragraphs' positions; the painter + // keeps their old painted ranges). + await superdoc.page.evaluate(() => { + const sed = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const TS = sed.state.selection.constructor; + sed.view.dispatch(sed.state.tr.setSelection(TS.create(sed.state.doc, 3))); + }); + await superdoc.page.keyboard.type('pri'); + await superdoc.waitForStable(800); + + // Caret to the very end, then walk up one line per press. + await superdoc.page.evaluate(() => { + const sed = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const TS = sed.state.selection.constructor; + sed.view.dispatch(sed.state.tr.setSelection(TS.create(sed.state.doc, sed.state.doc.content.size - 2))); + }); + await superdoc.waitForStable(600); + + const readCaretTop = () => + superdoc.page.evaluate(() => { + const cr = document.querySelector('.presentation-editor__selection-caret')?.getBoundingClientRect(); + return cr ? Math.round(cr.top) : null; + }); + const lineTops = await superdoc.page.evaluate(() => + (Array.from(document.querySelectorAll('[data-block-id^="footnote-1-"] .superdoc-line')) as HTMLElement[]) + .map((l) => Math.round(l.getBoundingClientRect().top)) + .sort((a, b) => a - b), + ); + expect(lineTops.length).toBe(6); + + let currentTop = await readCaretTop(); + expect(currentTop).toBe(lineTops[5]); + for (let expectedIndex = 4; expectedIndex >= 0; expectedIndex -= 1) { + await superdoc.page.keyboard.press('ArrowUp'); + await superdoc.waitForStable(500); + const top = await readCaretTop(); + expect(top, `ArrowUp should land on line ${expectedIndex} (top ${lineTops[expectedIndex]}), got ${top}`).toBe( + lineTops[expectedIndex], + ); + } +}); From ff9207c2700b1717863f8c8be3049d84cfb92cef Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 12:49:28 -0300 Subject: [PATCH 32/36] fix(painter): refresh position attributes on reused story fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the SD-3400 note caret/selection/arrow family: the painter keeps reused fragments' data-pm attributes fresh through the body transaction mapping, but story fragments (notes, headers/footers) were excluded because the body mapping is the wrong coordinate space — so their painted positions went permanently stale after any edit above them, and every DOM-based consumer (caret overlay, selection rects, vertical arrow navigation, click mapping) read wrong positions. The resolved paint item carries fresh story positions every paint, so reused story fragments now get their attributes shifted by the delta between the fragment's fresh first-line position and the painted one — exact for unchanged blocks and for continuation fragments. The fuzzer now asserts the direct invariant: painted note line ranges never overlap once paint settles. --- .../painters/dom/src/renderer.ts | 67 +++++++++++++++++++ .../tests/footnotes/note-typing-fuzz.spec.ts | 21 ++++++ 2 files changed, 88 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index d0817e2a1c..7cd70ee97b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -44,6 +44,7 @@ import type { ListBlock, } from '@superdoc/contracts'; import { + computeLinePmRange, LAYOUT_BOUNDARY_SCHEMA, buildLayoutSourceIdentityForFragment, expandRunsForInlineNewlines, @@ -2442,6 +2443,14 @@ export class DomPainter { pageEl.replaceChild(replacement, current.element); current.element = replacement; current.signature = resolvedSig; + } else if (isNonBodyStoryBlockId(fragment.blockId)) { + // Story fragments (notes, headers/footers) use story-local positions: + // the body transaction mapping does not apply, but the resolved item + // carries FRESH story positions every paint. Shift the painted + // attributes by the fresh-vs-painted delta so reused fragments never + // serve stale positions (SD-3400: stale note ranges broke caret, + // selection, and arrow navigation downstream). + this.updateStoryPositionAttributes(current.element, resolvedItem); } else if (this.currentMapping) { // Fragment NOT rebuilt - update position attributes to reflect document changes this.updatePositionAttributes(current.element, this.currentMapping); @@ -2489,6 +2498,64 @@ export class DomPainter { * using the transaction's mapping. Skips header/footer content (separate PM coordinate space). * Also skips fragments that end before the edit point (their positions don't change). */ + /** + * Refreshes data-pm-start/data-pm-end on a REUSED story fragment from the + * fresh resolved item. Story positions are local to their story document, + * so the body transaction mapping cannot update them; instead the uniform + * shift between the fresh first position and the painted one is applied. + * Exact for unchanged blocks (positions inside one block shift uniformly). + */ + private updateStoryPositionAttributes(fragmentEl: HTMLElement, resolvedItem: ResolvedPaintItem | undefined): void { + if (!resolvedItem || resolvedItem.kind !== 'fragment') return; + + // Fragment-scoped fresh landmark: the pm start of THIS fragment's first + // line (matches what render-line stamps as the first painted attribute, + // including continuation fragments that start mid-block). + let freshStart: number | undefined; + const block = 'block' in resolvedItem ? resolvedItem.block : undefined; + const firstLine = 'content' in resolvedItem ? resolvedItem.content?.lines?.[0]?.line : undefined; + if (block && firstLine) { + const range = computeLinePmRange(block, firstLine); + if (typeof range.pmStart === 'number' && Number.isFinite(range.pmStart)) { + freshStart = range.pmStart; + } + } + if (freshStart == null && block) { + const runs = (block as { runs?: Array<{ pmStart?: number | null }> }).runs; + if (Array.isArray(runs)) { + for (const run of runs) { + if (typeof run?.pmStart === 'number' && Number.isFinite(run.pmStart)) { + freshStart = run.pmStart; + break; + } + } + } + } + if (freshStart == null || !Number.isFinite(freshStart)) return; + + const elements = [fragmentEl, ...Array.from(fragmentEl.querySelectorAll('[data-pm-start], [data-pm-end]'))]; + let paintedStart = Infinity; + for (const el of elements) { + const start = Number(el.dataset.pmStart); + if (Number.isFinite(start)) paintedStart = Math.min(paintedStart, start); + } + if (!Number.isFinite(paintedStart)) return; + + const delta = freshStart - paintedStart; + if (delta === 0) return; + + for (const el of elements) { + const start = Number(el.dataset.pmStart); + if (el.dataset.pmStart !== undefined && el.dataset.pmStart !== '' && Number.isFinite(start)) { + el.dataset.pmStart = String(start + delta); + } + const end = Number(el.dataset.pmEnd); + if (el.dataset.pmEnd !== undefined && el.dataset.pmEnd !== '' && Number.isFinite(end)) { + el.dataset.pmEnd = String(end + delta); + } + } + } + private updatePositionAttributes(fragmentEl: HTMLElement, mapping: PositionMapping): void { // Skip header/footer elements (they use a separate PM coordinate space) if (fragmentEl.closest('.superdoc-page-header, .superdoc-page-footer')) { diff --git a/tests/behavior/tests/footnotes/note-typing-fuzz.spec.ts b/tests/behavior/tests/footnotes/note-typing-fuzz.spec.ts index 5e2f7985b5..67727fdd42 100644 --- a/tests/behavior/tests/footnotes/note-typing-fuzz.spec.ts +++ b/tests/behavior/tests/footnotes/note-typing-fuzz.spec.ts @@ -61,6 +61,26 @@ test(`fuzz(seed=${seed}): real keystroke ops never lose the note style or the ca expect(bad, `style lost after ops: ${opLog}`).toEqual([]); }; + // Root-fix invariant (SD-3400): painted note line pm ranges must never + // overlap across paragraphs once paint settles — the painter refreshes + // position attributes on reused story fragments. + const checkPaintedRanges = async (opLog: string) => { + const overlaps = await superdoc.page.evaluate(() => { + const ranges = (Array.from( + document.querySelectorAll('[data-block-id^="footnote-1-"] .superdoc-line[data-pm-start][data-pm-end]'), + ) as HTMLElement[]) + .map((l) => ({ s: Number(l.dataset.pmStart), e: Number(l.dataset.pmEnd) })) + .filter((r) => Number.isFinite(r.s) && Number.isFinite(r.e)) + .sort((a, b) => a.s - b.s); + const bad: string[] = []; + for (let i = 1; i < ranges.length; i += 1) { + if (ranges[i].s < ranges[i - 1].e) bad.push(`${ranges[i - 1].s}-${ranges[i - 1].e} overlaps ${ranges[i].s}-${ranges[i].e}`); + } + return bad; + }); + expect(overlaps, `stale painted ranges after ops: ${opLog}`).toEqual([]); + }; + const checkCaret = async (opLog: string) => { const evalCheck = () => superdoc.page.evaluate(() => { @@ -176,6 +196,7 @@ test(`fuzz(seed=${seed}): real keystroke ops never lose the note style or the ca if (i % 5 === 4) { await superdoc.waitForStable(400); await checkCaret(ops.slice(-12).join(' > ')); + await checkPaintedRanges(ops.slice(-12).join(' > ')); } } From 2403016dc9f38d37b301bdfad9a425705e43dee8 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 13:19:23 -0300 Subject: [PATCH 33/36] fix(footnotes): native goal-column resolution for arrows in notes Replaying the user's recorded arrow session exposed two defects in vertical navigation inside note sessions: the goal column drifted right on every press, and hit testing could return positions near the note end for an adjacent line's Y. Root cause: the pipeline converted between layout and client coordinate spaces that disagree for note surfaces (computeCaretLayoutRect, denormalizeClientPoint, and the binary-search fallback each spoke a different space), and the +-5 position plausibility tolerance accepted hits up to ~50px off-column. Note sessions now resolve the target column natively: the goal column comes from the painted caret overlay (true client space) and the adjacent line position from caretRangeFromPoint on the painted line's text, mapped to a position through the leaf pm attributes. No layout/client conversions remain in the note path; body navigation keeps its existing pipeline with the hit test now fed client-space coordinates directly. The ArrowUp regression test asserts both one line per press and goal-column preservation. --- .../vertical-navigation.js | 120 ++++++++++++++---- .../footnotes/footnote-interactions.spec.ts | 19 ++- 2 files changed, 109 insertions(+), 30 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js index ce2e05536a..50a215de87 100644 --- a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js @@ -11,6 +11,7 @@ export const VerticalNavigationPluginKey = new PluginKey('verticalNavigation'); */ const createDefaultState = () => ({ goalX: null, + goalClientX: null, }); /** @@ -50,24 +51,28 @@ export const VerticalNavigation = Extension.create({ if (meta?.type === 'vertical-move') { return { goalX: meta.goalX ?? value.goalX ?? null, + goalClientX: meta.goalClientX ?? value.goalClientX ?? null, }; } if (meta?.type === 'set-goal-x') { return { ...value, goalX: meta.goalX ?? null, + goalClientX: meta.goalClientX ?? null, }; } if (meta?.type === 'reset-goal-x') { return { ...value, goalX: null, + goalClientX: null, }; } if (tr.selectionSet) { return { ...value, goalX: null, + goalClientX: null, }; } return value; @@ -114,15 +119,18 @@ export const VerticalNavigation = Extension.create({ // 3. Perform hit test at (goal X, adjacent line center Y) to find target position. // 4. Move selection to target position, extending if Shift is held. - // 1. Get or set goal X + // 1. Get or set goal X (layout space for the body fallback, client + // space for hit testing — the two only coincide for body surfaces). const pluginState = VerticalNavigationPluginKey.getState(view.state); let goalX = pluginState?.goalX; + let goalClientX = pluginState?.goalClientX; const coords = getCurrentCoords(editor, view.state.selection); if (!coords) return false; - if (goalX == null) { + if (goalX == null || goalClientX == null) { goalX = coords?.x; - if (!Number.isFinite(goalX)) return false; - view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'set-goal-x', goalX })); + goalClientX = coords?.clientX; + if (!Number.isFinite(goalX) || !Number.isFinite(goalClientX)) return false; + view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'set-goal-x', goalX, goalClientX })); } // 2. Find adjacent line @@ -134,7 +142,20 @@ export const VerticalNavigation = Extension.create({ // a page boundary), hit testing with screen coordinates produces incorrect // positions. In that case, fall back to layout-based position resolution // using the line's PM position range and computeCaretLayoutRect. - let hit = getHitFromLayoutCoords(editor, goalX, adjacent.clientY, coords, adjacent.pageIndex); + // SD-3400: note sessions resolve the goal column natively on the + // painted adjacent line via caretRangeFromPoint — pure client space, + // no layout conversions (which mix coordinate systems for notes). + const isNoteSession = Boolean(editor?.options?.parentEditor && !editor?.options?.isHeaderOrFooter); + let hit = null; + if (isNoteSession && adjacent.lineElement) { + const ownerDoc = editor.presentationEditor?.visibleHost?.ownerDocument ?? document; + hit = resolvePositionAtClientPoint(ownerDoc, adjacent.lineElement, goalClientX); + } + if (!hit) { + // Hit test directly in client space — the goal column came from the + // painted caret, so no layout-to-client conversion is needed. + hit = editor.presentationEditor.hitTest(goalClientX, adjacent.clientY); + } // Check if the hit test result is plausible: if the adjacent line has PM // position data, the hit should land within or very close to that range. @@ -145,7 +166,7 @@ export const VerticalNavigation = Extension.create({ // lands on the current line's fragment start instead of the adjacent // line — this causes the cursor to appear stuck since the "new" position // equals the current one. - if (adjacent.pmStart != null && adjacent.pmEnd != null) { + if (!isNoteSession && adjacent.pmStart != null && adjacent.pmEnd != null) { const TOLERANCE = 5; const hitPos = hit?.pos; if ( @@ -167,7 +188,7 @@ export const VerticalNavigation = Extension.create({ if (!selection) return false; view.dispatch( view.state.tr - .setMeta(VerticalNavigationPluginKey, { type: 'vertical-move', goalX }) + .setMeta(VerticalNavigationPluginKey, { type: 'vertical-move', goalX, goalClientX }) .setSelection(selection), ); return true; @@ -229,6 +250,23 @@ function isPresenting(editor) { function getCurrentCoords(editor, selection) { const presentationEditor = editor.presentationEditor; const layoutSpaceCoords = presentationEditor.computeCaretLayoutRect(selection.head); + + // SD-3400: the painted caret overlay is the ground truth in client space. + // computeCaretLayoutRect + denormalizeClientPoint disagree on coordinate + // spaces for note sessions (stacked vs page-local y), which broke goal-x + // and produced off-screen client points for arrows inside footnotes. + const doc = presentationEditor?.visibleHost?.ownerDocument ?? document; + const caretRect = doc.querySelector('.presentation-editor__selection-caret')?.getBoundingClientRect?.(); + if (caretRect && caretRect.height > 0) { + return { + clientX: caretRect.left, + clientY: caretRect.top, + height: caretRect.height, + x: layoutSpaceCoords?.x ?? caretRect.left, + y: layoutSpaceCoords?.y ?? caretRect.top, + }; + } + if (!layoutSpaceCoords) return null; const clientCoords = presentationEditor.denormalizeClientPoint( layoutSpaceCoords.x, @@ -270,6 +308,56 @@ function resolveLineBoundaryPosition(editor, selection, key) { return key === 'Home' ? pmStart : pmEnd; } +/** + * Resolves the ProseMirror position at a client X on a painted line using the + * browser's native point-to-text mapping (caretRangeFromPoint) and the line's + * leaf pm attributes. Pure client space — no layout/client conversions. + * + * @param {Document} ownerDoc + * @param {Element} lineEl + * @param {number} clientX + * @returns {{ pos: number } | null} + */ +function resolvePositionAtClientPoint(ownerDoc, lineEl, clientX) { + const lineRect = lineEl.getBoundingClientRect?.(); + if (!lineRect || lineRect.height === 0) return null; + const y = lineRect.top + lineRect.height / 2; + const x = Math.max(lineRect.left, Math.min(clientX, lineRect.right - 1)); + + const range = typeof ownerDoc.caretRangeFromPoint === 'function' ? ownerDoc.caretRangeFromPoint(x, y) : null; + if (range?.startContainer) { + const node = range.startContainer; + const host = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + const leaf = host?.closest?.('[data-pm-start][data-pm-end]'); + if (leaf && lineEl.contains(leaf)) { + const pmStart = Number(leaf.dataset?.pmStart); + const pmEnd = Number(leaf.dataset?.pmEnd); + if (Number.isFinite(pmStart)) { + let offset = 0; + const walker = ownerDoc.createTreeWalker(leaf, NodeFilter.SHOW_TEXT); + let current = walker.nextNode(); + while (current) { + if (current === node) { + offset += range.startOffset; + break; + } + offset += current.textContent?.length ?? 0; + current = walker.nextNode(); + } + const pos = pmStart + offset; + return { pos: Number.isFinite(pmEnd) ? Math.min(pos, pmEnd) : pos }; + } + } + } + + // Point fell outside text (margins): clamp to the line's edges. + const lineStart = Number(lineEl.dataset?.pmStart); + const lineEnd = Number(lineEl.dataset?.pmEnd); + if (clientX <= lineRect.left && Number.isFinite(lineStart)) return { pos: lineStart }; + if (Number.isFinite(lineEnd)) return { pos: lineEnd }; + return null; +} + /** * SD-3400: painted pm ranges of unchanged note paragraphs drift after edits * (the painter skips repainting them), so the adjacent line's data-pm range @@ -369,26 +457,10 @@ function getAdjacentLineClientTarget(editor, coords, direction) { pmStart: Number.isFinite(pmStart) ? pmStart : undefined, pmEnd: Number.isFinite(pmEnd) ? pmEnd : undefined, isRtl, + lineElement: adjacentLine, }; } -/** - * Converts layout coords to client coords and performs a hit test. - * @param {Object} editor - * @param {number} goalX - * @param {number} clientY - * @param {{ y: number }} coords - * @param {number | undefined} pageIndex - * @returns {{ pos: number } | null} - */ -function getHitFromLayoutCoords(editor, goalX, clientY, coords, pageIndex) { - const presentationEditor = editor.presentationEditor; - const clientPoint = presentationEditor.denormalizeClientPoint(goalX, coords.y, pageIndex); - const clientX = clientPoint?.x; - if (!Number.isFinite(clientX)) return null; - return presentationEditor.hitTest(clientX, clientY); -} - /** * Builds a text selection for the target position, optionally extending. * @param {import('prosemirror-state').EditorState} state diff --git a/tests/behavior/tests/footnotes/footnote-interactions.spec.ts b/tests/behavior/tests/footnotes/footnote-interactions.spec.ts index 11ec72218a..39772f0194 100644 --- a/tests/behavior/tests/footnotes/footnote-interactions.spec.ts +++ b/tests/behavior/tests/footnotes/footnote-interactions.spec.ts @@ -286,11 +286,12 @@ test('ArrowUp walks one visual line at a time after edits drift painted ranges', }); await superdoc.waitForStable(600); - const readCaretTop = () => + const readCaret = () => superdoc.page.evaluate(() => { const cr = document.querySelector('.presentation-editor__selection-caret')?.getBoundingClientRect(); - return cr ? Math.round(cr.top) : null; + return cr ? { top: Math.round(cr.top), left: Math.round(cr.left) } : null; }); + const readCaretTop = async () => (await readCaret())?.top ?? null; const lineTops = await superdoc.page.evaluate(() => (Array.from(document.querySelectorAll('[data-block-id^="footnote-1-"] .superdoc-line')) as HTMLElement[]) .map((l) => Math.round(l.getBoundingClientRect().top)) @@ -298,14 +299,20 @@ test('ArrowUp walks one visual line at a time after edits drift painted ranges', ); expect(lineTops.length).toBe(6); - let currentTop = await readCaretTop(); - expect(currentTop).toBe(lineTops[5]); + const start = await readCaret(); + expect(start?.top).toBe(lineTops[5]); for (let expectedIndex = 4; expectedIndex >= 0; expectedIndex -= 1) { await superdoc.page.keyboard.press('ArrowUp'); await superdoc.waitForStable(500); - const top = await readCaretTop(); - expect(top, `ArrowUp should land on line ${expectedIndex} (top ${lineTops[expectedIndex]}), got ${top}`).toBe( + const caret = await readCaret(); + expect(caret?.top, `ArrowUp should land on line ${expectedIndex} (top ${lineTops[expectedIndex]}), got ${caret?.top}`).toBe( lineTops[expectedIndex], ); + // Goal column: the caret must stay near the starting column while + // arrowing vertically (SD-3400: notes drifted ~50px right per press). + expect( + Math.abs((caret?.left ?? 0) - (start?.left ?? 0)), + `goal-x drift on line ${expectedIndex}: ${start?.left} -> ${caret?.left}`, + ).toBeLessThan(12); } }); From 6ae52376603befe1cb8835535a36364e8b2c6c13 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 13:44:06 -0300 Subject: [PATCH 34/36] fix(footnotes): resolve DOM globals via the document window NodeFilter and Node are browser globals not defined in the JS lint environment; take them from ownerDoc.defaultView, which is also more correct for cross-document usage. Fixes the lint failure on CI. --- .../vertical-navigation/vertical-navigation.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js index 50a215de87..542c585c3c 100644 --- a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js @@ -324,17 +324,22 @@ function resolvePositionAtClientPoint(ownerDoc, lineEl, clientX) { const y = lineRect.top + lineRect.height / 2; const x = Math.max(lineRect.left, Math.min(clientX, lineRect.right - 1)); + // Browser globals via the document's own window (also keeps the file free + // of DOM globals for lint environments without them). + const win = ownerDoc.defaultView; + if (!win) return null; + const range = typeof ownerDoc.caretRangeFromPoint === 'function' ? ownerDoc.caretRangeFromPoint(x, y) : null; if (range?.startContainer) { const node = range.startContainer; - const host = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + const host = node.nodeType === win.Node.TEXT_NODE ? node.parentElement : node; const leaf = host?.closest?.('[data-pm-start][data-pm-end]'); if (leaf && lineEl.contains(leaf)) { const pmStart = Number(leaf.dataset?.pmStart); const pmEnd = Number(leaf.dataset?.pmEnd); if (Number.isFinite(pmStart)) { let offset = 0; - const walker = ownerDoc.createTreeWalker(leaf, NodeFilter.SHOW_TEXT); + const walker = ownerDoc.createTreeWalker(leaf, win.NodeFilter.SHOW_TEXT); let current = walker.nextNode(); while (current) { if (current === node) { From cb630b47f1153aaeb43c110c9ea537e6dc9eae29 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 14:31:00 -0300 Subject: [PATCH 35/36] fix(footnotes): scope dblclick reference resolution to body fragments Header/footer and rendered-note fragments carry data-pm-start values in their own story coordinate space. The double-click note-reference resolver fed those positions into the BODY document's nodeAt, which throws for positions past the body size and aborted the double-click handler before header/footer activation ran, so footers with behindDoc textboxes could no longer be activated. Resolve only elements outside header/footer containers and note fragments, and bounds-check the position. (SD-3400) --- .../pointer-events/note-reference-hit.test.ts | 44 +++++++++++++++++++ .../pointer-events/note-reference-hit.ts | 18 +++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts index 5dd07d8443..065f8a8d8c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.test.ts @@ -191,3 +191,47 @@ describe('resolveNoteReferenceAtPointer — cross-reference navigation (SD-3400) expect(target).toEqual({ storyType: 'footnote', noteId: '8' }); }); }); + +describe('resolveNoteReferenceAtPointer — story-space guards (footer dblclick regression)', () => { + function resolveTarget(doc: ProseMirrorNode, target: HTMLElement) { + return resolveNoteReferenceAtPointer({ target, clientX: 5, clientY: 5, doc, ownerDocument: document }); + } + + it.each(['superdoc-page-header', 'superdoc-page-footer'])( + 'ignores pm-start elements inside a %s container (header/footer story space)', + (containerClass) => { + const doc = makeDoc('footnoteReference'); + const container = document.createElement('div'); + container.className = containerClass; + const span = document.createElement('span'); + // A footer-local position that HAPPENS to resolve to a body note ref. + span.setAttribute('data-pm-start', String(findPos(doc, 'footnoteReference'))); + container.appendChild(span); + document.body.appendChild(container); + + expect(resolveTarget(doc, span)).toBeNull(); + }, + ); + + it('ignores pm-start elements inside a rendered-note fragment (note story space)', () => { + const doc = makeDoc('footnoteReference'); + const fragment = document.createElement('div'); + fragment.setAttribute('data-block-id', 'footnote-8-p0'); + const span = document.createElement('span'); + span.setAttribute('data-pm-start', String(findPos(doc, 'footnoteReference'))); + fragment.appendChild(span); + document.body.appendChild(fragment); + + expect(resolveTarget(doc, span)).toBeNull(); + }); + + it('returns null instead of throwing for a pm-start beyond the body document', () => { + const doc = makeDoc('footnoteReference'); + const span = document.createElement('span'); + // Story-local offsets can exceed the body size; nodeAt would throw. + span.setAttribute('data-pm-start', String(doc.content.size + 50)); + document.body.appendChild(span); + + expect(resolveTarget(doc, span)).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts index 10afd95d1d..98e9969322 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/note-reference-hit.ts @@ -11,7 +11,16 @@ */ import type { Node as ProseMirrorNode } from 'prosemirror-model'; -import type { RenderedNoteTarget } from '../notes/note-target.js'; +import { isRenderedNoteBlockId, type RenderedNoteTarget } from '../notes/note-target.js'; + +/** + * Painted header/footer content lives inside these containers and carries + * `data-pm-start` values in the header/footer part's OWN coordinate space. + * Resolving those against the body document is meaningless and, for positions + * past the body size, makes `nodeAt` throw — which would abort the caller's + * double-click handling before header/footer activation runs. + */ +const HEADER_FOOTER_CONTAINER_SELECTOR = '.superdoc-page-header, .superdoc-page-footer'; export type NoteReferenceHitOptions = { /** The pointer event's target. */ @@ -44,8 +53,13 @@ function noteTargetFromPmStartElement( doc: ProseMirrorNode | null | undefined, ): RenderedNoteTarget | null { if (!refEl || !doc) return null; + // Only BODY fragments carry body-space pm positions. Header/footer and + // rendered-note fragments use their own story's coordinate space. + if (refEl.closest(HEADER_FOOTER_CONTAINER_SELECTOR)) return null; + const blockId = refEl.closest('[data-block-id]')?.getAttribute('data-block-id') ?? ''; + if (isRenderedNoteBlockId(blockId)) return null; const pmStart = Number(refEl.getAttribute('data-pm-start')); - if (!Number.isFinite(pmStart)) return null; + if (!Number.isFinite(pmStart) || pmStart < 0 || pmStart >= doc.content.size) return null; const node = doc.nodeAt(pmStart); if (node?.type?.name === 'crossReference') { return noteTargetFromCrossReference(doc, node.attrs?.target); From 6844904d4574ff35ab8ca66e163ed532623efa45 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 10 Jun 2026 14:31:00 -0300 Subject: [PATCH 36/36] fix(footnotes): support firefox caret-from-point in arrow navigation caretRangeFromPoint is WebKit/Blink-only; firefox exposes caretPositionFromPoint. Without it, note sessions silently fell back to the mixed-coordinate hitTest path and the goal column drifted by tens of pixels on every vertical arrow. Normalize both APIs into one helper. (SD-3400) --- .../vertical-navigation.js | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js index 542c585c3c..3606d4afb6 100644 --- a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js @@ -308,10 +308,34 @@ function resolveLineBoundaryPosition(editor, selection, key) { return key === 'Home' ? pmStart : pmEnd; } +/** + * Browser-portable caret-from-point: WebKit/Blink expose caretRangeFromPoint, + * Firefox exposes caretPositionFromPoint. Normalizes both to {node, offset}. + * Without the Firefox branch, note sessions silently fell back to the + * mixed-coordinate hitTest path and the goal column drifted. + * + * @param {Document} ownerDoc + * @param {number} x + * @param {number} y + * @returns {{ node: Node, offset: number } | null} + */ +function caretPointFromClientPoint(ownerDoc, x, y) { + if (typeof ownerDoc.caretRangeFromPoint === 'function') { + const range = ownerDoc.caretRangeFromPoint(x, y); + if (range?.startContainer) return { node: range.startContainer, offset: range.startOffset }; + } + if (typeof ownerDoc.caretPositionFromPoint === 'function') { + const caret = ownerDoc.caretPositionFromPoint(x, y); + if (caret?.offsetNode) return { node: caret.offsetNode, offset: caret.offset }; + } + return null; +} + /** * Resolves the ProseMirror position at a client X on a painted line using the - * browser's native point-to-text mapping (caretRangeFromPoint) and the line's - * leaf pm attributes. Pure client space — no layout/client conversions. + * browser's native point-to-text mapping ({@link caretPointFromClientPoint}) + * and the line's leaf pm attributes. Pure client space — no layout/client + * conversions. * * @param {Document} ownerDoc * @param {Element} lineEl @@ -329,9 +353,9 @@ function resolvePositionAtClientPoint(ownerDoc, lineEl, clientX) { const win = ownerDoc.defaultView; if (!win) return null; - const range = typeof ownerDoc.caretRangeFromPoint === 'function' ? ownerDoc.caretRangeFromPoint(x, y) : null; - if (range?.startContainer) { - const node = range.startContainer; + const hit = caretPointFromClientPoint(ownerDoc, x, y); + if (hit?.node) { + const node = hit.node; const host = node.nodeType === win.Node.TEXT_NODE ? node.parentElement : node; const leaf = host?.closest?.('[data-pm-start][data-pm-end]'); if (leaf && lineEl.contains(leaf)) { @@ -343,7 +367,7 @@ function resolvePositionAtClientPoint(ownerDoc, lineEl, clientX) { let current = walker.nextNode(); while (current) { if (current === node) { - offset += range.startOffset; + offset += hit.offset; break; } offset += current.textContent?.length ?? 0;