From 255677d98ecc7e99bc9ebcec2ab1a866b39a00f6 Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Mon, 29 Dec 2025 14:23:16 +0500 Subject: [PATCH 1/5] editor: improve headingUpdatePlugin performance Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- .../editor/src/extensions/heading/heading.ts | 11 ++--- .../table/prosemirror-tables/fixtables.ts | 33 +-------------- packages/editor/src/utils/prosemirror.ts | 42 ++++++++++++++++++- 3 files changed, 48 insertions(+), 38 deletions(-) diff --git a/packages/editor/src/extensions/heading/heading.ts b/packages/editor/src/extensions/heading/heading.ts index 5ac71eec6..a9e7ac4b7 100644 --- a/packages/editor/src/extensions/heading/heading.ts +++ b/packages/editor/src/extensions/heading/heading.ts @@ -26,6 +26,7 @@ import { Heading as TiptapHeading } from "@tiptap/extension-heading"; import { Node } from "@tiptap/pm/model"; import { Plugin, PluginKey, Selection, Transaction } from "@tiptap/pm/state"; import { Callout } from "../callout/callout.js"; +import { changedDescendants } from "../../utils/prosemirror.js"; const COLLAPSIBLE_BLOCK_TYPES = [ "paragraph", @@ -408,12 +409,10 @@ const headingUpdatePlugin = new Plugin({ const newDoc = newState.doc; let modified = false; - newDoc.descendants((newNode, pos) => { - if (pos >= oldDoc.content.size) return; + function check(newNode: Node, pos: number, oldNode?: Node) { + if (!oldNode) return; - const oldNode = oldDoc.nodeAt(pos); if ( - oldNode && oldNode.type.name === "heading" && oldNode.attrs.level !== newNode.attrs.level ) { @@ -432,7 +431,9 @@ const headingUpdatePlugin = new Plugin({ modified = true; } } - }); + } + + changedDescendants(oldDoc, newDoc, 0, check); return modified ? tr : null; } diff --git a/packages/editor/src/extensions/table/prosemirror-tables/fixtables.ts b/packages/editor/src/extensions/table/prosemirror-tables/fixtables.ts index 848be68ac..920ba1e5b 100644 --- a/packages/editor/src/extensions/table/prosemirror-tables/fixtables.ts +++ b/packages/editor/src/extensions/table/prosemirror-tables/fixtables.ts @@ -8,44 +8,13 @@ import { EditorState, PluginKey, Transaction } from "prosemirror-state"; import { tableNodeTypes, TableRole } from "./schema.js"; import { TableMap } from "./tablemap.js"; import { CellAttrs, removeColSpan } from "./util.js"; +import { changedDescendants } from "../../../utils/prosemirror.js"; /** * @public */ export const fixTablesKey = new PluginKey<{ fixTables: boolean }>("fix-tables"); -/** - * Helper for iterating through the nodes in a document that changed - * compared to the given previous document. Useful for avoiding - * duplicate work on each transaction. - * - * @public - */ -function changedDescendants( - old: Node, - cur: Node, - offset: number, - f: (node: Node, pos: number) => void -): void { - const oldSize = old.childCount, - curSize = cur.childCount; - outer: for (let i = 0, j = 0; i < curSize; i++) { - const child = cur.child(i); - for (let scan = j, e = Math.min(oldSize, i + 3); scan < e; scan++) { - if (old.child(scan) == child) { - j = scan + 1; - offset += child.nodeSize; - continue outer; - } - } - f(child, offset); - if (j < oldSize && old.child(j).sameMarkup(child)) - changedDescendants(old.child(j), child, offset + 1, f); - else child.nodesBetween(0, child.content.size, f, offset + 1); - offset += child.nodeSize; - } -} - /** * Inspect all tables in the given state's document and return a * transaction that fixes them, if necessary. If `oldState` was diff --git a/packages/editor/src/utils/prosemirror.ts b/packages/editor/src/utils/prosemirror.ts index ff2068dd7..a10801f21 100644 --- a/packages/editor/src/utils/prosemirror.ts +++ b/packages/editor/src/utils/prosemirror.ts @@ -34,7 +34,8 @@ import { Slice, DOMParser, Schema, - Fragment + Fragment, + Node } from "prosemirror-model"; import { EditorState, Selection, Transaction } from "prosemirror-state"; import TextStyle from "@tiptap/extension-text-style"; @@ -410,3 +411,42 @@ export function ensureLeadingParagraph(node: Node, schema: Schema): Fragment { return fragment; } + +/** + * Helper for iterating through the nodes in a document that changed + * compared to the given previous document. Useful for avoiding + * duplicate work on each transaction. + * + * @public + */ +export function changedDescendants( + old: Node, + cur: Node, + offset: number, + f: (newNode: Node, pos: number, oldNode?: Node) => void +): void { + const oldSize = old.childCount, + curSize = cur.childCount; + outer: for (let i = 0, j = 0; i < curSize; i++) { + const child = cur.child(i); + for (let scan = j, e = Math.min(oldSize, i + 3); scan < e; scan++) { + if (old.child(scan) == child) { + j = scan + 1; + offset += child.nodeSize; + continue outer; + } + } + f(child, offset, i < oldSize ? old.child(i) : undefined); + if (j < oldSize && old.child(j).sameMarkup(child)) { + changedDescendants(old.child(j), child, offset + 1, f); + } else { + child.nodesBetween( + 0, + child.content.size, + f as (node: Node, pos: number) => void, + offset + 1 + ); + } + offset += child.nodeSize; + } +} From 7a12fd701e1f9dcf64748ee571ac1b79bf17291f Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:53:02 +0500 Subject: [PATCH 2/5] editor: batch update hidden attribute when toggling headings Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- .../heading/batch-attribute-step.ts | 145 ++++++++++++++++++ .../editor/src/extensions/heading/heading.ts | 25 ++- 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/extensions/heading/batch-attribute-step.ts diff --git a/packages/editor/src/extensions/heading/batch-attribute-step.ts b/packages/editor/src/extensions/heading/batch-attribute-step.ts new file mode 100644 index 000000000..f1a067c9a --- /dev/null +++ b/packages/editor/src/extensions/heading/batch-attribute-step.ts @@ -0,0 +1,145 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { Step, StepResult } from "@tiptap/pm/transform"; +import { Node, Schema, Fragment } from "@tiptap/pm/model"; +import { Mapping } from "@tiptap/pm/transform"; + +interface AttributeUpdate { + pos: number; + attrName: string; + value: any; +} + +export class BatchAttributeStep extends Step { + constructor(public updates: AttributeUpdate[]) { + super(); + } + + apply(doc: Node): StepResult { + const updateMap = new Map>(); + + for (const update of this.updates) { + if (!updateMap.has(update.pos)) { + updateMap.set(update.pos, new Map()); + } + updateMap.get(update.pos)!.set(update.attrName, update.value); + } + + const newDoc = doc.copy(this.updateContent(doc.content, 0, updateMap)); + + return StepResult.ok(newDoc); + } + + private updateContent( + content: Fragment, + startPos: number, + updateMap: Map> + ): Fragment { + const nodes: Node[] = []; + let pos = startPos; + + content.forEach((node: Node) => { + const nodePos = pos; + const attrs = updateMap.get(nodePos); + + if (attrs) { + const newAttrs = { ...node.attrs }; + for (const [attrName, value] of attrs.entries()) { + newAttrs[attrName] = value; + } + + const newContent = node.isLeaf + ? node.content + : this.updateContent(node.content, pos + 1, updateMap); + + nodes.push(node.type.create(newAttrs, newContent, node.marks)); + } else { + if (!node.isLeaf && node.content.size > 0) { + const newContent = this.updateContent( + node.content, + pos + 1, + updateMap + ); + nodes.push(node.copy(newContent)); + } else { + nodes.push(node); + } + } + + pos += node.nodeSize; + }); + + return Fragment.from(nodes); + } + + invert(doc: Node): Step { + const inverseUpdates: AttributeUpdate[] = []; + + for (const update of this.updates) { + const node = doc.nodeAt(update.pos); + if (node) { + inverseUpdates.push({ + pos: update.pos, + attrName: update.attrName, + value: node.attrs[update.attrName] + }); + } + } + + return new BatchAttributeStep(inverseUpdates); + } + + map(mapping: Mapping): Step | null { + const mappedUpdates: AttributeUpdate[] = []; + + for (const update of this.updates) { + const mappedPos = mapping.map(update.pos); + + if (mappedPos === null || mappedPos === undefined) { + continue; + } + + mappedUpdates.push({ + pos: mappedPos, + attrName: update.attrName, + value: update.value + }); + } + + if (mappedUpdates.length === 0) { + return null; + } + + return new BatchAttributeStep(mappedUpdates); + } + + toJSON() { + return { + stepType: "batchAttribute", + updates: this.updates + }; + } + + static fromJSON(_: Schema, json: any): BatchAttributeStep { + return new BatchAttributeStep(json.updates); + } +} + +Step.jsonID("batchAttribute", BatchAttributeStep); diff --git a/packages/editor/src/extensions/heading/heading.ts b/packages/editor/src/extensions/heading/heading.ts index a9e7ac4b7..56c91ac64 100644 --- a/packages/editor/src/extensions/heading/heading.ts +++ b/packages/editor/src/extensions/heading/heading.ts @@ -27,6 +27,7 @@ import { Node } from "@tiptap/pm/model"; import { Plugin, PluginKey, Selection, Transaction } from "@tiptap/pm/state"; import { Callout } from "../callout/callout.js"; import { changedDescendants } from "../../utils/prosemirror.js"; +import { BatchAttributeStep } from "./batch-attribute-step.js"; const COLLAPSIBLE_BLOCK_TYPES = [ "paragraph", @@ -305,6 +306,8 @@ function toggleNodesUnderPos( headingLevel: number, isCollapsing: boolean ) { + console.time("heading-toggle - total"); + console.time("heading-toggle - setup"); const { doc } = tr; const node = doc.nodeAt(pos); if (!node) return; @@ -315,6 +318,11 @@ function toggleNodesUnderPos( let insideCollapsedHeading = false; let nestedHeadingLevel: number | null = null; + const updates: { pos: number; attrName: string; value: any }[] = []; + + console.timeEnd("heading-toggle - setup"); + console.time("heading-toggle - traverse and collect"); + while (nextPos < doc.content.size) { const nextNode = doc.nodeAt(nextPos); if (!nextNode) break; @@ -339,7 +347,7 @@ function toggleNodesUnderPos( if (COLLAPSIBLE_BLOCK_TYPES.includes(nextNode.type.name)) { if (isCollapsing) { - tr.setNodeAttribute(currentPos, "hidden", true); + updates.push({ pos: currentPos, attrName: "hidden", value: true }); } else { if (insideCollapsedHeading) { if ( @@ -354,7 +362,7 @@ function toggleNodesUnderPos( } } - tr.setNodeAttribute(currentPos, "hidden", false); + updates.push({ pos: currentPos, attrName: "hidden", value: false }); if (nextNode.type.name === "heading" && nextNode.attrs.collapsed) { insideCollapsedHeading = true; nestedHeadingLevel = nextNode.attrs.level; @@ -363,10 +371,23 @@ function toggleNodesUnderPos( } } + console.timeEnd("heading-toggle - traverse and collect"); + console.log(`heading-toggle - collected ${updates.length} updates`); + + if (updates.length > 0) { + console.time("heading-toggle - apply batch step"); + tr.step(new BatchAttributeStep(updates)); + console.timeEnd("heading-toggle - apply batch step"); + } + if (shouldMoveCursor) { + console.time("heading-toggle - move cursor"); const endPos = pos + node.nodeSize - 1; tr.setSelection(Selection.near(tr.doc.resolve(endPos))); + console.timeEnd("heading-toggle - move cursor"); } + + console.timeEnd("heading-toggle - total"); } function findEndOfCollapsedSection( From 200c9bd9eb32ad1305188436b7067828694166c6 Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:01:14 +0500 Subject: [PATCH 3/5] editor: improve checks to not unecessary call toggle fn in heading update plugin Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- .../editor/src/extensions/heading/heading.ts | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/packages/editor/src/extensions/heading/heading.ts b/packages/editor/src/extensions/heading/heading.ts index 56c91ac64..3f608c094 100644 --- a/packages/editor/src/extensions/heading/heading.ts +++ b/packages/editor/src/extensions/heading/heading.ts @@ -26,7 +26,10 @@ import { Heading as TiptapHeading } from "@tiptap/extension-heading"; import { Node } from "@tiptap/pm/model"; import { Plugin, PluginKey, Selection, Transaction } from "@tiptap/pm/state"; import { Callout } from "../callout/callout.js"; -import { changedDescendants } from "../../utils/prosemirror.js"; +import { + changedDescendants, + findParentNodeOfTypeClosestToPos +} from "../../utils/prosemirror.js"; import { BatchAttributeStep } from "./batch-attribute-step.js"; const COLLAPSIBLE_BLOCK_TYPES = [ @@ -306,8 +309,6 @@ function toggleNodesUnderPos( headingLevel: number, isCollapsing: boolean ) { - console.time("heading-toggle - total"); - console.time("heading-toggle - setup"); const { doc } = tr; const node = doc.nodeAt(pos); if (!node) return; @@ -317,12 +318,8 @@ function toggleNodesUnderPos( let shouldMoveCursor = false; let insideCollapsedHeading = false; let nestedHeadingLevel: number | null = null; - const updates: { pos: number; attrName: string; value: any }[] = []; - console.timeEnd("heading-toggle - setup"); - console.time("heading-toggle - traverse and collect"); - while (nextPos < doc.content.size) { const nextNode = doc.nodeAt(nextPos); if (!nextNode) break; @@ -371,23 +368,14 @@ function toggleNodesUnderPos( } } - console.timeEnd("heading-toggle - traverse and collect"); - console.log(`heading-toggle - collected ${updates.length} updates`); - if (updates.length > 0) { - console.time("heading-toggle - apply batch step"); tr.step(new BatchAttributeStep(updates)); - console.timeEnd("heading-toggle - apply batch step"); } if (shouldMoveCursor) { - console.time("heading-toggle - move cursor"); const endPos = pos + node.nodeSize - 1; tr.setSelection(Selection.near(tr.doc.resolve(endPos))); - console.timeEnd("heading-toggle - move cursor"); } - - console.timeEnd("heading-toggle - total"); } function findEndOfCollapsedSection( @@ -434,23 +422,36 @@ const headingUpdatePlugin = new Plugin({ if (!oldNode) return; if ( - oldNode.type.name === "heading" && - oldNode.attrs.level !== newNode.attrs.level + oldNode.type.name !== "heading" || + oldNode.attrs.level === newNode.attrs.level ) { - /** - * if the level of a collapsed heading is changed, - * we need to reset visibility of all the nodes under it as there - * might be a heading of same or higher level previously - * hidden under this heading - */ - if (newNode.type.name === "heading" && newNode.attrs.collapsed) { - toggleNodesUnderPos(tr, pos, oldNode.attrs.level, false); - toggleNodesUnderPos(tr, pos, newNode.attrs.level, true); - modified = true; - } else if (newNode.type.name !== "heading" && oldNode.attrs.collapsed) { - toggleNodesUnderPos(tr, pos, oldNode.attrs.level, false); - modified = true; + return; + } + + /** + * if the level of a collapsed heading is changed, + * we need to reset visibility of all the nodes under it as there + * might be a heading of same or higher level previously + * hidden under this heading + */ + if (newNode.type.name === "heading" && newNode.attrs.collapsed) { + toggleNodesUnderPos(tr, pos, oldNode.attrs.level, false); + toggleNodesUnderPos(tr, pos, newNode.attrs.level, true); + modified = true; + return; + } + + if (newNode.type.name !== "heading" && oldNode.attrs.collapsed) { + if ( + newNode.type.name === "text" && + findParentNodeOfTypeClosestToPos(newDoc.resolve(pos), oldNode.type) + ?.node === oldNode + ) { + return; } + + toggleNodesUnderPos(tr, pos, oldNode.attrs.level, false); + modified = true; } } From 50666c130733691adda6da2df879229b99b336b2 Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:33:21 +0500 Subject: [PATCH 4/5] editor: add a collapsible heading test case Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- .../__snapshots__/heading.test.ts.snap | 2 ++ .../heading/__tests__/heading.test.ts | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/extensions/heading/__tests__/__snapshots__/heading.test.ts.snap b/packages/editor/src/extensions/heading/__tests__/__snapshots__/heading.test.ts.snap index 36f948208..61e8cb4cc 100644 --- a/packages/editor/src/extensions/heading/__tests__/__snapshots__/heading.test.ts.snap +++ b/packages/editor/src/extensions/heading/__tests__/__snapshots__/heading.test.ts.snap @@ -4,6 +4,8 @@ exports[`collapse heading > heading collapsed 1`] = `"

exports[`collapse heading > heading uncollapsed 1`] = `"

Main Heading

paragraph.

Subheading

subheading paragraph

Main heading 2

paragraph another

"`; +exports[`converting collapsed heading to lower level should unhide higher level headings 1`] = `"

Level 1 (to be changed)

Level 2

Paragraph under level 2

"`; + exports[`replacing collapsed heading with another heading level should not unhide content 1`] = `"

A collapsed heading

Hidden paragraph

"`; exports[`replacing collapsed heading with another node (blockquote) should unhide content 1`] = `"

A collpased heading

Hidden paragraph

"`; diff --git a/packages/editor/src/extensions/heading/__tests__/heading.test.ts b/packages/editor/src/extensions/heading/__tests__/heading.test.ts index ac007953c..420e01ffc 100644 --- a/packages/editor/src/extensions/heading/__tests__/heading.test.ts +++ b/packages/editor/src/extensions/heading/__tests__/heading.test.ts @@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { test, expect } from "vitest"; +import { test, expect, describe } from "vitest"; import { createEditor, h } from "../../../../test-utils/index.js"; import { Heading } from "../heading.js"; import { Editor } from "@tiptap/core"; @@ -125,3 +125,22 @@ test("empty heading should have empty class", () => { editor.commands.insertContent("Some content"); expect(headingElement?.classList.contains("empty")).toBe(false); }); + +test("converting collapsed heading to lower level should unhide higher level headings", () => { + const el = h("div", [ + h("h1", ["Level 1 (to be changed)"], { "data-collapsed": "true" }), + h("h2", ["Level 2"], { "data-hidden": "true", "data-collapsed": "true" }), + h("p", ["Paragraph under level 2"], { "data-hidden": "true" }) + ]); + const { editor } = createEditor({ + extensions: { + heading: Heading.configure({ levels: [1, 2, 3, 4, 5, 6] }) + }, + initialContent: el.outerHTML + }); + + editor.commands.setTextSelection(0); + editor.commands.setHeading({ level: 3 }); + + expect(editor.getHTML()).toMatchSnapshot(); +}); From b881b236a07cd7b95acb78ba310ed0c878e09fd7 Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:23:27 +0500 Subject: [PATCH 5/5] editor: export & use AttributeUpdate interface Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- .../editor/src/extensions/heading/batch-attribute-step.ts | 2 +- packages/editor/src/extensions/heading/heading.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/extensions/heading/batch-attribute-step.ts b/packages/editor/src/extensions/heading/batch-attribute-step.ts index f1a067c9a..6dec08802 100644 --- a/packages/editor/src/extensions/heading/batch-attribute-step.ts +++ b/packages/editor/src/extensions/heading/batch-attribute-step.ts @@ -21,7 +21,7 @@ import { Step, StepResult } from "@tiptap/pm/transform"; import { Node, Schema, Fragment } from "@tiptap/pm/model"; import { Mapping } from "@tiptap/pm/transform"; -interface AttributeUpdate { +export interface AttributeUpdate { pos: number; attrName: string; value: any; diff --git a/packages/editor/src/extensions/heading/heading.ts b/packages/editor/src/extensions/heading/heading.ts index 3f608c094..a16efc0ae 100644 --- a/packages/editor/src/extensions/heading/heading.ts +++ b/packages/editor/src/extensions/heading/heading.ts @@ -30,7 +30,7 @@ import { changedDescendants, findParentNodeOfTypeClosestToPos } from "../../utils/prosemirror.js"; -import { BatchAttributeStep } from "./batch-attribute-step.js"; +import { AttributeUpdate, BatchAttributeStep } from "./batch-attribute-step.js"; const COLLAPSIBLE_BLOCK_TYPES = [ "paragraph", @@ -318,7 +318,7 @@ function toggleNodesUnderPos( let shouldMoveCursor = false; let insideCollapsedHeading = false; let nestedHeadingLevel: number | null = null; - const updates: { pos: number; attrName: string; value: any }[] = []; + const updates: AttributeUpdate[] = []; while (nextPos < doc.content.size) { const nextNode = doc.nodeAt(nextPos);