editor: fix old inline images in bullet,numbered,task&check lists

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2025-11-03 13:13:27 +05:00
parent 33559250d8
commit 5fde1c0f07
13 changed files with 239 additions and 56 deletions

View File

@@ -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`] = `"<ul class="simple-checklist"><li class="simple-checklist--item"><p data-spacing="double">item 1</p></li><li class="simple-checklist--item"><p data-spacing="double"></p><img src="image.png" data-aspect-ratio="1"></li></ul>"`;

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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();
});
});

View File

@@ -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<CheckListItemOptions>({
return [
{
tag: `li.simple-checklist--item`,
priority: 51
priority: 51,
getContent: ensureLeadingParagraph
}
];
},

View File

@@ -18,8 +18,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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?.(),

View File

@@ -5,3 +5,5 @@ exports[`hitting backspace at the start of first list item 1`] = `"<div><div con
exports[`hitting backspace at the start of the second (or next) list item 1`] = `"<div><div contenteditable="true" translate="no" class="tiptap ProseMirror" tabindex="0"><ul><li><p>item1item2</p></li></ul></div></div>"`;
exports[`hitting backspace at the start of the second (or next) paragraph inside the list item 1`] = `"<div><div contenteditable="true" translate="no" class="tiptap ProseMirror" tabindex="0"><ul><li><p>item 1item 2</p></li></ul></div></div>"`;
exports[`inline image as first child in list item 1`] = `"<ul><li><p data-spacing="double">item 1</p></li><li><p data-spacing="double"></p><img src="image.png" data-aspect-ratio="1"></li></ul>"`;

View File

@@ -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();
});

View File

@@ -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<string, unknown>;
@@ -66,17 +66,7 @@ export const OutlineListItem = Node.create<ListItemOptions>({
{
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
}
];
},

View File

@@ -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`] = `"<ul class="checklist"><li class="checklist--item"><p data-spacing="double">item 1</p></li><li class="checklist--item"><p data-spacing="double"></p><img src="image.png" data-aspect-ratio="1"></li></ul>"`;

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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();
});
});

View File

@@ -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
}
];
},

View File

@@ -17,52 +17,39 @@ 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, 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

View File

@@ -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;
}

View File

@@ -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 "
});
}