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

/**
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,81 @@ export const TableOfContents = Node.create({
);
};

/**
* 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) => {
const { editor } = props;
const pos = editor.state.selection.from;
const index = getBlockIndex(editor);
const containing = index.candidates.filter((c) => pos >= c.pos && pos < c.pos + c.node.nodeSize);
const anchor =
containing.length > 0 ? containing.reduce((a, b) => (a.node.nodeSize < b.node.nodeSize ? a : b)) : null;

Comment on lines +101 to +104
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Anchor TOC insertion outside nested TOC entry blocks

Selecting the smallest containing block here causes failures when the cursor is inside an existing TOC entry paragraph: toBlockAddress(anchor) resolves to that inner paragraph, so prepareTableOfContentsInsertion(..., { at: { kind: 'after', target } }) returns a position inside a tableOfContents node (whose content only allows paragraphs), and insertTableOfContentsAt then no-ops after a RangeError. In that context the toolbar button appears enabled but clicking it does nothing; the anchor should be promoted to an enclosing insertable block (e.g., the TOC block itself or another valid outer block).

Useful? React with 👍 / 👎.

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

return inserted;
},

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