editor: add keybinding to collapse/expand headings & callouts

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2026-01-28 13:02:13 +05:00
parent 12687dae7b
commit 04b9023315
11 changed files with 207 additions and 11 deletions

View File

@@ -65,7 +65,7 @@ The following keyboard shortcuts will help you navigate Notesnook faster.
| Insert math block | Ctrl ⇧ M | Ctrl ⇧ M | ⌘ ⇧ M |
| Toggle ordered list | Ctrl ⇧ 7 | Ctrl ⇧ 7 | ⌘ ⇧ 7 |
| Toggle outline list | Ctrl ⇧ O | Ctrl ⇧ O | ⌘ ⇧ O |
| Toggle outline list expand | Ctrl Space | Ctrl Space | ⌘ Space |
| Toggle callout/heading/outline list expand | Ctrl Space | Ctrl Space | ⌘ Space |
| Open search | Ctrl F | Ctrl F | ⌘ F |
| Open search and replace | Ctrl Alt F | Ctrl Alt F | ⌘ ⌥ F |
| Toggle strike | Ctrl ⇧ S | Ctrl ⇧ S | ⌘ ⇧ S |

View File

@@ -323,9 +323,9 @@ export const tiptapKeys = {
category: "Editor",
type: "tiptap"
},
toggleOutlineListExpand: {
toggleNodeExpand: {
keys: "Mod-Space",
description: "Toggle outline list expand",
description: "Toggle callout/heading/outline list expand",
category: "Editor",
type: "tiptap"
},

View File

@@ -0,0 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ctrl+space should collapse/expand callout when cursor is in first heading 1`] = `"<div data-callout-type="info" class="collapsed callout"><h4>INFO</h4><p>This is the callout content.</p></div>"`;
exports[`ctrl+space should collapse/expand callout when cursor is in first heading 2`] = `"<div data-callout-type="info" class="callout"><h4>INFO</h4><p>This is the callout content.</p></div>"`;

View File

@@ -0,0 +1,60 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
import { test, expect } from "vitest";
import { createEditor, h } from "../../../../test-utils/index.js";
import { Callout } from "../callout.js";
import { Heading } from "../../heading/heading.js";
test("ctrl+space should collapse/expand callout when cursor is in first heading", async () => {
const el = h("div", [
h("div", [h("h4", ["INFO"]), h("p", ["This is the callout content."])], {
class: "callout",
"data-callout-type": "info"
})
]);
const { editor } = createEditor({
extensions: {
callout: Callout,
heading: Heading.configure({ levels: [1, 2, 3, 4, 5, 6] })
},
initialContent: el.outerHTML
});
editor.commands.setTextSelection(3);
const collapseEvent = new KeyboardEvent("keydown", {
key: " ",
code: "Space",
ctrlKey: true,
bubbles: true
});
editor.view.dom.dispatchEvent(collapseEvent);
expect(editor.getHTML()).toMatchSnapshot();
const expandEvent = new KeyboardEvent("keydown", {
key: " ",
code: "Space",
ctrlKey: true,
bubbles: true
});
editor.view.dom.dispatchEvent(expandEvent);
expect(editor.getHTML()).toMatchSnapshot();
});

View File

@@ -18,14 +18,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
getParentAttributes,
isClickWithinBounds
isClickWithinBounds,
findParentNodeOfTypeClosestToPos
} from "../../utils/prosemirror.js";
import {
InputRule,
Node,
findParentNodeClosestToPos,
mergeAttributes
} from "@tiptap/core";
import { tiptapKeys } from "@notesnook/common";
import { InputRule, Node, mergeAttributes } from "@tiptap/core";
import { Paragraph } from "../paragraph/index.js";
import { Heading } from "../heading/index.js";
import { TextSelection } from "@tiptap/pm/state";
@@ -220,6 +217,35 @@ export const Callout = Node.create({
];
},
addKeyboardShortcuts() {
return {
[tiptapKeys.toggleNodeExpand.keys]: ({ editor }) => {
const { selection } = editor.state;
const { $from, empty } = selection;
if (!empty) return false;
if ($from.parent.type.name !== Heading.name) return false;
const callout = findParentNodeOfTypeClosestToPos($from, this.type);
if (!callout) return false;
const firstChild = callout.node.firstChild;
if (!firstChild || firstChild.type.name !== Heading.name) return false;
const headingStart = callout.start;
const headingEnd = headingStart + firstChild.nodeSize;
if ($from.pos < headingStart || $from.pos > headingEnd) return false;
const isCollapsed = callout.node.attrs.collapsed;
return editor.commands.command(({ tr }) => {
tr.setNodeAttribute(callout.pos, "collapsed", !isCollapsed);
return true;
});
}
};
},
addNodeView() {
return ({ node, getPos, editor, HTMLAttributes }) => {
const container = document.createElement("div");

View File

@@ -6,6 +6,10 @@ exports[`collapse heading > heading uncollapsed 1`] = `"<h1>Main Heading</h1><p>
exports[`converting collapsed heading to lower level should unhide higher level headings 1`] = `"<h3 data-collapsed="true">Level 1 (to be changed)</h3><h2 data-collapsed="true">Level 2</h2><p data-hidden="true">Paragraph under level 2</p>"`;
exports[`ctrl+space should collapse/expand heading 1`] = `"<h1 data-collapsed="true">Heading</h1><p data-hidden="true">Paragraph</p>"`;
exports[`ctrl+space should collapse/expand heading 2`] = `"<h1>Heading</h1><p>Paragraph</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>"`;

View File

@@ -144,3 +144,34 @@ test("converting collapsed heading to lower level should unhide higher level hea
expect(editor.getHTML()).toMatchSnapshot();
});
test("ctrl+space should collapse/expand heading", async () => {
const el = h("div", [h("h1", ["Heading"]), h("p", ["Paragraph"])]);
const { editor } = createEditor({
extensions: {
heading: Heading.configure({ levels: [1, 2, 3, 4, 5, 6] })
},
initialContent: el.outerHTML
});
editor.commands.setTextSelection(3);
const collapseEvent = new KeyboardEvent("keydown", {
key: " ",
code: "Space",
ctrlKey: true,
bubbles: true
});
editor.view.dom.dispatchEvent(collapseEvent);
expect(editor.getHTML()).toMatchSnapshot();
const expandEvent = new KeyboardEvent("keydown", {
key: " ",
code: "Space",
ctrlKey: true,
bubbles: true
});
editor.view.dom.dispatchEvent(expandEvent);
expect(editor.getHTML()).toMatchSnapshot();
});

View File

@@ -126,6 +126,37 @@ export const Heading = TiptapHeading.extend({
}),
{}
),
[tiptapKeys.toggleNodeExpand.keys]: ({ editor }) => {
const { selection } = editor.state;
const { $from, empty } = selection;
if (!empty) return false;
if ($from.parent.type.name !== this.name) return false;
const headingPos = $from.before();
const headingNode = editor.state.doc.nodeAt(headingPos);
if (!headingNode || headingNode.type.name !== this.name) return false;
// the first callout heading's collapsibility is handled by callout itself
const callout = findParentNodeClosestToPos(
$from,
(node) => node.type.name === Callout.name
);
if (callout?.node.firstChild === headingNode) return false;
const isCollapsed = headingNode.attrs.collapsed;
return editor.commands.command(({ tr }) => {
tr.setNodeAttribute(headingPos, "collapsed", !isCollapsed);
toggleNodesUnderPos(
tr,
headingPos,
headingNode.attrs.level,
!isCollapsed
);
return true;
});
},
Enter: ({ editor }) => {
const { state, commands } = editor;
const { $from } = state.selection;

View File

@@ -129,4 +129,8 @@ exports[`outline list item > code block in outline list item 1`] = `
}
`;
exports[`outline list item > ctrl+space should collapse/expand outline list item 1`] = `"<ul data-type="outlineList"><li data-collapsed="true" data-type="outlineListItem"><p data-spacing="double">item</p><ul data-type="outlineList"><li data-type="outlineListItem"><p data-spacing="double">sub item</p></li></ul></li></ul>"`;
exports[`outline list item > ctrl+space should collapse/expand outline list item 2`] = `"<ul data-type="outlineList"><li data-type="outlineListItem"><p data-spacing="double">item</p><ul data-type="outlineList"><li data-type="outlineListItem"><p data-spacing="double">sub item</p></li></ul></li></ul>"`;
exports[`outline list item > inline image as first child in the old outline list item 1`] = `"<ul data-type="outlineList"><li data-type="outlineListItem"><p data-spacing="double">item 1</p></li><li data-type="outlineListItem"><p data-spacing="double"></p><img src="image.png" data-aspect-ratio="1"></li></ul>"`;

View File

@@ -98,4 +98,39 @@ describe("outline list item", () => {
expect(editor.getHTML()).toMatchSnapshot();
});
test("ctrl+space should collapse/expand outline list item", async () => {
const el = outlineList(
outlineListItem(["item"], outlineList(outlineListItem(["sub item"])))
);
const { editor } = createEditor({
initialContent: el.outerHTML,
extensions: {
outlineList: OutlineList,
outlineListItem: OutlineListItem,
paragraph: Paragraph
}
});
editor.commands.setTextSelection(3);
const collapseEvent = new KeyboardEvent("keydown", {
key: " ",
code: "Space",
ctrlKey: true,
bubbles: true
});
editor.view.dom.dispatchEvent(collapseEvent);
expect(editor.getHTML()).toMatchSnapshot();
const expandEvent = new KeyboardEvent("keydown", {
key: " ",
code: "Space",
ctrlKey: true,
bubbles: true
});
editor.view.dom.dispatchEvent(expandEvent);
expect(editor.getHTML()).toMatchSnapshot();
});
});

View File

@@ -83,7 +83,7 @@ export const OutlineListItem = Node.create<ListItemOptions>({
addKeyboardShortcuts() {
return {
[tiptapKeys.toggleOutlineListExpand.keys]: ({ editor }) => {
[tiptapKeys.toggleNodeExpand.keys]: ({ editor }) => {
const { selection } = editor.state;
const { $from, empty } = selection;