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 @@ -404,6 +404,19 @@ export const makeDefaultItems = ({
},
});

const tableOfContents = useToolbarItem({
type: 'button',
name: 'tableOfContents',
command: 'insertTableOfContentsFromToolbar',
icon: toolbarIcons.tableOfContents,
active: false,
tooltip: toolbarTexts.tableOfContents,
disabled: false,
attributes: {
ariaLabel: 'Table of contents',
},
});

// table
const tableItem = useToolbarItem({
type: 'dropdown',
Expand Down Expand Up @@ -1064,7 +1077,7 @@ export const makeDefaultItems = ({
const toolbarPadding = 32;

const itemsToHideXL = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler', 'formattingMarks'];
const itemsToHideSM = ['zoom', 'fontFamily', 'fontSize', 'redo'];
const itemsToHideSM = ['zoom', 'fontFamily', 'fontSize', 'redo', 'tableOfContents'];
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.

tableOfContents got added to itemsToHideSM but the test file only covers XL overflow. worth a small SM case mirroring the XL one — otherwise a future reorder could quietly move tableOfContents out of overflow without a test failure.

const shouldUseLgCompactStyles = availableWidth <= RESPONSIVE_BREAKPOINTS.lg;

if (shouldUseLgCompactStyles) {
Expand Down Expand Up @@ -1101,6 +1114,7 @@ export const makeDefaultItems = ({
separator,
link,
image,
tableOfContents,
tableItem,
tableActionsItem,
separator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function buildItems(availableWidth) {
describe('makeDefaultItems XL overflow boundary (SD-2328)', () => {
const XL_OVERFLOW_SAFETY_BUFFER = 20;
const XL_CUTOFF = RESPONSIVE_BREAKPOINTS.xl + XL_OVERFLOW_SAFETY_BUFFER;
const XL_ITEMS = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler'];
const XL_ITEMS = ['linkedStyles', 'copyFormat', 'ruler'];
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.

clearFormatting is still in itemsToHideXL (defaultItems.js:1079), so removing it from this expected list silently drops a boundary assertion. was that intentional? if not, adding 'clearFormatting' back keeps the coverage.


it(`moves XL items into overflow at ${XL_CUTOFF - 1}px (below cutoff)`, () => {
const { defaultItems, overflowItems } = buildItems(XL_CUTOFF - 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,33 @@ export class SuperToolbar extends EventEmitter {
item.resetDisabled();
this.#applyHeadlessState(item);
});

this.#syncTableOfContentsToolbarAvailability();
}

/**
* TOC toolbar control calls `create.tableOfContents`; mirror capability gating
* (tracked mode, missing commands, etc.) so the button matches the document API.
* @returns {void}
*/
#syncTableOfContentsToolbarAvailability() {
const tocItem = this.toolbarItems.find((item) => item.name.value === 'tableOfContents');
if (!tocItem) return;

if (!this.activeEditor) {
tocItem.setDisabled(true);
return;
}

let available = false;
try {
const cap = this.activeEditor.doc.capabilities();
available = Boolean(cap.operations['create.tableOfContents']?.available);
} catch {
available = false;
}

tocItem.setDisabled(!available);
}
Comment on lines +728 to 746
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.

every other toolbar item derives its disabled state from HEADLESS_ITEM_MAP + the registry's state() deriver, which feeds snapshot.commands[id].disabled through #applyHeadlessState. this adds a parallel one-off path that calls capabilities() directly and only refreshes when updateToolbarState() runs. could tableOfContents register through HEADLESS_ITEM_MAP instead so it stays in sync with snapshots automatically and doesn't introduce a second mechanism for the same thing?


/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import copyIconSvg from '@superdoc/common/icons/copy-solid.svg?raw';
import pasteIconSvg from '@superdoc/common/icons/paste-solid.svg?raw';
import strikethroughSvg from '@superdoc/common/icons/strikethrough.svg?raw';
import paragraphIconSvg from '@superdoc/common/icons/paragraph-solid.svg?raw';
import tocIconSvg from '@superdoc/common/icons/toc-solid.svg?raw';

export const toolbarIcons = {
undo: rotateLeftIconSvg,
Expand All @@ -70,6 +71,7 @@ export const toolbarIcons = {
color: fontIconSvg,
link: linkIconSvg,
image: imageIconSvg,
tableOfContents: tocIconSvg,
alignLeft: alignLeftIconSvg,
alignRight: alignRightIconSvg,
alignCenter: alignCenterIconSvg,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const toolbarTexts = {
search: 'Search',
link: 'Link',
image: 'Image',
tableOfContents: 'Table of contents',
table: 'Insert table',
tableActions: 'Table options',
addRowBefore: 'Insert row above',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,19 @@ describe('toc wrappers', () => {
expect(commands.insertTableOfContentsAt.mock.calls[0]?.[0]).toMatchObject({ pos: 13 });
});

it('validates create.tableOfContents targets during dryRun', () => {
const { editor, commands } = makeTocEditor();

expect(() =>
createTableOfContentsWrapper(
editor,
{ at: { kind: 'after', target: { kind: 'block', nodeType: 'paragraph', nodeId: 'missing' } } },
{ dryRun: true },
),
).toThrow();
expect(commands.insertTableOfContentsAt).not.toHaveBeenCalled();
});

it('rejects tracked mode for TOC mutation wrappers', () => {
const { editor } = makeTocEditor();
const tocTarget = { kind: 'block', nodeType: 'tableOfContents', nodeId: 'toc-1' } as const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -780,14 +780,30 @@ export function tocRemoveWrapper(editor: Editor, input: TocRemoveInput, options?
// create.tableOfContents
// ---------------------------------------------------------------------------

export function createTableOfContentsWrapper(
/** Payload for inserting a TOC block (shared by document API and toolbar). */
export type PreparedTableOfContentsInsert = {
pos: number;
instruction: string;
sdBlockId: string;
content: unknown[];
sources: TocSource[];
rightAlignPageNumbers?: boolean;
};

/**
* Resolves insertion position and materializes TOC content/instruction.
* Callers that run inside `editor.commands.*` must apply the insert on the
* **same** command transaction (see `insertTableOfContentsFromToolbar`) —
* never call `editor.commands.insertTableOfContentsAt` from here, or nested
* dispatches can throw "Applying a mismatched transaction".
*/
export function prepareTableOfContentsInsertion(
editor: Editor,
input: CreateTableOfContentsInput,
options?: MutationOptions,
): CreateTableOfContentsResult {
): PreparedTableOfContentsInsert {
rejectTrackedMode('create.tableOfContents', options);

// Resolve insertion position
const at = input.at ?? { kind: 'documentEnd' as const };
let pos: number;
if (at.kind === 'documentStart') {
Expand All @@ -798,7 +814,6 @@ export function createTableOfContentsWrapper(
pos = resolveCreateAnchor(editor, at.target, at.kind).pos;
}

// Build instruction from config patch or use defaults
const config = input.config ? applyTocPatchTyped(DEFAULT_TOC_CONFIG, input.config) : DEFAULT_TOC_CONFIG;
const instruction = serializeTocInstruction(config);
const { content, sources } = materializeTocContent(
Expand All @@ -809,6 +824,25 @@ export function createTableOfContentsWrapper(

const sdBlockId = uuidv4();

return {
pos,
instruction,
sdBlockId,
content,
sources,
...(input.config?.rightAlignPageNumbers !== undefined
? { rightAlignPageNumbers: input.config.rightAlignPageNumbers }
: {}),
};
}

export function createTableOfContentsWrapper(
editor: Editor,
input: CreateTableOfContentsInput,
options?: MutationOptions,
): CreateTableOfContentsResult {
const prepared = prepareTableOfContentsInsertion(editor, input, options);

if (options?.dryRun) {
return { success: true, toc: buildTocAddress('(dry-run)') };
}
Expand All @@ -820,12 +854,12 @@ export function createTableOfContentsWrapper(
editor,
command,
{
pos,
instruction,
sdBlockId,
content,
...(input.config?.rightAlignPageNumbers !== undefined
? { rightAlignPageNumbers: input.config.rightAlignPageNumbers }
pos: prepared.pos,
instruction: prepared.instruction,
sdBlockId: prepared.sdBlockId,
content: prepared.content,
...(prepared.rightAlignPageNumbers !== undefined
? { rightAlignPageNumbers: prepared.rightAlignPageNumbers }
: {}),
},
options?.expectedRevision,
Expand All @@ -840,21 +874,21 @@ export function createTableOfContentsWrapper(
const defaultContent = [
paragraphType.create({}, editor.state.schema.text('Update table of contents to populate entries.')),
];
const materializedContent = normalizeTocContent(content, editor) ?? defaultContent;
const materializedContent = normalizeTocContent(prepared.content, editor) ?? defaultContent;
const tocNode = tocType.create(
{
instruction,
sdBlockId,
...(input.config?.rightAlignPageNumbers !== undefined
? { rightAlignPageNumbers: input.config.rightAlignPageNumbers }
instruction: prepared.instruction,
sdBlockId: prepared.sdBlockId,
...(prepared.rightAlignPageNumbers !== undefined
? { rightAlignPageNumbers: prepared.rightAlignPageNumbers }
: {}),
},
materializedContent,
);

try {
const { tr } = editor.state;
tr.insert(pos, tocNode);
tr.insert(prepared.pos, tocNode);
dispatchEditorTransaction(editor, tr);
return true;
} catch (error) {
Expand All @@ -875,9 +909,9 @@ export function createTableOfContentsWrapper(
};
}

syncTocBookmarks(editor, sources);
syncTocBookmarks(editor, prepared.sources);

// Re-resolve and return the public TOC id exposed by toc.list/toc.get.
const postMutationId = resolvePostMutationTocId(editor.state.doc, sdBlockId);
const postMutationId = resolvePostMutationTocId(editor.state.doc, prepared.sdBlockId);
return { success: true, toc: buildTocAddress(postMutationId) };
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Node } from '@core/Node.js';
import { Attribute } from '@core/Attribute.js';
import { getBlockIndex } from '../../document-api-adapters/helpers/index-cache.js';
import { toBlockAddress } from '../../document-api-adapters/helpers/node-address-resolver.js';
import { prepareTableOfContentsInsertion } from '../../document-api-adapters/plan-engine/toc-wrappers.js';
import { syncTocBookmarks } from '../../document-api-adapters/helpers/toc-bookmark-sync.js';

export const TableOfContents = Node.create({
name: 'tableOfContents',
Expand Down Expand Up @@ -50,37 +54,100 @@ export const TableOfContents = Node.create({
);
};

const canInsertTableOfContentsAfter = (candidate, editor) => {
const tocType = editor.schema.nodes.tableOfContents;
const doc = editor.state.doc;
if (!tocType || typeof doc?.resolve !== 'function') return true;

const pos = candidate.end ?? candidate.pos + candidate.node.nodeSize;
try {
const $pos = doc.resolve(pos);
if (typeof $pos.parent?.canReplaceWith !== 'function') return true;
return $pos.parent.canReplaceWith($pos.index(), $pos.index(), tocType);
} catch {
return false;
}
};

/**
* Insert a tableOfContents node at the given document position.
* @param {{ pos: number, instruction?: string, sdBlockId?: string, content?: object[], rightAlignPageNumbers?: boolean }} options
*/
const insertTableOfContentsAt =
(options) =>
({ tr, dispatch, state }) => {
const { pos, instruction = '', sdBlockId = null, content, rightAlignPageNumbers } = options;
const tocType = this.editor.schema.nodes.tableOfContents;
if (!tocType) return false;

const paragraphType = this.editor.schema.nodes.paragraph;
const defaultContent = [
paragraphType.create({}, this.editor.schema.text('Update table of contents to populate entries.')),
];
const materializedContent = normalizeTocContent(content, state.schema) ?? defaultContent;
const attrs = { instruction, sdBlockId };
if (rightAlignPageNumbers !== undefined) attrs.rightAlignPageNumbers = rightAlignPageNumbers;
const tocNode = tocType.create(attrs, materializedContent);

try {
if (dispatch) {
tr.insert(pos, tocNode);
}
return true;
} catch (error) {
if (error instanceof RangeError) return false;
throw error;
}
};

return {
insertTableOfContentsAt,

/**
* Insert a tableOfContents node at the given document position.
* @param {{ pos: number, instruction?: string, sdBlockId?: string, content?: object[], rightAlignPageNumbers?: boolean }} options
* Inserts a TOC at the selection using the same materialization as
* `create.tableOfContents`, applied on the **current command transaction**
* (must not call `editor.doc.create` here — nested dispatches cause
* "Applying a mismatched transaction").
*/
insertTableOfContentsAt:
(options) =>
({ tr, dispatch, state }) => {
const { pos, instruction = '', sdBlockId = null, content, rightAlignPageNumbers } = options;
const tocType = this.editor.schema.nodes.tableOfContents;
if (!tocType) return false;

const paragraphType = this.editor.schema.nodes.paragraph;
const defaultContent = [
paragraphType.create({}, this.editor.schema.text('Update table of contents to populate entries.')),
];
const materializedContent = normalizeTocContent(content, state.schema) ?? defaultContent;
const attrs = { instruction, sdBlockId };
if (rightAlignPageNumbers !== undefined) attrs.rightAlignPageNumbers = rightAlignPageNumbers;
const tocNode = tocType.create(attrs, materializedContent);

try {
if (dispatch) {
tr.insert(pos, tocNode);
}
return true;
} catch (error) {
if (error instanceof RangeError) return false;
throw error;
}
},
insertTableOfContentsFromToolbar: () => (props) => {
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.

the FromToolbar suffix bakes the caller into the API, but the behavior (insert at selection, fall back to document end) isn't toolbar-specific. if a slash menu or shortcut wants the same thing later the name will lie — drop the suffix?

const { editor } = props;
const pos = editor.state.selection.from;
const index = getBlockIndex(editor);
const containing = index.candidates.filter((c) => pos >= c.pos && pos < (c.end ?? c.pos + c.node.nodeSize));
const anchor =
containing.length > 0
? [...containing]
.sort((a, b) => a.node.nodeSize - b.node.nodeSize)
.find((candidate) => canInsertTableOfContentsAfter(candidate, editor))
: null;

const at = anchor ? { kind: 'after', target: toBlockAddress(anchor) } : { kind: 'documentEnd' };

let prepared;
try {
prepared = prepareTableOfContentsInsertion(editor, { at });
} catch {
return false;
}

const inserted = insertTableOfContentsAt({
pos: prepared.pos,
instruction: prepared.instruction,
sdBlockId: prepared.sdBlockId,
content: prepared.content,
...(prepared.rightAlignPageNumbers !== undefined
? { rightAlignPageNumbers: prepared.rightAlignPageNumbers }
: {}),
})(props);

if (inserted && props.dispatch) {
globalThis.queueMicrotask(() => {
syncTocBookmarks(editor, prepared.sources);
});
}
Comment on lines +143 to +147
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.

the non-toolbar TOC path runs syncTocBookmarks synchronously after dispatch (toc-wrappers.ts:912). here the command schedules it via queueMicrotask. was the asymmetry intentional? running the sync from the toolbar caller after the command returns would match the existing pattern and keep the command itself a pure transaction producer.


return inserted;
},

/**
* Update the instruction attribute of a tableOfContents node by sdBlockId.
Expand Down
Loading
Loading