-
Notifications
You must be signed in to change notification settings - Fork 144
feat: add toc toolbar item #3247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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']; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| it(`moves XL items into overflow at ${XL_CUTOFF - 1}px (below cutoff)`, () => { | ||
| const { defaultItems, overflowItems } = buildItems(XL_CUTOFF - 1); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. every other toolbar item derives its disabled state from |
||
|
|
||
| /** | ||
|
|
||
| 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', | ||
|
|
@@ -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) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the non-toolbar TOC path runs |
||
|
|
||
| return inserted; | ||
| }, | ||
|
|
||
| /** | ||
| * Update the instruction attribute of a tableOfContents node by sdBlockId. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tableOfContentsgot added toitemsToHideSMbut the test file only covers XL overflow. worth a small SM case mirroring the XL one — otherwise a future reorder could quietly movetableOfContentsout of overflow without a test failure.