diff --git a/packages/editor/src/extensions/check-list-item/check-list-item.ts b/packages/editor/src/extensions/check-list-item/check-list-item.ts index 6f9b97a24..e7e277d58 100644 --- a/packages/editor/src/extensions/check-list-item/check-list-item.ts +++ b/packages/editor/src/extensions/check-list-item/check-list-item.ts @@ -17,8 +17,14 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ 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 { CheckList } from "../check-list/check-list"; export interface CheckListItemOptions { onReadOnlyChecked?: (node: ProseMirrorNode, checked: boolean) => boolean; @@ -97,94 +103,76 @@ export const CheckListItem = Node.create({ addNodeView() { return ({ node, getPos, editor }) => { - const listItem = document.createElement("li"); - const checkboxWrapper = document.createElement("label"); - const checkboxStyler = document.createElement("span"); - const checkbox = document.createElement("input"); - const content = document.createElement("div"); + const li = document.createElement("li"); + if (node.attrs.checked) li.classList.add("checked"); + else li.classList.remove("checked"); - checkboxWrapper.contentEditable = "false"; - checkbox.type = "checkbox"; + function onClick(e: MouseEvent | TouchEvent) { + if (e instanceof MouseEvent && e.button !== 0) return; + if (!(e.target instanceof HTMLElement)) return; - checkbox.addEventListener("mousedown", (event) => { - if (globalThis.keyboardShown) { - event.preventDefault(); - } - }); + const pos = typeof getPos === "function" ? getPos() : 0; + if (typeof pos !== "number") return; + const resolvedPos = editor.state.doc.resolve(pos); - checkbox.addEventListener("change", (event) => { - event.preventDefault(); - // if the editor isn’t 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; + const { x, y, right } = li.getBoundingClientRect(); - 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 (editor.isEditable && typeof getPos === "function") { - editor - .chain() - .command(({ tr }) => { - const position = getPos(); - const currentNode = tr.doc.nodeAt(position); - - tr.setNodeMarkup(position, undefined, { - ...currentNode?.attrs, - checked - }); - - return true; - }) - .run(); + if (xStart && xEnd && yStart && yEnd) { + e.preventDefault(); + editor.commands.command(({ tr }) => { + tr.setNodeAttribute( + pos, + "checked", + !li.classList.contains("checked") + ); + return true; + }); } - 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); - listItem.append(checkboxWrapper, content); + li.onmousedown = onClick; + li.ontouchstart = onClick; return { - dom: listItem, - contentDOM: content, + dom: li, + contentDOM: li, update: (updatedNode) => { if (updatedNode.type !== this.type) { return false; } + const isNested = updatedNode.lastChild?.type.name === CheckList.name; - listItem.dataset.checked = updatedNode.attrs.checked; - if (updatedNode.attrs.checked) { - checkbox.setAttribute("checked", "checked"); - } else { - checkbox.removeAttribute("checked"); - } + if (updatedNode.attrs.checked) li.classList.add("checked"); + else li.classList.remove("checked"); return true; } }; }; } - - // addInputRules() { - // return [ - // wrappingInputRule({ - // find: inputRegex, - // type: this.type, - // getAttributes: (match) => ({ - // checked: match[match.length - 1] === "x" - // }) - // }) - // ]; - // } }); diff --git a/packages/editor/src/extensions/image/__tests__/__snapshots__/image.test.ts.snap b/packages/editor/src/extensions/image/__tests__/__snapshots__/image.test.ts.snap new file mode 100644 index 000000000..acd067595 --- /dev/null +++ b/packages/editor/src/extensions/image/__tests__/__snapshots__/image.test.ts.snap @@ -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", +} +`; diff --git a/packages/editor/src/extensions/image/__tests__/image.test.ts b/packages/editor/src/extensions/image/__tests__/image.test.ts new file mode 100644 index 000000000..00b80bb92 --- /dev/null +++ b/packages/editor/src/extensions/image/__tests__/image.test.ts @@ -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 . +*/ + +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(); + }); +}); diff --git a/packages/editor/src/extensions/image/image.ts b/packages/editor/src/extensions/image/image.ts index b59554d2a..85f7a8ce7 100644 --- a/packages/editor/src/extensions/image/image.ts +++ b/packages/editor/src/extensions/image/image.ts @@ -131,39 +131,6 @@ export const ImageNode = Node.create({ getAttrs(node) { if (node.querySelectorAll("img").length <= 0) return false; 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; } }, { 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 new file mode 100644 index 000000000..100c63fd2 --- /dev/null +++ b/packages/editor/src/extensions/outline-list-item/__tests__/__snapshots__/outline-list-item.test.ts.snap @@ -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", +} +`; 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 new file mode 100644 index 000000000..e083e89fb --- /dev/null +++ b/packages/editor/src/extensions/outline-list-item/__tests__/outline-list-item.test.ts @@ -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 . +*/ + +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(); + }); +}); 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 d2e987496..69552a2ed 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 @@ -28,6 +28,7 @@ import { } from "../../utils/prosemirror.js"; import { OutlineList } from "../outline-list/outline-list.js"; import { keybindings, tiptapKeys } from "@notesnook/common"; +import { Paragraph } from "../paragraph/paragraph.js"; export interface ListItemOptions { HTMLAttributes: Record; @@ -55,13 +56,14 @@ export const OutlineListItem = Node.create({ }; }, - content: "paragraph+ list?", + content: "block+", defining: true, parseHTML() { return [ { + priority: 100, tag: `li[data-type="${this.name}"]` } ]; @@ -95,21 +97,37 @@ export const OutlineListItem = Node.create({ return true; }); }, - Enter: () => { - // const subList = findSublist(editor, this.type); - // if (!subList) return this.editor.commands.splitListItem(this.name); - - // const { isCollapsed, subListPos } = subList; - - // if (isCollapsed) { - // return this.editor.commands.toggleOutlineCollapse(subListPos, false); - // } + Enter: ({ 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.splitListItem(this.name); }, - Tab: () => this.editor.commands.sinkListItem(this.name), - [keybindings.liftListItem.keys]: () => - this.editor.commands.liftListItem(this.name) + Tab: ({ 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.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({ function onClick(e: MouseEvent | TouchEvent) { 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; const pos = typeof getPos === "function" ? getPos() : 0; diff --git a/packages/editor/src/extensions/text-direction/text-direction.ts b/packages/editor/src/extensions/text-direction/text-direction.ts index 9547eb22b..bbc67a1eb 100644 --- a/packages/editor/src/extensions/text-direction/text-direction.ts +++ b/packages/editor/src/extensions/text-direction/text-direction.ts @@ -30,6 +30,7 @@ const TEXT_DIRECTION_TYPES = [ "orderedList", "bulletList", "outlineList", + "checkList", "taskList", "table", "blockquote", diff --git a/packages/editor/styles/styles.css b/packages/editor/styles/styles.css index 71601cb82..a49250286 100644 --- a/packages/editor/styles/styles.css +++ b/packages/editor/styles/styles.css @@ -76,6 +76,14 @@ margin-bottom: 5px; } +.ProseMirror li:last-of-type { + margin-bottom: 0px; +} + +.ProseMirror li:first-of-type { + margin-top: 5px; +} + .ProseMirror ul.tasklist-content-wrapper { padding-left: 0px; } @@ -536,11 +544,11 @@ p > *::selection { .outline-list li.collapsed .outline-list { display: none; } -.outline-list li > :first-child { +.outline-list li { position: relative; } -.outline-list > li > :first-child::before { +.outline-list > li::before { position: absolute; top: 0px; cursor: pointer; @@ -560,13 +568,13 @@ p > *::selection { left: -22px; } -.outline-list li:not(.nested) > :first-child::before { +.outline-list li:not(.nested)::before { mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik0xMiAyQTEwIDEwIDAgMCAwIDIgMTJhMTAgMTAgMCAwIDAgMTAgMTBhMTAgMTAgMCAwIDAgMTAtMTBBMTAgMTAgMCAwIDAgMTIgMloiLz48L3N2Zz4=) no-repeat 50% 50%; scale: 0.4; } -.outline-list li.collapsed > :first-child::before { +.outline-list li.collapsed::before { transform: rotate(-90deg); } @@ -638,40 +646,71 @@ p > *::selection { 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 } /* Check list */ .ProseMirror ul.simple-checklist { list-style: none; - padding: 0; + margin-block: 0px !important; + padding-inline: 0px !important; + padding-inline-start: 24px !important; } -.ProseMirror ul.simple-checklist > li { - display: flex; +.ProseMirror li.nested > ul.simple-checklist { + padding-inline-start: 15px !important; } -.ProseMirror ul.simple-checklist > li input { - flex: 0 0 auto; - margin-right: 0.5rem; - user-select: none; - height: 1rem; - width: 1rem; - accent-color: var(--accent); +.simple-checklist li { + position: relative; } -.ProseMirror ul.simple-checklist > li > div { - flex: 1 1 auto; +.simple-checklist > li::after { + 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) { - .ProseMirror ul.simple-checklist > li > input { - height: 21px; - width: 21px; - } +.simple-checklist > li.checked::after { + border: 2px solid var(--accent); +} - .ProseMirror ul.simple-checklist > li > div { - margin-top: 2px; - } +.simple-checklist > li.checked::before { + 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 */ diff --git a/packages/editor/test-utils/index.ts b/packages/editor/test-utils/index.ts index 208577dbe..3474c87de 100644 --- a/packages/editor/test-utils/index.ts +++ b/packages/editor/test-utils/index.ts @@ -92,3 +92,19 @@ export const p = elem("p"); export function text(text: string) { 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" + }); +}