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: '',
@@ -42,6 +43,7 @@ vi.mock('../constants.js', () => ({
copy: '',
paste: '',
cellBackground: '',
+ updateTableOfContents: '',
},
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);
+ }
+});