Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -37,6 +38,7 @@ export const ICONS = {
trackChangesAccept: checkIconSvg,
trackChangesReject: xMarkIconSvg,
cellBackground: paintRollerIconSvg,
updateTableOfContents: rotateRightIconSvg,
};

// Table actions constant
Expand Down Expand Up @@ -65,6 +67,7 @@ export const TEXTS = {
trackChangesAccept: 'Accept change',
trackChangesReject: 'Reject change',
cellBackground: 'Cell background',
updateTableOfContents: 'Update table of contents',
};

export const tableActionsOptions = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<svg>ai-icon</svg>',
Expand All @@ -42,6 +43,7 @@ vi.mock('../constants.js', () => ({
copy: '<svg>copy-icon</svg>',
paste: '<svg>paste-icon</svg>',
cellBackground: '<svg>cell-background-icon</svg>',
updateTableOfContents: '<svg>rotate-right-icon</svg>',
},
TRIGGERS: {
slash: 'slash',
Expand Down Expand Up @@ -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',
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -223,6 +225,7 @@ export async function getEditorContext(editor, event) {
editor,
trackedChanges,
proofingContext,
tocAncestor,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,49 +19,55 @@ function makeConfig(display: TocSwitchConfig['display'] = {}): TocSwitchConfig {
};
}

type TextLike = { type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> };

/** Pull the title text node out of a run wrapper. */
function titleTextOf(paragraphs: ReturnType<typeof buildTocEntryParagraphs>): 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<typeof buildTocEntryParagraphs>): 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<string, unknown> }> };
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<string, unknown> }> };
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<string, unknown>;
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<string, unknown>;
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', () => {
Expand Down Expand Up @@ -96,6 +102,60 @@ describe('buildTocEntryParagraphs', () => {
const props = paragraphs[0]!.attrs.paragraphProperties as Record<string, unknown>;
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<string, unknown>;
expect(props.tabStops).toEqual([{ tab: { tabType: 'right', pos: 12345, leader: 'dot' } }]);
});
});

describe('entry text marks (SD-2664)', () => {
it('prepends preserved marks and stacks the link mark on top', () => {
const entryTextMarks = [
{ type: 'textStyle', attrs: { fontFamily: 'Aptos', fontSize: '12pt' } },
{ type: 'bold' },
];
const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true }), { entryTextMarks });
const text = titleTextOf(paragraphs);
expect(text.marks!.map((m) => m.type)).toEqual(['textStyle', 'bold', 'link']);
expect(text.marks![0].attrs).toEqual({ fontFamily: 'Aptos', fontSize: '12pt' });
});

it('drops any incoming link mark; the builder rebuilds it from the source bookmark', () => {
const entryTextMarks = [
{ type: 'textStyle', attrs: { fontFamily: 'Aptos' } },
{ type: 'link', attrs: { anchor: 'old-stale-anchor' } },
];
const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true }), { entryTextMarks });
const linkMark = titleTextOf(paragraphs).marks?.find((m) => m.type === 'link');
expect(linkMark?.attrs?.anchor).toBe(generateTocBookmarkName(BASE_SOURCE.sdBlockId));
expect(linkMark?.attrs?.anchor).not.toBe('old-stale-anchor');
});

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'));
});
});

describe('page numbers (SD-2664)', () => {
it('substitutes page numbers from options.pageMap when present', () => {
const pageMap = new Map<string, number>([['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<string, number>(); // empty
const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ hyperlinks: true }), { pageMap });
expect(pageNumberTextOf(paragraphs).text).toBe('0');
});
});
});

Expand All @@ -104,7 +164,7 @@ describe('buildTocEntryParagraphs', () => {
// ---------------------------------------------------------------------------

interface MockParagraph {
sdBlockId: string;
sdBlockId: string | null;
text: string;
styleId?: string;
outlineLevel?: number;
Expand Down Expand Up @@ -252,4 +312,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-/);
});
});
Loading
Loading