From 93a7f8c711cf1eac1c7657fec005a8504e493589 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 5 Mar 2026 19:03:32 +0200 Subject: [PATCH 1/3] fix(toc): not reacting to attribute changes in CKEditor --- .../src/widgets/sidebar/TableOfContents.tsx | 5 ++- packages/ckeditor5/src/index.ts | 1 + packages/ckeditor5/src/utils.ts | 37 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 packages/ckeditor5/src/utils.ts 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; +} From 65514a6fd75ef986f58caf9a7077f3ba1abadb25 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 5 Mar 2026 19:08:56 +0200 Subject: [PATCH 2/3] fix(toc): title is extracted before changes are made --- apps/client/src/widgets/sidebar/TableOfContents.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index ef5ed73b4e..fdf616ee26 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -175,7 +175,9 @@ function EditableTextTableOfContents() { ); }); if (affectsHeadings) { - setHeadings(extractTocFromTextEditor(textEditor)); + requestAnimationFrame(() => { + setHeadings(extractTocFromTextEditor(textEditor)); + }); } }; From 8128a8192ae0894033df53fad00bb59f024f43a5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 5 Mar 2026 19:28:52 +0200 Subject: [PATCH 3/3] refactor(ckeditor): address requested changes --- packages/ckeditor5/src/utils.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/ckeditor5/src/utils.ts b/packages/ckeditor5/src/utils.ts index 5bdac16f0a..4ed648d114 100644 --- a/packages/ckeditor5/src/utils.ts +++ b/packages/ckeditor5/src/utils.ts @@ -1,24 +1,15 @@ -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"); -} +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 (isHeadingElement(current)) return true; + if (!!current && current.is('element') && (current as ModelElement).name.startsWith("heading")) return true; current = current.parent; } return false; } -export function attributeChangeAffectsHeading(change: DifferItemAttribute, editor: CKTextEditor): boolean { +export function attributeChangeAffectsHeading(change: DifferItemAttribute, editor: Editor): boolean { if (change.type !== "attribute") return false; // Fast checks on range boundaries