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
7 changes: 6 additions & 1 deletion packages/super-editor/src/editors/v1/core/InputRule.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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);
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 Expand Up @@ -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', () => {
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repro: open a TOC with a hyphen leader → editor.doc.toc.configure({ target, patch: { tabLeader: 'none' } })toc.update({ target, mode: 'all' }). the rebuilt TOC comes back with dots, not none. on a TOC that already has no leader, configure does nothing - the stored text doesn't change. fix: write \p "" for explicit none, or store the choice in a separate field.

if (separator === '') return 'none';
const leader = SEPARATOR_TO_TAB_LEADER[separator];
return leader as TocDisplayConfig['tabLeader'] | undefined;
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"`);
}

Expand Down Expand Up @@ -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];
}
Expand Down
Loading