From 704ad578fa32c7cc1674db55acd4156e8a129de9 Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:20:02 +0500 Subject: [PATCH] editor: unhide children when collapsed heading is changed to non-heading node (#8946) Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- .../__snapshots__/heading.test.ts.snap | 10 ++++ .../heading/__tests__/heading.test.ts | 59 ++++++++++++++++++- .../editor/src/extensions/heading/heading.ts | 55 ++++++++--------- 3 files changed, 96 insertions(+), 28 deletions(-) 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 36139c74a..36f948208 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 @@ -3,3 +3,13 @@ exports[`collapse heading > heading collapsed 1`] = `"

Main Heading

paragraph.

Subheading

subheading paragraph

Main heading 2

paragraph another

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

Main Heading

paragraph.

Subheading

subheading paragraph

Main heading 2

paragraph another

"`; + +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

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

Hidden paragraph

"`; + +exports[`replacing collapsed heading with another node (codeBlock) should unhide content 1`] = `"
A collpased heading

Hidden paragraph

"`; + +exports[`replacing collapsed heading with another node (paragraph) 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 9fda63e91..8f6a8a46d 100644 --- a/packages/editor/src/extensions/heading/__tests__/heading.test.ts +++ b/packages/editor/src/extensions/heading/__tests__/heading.test.ts @@ -18,8 +18,9 @@ along with this program. If not, see . */ import { test, expect } from "vitest"; -import { createEditor } from "../../../../test-utils/index.js"; +import { createEditor, h } from "../../../../test-utils/index.js"; import { Heading } from "../heading.js"; +import { Editor } from "@tiptap/core"; test("collapse heading", () => { const { editor } = createEditor({ @@ -52,3 +53,59 @@ test("collapse heading", () => { expect(editor.getHTML()).toMatchSnapshot("heading uncollapsed"); }); + +test("replacing collapsed heading with another heading level should not unhide content", () => { + const el = h("div", [ + h("h1", ["A collapsed heading"], { "data-collapsed": "true" }), + h("p", ["Hidden paragraph"], { "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: 2 }); + + expect(editor.getHTML()).toMatchSnapshot(); +}); + +const nodes: { name: string; setNode: (editor: Editor) => void }[] = [ + { + name: "paragraph", + setNode: (editor) => editor.commands.setParagraph() + }, + { + name: "codeBlock", + setNode: (editor) => editor.commands.setCodeBlock() + }, + { + name: "bulletList", + setNode: (editor) => editor.commands.toggleList("bulletList", "listItem") + }, + { + name: "blockquote", + setNode: (editor) => editor.commands.toggleBlockquote() + } +]; +for (const { name, setNode } of nodes) { + test(`replacing collapsed heading with another node (${name}) should unhide content`, () => { + const el = h("div", [ + h("h1", ["A collpased heading"], { "data-collapsed": "true" }), + h("p", ["Hidden paragraph"], { "data-hidden": "true" }) + ]); + const { editor } = createEditor({ + extensions: { + heading: Heading.configure({ levels: [1, 2, 3, 4, 5, 6] }) + }, + initialContent: el.outerHTML + }); + + editor.commands.setTextSelection(0); + setNode(editor); + + expect(editor.getHTML()).toMatchSnapshot(); + }); +} diff --git a/packages/editor/src/extensions/heading/heading.ts b/packages/editor/src/extensions/heading/heading.ts index 83a1cde0d..45ac83846 100644 --- a/packages/editor/src/extensions/heading/heading.ts +++ b/packages/editor/src/extensions/heading/heading.ts @@ -207,7 +207,7 @@ export const Heading = TiptapHeading.extend({ const headingLevel = currentNode.attrs.level; tr.setNodeAttribute(pos, "collapsed", shouldCollapse); - toggleNodesUnderHeading(tr, pos, headingLevel, shouldCollapse); + toggleNodesUnderPos(tr, pos, headingLevel, shouldCollapse); } return true; }); @@ -255,17 +255,17 @@ export const Heading = TiptapHeading.extend({ } }); -function toggleNodesUnderHeading( +function toggleNodesUnderPos( tr: Transaction, - headingPos: number, + pos: number, headingLevel: number, isCollapsing: boolean ) { const { doc } = tr; - const headingNode = doc.nodeAt(headingPos); - if (!headingNode || headingNode.type.name !== "heading") return; + const node = doc.nodeAt(pos); + if (!node) return; - let nextPos = headingPos + headingNode.nodeSize; + let nextPos = pos + node.nodeSize; const cursorPos = tr.selection.from; let shouldMoveCursor = false; let insideCollapsedHeading = false; @@ -320,8 +320,8 @@ function toggleNodesUnderHeading( } if (shouldMoveCursor) { - const headingEndPos = headingPos + headingNode.nodeSize - 1; - tr.setSelection(Selection.near(tr.doc.resolve(headingEndPos))); + const endPos = pos + node.nodeSize - 1; + tr.setSelection(Selection.near(tr.doc.resolve(endPos))); } } @@ -366,26 +366,27 @@ const headingUpdatePlugin = new Plugin({ let modified = false; newDoc.descendants((newNode, pos) => { - if (newNode.type.name === "heading") { - if (pos >= oldDoc.content.size) return; + if (pos >= oldDoc.content.size) return; - const oldNode = oldDoc.nodeAt(pos); - if ( - oldNode && - 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.attrs.collapsed) { - toggleNodesUnderHeading(tr, pos, oldNode.attrs.level, false); - toggleNodesUnderHeading(tr, pos, newNode.attrs.level, true); - modified = true; - } + const oldNode = oldDoc.nodeAt(pos); + if ( + oldNode && + 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; } } });