diff --git a/packages/tiptap/src/shared/extensions/index.ts b/packages/tiptap/src/shared/extensions/index.ts index c0709d76a5..ecf20ea4fc 100644 --- a/packages/tiptap/src/shared/extensions/index.ts +++ b/packages/tiptap/src/shared/extensions/index.ts @@ -7,7 +7,6 @@ import { TableHeader, TableRow, } from "@tiptap/extension-table"; -import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import Underline from "@tiptap/extension-underline"; import { Mark } from "@tiptap/pm/model"; @@ -24,6 +23,7 @@ import { Hashtag } from "../hashtag"; import { AttachmentImage } from "./image"; import { Placeholder, type PlaceholderFunction } from "./placeholder"; import { SearchAndReplace } from "./search-and-replace"; +import TaskItem from "./task-item"; export type { PlaceholderFunction }; export * from "./image"; diff --git a/packages/tiptap/src/shared/extensions/task-item.test.ts b/packages/tiptap/src/shared/extensions/task-item.test.ts new file mode 100644 index 0000000000..529e24680b --- /dev/null +++ b/packages/tiptap/src/shared/extensions/task-item.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom + +import { Editor } from "@tiptap/core"; +import TaskList from "@tiptap/extension-task-list"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, test } from "vitest"; + +import TaskItem from "./task-item"; + +const editors: Editor[] = []; + +function createEditor() { + const editor = new Editor({ + extensions: [ + StarterKit.configure({ listKeymap: false }), + TaskList, + TaskItem.configure({ nested: true }), + ], + content: { + type: "doc", + content: [ + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "one" }], + }, + ], + }, + ], + }, + ], + }, + }); + + editors.push(editor); + + return editor; +} + +afterEach(() => { + while (editors.length > 0) { + editors.pop()?.destroy(); + } +}); + +describe("task item node view", () => { + test("does not mark the checkbox wrapper as contenteditable=false", () => { + const editor = createEditor(); + const taskItemNode = editor.state.doc.firstChild?.firstChild; + + expect(taskItemNode).not.toBeNull(); + + const nodeView = editor.extensionManager.nodeViews.taskItem( + taskItemNode!, + {} as any, + () => 1, + [] as any, + {} as any, + ); + const checkboxWrapper = nodeView.dom.querySelector("label"); + + expect(checkboxWrapper).not.toBeNull(); + expect(checkboxWrapper?.getAttribute("contenteditable")).toBeNull(); + }); +}); diff --git a/packages/tiptap/src/shared/extensions/task-item.ts b/packages/tiptap/src/shared/extensions/task-item.ts new file mode 100644 index 0000000000..d794e17814 --- /dev/null +++ b/packages/tiptap/src/shared/extensions/task-item.ts @@ -0,0 +1,130 @@ +import { getRenderedAttributes } from "@tiptap/core"; +import BaseTaskItem from "@tiptap/extension-task-item"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +const TaskItem = BaseTaskItem.extend({ + addNodeView() { + return ({ node, HTMLAttributes, 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 updateA11Y = (currentNode: ProseMirrorNode) => { + checkbox.ariaLabel = + this.options.a11y?.checkboxLabel?.(currentNode, checkbox.checked) || + `Task item checkbox for ${currentNode.textContent || "empty task item"}`; + }; + + updateA11Y(node); + + // Chrome can fail to paint full-document selections across task items when + // the checkbox wrapper is marked contenteditable="false". + checkbox.type = "checkbox"; + checkbox.addEventListener("mousedown", (event) => event.preventDefault()); + checkbox.addEventListener("change", (event) => { + if (!editor.isEditable && !this.options.onReadOnlyChecked) { + checkbox.checked = !checkbox.checked; + return; + } + + const { checked } = event.target as HTMLInputElement; + + if (editor.isEditable && typeof getPos === "function") { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .command(({ tr }) => { + const position = getPos(); + + if (typeof position !== "number") { + return false; + } + + const currentNode = tr.doc.nodeAt(position); + + tr.setNodeMarkup(position, undefined, { + ...currentNode?.attrs, + checked, + }); + + return true; + }) + .run(); + } + + if (!editor.isEditable && this.options.onReadOnlyChecked) { + if (!this.options.onReadOnlyChecked(node, checked)) { + checkbox.checked = !checkbox.checked; + } + } + }); + + Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => { + listItem.setAttribute(key, value); + }); + + listItem.dataset.checked = node.attrs.checked; + checkbox.checked = node.attrs.checked; + checkboxWrapper.append(checkbox, checkboxStyler); + listItem.append(checkboxWrapper, content); + + Object.entries(HTMLAttributes).forEach(([key, value]) => { + listItem.setAttribute(key, value); + }); + + let prevRenderedAttributeKeys = new Set(Object.keys(HTMLAttributes)); + + return { + dom: listItem, + contentDOM: content, + update: (updatedNode) => { + if (updatedNode.type !== this.type) { + return false; + } + + listItem.dataset.checked = updatedNode.attrs.checked; + checkbox.checked = updatedNode.attrs.checked; + updateA11Y(updatedNode); + + const extensionAttributes = editor.extensionManager.attributes; + const newHTMLAttributes = getRenderedAttributes( + updatedNode, + extensionAttributes, + ); + const newKeys = new Set(Object.keys(newHTMLAttributes)); + const staticAttrs = this.options.HTMLAttributes; + + prevRenderedAttributeKeys.forEach((key) => { + if (!newKeys.has(key)) { + if (key in staticAttrs) { + listItem.setAttribute(key, staticAttrs[key]); + } else { + listItem.removeAttribute(key); + } + } + }); + + Object.entries(newHTMLAttributes).forEach(([key, value]) => { + if (value === null || value === undefined) { + if (key in staticAttrs) { + listItem.setAttribute(key, staticAttrs[key]); + } else { + listItem.removeAttribute(key); + } + } else { + listItem.setAttribute(key, value); + } + }); + + prevRenderedAttributeKeys = newKeys; + + return true; + }, + }; + }; + }, +}); + +export default TaskItem;