diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 483ddb1793..ef5ed73b4e 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,7 +170,8 @@ 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) { 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..5bdac16f0a --- /dev/null +++ b/packages/ckeditor5/src/utils.ts @@ -0,0 +1,37 @@ +import { DifferItemAttribute, ModelDocumentFragment, ModelElement, ModelNode } from "ckeditor5"; +import { CKTextEditor } from "src"; + +function isHeadingElement(node: ModelElement | ModelNode | ModelDocumentFragment | null): node is ModelElement { + return !!node + && typeof (node as any).is === "function" + && (node as any).is("element") + && typeof (node as any).name === "string" + && (node as any).name.startsWith("heading"); +} + +function hasHeadingAncestor(node: ModelElement | ModelNode | ModelDocumentFragment | null): boolean { + let current: ModelElement | ModelNode | ModelDocumentFragment | null = node; + while (current) { + if (isHeadingElement(current)) return true; + current = current.parent; + } + return false; +} + +export function attributeChangeAffectsHeading(change: DifferItemAttribute, editor: CKTextEditor): 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; +}