From 1456204222d690c7cc7714abfb5ebe855f49aa2c Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 12 May 2026 18:24:01 +0300 Subject: [PATCH] feat: add toc toolbar item --- .../v1/components/toolbar/defaultItems.js | 14 ++ .../v1/components/toolbar/super-toolbar.js | 27 ++++ .../v1/components/toolbar/toolbarIcons.js | 2 + .../v1/components/toolbar/toolbarTexts.js | 1 + .../plan-engine/toc-wrappers.test.ts | 13 ++ .../plan-engine/toc-wrappers.ts | 70 ++++++--- .../table-of-contents/table-of-contents.js | 104 ++++++++++---- .../table-of-contents.test.js | 134 ++++++++++++++++++ shared/common/icons/toc-solid.svg | 1 + 9 files changed, 320 insertions(+), 46 deletions(-) create mode 100644 shared/common/icons/toc-solid.svg diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js index 2da53e57f8..af7670270c 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js @@ -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', @@ -1101,6 +1114,7 @@ export const makeDefaultItems = ({ separator, link, image, + tableOfContents, tableItem, tableActionsItem, separator, diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index 841ab1ecb1..4d4007aee8 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -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); } /** diff --git a/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js b/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js index 21c71a3e38..fd245ca747 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js @@ -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, @@ -70,6 +71,7 @@ export const toolbarIcons = { color: fontIconSvg, link: linkIconSvg, image: imageIconSvg, + tableOfContents: tocIconSvg, alignLeft: alignLeftIconSvg, alignRight: alignRightIconSvg, alignCenter: alignCenterIconSvg, diff --git a/packages/super-editor/src/editors/v1/components/toolbar/toolbarTexts.js b/packages/super-editor/src/editors/v1/components/toolbar/toolbarTexts.js index 904365fb2e..2fd00dd5d1 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/toolbarTexts.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/toolbarTexts.js @@ -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', diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/toc-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/toc-wrappers.test.ts index 38c3dda0f2..773f1a8f9e 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/toc-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/toc-wrappers.test.ts @@ -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; 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 9d66b6ba93..effbaf83f3 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 @@ -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') { @@ -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( @@ -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)') }; } @@ -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, @@ -840,13 +874,13 @@ 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, @@ -854,7 +888,7 @@ export function createTableOfContentsWrapper( try { const { tr } = editor.state; - tr.insert(pos, tocNode); + tr.insert(prepared.pos, tocNode); dispatchEditorTransaction(editor, tr); return true; } catch (error) { @@ -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) }; } diff --git a/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js b/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js index 05b74847c2..29cc256fea 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js +++ b/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js @@ -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', @@ -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; + + 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. diff --git a/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.test.js b/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.test.js index 46170733a7..b82734af1c 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.test.js @@ -1,5 +1,34 @@ import { describe, expect, it, vi } from 'vitest'; +const { mockGetBlockIndex } = vi.hoisted(() => ({ + mockGetBlockIndex: vi.fn(), +})); + +vi.mock('../../document-api-adapters/helpers/index-cache.js', () => ({ + getBlockIndex: (...args) => mockGetBlockIndex(...args), +})); + +vi.mock('../../document-api-adapters/helpers/node-address-resolver.js', () => ({ + toBlockAddress: vi.fn((candidate) => ({ + kind: 'block', + nodeType: candidate.nodeType, + nodeId: candidate.nodeId, + })), +})); + +const { mockPrepare, mockSync } = vi.hoisted(() => ({ + mockPrepare: vi.fn(), + mockSync: vi.fn(), +})); + +vi.mock('../../document-api-adapters/plan-engine/toc-wrappers.js', () => ({ + prepareTableOfContentsInsertion: (...args) => mockPrepare(...args), +})); + +vi.mock('../../document-api-adapters/helpers/toc-bookmark-sync.js', () => ({ + syncTocBookmarks: (...args) => mockSync(...args), +})); + vi.mock('@core/Node.js', () => ({ Node: { create: (config) => ({ config }), @@ -87,3 +116,108 @@ describe('tableOfContents extension commands', () => { expect(insert).toHaveBeenCalledWith(3, tocNode); }); }); + +describe('insertTableOfContentsFromToolbar', () => { + beforeEach(() => { + mockGetBlockIndex.mockReset(); + mockPrepare.mockReset(); + mockSync.mockReset(); + }); + + it('calls prepare with after-inner anchor and inserts on the command transaction', async () => { + const { commands, schema, tocNode } = createCommandContext(); + mockPrepare.mockReturnValue({ + pos: 7, + instruction: 'TOC', + sdBlockId: 'new-toc', + content: [], + sources: [], + }); + const editor = { schema, state: { selection: { from: 15 } } }; + mockGetBlockIndex.mockReturnValue({ + candidates: [ + { pos: 0, node: { nodeSize: 30 }, nodeType: 'paragraph', nodeId: 'outer' }, + { pos: 10, node: { nodeSize: 10 }, nodeType: 'paragraph', nodeId: 'inner' }, + ], + }); + + const insert = vi.fn(); + const tr = { insert }; + const dispatch = () => {}; + const state = { schema }; + + const result = commands.insertTableOfContentsFromToolbar()({ editor, tr, dispatch, state }); + + expect(result).toBe(true); + expect(mockPrepare).toHaveBeenCalledWith(editor, { + at: { kind: 'after', target: { kind: 'block', nodeType: 'paragraph', nodeId: 'inner' } }, + }); + expect(insert).toHaveBeenCalledWith(7, tocNode); + + await Promise.resolve(); + expect(mockSync).toHaveBeenCalledWith(editor, []); + }); + + it('calls prepare with documentEnd when no block contains the selection anchor', () => { + const { commands, schema, tocNode } = createCommandContext(); + mockPrepare.mockReturnValue({ + pos: 99, + instruction: 'TOC', + sdBlockId: 'x', + content: [], + sources: [], + }); + const editor = { schema, state: { selection: { from: 999 } } }; + mockGetBlockIndex.mockReturnValue({ + candidates: [{ pos: 0, node: { nodeSize: 5 }, nodeType: 'paragraph', nodeId: 'p1' }], + }); + + const insert = vi.fn(); + const tr = { insert }; + const dispatch = () => {}; + const state = { schema }; + + expect(commands.insertTableOfContentsFromToolbar()({ editor, tr, dispatch, state })).toBe(true); + expect(mockPrepare).toHaveBeenCalledWith(editor, { at: { kind: 'documentEnd' } }); + expect(insert).toHaveBeenCalledWith(99, tocNode); + }); + + it('returns false when prepare throws (e.g. tracked mode)', () => { + const { commands, schema } = createCommandContext(); + mockPrepare.mockImplementation(() => { + throw new Error('tracked'); + }); + const editor = { schema, state: { selection: { from: 1 } } }; + mockGetBlockIndex.mockReturnValue({ candidates: [] }); + + const tr = { insert: vi.fn() }; + const dispatch = () => {}; + const state = { schema }; + + expect(commands.insertTableOfContentsFromToolbar()({ editor, tr, dispatch, state })).toBe(false); + }); + + it('does not sync bookmarks during command availability checks without dispatch', async () => { + const { commands, schema } = createCommandContext(); + mockPrepare.mockReturnValue({ + pos: 7, + instruction: 'TOC', + sdBlockId: 'new-toc', + content: [], + sources: [{ sdBlockId: 'heading-1' }], + }); + const editor = { schema, state: { selection: { from: 15 } } }; + mockGetBlockIndex.mockReturnValue({ + candidates: [{ pos: 10, node: { nodeSize: 10 }, nodeType: 'paragraph', nodeId: 'inner' }], + }); + + const tr = { insert: vi.fn() }; + const state = { schema }; + + expect(commands.insertTableOfContentsFromToolbar()({ editor, tr, dispatch: undefined, state })).toBe(true); + + await Promise.resolve(); + expect(tr.insert).not.toHaveBeenCalled(); + expect(mockSync).not.toHaveBeenCalled(); + }); +}); diff --git a/shared/common/icons/toc-solid.svg b/shared/common/icons/toc-solid.svg new file mode 100644 index 0000000000..a9913896e6 --- /dev/null +++ b/shared/common/icons/toc-solid.svg @@ -0,0 +1 @@ +