Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/tiptap/src/shared/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down
71 changes: 71 additions & 0 deletions packages/tiptap/src/shared/extensions/task-item.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
130 changes: 130 additions & 0 deletions packages/tiptap/src/shared/extensions/task-item.ts
Original file line number Diff line number Diff line change
@@ -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;