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; + } +}