Merge branch 'master' into beta

This commit is contained in:
Abdullah Atta
2025-10-17 08:51:19 +05:00
10 changed files with 618 additions and 140 deletions

View File

@@ -17,8 +17,14 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { keybindings } from "@notesnook/common"; import { keybindings } from "@notesnook/common";
import { KeyboardShortcutCommand, mergeAttributes, Node } from "@tiptap/core"; import {
findParentNodeClosestToPos,
KeyboardShortcutCommand,
mergeAttributes,
Node
} from "@tiptap/core";
import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { CheckList } from "../check-list/check-list";
export interface CheckListItemOptions { export interface CheckListItemOptions {
onReadOnlyChecked?: (node: ProseMirrorNode, checked: boolean) => boolean; onReadOnlyChecked?: (node: ProseMirrorNode, checked: boolean) => boolean;
@@ -97,94 +103,76 @@ export const CheckListItem = Node.create<CheckListItemOptions>({
addNodeView() { addNodeView() {
return ({ node, getPos, editor }) => { return ({ node, getPos, editor }) => {
const listItem = document.createElement("li"); const li = document.createElement("li");
const checkboxWrapper = document.createElement("label"); if (node.attrs.checked) li.classList.add("checked");
const checkboxStyler = document.createElement("span"); else li.classList.remove("checked");
const checkbox = document.createElement("input");
const content = document.createElement("div");
checkboxWrapper.contentEditable = "false"; function onClick(e: MouseEvent | TouchEvent) {
checkbox.type = "checkbox"; if (e instanceof MouseEvent && e.button !== 0) return;
if (!(e.target instanceof HTMLElement)) return;
checkbox.addEventListener("mousedown", (event) => { const pos = typeof getPos === "function" ? getPos() : 0;
if (globalThis.keyboardShown) { if (typeof pos !== "number") return;
event.preventDefault(); const resolvedPos = editor.state.doc.resolve(pos);
}
});
checkbox.addEventListener("change", (event) => { const { x, y, right } = li.getBoundingClientRect();
event.preventDefault();
// if the editor isnt editable and we don't have a handler for
// readonly checks we have to undo the latest change
if (!editor.isEditable && !this.options.onReadOnlyChecked) {
checkbox.checked = !checkbox.checked;
return; 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;
} }
const { checked } = event.target as any; if (xStart && xEnd && yStart && yEnd) {
e.preventDefault();
if (editor.isEditable && typeof getPos === "function") { editor.commands.command(({ tr }) => {
editor tr.setNodeAttribute(
.chain() pos,
.command(({ tr }) => { "checked",
const position = getPos(); !li.classList.contains("checked")
const currentNode = tr.doc.nodeAt(position); );
return true;
tr.setNodeMarkup(position, undefined, { });
...currentNode?.attrs,
checked
});
return true;
})
.run();
} }
if (!editor.isEditable && this.options.onReadOnlyChecked) {
// Reset state if onReadOnlyChecked returns false
if (!this.options.onReadOnlyChecked(node, checked)) {
checkbox.checked = !checkbox.checked;
}
}
});
if (node.attrs.checked) {
checkbox.setAttribute("checked", "checked");
} }
checkboxWrapper.append(checkbox, checkboxStyler); li.onmousedown = onClick;
listItem.append(checkboxWrapper, content); li.ontouchstart = onClick;
return { return {
dom: listItem, dom: li,
contentDOM: content, contentDOM: li,
update: (updatedNode) => { update: (updatedNode) => {
if (updatedNode.type !== this.type) { if (updatedNode.type !== this.type) {
return false; return false;
} }
const isNested = updatedNode.lastChild?.type.name === CheckList.name;
listItem.dataset.checked = updatedNode.attrs.checked; if (updatedNode.attrs.checked) li.classList.add("checked");
if (updatedNode.attrs.checked) { else li.classList.remove("checked");
checkbox.setAttribute("checked", "checked");
} else {
checkbox.removeAttribute("checked");
}
return true; return true;
} }
}; };
}; };
} }
// addInputRules() {
// return [
// wrappingInputRule({
// find: inputRegex,
// type: this.type,
// getAttributes: (match) => ({
// checked: match[match.length - 1] === "x"
// })
// })
// ];
// }
}); });

View File

@@ -0,0 +1,171 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`migration > inline image in outline list 1`] = `
{
"content": [
{
"content": [
{
"attrs": {
"collapsed": false,
},
"content": [
{
"content": [
{
"text": "item 1",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "outlineListItem",
},
{
"attrs": {
"collapsed": false,
},
"content": [
{
"content": [
{
"text": "hello",
"type": "text",
},
],
"type": "paragraph",
},
{
"attrs": {
"align": undefined,
"aspectRatio": 1,
"filename": undefined,
"hash": undefined,
"height": null,
"mime": undefined,
"progress": 0,
"size": undefined,
"src": "image.png",
"type": "image",
"width": null,
},
"type": "image",
},
{
"content": [
{
"text": "world",
"type": "text",
},
],
"type": "paragraph",
},
{
"content": [
{
"attrs": {
"collapsed": false,
},
"content": [
{
"content": [
{
"text": "sub item 2",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "outlineListItem",
},
{
"attrs": {
"collapsed": false,
},
"content": [
{
"content": [
{
"text": "sub item 3",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "outlineListItem",
},
],
"type": "outlineList",
},
],
"type": "outlineListItem",
},
{
"attrs": {
"collapsed": false,
},
"content": [
{
"content": [
{
"text": "item 4",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "outlineListItem",
},
],
"type": "outlineList",
},
],
"type": "doc",
}
`;
exports[`migration > inline image in paragraph 1`] = `
{
"content": [
{
"content": [
{
"text": "hello",
"type": "text",
},
],
"type": "paragraph",
},
{
"attrs": {
"align": undefined,
"aspectRatio": 1,
"filename": undefined,
"hash": undefined,
"height": null,
"mime": undefined,
"progress": 0,
"size": undefined,
"src": "image.png",
"type": "image",
"width": null,
},
"type": "image",
},
{
"content": [
{
"text": "world",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`;

View File

@@ -0,0 +1,75 @@
/*
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 {
createEditor,
h,
p,
outlineList,
outlineListItem
} from "../../../../test-utils/index.js";
import { test, expect, describe } from "vitest";
import { ImageNode } from "../index.js";
import { OutlineList } from "../../outline-list/outline-list.js";
import { OutlineListItem } from "../../outline-list-item/outline-list-item.js";
describe("migration", () => {
test(`inline image in paragraph`, async () => {
const el = p(["hello", h("img", [], { src: "image.png" }), "world"]);
const {
builder: { image },
editor
} = createEditor({
initialContent: el.outerHTML,
extensions: {
image: ImageNode.configure({})
}
});
expect(editor.getJSON()).toMatchSnapshot();
});
test(`inline image in outline list`, async () => {
const el = outlineList(
outlineListItem(["item 1"]),
outlineListItem(
["hello", h("img", [], { src: "image.png" }), "world"],
outlineList(
outlineListItem(["sub item 2"]),
outlineListItem(["sub item 3"])
)
),
outlineListItem(["item 4"])
);
const {
builder: { image },
editor
} = createEditor({
initialContent: el.outerHTML,
extensions: {
outlineList: OutlineList,
outlineListItem: OutlineListItem,
image: ImageNode
}
});
expect(editor.getJSON()).toMatchSnapshot();
});
});

View File

@@ -131,39 +131,6 @@ export const ImageNode = Node.create<ImageOptions>({
getAttrs(node) { getAttrs(node) {
if (node.querySelectorAll("img").length <= 0) return false; if (node.querySelectorAll("img").length <= 0) return false;
return null; return null;
},
getContent: (dom, schema) => {
const wrapper = document.createElement("div");
let buffer = "";
const flushBuffer = () => {
if (buffer.trim().length > 0) {
const pEl = document.createElement("p");
pEl.innerHTML = buffer;
wrapper.appendChild(pEl);
buffer = "";
}
};
for (const child of dom.childNodes) {
if (
child.nodeType === globalThis.Node.ELEMENT_NODE &&
(child as HTMLElement).tagName === "IMG"
) {
flushBuffer();
wrapper.appendChild(child);
} else {
if (child.nodeType === globalThis.Node.ELEMENT_NODE) {
buffer += (child as HTMLElement).outerHTML;
} else if (child.nodeType === globalThis.Node.TEXT_NODE) {
buffer += child.textContent;
}
}
}
flushBuffer();
const parser = DOMParser.fromSchema(schema);
return parser.parse(wrapper).content;
} }
}, },
{ {

View File

@@ -0,0 +1,130 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`outline list item > code block in outline list item 1`] = `
{
"content": [
{
"content": [
{
"attrs": {
"collapsed": false,
},
"content": [
{
"content": [
{
"text": "item 1",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "outlineListItem",
},
{
"attrs": {
"collapsed": false,
},
"content": [
{
"content": [
{
"text": "hello",
"type": "text",
},
],
"type": "paragraph",
},
{
"attrs": {
"caretPosition": undefined,
"id": "codeblock-test-id-123456",
"indentLength": 2,
"indentType": "space",
"language": null,
"lines": [],
},
"content": [
{
"text": "const x = 1;",
"type": "text",
},
],
"type": "codeblock",
},
{
"content": [
{
"text": "world",
"type": "text",
},
],
"type": "paragraph",
},
{
"content": [
{
"attrs": {
"collapsed": false,
},
"content": [
{
"content": [
{
"text": "sub item 2",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "outlineListItem",
},
{
"attrs": {
"collapsed": false,
},
"content": [
{
"content": [
{
"text": "sub item 3",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "outlineListItem",
},
],
"type": "outlineList",
},
],
"type": "outlineListItem",
},
{
"attrs": {
"collapsed": false,
},
"content": [
{
"content": [
{
"text": "item 4",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "outlineListItem",
},
],
"type": "outlineList",
},
],
"type": "doc",
}
`;

View File

@@ -0,0 +1,73 @@
/*
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 {
createEditor,
h,
li,
outlineList,
outlineListItem
} from "../../../../test-utils/index.js";
import { test, expect, describe, beforeAll, vi } from "vitest";
import { OutlineList } from "../../outline-list/outline-list.js";
import { OutlineListItem } from "../outline-list-item.js";
import { CodeBlock } from "../../code-block/code-block.js";
describe("outline list item", () => {
beforeAll(() => {
vi.mock("nanoid", () => ({
nanoid: () => "test-id-123456"
}));
});
test(`code block in outline list item`, async () => {
const subList = outlineList(
outlineListItem(["sub item 2"]),
outlineListItem(["sub item 3"])
);
const listItemWithCodeBlock = li(
[
h("p", ["hello"]),
h("pre", [h("code", ["const x = 1;"])]),
h("p", ["world"]),
subList
],
{ "data-type": "outlineListItem" }
);
const el = outlineList(
outlineListItem(["item 1"]),
listItemWithCodeBlock,
outlineListItem(["item 4"])
);
const {
builder: { codeBlock },
editor
} = createEditor({
initialContent: el.outerHTML,
extensions: {
outlineList: OutlineList,
outlineListItem: OutlineListItem,
codeBlock: CodeBlock
}
});
expect(editor.getJSON()).toMatchSnapshot();
});
});

View File

@@ -28,6 +28,7 @@ import {
} from "../../utils/prosemirror.js"; } from "../../utils/prosemirror.js";
import { OutlineList } from "../outline-list/outline-list.js"; import { OutlineList } from "../outline-list/outline-list.js";
import { keybindings, tiptapKeys } from "@notesnook/common"; import { keybindings, tiptapKeys } from "@notesnook/common";
import { Paragraph } from "../paragraph/paragraph.js";
export interface ListItemOptions { export interface ListItemOptions {
HTMLAttributes: Record<string, unknown>; HTMLAttributes: Record<string, unknown>;
@@ -55,13 +56,14 @@ export const OutlineListItem = Node.create<ListItemOptions>({
}; };
}, },
content: "paragraph+ list?", content: "block+",
defining: true, defining: true,
parseHTML() { parseHTML() {
return [ return [
{ {
priority: 100,
tag: `li[data-type="${this.name}"]` tag: `li[data-type="${this.name}"]`
} }
]; ];
@@ -95,21 +97,37 @@ export const OutlineListItem = Node.create<ListItemOptions>({
return true; return true;
}); });
}, },
Enter: () => { Enter: ({ editor }) => {
// const subList = findSublist(editor, this.type); const { $anchor } = editor.state.selection;
// if (!subList) return this.editor.commands.splitListItem(this.name); if (
$anchor.parent.type.name !== Paragraph.name ||
// const { isCollapsed, subListPos } = subList; ($anchor.parent.type.name === Paragraph.name &&
$anchor.node($anchor.depth - 1)?.type.name !== this.type.name)
// if (isCollapsed) { )
// return this.editor.commands.toggleOutlineCollapse(subListPos, false); return false;
// }
return this.editor.commands.splitListItem(this.name); return this.editor.commands.splitListItem(this.name);
}, },
Tab: () => this.editor.commands.sinkListItem(this.name), Tab: ({ editor }) => {
[keybindings.liftListItem.keys]: () => const { $anchor } = editor.state.selection;
this.editor.commands.liftListItem(this.name) if (
$anchor.parent.type.name !== Paragraph.name ||
($anchor.parent.type.name === Paragraph.name &&
$anchor.node($anchor.depth - 1)?.type.name !== this.type.name)
)
return false;
return this.editor.commands.sinkListItem(this.name);
},
[keybindings.liftListItem.keys]: ({ editor }) => {
const { $anchor } = editor.state.selection;
if (
$anchor.parent.type.name !== Paragraph.name ||
($anchor.parent.type.name === Paragraph.name &&
$anchor.node($anchor.depth - 1)?.type.name !== this.type.name)
)
return false;
return this.editor.commands.liftListItem(this.name);
}
}; };
}, },
@@ -127,7 +145,7 @@ export const OutlineListItem = Node.create<ListItemOptions>({
function onClick(e: MouseEvent | TouchEvent) { function onClick(e: MouseEvent | TouchEvent) {
if (e instanceof MouseEvent && e.button !== 0) return; if (e instanceof MouseEvent && e.button !== 0) return;
if (!(e.target instanceof HTMLParagraphElement)) return; if (!(e.target instanceof HTMLElement)) return;
if (!li.classList.contains("nested")) return; if (!li.classList.contains("nested")) return;
const pos = typeof getPos === "function" ? getPos() : 0; const pos = typeof getPos === "function" ? getPos() : 0;

View File

@@ -30,6 +30,7 @@ const TEXT_DIRECTION_TYPES = [
"orderedList", "orderedList",
"bulletList", "bulletList",
"outlineList", "outlineList",
"checkList",
"taskList", "taskList",
"table", "table",
"blockquote", "blockquote",

View File

@@ -76,6 +76,14 @@
margin-bottom: 5px; margin-bottom: 5px;
} }
.ProseMirror li:last-of-type {
margin-bottom: 0px;
}
.ProseMirror li:first-of-type {
margin-top: 5px;
}
.ProseMirror ul.tasklist-content-wrapper { .ProseMirror ul.tasklist-content-wrapper {
padding-left: 0px; padding-left: 0px;
} }
@@ -536,11 +544,11 @@ p > *::selection {
.outline-list li.collapsed .outline-list { .outline-list li.collapsed .outline-list {
display: none; display: none;
} }
.outline-list li > :first-child { .outline-list li {
position: relative; position: relative;
} }
.outline-list > li > :first-child::before { .outline-list > li::before {
position: absolute; position: absolute;
top: 0px; top: 0px;
cursor: pointer; cursor: pointer;
@@ -560,13 +568,13 @@ p > *::selection {
left: -22px; left: -22px;
} }
.outline-list li:not(.nested) > :first-child::before { .outline-list li:not(.nested)::before {
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik0xMiAyQTEwIDEwIDAgMCAwIDIgMTJhMTAgMTAgMCAwIDAgMTAgMTBhMTAgMTAgMCAwIDAgMTAtMTBBMTAgMTAgMCAwIDAgMTIgMloiLz48L3N2Zz4=) mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik0xMiAyQTEwIDEwIDAgMCAwIDIgMTJhMTAgMTAgMCAwIDAgMTAgMTBhMTAgMTAgMCAwIDAgMTAtMTBBMTAgMTAgMCAwIDAgMTIgMloiLz48L3N2Zz4=)
no-repeat 50% 50%; no-repeat 50% 50%;
scale: 0.4; scale: 0.4;
} }
.outline-list li.collapsed > :first-child::before { .outline-list li.collapsed::before {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
@@ -638,40 +646,71 @@ p > *::selection {
transform: rotate(90deg); transform: rotate(90deg);
} }
.simple-checklist[dir="rtl"] li::after {
left: unset;
right: -24px;
}
.simple-checklist[dir="rtl"] li.checked::before {
left: unset;
right: -22px;
}
[dir="rtl"] .taskItemTools { right: unset; left: 0 } [dir="rtl"] .taskItemTools { right: unset; left: 0 }
/* Check list */ /* Check list */
.ProseMirror ul.simple-checklist { .ProseMirror ul.simple-checklist {
list-style: none; list-style: none;
padding: 0; margin-block: 0px !important;
padding-inline: 0px !important;
padding-inline-start: 24px !important;
} }
.ProseMirror ul.simple-checklist > li { .ProseMirror li.nested > ul.simple-checklist {
display: flex; padding-inline-start: 15px !important;
} }
.ProseMirror ul.simple-checklist > li input { .simple-checklist li {
flex: 0 0 auto; position: relative;
margin-right: 0.5rem;
user-select: none;
height: 1rem;
width: 1rem;
accent-color: var(--accent);
} }
.ProseMirror ul.simple-checklist > li > div { .simple-checklist > li::after {
flex: 1 1 auto; position: absolute;
top: 0px;
cursor: pointer;
content: "";
background-size: 18px;
width: 14px;
height: 14px;
border: 2px solid var(--icon);
border-radius: 5px;
left: -24px;
} }
@media screen and (max-width: 480px) { .simple-checklist > li.checked::after {
.ProseMirror ul.simple-checklist > li > input { border: 2px solid var(--accent);
height: 21px; }
width: 21px;
}
.ProseMirror ul.simple-checklist > li > div { .simple-checklist > li.checked::before {
margin-top: 2px; position: absolute;
} top: 2px;
cursor: pointer;
content: "";
background-size: 18px;
width: 14px;
height: 14px;
left: -22px;
background-color: var(--accent);
mask: url(data:image/svg+xml;base64,ICA8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDI0IDI0Ij4KICAgIDxwYXRoIGQ9Ik0yMSw3TDksMTlMMy41LDEzLjVMNC45MSwxMi4wOUw5LDE2LjE3TDE5LjU5LDUuNTlMMjEsN1oiLz4KICA8L3N2Zz4K)
no-repeat 50% 50%;
mask-size: cover;
}
.simple-checklist > li.checked > p {
opacity: 0.8;
text-decoration-line: line-through;
} }
/* Callout */ /* Callout */

View File

@@ -92,3 +92,19 @@ export const p = elem("p");
export function text(text: string) { export function text(text: string) {
return document.createTextNode(text); return document.createTextNode(text);
} }
export function outlineList(...children: HTMLLIElement[]) {
return ul(children, { "data-type": "outlineList" });
}
export function outlineListItem(
paragraphChildren: (string | HTMLElement)[],
subList?: HTMLUListElement
) {
const children: HTMLElement[] = [h("p", paragraphChildren)];
if (subList) children.push(subList);
return li(children, {
"data-type": "outlineListItem"
});
}