editor: unhide children when collapsed heading is changed to non-heading node (#8946)

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2025-11-24 14:20:02 +05:00
committed by GitHub
parent cf608977e1
commit 704ad578fa
3 changed files with 96 additions and 28 deletions

View File

@@ -3,3 +3,13 @@
exports[`collapse heading > heading collapsed 1`] = `"<h1 data-collapsed="true">Main Heading</h1><p>paragraph.</p><h2>Subheading</h2><p>subheading paragraph</p><h1>Main heading 2</h1><p>paragraph another</p>"`;
exports[`collapse heading > heading uncollapsed 1`] = `"<h1>Main Heading</h1><p>paragraph.</p><h2>Subheading</h2><p>subheading paragraph</p><h1>Main heading 2</h1><p>paragraph another</p>"`;
exports[`replacing collapsed heading with another heading level should not unhide content 1`] = `"<h2 data-collapsed="true">A collapsed heading</h2><p data-hidden="true">Hidden paragraph</p>"`;
exports[`replacing collapsed heading with another node (blockquote) should unhide content 1`] = `"<blockquote><h1 data-collapsed="true">A collpased heading</h1></blockquote><p>Hidden paragraph</p>"`;
exports[`replacing collapsed heading with another node (bulletList) should unhide content 1`] = `"<ul><li><p>A collpased heading</p></li></ul><p>Hidden paragraph</p>"`;
exports[`replacing collapsed heading with another node (codeBlock) should unhide content 1`] = `"<pre><code>A collpased heading</code></pre><p>Hidden paragraph</p>"`;
exports[`replacing collapsed heading with another node (paragraph) should unhide content 1`] = `"<p>A collpased heading</p><p>Hidden paragraph</p>"`;

View File

@@ -18,8 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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();
});
}

View File

@@ -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;
}
}
});