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();
+});
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..6dec08802
--- /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";
+
+export 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 5ac71eec6..a16efc0ae 100644
--- a/packages/editor/src/extensions/heading/heading.ts
+++ b/packages/editor/src/extensions/heading/heading.ts
@@ -26,6 +26,11 @@ 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,
+ findParentNodeOfTypeClosestToPos
+} from "../../utils/prosemirror.js";
+import { AttributeUpdate, BatchAttributeStep } from "./batch-attribute-step.js";
const COLLAPSIBLE_BLOCK_TYPES = [
"paragraph",
@@ -313,6 +318,7 @@ function toggleNodesUnderPos(
let shouldMoveCursor = false;
let insideCollapsedHeading = false;
let nestedHeadingLevel: number | null = null;
+ const updates: AttributeUpdate[] = [];
while (nextPos < doc.content.size) {
const nextNode = doc.nodeAt(nextPos);
@@ -338,7 +344,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 (
@@ -353,7 +359,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;
@@ -362,6 +368,10 @@ function toggleNodesUnderPos(
}
}
+ if (updates.length > 0) {
+ tr.step(new BatchAttributeStep(updates));
+ }
+
if (shouldMoveCursor) {
const endPos = pos + node.nodeSize - 1;
tr.setSelection(Selection.near(tr.doc.resolve(endPos)));
@@ -408,31 +418,44 @@ 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
+ 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;
+ }
+ }
+
+ 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;
+ }
+}