diff --git a/docs/help/contents/keyboard-shortcuts.md b/docs/help/contents/keyboard-shortcuts.md
index 9a5a85818..35759807e 100644
--- a/docs/help/contents/keyboard-shortcuts.md
+++ b/docs/help/contents/keyboard-shortcuts.md
@@ -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 |
diff --git a/packages/common/src/utils/keybindings.ts b/packages/common/src/utils/keybindings.ts
index 73c1296a7..2fa6b1508 100644
--- a/packages/common/src/utils/keybindings.ts
+++ b/packages/common/src/utils/keybindings.ts
@@ -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"
},
diff --git a/packages/editor/src/extensions/callout/__tests__/__snapshots__/callout.test.ts.snap b/packages/editor/src/extensions/callout/__tests__/__snapshots__/callout.test.ts.snap
new file mode 100644
index 000000000..8b5989479
--- /dev/null
+++ b/packages/editor/src/extensions/callout/__tests__/__snapshots__/callout.test.ts.snap
@@ -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`] = `"
INFO
This is the callout content.
"`;
+
+exports[`ctrl+space should collapse/expand callout when cursor is in first heading 2`] = `"INFO
This is the callout content.
"`;
diff --git a/packages/editor/src/extensions/callout/__tests__/callout.test.ts b/packages/editor/src/extensions/callout/__tests__/callout.test.ts
new file mode 100644
index 000000000..473a358e6
--- /dev/null
+++ b/packages/editor/src/extensions/callout/__tests__/callout.test.ts
@@ -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 .
+*/
+
+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();
+});
diff --git a/packages/editor/src/extensions/callout/callout.ts b/packages/editor/src/extensions/callout/callout.ts
index 86d8b0f03..ce815c5b1 100644
--- a/packages/editor/src/extensions/callout/callout.ts
+++ b/packages/editor/src/extensions/callout/callout.ts
@@ -18,14 +18,11 @@ along with this program. If not, see .
*/
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");
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 61e8cb4cc..37e8bb512 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
@@ -6,6 +6,10 @@ exports[`collapse heading > heading uncollapsed 1`] = `"Main Heading
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[`ctrl+space should collapse/expand heading 1`] = `"Heading
Paragraph
"`;
+
+exports[`ctrl+space should collapse/expand heading 2`] = `"Heading
Paragraph
"`;
+
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 420e01ffc..6b32f2279 100644
--- a/packages/editor/src/extensions/heading/__tests__/heading.test.ts
+++ b/packages/editor/src/extensions/heading/__tests__/heading.test.ts
@@ -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();
+});
diff --git a/packages/editor/src/extensions/heading/heading.ts b/packages/editor/src/extensions/heading/heading.ts
index d5c2720fa..e9b341afc 100644
--- a/packages/editor/src/extensions/heading/heading.ts
+++ b/packages/editor/src/extensions/heading/heading.ts
@@ -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;
diff --git a/packages/editor/src/extensions/outline-list-item/__tests__/__snapshots__/outline-list-item.test.ts.snap b/packages/editor/src/extensions/outline-list-item/__tests__/__snapshots__/outline-list-item.test.ts.snap
index 3aebd38b5..d81bf5cfb 100644
--- a/packages/editor/src/extensions/outline-list-item/__tests__/__snapshots__/outline-list-item.test.ts.snap
+++ b/packages/editor/src/extensions/outline-list-item/__tests__/__snapshots__/outline-list-item.test.ts.snap
@@ -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`] = `""`;
+
+exports[`outline list item > ctrl+space should collapse/expand outline list item 2`] = `""`;
+
exports[`outline list item > inline image as first child in the old outline list item 1`] = `"item 1

"`;
diff --git a/packages/editor/src/extensions/outline-list-item/__tests__/outline-list-item.test.ts b/packages/editor/src/extensions/outline-list-item/__tests__/outline-list-item.test.ts
index 673040c39..49a2622ec 100644
--- a/packages/editor/src/extensions/outline-list-item/__tests__/outline-list-item.test.ts
+++ b/packages/editor/src/extensions/outline-list-item/__tests__/outline-list-item.test.ts
@@ -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();
+ });
});
diff --git a/packages/editor/src/extensions/outline-list-item/outline-list-item.ts b/packages/editor/src/extensions/outline-list-item/outline-list-item.ts
index b26574b5b..0eb2e6c07 100644
--- a/packages/editor/src/extensions/outline-list-item/outline-list-item.ts
+++ b/packages/editor/src/extensions/outline-list-item/outline-list-item.ts
@@ -83,7 +83,7 @@ export const OutlineListItem = Node.create({
addKeyboardShortcuts() {
return {
- [tiptapKeys.toggleOutlineListExpand.keys]: ({ editor }) => {
+ [tiptapKeys.toggleNodeExpand.keys]: ({ editor }) => {
const { selection } = editor.state;
const { $from, empty } = selection;