import type { KeyboardShortcutCommand } from '@tiptap/core' import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' export interface TaskItemOptions { onReadOnlyChecked?: (node: ProseMirrorNode, checked: boolean) => boolean nested: boolean HTMLAttributes: Record taskListTypeName: string } export const inputRegex = /^\s*\[( |x)?\]\s$/i export const TaskItem = Node.create({ name: 'taskItem', addOptions() { return { nested: false, HTMLAttributes: {}, taskListTypeName: 'taskList', } }, content() { return this.options.nested ? 'paragraph block*' : 'paragraph+' }, defining: true, addAttributes() { return { checked: { default: false, keepOnSplit: false, parseHTML: (element) => element.getAttribute('data-checked') === 'true', renderHTML: (attributes) => ({ 'data-checked': attributes.checked, }), }, } }, parseHTML() { return [ { tag: `li[data-type="${this.name}"]`, priority: 51, }, ] }, renderHTML({ node, HTMLAttributes }) { return [ 'li', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name, }), [ 'label', [ 'input', { type: 'checkbox', checked: node.attrs.checked ? 'checked' : null, }, ], ['span'], ], ['div', 0], ] }, addKeyboardShortcuts() { const shortcuts: { [key: string]: KeyboardShortcutCommand } = { 'Enter': () => this.editor.commands.splitListItem(this.name), 'Shift-Tab': () => this.editor.commands.liftListItem(this.name), } if (!this.options.nested) { return shortcuts } return { ...shortcuts, Tab: () => this.editor.commands.sinkListItem(this.name), } }, 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') checkboxWrapper.contentEditable = 'false' checkbox.type = 'checkbox' checkbox.addEventListener('change', (event) => { // 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 return } const { checked } = event.target as any if (editor.isEditable && typeof getPos === 'function') { editor .chain() .focus(undefined, { scrollIntoView: false }) .command(({ tr }) => { const position = getPos() const currentNode = tr.doc.nodeAt(position) tr.setNodeMarkup(position, undefined, { ...currentNode?.attrs, checked, }) return true }) .run() } if (!editor.isEditable && this.options.onReadOnlyChecked) { // Reset state if onReadOnlyChecked returns false 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 if (node.attrs.checked) { checkbox.setAttribute('checked', 'checked') } checkboxWrapper.append(checkbox, checkboxStyler) listItem.append(checkboxWrapper, content) Object.entries(HTMLAttributes).forEach(([key, value]) => { listItem.setAttribute(key, value) }) return { dom: listItem, contentDOM: content, update: (updatedNode) => { if (updatedNode.type !== this.type) { return false } listItem.dataset.checked = updatedNode.attrs.checked if (updatedNode.attrs.checked) { checkbox.setAttribute('checked', 'checked') } else { checkbox.removeAttribute('checked') } return true }, } } }, addInputRules() { return [ wrappingInputRule({ find: inputRegex, type: this.type, getAttributes: (match) => ({ checked: match[match.length - 1].toLowerCase() === 'x', }), }), ] }, })