checklist rebuild

This commit is contained in:
2026-02-28 15:47:12 -06:00
parent 14ebb21049
commit 5bf00de940
2 changed files with 172 additions and 10 deletions
+164
View File
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'
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
},
}
}
},
})
+8 -10
View File
@@ -3,7 +3,7 @@ import { createTiptapEditor } from "solid-tiptap";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder"; import Placeholder from "@tiptap/extension-placeholder";
import TaskList from "@tiptap/extension-task-list"; 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 Image from "@tiptap/extension-image";
import Underline from "@tiptap/extension-underline"; import Underline from "@tiptap/extension-underline";
import { X } from "lucide-solid"; import { X } from "lucide-solid";
@@ -57,7 +57,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
class: "not-prose pl-2", class: "not-prose pl-2",
}, },
}), }),
TaskItem.configure({ CustomTaskItem.configure({
nested: true, nested: true,
HTMLAttributes: { HTMLAttributes: {
class: "flex gap-2 items-start my-1", class: "flex gap-2 items-start my-1",
@@ -91,12 +91,10 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
// Custom Task List Styling // Custom Task List Styling
"[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0", "[&_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']]: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", "[&_.tiptap-task-content]:flex-1 [&_.tiptap-task-content]:min-w-0 [&_.tiptap-task-content]:min-h-[1.5rem]",
"[&_li[data-type='taskItem']>label>input]:w-5 [&_li[data-type='taskItem']>label>input]:h-5 [&_li[data-type='taskItem']>label>input]:cursor-pointer", "[&_.tiptap-task-content>p]:min-h-[1.5rem] [&_.tiptap-task-content>p]:m-0 [&_.tiptap-task-content>p]:w-full",
"[&_li[data-type='taskItem']>div]:flex-1 [&_li[data-type='taskItem']>div]:min-w-0 [&_li[data-type='taskItem']>div]:min-h-[1.5rem]", "[&_.tiptap-task-content>p:empty]:after:content-['\\200B'] [&_.tiptap-task-content>p:empty]:after:inline-block",
"[&_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", "[&_.tiptap-task-content>p>br]:after:content-['\\200B'] [&_.tiptap-task-content>p>br]:after:inline-block", // if tiptap adds br
"[&_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
props.class props.class
), ),
}, },
@@ -123,7 +121,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
return ( return (
<div class="relative w-full h-full flex flex-col"> <div class="relative w-full h-full flex flex-col">
<div <div
class="absolute -top-4 -left-4 -right-4 h-8 cursor-pointer group flex items-center justify-center z-10" class="absolute -top-6 -left-4 -right-4 h-8 cursor-pointer group flex items-center justify-center z-10"
onClick={() => { onClick={() => {
const e = editor(); const e = editor();
if (!e) return; if (!e) return;
@@ -131,7 +129,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
}} }}
title="Add empty line at the top" title="Add empty line at the top"
> >
<span class="opacity-0 group-hover:opacity-100 text-[10px] uppercase font-bold text-muted-foreground tracking-widest transition-opacity mt-2"> <span class="opacity-0 group-hover:opacity-100 text-[10px] uppercase font-bold text-muted-foreground tracking-widest transition-opacity mt-1">
Click to insert newline above Click to insert newline above
</span> </span>
</div> </div>