diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 483ddb1793..fdf616ee26 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -1,6 +1,6 @@ import "./TableOfContents.css"; -import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5"; +import { attributeChangeAffectsHeading, CKTextEditor, ModelElement } from "@triliumnext/ckeditor5"; import clsx from "clsx"; import { useCallback, useEffect, useRef, useState } from "preact/hooks"; @@ -170,11 +170,14 @@ function EditableTextTableOfContents() { const affectsHeadings = changes.some( change => { return ( - change.type === 'insert' || change.type === 'remove' || (change.type === 'attribute' && change.attributeKey === 'headingLevel') + change.type === 'insert' || change.type === 'remove' || + (change.type === 'attribute' && attributeChangeAffectsHeading(change, textEditor)) ); }); if (affectsHeadings) { - setHeadings(extractTocFromTextEditor(textEditor)); + requestAnimationFrame(() => { + setHeadings(extractTocFromTextEditor(textEditor)); + }); } }; diff --git a/packages/ckeditor5/src/index.ts b/packages/ckeditor5/src/index.ts index 7f005b4f74..ca51d92d7b 100644 --- a/packages/ckeditor5/src/index.ts +++ b/packages/ckeditor5/src/index.ts @@ -11,6 +11,7 @@ export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, Model export type { TemplateDefinition } from "ckeditor5-premium-features"; export { default as buildExtraCommands } from "./extra_slash_commands.js"; export { default as getCkLocale } from "./i18n.js"; +export * from "./utils.js"; // Import with sideffects to ensure that type augmentations are present. import "@triliumnext/ckeditor5-math"; diff --git a/packages/ckeditor5/src/utils.ts b/packages/ckeditor5/src/utils.ts new file mode 100644 index 0000000000..4ed648d114 --- /dev/null +++ b/packages/ckeditor5/src/utils.ts @@ -0,0 +1,28 @@ +import type { DifferItemAttribute, Editor, ModelDocumentFragment, ModelElement, ModelNode } from "ckeditor5"; + +function hasHeadingAncestor(node: ModelElement | ModelNode | ModelDocumentFragment | null): boolean { + let current: ModelElement | ModelNode | ModelDocumentFragment | null = node; + while (current) { + if (!!current && current.is('element') && (current as ModelElement).name.startsWith("heading")) return true; + current = current.parent; + } + return false; +} + +export function attributeChangeAffectsHeading(change: DifferItemAttribute, editor: Editor): boolean { + if (change.type !== "attribute") return false; + + // Fast checks on range boundaries + if (hasHeadingAncestor(change.range.start.parent) || hasHeadingAncestor(change.range.end.parent)) { + return true; + } + + // Robust check across the whole changed range + const range = editor.model.createRange(change.range.start, change.range.end); + for (const item of range.getItems()) { + const baseNode = item.is("$textProxy") ? item.parent : item; + if (hasHeadingAncestor(baseNode)) return true; + } + + return false; +}