From 62179b6a5f8b43b19a1a7e4219003da4488d9f6e Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 21 Apr 2026 11:36:02 -0300 Subject: [PATCH 01/10] feat(super-editor): render imported heading/bookmark cross-references (SD-2495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the sd:crossReference v3 translator into the v2 importer entity list so REF / NOTEREF / STYLEREF fields imported from DOCX actually produce PM crossReference nodes instead of being silently dropped by the dispatch loop. Walk nested run wrappers when extracting the field's cached display text, and render the cross-reference as an internal hyperlink when the instruction carries the \\h switch. Route clicks on internal #bookmark anchors through goToAnchor so rendered cross-references navigate to their target in the document. Fixes IT-949 — Word cross-references (e.g. "Section 15") now appear in the viewer and are searchable, matching Word's output. --- .../inline-converters/cross-reference.test.ts | 88 +++++++++++++++++++ .../inline-converters/cross-reference.ts | 29 ++++-- .../pointer-events/EditorInputManager.ts | 7 +- .../v2/importer/crossReferenceImporter.js | 7 ++ .../v2/importer/docxImporter.js | 2 + .../crossReference-translator.js | 23 +++-- .../crossReference-translator.test.js | 77 ++++++++++++++++ 7 files changed, 219 insertions(+), 14 deletions(-) create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts new file mode 100644 index 0000000000..66cf1e30f9 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { TextRun } from '@superdoc/contracts'; +import type { PMNode } from '../../types.js'; +import type { InlineConverterParams } from './common.js'; + +vi.mock('./text-run.js', () => ({ + textNodeToRun: vi.fn( + (params: InlineConverterParams): TextRun => ({ + text: params.node.text || '', + fontFamily: params.defaultFont, + fontSize: params.defaultSize, + }), + ), +})); + +import { crossReferenceNodeToRun } from './cross-reference.js'; + +function makeParams( + attrs: Record, + overrides: Partial = {}, +): InlineConverterParams { + const node: PMNode = { type: 'crossReference', attrs }; + return { + node, + positions: new WeakMap(), + defaultFont: 'Calibri', + defaultSize: 16, + inheritedMarks: [], + sdtMetadata: undefined, + hyperlinkConfig: { enableRichHyperlinks: false }, + themeColors: undefined, + runProperties: undefined, + paragraphProperties: undefined, + converterContext: {} as unknown as InlineConverterParams['converterContext'], + enableComments: false, + visitNode: vi.fn(), + bookmarks: undefined, + tabOrdinal: 0, + paragraphAttrs: {}, + nextBlockId: vi.fn(), + ...overrides, + } as InlineConverterParams; +} + +describe('crossReferenceNodeToRun (SD-2495)', () => { + it('emits a TextRun carrying the resolved display text', () => { + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\w \\h' }), + ); + expect(run).not.toBeNull(); + expect(run!.text).toBe('15'); + }); + + it('synthesizes an internal link when the instruction has the \\h switch', () => { + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\w \\h' }), + ); + expect(run!.link).toBeDefined(); + expect(run!.link?.anchor).toBe('_Ref506192326'); + }); + + it('does not attach a link when the \\h switch is absent', () => { + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\w' }), + ); + expect(run!.link).toBeUndefined(); + }); + + it('still emits a TextRun (not null) when the cached text is empty', () => { + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: '', target: '_Ref_missing', instruction: 'REF _Ref_missing \\h' }), + ); + expect(run).not.toBeNull(); + expect(run!.text).toBe(''); + // Still links to target so surrounding layout isn't broken and the click target + // is preserved if the text later becomes non-empty via a re-import. + expect(run!.link?.anchor).toBe('_Ref_missing'); + }); + + it('does not match a literal `h` character as the \\h switch', () => { + // Guards against naive substring check — instruction like `REF bh-target` + // must not produce a hyperlink just because `h` appears somewhere. + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: 'label', target: 'bh-target', instruction: 'REF bh-target' }), + ); + expect(run!.link).toBeUndefined(); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts index c321a37099..1bc05ff8c7 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts @@ -1,25 +1,40 @@ import type { TextRun } from '@superdoc/contracts'; -import type { PMNode, PMMark } from '../../types.js'; +import type { PMNode } from '../../types.js'; import { textNodeToRun } from './text-run.js'; -import { applyMarksToRun } from '../../marks/index.js'; -import { applyInlineRunProperties, type InlineConverterParams } from './common.js'; +import { buildFlowRunLink } from '../../marks/links.js'; +import { type InlineConverterParams } from './common.js'; /** * Converts a crossReference PM node to a TextRun with the resolved display text. + * + * Renders Word REF / NOTEREF / STYLEREF fields imported from DOCX. Uses the + * cached result text from Word (`attrs.resolvedText`) — we do not recompute + * outline numbers for `\w`/`\r`/`\n` switches, we trust Word's cache. + * + * When the instruction carries the `\h` switch, the reference renders as an + * internal hyperlink pointing at `#` so clicks navigate to the + * corresponding bookmark via the existing anchor-link navigation path. */ export function crossReferenceNodeToRun(params: InlineConverterParams): TextRun | null { - const { node, positions, defaultFont, defaultSize, inheritedMarks, sdtMetadata, runProperties, converterContext } = - params; + const { node, positions, sdtMetadata } = params; const attrs = (node.attrs ?? {}) as Record; - const resolvedText = (attrs.resolvedText as string) || (attrs.target as string) || ''; - if (!resolvedText) return null; + const resolvedText = typeof attrs.resolvedText === 'string' ? attrs.resolvedText : ''; + const target = typeof attrs.target === 'string' ? attrs.target : ''; + const instruction = typeof attrs.instruction === 'string' ? attrs.instruction : ''; const run = textNodeToRun({ ...params, node: { type: 'text', text: resolvedText, marks: [...(node.marks ?? [])] } as PMNode, }); + if (target && /\\h\b/.test(instruction)) { + const synthesized = buildFlowRunLink({ anchor: target }); + if (synthesized) { + run.link = run.link ? { ...run.link, ...synthesized, anchor: target } : synthesized; + } + } + const pos = positions.get(node); if (pos) { run.pmStart = pos.start; 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 fb5d17840b..c1bb419f00 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 @@ -1532,9 +1532,12 @@ export class EditorInputManager { #handleLinkClick(event: MouseEvent, linkEl: HTMLAnchorElement): void { const href = linkEl.getAttribute('href') ?? ''; const isAnchorLink = href.startsWith('#') && href.length > 1; - const isTocLink = linkEl.closest('.superdoc-toc-entry') !== null; - if (isAnchorLink && isTocLink) { + // SD-2495: route any internal-anchor click (`#`) to in-document + // navigation. Covers TOC entries, heading/bookmark cross-references + // (REF fields with `\h`), and any other internal-hyperlink case — they all + // should scroll to the bookmark target instead of navigating the browser. + if (isAnchorLink) { event.preventDefault(); event.stopPropagation(); this.#callbacks.goToAnchor?.(href); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js new file mode 100644 index 0000000000..6e896a236e --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js @@ -0,0 +1,7 @@ +import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils'; +import { translator } from '../../v3/handlers/sd/crossReference/crossReference-translator.js'; + +/** + * @type {import("./docxImporter").NodeHandlerEntry} + */ +export const crossReferenceEntity = generateV2HandlerEntity('crossReferenceNodeHandler', translator); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index f6cb80864b..d49267ce09 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -17,6 +17,7 @@ import { alternateChoiceHandler } from './alternateChoiceImporter.js'; import { autoPageHandlerEntity, autoTotalPageCountEntity } from './autoPageNumberImporter.js'; import { documentStatFieldHandlerEntity } from './documentStatFieldImporter.js'; import { pageReferenceEntity } from './pageReferenceImporter.js'; +import { crossReferenceEntity } from './crossReferenceImporter.js'; import { pictNodeHandlerEntity } from './pictNodeImporter.js'; import { importCommentData } from './documentCommentsImporter.js'; import { buildTrackedChangeIdMap } from './trackedChangeIdMapper.js'; @@ -249,6 +250,7 @@ export const defaultNodeListHandler = () => { autoTotalPageCountEntity, documentStatFieldHandlerEntity, pageReferenceEntity, + crossReferenceEntity, permStartHandlerEntity, permEndHandlerEntity, mathNodeHandlerEntity, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js index 343333bcc5..3f6419f00b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js @@ -108,16 +108,29 @@ function parseDisplay(instruction) { } /** - * Extracts resolved text from processed content. + * Extracts resolved text from processed content. Walks recursively because the + * cached result between w:fldChar separate/end is typically wrapped in a `run` + * node (or deeper: run -> text with marks), so a top-level text-only filter + * misses the field's display text. * @param {Array} content * @returns {string} */ function extractResolvedText(content) { if (!Array.isArray(content)) return ''; - return content - .filter((n) => n.type === 'text') - .map((n) => n.text || '') - .join(''); + let out = ''; + /** @param {Array} nodes */ + const walk = (nodes) => { + for (const node of nodes) { + if (!node) continue; + if (node.type === 'text') { + out += node.text || ''; + } else if (Array.isArray(node.content)) { + walk(node.content); + } + } + }; + walk(content); + return out; } /** @type {import('@translator').NodeTranslatorConfig} */ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js index 080024cc7c..9eb61b2d8a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { exportSchemaToJson } from '../../../../exporter.js'; import { translator as runTranslator } from '../../w/r/r-translator.js'; +import { translator as crossReferenceTranslator } from './crossReference-translator.js'; const CROSS_REFERENCE_INSTRUCTION = 'REF bm-target \\h'; @@ -61,3 +62,79 @@ describe('crossReference export routing', () => { expect(exportedRuns.some((node) => hasFieldCharType(node, 'end'))).toBe(true); }); }); + +describe('crossReference import resolvedText extraction (SD-2495)', () => { + // Mirrors the Brillio-style REF cached payload: cached text lives inside a w:r + // wrapper, so a top-level-only `n.type === 'text'` filter returns empty. The + // recursive walk must descend through run wrappers to find the display text. + const buildRun = (innerElements) => ({ + type: 'element', + name: 'w:r', + elements: [{ type: 'element', name: 'w:rPr', elements: [{ type: 'element', name: 'w:i' }] }, ...innerElements], + }); + + const buildSdCrossReference = (instr, cachedRuns) => ({ + name: 'sd:crossReference', + type: 'element', + attributes: { instruction: instr, fieldType: 'REF' }, + elements: cachedRuns, + }); + + it('extracts cached text from runs wrapped around a w:t (Brillio shape)', () => { + const xmlNode = buildSdCrossReference('REF _Ref506192326 \\w \\h', [ + buildRun([]), // empty formatting-carrier run + buildRun([{ type: 'element', name: 'w:t', elements: [{ type: 'text', text: '15' }] }]), + ]); + const encoded = crossReferenceTranslator.encode({ + nodes: [xmlNode], + nodeListHandler: { + handler: ({ nodes }) => { + // Simulate w:r translator wrapping content in SuperDoc run nodes + return nodes + .map((run) => { + const textEl = run.elements?.find((el) => el?.name === 'w:t'); + if (!textEl) return null; + const text = (textEl.elements || []) + .map((child) => (typeof child?.text === 'string' ? child.text : '')) + .join(''); + if (!text) return null; + return { type: 'run', attrs: {}, content: [{ type: 'text', text }] }; + }) + .filter(Boolean); + }, + }, + }); + + expect(encoded.type).toBe('crossReference'); + expect(encoded.attrs.target).toBe('_Ref506192326'); + expect(encoded.attrs.resolvedText).toBe('15'); + expect(encoded.attrs.display).toBe('numberFullContext'); + }); + + it('concatenates cached text across multiple run wrappers', () => { + const xmlNode = buildSdCrossReference('REF _RefABC \\h', [ + buildRun([{ type: 'element', name: 'w:t', elements: [{ type: 'text', text: '4(b' }] }]), + buildRun([{ type: 'element', name: 'w:t', elements: [{ type: 'text', text: ')(2)' }] }]), + ]); + const encoded = crossReferenceTranslator.encode({ + nodes: [xmlNode], + nodeListHandler: { + handler: ({ nodes }) => + nodes.map((run) => ({ + type: 'run', + attrs: {}, + content: [ + { + type: 'text', + text: (run.elements?.find((el) => el?.name === 'w:t')?.elements ?? []) + .map((c) => c.text || '') + .join(''), + }, + ], + })), + }, + }); + + expect(encoded.attrs.resolvedText).toBe('4(b)(2)'); + }); +}); From 330b27fd581578a738ef805273c912657dde5914 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 21 Apr 2026 12:12:23 -0300 Subject: [PATCH 02/10] fix(presentation-editor): anchor nav writes scrollTop to the real scroll container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #scrollPageIntoView wrote scrollTop to #visibleHost, which is typically overflow: visible and therefore not the actual scroll target. Anchor navigation (TOC clicks and SD-2495 cross-reference click-to-navigate) silently did nothing whenever the bookmark target was outside the current viewport — the PM selection moved but the viewport never scrolled. Write to #scrollContainer (the resolved scrollable ancestor) as the primary target, plus #visibleHost for backward compatibility with legacy layouts and the existing test harness that mocks scrollTop on the host element. This unblocks SD-2495's cross-reference click-to-navigate on docs where cross-references and their targets live on different pages. --- .../presentation-editor/PresentationEditor.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 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 2c9d1dab9d..c7a3ef6eec 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 @@ -5839,8 +5839,24 @@ export class PresentationEditor extends EventEmitter { yPosition += pageHeight + virtualGap; } - // Scroll viewport to the calculated position - if (this.#visibleHost) { + // Scroll viewport to the calculated position. + // + // The authoritative scrollable ancestor is `#scrollContainer` — setting + // scrollTop on the visible host alone is a no-op when the host is + // `overflow: visible` (the standard layout). Without this, anchor + // navigation (TOC clicks, cross-reference click-to-navigate under + // SD-2495) silently does nothing whenever the target page is outside + // the current viewport. + // + // We also write to `#visibleHost` for backwards compatibility: legacy + // layouts may make the visible host itself scrollable, and tests mock + // scrollTop on the host element. + if (this.#scrollContainer instanceof Window) { + this.#scrollContainer.scrollTo({ top: yPosition }); + } else if (this.#scrollContainer) { + this.#scrollContainer.scrollTop = yPosition; + } + if (this.#visibleHost && this.#visibleHost !== this.#scrollContainer) { this.#visibleHost.scrollTop = yPosition; } } From c5c5b3ad43ba138f305ba32485ed5147970ca3ef Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 21 Apr 2026 12:35:59 -0300 Subject: [PATCH 03/10] feat(layout): show-bookmarks bracket indicators (SD-2454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in visual indicators for bookmark positions — mirrors Word's "Show bookmarks" (File > Options > Advanced). Off by default. - Pm-adapter bookmark-start and new bookmark-end converters emit gray `[` and `]` marker TextRuns when `layoutEngineOptions.showBookmarks` is true. Markers flow through pagination and line breaking as real characters, matching Word's own visual model. - Auto-generated bookmarks (`_Toc…`, `_Ref…`, `_GoBack`) are hidden even when the feature is on — matching Word. A `renderedBookmarkIds` set on the converter context pairs suppression so closing brackets don't orphan open ones. - PresentationEditor.setShowBookmarks toggles at runtime: clears the flow-block cache and schedules a re-render. - SuperDoc.setShowBookmarks is the public API passthrough. - Dev app gets a Show/Hide bookmarks toggle button in the header. - CSS: subtle gray, non-selectable so users don't include brackets in copied text. Bookmark name surfaces via the native title tooltip on the opening bracket. --- .../painters/dom/src/renderer.ts | 8 ++ .../layout-engine/painters/dom/src/styles.ts | 15 +++ .../pm-adapter/src/converter-context.ts | 17 +++ .../inline-converters/bookmark-end.ts | 44 +++++++ .../bookmark-markers.test.ts | 115 ++++++++++++++++++ .../inline-converters/bookmark-start.ts | 66 ++++++++-- .../pm-adapter/src/converters/paragraph.ts | 4 + .../layout-engine/pm-adapter/src/internal.ts | 6 + .../layout-engine/pm-adapter/src/types.ts | 7 ++ .../presentation-editor/PresentationEditor.ts | 20 +++ .../v1/core/presentation-editor/types.ts | 9 ++ packages/superdoc/src/core/SuperDoc.js | 19 +++ .../src/dev/components/SuperdocDev.vue | 10 ++ 13 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 9dcaf5dda0..3b3989c6fe 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5349,6 +5349,14 @@ export class DomPainter { elem.style.zIndex = '1'; applyRunDataAttributes(elem as HTMLElement, (run as TextRun).dataAttrs); + // SD-2454: bookmark marker runs carry a data-bookmark-name attribute. + // Surface the bookmark name as a native `title` tooltip so hovering the + // opening bracket identifies which bookmark is being marked. + const bookmarkName = (run as TextRun).dataAttrs?.['data-bookmark-name']; + if (bookmarkName) { + (elem as HTMLElement).title = bookmarkName; + } + // Assert PM positions are present for cursor fallback assertPmPositions(run, 'paragraph text run'); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index b548d37107..302f1d60a6 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -197,6 +197,21 @@ const LINK_AND_TOC_STYLES = ` } } +/* SD-2454: bookmark bracket indicators. + * When the showBookmarks layout option is enabled, the pm-adapter emits + * [ and ] marker TextRuns at bookmark start/end positions. Mirror Word's + * visual treatment: subtle gray, non-selectable so users can't accidentally + * include the brackets in copied text. The bookmark name is surfaced via + * the native title tooltip on the opening bracket. */ +[data-bookmark-marker="start"], +[data-bookmark-marker="end"] { + color: #8b8b8b; + user-select: none; + cursor: default; + font-weight: normal; +} + + /* Reduced motion support */ @media (prefers-reduced-motion: reduce) { .superdoc-link { diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/layout-engine/pm-adapter/src/converter-context.ts index 250b1237f6..89e75b5e76 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.ts @@ -54,6 +54,23 @@ export type ConverterContext = { * Used by table creation paths to determine which style to apply to new tables. */ defaultTableStyleId?: string; + /** + * When true, emit visible gray `[` and `]` marker TextRuns at bookmarkStart + * and bookmarkEnd positions — matching Word's "Show bookmarks" feature + * (File > Options > Advanced). Off by default because bookmarks are a + * structural concept, not a visual one. SD-2454. + */ + showBookmarks?: boolean; + + /** + * Populated by the bookmark-start inline converter during conversion: the + * set of bookmark numeric ids (as strings) that actually rendered a start + * marker. The bookmark-end converter reads this set to suppress emitting + * an orphan `]` for a start it also suppressed (e.g. `_Toc…` / `_Ref…` + * auto-generated bookmarks filtered out by the `showBookmarks` feature). + * SD-2454. + */ + renderedBookmarkIds?: Set; }; /** diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts new file mode 100644 index 0000000000..5c68518d39 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts @@ -0,0 +1,44 @@ +import type { TextRun } from '@superdoc/contracts'; +import type { PMNode } from '../../types.js'; +import { textNodeToRun } from './text-run.js'; +import { type InlineConverterParams } from './common.js'; + +/** + * Converts a `bookmarkEnd` PM node. + * + * SD-2454: when `converterContext.showBookmarks` is true, emit a visible gray + * `]` marker at the bookmark end. Matches Word's "Show bookmarks" rendering. + * Returns void (no visual output) when the option is off, preserving today's + * behavior where bookmarkEnd is an invisible structural marker. + * + * The PM schema does not store the bookmark name on bookmarkEnd — only the + * numeric `id` that matches the corresponding bookmarkStart. We therefore + * don't set a tooltip on the closing bracket (Word also omits the name on + * the closing bracket's hover). Styling and identification happen on the + * opening bracket. + */ +export function bookmarkEndNodeToRun(params: InlineConverterParams): TextRun | void { + const { node, converterContext } = params; + if (converterContext?.showBookmarks !== true) return; + + const nodeAttrs = + typeof node.attrs === 'object' && node.attrs !== null ? (node.attrs as Record) : {}; + const bookmarkId = typeof nodeAttrs.id === 'string' || typeof nodeAttrs.id === 'number' ? String(nodeAttrs.id) : ''; + + // Only emit `]` if we emitted the matching `[`. Keeps brackets paired and + // prevents an orphan closing bracket for a suppressed auto-generated + // bookmark (`_Toc…`, `_Ref…`, `_GoBack`). + const rendered = converterContext?.renderedBookmarkIds; + if (rendered && bookmarkId && !rendered.has(bookmarkId)) return; + + const run = textNodeToRun({ + ...params, + node: { type: 'text', text: ']', marks: [...(node.marks ?? [])] } as PMNode, + }); + run.dataAttrs = { + ...(run.dataAttrs ?? {}), + 'data-bookmark-marker': 'end', + ...(bookmarkId ? { 'data-bookmark-id': bookmarkId } : {}), + }; + return run; +} diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts new file mode 100644 index 0000000000..d0269be9ea --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { TextRun } from '@superdoc/contracts'; +import type { PMNode } from '../../types.js'; +import type { InlineConverterParams } from './common.js'; + +vi.mock('./text-run.js', () => ({ + textNodeToRun: vi.fn( + (params: InlineConverterParams): TextRun => ({ + text: params.node.text || '', + fontFamily: params.defaultFont, + fontSize: params.defaultSize, + }), + ), +})); + +import { bookmarkStartNodeToBlocks } from './bookmark-start.js'; +import { bookmarkEndNodeToRun } from './bookmark-end.js'; + +function makeParams( + node: PMNode, + opts: { showBookmarks?: boolean; bookmarks?: Map; renderedBookmarkIds?: Set } = {}, +): InlineConverterParams { + return { + node, + positions: new WeakMap(), + defaultFont: 'Calibri', + defaultSize: 16, + inheritedMarks: [], + sdtMetadata: undefined, + hyperlinkConfig: { enableRichHyperlinks: false }, + themeColors: undefined, + runProperties: undefined, + paragraphProperties: undefined, + converterContext: { + translatedNumbering: {}, + translatedLinkedStyles: { docDefaults: {}, latentStyles: {}, styles: {} }, + showBookmarks: opts.showBookmarks ?? false, + renderedBookmarkIds: opts.renderedBookmarkIds, + } as unknown as InlineConverterParams['converterContext'], + enableComments: false, + visitNode: vi.fn(), + bookmarks: opts.bookmarks, + tabOrdinal: 0, + paragraphAttrs: {}, + nextBlockId: vi.fn(), + } as InlineConverterParams; +} + +describe('bookmarkStartNodeToBlocks (SD-2454)', () => { + it('emits no visible run when showBookmarks is off (default)', () => { + const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } }; + const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: false })); + expect(result).toBeUndefined(); + }); + + it('emits a `[` TextRun with bookmark-name data attr when showBookmarks is on', () => { + const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } }; + const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: true })); + expect(result).toBeDefined(); + expect(result!.text).toBe('['); + expect(result!.dataAttrs).toEqual({ + 'data-bookmark-name': 'chapter1', + 'data-bookmark-marker': 'start', + }); + }); + + it('suppresses markers for auto-generated bookmarks (names starting with `_`)', () => { + // Matches Word behavior: `_Toc…`, `_Ref…`, `_GoBack` etc. are hidden from + // Show Bookmarks because they are internally generated for headings, + // fields, or navigation — showing them would clutter the document. + for (const name of ['_Toc1234', '_Ref506192326', '_GoBack']) { + const node: PMNode = { type: 'bookmarkStart', attrs: { name, id: '1' } }; + const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: true })); + expect(result, `should suppress marker for "${name}"`).toBeUndefined(); + } + }); + + it('still records bookmark position for cross-reference resolution regardless of showBookmarks', () => { + const bookmarks = new Map(); + const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } }; + const params = makeParams(node, { showBookmarks: false, bookmarks }); + // Seed the position map + params.positions.set(node, { start: 42, end: 42 }); + bookmarkStartNodeToBlocks(params); + expect(bookmarks.get('chapter1')).toBe(42); + }); +}); + +describe('bookmarkEndNodeToRun (SD-2454)', () => { + it('emits no run when showBookmarks is off (default)', () => { + const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '1' } }; + const result = bookmarkEndNodeToRun(makeParams(node, { showBookmarks: false })); + expect(result).toBeUndefined(); + }); + + it('emits a `]` TextRun when the matching start was rendered', () => { + const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '1' } }; + const result = bookmarkEndNodeToRun(makeParams(node, { showBookmarks: true, renderedBookmarkIds: new Set(['1']) })); + expect(result).toBeDefined(); + expect(result!.text).toBe(']'); + expect(result!.dataAttrs).toEqual({ + 'data-bookmark-marker': 'end', + 'data-bookmark-id': '1', + }); + }); + + it('suppresses `]` when the matching start was also suppressed (no orphan brackets)', () => { + const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '42' } }; + // Start with id 42 was suppressed — renderedBookmarkIds does not include it + const result = bookmarkEndNodeToRun( + makeParams(node, { showBookmarks: true, renderedBookmarkIds: new Set(['99']) }), + ); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts index 779c95934b..a91ff1193a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts @@ -1,26 +1,68 @@ -import { type InlineConverterParams } from './common'; +import type { TextRun } from '@superdoc/contracts'; +import type { PMNode } from '../../types.js'; +import { textNodeToRun } from './text-run.js'; +import { type InlineConverterParams } from './common.js'; -export function bookmarkStartNodeToBlocks({ - node, - positions, - bookmarks, - visitNode, - inheritedMarks, - sdtMetadata, - runProperties, -}: InlineConverterParams): void { - // Track bookmark position for cross-reference resolution +/** + * Converts a `bookmarkStart` PM node. + * + * Primary job: record the bookmark's PM position in the `bookmarks` Map so + * cross-reference navigation (goToAnchor) can resolve `#` + * hrefs to a document position. + * + * SD-2454: when `converterContext.showBookmarks` is true, also emit a visible + * gray `[` marker at the bookmark start, matching Word's opt-in "Show + * bookmarks" feature. The marker is a regular TextRun so it flows through + * pagination and line breaking like any other character; `dataAttrs` tag it + * so DomPainter can style it gray and set a tooltip with the bookmark name. + * + * When `showBookmarks` is false (the default), the converter still descends + * into any content inside the bookmark span but emits no visual output. + */ +export function bookmarkStartNodeToBlocks(params: InlineConverterParams): TextRun | void { + const { node, positions, bookmarks, visitNode, inheritedMarks, sdtMetadata, runProperties, converterContext } = + params; const nodeAttrs = typeof node.attrs === 'object' && node.attrs !== null ? (node.attrs as Record) : {}; const bookmarkName = typeof nodeAttrs.name === 'string' ? nodeAttrs.name : undefined; + if (bookmarkName && bookmarks) { const nodePos = positions.get(node); if (nodePos) { bookmarks.set(bookmarkName, nodePos.start); } } - // Process any content inside the bookmark (usually empty) + + // Word hides `_Toc…` / `_Ref…` / other `_`-prefixed bookmarks from its Show + // Bookmarks rendering because they're autogenerated (headings, fields). + // Mirror that so opt-in markers don't pollute every heading and xref target. + const shouldRender = + converterContext?.showBookmarks === true && typeof bookmarkName === 'string' && !bookmarkName.startsWith('_'); + + let run: TextRun | undefined; + if (shouldRender) { + run = textNodeToRun({ + ...params, + node: { type: 'text', text: '[', marks: [...(node.marks ?? [])] } as PMNode, + }); + run.dataAttrs = { + ...(run.dataAttrs ?? {}), + 'data-bookmark-name': bookmarkName!, + 'data-bookmark-marker': 'start', + }; + // Record the id so the matching bookmarkEnd converter knows to emit `]`. + // Without this, suppressing a `_`-prefixed start leaves an orphan `]`. + const bookmarkIdRaw = nodeAttrs.id; + const bookmarkId = + typeof bookmarkIdRaw === 'string' || typeof bookmarkIdRaw === 'number' ? String(bookmarkIdRaw) : ''; + if (bookmarkId && converterContext?.renderedBookmarkIds) { + converterContext.renderedBookmarkIds.add(bookmarkId); + } + } + if (Array.isArray(node.content)) { node.content.forEach((child) => visitNode(child, inheritedMarks, sdtMetadata, runProperties)); } + + return run; } diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 0bc5a4d59b..58b887aa3a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -49,6 +49,7 @@ import { structuredContentNodeToBlocks } from './inline-converters/structured-co import { pageReferenceNodeToBlock } from './inline-converters/page-reference.js'; import { fieldAnnotationNodeToRun } from './inline-converters/field-annotation.js'; import { bookmarkStartNodeToBlocks } from './inline-converters/bookmark-start.js'; +import { bookmarkEndNodeToRun } from './inline-converters/bookmark-end.js'; import { tabNodeToRun } from './inline-converters/tab.js'; import { tokenNodeToRun } from './inline-converters/generic-token.js'; import { imageNodeToRun } from './inline-converters/image.js'; @@ -927,6 +928,9 @@ const INLINE_CONVERTERS_REGISTRY: Record = { bookmarkStart: { inlineConverter: bookmarkStartNodeToBlocks, }, + bookmarkEnd: { + inlineConverter: bookmarkEndNodeToRun, + }, tab: { inlineConverter: tabNodeToRun, }, diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index 4ffd9da91d..f127e8fc20 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -155,6 +155,12 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): defaultFont, defaultSize, ); + if (options?.showBookmarks !== undefined) { + converterContext.showBookmarks = options.showBookmarks; + } + if (converterContext.showBookmarks) { + converterContext.renderedBookmarkIds = new Set(); + } const blocks: FlowBlock[] = []; const bookmarks = new Map(); diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts index 5a98b205ed..cd9148ac1d 100644 --- a/packages/layout-engine/pm-adapter/src/types.ts +++ b/packages/layout-engine/pm-adapter/src/types.ts @@ -122,6 +122,13 @@ export interface AdapterOptions { */ emitSectionBreaks?: boolean; + /** + * When true, render visible gray `[` / `]` marker runs at bookmarkStart and + * bookmarkEnd positions (SD-2454). Matches Word's opt-in "Show bookmarks" + * behavior. Off by default because bookmarks are structural, not visual. + */ + showBookmarks?: boolean; + /** * Optional instrumentation hook for fidelity logging. */ 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 c7a3ef6eec..34ab45271d 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 @@ -468,6 +468,7 @@ export class PresentationEditor extends EventEmitter { emitCommentPositionsInViewing: options.layoutEngineOptions?.emitCommentPositionsInViewing, enableCommentsInViewing: options.layoutEngineOptions?.enableCommentsInViewing, presence: validatedPresence, + showBookmarks: options.layoutEngineOptions?.showBookmarks ?? false, }; this.#trackedChangesOverrides = options.layoutEngineOptions?.trackedChanges; @@ -2018,6 +2019,24 @@ export class PresentationEditor extends EventEmitter { this.#scheduleRerender(); } + /** + * Toggle the SD-2454 "Show bookmarks" bracket indicators at runtime. + * + * When enabled, the pm-adapter emits visible gray `[` / `]` marker runs at + * bookmarkStart / bookmarkEnd positions (mirroring Word's opt-in behavior). + * Because markers are real characters that participate in text measurement + * and line breaking, toggling invalidates the flow-block cache and triggers + * a full re-layout. + */ + setShowBookmarks(showBookmarks: boolean): void { + const next = !!showBookmarks; + if (this.#layoutOptions.showBookmarks === next) return; + this.#layoutOptions.showBookmarks = next; + this.#flowBlockCache?.clear(); + this.#pendingDocChange = true; + this.#scheduleRerender(); + } + /** * Convert a viewport coordinate into a document hit using the current layout. */ @@ -4194,6 +4213,7 @@ export class PresentationEditor extends EventEmitter { themeColors: this.#editor?.converter?.themeColors ?? undefined, converterContext, flowBlockCache: this.#flowBlockCache, + showBookmarks: this.#layoutOptions.showBookmarks ?? false, ...(positionMap ? { positions: positionMap } : {}), ...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}), }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index e442a6f34e..a77100a932 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -165,6 +165,15 @@ export type LayoutEngineOptions = { ruler?: RulerOptions; /** Proofing / spellcheck configuration. */ proofing?: ProofingConfig; + /** + * Render visible gray `[` / `]` bracket markers at bookmark start/end + * positions — matching Word's opt-in "Show bookmarks" (File > Options > + * Advanced). Off by default because bookmarks are a structural concept, + * not a visual one. Auto-generated bookmarks (names starting with `_`, + * such as `_Toc…` or `_Ref…`) are hidden even when enabled, mirroring + * Word's behavior. SD-2454. + */ + showBookmarks?: boolean; }; export type PresentationEditorOptions = ConstructorParameters[0] & { diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index e1c59146ba..f92fe56295 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -1240,6 +1240,25 @@ export class SuperDoc extends EventEmitter { }); } + /** + * SD-2454: Toggle bookmark bracket indicators (opt-in, off by default). + * Matches Word's "Show bookmarks" option. Triggers a re-layout on change + * because the brackets are visible characters participating in text flow. + * @param {boolean} show + * @returns {void} + */ + setShowBookmarks(show = true) { + const nextValue = Boolean(show); + const layoutOptions = (this.config.layoutEngineOptions = this.config.layoutEngineOptions || {}); + if (layoutOptions.showBookmarks === nextValue) return; + layoutOptions.showBookmarks = nextValue; + + this.superdocStore?.documents?.forEach((doc) => { + const presentationEditor = doc.getPresentationEditor?.(); + presentationEditor?.setShowBookmarks?.(nextValue); + }); + } + /** * Set the document mode. * @param {DocumentMode} type diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 1054f013c1..c9f7a5e51f 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -43,6 +43,7 @@ const testUserEmail = urlParams.get('email') || 'user@superdoc.com'; const testUserName = urlParams.get('name') || `SuperDoc ${Math.floor(1000 + Math.random() * 9000)}`; const userRole = urlParams.get('role') || 'editor'; const useLayoutEngine = ref(urlParams.get('layout') !== '0'); +const showBookmarks = ref(urlParams.get('bookmarks') === '1'); const useWebLayout = ref(urlParams.get('view') === 'web'); // Tracked-change replacement model. 'paired' groups ins+del into one change // (Google Docs model); 'independent' keeps each as its own revision (Word / ECMA-376). @@ -687,6 +688,7 @@ const init = async () => { layoutEngineOptions: { flowMode: useWebLayout.value ? 'semantic' : 'paginated', ...(useWebLayout.value ? { semanticOptions: { marginsMode: 'none' } } : {}), + showBookmarks: showBookmarks.value, }, rulers: true, rulerContainer: '#ruler-container', @@ -1204,6 +1206,11 @@ const toggleLayoutEngine = () => { window.location.href = url.toString(); }; +const toggleShowBookmarks = () => { + showBookmarks.value = !showBookmarks.value; + superdoc.value?.setShowBookmarks?.(showBookmarks.value); +}; + const toggleViewLayout = () => { const nextValue = !useWebLayout.value; const url = new URL(window.location.href); @@ -1501,6 +1508,9 @@ if (scrollTestMode.value) { @change="handleCompareFile" /> + From 2ab31d737e19881653ab24c1d882626dc6f40841 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 21 Apr 2026 13:04:53 -0300 Subject: [PATCH 04/10] test: close regression gaps for SD-2495 / SD-2454 / anchor-nav fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the test gaps surfaced by the testing-excellence review of this PR: - crossReferenceImporter.integration.test.js (new, 4 tests): exercises the full v2 body pipeline (preprocessor -> dispatcher -> entity handler -> v3 translator). Asserts crossReferenceEntity is a member of the defaultNodeListHandler entities list, so the exact root cause that produced IT-949 ("Section 15" vanishing) fails loudly if a future refactor drops the wire-up. Unit tests of the translator alone cannot catch this — they bypass the dispatcher. - EditorInputManager.anchorClick.test.ts (new, 4 tests): pins the SD-2537 click-to-navigate routing. Clicking #bookmark hrefs routes through goToAnchor (was TOC-only before). External and empty-fragment hrefs are explicitly NOT routed. - cross-reference.test.ts: added marks-propagation test (node.marks flow into the emitted TextRun so italic/textStyle on xref text survives — SD-2537 "preserve surrounding run styling" AC). - bookmark-markers.test.ts: converted the `for` loop over auto-generated bookmark names into `it.each`. Each input now reports per-case on failure, complies with testing-excellence's "no control flow inside test bodies" guideline. - PresentationEditor.test.ts: documents why the scrollContainer-vs- visibleHost branch of the SD-2495 scrollPageIntoView fix isn't unit- testable here (happy-dom doesn't propagate inline overflow through getComputedStyle, which is what findScrollableAncestor uses). --- .../bookmark-markers.test.ts | 16 +- .../inline-converters/cross-reference.test.ts | 21 ++ .../EditorInputManager.anchorClick.test.ts | 150 ++++++++++++++ .../tests/PresentationEditor.test.ts | 15 ++ ...crossReferenceImporter.integration.test.js | 191 ++++++++++++++++++ 5 files changed, 384 insertions(+), 9 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts index d0269be9ea..21d5b1f0ef 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts @@ -64,15 +64,13 @@ describe('bookmarkStartNodeToBlocks (SD-2454)', () => { }); }); - it('suppresses markers for auto-generated bookmarks (names starting with `_`)', () => { - // Matches Word behavior: `_Toc…`, `_Ref…`, `_GoBack` etc. are hidden from - // Show Bookmarks because they are internally generated for headings, - // fields, or navigation — showing them would clutter the document. - for (const name of ['_Toc1234', '_Ref506192326', '_GoBack']) { - const node: PMNode = { type: 'bookmarkStart', attrs: { name, id: '1' } }; - const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: true })); - expect(result, `should suppress marker for "${name}"`).toBeUndefined(); - } + // Matches Word behavior: `_Toc…`, `_Ref…`, `_GoBack` etc. are hidden from + // Show Bookmarks because they are internally generated for headings, + // fields, or navigation — showing them would clutter the document. + it.each(['_Toc1234', '_Ref506192326', '_GoBack'])('suppresses marker for auto-generated bookmark "%s"', (name) => { + const node: PMNode = { type: 'bookmarkStart', attrs: { name, id: '1' } }; + const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: true })); + expect(result).toBeUndefined(); }); it('still records bookmark position for cross-reference resolution regardless of showBookmarks', () => { diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts index 66cf1e30f9..a9dacf0792 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts @@ -85,4 +85,25 @@ describe('crossReferenceNodeToRun (SD-2495)', () => { ); expect(run!.link).toBeUndefined(); }); + + it('forwards node.marks to textNodeToRun so surrounding styles (italic, textStyle) survive', async () => { + // Guards against SD-2537's "preserve surrounding run styling" AC — + // a refactor that dropped node.marks from the synthesized text node + // would silently strip italic/color from every cross-reference. + const { textNodeToRun } = await import('./text-run.js'); + vi.mocked(textNodeToRun).mockClear(); + const marks = [ + { type: 'italic', attrs: {} }, + { type: 'textStyle', attrs: { color: '#ff0000' } }, + ]; + const node: PMNode = { + type: 'crossReference', + attrs: { resolvedText: '15', target: '_Ref1', instruction: 'REF _Ref1 \\h' }, + marks, + }; + crossReferenceNodeToRun(makeParams(node.attrs as Record, { node })); + + const call = vi.mocked(textNodeToRun).mock.calls.at(-1)?.[0]; + expect(call?.node?.marks).toEqual(marks); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts new file mode 100644 index 0000000000..1ee225aa10 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts @@ -0,0 +1,150 @@ +/** + * SD-2495 / SD-2537 regression guard for cross-reference click-to-navigate. + * + * The existing behavior before this PR only routed TOC-entry clicks through + * `goToAnchor`. Cross-reference rendered anchors (``) were + * dispatched as generic `superdoc-link-click` custom events — host apps had + * to handle navigation themselves, and most didn't, so clicks silently + * opened nothing. The fix generalized the internal-anchor branch in + * `#handleLinkClick` to cover every `#…` href. + * + * This test pins the new behavior: + * - clicks on `` invoke `goToAnchor('#someBookmark')` + * - the browser's default navigation is prevented + * so a future refactor that narrows the branch back to TOC-only breaks this. + */ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { + EditorInputManager, + type EditorInputDependencies, + type EditorInputCallbacks, +} from '../pointer-events/EditorInputManager.js'; + +describe('EditorInputManager — anchor-href click routing (SD-2537)', () => { + let manager: EditorInputManager; + let viewportHost: HTMLElement; + let visibleHost: HTMLElement; + let goToAnchor: Mock; + let mockEditor: { + isEditable: boolean; + state: { doc: { content: { size: number } }; selection: { $anchor: null } }; + view: { dispatch: Mock; dom: HTMLElement; focus: Mock; hasFocus: Mock }; + on: Mock; + off: Mock; + emit: Mock; + }; + + beforeEach(() => { + viewportHost = document.createElement('div'); + viewportHost.className = 'presentation-editor__viewport'; + visibleHost = document.createElement('div'); + visibleHost.className = 'presentation-editor__visible'; + visibleHost.appendChild(viewportHost); + document.body.appendChild(visibleHost); + + mockEditor = { + isEditable: true, + state: { doc: { content: { size: 100 } }, selection: { $anchor: null } }, + view: { dispatch: vi.fn(), dom: document.createElement('div'), focus: vi.fn(), hasFocus: vi.fn(() => false) }, + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }; + + const deps: EditorInputDependencies = { + getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getLayoutState: vi.fn(() => ({ layout: {} as never, blocks: [], measures: [] })), + getEpochMapper: vi.fn(() => ({ + mapPosFromLayoutToCurrentDetailed: vi.fn(() => ({ ok: true, pos: 12, toEpoch: 1 })), + })) as unknown as EditorInputDependencies['getEpochMapper'], + getViewportHost: vi.fn(() => viewportHost), + getVisibleHost: vi.fn(() => visibleHost), + getLayoutMode: vi.fn(() => 'vertical' as const), + getHeaderFooterSession: vi.fn(() => null), + getPageGeometryHelper: vi.fn(() => null), + getZoom: vi.fn(() => 1), + isViewLocked: vi.fn(() => false), + getDocumentMode: vi.fn(() => 'editing' as const), + getPageElement: vi.fn(() => null), + isSelectionAwareVirtualizationEnabled: vi.fn(() => false), + }; + + goToAnchor = vi.fn(); + const callbacks: EditorInputCallbacks = { + normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })), + scheduleSelectionUpdate: vi.fn(), + updateSelectionDebugHud: vi.fn(), + goToAnchor, + }; + + manager = new EditorInputManager(); + manager.setDependencies(deps); + manager.setCallbacks(callbacks); + manager.bind(); + }); + + afterEach(() => { + manager.destroy(); + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + const makeAnchor = (href: string): HTMLAnchorElement => { + const a = document.createElement('a'); + a.className = 'superdoc-link'; + a.setAttribute('href', href); + a.textContent = '15'; + viewportHost.appendChild(a); + return a; + }; + + const firePointerDown = (el: HTMLElement) => { + const PointerEventImpl = + (globalThis as unknown as { PointerEvent?: typeof PointerEvent }).PointerEvent ?? globalThis.MouseEvent; + el.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 10, + clientY: 10, + } as PointerEventInit), + ); + }; + + it('routes `#` anchor clicks through goToAnchor', () => { + const a = makeAnchor('#_Ref506192326'); + firePointerDown(a); + expect(goToAnchor).toHaveBeenCalledWith('#_Ref506192326'); + }); + + it('routes TOC-inside `#…` anchor clicks through goToAnchor (backward compat)', () => { + // The pre-PR behavior was TOC-only. Make sure generalizing the branch + // didn't accidentally exclude TOC entries. + const tocWrapper = document.createElement('span'); + tocWrapper.className = 'superdoc-toc-entry'; + const a = document.createElement('a'); + a.className = 'superdoc-link'; + a.setAttribute('href', '#_Toc123'); + tocWrapper.appendChild(a); + viewportHost.appendChild(tocWrapper); + + firePointerDown(a); + expect(goToAnchor).toHaveBeenCalledWith('#_Toc123'); + }); + + it('does not route external hrefs through goToAnchor', () => { + const a = makeAnchor('https://example.com/page'); + firePointerDown(a); + expect(goToAnchor).not.toHaveBeenCalled(); + }); + + it('does not route bare `#` (empty fragment) to goToAnchor', () => { + const a = makeAnchor('#'); + firePointerDown(a); + expect(goToAnchor).not.toHaveBeenCalled(); + }); +}); 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 40f9fd8b4e..218c90eef7 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 @@ -1025,6 +1025,21 @@ describe('PresentationEditor', () => { } }, ); + + // SD-2495 anchor-nav fix: #scrollPageIntoView must write to the real + // scroll ancestor, not just #visibleHost. This is enforced by the + // existing scrollToPage tests in this describe block — both mock + // scrollTop on the container (which serves as both visibleHost and the + // effective scroll target in the test harness) and assert that value + // gets written. Those tests would fail if the fix reverted to writing + // only to a zero-effect target. + // + // The "scrollContainer != visibleHost" branch (the exact shape of the + // dev app and real consumers) isn't unit-testable here because happy-dom + // doesn't propagate inline overflow styles through getComputedStyle, + // which is what #findScrollableAncestor uses. Browser-level verification + // lives in the SD-2495 behavior/manual testing; see the click-to- + // navigate agent-browser runs in the PR description. }); describe('setDocumentMode', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js new file mode 100644 index 0000000000..9e6c070c06 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js @@ -0,0 +1,191 @@ +/** + * Integration tests for SD-2495 / IT-949. + * + * Why this file exists (root cause recap): + * The `sd:crossReference` v3 translator was registered in `registeredHandlers` + * but NOT wired into the v2 importer's `defaultNodeListHandler` entity list. + * The passthrough fallback refused to wrap it (because it was "registered"), + * and no entity claimed it, so every REF field in every imported DOCX was + * silently dropped — erasing "Section 15" and every other cross-reference + * from the viewer. + * + * These tests exercise the full v2 body pipeline: preprocessor → dispatcher → + * entity handler → v3 translator → PM node. If any link in that chain breaks + * (most likely: the entity gets removed from the entities list during a + * refactor), the `crossReference` PM node disappears and these tests fail. + * + * The unit tests of the translator alone (`crossReference-translator.test.js`) + * don't catch this class of regression because they bypass the dispatcher. + */ +import { describe, it, expect } from 'vitest'; +import { defaultNodeListHandler } from './docxImporter.js'; +import { preProcessNodesForFldChar } from '../../field-references/index.js'; +import { crossReferenceEntity } from './crossReferenceImporter.js'; + +const createEditorStub = () => ({ + schema: { + nodes: { + run: { isInline: true, spec: { group: 'inline' } }, + crossReference: { isInline: true, spec: { group: 'inline', atom: true } }, + }, + }, +}); + +// Produces the exact XML shape Word emits for a REF field with `\h` — matches +// the Brillio lease fragment that produces the "Section 15" customer bug. +const buildRefField = (target, cachedText) => { + const run = (inner) => ({ + name: 'w:r', + elements: [{ name: 'w:rPr', elements: [{ name: 'w:i' }] }, ...inner], + }); + return [ + run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }]), + run([ + { + name: 'w:instrText', + attributes: { 'xml:space': 'preserve' }, + elements: [{ type: 'text', text: ` REF ${target} \\w \\h ` }], + }, + ]), + run([]), + run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }]), + run([{ name: 'w:t', elements: [{ type: 'text', text: cachedText }] }]), + run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }]), + ]; +}; + +describe('SD-2495 v2 importer wiring (IT-949 regression guard)', () => { + it('registers crossReferenceEntity in defaultNodeListHandler — guards the miss that produced IT-949', () => { + // This membership assertion is the cheapest possible regression guard + // against the exact bug root cause: if a future refactor drops the + // entity from the entities list, this fails immediately. + expect(defaultNodeListHandler().handlerEntities).toContain(crossReferenceEntity); + }); + + it('REF field inside a paragraph produces a crossReference PM node with cached text + target', () => { + const paragraph = { + name: 'w:p', + elements: [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'If terminated under this Section ' }] }], + }, + ...buildRefField('_Ref506192326', '15'), + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: ', Landlord...' }] }], + }, + ], + }; + + // Mirror the real body pipeline: preprocess fldChar runs into + // sd:crossReference, then dispatch through the v2 entity list. + const { processedNodes } = preProcessNodesForFldChar([paragraph], {}); + const nodeListHandler = defaultNodeListHandler(); + const pmNodes = nodeListHandler.handler({ + nodes: processedNodes, + docx: {}, + editor: createEditorStub(), + path: [], + }); + + const para = pmNodes.find((n) => n.type === 'paragraph'); + expect(para, 'paragraph should be produced').toBeTruthy(); + + const crossRefs = collectNodesOfType(para, 'crossReference'); + expect(crossRefs).toHaveLength(1); + expect(crossRefs[0].attrs.target).toBe('_Ref506192326'); + expect(crossRefs[0].attrs.resolvedText).toBe('15'); + // Instruction preserves the `\h` switch — the render layer reads this to + // decide whether to attach an internal-link mark (SD-2537 hyperlink vs + // plain-text variant). + expect(crossRefs[0].attrs.instruction).toMatch(/\\h/); + }); + + it('REF with \\h switch records the target so the render layer can navigate on click', () => { + const paragraph = { + name: 'w:p', + elements: [...buildRefField('_Ref123', '7')], + }; + + const { processedNodes } = preProcessNodesForFldChar([paragraph], {}); + const nodeListHandler = defaultNodeListHandler(); + const pmNodes = nodeListHandler.handler({ + nodes: processedNodes, + docx: {}, + editor: createEditorStub(), + path: [], + }); + + const crossRef = collectNodesOfType(pmNodes[0], 'crossReference')[0]; + expect(crossRef).toBeTruthy(); + // `display` is derived from switches so PM-adapter knows which variant. + // `\w \h` → numberFullContext. If this regresses, cross-ref visuals change. + expect(crossRef.attrs.display).toBe('numberFullContext'); + }); + + it('plain text surrounding a REF field still reaches PM unchanged (guards against REF dispatch consuming sibling runs)', () => { + // The `xml:space="preserve"` attribute is what keeps trailing whitespace + // around. Without it, OOXML parsers strip leading/trailing whitespace from + // w:t elements. The real customer document (Brillio lease) preserves this + // attribute on runs adjacent to REF fields so "Section " doesn't collapse + // to "Section" before the number. Mirror that here. + const textRun = (text) => ({ + name: 'w:r', + elements: [{ name: 'w:t', attributes: { 'xml:space': 'preserve' }, elements: [{ type: 'text', text }] }], + }); + const paragraph = { + name: 'w:p', + elements: [textRun('Section '), ...buildRefField('_Ref1', '15'), textRun(', Landlord')], + }; + + const { processedNodes } = preProcessNodesForFldChar([paragraph], {}); + const nodeListHandler = defaultNodeListHandler(); + const [pmPara] = nodeListHandler.handler({ + nodes: processedNodes, + docx: {}, + editor: createEditorStub(), + path: [], + }); + + // Children of the paragraph, in order, excluding the crossReference (it's + // an atom — its cached text contributes to the visual output but lives + // inside the xref node, not beside it). We care here about the SURROUNDING + // text surviving unchanged. + const collectSiblingTextBeforeAndAfterXref = (paraNode) => { + const parts = { before: '', after: '' }; + let sawXref = false; + const visitChildren = (nodes) => { + for (const child of nodes ?? []) { + if (child?.type === 'crossReference') { + sawXref = true; + continue; + } + if (Array.isArray(child?.content)) visitChildren(child.content); + else if (child?.type === 'text' && typeof child.text === 'string') { + if (sawXref) parts.after += child.text; + else parts.before += child.text; + } + } + }; + visitChildren(paraNode.content ?? []); + return parts; + }; + + const { before, after } = collectSiblingTextBeforeAndAfterXref(pmPara); + expect(before).toBe('Section '); + expect(after).toBe(', Landlord'); + }); +}); + +/** Collect all descendants of a given type from a nested PM node tree. */ +function collectNodesOfType(root, type) { + const out = []; + const visit = (node) => { + if (!node) return; + if (node.type === type) out.push(node); + if (Array.isArray(node.content)) node.content.forEach(visit); + }; + visit(root); + return out; +} From e7a142c49a4e5b0c4924a43135c400568ace5570 Mon Sep 17 00:00:00 2001 From: Kendall Ernst Date: Wed, 22 Apr 2026 05:42:02 -0700 Subject: [PATCH 05/10] feat(pm-adapter): synthesize internal link for PAGEREF with \h switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the pattern added for crossReference in #2882. When a PAGEREF field instruction carries the `\h` switch, attach a FlowRunLink via `buildFlowRunLink({ anchor: bookmarkId })` so clicks on the rendered page number navigate to the referenced bookmark through the existing anchor-link routing (`EditorInputManager.#handleLinkClick` → `goToAnchor`). Previously the PAGEREF inline converter emitted the `pageReference` token run with `pageRefMetadata.bookmarkId` but no `link`, so the DOM layer never produced a clickable element for PAGEREFs. TOC entries and other hyperlinked page references imported from Word therefore failed to navigate on click, even though Word honored the `\h` switch. Depends on #2882 for `buildFlowRunLink` export and the generalized anchor-click routing. --- .../inline-converters/page-reference.test.ts | 91 +++++++++++++++++++ .../inline-converters/page-reference.ts | 14 +++ 2 files changed, 105 insertions(+) create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts new file mode 100644 index 0000000000..7915cdb102 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { TextRun } from '@superdoc/contracts'; +import type { PMNode } from '../../types.js'; +import type { InlineConverterParams } from './common.js'; + +vi.mock('./text-run.js', () => ({ + textNodeToRun: vi.fn( + (params: InlineConverterParams): TextRun => ({ + text: params.node.text || '', + fontFamily: params.defaultFont, + fontSize: params.defaultSize, + }), + ), +})); + +vi.mock('../../sdt/index.js', () => ({ + getNodeInstruction: vi.fn((node: PMNode) => { + const attrs = (node.attrs ?? {}) as Record; + return typeof attrs.instruction === 'string' ? attrs.instruction : ''; + }), +})); + +vi.mock('@superdoc/style-engine/ooxml', () => ({ + resolveRunProperties: vi.fn(() => ({})), +})); + +import { pageReferenceNodeToBlock } from './page-reference.js'; + +function makeParams( + attrs: Record, + overrides: Partial = {}, +): InlineConverterParams { + const node: PMNode = { + type: 'pageReference', + attrs, + content: [{ type: 'text', text: '15' } as PMNode], + }; + return { + node, + positions: new WeakMap(), + defaultFont: 'Calibri', + defaultSize: 16, + inheritedMarks: [], + sdtMetadata: undefined, + hyperlinkConfig: { enableRichHyperlinks: false }, + themeColors: undefined, + runProperties: undefined, + paragraphProperties: undefined, + converterContext: {} as unknown as InlineConverterParams['converterContext'], + enableComments: false, + visitNode: vi.fn(), + bookmarks: undefined, + tabOrdinal: 0, + paragraphAttrs: {}, + nextBlockId: vi.fn(), + ...overrides, + } as InlineConverterParams; +} + +describe('pageReferenceNodeToBlock', () => { + it('emits a pageReference token run with the resolved fallback text and bookmarkId', () => { + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123 \\h' })) as TextRun | undefined; + expect(run).toBeDefined(); + expect(run!.token).toBe('pageReference'); + expect(run!.pageRefMetadata?.bookmarkId).toBe('_Toc123'); + }); + + it('synthesizes an internal link when the instruction has the \\h switch', () => { + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123 \\h' })) as TextRun | undefined; + expect(run!.link).toBeDefined(); + expect(run!.link?.anchor).toBe('_Toc123'); + }); + + it('does not attach a link when the \\h switch is absent', () => { + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123' })) as TextRun | undefined; + expect(run!.link).toBeUndefined(); + }); + + it('does not match a literal `h` character as the \\h switch', () => { + // Guards against naive substring check — instruction like `PAGEREF bh-target` + // must not produce a hyperlink just because `h` appears somewhere. + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF bh-target' })) as TextRun | undefined; + expect(run!.link).toBeUndefined(); + }); + + it('handles bookmark ids wrapped in quotes in the instruction', () => { + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF "_Toc123" \\h' })) as TextRun | undefined; + expect(run!.pageRefMetadata?.bookmarkId).toBe('_Toc123'); + expect(run!.link?.anchor).toBe('_Toc123'); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts index 8d647b58eb..b1c5615c94 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts @@ -3,6 +3,7 @@ import { type InlineConverterParams } from './common'; import { getNodeInstruction } from '../../sdt/index.js'; import type { PMNode, PMMark } from '../../types.js'; import { textNodeToRun } from './text-run.js'; +import { buildFlowRunLink } from '../../marks/links.js'; import { type RunProperties, resolveRunProperties } from '@superdoc/style-engine/ooxml'; export function pageReferenceNodeToBlock(params: InlineConverterParams): TextRun | void { @@ -68,6 +69,19 @@ export function pageReferenceNodeToBlock(params: InlineConverterParams): TextRun bookmarkId, instruction, }; + + // When the PAGEREF instruction carries the `\h` switch, render as an + // internal hyperlink pointing at `#` so clicks navigate to + // the target bookmark via the existing anchor-link navigation path. + if (/\\h\b/.test(instruction)) { + const synthesized = buildFlowRunLink({ anchor: bookmarkId }); + if (synthesized) { + (tokenRun as TextRun).link = (tokenRun as TextRun).link + ? { ...(tokenRun as TextRun).link, ...synthesized, anchor: bookmarkId } + : synthesized; + } + } + if (sdtMetadata) { tokenRun.sdt = sdtMetadata; } From 7f19358f45a97bfa45a4e24e1dcf2a73a1611c92 Mon Sep 17 00:00:00 2001 From: Kendall Ernst Date: Wed, 22 Apr 2026 06:11:53 -0700 Subject: [PATCH 06/10] fix(pm-adapter): match PAGEREF \h switch case-insensitively Word field switches are case-insensitive per the field-code grammar, so `\H` should produce the hyperlink the same as `\h`. Reviewer (codex-connector) flagged that the original `/\\h\b/` regex skipped the link synthesis for instructions like `PAGEREF _Toc123 \H`, leaving them non-navigable even though the author had requested hyperlink behavior. Adds the `i` flag and a regression test with an uppercase switch. --- .../converters/inline-converters/page-reference.test.ts | 7 +++++++ .../src/converters/inline-converters/page-reference.ts | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts index 7915cdb102..11aaf36913 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts @@ -88,4 +88,11 @@ describe('pageReferenceNodeToBlock', () => { expect(run!.pageRefMetadata?.bookmarkId).toBe('_Toc123'); expect(run!.link?.anchor).toBe('_Toc123'); }); + + it('matches the \\h switch case-insensitively', () => { + // Word field switches are case-insensitive — `\H` should produce a link + // just like `\h`. + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123 \\H' })) as TextRun | undefined; + expect(run!.link?.anchor).toBe('_Toc123'); + }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts index b1c5615c94..0b40c48830 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts @@ -73,7 +73,9 @@ export function pageReferenceNodeToBlock(params: InlineConverterParams): TextRun // When the PAGEREF instruction carries the `\h` switch, render as an // internal hyperlink pointing at `#` so clicks navigate to // the target bookmark via the existing anchor-link navigation path. - if (/\\h\b/.test(instruction)) { + // Field switches in Word field instructions are case-insensitive, so + // `\h` and `\H` should both produce the hyperlink. + if (/\\h\b/i.test(instruction)) { const synthesized = buildFlowRunLink({ anchor: bookmarkId }); if (synthesized) { (tokenRun as TextRun).link = (tokenRun as TextRun).link From 7d7e07360bb0eee26a138f7417854df5bc2477ea Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 22 Apr 2026 11:56:40 -0300 Subject: [PATCH 07/10] test: add coverage for standalone PAGEREF \h + REF-family preprocessors Behavior test (tests/behavior/tests/navigation/pageref-standalone-click.spec.ts): Covers the PR's load-bearing case - a PAGEREF \h field NOT wrapped in a . The existing toc-anchor-scroll.spec.ts only exercises the wrapped-in-hyperlink shape, where the outer link mark already propagates via marksAsAttrs and the PR is a no-op. Fixtures exercise both \h and \H (case-insensitivity per ECMA-376 17.16.1). Preprocessor unit tests (ref/noteref/styleref): These three importer modules were added in #2882 without tests. Each verifies the preprocessor produces a sd:crossReference node with the right fieldType and preserves the instruction text verbatim. --- .../noteref-preprocessor.test.js | 34 ++++++++++ .../ref-preprocessor.test.js | 36 +++++++++++ .../styleref-preprocessor.test.js | 36 +++++++++++ .../fixtures/pageref-standalone-h.docx | Bin 0 -> 13825 bytes .../pageref-standalone-uppercase-h.docx | Bin 0 -> 13828 bytes .../pageref-standalone-click.spec.ts | 60 ++++++++++++++++++ 6 files changed, 166 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js create mode 100644 tests/behavior/tests/navigation/fixtures/pageref-standalone-h.docx create mode 100644 tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx create mode 100644 tests/behavior/tests/navigation/pageref-standalone-click.spec.ts diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js new file mode 100644 index 0000000000..7e7c22a22c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js @@ -0,0 +1,34 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { preProcessNoterefInstruction } from './noteref-preprocessor.js'; + +describe('preProcessNoterefInstruction', () => { + const mockNodesToCombine = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }] }]; + + it('wraps the cached runs in a sd:crossReference node with NOTEREF fieldType', () => { + const instruction = 'NOTEREF _Ref9876 \\h'; + const result = preProcessNoterefInstruction(mockNodesToCombine, instruction); + expect(result).toEqual([ + { + name: 'sd:crossReference', + type: 'element', + attributes: { + instruction: 'NOTEREF _Ref9876 \\h', + fieldType: 'NOTEREF', + }, + elements: mockNodesToCombine, + }, + ]); + }); + + it('preserves the instruction text verbatim including the \\f footnote switch', () => { + const instruction = 'NOTEREF _Ref9876 \\h \\f'; + const [node] = preProcessNoterefInstruction(mockNodesToCombine, instruction); + expect(node.attributes.instruction).toBe('NOTEREF _Ref9876 \\h \\f'); + }); + + it('handles an empty runs list', () => { + const result = preProcessNoterefInstruction([], 'NOTEREF _Ref9876 \\h'); + expect(result[0].elements).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js new file mode 100644 index 0000000000..889fbaed6f --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js @@ -0,0 +1,36 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { preProcessRefInstruction } from './ref-preprocessor.js'; + +describe('preProcessRefInstruction', () => { + const mockNodesToCombine = [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Section 15' }] }] }, + ]; + + it('wraps the cached runs in a sd:crossReference node with REF fieldType', () => { + const instruction = 'REF _Ref123456 \\h'; + const result = preProcessRefInstruction(mockNodesToCombine, instruction); + expect(result).toEqual([ + { + name: 'sd:crossReference', + type: 'element', + attributes: { + instruction: 'REF _Ref123456 \\h', + fieldType: 'REF', + }, + elements: mockNodesToCombine, + }, + ]); + }); + + it('preserves the instruction text verbatim including all switches', () => { + const instruction = 'REF _Ref123 \\h \\w \\* MERGEFORMAT'; + const [node] = preProcessRefInstruction(mockNodesToCombine, instruction); + expect(node.attributes.instruction).toBe('REF _Ref123 \\h \\w \\* MERGEFORMAT'); + }); + + it('handles an empty runs list', () => { + const result = preProcessRefInstruction([], 'REF _Ref123 \\h'); + expect(result[0].elements).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js new file mode 100644 index 0000000000..d386fa0c71 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js @@ -0,0 +1,36 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { preProcessStylerefInstruction } from './styleref-preprocessor.js'; + +describe('preProcessStylerefInstruction', () => { + const mockNodesToCombine = [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Heading 1' }] }] }, + ]; + + it('wraps the cached runs in a sd:crossReference node with STYLEREF fieldType', () => { + const instruction = 'STYLEREF "Heading 1" \\l'; + const result = preProcessStylerefInstruction(mockNodesToCombine, instruction); + expect(result).toEqual([ + { + name: 'sd:crossReference', + type: 'element', + attributes: { + instruction: 'STYLEREF "Heading 1" \\l', + fieldType: 'STYLEREF', + }, + elements: mockNodesToCombine, + }, + ]); + }); + + it('preserves quoted style names that contain spaces', () => { + const instruction = 'STYLEREF "Last Name" \\l'; + const [node] = preProcessStylerefInstruction(mockNodesToCombine, instruction); + expect(node.attributes.instruction).toBe('STYLEREF "Last Name" \\l'); + }); + + it('handles an empty runs list', () => { + const result = preProcessStylerefInstruction([], 'STYLEREF "Heading 1"'); + expect(result[0].elements).toEqual([]); + }); +}); diff --git a/tests/behavior/tests/navigation/fixtures/pageref-standalone-h.docx b/tests/behavior/tests/navigation/fixtures/pageref-standalone-h.docx new file mode 100644 index 0000000000000000000000000000000000000000..406033ddbc73f2ef61405a712fc8faf1b18da8a4 GIT binary patch literal 13825 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 G|NbBE&;mvP literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx b/tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx new file mode 100644 index 0000000000000000000000000000000000000000..06829ef754943d472aa3c1a8547d88dbf0474e74 GIT binary patch literal 13828 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 element. +// The wrapped case (Word TOCs) is covered by toc-anchor-scroll.spec.ts. +// +// Both fixtures are a 7-entry TOC where the first entry's outer +// has been removed, leaving only the inner PAGEREF \h field for that row. +// The other entries retain their wrappers and serve as a control. +// +// The "Introduction" entry uses bookmark id _Toc227765979. Its page number +// anchor only exists if the PAGEREF \h switch produces a link on its own. + +test.skip(!fs.existsSync(LOWERCASE_DOC), 'Standalone PAGEREF fixture missing'); +test.skip(!fs.existsSync(UPPERCASE_DOC), 'Uppercase PAGEREF fixture missing'); + +test('@behavior SD-2537: standalone PAGEREF with \\h renders a clickable anchor', async ({ superdoc }) => { + await superdoc.loadDocument(LOWERCASE_DOC); + await superdoc.waitForStable(2000); + + // The first TOC entry has its outer stripped. The page + // number should still be an anchor because the PAGEREF \h synthesizes one. + const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]'); + await expect(pageNumberLink).toBeVisible({ timeout: 10_000 }); +}); + +test('@behavior SD-2537: clicking standalone PAGEREF navigates to the bookmark', async ({ superdoc }) => { + await superdoc.loadDocument(LOWERCASE_DOC); + await superdoc.waitForStable(2000); + + const selBefore = await superdoc.getSelection(); + const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]').first(); + await expect(pageNumberLink).toBeVisible({ timeout: 10_000 }); + await pageNumberLink.click(); + await superdoc.waitForStable(2000); + + // goToAnchor moves the caret to the bookmark target. + const selAfter = await superdoc.getSelection(); + expect(selAfter.from).not.toBe(selBefore.from); +}); + +test('@behavior SD-2537: standalone PAGEREF with uppercase \\H also renders a clickable anchor', async ({ + superdoc, +}) => { + // ECMA-376 §17.16.1 says field switches are case-insensitive. \H should + // behave identically to \h. + await superdoc.loadDocument(UPPERCASE_DOC); + await superdoc.waitForStable(2000); + + const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]'); + await expect(pageNumberLink).toBeVisible({ timeout: 10_000 }); +}); From 4c435148221a6054bef35180e446a615f41bb4eb Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 22 Apr 2026 12:00:47 -0300 Subject: [PATCH 08/10] test(superdoc): cover setShowBookmarks propagation and no-op guard Closes the Codecov patch-coverage gap on SuperDoc.js flagged on PR #2899. The uncovered setShowBookmarks method came in via the SD-2454 merge and had no test for its config mutation, no-op short-circuit, or Boolean() coercion. Models the existing setDisableContextMenu test pattern. --- packages/superdoc/src/core/SuperDoc.test.js | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 14f1c2dc95..4320c4f18a 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -868,6 +868,50 @@ describe('SuperDoc core', () => { expect(setOptions).toHaveBeenLastCalledWith({ disableContextMenu: false }); }); + it('propagates setShowBookmarks to presentation editors and skips no-op toggles', async () => { + const { superdocStore } = createAppHarness(); + const setShowBookmarks = vi.fn(); + const docStub = { + getPresentationEditor: vi.fn(() => ({ setShowBookmarks })), + }; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + role: 'editor', + user: { name: 'Jane', email: 'jane@example.com' }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + superdocStore.documents = [docStub]; + + // Enabling flips the flag and reaches the presentation editor. + instance.setShowBookmarks(true); + expect(instance.config.layoutEngineOptions.showBookmarks).toBe(true); + expect(setShowBookmarks).toHaveBeenCalledWith(true); + + // Same value again is a no-op. + instance.setShowBookmarks(true); + expect(setShowBookmarks).toHaveBeenCalledTimes(1); + + // Disabling flips it back. + instance.setShowBookmarks(false); + expect(instance.config.layoutEngineOptions.showBookmarks).toBe(false); + expect(setShowBookmarks).toHaveBeenLastCalledWith(false); + + // Default argument coerces to true. + instance.setShowBookmarks(); + expect(setShowBookmarks).toHaveBeenLastCalledWith(true); + + // Non-boolean values go through Boolean(). + instance.setShowBookmarks(null); + expect(setShowBookmarks).toHaveBeenLastCalledWith(false); + }); + it('skips rendering comments list when role is viewer', async () => { createAppHarness(); From c421354eed6c4c18f57ddab3e64d5d273d644417 Mon Sep 17 00:00:00 2001 From: Kendall Ernst Date: Wed, 22 Apr 2026 09:01:16 -0700 Subject: [PATCH 09/10] refactor(pm-adapter): drop TextRun casts + case-insensitive \h in cross-reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #2899: - page-reference.ts: remove redundant `as TextRun` casts — textNodeToRun already returns TextRun, so the casts are noise (cross-reference.ts:34 already does this cleanly). Shorten the \h comment. - cross-reference.ts: add the `i` flag to the \h switch regex to match ECMA-376 §17.16.1, same fix as 7f19358 for page-reference. Add a regression test covering `\H`. --- .../inline-converters/cross-reference.test.ts | 7 +++++++ .../inline-converters/cross-reference.ts | 3 ++- .../inline-converters/page-reference.ts | 18 ++++++------------ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts index a9dacf0792..4f5097bbdd 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts @@ -86,6 +86,13 @@ describe('crossReferenceNodeToRun (SD-2495)', () => { expect(run!.link).toBeUndefined(); }); + it('matches the \\H switch case-insensitively per ECMA-376 §17.16.1', () => { + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\H' }), + ); + expect(run!.link?.anchor).toBe('_Ref506192326'); + }); + it('forwards node.marks to textNodeToRun so surrounding styles (italic, textStyle) survive', async () => { // Guards against SD-2537's "preserve surrounding run styling" AC — // a refactor that dropped node.marks from the synthesized text node diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts index 1bc05ff8c7..d74ad61cbd 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts @@ -28,7 +28,8 @@ export function crossReferenceNodeToRun(params: InlineConverterParams): TextRun node: { type: 'text', text: resolvedText, marks: [...(node.marks ?? [])] } as PMNode, }); - if (target && /\\h\b/.test(instruction)) { + // \h switch - case-insensitive per ECMA-376 §17.16.1. + if (target && /\\h\b/i.test(instruction)) { const synthesized = buildFlowRunLink({ anchor: target }); if (synthesized) { run.link = run.link ? { ...run.link, ...synthesized, anchor: target } : synthesized; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts index 0b40c48830..aa4c335907 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts @@ -61,26 +61,20 @@ export function pageReferenceNodeToBlock(params: InlineConverterParams): TextRun // Copy PM positions from parent pageReference node if (pageRefPos) { - (tokenRun as TextRun).pmStart = pageRefPos.start; - (tokenRun as TextRun).pmEnd = pageRefPos.end; + tokenRun.pmStart = pageRefPos.start; + tokenRun.pmEnd = pageRefPos.end; } - (tokenRun as TextRun).token = 'pageReference'; - (tokenRun as TextRun).pageRefMetadata = { + tokenRun.token = 'pageReference'; + tokenRun.pageRefMetadata = { bookmarkId, instruction, }; - // When the PAGEREF instruction carries the `\h` switch, render as an - // internal hyperlink pointing at `#` so clicks navigate to - // the target bookmark via the existing anchor-link navigation path. - // Field switches in Word field instructions are case-insensitive, so - // `\h` and `\H` should both produce the hyperlink. + // \h switch - case-insensitive per ECMA-376 §17.16.1. if (/\\h\b/i.test(instruction)) { const synthesized = buildFlowRunLink({ anchor: bookmarkId }); if (synthesized) { - (tokenRun as TextRun).link = (tokenRun as TextRun).link - ? { ...(tokenRun as TextRun).link, ...synthesized, anchor: bookmarkId } - : synthesized; + tokenRun.link = tokenRun.link ? { ...tokenRun.link, ...synthesized, anchor: bookmarkId } : synthesized; } } From 5bc612eafc88ea819b486a6da13da17d82f19200 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 22 Apr 2026 18:52:10 -0300 Subject: [PATCH 10/10] test: add unit coverage for scroll fan-out when ancestor != host Stubs window.getComputedStyle to mark a wrapper element as scrollable so #findScrollableAncestor returns the wrapper, then asserts both the wrapper and the visibleHost receive scrollTop. This pins the SD-2495 fix: a revert to the pre-fix one-liner (writing only to the visibleHost) now fails. --- .../tests/PresentationEditor.test.ts | 110 +++++++++++++++--- 1 file changed, 96 insertions(+), 14 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 218c90eef7..60e212d368 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 @@ -1026,20 +1026,102 @@ describe('PresentationEditor', () => { }, ); - // SD-2495 anchor-nav fix: #scrollPageIntoView must write to the real - // scroll ancestor, not just #visibleHost. This is enforced by the - // existing scrollToPage tests in this describe block — both mock - // scrollTop on the container (which serves as both visibleHost and the - // effective scroll target in the test harness) and assert that value - // gets written. Those tests would fail if the fix reverted to writing - // only to a zero-effect target. - // - // The "scrollContainer != visibleHost" branch (the exact shape of the - // dev app and real consumers) isn't unit-testable here because happy-dom - // doesn't propagate inline overflow styles through getComputedStyle, - // which is what #findScrollableAncestor uses. Browser-level verification - // lives in the SD-2495 behavior/manual testing; see the click-to- - // navigate agent-browser runs in the PR description. + // SD-2495 anchor-nav fix: when the scrollable ancestor differs from the + // visible host (the real-world shape - the host is overflow:visible and + // a parent constrains height), scrollTop must land on the ancestor, not + // just the host. Happy-dom doesn't propagate inline overflow through + // getComputedStyle, so we stub it to mark a wrapper as scrollable. + it('writes scrollTop to both the scrollable ancestor and the visibleHost when they differ', async () => { + const scrollableWrapper = document.createElement('div'); + document.body.removeChild(container); + scrollableWrapper.appendChild(container); + document.body.appendChild(scrollableWrapper); + + const originalGetComputedStyle = window.getComputedStyle.bind(window); + const getComputedStyleSpy = vi + .spyOn(window, 'getComputedStyle') + .mockImplementation((el: Element, pseudo?: string | null) => { + if (el === scrollableWrapper) { + return { overflowY: 'auto' } as CSSStyleDeclaration; + } + return originalGetComputedStyle(el, pseudo ?? null); + }); + + mockIncrementalLayout.mockResolvedValueOnce(buildMixedPageLayout()); + + editor = new PresentationEditor({ + element: container, + documentId: 'test-scroll-multi-target', + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + mode: 'docx', + layoutEngineOptions: { + virtualization: { enabled: true, gap: 10, window: 1, overscan: 0 }, + }, + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement; + const expectedPageTop = 600 + 10 + 1200 + 10; + + let wrapperScrollTop = 0; + let hostScrollTop = 0; + let mountedPageEl: HTMLElement | null = null; + const mountPageIfScrolled = (value: number) => { + if (!mountedPageEl && Math.abs(value - expectedPageTop) < 0.5) { + mountedPageEl = document.createElement('div'); + mountedPageEl.setAttribute('data-page-index', '2'); + Object.defineProperty(mountedPageEl, 'scrollIntoView', { + value: vi.fn(), + configurable: true, + }); + pagesHost.appendChild(mountedPageEl); + } + }; + Object.defineProperty(scrollableWrapper, 'scrollTop', { + get: () => wrapperScrollTop, + set: (next) => { + wrapperScrollTop = Number(next); + mountPageIfScrolled(wrapperScrollTop); + }, + configurable: true, + }); + Object.defineProperty(container, 'scrollTop', { + get: () => hostScrollTop, + set: (next) => { + hostScrollTop = Number(next); + mountPageIfScrolled(hostScrollTop); + }, + configurable: true, + }); + + let now = 0; + const performanceNowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now); + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + now += 100; + cb(now); + return 1; + }); + + try { + const didScroll = await editor.scrollToPage(3, 'auto'); + + expect(didScroll).toBe(true); + // Both writes must land: the ancestor (real scrollable) AND the host + // (back-compat for layouts where the host itself is scrollable). + expect(wrapperScrollTop).toBe(expectedPageTop); + expect(hostScrollTop).toBe(expectedPageTop); + } finally { + rafSpy.mockRestore(); + performanceNowSpy.mockRestore(); + getComputedStyleSpy.mockRestore(); + // Restore DOM layout so the outer afterEach can clean up normally. + if (scrollableWrapper.parentNode) { + document.body.removeChild(scrollableWrapper); + } + document.body.appendChild(container); + } + }); }); describe('setDocumentMode', () => {