Merge pull request #8557 from 01zulfi/editor/refactor-collapsible-headings

editor: make headings collapsible
This commit is contained in:
01zulfi
2025-10-08 13:26:37 +05:00
committed by GitHub
parent bef9fd0fd0
commit 5219814b5a
11 changed files with 561 additions and 76 deletions

View File

@@ -704,8 +704,10 @@ function EditorChrome(props: PropsWithChildren<EditorProps>) {
);
requestAnimationFrame(() => {
editor.style.marginLeft = `-${negativeSpace}px`;
editor.style.marginRight = `-${negativeSpace}px`;
if (!isMobile() && !isTablet()) {
editor.style.marginLeft = `-${negativeSpace}px`;
editor.style.marginRight = `-${negativeSpace}px`;
}
editor.style.paddingLeft = `${negativeSpace}px`;
editor.style.paddingRight = `${negativeSpace}px`;
});

View File

@@ -16,7 +16,10 @@ 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 { getParentAttributes } from "../../utils/prosemirror.js";
import {
getParentAttributes,
isClickWithinBounds
} from "../../utils/prosemirror.js";
import {
InputRule,
Node,
@@ -235,37 +238,9 @@ export const Callout = Node.create({
const pos = typeof getPos === "function" ? getPos() : 0;
if (typeof pos !== "number") return;
const resolvedPos = editor.state.doc.resolve(pos);
const { x, y, width } = e.target.getBoundingClientRect();
const clientX =
e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
const clientY =
e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
const hitArea = { width: 40, height: 40 };
const isRtl =
e.target.dir === "rtl" ||
findParentNodeClosestToPos(
resolvedPos,
(node) => !!node.attrs.textDirection
)?.node.attrs.textDirection === "rtl";
let xEnd = clientX <= x + width;
let xStart = clientX >= x + width - hitArea.width;
const yStart = clientY >= y;
const yEnd = clientY <= y + hitArea.height;
if (isRtl) {
xStart = clientX >= x;
xEnd = clientX <= x + hitArea.width;
}
if (xStart && xEnd && yStart && yEnd) {
if (isClickWithinBounds(e, resolvedPos, "right")) {
e.preventDefault();
e.stopImmediatePropagation();
@@ -283,6 +258,12 @@ export const Callout = Node.create({
container.onmousedown = onClick;
container.ontouchstart = onClick;
if (node.attrs.hiddenUnder) {
container.dataset.hiddenUnder = node.attrs.hiddenUnder;
} else {
delete container.dataset.hiddenUnder;
}
return {
dom: container,
contentDOM: container,
@@ -294,6 +275,10 @@ export const Callout = Node.create({
if (updatedNode.attrs.collapsed) container.classList.add("collapsed");
else container.classList.remove("collapsed");
if (updatedNode.attrs.hiddenUnder)
container.dataset.hiddenUnder = updatedNode.attrs.hiddenUnder;
else delete container.dataset.hiddenUnder;
return true;
}
};

View File

@@ -554,7 +554,8 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return (
compareCaretPosition(prev.caretPosition, next.caretPosition) ||
prev.language !== next.language ||
prev.indentType !== next.indentType
prev.indentType !== next.indentType ||
prev.hiddenUnder !== next.hiddenUnder
);
}
});

View File

@@ -0,0 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
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>"`;

View File

@@ -0,0 +1,54 @@
/*
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 } from "../../../../test-utils/index.js";
import { Heading } from "../heading.js";
test("collapse heading", () => {
const { editor } = createEditor({
extensions: {
heading: Heading.configure({ levels: [1, 2, 3, 4, 5, 6] })
},
initialContent: `
<h1>Main Heading</h1>
<p>paragraph.</p>
<h2>Subheading</h2>
<p>subheading paragraph</p>
<h1>Main heading 2</h1>
<p>paragraph another</p>
`
});
const headingPos = 0;
editor.commands.command(({ tr }) => {
tr.setNodeAttribute(headingPos, "collapsed", true);
return true;
});
expect(editor.getHTML()).toMatchSnapshot("heading collapsed");
editor.commands.command(({ tr }) => {
tr.setNodeAttribute(headingPos, "collapsed", false);
return true;
});
expect(editor.getHTML()).toMatchSnapshot("heading uncollapsed");
});

View File

@@ -18,11 +18,49 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { tiptapKeys } from "@notesnook/common";
import { textblockTypeInputRule } from "@tiptap/core";
import {
findParentNodeClosestToPos,
textblockTypeInputRule
} from "@tiptap/core";
import { Heading as TiptapHeading } from "@tiptap/extension-heading";
import { isClickWithinBounds } from "../../utils/prosemirror";
import { Selection, Transaction } from "@tiptap/pm/state";
import { Node } from "@tiptap/pm/model";
const COLLAPSIBLE_BLOCK_TYPES = [
"paragraph",
"heading",
"blockquote",
"bulletList",
"orderedList",
"checkList",
"taskList",
"table",
"callout",
"codeblock",
"image",
"outlineList",
"mathBlock",
"webclip",
"embed"
];
const HEADING_REGEX = /^(#{1,6})\s$/;
export const Heading = TiptapHeading.extend({
addAttributes() {
return {
...this.parent?.(),
collapsed: {
default: false,
keepOnSplit: false,
parseHTML: (element) => element.dataset.collapsed === "true",
renderHTML: (attributes) => ({
"data-collapsed": attributes.collapsed === true
})
}
};
},
addCommands() {
return {
...this.parent?.(),
@@ -45,17 +83,65 @@ export const Heading = TiptapHeading.extend({
};
},
addKeyboardShortcuts() {
return this.options.levels.reduce(
(items, level) => ({
...items,
...{
[tiptapKeys[`insertHeading${level}`].keys]: () =>
this.editor.commands.setHeading({ level })
addGlobalAttributes() {
return [
{
types: COLLAPSIBLE_BLOCK_TYPES,
attributes: {
hiddenUnder: {
default: null,
keepOnSplit: false,
parseHTML: (element) => element.dataset.hiddenUnder || null,
renderHTML: (attributes) => {
if (!attributes.hiddenUnder) return {};
return {
"data-hidden-under": attributes.hiddenUnder
};
}
}
}
}),
{}
);
}
];
},
addKeyboardShortcuts() {
return {
...this.options.levels.reduce(
(items, level) => ({
...items,
...{
[tiptapKeys[`insertHeading${level}`].keys]: () =>
this.editor.commands.setHeading({ level })
}
}),
{}
),
Enter: ({ editor }) => {
const { state, commands } = editor;
const { $from } = state.selection;
const node = $from.node();
if (node.type.name !== this.name) return false;
const isAtEnd = $from.parentOffset === node.textContent.length;
if (isAtEnd && node.attrs.collapsed) {
const headingPos = $from.before();
const endPos = findEndOfCollapsedSection(
state.doc,
headingPos,
node.attrs.level
);
if (endPos === -1) return false;
return commands.command(({ tr }) => {
tr.insert(endPos, state.schema.nodes.paragraph.create());
const newPos = endPos + 1;
tr.setSelection(Selection.near(tr.doc.resolve(newPos)));
return true;
});
}
return false;
}
};
},
addInputRules() {
@@ -71,5 +157,171 @@ export const Heading = TiptapHeading.extend({
}
})
];
},
addNodeView() {
return ({ node, getPos, editor, HTMLAttributes }) => {
const heading = document.createElement(`h${node.attrs.level}`);
for (const attr in HTMLAttributes) {
heading.setAttribute(attr, HTMLAttributes[attr]);
}
if (node.attrs.collapsed) heading.dataset.collapsed = "true";
else delete heading.dataset.collapsed;
function onClick(e: MouseEvent | TouchEvent) {
if (e instanceof MouseEvent && e.button !== 0) return;
if (!(e.target instanceof HTMLHeadingElement)) return;
const pos = typeof getPos === "function" ? getPos() : 0;
if (typeof pos !== "number") return;
const resolvedPos = editor.state.doc.resolve(pos);
const calloutAncestor = findParentNodeClosestToPos(
resolvedPos,
(node) => node.type.name === "callout"
);
if (calloutAncestor) return;
if (isClickWithinBounds(e, resolvedPos, "left")) {
e.preventDefault();
e.stopImmediatePropagation();
editor.commands.command(({ tr }) => {
const currentNode = tr.doc.nodeAt(pos);
if (currentNode && currentNode.type.name === "heading") {
const shouldCollapse = !currentNode.attrs.collapsed;
const headingLevel = currentNode.attrs.level;
const headingId = currentNode.attrs.blockId;
tr.setNodeAttribute(pos, "collapsed", shouldCollapse);
toggleNodesUnderHeading(
tr,
pos,
headingLevel,
shouldCollapse,
headingId
);
}
return true;
});
}
}
heading.onmousedown = onClick;
heading.ontouchstart = onClick;
return {
dom: heading,
contentDOM: heading,
update: (updatedNode) => {
if (updatedNode.type !== this.type) {
return false;
}
if (updatedNode.attrs.level !== node.attrs.level) {
return false;
}
if (updatedNode.attrs.collapsed) heading.dataset.collapsed = "true";
else delete heading.dataset.collapsed;
if (updatedNode.attrs.hiddenUnder)
heading.dataset.hiddenUnder = updatedNode.attrs.hiddenUnder;
else delete heading.dataset.hiddenUnder;
if (updatedNode.attrs.textAlign)
heading.style.textAlign =
updatedNode.attrs.textAlign === "left"
? ""
: updatedNode.attrs.textAlign;
if (updatedNode.attrs.textDirection)
heading.dir = updatedNode.attrs.textDirection;
else heading.dir = "";
return true;
}
};
};
}
});
function toggleNodesUnderHeading(
tr: Transaction,
headingPos: number,
headingLevel: number,
isCollapsing: boolean,
headingId: string
) {
const { doc } = tr;
const headingNode = doc.nodeAt(headingPos);
if (!headingNode || headingNode.type.name !== "heading") return;
let nextPos = headingPos + headingNode.nodeSize;
const cursorPos = tr.selection.from;
let shouldMoveCursor = false;
while (nextPos < doc.content.size) {
const nextNode = doc.nodeAt(nextPos);
if (!nextNode) break;
if (
nextNode.type.name === "heading" &&
nextNode.attrs.level <= headingLevel
) {
break;
}
if (
isCollapsing &&
cursorPos >= nextPos &&
cursorPos < nextPos + nextNode.nodeSize
) {
shouldMoveCursor = true;
}
if (COLLAPSIBLE_BLOCK_TYPES.includes(nextNode.type.name)) {
if (isCollapsing && typeof nextNode.attrs.hiddenUnder !== "string") {
tr.setNodeAttribute(nextPos, "hiddenUnder", headingId);
} else if (!isCollapsing && nextNode.attrs.hiddenUnder === headingId) {
tr.setNodeAttribute(nextPos, "hiddenUnder", null);
}
}
nextPos += nextNode.nodeSize;
}
if (shouldMoveCursor) {
const headingEndPos = headingPos + headingNode.nodeSize - 1;
tr.setSelection(Selection.near(tr.doc.resolve(headingEndPos)));
}
}
function findEndOfCollapsedSection(
doc: Node,
headingPos: number,
headingLevel: number
) {
const headingNode = doc.nodeAt(headingPos);
if (!headingNode || headingNode.type.name !== "heading") return -1;
let nextPos = headingPos + headingNode.nodeSize;
while (nextPos < doc.content.size) {
const nextNode = doc.nodeAt(nextPos);
if (!nextNode) break;
if (
nextNode.type.name === "heading" &&
nextNode.attrs.level <= headingLevel
) {
break;
}
nextPos += nextNode.nodeSize;
}
return nextPos;
}

View File

@@ -125,6 +125,12 @@ export class MathView implements NodeView, ICursorPosObserver {
if (options.className) this.dom.classList.add(options.className);
this.dom.classList.add("math-node");
if (node.attrs.hiddenUnder) {
this.dom.dataset.hiddenUnder = node.attrs.hiddenUnder;
} else {
delete this.dom.dataset.hiddenUnder;
}
this._mathRenderElt = document.createElement("span");
this._mathRenderElt.textContent = "";
this._mathRenderElt.classList.add("math-render");

View File

@@ -22,7 +22,10 @@ import {
mergeAttributes,
findParentNodeClosestToPos
} from "@tiptap/core";
import { findParentNodeOfTypeClosestToPos } from "../../utils/prosemirror.js";
import {
findParentNodeOfTypeClosestToPos,
isClickWithinBounds
} from "../../utils/prosemirror.js";
import { OutlineList } from "../outline-list/outline-list.js";
import { keybindings, tiptapKeys } from "@notesnook/common";
@@ -129,36 +132,9 @@ export const OutlineListItem = Node.create<ListItemOptions>({
const pos = typeof getPos === "function" ? getPos() : 0;
if (typeof pos !== "number") return;
const resolvedPos = editor.state.doc.resolve(pos);
const { x, y, right } = li.getBoundingClientRect();
const clientX =
e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
const clientY =
e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
const hitArea = { width: 40, height: 40 };
const isRtl =
e.target.dir === "rtl" ||
findParentNodeClosestToPos(
resolvedPos,
(node) => !!node.attrs.textDirection
)?.node.attrs.textDirection === "rtl";
let xStart = clientX >= x - hitArea.width;
let xEnd = clientX <= x;
const yStart = clientY >= y;
const yEnd = clientY <= y + hitArea.height;
if (isRtl) {
xEnd = clientX <= right + hitArea.width;
xStart = clientX >= right;
}
if (xStart && xEnd && yStart && yEnd) {
if (isClickWithinBounds(e, resolvedPos, "left")) {
e.preventDefault();
editor.commands.command(({ tr }) => {
tr.setNodeAttribute(

View File

@@ -112,6 +112,12 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
return;
}
if (this.node.attrs.hiddenUnder) {
this.domRef.dataset.hiddenUnder = this.node.attrs.hiddenUnder;
} else {
delete this.domRef.dataset.hiddenUnder;
}
portalProviderAPI.render(this.Component, this.domRef);
}

View File

@@ -345,3 +345,52 @@ export function getDeletedNodes(
}
return nodes;
}
export function isClickWithinBounds(
e: MouseEvent | TouchEvent,
pos: ResolvedPos,
hitPosition: "left" | "right",
hitArea: { width: number; height: number } = { width: 40, height: 40 }
) {
const { target } = e;
if (!(target instanceof HTMLElement)) return false;
const { x, y, right, width } = target.getBoundingClientRect();
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
const isRtl =
target.dir === "rtl" ||
findParentNodeClosestToPos(pos, (node) => !!node.attrs.textDirection)?.node
.attrs.textDirection === "rtl";
switch (hitPosition) {
case "left": {
let xStart = clientX >= x - hitArea.width;
let xEnd = clientX <= x;
const yStart = clientY >= y;
const yEnd = clientY <= y + hitArea.height;
if (isRtl) {
xEnd = clientX <= right + hitArea.width;
xStart = clientX >= right;
}
return xStart && xEnd && yStart && yEnd;
}
case "right": {
let xEnd = clientX <= x + width;
let xStart = clientX >= x + width - hitArea.width;
const yStart = clientY >= y;
const yEnd = clientY <= y + hitArea.height;
if (isRtl) {
xStart = clientX >= x;
xEnd = clientX <= x + hitArea.width;
}
return xStart && xEnd && yStart && yEnd;
}
default:
return false;
}
}

View File

@@ -714,6 +714,7 @@ p > *::selection {
transform: rotate(0);
transition: transform 250ms ease;
opacity: 1;
}
.ProseMirror div.callout > :first-child[dir="rtl"]::after {
@@ -800,6 +801,154 @@ del.diffdel {
text-decoration: none;
}
.ProseMirror h1,
.ProseMirror h2,
.ProseMirror h3,
.ProseMirror h4,
.ProseMirror h5,
.ProseMirror h6 {
position: relative;
}
.ProseMirror h1::before,
.ProseMirror h2::before,
.ProseMirror h3::before,
.ProseMirror h4::before,
.ProseMirror h5::before,
.ProseMirror h6::before {
position: absolute;
cursor: pointer;
content: "";
background-size: 18px;
width: 18px;
height: 18px;
background-color: var(--icon);
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik03LjQxIDguNThMMTIgMTMuMTdsNC41OS00LjU5TDE4IDEwbC02IDZsLTYtNmwxLjQxLTEuNDJaIi8+PC9zdmc+)
no-repeat 50% 50%;
mask-size: cover;
border: 1px solid var(--background);
transform: rotate(0);
transition: transform 250ms ease, opacity 200ms ease;
left: -22px;
opacity: 0;
}
.ProseMirror h1[dir="rtl"]::before,
.ProseMirror h2[dir="rtl"]::before,
.ProseMirror h3[dir="rtl"]::before,
.ProseMirror h4[dir="rtl"]::before,
.ProseMirror h5[dir="rtl"]::before,
.ProseMirror h6[dir="rtl"]::before {
display: none;
}
.ProseMirror h1[dir="rtl"]::after,
.ProseMirror h2[dir="rtl"]::after,
.ProseMirror h3[dir="rtl"]::after,
.ProseMirror h4[dir="rtl"]::after,
.ProseMirror h5[dir="rtl"]::after,
.ProseMirror h6[dir="rtl"]::after {
position: absolute;
cursor: pointer;
content: "";
background-size: 18px;
width: 18px;
height: 18px;
background-color: var(--icon);
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik03LjQxIDguNThMMTIgMTMuMTdsNC41OS00LjU5TDE4IDEwbC02IDZsLTYtNmwxLjQxLTEuNDJaIi8+PC9zdmc+)
no-repeat 50% 50%;
mask-size: cover;
border: 1px solid var(--background);
transform: rotate(0deg);
transition: transform 250ms ease, opacity 200ms ease;
right: -22px;
opacity: 0;
}
.ProseMirror h1::before,
.ProseMirror h1::after {
top: 8px;
}
.ProseMirror h2::before,
.ProseMirror h2::after
{
top: 3px;
}
.ProseMirror h3::before,
.ProseMirror h3::after {
top: 0px;
}
.ProseMirror h4::before,
.ProseMirror h4::after {
top: -1px;
}
.ProseMirror h5::before,
.ProseMirror h5::after {
top: -2px;
}
.ProseMirror h6::before,
.ProseMirror h6::after {
top: -4px;
}
.ProseMirror h1[data-collapsed="true"]::before,
.ProseMirror h2[data-collapsed="true"]::before,
.ProseMirror h3[data-collapsed="true"]::before,
.ProseMirror h4[data-collapsed="true"]::before,
.ProseMirror h5[data-collapsed="true"]::before,
.ProseMirror h6[data-collapsed="true"]::before {
transform: rotate(-90deg);
opacity: 1;
}
.ProseMirror h1[data-collapsed="true"]::after,
.ProseMirror h2[data-collapsed="true"]::after,
.ProseMirror h3[data-collapsed="true"]::after,
.ProseMirror h4[data-collapsed="true"]::after,
.ProseMirror h5[data-collapsed="true"]::after,
.ProseMirror h6[data-collapsed="true"]::after {
transform: rotate(90deg);
opacity: 1;
}
.ProseMirror h1:hover::before,
.ProseMirror h2:hover::before,
.ProseMirror h3:hover::before,
.ProseMirror h4:hover::before,
.ProseMirror h5:hover::before,
.ProseMirror h6:hover::before,
.ProseMirror h1:hover::after,
.ProseMirror h2:hover::after,
.ProseMirror h3:hover::after,
.ProseMirror h4:hover::after,
.ProseMirror h5:hover::after,
.ProseMirror h6:hover::after {
opacity: 1;
}
.ProseMirror div.callout h1::before,
.ProseMirror div.callout h2::before,
.ProseMirror div.callout h3::before,
.ProseMirror div.callout h4::before,
.ProseMirror div.callout h5::before,
.ProseMirror div.callout h6::before {
display: none;
}
[data-hidden-under] {
display: none !important;
}
/* simplebar */
.simplebar-track {
height: 9px !important;