diff --git a/packages/editor/src/extensions/check-list-item/__tests__/__snapshots__/check-list-item.test.ts.snap b/packages/editor/src/extensions/check-list-item/__tests__/__snapshots__/check-list-item.test.ts.snap new file mode 100644 index 000000000..4e064d05b --- /dev/null +++ b/packages/editor/src/extensions/check-list-item/__tests__/__snapshots__/check-list-item.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`check list item > inline image as first child in check list item 1`] = `""`; diff --git a/packages/editor/src/extensions/check-list-item/__tests__/check-list-item.test.ts b/packages/editor/src/extensions/check-list-item/__tests__/check-list-item.test.ts new file mode 100644 index 000000000..738837033 --- /dev/null +++ b/packages/editor/src/extensions/check-list-item/__tests__/check-list-item.test.ts @@ -0,0 +1,55 @@ +/* +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 { describe, expect, test } from "vitest"; +import { + createEditor, + h, + p, + checkList, + checkListItem +} from "../../../../test-utils/index.js"; +import { CheckList } from "../../check-list/check-list.js"; +import { CheckListItem } from "../check-list-item.js"; +import { Paragraph } from "../../paragraph/paragraph.js"; +import { ImageNode } from "../../image/image.js"; + +describe("check list item", () => { + /** + * see https://github.com/streetwriters/notesnook/pull/8877 for more context + */ + test("inline image as first child in check list item", async () => { + const el = checkList( + checkListItem([p(["item 1"])]), + checkListItem([h("img", [], { src: "image.png" })]) + ); + + const { editor } = createEditor({ + initialContent: el.outerHTML, + extensions: { + checkList: CheckList, + checkListItem: CheckListItem.configure({ nested: true }), + paragraph: Paragraph, + image: ImageNode + } + }); + + expect(editor.getHTML()).toMatchSnapshot(); + }); +}); 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 ff9341ec0..7f08df6ab 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 @@ -25,6 +25,7 @@ import { } from "@tiptap/core"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { CheckList } from "../check-list/check-list.js"; +import { ensureLeadingParagraph } from "../../utils/prosemirror.js"; export interface CheckListItemOptions { onReadOnlyChecked?: (node: ProseMirrorNode, checked: boolean) => boolean; @@ -67,7 +68,8 @@ export const CheckListItem = Node.create({ return [ { tag: `li.simple-checklist--item`, - priority: 51 + priority: 51, + getContent: ensureLeadingParagraph } ]; }, diff --git a/packages/editor/src/extensions/list-item/list-item.ts b/packages/editor/src/extensions/list-item/list-item.ts index 05daff19b..543251a5a 100644 --- a/packages/editor/src/extensions/list-item/list-item.ts +++ b/packages/editor/src/extensions/list-item/list-item.ts @@ -18,8 +18,18 @@ along with this program. If not, see . */ import { ListItem as TiptapListItem } from "@tiptap/extension-list-item"; +import { ensureLeadingParagraph } from "../../utils/prosemirror.js"; export const ListItem = TiptapListItem.extend({ + parseHTML() { + return [ + { + priority: 100, + tag: `li`, + getContent: ensureLeadingParagraph + } + ]; + }, addKeyboardShortcuts() { return { ...this.parent?.(), diff --git a/packages/editor/src/extensions/list-item/tests/__snapshots__/list-item.test.ts.snap b/packages/editor/src/extensions/list-item/tests/__snapshots__/list-item.test.ts.snap index 98df6e7a9..62d9c3f47 100644 --- a/packages/editor/src/extensions/list-item/tests/__snapshots__/list-item.test.ts.snap +++ b/packages/editor/src/extensions/list-item/tests/__snapshots__/list-item.test.ts.snap @@ -5,3 +5,5 @@ exports[`hitting backspace at the start of first list item 1`] = `"
  • item1item2

"`; exports[`hitting backspace at the start of the second (or next) paragraph inside the list item 1`] = `"
  • item 1item 2

"`; + +exports[`inline image as first child in list item 1`] = `"
  • item 1

"`; diff --git a/packages/editor/src/extensions/list-item/tests/list-item.test.ts b/packages/editor/src/extensions/list-item/tests/list-item.test.ts index acfbea17d..382d31d05 100644 --- a/packages/editor/src/extensions/list-item/tests/list-item.test.ts +++ b/packages/editor/src/extensions/list-item/tests/list-item.test.ts @@ -23,6 +23,8 @@ import { createEditor, h, li, p, ul } from "../../../../test-utils/index.js"; import BulletList from "../../bullet-list/index.js"; import OrderedList from "../../ordered-list/index.js"; import { ListItem } from "../index.js"; +import { Paragraph } from "../../paragraph/paragraph.js"; +import { ImageNode } from "../../image/image.js"; test("hitting backspace at the start of first list item", async () => { const el = ul([li([p(["item1"])]), li([p(["item2"])])]); @@ -97,3 +99,24 @@ test("hitting backspace at the start of the second (or next) paragraph inside th await new Promise((resolve) => setTimeout(resolve, 100)); expect(editorElement.outerHTML).toMatchSnapshot(); }); + +/** + * see https://github.com/streetwriters/notesnook/pull/8877 for more context + */ +test("inline image as first child in list item", async () => { + const el = ul([ + li([p(["item 1"])]), + li([h("img", [], { src: "image.png" })]) + ]); + + const { editor } = createEditor({ + initialContent: el.outerHTML, + extensions: { + listItem: ListItem, + paragraph: Paragraph, + image: ImageNode + } + }); + + 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 9b0801dfb..b26574b5b 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 @@ -24,12 +24,12 @@ import { } from "@tiptap/core"; import { findParentNodeOfTypeClosestToPos, - isClickWithinBounds + isClickWithinBounds, + ensureLeadingParagraph } from "../../utils/prosemirror.js"; import { OutlineList } from "../outline-list/outline-list.js"; import { keybindings, tiptapKeys } from "@notesnook/common"; import { Paragraph } from "../paragraph/paragraph.js"; -import { DOMParser } from "@tiptap/pm/model"; export interface ListItemOptions { HTMLAttributes: Record; @@ -66,17 +66,7 @@ export const OutlineListItem = Node.create({ { priority: 100, tag: `li[data-type="${this.name}"]`, - getContent: (node, schema) => { - const parser = DOMParser.fromSchema(schema); - const fragment = parser.parse(node).content; - const firstNode = fragment.firstChild; - if (firstNode && firstNode.type.name !== "paragraph") { - const emptyParagraph = schema.nodes.paragraph.create(); - return fragment.addToStart(emptyParagraph); - } - - return fragment; - } + getContent: ensureLeadingParagraph } ]; }, diff --git a/packages/editor/src/extensions/task-item/__tests__/__snapshots__/task-item.test.ts.snap b/packages/editor/src/extensions/task-item/__tests__/__snapshots__/task-item.test.ts.snap new file mode 100644 index 000000000..df94e21a9 --- /dev/null +++ b/packages/editor/src/extensions/task-item/__tests__/__snapshots__/task-item.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`task list item > inline image as first child in task list item 1`] = `"
  • item 1

"`; diff --git a/packages/editor/src/extensions/task-item/__tests__/task-item.test.ts b/packages/editor/src/extensions/task-item/__tests__/task-item.test.ts new file mode 100644 index 000000000..c9300c8ff --- /dev/null +++ b/packages/editor/src/extensions/task-item/__tests__/task-item.test.ts @@ -0,0 +1,55 @@ +/* +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 { describe, expect, test } from "vitest"; +import { + createEditor, + h, + p, + taskList, + taskItem +} from "../../../../test-utils/index.js"; +import { TaskListNode } from "../../task-list/task-list.js"; +import { TaskItemNode } from "../task-item.js"; +import { Paragraph } from "../../paragraph/paragraph.js"; +import { ImageNode } from "../../image/image.js"; + +describe("task list item", () => { + /** + * see https://github.com/streetwriters/notesnook/pull/8877 for more context + */ + test("inline image as first child in task list item", async () => { + const el = taskList( + taskItem([p(["item 1"])]), + taskItem([h("img", [], { src: "image.png" })]) + ); + + const { editor } = createEditor({ + initialContent: el.outerHTML, + extensions: { + taskList: TaskListNode, + taskListItem: TaskItemNode.configure({ nested: true }), + paragraph: Paragraph, + image: ImageNode + } + }); + + expect(editor.getHTML()).toMatchSnapshot(); + }); +}); diff --git a/packages/editor/src/extensions/task-item/task-item.ts b/packages/editor/src/extensions/task-item/task-item.ts index 467fea976..7f4af2db1 100644 --- a/packages/editor/src/extensions/task-item/task-item.ts +++ b/packages/editor/src/extensions/task-item/task-item.ts @@ -21,6 +21,7 @@ import { mergeAttributes } from "@tiptap/core"; import { TaskItem } from "@tiptap/extension-task-item"; import { TaskItemComponent } from "./component.js"; import { createNodeView } from "../react/index.js"; +import { ensureLeadingParagraph } from "../../utils/prosemirror.js"; export type TaskItemAttributes = { checked: boolean; @@ -56,7 +57,8 @@ export const TaskItemNode = TaskItem.extend({ return [ { tag: ".checklist > li", - priority: 51 + priority: 100, + getContent: ensureLeadingParagraph } ]; }, diff --git a/packages/editor/src/extensions/task-list/__tests__/task-list.test.ts b/packages/editor/src/extensions/task-list/__tests__/task-list.test.ts index e1ea43854..826a73c9d 100644 --- a/packages/editor/src/extensions/task-list/__tests__/task-list.test.ts +++ b/packages/editor/src/extensions/task-list/__tests__/task-list.test.ts @@ -17,52 +17,39 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { createEditor, h, ul, li } from "../../../../test-utils/index.js"; +import { + createEditor, + taskItem, + taskList +} from "../../../../test-utils/index.js"; import { test, expect } from "vitest"; import { TaskListNode } from "../index.js"; import { TaskItemNode } from "../../task-item/index.js"; import { p, eq } from "prosemirror-test-builder"; import { countCheckedItems, deleteCheckedItems, sortList } from "../utils.js"; -function taskList(...children: HTMLLIElement[]) { - return ul(children, { class: "checklist" }); -} - -function taskItem( - text: string, - attr: { checked?: boolean } = {}, - subList?: HTMLUListElement -) { - const children: HTMLElement[] = [h("p", [text])]; - if (subList) children.push(subList); - - return li(children, { - class: "checklist--item " + (attr.checked ? "checked" : "") - }); -} - const NESTED_TASK_LIST = taskList( - taskItem("Task item 1", { checked: true }), - taskItem("Task item 2"), + taskItem(["Task item 1"], { checked: true }), + taskItem(["Task item 2"]), taskItem( - "Task item 3", + ["Task item 3"], { checked: false }, taskList( - taskItem("Task item 4", { checked: true }), - taskItem("Task item 5"), + taskItem(["Task item 4"], { checked: true }), + taskItem(["Task item 5"]), taskItem( - "Task item 6", + ["Task item 6"], { checked: false }, taskList( - taskItem("Task item 7", { checked: true }), - taskItem("Task item 8", { checked: true }), + taskItem(["Task item 7"], { checked: true }), + taskItem(["Task item 8"], { checked: true }), taskItem( - "Task item 9", + ["Task item 9"], { checked: false }, taskList( - taskItem("Task item 10", { checked: true }), - taskItem("Task item 11", { checked: true }), - taskItem("Task item 12") + taskItem(["Task item 10"], { checked: true }), + taskItem(["Task item 11"], { checked: true }), + taskItem(["Task item 12"]) ) ) ) @@ -99,8 +86,8 @@ test(`count items in a task list`, async () => { test(`delete checked items in a task list`, async () => { const { editor } = createEditor({ initialContent: taskList( - taskItem("Task item 1", { checked: true }), - taskItem("Task item 2") + taskItem(["Task item 1"], { checked: true }), + taskItem(["Task item 2"]) ).outerHTML, extensions: { taskItem: TaskItemNode.configure({ nested: true }), @@ -132,15 +119,15 @@ test(`delete checked items in a nested task list`, async () => { test(`delete checked items in a task list with no checked items should do nothing`, async () => { const { editor } = createEditor({ initialContent: taskList( - taskItem("Task item 1", { checked: false }), - taskItem("Task item 2"), + taskItem(["Task item 1"], { checked: false }), + taskItem(["Task item 2"]), taskItem( - "Task item 3", + ["Task item 3"], { checked: false }, taskList( - taskItem("Task item 4", { checked: false }), - taskItem("Task item 5"), - taskItem("Task item 6", { checked: false }) + taskItem(["Task item 4"], { checked: false }), + taskItem(["Task item 5"]), + taskItem(["Task item 6"], { checked: false }) ) ) ).outerHTML, @@ -172,8 +159,10 @@ test(`sort checked items to the bottom of the task list`, async () => { test(`sorting a task list with no checked items should do nothing`, async () => { const { editor } = createEditor({ - initialContent: taskList(taskItem("Task item 1"), taskItem("Task item 2")) - .outerHTML, + initialContent: taskList( + taskItem(["Task item 1"]), + taskItem(["Task item 2"]) + ).outerHTML, extensions: { taskItem: TaskItemNode.configure({ nested: true }), taskList: TaskListNode diff --git a/packages/editor/src/utils/prosemirror.ts b/packages/editor/src/utils/prosemirror.ts index f3f7c406d..ff2068dd7 100644 --- a/packages/editor/src/utils/prosemirror.ts +++ b/packages/editor/src/utils/prosemirror.ts @@ -31,7 +31,10 @@ import { NodeType, ResolvedPos, Attrs, - Slice + Slice, + DOMParser, + Schema, + Fragment } from "prosemirror-model"; import { EditorState, Selection, Transaction } from "prosemirror-state"; import TextStyle from "@tiptap/extension-text-style"; @@ -394,3 +397,16 @@ export function isClickWithinBounds( return false; } } + +export function ensureLeadingParagraph(node: Node, schema: Schema): Fragment { + const parser = DOMParser.fromSchema(schema); + const fragment = parser.parse(node).content; + const firstNode = fragment.firstChild; + + if (firstNode && firstNode.type.name !== "paragraph") { + const emptyParagraph = schema.nodes.paragraph.create(); + return fragment.addToStart(emptyParagraph); + } + + return fragment; +} diff --git a/packages/editor/test-utils/index.ts b/packages/editor/test-utils/index.ts index 3474c87de..40a3d3df0 100644 --- a/packages/editor/test-utils/index.ts +++ b/packages/editor/test-utils/index.ts @@ -108,3 +108,36 @@ export function outlineListItem( "data-type": "outlineListItem" }); } + +export function taskList(...children: HTMLLIElement[]) { + return ul(children, { class: "checklist" }); +} + +export function taskItem( + paragraphChildren: (string | HTMLElement)[], + attr: { checked?: boolean } = {}, + subList?: HTMLUListElement +) { + const children: HTMLElement[] = [h("p", paragraphChildren)]; + if (subList) children.push(subList); + + return li(children, { + class: "checklist--item " + (attr.checked ? "checked" : "") + }); +} + +export function checkList(...children: HTMLLIElement[]) { + return ul(children, { class: "simple-checklist" }); +} + +export function checkListItem( + paragraphChildren: (string | HTMLElement)[], + subList?: HTMLUListElement +) { + const children: HTMLElement[] = [h("p", paragraphChildren)]; + if (subList) children.push(subList); + + return li(children, { + class: "simple-checklist--item " + }); +}