diff --git a/packages/super-editor/src/editors/v1/components/context-menu/constants.js b/packages/super-editor/src/editors/v1/components/context-menu/constants.js index 8a8f3fad52..8242b96968 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/constants.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/constants.js @@ -13,6 +13,7 @@ import pasteIconSvg from '@superdoc/common/icons/paste-solid.svg?raw'; import checkIconSvg from '@superdoc/common/icons/check-solid.svg?raw'; import xMarkIconSvg from '@superdoc/common/icons/xmark-solid.svg?raw'; import paintRollerIconSvg from '@superdoc/common/icons/paint-roller-solid.svg?raw'; +import rotateRightIconSvg from '@superdoc/common/icons/rotate-right-solid.svg?raw'; export const ICONS = { addRowBefore: plusIconSvg, @@ -37,6 +38,7 @@ export const ICONS = { trackChangesAccept: checkIconSvg, trackChangesReject: xMarkIconSvg, cellBackground: paintRollerIconSvg, + updateTableOfContents: rotateRightIconSvg, }; // Table actions constant @@ -65,6 +67,7 @@ export const TEXTS = { trackChangesAccept: 'Accept change', trackChangesReject: 'Reject change', cellBackground: 'Cell background', + updateTableOfContents: 'Update table of contents', }; export const tableActionsOptions = [ diff --git a/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js b/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js index d9445b0c1c..543825b944 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js @@ -337,6 +337,25 @@ export function getItems(context, customItems = [], includeDefaultItems = true) return context.trigger === TRIGGERS.click && (context.isCellSelection || context.isInTable); }, }, + { + id: 'update-table-of-contents', + label: TEXTS.updateTableOfContents, + icon: ICONS.updateTableOfContents, + isDefault: true, + action: (editor, context) => { + const sdBlockId = context.tocAncestor?.sdBlockId; + if (!sdBlockId) return; + try { + editor.doc?.toc?.update?.({ + target: { kind: 'block', nodeType: 'tableOfContents', nodeId: sdBlockId }, + mode: 'all', + }); + } catch (error) { + console.warn('[ContextMenu] toc.update failed:', error); + } + }, + showWhen: (context) => context.trigger === TRIGGERS.click && !!context.tocAncestor?.sdBlockId, + }, ], }, { diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js index a5e5e4e9c8..5538b4bd89 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js @@ -31,6 +31,7 @@ vi.mock('../constants.js', () => ({ trackChangesAccept: 'Accept Tracked Changes', trackChangesReject: 'Reject Tracked Changes', cellBackground: 'Cell background', + updateTableOfContents: 'Update table of contents', }, ICONS: { ai: 'ai-icon', @@ -42,6 +43,7 @@ vi.mock('../constants.js', () => ({ copy: 'copy-icon', paste: 'paste-icon', cellBackground: 'cell-background-icon', + updateTableOfContents: 'rotate-right-icon', }, TRIGGERS: { slash: 'slash', @@ -1059,4 +1061,66 @@ describe('menuItems.js', () => { expect(callOrder).toEqual(['setSelection', 'handleClipboardPaste']); }); }); + + // --------------------------------------------------------------------------- + // SD-2664 — "Update table of contents" item + // --------------------------------------------------------------------------- + + describe('update-table-of-contents item', () => { + const findItem = (sections) => { + for (const section of sections) { + const item = section.items.find((it) => it.id === 'update-table-of-contents'); + if (item) return item; + } + return undefined; + }; + + it('appears when right-clicking inside a TOC (tocAncestor.sdBlockId set, click trigger)', () => { + mockContext = createMockContext({ + editor: mockEditor, + trigger: TRIGGERS.click, + tocAncestor: { node: {}, pos: 5, sdBlockId: 'toc-1' }, + }); + const sections = getItems(mockContext); + expect(findItem(sections)).toBeDefined(); + }); + + it('is hidden when no tocAncestor is present', () => { + mockContext = createMockContext({ + editor: mockEditor, + trigger: TRIGGERS.click, + tocAncestor: null, + }); + const sections = getItems(mockContext); + expect(findItem(sections)).toBeUndefined(); + }); + + it('is hidden on the slash trigger even when inside a TOC', () => { + mockContext = createMockContext({ + editor: mockEditor, + trigger: TRIGGERS.slash, + tocAncestor: { node: {}, pos: 5, sdBlockId: 'toc-1' }, + }); + const sections = getItems(mockContext); + expect(findItem(sections)).toBeUndefined(); + }); + + it('action invokes editor.doc.toc.update with the resolved sdBlockId and mode "all"', () => { + const update = vi.fn(); + const ed = { ...mockEditor, doc: { toc: { update } } }; + mockContext = createMockContext({ + editor: ed, + trigger: TRIGGERS.click, + tocAncestor: { node: {}, pos: 5, sdBlockId: 'toc-42' }, + }); + const sections = getItems(mockContext); + const item = findItem(sections); + expect(item).toBeDefined(); + item.action(ed, mockContext); + expect(update).toHaveBeenCalledWith({ + target: { kind: 'block', nodeType: 'tableOfContents', nodeId: 'toc-42' }, + mode: 'all', + }); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js index 28d6796901..5d290eb134 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js @@ -163,6 +163,9 @@ describe('utils.js', () => { // Proofing context (null when no PresentationEditor proofing active) proofingContext: null, + + // TOC ancestor (null when not inside a tableOfContents node) + tocAncestor: null, }); // Verify clipboard is not read during context gathering diff --git a/packages/super-editor/src/editors/v1/components/context-menu/utils.js b/packages/super-editor/src/editors/v1/components/context-menu/utils.js index 04cdc09228..2d1e47a5e7 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/utils.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/utils.js @@ -1,5 +1,6 @@ import { selectionHasNodeOrMark } from '../cursor-helpers.js'; import { tableActionsOptions } from './constants.js'; +import { findTocAncestor } from '@extensions/table-of-contents/find-toc-ancestor.js'; import { markRaw } from 'vue'; import { undoDepth, redoDepth } from 'prosemirror-history'; import { yUndoPluginKey } from 'y-prosemirror'; @@ -123,6 +124,7 @@ export async function getEditorContext(editor, event) { }; const structureFromResolvedPos = pos !== null ? getStructureFromResolvedPos(state, pos) : null; + const tocAncestor = pos !== null ? findTocAncestor(state.doc, pos) : null; const isInTable = structureFromResolvedPos?.isInTable ?? selectionHasNodeOrMark(state, 'table', { requireEnds: true }); const isInList = structureFromResolvedPos?.isInList ?? selectionIncludesListParagraph(state); @@ -223,6 +225,7 @@ export async function getEditorContext(editor, event) { editor, trackedChanges, proofingContext, + tocAncestor, }; } diff --git a/packages/super-editor/src/editors/v1/core/InputRule.js b/packages/super-editor/src/editors/v1/core/InputRule.js index 2de4ca20c1..9d95088b0a 100644 --- a/packages/super-editor/src/editors/v1/core/InputRule.js +++ b/packages/super-editor/src/editors/v1/core/InputRule.js @@ -473,7 +473,12 @@ export function handleHtmlPaste(html, editor, source) { // Check if the pasted content is a single paragraph const isSingleParagraph = doc.childCount === 1 && doc.firstChild.type.name === 'paragraph'; - if (isInParagraph && isSingleParagraph) { + // Heading paragraphs must keep their wrapper so the styleId survives — + // unwrapping silently strips the heading style and TOC rebuilds miss it. + const sourceStyleId = isSingleParagraph ? (doc.firstChild.attrs?.paragraphProperties?.styleId ?? null) : null; + const sourceIsHeading = typeof sourceStyleId === 'string' && /^Heading[1-9]$/i.test(sourceStyleId); + + if (isInParagraph && isSingleParagraph && !sourceIsHeading) { // Extract the contents of the paragraph and paste only those const paragraphContent = doc.firstChild.content; const tr = state.tr.replaceSelectionWith(paragraphContent, false); diff --git a/packages/super-editor/src/editors/v1/core/helpers/clipboardFragmentAnnotate.js b/packages/super-editor/src/editors/v1/core/helpers/clipboardFragmentAnnotate.js index 7f96bbebd3..b4bb8de26c 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/clipboardFragmentAnnotate.js +++ b/packages/super-editor/src/editors/v1/core/helpers/clipboardFragmentAnnotate.js @@ -35,7 +35,7 @@ export function annotateFragmentDomWithClipboardData(container, fragment, editor * * @param {HTMLElement} container cloned selection HTML * @param {import('prosemirror-view').EditorView} view - * @param {import('../Editor').Editor} editor + * @param {import('../Editor').Editor} [editor] optional editor instance — function bails out when missing */ export function mergeSerializedClipboardMetadataIntoDomContainer(container, view, editor) { if (!editor || !view || typeof document === 'undefined') return; diff --git a/packages/super-editor/src/editors/v1/core/renderers/ProseMirrorRenderer.ts b/packages/super-editor/src/editors/v1/core/renderers/ProseMirrorRenderer.ts index 8c6c891c49..5d2e8f8def 100644 --- a/packages/super-editor/src/editors/v1/core/renderers/ProseMirrorRenderer.ts +++ b/packages/super-editor/src/editors/v1/core/renderers/ProseMirrorRenderer.ts @@ -1,6 +1,6 @@ import { EditorView } from 'prosemirror-view'; import type { DirectEditorProps } from 'prosemirror-view'; -import { DOMSerializer as PmDOMSerializer } from 'prosemirror-model'; +import { DOMSerializer as PmDOMSerializer, Slice as PmSlice, Fragment as PmFragment } from 'prosemirror-model'; import type { Node as PmNode } from 'prosemirror-model'; import { annotateFragmentDomWithClipboardData, @@ -19,6 +19,46 @@ import type { EditorRenderer, EditorRendererAttachParams } from './EditorRendere import type { Editor } from '../Editor.js'; import type { EditorOptions } from '../types/EditorConfig.js'; +/** Heading[1-9] styleId regex — paste/copy must keep these paragraph wrappers intact. */ +const HEADING_STYLE_RE = /^Heading[1-9]$/i; + +/** + * If the active selection is entirely inside a single Heading[1-9] paragraph + * AND the slice PM produced has no paragraph wrapper at the top, wrap the + * slice's inline content in a copy of that heading paragraph. The result is + * a closed-boundary slice that survives paste with the heading styleId + * intact — without this, F9 / "Update table of contents" cannot detect the + * pasted heading. + */ +function wrapHeadingSelectionAsParagraph(slice: PmSlice, state: { selection: any }): PmSlice { + // Only act when the slice content is inline-only (no paragraph wrapper). + const firstChild = slice.content.firstChild; + if (!firstChild || firstChild.type.name === 'paragraph') return slice; + + const $from = state.selection.$from; + const $to = state.selection.$to; + if (!$from || !$to) return slice; + + // Selection must be inside a single paragraph. + let parentParagraph: PmNode | null = null; + for (let depth = $from.depth; depth >= 0; depth--) { + const node = $from.node(depth); + if (node?.type?.name === 'paragraph') { + // Confirm $to has the same paragraph ancestor at the same depth. + if ($to.depth >= depth && $to.node(depth) === node) parentParagraph = node; + break; + } + } + if (!parentParagraph) return slice; + + const styleId = (parentParagraph.attrs as { paragraphProperties?: { styleId?: string } } | undefined) + ?.paragraphProperties?.styleId; + if (typeof styleId !== 'string' || !HEADING_STYLE_RE.test(styleId)) return slice; + + const wrapped = parentParagraph.type.create(parentParagraph.attrs, slice.content, parentParagraph.marks); + return new PmSlice(PmFragment.from(wrapped), 0, 0); +} + /** * Default fallback margin for presentation mode when pageMargins.top is undefined. * This value provides consistent spacing for header/footer content. @@ -843,7 +883,8 @@ export class ProseMirrorRenderer implements EditorRenderer { const { from, to } = this.view.state.selection; let sliceJson = ''; if (from !== to) { - const slice = this.view.state.doc.slice(from, to); + const rawSlice = this.view.state.doc.slice(from, to); + const slice = wrapHeadingSelectionAsParagraph(rawSlice, this.view.state); sliceJson = JSON.stringify(slice.toJSON()); clipboardData.setData('application/x-superdoc-slice', sliceJson); const mediaJson = collectReferencedImageMediaForClipboard(sliceJson, editor); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/toc-switches.test.ts b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/toc-switches.test.ts index 3ee9a5c937..a57fb00c13 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/toc-switches.test.ts +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/toc-switches.test.ts @@ -59,8 +59,9 @@ describe('parseTocInstruction', () => { it('handles empty instruction', () => { const config = parseTocInstruction('TOC'); expect(config.source).toEqual({}); - // Convenience projections are derived even for bare TOC instructions - expect(config.display).toEqual({ includePageNumbers: true, tabLeader: 'none' }); + // No \p in the instruction means "use Word's default tab leader" (dots), + // not an explicit opt-out, so tabLeader should be undefined here. + expect(config.display).toEqual({ includePageNumbers: true }); expect(config.preserved).toEqual({}); }); }); @@ -164,11 +165,13 @@ describe('applyTocPatch', () => { expect(patched.display.separator).toBe('.'); }); - it('tabLeader: none removes separator', () => { + it('tabLeader: none records an explicit empty separator (\\p "") so the choice round-trips', () => { const existing = parseTocInstruction('TOC \\o "1-3" \\p "."'); const patched = applyTocPatch(existing, { tabLeader: 'none' }); expect(patched.display.tabLeader).toBe('none'); - expect(patched.display.separator).toBeUndefined(); + // Empty string == explicit "no leader" (\p ""); deleting the separator + // would collapse to "absent \p" which Word treats as the dot default. + expect(patched.display.separator).toBe(''); }); it('throws on tabLeader + separator conflict', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/toc-switches.ts b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/toc-switches.ts index c816b0b377..c3c23e11c3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/toc-switches.ts +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/toc-switches.ts @@ -107,10 +107,17 @@ export function deriveIncludePageNumbers( /** * Derives the `tabLeader` value from the raw \p separator string. - * Returns undefined if the separator doesn't match a known leader pattern. + * + * - `undefined` → caller did not pass a separator (no \p switch). Returns + * `undefined` so consumers fall back to Word's default (dots) instead of + * treating "no \p" as an explicit opt-out. + * - `''` → \p was present but empty. Returns `'none'` (explicit opt-out). + * - non-empty string → mapped via SEPARATOR_TO_TAB_LEADER, or `undefined` + * when the separator is not a known leader character. */ function deriveTabLeader(separator: string | undefined): TocDisplayConfig['tabLeader'] | undefined { - if (!separator) return 'none'; + if (separator === undefined) return undefined; + if (separator === '') return 'none'; const leader = SEPARATOR_TO_TAB_LEADER[separator]; return leader as TocDisplayConfig['tabLeader'] | undefined; } @@ -125,7 +132,8 @@ export function parseTocInstruction(instruction: string): TocSwitchConfig { SWITCH_PATTERN.lastIndex = 0; while ((match = SWITCH_PATTERN.exec(instruction)) !== null) { const switchChar = match[1].toLowerCase(); - const arg = match[2] ?? ''; + const rawArg = match[2]; + const arg = rawArg ?? ''; switch (switchChar) { // Configurable source switches @@ -159,7 +167,10 @@ export function parseTocInstruction(instruction: string): TocSwitchConfig { break; } case 'p': - if (arg) display.separator = arg; + // \p with an explicit empty arg (`\p ""`) means "no leader" and must be + // distinguishable from \p being absent entirely (Word default = dots). + // Preserve the empty string so deriveTabLeader can map it to 'none'. + if (rawArg !== undefined) display.separator = rawArg; break; // Preserved switches @@ -267,8 +278,8 @@ export function serializeTocInstruction(config: TocSwitchConfig): string { parts.push(`\\n "${display.omitPageNumberLevels.from}-${display.omitPageNumberLevels.to}"`); } - // \p — separator - if (display.separator) { + // \p — separator. Empty string is meaningful (`\p ""` = explicit "no leader"). + if (display.separator !== undefined) { parts.push(`\\p "${display.separator}"`); } @@ -367,7 +378,9 @@ export function applyTocPatch(existing: TocSwitchConfig, patch: TocConfigurePatc // Handle tabLeader → \p switch mapping if (patch.tabLeader !== undefined) { if (patch.tabLeader === 'none') { - delete newDisplay.separator; + // Use \p "" to record an explicit "no leader" so it round-trips through + // serialize → parse without collapsing to "absent \p" (Word default = dots). + newDisplay.separator = ''; } else { newDisplay.separator = TAB_LEADER_TO_SEPARATOR[patch.tabLeader]; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/toc-entry-builder.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/toc-entry-builder.test.ts index ce6ca8c80c..bedddbccc1 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/toc-entry-builder.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/toc-entry-builder.test.ts @@ -19,49 +19,55 @@ function makeConfig(display: TocSwitchConfig['display'] = {}): TocSwitchConfig { }; } +type TextLike = { type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }; + +/** Pull the title text node out of a run wrapper. */ +function titleTextOf(paragraphs: ReturnType): TextLike { + const titleRun = paragraphs[0]!.content[0] as { content?: TextLike[] }; + return titleRun.content?.[0] ?? {}; +} + +/** Find the page-number text node (carries the tocPageNumber mark) inside any run. */ +function pageNumberTextOf(paragraphs: ReturnType): TextLike { + const runs = paragraphs[0]!.content as Array<{ content?: TextLike[] }>; + for (const run of runs) { + const child = run.content?.find((c) => Array.isArray(c.marks) && c.marks.some((m) => m.type === 'tocPageNumber')); + if (child) return child; + } + return {}; +} + describe('buildTocEntryParagraphs', () => { describe('hyperlink anchors', () => { it('uses a _Toc bookmark name as the hyperlink anchor, not the raw sdBlockId', () => { const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true })); - const textNode = paragraphs[0]!.content[0] as { marks?: Array<{ type: string; attrs: Record }> }; + const textNode = titleTextOf(paragraphs); const linkMark = textNode.marks?.find((m) => m.type === 'link'); expect(linkMark).toBeDefined(); - expect(linkMark!.attrs.anchor).toMatch(/^_Toc[a-zA-Z0-9_]+$/); - expect(linkMark!.attrs.anchor).toBe(generateTocBookmarkName(BASE_SOURCE.sdBlockId)); - expect(linkMark!.attrs.anchor).not.toBe(BASE_SOURCE.sdBlockId); + expect(linkMark!.attrs!.anchor).toMatch(/^_Toc[a-zA-Z0-9_]+$/); + expect(linkMark!.attrs!.anchor).toBe(generateTocBookmarkName(BASE_SOURCE.sdBlockId)); + expect(linkMark!.attrs!.anchor).not.toBe(BASE_SOURCE.sdBlockId); }); it('produces the same anchor for the same sdBlockId across calls', () => { const first = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true })); const second = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true })); - - const getAnchor = (paragraphs: typeof first) => { - const node = paragraphs[0]!.content[0] as { marks?: Array<{ attrs: Record }> }; - return node.marks?.[0]?.attrs.anchor; - }; - + const getAnchor = (paragraphs: typeof first) => titleTextOf(paragraphs).marks?.[0]?.attrs?.anchor; expect(getAnchor(first)).toBe(getAnchor(second)); }); it('does not add link mark when hyperlinks display option is false', () => { const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: false })); - const textNode = paragraphs[0]!.content[0] as { marks?: unknown[] }; - expect(textNode.marks).toBeUndefined(); + expect(titleTextOf(paragraphs).marks).toBeUndefined(); }); }); describe('rightAlignPageNumbers', () => { - it('adds a right-aligned tab stop when rightAlignPageNumbers is true', () => { - const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ rightAlignPageNumbers: true })); - const tabStops = paragraphs[0]!.attrs.paragraphProperties as Record; - expect(tabStops.tabStops).toEqual([{ tab: { tabType: 'right', pos: 9350 } }]); - }); - - it('adds a right-aligned tab stop by default (undefined)', () => { + it('adds a right-aligned tab stop with default dot leader', () => { const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig()); const tabStops = paragraphs[0]!.attrs.paragraphProperties as Record; - expect(tabStops.tabStops).toEqual([{ tab: { tabType: 'right', pos: 9350 } }]); + expect(tabStops.tabStops).toEqual([{ tab: { tabType: 'right', pos: 9350, leader: 'dot' } }]); }); it('omits tab stop when rightAlignPageNumbers is false', () => { @@ -96,6 +102,112 @@ describe('buildTocEntryParagraphs', () => { const props = paragraphs[0]!.attrs.paragraphProperties as Record; expect(props.tabStops).toBeUndefined(); }); + + it('honours options.tabPos when provided', () => { + const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ rightAlignPageNumbers: true }), { + tabPos: 12345, + }); + const props = paragraphs[0]!.attrs.paragraphProperties as Record; + expect(props.tabStops).toEqual([{ tab: { tabType: 'right', pos: 12345, leader: 'dot' } }]); + }); + }); + + describe('entry formatting (SD-2664)', () => { + it('emits only the link mark on the title text — Word rebuilds run formatting from the linked TOC{n} paragraph styles', () => { + const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true })); + const text = titleTextOf(paragraphs); + expect(text.marks!.map((m) => m.type)).toEqual(['link']); + }); + + it('the rebuilt link uses the source bookmark anchor', () => { + const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true })); + const linkMark = titleTextOf(paragraphs).marks?.find((m) => m.type === 'link'); + expect(linkMark?.attrs?.anchor).toBe(generateTocBookmarkName(BASE_SOURCE.sdBlockId)); + }); + + it('wraps each text run in a `run` node so wrapTextInRunsPlugin does not clobber marks', () => { + const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true })); + const runs = paragraphs[0]!.content as Array<{ type: string }>; + // Title run + tab run + page-number run = 3 runs (no \p, no omit). + expect(runs.length).toBe(3); + runs.forEach((r) => expect(r.type).toBe('run')); + }); + + it('carries allowed character marks (bold, italic, underline, color, highlight, fontFamily, textStyle.fontFamily) from the source heading', () => { + const sourceWithMarks: TocSource = { + ...BASE_SOURCE, + segments: [ + { + text: 'Heading', + marks: [ + { type: 'textStyle', attrs: { fontFamily: 'Aptos', fontSize: '24pt' } }, // fontSize must be scrubbed + { type: 'bold' }, + { type: 'italic' }, + { type: 'underline' }, + { type: 'color', attrs: { color: '#ff0000' } }, + { type: 'highlight', attrs: { color: '#ffff00' } }, + { type: 'fontFamily', attrs: { fontFamily: 'Calibri' } }, + ], + }, + ], + }; + const paragraphs = buildTocEntryParagraphs([sourceWithMarks], makeConfig({ hyperlinks: true })); + const text = titleTextOf(paragraphs); + expect(text.marks!.map((m) => m.type)).toEqual([ + 'textStyle', + 'bold', + 'italic', + 'underline', + 'color', + 'highlight', + 'fontFamily', + 'link', + ]); + // textStyle keeps fontFamily, drops fontSize. + const textStyleMark = text.marks!.find((m) => m.type === 'textStyle'); + expect(textStyleMark!.attrs).toEqual({ fontFamily: 'Aptos' }); + }); + + it('drops disallowed marks (fontSize, strike, link, comments, track-changes, tocPageNumber)', () => { + const sourceWithDisallowed: TocSource = { + ...BASE_SOURCE, + segments: [ + { + text: 'Heading', + marks: [ + { type: 'bold' }, + { type: 'fontSize', attrs: { fontSize: '24pt' } }, + { type: 'strike' }, + { type: 'link', attrs: { href: 'https://example.com' } }, + { type: 'commentMark', attrs: { commentId: 'c1' } }, + { type: 'trackInsert' }, + { type: 'tocPageNumber' }, + ], + }, + ], + }; + const paragraphs = buildTocEntryParagraphs([sourceWithDisallowed], makeConfig({ hyperlinks: true })); + const text = titleTextOf(paragraphs); + // Only the allowed `bold` survives, plus the rebuilt `link` to the source bookmark. + expect(text.marks!.map((m) => m.type)).toEqual(['bold', 'link']); + const linkMark = text.marks!.find((m) => m.type === 'link'); + expect(linkMark!.attrs!.anchor).toBe(generateTocBookmarkName(BASE_SOURCE.sdBlockId)); + expect(linkMark!.attrs!.href).toBeUndefined(); + }); + }); + + describe('page numbers (SD-2664)', () => { + it('substitutes page numbers from options.pageMap when present', () => { + const pageMap = new Map([['h-1', 7]]); + const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true }), { pageMap }); + expect(pageNumberTextOf(paragraphs).text).toBe('7'); + }); + + it('falls back to "0" placeholder when the source is not in the page map', () => { + const pageMap = new Map(); // empty + const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true }), { pageMap }); + expect(pageNumberTextOf(paragraphs).text).toBe('0'); + }); }); }); @@ -104,7 +216,7 @@ describe('buildTocEntryParagraphs', () => { // --------------------------------------------------------------------------- interface MockParagraph { - sdBlockId: string; + sdBlockId: string | null; text: string; styleId?: string; outlineLevel?: number; @@ -196,6 +308,32 @@ describe('collectTocSources', () => { expect(applied.length).toBe(3); }); + it('picks up a freshly-pasted heading whose paraId/sdBlockId were stripped by the slice paste reset', () => { + // Repro for "paste an existing heading, F9, new entry doesn't appear": + // SUPERDOC_SLICE_PASTE_IDENTITY_RESETS clears paraId AND sdBlockId on a + // pasted paragraph. Until the block-node plugin's appendTransaction runs + // and assigns a UUID, the paragraph carries `sdBlockId: null` while still + // having its heading styleId. The TOC scanner must fall back to a + // synthetic id and still surface it as a TOC source. + const docWithPastedHeading = mockDoc([ + { sdBlockId: 'p-existing', text: 'Conclusion 1', styleId: 'Heading2' }, + // Pasted heading, identity reset, plugin hasn't re-stamped yet + { sdBlockId: null, text: 'Conclusion 2', styleId: 'Heading2' }, + ]); + + const config: TocSwitchConfig = { + source: { outlineLevels: { from: 1, to: 3 } }, + display: { hyperlinks: true }, + preserved: {}, + }; + + const sources = collectTocSources(docWithPastedHeading, config); + expect(sources.map((s) => s.text)).toEqual(['Conclusion 1', 'Conclusion 2']); + // The fallback must produce a non-empty sdBlockId so generateTocBookmarkName + // can hash it into a stable anchor for the rebuilt entry. + expect(sources[1].sdBlockId).toBeTruthy(); + }); + it('collects only headings when \\u is not set', () => { const config: TocSwitchConfig = { source: { outlineLevels: { from: 1, to: 3 } }, @@ -252,4 +390,46 @@ describe('collectTocSources', () => { const sources = collectTocSources(doc, config); expect(sources.length).toBe(0); }); + + it('skips heading-styled paragraphs whose visible text is empty (SD-2664)', () => { + // Page-break / spacer paragraphs that inherit Heading1 must not produce + // ghost TOC entries on rebuild. + const docWithEmptyHeading = mockDoc([ + { sdBlockId: 'p1', text: 'Part 1', styleId: 'Heading1' }, + { sdBlockId: 'p2', text: '', styleId: 'Heading1' }, + { sdBlockId: 'p3', text: ' ', styleId: 'Heading1' }, + { sdBlockId: 'p4', text: 'Part 2', styleId: 'Heading1' }, + ]); + + const config: TocSwitchConfig = { + source: { outlineLevels: { from: 1, to: 3 } }, + display: { hyperlinks: true }, + preserved: {}, + }; + + const sources = collectTocSources(docWithEmptyHeading, config); + expect(sources.map((s) => s.text)).toEqual(['Part 1', 'Part 2']); + }); + + it('collects pasted heading paragraphs that lack sdBlockId/paraId (SD-2664)', () => { + // SuperDoc's slice paste resets paraId/sdBlockId to null on pasted paragraphs + // (InputRule.js SUPERDOC_SLICE_PASTE_IDENTITY_RESETS) to avoid public-id + // duplicates. The TOC rebuild must still pick those paragraphs up via a + // synthetic deterministic id so toc.update mode 'all' reflects new entries. + const docWithPastedHeading = mockDoc([ + { sdBlockId: 'p1', text: 'Part 3', styleId: 'Heading1' }, + { sdBlockId: null, text: 'Part 4', styleId: 'Heading1' }, + ]); + + const config: TocSwitchConfig = { + source: { outlineLevels: { from: 1, to: 3 } }, + display: { hyperlinks: true }, + preserved: {}, + }; + + const sources = collectTocSources(docWithPastedHeading, config); + + expect(sources.map((s) => s.text)).toEqual(['Part 3', 'Part 4']); + expect(sources[1].sdBlockId).toMatch(/^para-auto-/); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/toc-entry-builder.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/toc-entry-builder.ts index 5cd5929816..d5545eda50 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/toc-entry-builder.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/toc-entry-builder.ts @@ -9,6 +9,7 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { TocSwitchConfig } from '@superdoc/document-api'; import { parseTcInstruction } from '../../core/super-converter/field-references/shared/tc-switches.js'; import { getHeadingLevel } from './node-address-resolver.js'; +import { buildFallbackBlockNodeId } from './deterministic-node-id.js'; import { generateTocBookmarkName } from './toc-bookmark-sync.js'; // --------------------------------------------------------------------------- @@ -16,8 +17,16 @@ import { generateTocBookmarkName } from './toc-bookmark-sync.js'; // --------------------------------------------------------------------------- export interface TocSource { - /** Display text for this entry. */ + /** Flat display text for this entry (used as a fallback and for diagnostics). */ text: string; + /** + * Per-text-node segments captured from the source paragraph, preserving the + * character-level marks (bold, italic, color, font…). When present, the + * entry builder emits one styled text node per segment so heading-level + * formatting is reflected in the TOC. Absent for TC fields, where only a + * plain string is available from the field instruction. + */ + segments?: TocTextSegment[]; /** TOC level (1-based). */ level: number; /** @@ -32,6 +41,55 @@ export interface TocSource { omitPageNumber?: boolean; } +/** A run of source text with its surviving character marks. */ +export interface TocTextSegment { + text: string; + marks?: EntryTextMark[]; +} + +/** + * Marks that ARE allowed to flow from the source heading into a TOC entry. + * Anything not on this list is dropped — the TOC mirrors a deliberately + * narrow subset of character formatting from the heading: + * + * - `bold`, `italic`, `underline` — font style. + * - `color` — font color. + * - `highlight` — background color. + * - `fontFamily` — font family. + * - `textStyle` — kept ONLY for its `fontFamily` attribute; `fontSize` and + * any other attributes are scrubbed so heading point sizes do not bleed + * into the (typically smaller) TOC entry size. + * + * Notably excluded: `fontSize`, `link` (TOC has its own anchor), comments, + * track-changes, strike, baseline shifts, and `tocPageNumber`. + */ +const ALLOWED_SOURCE_MARK_TYPES = new Set(['bold', 'italic', 'underline', 'color', 'highlight', 'fontFamily']); + +/** Attributes preserved on a passthrough `textStyle` mark — `fontSize` is dropped. */ +const TEXT_STYLE_ALLOWED_ATTRS = new Set(['fontFamily']); + +/** + * Filters and rewrites a single source mark to the form allowed on a TOC + * entry. Returns `null` when the mark must be dropped entirely. + */ +function sanitizeSourceMark(mark: EntryTextMark): EntryTextMark | null { + if (!mark?.type) return null; + + if (mark.type === 'textStyle') { + const attrs = mark.attrs ?? {}; + const kept: Record = {}; + for (const key of Object.keys(attrs)) { + if (TEXT_STYLE_ALLOWED_ATTRS.has(key) && attrs[key] != null) kept[key] = attrs[key]; + } + return Object.keys(kept).length > 0 ? { type: 'textStyle', attrs: kept } : null; + } + + if (!ALLOWED_SOURCE_MARK_TYPES.has(mark.type)) return null; + return mark.attrs && Object.keys(mark.attrs).length > 0 + ? { type: mark.type, attrs: { ...mark.attrs } } + : { type: mark.type }; +} + // --------------------------------------------------------------------------- // Source collection // --------------------------------------------------------------------------- @@ -57,7 +115,7 @@ export function collectTocSources(doc: ProseMirrorNode, config: TocSwitchConfig) // Track the current paragraph context for TC field collection let currentParagraphSdBlockId: string | undefined; - doc.descendants((node, _pos) => { + doc.descendants((node, pos) => { // Skip TOC nodes themselves — don't collect entries from within a TOC if (node.type.name === 'tableOfContents') return false; @@ -65,31 +123,42 @@ export function collectTocSources(doc: ProseMirrorNode, config: TocSwitchConfig) const attrs = node.attrs as Record | undefined; const paragraphProps = attrs?.paragraphProperties as Record | undefined; const styleId = paragraphProps?.styleId as string | undefined; - const sdBlockId = (attrs?.sdBlockId ?? attrs?.paraId) as string | undefined; - - // Update paragraph context for TC field collection + // Pasted/new paragraphs intentionally lose paraId/sdBlockId (see + // InputRule.js SUPERDOC_SLICE_PASTE_IDENTITY_RESETS). Synthesize a + // position-based id so they still appear in the rebuilt TOC. + const sdBlockId = + ((attrs?.sdBlockId ?? attrs?.paraId) as string | undefined) ?? buildFallbackBlockNodeId('paragraph', pos); currentParagraphSdBlockId = sdBlockId; - if (!sdBlockId) return true; - // Check heading by style (\o switch) + const text = flattenText(node); + // Word's TOC skips heading-styled paragraphs with no visible text + // (page-break spacers, empty stubs). + if (text.trim().length === 0) return true; + + // \o switch — heading-style level if (outlineLevels) { const headingLevel = getHeadingLevel(styleId); if (headingLevel != null && headingLevel >= outlineLevels.from && headingLevel <= outlineLevels.to) { - sources.push({ text: flattenText(node), level: headingLevel, sdBlockId, kind: 'heading' }); - // Continue descending to find TC fields within this paragraph - return true; + sources.push({ text, segments: extractTextSegments(node), level: headingLevel, sdBlockId, kind: 'heading' }); + return true; // descend so TC fields inside this paragraph are still collected } } - // Check applied outline level (\u switch) + // \u switch — applied paragraph outline level if (useApplied) { const effectiveLevels = outlineLevels ?? { from: 1, to: 9 }; const rawOutlineLevel = paragraphProps?.outlineLevel as number | undefined; if (rawOutlineLevel != null) { const tocLevel = rawOutlineLevel + 1; if (tocLevel >= effectiveLevels.from && tocLevel <= effectiveLevels.to) { - sources.push({ text: flattenText(node), level: tocLevel, sdBlockId, kind: 'appliedOutline' }); + sources.push({ + text, + segments: extractTextSegments(node), + level: tocLevel, + sdBlockId, + kind: 'appliedOutline', + }); return true; } } @@ -144,6 +213,43 @@ function flattenText(node: ProseMirrorNode): string { return text; } +/** + * Walks the paragraph's text descendants and returns one segment per text node, + * sanitised through `sanitizeSourceMark`. Adjacent segments with identical + * mark sets are coalesced to keep the rebuilt content tidy. + */ +function extractTextSegments(node: ProseMirrorNode): TocTextSegment[] { + const segments: TocTextSegment[] = []; + node.descendants((child) => { + if (!child.isText || !child.text) return true; + const marks: EntryTextMark[] = []; + for (const mark of child.marks ?? []) { + const raw: EntryTextMark = { type: mark.type?.name ?? '' }; + if (mark.attrs && Object.keys(mark.attrs).length > 0) raw.attrs = { ...mark.attrs }; + const sanitized = sanitizeSourceMark(raw); + if (sanitized) marks.push(sanitized); + } + const last = segments[segments.length - 1]; + if (last && marksEqual(last.marks, marks)) { + last.text += child.text; + } else { + segments.push(marks.length > 0 ? { text: child.text, marks } : { text: child.text }); + } + return true; + }); + return segments; +} + +function marksEqual(a: EntryTextMark[] | undefined, b: EntryTextMark[] | undefined): boolean { + const aLen = a?.length ?? 0; + const bLen = b?.length ?? 0; + if (aLen !== bLen) return false; + if (aLen === 0) return true; + // Compare structurally — JSON.stringify is sufficient because attrs are flat + // and the iteration order of ProseMirror marks is stable per text node. + return JSON.stringify(a) === JSON.stringify(b); +} + // --------------------------------------------------------------------------- // Entry paragraph builder // --------------------------------------------------------------------------- @@ -154,18 +260,41 @@ export interface EntryParagraphJson { content: Array>; } +/** A mark in JSON form, as carried on the rebuilt TOC entry's text runs. */ +export interface EntryTextMark { + type: string; + attrs?: Record; +} + /** - * Builds ProseMirror-compatible paragraph JSON nodes for TOC entries. + * Optional context that lets the entry builder produce final-looking output + * (resolved page numbers, preserved tab spacing) without a follow-up + * `mode: 'pageNumbers'` pass. * - * Each entry gets: - * - Paragraph style: TOC{level} - * - tocSourceId paragraph attribute (source heading/TC field's sdBlockId) - * - Link mark with anchor pointing to a `_Toc`-prefixed bookmark name (when \h is set) - * - Page number placeholder "0" with tocPageNumber mark - * - Separator: custom (\p switch) or default tab + * Run-level formatting is intentionally NOT sampled from the existing TOC. + * Word's "Update field" rebuilds entries from the linked TOC1, TOC2, … + * paragraph styles — it does not copy direct formatting from the first entry. + * Sampling marks from the existing TOC made any direct formatting on entry 1 + * (e.g. bold) leak into every rebuilt entry. + */ +export interface BuildTocEntryOptions { + /** sdBlockId → page number map from PresentationEditor's last layout cycle. */ + pageMap?: Map; + /** Right-tab stop position (twips) to mirror the existing TOC's spacing. */ + tabPos?: number; +} + +/** + * Build TOC entry paragraphs. Each paragraph carries `pStyle="TOC{level}"`, + * a `tocSourceId` attr pointing back to the source heading, and three runs: + * the (linked) entry title, the tab/separator, and the page number. */ -export function buildTocEntryParagraphs(sources: TocSource[], config: TocSwitchConfig): EntryParagraphJson[] { - return sources.map((source) => buildEntryParagraph(source, config)); +export function buildTocEntryParagraphs( + sources: TocSource[], + config: TocSwitchConfig, + options: BuildTocEntryOptions = {}, +): EntryParagraphJson[] { + return sources.map((source) => buildEntryParagraph(source, config, options)); } /** Default right-margin position for right-aligned tab stops (twips). ~6.5 inches. */ @@ -179,65 +308,86 @@ const TAB_LEADER_MAP: Record = { middleDot: 'middleDot', }; -function buildEntryParagraph(source: TocSource, config: TocSwitchConfig): EntryParagraphJson { - const { display } = config; - const content: Array> = []; +/** Wrap inline children in a `run` node — the schema unit that `wrapTextInRunsPlugin` skips. */ +function asRun(children: Array>): Record { + return { type: 'run', content: children }; +} - // Entry text — optionally wrapped in hyperlink mark - const textNode: Record = { - type: 'text', - text: source.text || ' ', - }; +function buildEntryParagraph( + source: TocSource, + config: TocSwitchConfig, + options: BuildTocEntryOptions = {}, +): EntryParagraphJson { + const { display } = config; - if (display.hyperlinks) { - textNode.marks = [ - { - type: 'link', - attrs: { - anchor: generateTocBookmarkName(source.sdBlockId), - rId: null, - history: true, - }, - }, - ]; - } + // Title text. Character-level marks (bold, italic, color, font…) are + // carried over from the *source heading* — never sampled from the existing + // TOC entry, which would leak entry-1's direct formatting onto every + // rebuilt entry (Word rebuilds entries from the linked TOC1, TOC2, … + // paragraph styles, plus character formatting from the source). + // Each text node is wrapped in a `run` so wrapTextInRunsPlugin does not + // re-wrap and merge the paragraph style's run properties via addToSet. + const linkMark: EntryTextMark | undefined = display.hyperlinks + ? { type: 'link', attrs: { anchor: generateTocBookmarkName(source.sdBlockId), rId: null, history: true } } + : undefined; + + const segments: TocTextSegment[] = + source.segments && source.segments.length > 0 ? source.segments : [{ text: source.text || ' ' }]; + + const titleTextNodes: Array> = segments.map((segment) => { + // Re-apply the allowlist at build time so callers passing hand-built + // segments cannot smuggle in disallowed marks (font-size, link, comments, + // track-changes, etc.). collectTocSources also sanitizes, but the + // builder is the contract boundary that users of buildTocEntryParagraphs + // hit directly — defending here keeps the rule in one place. + const sourceMarks = (segment.marks ?? []) + .map((m) => sanitizeSourceMark(m)) + .filter((m): m is EntryTextMark => m !== null); + const marks: EntryTextMark[] = [...sourceMarks]; + if (linkMark) marks.push(linkMark); + const node: Record = { type: 'text', text: segment.text || ' ' }; + if (marks.length > 0) node.marks = marks; + return node; + }); - content.push(textNode); + const content: Array> = [asRun(titleTextNodes)]; - // Determine whether to omit page number for this entry + // Determine whether to omit page number for this entry. const omitRange = display.omitPageNumberLevels; - const levelOmitted = omitRange && source.level >= omitRange.from && source.level <= omitRange.to; - const entryOmitted = source.omitPageNumber; - const omitPageNumber = levelOmitted || entryOmitted; + const omitPageNumber = Boolean( + (omitRange && source.level >= omitRange.from && source.level <= omitRange.to) || source.omitPageNumber, + ); if (!omitPageNumber) { - // Separator between entry text and page number (\p switch overrides default tab) - if (display.separator) { - content.push({ type: 'text', text: display.separator }); - } else { - content.push({ type: 'tab' }); - } - - // Page number placeholder with tocPageNumber mark for surgical updates - content.push({ - type: 'text', - text: '0', - marks: [{ type: 'tocPageNumber' }], - }); + // Separator: custom \p text or default tab. + content.push(asRun([display.separator ? { type: 'text', text: display.separator } : { type: 'tab' }])); + + // Page number — resolved from the page map when available; '0' placeholder + // otherwise (e.g. freshly-pasted heading whose synthetic id hasn't been + // seen by a layout cycle yet). + const resolvedPage = options.pageMap?.get(source.sdBlockId); + content.push( + asRun([ + { + type: 'text', + text: resolvedPage != null ? String(resolvedPage) : '0', + marks: [{ type: 'tocPageNumber' }], + }, + ]), + ); } - // Build paragraph properties — add right-aligned tab stop when enabled - const paragraphProperties: Record = { - styleId: `TOC${source.level}`, - }; + const paragraphProperties: Record = { styleId: `TOC${source.level}` }; const rightAlign = display.rightAlignPageNumbers !== false; // default true if (rightAlign && !omitPageNumber) { + // Word's default TOC tab leader is dots. The \p switch is only emitted + // for a non-default separator, so an absent `tabLeader` means "use the + // default", not "no leader". `'none'` is the explicit opt-out. const leader = - display.tabLeader && display.tabLeader !== 'none' ? (TAB_LEADER_MAP[display.tabLeader] ?? undefined) : undefined; - paragraphProperties.tabStops = [ - { tab: { tabType: 'right', pos: DEFAULT_RIGHT_TAB_POS, ...(leader ? { leader } : {}) } }, - ]; + display.tabLeader === 'none' ? undefined : (display.tabLeader && TAB_LEADER_MAP[display.tabLeader]) || 'dot'; + const pos = options.tabPos ?? DEFAULT_RIGHT_TAB_POS; + paragraphProperties.tabStops = [{ tab: { tabType: 'right', pos, ...(leader ? { leader } : {}) } }]; } return { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/toc-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/toc-wrappers.ts index 94e18f0883..de6eeec507 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/toc-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/toc-wrappers.ts @@ -40,6 +40,7 @@ import { import { collectTocSources, buildTocEntryParagraphs, + type BuildTocEntryOptions, type EntryParagraphJson, type TocSource, } from '../helpers/toc-entry-builder.js'; @@ -257,13 +258,69 @@ interface MaterializedToc { sources: TocSource[]; } -function materializeTocContent(doc: ProseMirrorNode, config: TocSwitchConfig, editor: Editor): MaterializedToc { +type MaterializeTocOptions = BuildTocEntryOptions; + +function materializeTocContent( + doc: ProseMirrorNode, + config: TocSwitchConfig, + editor: Editor, + options: MaterializeTocOptions = {}, +): MaterializedToc { const sources = collectTocSources(doc, config); - const entryParagraphs = buildTocEntryParagraphs(sources, config); + const entryParagraphs = buildTocEntryParagraphs(sources, config, options); const content = entryParagraphs.length > 0 ? entryParagraphs : NO_ENTRIES_PLACEHOLDER; return { content: sanitizeTocContentForSchema(content, editor), sources }; } +/** Recognises TOC entry paragraph styles (TOC1, TOC2, … TOC9). */ +const TOC_ENTRY_STYLE_RE = /^TOC[1-9]$/; + +type TocParagraphProps = { + styleId?: string; + tabStops?: TabStopJson[]; + runProperties?: Record; +}; +type TocParagraphAttrs = { paragraphProperties?: TocParagraphProps }; +type TabStopJson = { tab?: { pos?: number; tabType?: string; leader?: string } }; + +/** First TOC1–TOC9 paragraph in the existing TOC node, or `undefined`. */ +function findFirstTocEntryParagraph(node: ProseMirrorNode): ProseMirrorNode | undefined { + let entry: ProseMirrorNode | undefined; + node.forEach((paragraph) => { + if (entry || paragraph.type.name !== 'paragraph') return; + const styleId = (paragraph.attrs as TocParagraphAttrs | undefined)?.paragraphProperties?.styleId; + if (styleId && TOC_ENTRY_STYLE_RE.test(styleId)) entry = paragraph; + }); + return entry; +} + +/** Right-tab stop position (twips) from the first existing TOC entry. */ +function readExistingTocTabPos(node: ProseMirrorNode): number | undefined { + const entry = findFirstTocEntryParagraph(node) ?? node.firstChild ?? undefined; + const tabStops = (entry?.attrs as TocParagraphAttrs | undefined)?.paragraphProperties?.tabStops; + const pos = tabStops?.find((t) => t?.tab?.tabType === 'right')?.tab?.pos; + return typeof pos === 'number' ? pos : undefined; +} + +/** + * Word's TOC field always closes with a paragraph that holds the + * `` — typically a Normal-styled empty + * paragraph after the entries. SuperDoc's importer preserves it as the last + * child of the `tableOfContents` node, and it renders as a blank line below + * the entries. If we replace **all** children with just the rebuilt entries, + * the TOC visually shrinks by that blank line and the gap to the text below + * shifts. Capture the original trailing non-entry paragraph (when present) + * as JSON so we can append it after the rebuilt entries to keep the visual + * end of the TOC stable. + */ +function readExistingTocTrailingParagraph(node: ProseMirrorNode): unknown | undefined { + const last = node.lastChild; + if (!last || last.type.name !== 'paragraph') return undefined; + const styleId = (last.attrs as TocParagraphAttrs | undefined)?.paragraphProperties?.styleId; + if (styleId && TOC_ENTRY_STYLE_RE.test(styleId)) return undefined; // it's an entry, not the trailer + return typeof last.toJSON === 'function' ? last.toJSON() : undefined; +} + // --------------------------------------------------------------------------- // toc.configure // --------------------------------------------------------------------------- @@ -289,11 +346,17 @@ export function tocConfigureWrapper( // Patch value takes priority; fall back to existing node attr. const effectiveRightAlign = input.patch.rightAlignPageNumbers ?? (resolved.node.attrs?.rightAlignPageNumbers as boolean | undefined); - const { content: nextContent, sources } = materializeTocContent( + const { content: rebuiltEntries, sources } = materializeTocContent( editor.state.doc, withRightAlign(patched, effectiveRightAlign), editor, + { + pageMap: getPageMap(editor) ?? undefined, + tabPos: readExistingTocTabPos(resolved.node), + }, ); + const trailing = readExistingTocTrailingParagraph(resolved.node); + const nextContent = trailing ? [...rebuiltEntries, trailing as EntryParagraphJson] : rebuiltEntries; if (areTocConfigsEqual(currentConfig, patched) && !rightAlignChanged) { return tocFailure('NO_OP', 'Configuration patch produced no change.'); @@ -378,7 +441,20 @@ function tocUpdateAll(editor: Editor, input: TocUpdateInput, options?: MutationO const resolved = resolveTocTarget(editor.state.doc, input.target); const config = parseTocInstruction(resolved.node.attrs?.instruction ?? ''); const rightAlign = resolved.node.attrs?.rightAlignPageNumbers as boolean | undefined; - const { content, sources } = materializeTocContent(editor.state.doc, withRightAlign(config, rightAlign), editor); + const { content: rebuiltEntries, sources } = materializeTocContent( + editor.state.doc, + withRightAlign(config, rightAlign), + editor, + { + pageMap: getPageMap(editor) ?? undefined, + tabPos: readExistingTocTabPos(resolved.node), + }, + ); + + // Preserve the trailer paragraph if the existing TOC ends with one — keeps + // the visual gap below the TOC stable across rebuilds. + const trailing = readExistingTocTrailingParagraph(resolved.node); + const content = trailing ? [...rebuiltEntries, trailing as EntryParagraphJson] : rebuiltEntries; // NO_OP detection: compare new content against existing before executing. // The PM command returns "found" (not "content changed"), so receipt-based @@ -560,31 +636,40 @@ function buildPageNumberUpdatedContent( const tocSourceId = child.attrs?.tocSourceId as string | undefined; const childJson = child.toJSON() as EntryParagraphJson; - const content = childJson.content ?? []; let paragraphChanged = false; - const updatedContentArray = content.map((node: Record) => { + // Walk recursively — the rebuilt paragraph wraps its runs in `run` nodes, + // so the tocPageNumber mark sits one level below the paragraph's direct + // children. A flat scan over `paragraph.content` would miss it and fall + // through to PAGE_NUMBERS_NOT_MATERIALIZED. + const visit = (node: Record): Record => { const marks = node.marks as Array<{ type: string }> | undefined; const hasTocPageNumberMark = marks?.some((m) => m.type === 'tocPageNumber'); - if (!hasTocPageNumberMark) return node; - - hasPageNumberMarks = true; + if (hasTocPageNumberMark) { + hasPageNumberMarks = true; - // Skip entries without tocSourceId — no anchor for page map lookup - if (!tocSourceId) return node; + if (!tocSourceId) return node; - const pageNumber = pageMap.get(tocSourceId); - const newText = pageNumber !== undefined ? String(pageNumber) : '??'; + const pageNumber = pageMap.get(tocSourceId); + const newText = pageNumber !== undefined ? String(pageNumber) : '??'; - if (node.text !== newText) { - paragraphChanged = true; - return { ...node, text: newText }; + if (node.text !== newText) { + paragraphChanged = true; + return { ...node, text: newText }; + } + return node; } - return node; - }); + const nested = node.content as Array> | undefined; + if (!Array.isArray(nested) || nested.length === 0) return node; + const visited = nested.map(visit); + const replaced = visited.some((next, idx) => next !== nested[idx]); + return replaced ? { ...node, content: visited } : node; + }; + + const updatedContentArray = (childJson.content ?? []).map(visit); if (paragraphChanged) { anyChanged = true; diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js index 17c1d513c5..96f7090998 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js +++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js @@ -1,12 +1,13 @@ import { Extension } from '@core/Extension.js'; import { findFieldsInRange } from '../../document-api-adapters/helpers/field-resolver.js'; +import { findAllTocNodes } from '../../document-api-adapters/helpers/toc-resolver.js'; import { getWordStatistics, resolveDocumentStatFieldValue, resolveMainBodyEditor, } from '../../document-api-adapters/helpers/word-statistics.js'; -/** Field types eligible for value updates via F9. */ +/** Stat-field types refreshed by F9 when the doc has no TOCs. */ const UPDATABLE_FIELD_TYPES = new Set(['NUMWORDS', 'NUMCHARS', 'NUMPAGES']); /** @@ -20,12 +21,14 @@ export const FieldUpdate = Extension.create({ addCommands() { return { /** - * Update all field values intersecting the current selection. + * Refresh document fields. * - * Mirrors Word's F9 semantics: - * - Collapsed selection: updates the single field at the cursor - * - Range selection: updates all fields intersecting the range - * - Select-all then F9: updates every field in the document + * - When the doc contains any TOCs, rebuilds **all** of them via + * `editor.doc.toc.update({ mode: 'all' })` and stops. + * - Otherwise, refreshes stat fields (NUMWORDS, NUMCHARS, NUMPAGES) that + * intersect the current selection. + * + * Bound to F9. Returns `true` if anything was updated. * * @category Command * @returns {Function} ProseMirror command function @@ -34,10 +37,50 @@ export const FieldUpdate = Extension.create({ */ updateFieldsInSelection: () => - ({ editor, state, dispatch }) => { + ({ editor, state, tr: outerTr, dispatch }) => { const { from, to } = state.selection; - const fields = findFieldsInRange(state.doc, from, to); + // toc.update dispatches its own transaction per TOC; CommandService + // would then auto-apply its captured (now-stale) `tr` to the new + // state. Set preventDispatch so it skips that. + if (editor?.doc?.toc?.update) { + const tocTargets = findAllTocNodes(state.doc) + .map((toc) => toc.commandNodeId) + .filter((id) => typeof id === 'string' && id); + + if (tocTargets.length > 0) { + if (!dispatch) return true; // can()-style probe + + // Each toc.update swaps editor.state.doc, which makes + // tocStorage.pageMapDoc stale and forces subsequent TOCs to + // rebuild with '0' placeholders. Re-stamp pageMapDoc to the + // current doc each iteration — the layout has not been + // recomputed, so the page numbers from the original layout + // are still authoritative for this update cycle. + const tocStorage = editor.storage?.tableOfContents; + const cachedPageMap = tocStorage?.pageMap ?? null; + + for (const sdBlockId of tocTargets) { + if (tocStorage && cachedPageMap) { + tocStorage.pageMap = cachedPageMap; + tocStorage.pageMapDoc = editor.state.doc; + } + try { + editor.doc.toc.update({ + target: { kind: 'block', nodeType: 'tableOfContents', nodeId: sdBlockId }, + mode: 'all', + }); + } catch (error) { + console.warn('[FieldUpdate] toc.update failed for', sdBlockId, error); + } + } + + outerTr?.setMeta?.('preventDispatch', true); + return true; + } + } + + const fields = findFieldsInRange(state.doc, from, to); const updatable = fields.filter((f) => UPDATABLE_FIELD_TYPES.has(f.fieldType)); if (updatable.length === 0) return false; diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js index c621d7b2cd..11e76c3ca6 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js +++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js @@ -4,12 +4,16 @@ * Tests for the FieldUpdate extension's updateFieldsInSelection command. * * Uses the numwords.docx fixture which contains NUMWORDS, NUMCHARS, and - * NUMPAGES fields with known imported values. + * NUMPAGES fields with known imported values for the stat-field path. The + * TOC path is exercised via direct command-function invocation against a + * synthetic doc/editor — no docx fixture required. */ -import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; import { getWordStatistics } from '../../document-api-adapters/helpers/word-statistics.js'; +import { FieldUpdate } from './field-update.js'; describe('FieldUpdate extension', () => { let docData; @@ -107,3 +111,119 @@ describe('FieldUpdate extension', () => { expect(numcharsField.attrs.resolvedText).toBe(expectedValue); }); }); + +// --------------------------------------------------------------------------- +// TOC path — invoked directly against synthetic state to avoid needing a +// fully-imported TOC fixture. +// --------------------------------------------------------------------------- + +const tocSchema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] }, + tableOfContents: { + group: 'block', + content: 'paragraph*', + attrs: { sdBlockId: { default: null } }, + toDOM: () => ['div', 0], + }, + text: { group: 'inline' }, + }, +}); + +const buildTocDoc = (sdBlockIds) => { + const para = (txt) => tocSchema.nodes.paragraph.create({}, txt ? tocSchema.text(txt) : null); + const tocs = sdBlockIds.map((id) => tocSchema.nodes.tableOfContents.create({ sdBlockId: id }, [para('entry')])); + return tocSchema.nodes.doc.create({}, [para('intro'), ...tocs, para('outro')]); +}; + +const runUpdateFields = (overrides) => { + const { doc, editor } = overrides; + const dispatch = 'dispatch' in overrides ? overrides.dispatch : () => {}; + // FieldUpdate is wrapped by Extension.create(); reach into config.addCommands + // to invoke the raw command function the same way ExtensionService does. + const commands = FieldUpdate.config.addCommands.call({ editor }); + const command = commands.updateFieldsInSelection(); + const tr = { setMeta: vi.fn() }; + const state = { doc, selection: { from: 0, to: 0 }, schema: tocSchema, tr }; + return { result: command({ editor, state, tr, dispatch }), tr }; +}; + +describe('updateFieldsInSelection — TOC path', () => { + it('calls editor.doc.toc.update for every tableOfContents node in document order', () => { + const update = vi.fn(() => ({ success: true })); + const editor = { doc: { toc: { update } } }; + const doc = buildTocDoc(['toc-a', 'toc-b']); + + const { result } = runUpdateFields({ doc, editor }); + + expect(result).toBe(true); + expect(update).toHaveBeenCalledTimes(2); + expect(update.mock.calls[0][0]).toEqual({ + target: { kind: 'block', nodeType: 'tableOfContents', nodeId: 'toc-a' }, + mode: 'all', + }); + expect(update.mock.calls[1][0]).toEqual({ + target: { kind: 'block', nodeType: 'tableOfContents', nodeId: 'toc-b' }, + mode: 'all', + }); + }); + + it('sets preventDispatch on the framework tr so CommandService skips its auto-dispatch', () => { + const update = vi.fn(() => ({ success: true })); + const editor = { doc: { toc: { update } } }; + const doc = buildTocDoc(['toc-a']); + + const { tr } = runUpdateFields({ doc, editor }); + expect(tr.setMeta).toHaveBeenCalledWith('preventDispatch', true); + }); + + it('skips a TOC whose sdBlockId is missing or empty', () => { + const update = vi.fn(() => ({ success: true })); + const editor = { doc: { toc: { update } } }; + const doc = buildTocDoc([null, '', 'toc-real']); + + runUpdateFields({ doc, editor }); + expect(update).toHaveBeenCalledTimes(1); + expect(update.mock.calls[0][0].target.nodeId).toBe('toc-real'); + }); + + it('swallows toc.update errors and continues with the remaining TOCs', () => { + const update = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('boom'); + }) + .mockImplementationOnce(() => ({ success: true })); + const editor = { doc: { toc: { update } } }; + const doc = buildTocDoc(['toc-a', 'toc-b']); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { result } = runUpdateFields({ doc, editor }); + expect(result).toBe(true); + expect(update).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('falls through to the stat-field path when the doc has no TOCs', () => { + const update = vi.fn(); + const editor = { doc: { toc: { update } } }; + const para = (txt) => tocSchema.nodes.paragraph.create({}, txt ? tocSchema.text(txt) : null); + const doc = tocSchema.nodes.doc.create({}, [para('hello world')]); + + const { tr } = runUpdateFields({ doc, editor }); + expect(update).not.toHaveBeenCalled(); + expect(tr.setMeta).not.toHaveBeenCalled(); // no preventDispatch when not taking the TOC path + }); +}); + +describe('FieldUpdate extension shortcuts', () => { + it('binds F9 to updateFieldsInSelection', () => { + const ed = { commands: { updateFieldsInSelection: vi.fn(() => true) } }; + const shortcuts = FieldUpdate.config.addShortcuts.call({ editor: ed }); + expect(Object.keys(shortcuts)).toEqual(['F9']); + shortcuts.F9(); + expect(ed.commands.updateFieldsInSelection).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/table-of-contents/find-toc-ancestor.js b/packages/super-editor/src/editors/v1/extensions/table-of-contents/find-toc-ancestor.js new file mode 100644 index 0000000000..d7c60de513 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/table-of-contents/find-toc-ancestor.js @@ -0,0 +1,24 @@ +import { findParentNodeClosestToPos } from '@core/helpers/findParentNodeClosestToPos.js'; + +/** + * Find the enclosing `tableOfContents` node for a document position. Used by + * the context menu to route "Update table of contents" through + * `editor.doc.toc.update`. + * + * @param {import('prosemirror-model').Node} doc + * @param {number} pos + * @returns {{ node: import('prosemirror-model').Node, pos: number, sdBlockId: string | null } | null} + */ +export function findTocAncestor(doc, pos) { + if (!doc || typeof pos !== 'number' || !Number.isFinite(pos)) return null; + let resolved; + try { + resolved = doc.resolve(pos); + } catch { + return null; + } + const found = findParentNodeClosestToPos(resolved, (n) => n.type.name === 'tableOfContents'); + if (!found) return null; + const sdBlockId = typeof found.node.attrs?.sdBlockId === 'string' ? found.node.attrs.sdBlockId : null; + return { node: found.node, pos: found.pos, sdBlockId }; +} diff --git a/packages/super-editor/src/editors/v1/extensions/table-of-contents/find-toc-ancestor.test.js b/packages/super-editor/src/editors/v1/extensions/table-of-contents/find-toc-ancestor.test.js new file mode 100644 index 0000000000..80c51d03d7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/table-of-contents/find-toc-ancestor.test.js @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { Schema } from 'prosemirror-model'; + +import { findTocAncestor } from './find-toc-ancestor.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + toDOM: () => ['p', 0], + }, + tableOfContents: { + group: 'block', + content: 'paragraph*', + attrs: { + sdBlockId: { default: null }, + }, + toDOM: () => ['div', 0], + }, + text: { group: 'inline' }, + }, +}); + +const para = (text) => schema.nodes.paragraph.create({}, text ? schema.text(text) : null); +const toc = (sdBlockId, paragraphs) => schema.nodes.tableOfContents.create({ sdBlockId }, paragraphs); + +describe('findTocAncestor', () => { + it('returns null when the position is not inside a TOC', () => { + const doc = schema.nodes.doc.create(null, [para('outside')]); + expect(findTocAncestor(doc, 2)).toBeNull(); + }); + + it('finds the TOC and exposes its sdBlockId for a position inside a TOC paragraph', () => { + const tocNode = toc('toc-1', [para('Heading 1'), para('Heading 2')]); + const doc = schema.nodes.doc.create(null, [para('intro'), tocNode, para('outro')]); + + // First paragraph is 7 chars including boundaries: 0..7. TOC starts at pos 7. + const tocStart = 1 + para('intro').nodeSize; // 1 (doc open) + intro size minus 1 = simpler: locate by walk + const introSize = para('intro').nodeSize; + const insideTocPos = introSize + 2; // inside TOC's first paragraph + + const result = findTocAncestor(doc, insideTocPos); + expect(result).not.toBeNull(); + expect(result.sdBlockId).toBe('toc-1'); + expect(result.node.type.name).toBe('tableOfContents'); + // pos returned should be the TOC node's start position (one before its content range). + // Using the same arithmetic the helper uses: resolved.before(depth). + expect(typeof result.pos).toBe('number'); + expect(tocStart).toBeGreaterThan(0); + }); + + it('returns null sdBlockId when the TOC has none', () => { + const tocNode = toc(null, [para('entry')]); + const doc = schema.nodes.doc.create(null, [tocNode]); + const result = findTocAncestor(doc, 2); + expect(result).not.toBeNull(); + expect(result.sdBlockId).toBeNull(); + }); + + it('returns null for invalid positions', () => { + const doc = schema.nodes.doc.create(null, [para('text')]); + expect(findTocAncestor(doc, -1)).toBeNull(); + expect(findTocAncestor(doc, Number.NaN)).toBeNull(); + expect(findTocAncestor(null, 0)).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/table-of-contents/toc-page-number.js b/packages/super-editor/src/editors/v1/extensions/table-of-contents/toc-page-number.js index 0c4b9f47df..e68ae5d534 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-of-contents/toc-page-number.js +++ b/packages/super-editor/src/editors/v1/extensions/table-of-contents/toc-page-number.js @@ -23,11 +23,11 @@ export const TocPageNumber = Mark.create({ }; }, - parseHTML() { + parseDOM() { return [{ tag: 'span[data-toc-page-number]' }]; }, - renderHTML() { + renderDOM() { return ['span', { 'data-toc-page-number': '' }, 0]; }, }); diff --git a/tests/behavior/tests/navigation/toc-update.spec.ts b/tests/behavior/tests/navigation/toc-update.spec.ts new file mode 100644 index 0000000000..b87923183d --- /dev/null +++ b/tests/behavior/tests/navigation/toc-update.spec.ts @@ -0,0 +1,310 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/layout/toc-with-heading2.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +/** + * Reads every TOC entry's title text from the document. + * + * The rebuilt entries are wrapped in `run` nodes whose first text run holds + * the title (without the page-number `tocPageNumber` mark). + */ +const readTocTitles = async (superdoc) => + superdoc.page.evaluate(() => { + const editor = (window as unknown as { editor?: { state: { doc: unknown } } }).editor; + if (!editor?.state?.doc) return []; + + const titles: string[] = []; + + (editor.state.doc as { descendants: (cb: (n: any) => boolean | void) => void }).descendants((node) => { + if (node?.type?.name !== 'tableOfContents') return true; + + node.descendants((child: any) => { + if (child?.type?.name !== 'paragraph') return true; + // First non-page-number text run is the entry title. + let captured = false; + + child.descendants((leaf: any) => { + if (captured) return false; + if (!leaf.isText || !leaf.text) return true; + + const isPageNumber = (leaf.marks ?? []).some((m: any) => m.type?.name === 'tocPageNumber'); + if (!isPageNumber) { + titles.push(leaf.text); + captured = true; + } + + return true; + }); + + return false; + }); + + return false; + }); + + return titles; + }); + +test('@behavior SD-2664: updateFieldsInSelection (F9) rebuilds every TOC entry from the document headings', async ({ + superdoc, +}) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(2000); + + // Capture the original TOC entries. + const titlesBefore = await readTocTitles(superdoc); + expect(titlesBefore.length).toBeGreaterThan(0); + + // Read the heading texts that should drive the rebuilt TOC. The fixture + // contains Heading1/Heading2 paragraphs in the body. + const headingTexts = await superdoc.page.evaluate(() => { + const editor = (window as unknown as { editor?: { state: { doc: unknown } } }).editor; + if (!editor?.state?.doc) return []; + + const out: string[] = []; + + (editor.state.doc as { descendants: (cb: (n: any) => boolean | void) => void }).descendants((node) => { + if (node?.type?.name === 'tableOfContents') return false; // skip TOC contents + if (node?.type?.name !== 'paragraph') return true; + + const styleId = node.attrs?.paragraphProperties?.styleId; + if (!styleId || !/^Heading[1-9]$/.test(styleId)) return true; + + let text = ''; + + node.descendants((c: any) => { + if (c.isText && c.text) text += c.text; + return true; + }); + + if (text.trim()) out.push(text.trim()); + + return true; + }); + return out; + }); + expect(headingTexts.length).toBeGreaterThan(0); + + // Press F9 — the FieldUpdate extension binds it to updateFieldsInSelection, + // which routes through editor.doc.toc.update for every TOC in the doc. + await superdoc.executeCommand('updateFieldsInSelection'); + await superdoc.waitForStable(2000); + + const titlesAfter = await readTocTitles(superdoc); + // Every heading in the doc should now appear as an entry, and every entry + // should map to a heading text. Order must match document order. + expect(titlesAfter).toEqual(headingTexts); +}); + +const PR312_BOLD_DOC = path.resolve(__dirname, '../../test-data/layout/word-fixture-pr-312-bold.docx'); + +test('@behavior SD-2664 review: pasting "Conclusion 2" below itself produces a duplicate TOC entry on context-menu update', async ({ + superdoc, +}) => { + test.skip(!fs.existsSync(PR312_BOLD_DOC), 'word-fixture-pr-312-bold.docx not available'); + + await superdoc.loadDocument(PR312_BOLD_DOC); + await superdoc.waitForStable(2000); + + // The doc stores the heading title as "Conclusion" + "2" in separate runs + // (no space text node), so the source scanner sees the concatenated text. + const TARGET_TITLE = 'Conclusion2'; + + // Establish a rebuild baseline FIRST. Without this, the post-paste + // assertion would also reflect any unbookmarked headings the rebuild picks + // up that weren't yet materialised — making the test fragile to fixture + // changes. We want to isolate "the pasted heading was preserved". + const updateAllTocs = async () => { + await superdoc.page.evaluate(() => { + const editor = ( + window as unknown as { + editor?: { state: { doc: any }; doc?: { toc?: { update?: (input: any) => any } } }; + } + ).editor; + if (!editor?.doc?.toc?.update) return; + const ids: string[] = []; + editor.state.doc.descendants((n: any) => { + if (n?.type?.name === 'tableOfContents') { + const id = n.attrs?.sdBlockId as string | null | undefined; + if (id) ids.push(id); + return false; + } + return true; + }); + for (const id of ids) { + editor.doc.toc.update({ + target: { kind: 'block', nodeType: 'tableOfContents', nodeId: id }, + mode: 'all', + }); + } + }); + await superdoc.waitForStable(1500); + }; + + await updateAllTocs(); + const titlesBaseline = await readTocTitles(superdoc); + const baselineCount = titlesBaseline.filter((t: string) => t === TARGET_TITLE).length; + + // Real copy → paste round-trip: select the inline content of the heading, + // dispatch a copy event so ProseMirrorRenderer's production handler writes + // the slice, then dispatch a paste event with that same clipboard payload + // and a cursor inside the body paragraph below the heading. + await superdoc.page.evaluate((target: string) => { + const editor = ( + window as unknown as { + editor?: { + state: { doc: any; tr: any; selection: any }; + view: { dispatch: (tr: any) => void; dom: HTMLElement; state: any }; + }; + } + ).editor; + if (!editor) return; + const { state, view } = editor; + + let sourceNode: any = null; + let sourceEnd = 0; + let nextParagraphInsidePos = 0; + let foundSource = false; + state.doc.descendants((n: any, pos: number) => { + if (foundSource && nextParagraphInsidePos === 0) { + if (n?.type?.name === 'paragraph' && pos >= sourceEnd) { + nextParagraphInsidePos = pos + 1; + return false; + } + } + if (n?.type?.name !== 'paragraph') return true; + const styleId = n.attrs?.paragraphProperties?.styleId; + if (!styleId || !/^Heading[1-9]$/.test(styleId)) return true; + let text = ''; + n.descendants((c: any) => { + if (c.isText && c.text) text += c.text; + return true; + }); + if (text.trim() === target) { + sourceNode = n; + sourceEnd = pos + n.nodeSize; + foundSource = true; + } + return true; + }); + if (!sourceNode || !nextParagraphInsidePos) return; + + const TextSelection = state.selection.constructor; + const sourceStart = sourceEnd - sourceNode.nodeSize; + + // Select the heading's inline content and copy. + view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, sourceStart + 1, sourceEnd - 1))); + const copyData = new DataTransfer(); + view.dom.dispatchEvent(new ClipboardEvent('copy', { clipboardData: copyData, bubbles: true, cancelable: true })); + + // Move cursor into the next paragraph and paste. + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, nextParagraphInsidePos))); + const pasteData = new DataTransfer(); + for (const type of copyData.types) pasteData.setData(type, copyData.getData(type)); + view.dom.dispatchEvent(new ClipboardEvent('paste', { clipboardData: pasteData, bubbles: true, cancelable: true })); + }, TARGET_TITLE); + + await superdoc.waitForStable(1000); + await updateAllTocs(); + + const titlesAfter = await readTocTitles(superdoc); + const afterCount = titlesAfter.filter((t: string) => t === TARGET_TITLE).length; + // The pasted heading must add exactly one more entry to the rebuild. + expect(afterCount).toBe(baselineCount + 1); +}); + +test('@behavior SD-2664 review: F9 rebuilds page numbers for every TOC in a multi-TOC document', async ({ + superdoc, +}) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(2000); + + // Clone the imported TOC node and insert a copy at the end of the doc, so + // the doc has two TOCs that should rebuild from the same headings. + const tocCount = await superdoc.page.evaluate(() => { + const editor = ( + window as unknown as { + editor?: { state: { doc: any; tr: any }; view: { dispatch: (tr: any) => void } }; + } + ).editor; + if (!editor) return 0; + + let sourceToc: any = null; + editor.state.doc.descendants((n: any) => { + if (sourceToc) return false; + if (n?.type?.name === 'tableOfContents') { + sourceToc = n; + return false; + } + return true; + }); + if (!sourceToc) return 0; + + // Fresh sdBlockId so the two TOCs have distinct identities. + const cleanAttrs = { ...sourceToc.attrs, sdBlockId: null }; + const clone = sourceToc.type.create(cleanAttrs, sourceToc.content, sourceToc.marks); + const tr = editor.state.tr.insert(editor.state.doc.content.size, clone); + editor.view.dispatch(tr); + + let count = 0; + editor.state.doc.descendants((n: any) => { + if (n?.type?.name === 'tableOfContents') { + count += 1; + return false; + } + return true; + }); + return count; + }); + // Some other plugin may dedupe, so guard the precondition we rely on. + expect(tocCount).toBeGreaterThanOrEqual(2); + + // Wait for layout to recompute the page map after the insertion. + await superdoc.waitForStable(2000); + + // F9 → updateFieldsInSelection iterates every TOC. Without the page-map + // refresh in field-update.js, only the FIRST TOC rebuilds with real page + // numbers; subsequent TOCs see the stored pageMapDoc as stale (its + // snapshot was taken before this iteration's transaction) and fall back + // to '0' placeholders. + await superdoc.executeCommand('updateFieldsInSelection'); + await superdoc.waitForStable(2000); + + // Pull the page-number text for every entry in every TOC. + const tocPageNumbers = await superdoc.page.evaluate(() => { + const editor = (window as unknown as { editor?: { state: { doc: any } } }).editor; + if (!editor) return [] as string[][]; + const result: string[][] = []; + + editor.state.doc.descendants((toc: any) => { + if (toc?.type?.name !== 'tableOfContents') return true; + + const numbers: string[] = []; + toc.descendants((leaf: any) => { + if (!leaf.isText || !leaf.text) return true; + const isPageNumber = (leaf.marks ?? []).some((m: any) => m.type?.name === 'tocPageNumber'); + if (isPageNumber) numbers.push(leaf.text); + return true; + }); + if (numbers.length > 0) result.push(numbers); + return false; + }); + + return result; + }); + + expect(tocPageNumbers.length).toBe(tocCount); + // Every TOC must have at least one entry with a non-zero page number — + // the bug surfaces as every entry in the second+ TOC reading "0". + for (const numbers of tocPageNumbers) { + expect(numbers.length).toBeGreaterThan(0); + const allZero = numbers.every((n) => n === '0'); + expect(allZero).toBe(false); + } +});