From 5bf00de94036ae478496927045117809188dbadb Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Sat, 28 Feb 2026 15:47:12 -0600 Subject: [PATCH] checklist rebuild --- src/components/CustomTaskItem.ts | 164 +++++++++++++++++++++++++++++++ src/components/TaskEditor.tsx | 18 ++-- 2 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 src/components/CustomTaskItem.ts diff --git a/src/components/CustomTaskItem.ts b/src/components/CustomTaskItem.ts new file mode 100644 index 0000000..47731f6 --- /dev/null +++ b/src/components/CustomTaskItem.ts @@ -0,0 +1,164 @@ +import { Node, mergeAttributes } from '@tiptap/core' +export const CustomTaskItem = Node.create({ + name: 'taskItem', + + addOptions() { + return { + nested: false, + HTMLAttributes: {}, + } + }, + + content: 'paragraph block*', + + 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="taskItem"]', + priority: 51, + }, + ] + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + 'li', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + 'data-type': 'taskItem', + }), + [ + 'label', + [ + 'input', + { + type: 'checkbox', + checked: node.attrs.checked ? 'checked' : null, + }, + ], + ['span', ''], + ], + ['div', 0], + ] + }, + + addKeyboardShortcuts() { + return { + Enter: () => this.editor.commands.splitListItem(this.name), + 'Shift-Tab': () => this.editor.commands.liftListItem(this.name), + Tab: () => this.editor.commands.sinkListItem(this.name), + } + }, + + addNodeView() { + return ({ node, getPos, editor }) => { + let currentNode = node; + + const dom = document.createElement('li') + dom.classList.add('flex', 'items-center', 'group', 'relative', 'my-1') + dom.dataset.type = 'taskItem' + if (currentNode.attrs.checked) { + dom.dataset.checked = 'true' + } + + // Wrapper for the checkbox + const handle = document.createElement('label') + handle.contentEditable = 'false' + handle.classList.add( + 'flex', 'items-center', 'justify-center', + 'mr-3', 'select-none', 'relative' + ) + + const checkbox = document.createElement('div') + checkbox.classList.add( + 'w-5', 'h-5', + 'flex', 'items-center', 'justify-center', + 'rounded-md', 'border-[1.5px]', + 'transition-colors', 'duration-300', 'cursor-pointer', + 'shrink-0' // removed mt-0.5 + ) + + // The SVG checkmark + const checkIcon = document.createElement('div') + checkIcon.innerHTML = '' + checkIcon.classList.add('transition-opacity', 'duration-300', 'ease-in-out') + checkbox.appendChild(checkIcon) + + const updateCheckboxVisuals = (isChecked: boolean) => { + if (isChecked) { + checkbox.classList.add('bg-primary', 'border-primary', 'text-primary-foreground') + checkbox.classList.remove('border-muted-foreground/30', 'bg-transparent', 'text-transparent') + checkIcon.style.opacity = '1' + } else { + checkbox.classList.remove('bg-primary', 'border-primary', 'text-primary-foreground') + checkbox.classList.add('border-muted-foreground/30', 'bg-transparent', 'text-transparent') + checkIcon.style.opacity = '0' + } + } + + // Initialize visually + updateCheckboxVisuals(currentNode.attrs.checked) + + handle.addEventListener('click', (e) => { + // Prevent label default toggling since we handle it manually + e.preventDefault(); + if (typeof getPos === 'function') { + editor.commands.command(({ tr }) => { + const position = getPos() + if (typeof position === 'number') { + tr.setNodeMarkup(position, undefined, { + checked: !currentNode.attrs.checked, + }) + return true + } + return false + }) + } + }) + + handle.appendChild(checkbox) + + const content = document.createElement('div') + content.classList.add('flex-1', 'min-w-0', 'min-h-[1.5rem]') + + // Add a class that can be targeted for the zero-width-space hack + content.classList.add('tiptap-task-content') + + dom.appendChild(handle) + dom.appendChild(content) + + return { + dom, + contentDOM: content, + update: (updatedNode) => { + if (updatedNode.type.name !== this.name) { + return false + } + currentNode = updatedNode; + + updateCheckboxVisuals(updatedNode.attrs.checked) + if (updatedNode.attrs.checked) { + dom.dataset.checked = 'true' + } else { + delete dom.dataset.checked + } + return true + }, + } + } + }, +}) diff --git a/src/components/TaskEditor.tsx b/src/components/TaskEditor.tsx index 15169dc..8dc16ac 100644 --- a/src/components/TaskEditor.tsx +++ b/src/components/TaskEditor.tsx @@ -3,7 +3,7 @@ import { createTiptapEditor } from "solid-tiptap"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import TaskList from "@tiptap/extension-task-list"; -import TaskItem from "@tiptap/extension-task-item"; +import { CustomTaskItem } from "./CustomTaskItem"; import Image from "@tiptap/extension-image"; import Underline from "@tiptap/extension-underline"; import { X } from "lucide-solid"; @@ -57,7 +57,7 @@ export const TaskEditor: Component = (props) => { class: "not-prose pl-2", }, }), - TaskItem.configure({ + CustomTaskItem.configure({ nested: true, HTMLAttributes: { class: "flex gap-2 items-start my-1", @@ -91,12 +91,10 @@ export const TaskEditor: Component = (props) => { // Custom Task List Styling "[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0", "[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start", - "[&_li[data-type='taskItem']>label]:mr-3 [&_li[data-type='taskItem']>label]:mt-1 [&_li[data-type='taskItem']>label]:select-none", - "[&_li[data-type='taskItem']>label>input]:w-5 [&_li[data-type='taskItem']>label>input]:h-5 [&_li[data-type='taskItem']>label>input]:cursor-pointer", - "[&_li[data-type='taskItem']>div]:flex-1 [&_li[data-type='taskItem']>div]:min-w-0 [&_li[data-type='taskItem']>div]:min-h-[1.5rem]", - "[&_li[data-type='taskItem']>div>p]:min-h-[1.5rem] [&_li[data-type='taskItem']>div>p]:m-0 [&_li[data-type='taskItem']>div>p]:w-full", - "[&_li[data-type='taskItem']>div>p:empty]:after:content-['\\200B'] [&_li[data-type='taskItem']>div>p:empty]:after:inline-block", - "[&_li[data-type='taskItem']>div>p>br]:after:content-['\\200B'] [&_li[data-type='taskItem']>div>p>br]:after:inline-block", // if tiptap adds br + "[&_.tiptap-task-content]:flex-1 [&_.tiptap-task-content]:min-w-0 [&_.tiptap-task-content]:min-h-[1.5rem]", + "[&_.tiptap-task-content>p]:min-h-[1.5rem] [&_.tiptap-task-content>p]:m-0 [&_.tiptap-task-content>p]:w-full", + "[&_.tiptap-task-content>p:empty]:after:content-['\\200B'] [&_.tiptap-task-content>p:empty]:after:inline-block", + "[&_.tiptap-task-content>p>br]:after:content-['\\200B'] [&_.tiptap-task-content>p>br]:after:inline-block", // if tiptap adds br props.class ), }, @@ -123,7 +121,7 @@ export const TaskEditor: Component = (props) => { return (
{ const e = editor(); if (!e) return; @@ -131,7 +129,7 @@ export const TaskEditor: Component = (props) => { }} title="Add empty line at the top" > - + Click to insert newline above