diff --git a/src/lib/components/Checklist.svelte b/src/lib/components/Checklist.svelte index bddf394..998edb0 100644 --- a/src/lib/components/Checklist.svelte +++ b/src/lib/components/Checklist.svelte @@ -3,14 +3,10 @@ import Plus from 'lucide-svelte/icons/plus'; import GripVertical from 'lucide-svelte/icons/grip-vertical'; import ChevronRight from 'lucide-svelte/icons/chevron-right'; + import { onDestroy, flushSync } from 'svelte'; import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action'; import { flip } from 'svelte/animate'; - - interface ChecklistItem { - id: string; - text: string; - checked: boolean; - } + import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem } from '$lib/utils/checklist.js'; interface Props { content: string; @@ -40,25 +36,43 @@ let doneItems = $derived(items.filter((i) => i.checked)); let doneCount = $derived(doneItems.length); - function generateId(): string { - return crypto.randomUUID(); + interface DoneSectionGroup { + parentLabel: ChecklistItem | null; + children: ChecklistItem[]; } - function parseChecklist(text: string): ChecklistItem[] { - if (!text.trim()) return [{ id: generateId(), text: '', checked: false }]; - const lines = text.split('\n'); - return lines.map((line) => { - if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true }; - if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false }; - return { id: generateId(), text: line, checked: false }; - }); - } + let doneSectionGroups = $derived.by(() => { + const groups: DoneSectionGroup[] = []; + const childrenByParent = new Map(); + const topLevelDone: ChecklistItem[] = []; - function serializeChecklist(list: ChecklistItem[]): string { - return list - .map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`) - .join('\n'); - } + for (const item of doneItems) { + if (item.parentId) { + const siblings = childrenByParent.get(item.parentId) ?? []; + siblings.push(item); + childrenByParent.set(item.parentId, siblings); + } else { + topLevelDone.push(item); + } + } + + for (const item of topLevelDone) { + const doneChildren = childrenByParent.get(item.id) ?? []; + groups.push({ parentLabel: null, children: [item, ...doneChildren] }); + childrenByParent.delete(item.id); + } + + for (const [parentId, children] of childrenByParent) { + const parent = items.find((i) => i.id === parentId); + if (parent) { + groups.push({ parentLabel: parent, children }); + } else { + groups.push({ parentLabel: null, children }); + } + } + + return groups; + }); function emitChange() { const serialized = serializeChecklist(items); @@ -67,11 +81,8 @@ } function toggleItem(id: string) { - const item = items.find((i) => i.id === id); - if (item) { - item.checked = !item.checked; - emitChange(); - } + items = toggleItemWithCascade(id, items); + emitChange(); } function updateText(id: string, text: string) { @@ -83,13 +94,16 @@ } function addItem(afterIndex: number) { - const newItem = { id: generateId(), text: '', checked: false }; + const afterItem = items[afterIndex]; + const newItem: ChecklistItem = { + id: generateId(), + text: '', + checked: false, + parentId: afterItem?.parentId ?? null + }; items.splice(afterIndex + 1, 0, newItem); items = [...items]; emitChange(); - // Focus by unique ID — positional indexing breaks when checked items - // are interleaved in items[] (e.g. reopened notes) because the DOM - // only renders active items as inputs const newId = newItem.id; setTimeout(() => { document.querySelector(`[data-item-id="${newId}"]`)?.focus(); @@ -97,7 +111,11 @@ } function removeItem(id: string) { - if (items.length <= 1) return; + if (items.length <= 1) { + if (items[0].parentId) items[0].parentId = null; + return; + } + items = items.map((i) => (i.parentId === id ? { ...i, parentId: null } : i)); const index = items.findIndex((i) => i.id === id); items.splice(index, 1); items = [...items]; @@ -124,30 +142,131 @@ e.preventDefault(); inputs[target].focus(); } + } else if (e.key === 'Tab' && !e.shiftKey) { + e.preventDefault(); + items = indentItem(id, items, activeItems); + emitChange(); + } else if (e.key === 'Tab' && e.shiftKey) { + e.preventDefault(); + items = outdentItem(id, items); + emitChange(); } } + // --- Directional lock drag handle --- + // Instead of blocking DnD events (which corrupts its internal state), + // we detect horizontal intent and store the pending operation. DnD runs + // its full course unimpeded. After DnD finalizes, we apply the indent. + let dragStartX = 0; + let dragStartY = 0; + let pendingIndent: { id: string; direction: 'indent' | 'outdent' } | null = null; + let directionLocked = false; + const LOCK_THRESHOLD = 10; + const INDENT_THRESHOLD = 32; + + function windowPointerMove(e: PointerEvent) { + if (directionLocked) return; + + const dx = Math.abs(e.clientX - dragStartX); + const dy = Math.abs(e.clientY - dragStartY); + if (dx >= LOCK_THRESHOLD || dy >= LOCK_THRESHOLD) { + directionLocked = true; + if (dx <= dy) { + // Vertical — DnD handles it, we're done + removeWindowListeners(); + } + // Horizontal — keep listening for pointerup to measure total dx + } + } + + function windowPointerUp(e: PointerEvent) { + const dx = e.clientX - dragStartX; + if (Math.abs(dx) > INDENT_THRESHOLD && directionLocked && currentDragItemId) { + pendingIndent = { + id: currentDragItemId, + direction: dx > 0 ? 'indent' : 'outdent' + }; + } + removeWindowListeners(); + directionLocked = false; + } + + function addWindowListeners() { + window.addEventListener('pointermove', windowPointerMove, { capture: true }); + window.addEventListener('pointerup', windowPointerUp, { capture: true }); + } + + function removeWindowListeners() { + window.removeEventListener('pointermove', windowPointerMove, { capture: true }); + window.removeEventListener('pointerup', windowPointerUp, { capture: true }); + } + + let currentDragItemId = ''; + function handleDragPointerDown(e: PointerEvent, id: string) { + dragStartX = e.clientX; + dragStartY = e.clientY; + directionLocked = false; + pendingIndent = null; + currentDragItemId = id; + addWindowListeners(); + } + + onDestroy(removeWindowListeners); + + // --- DnD handlers --- + function recomputeParentIds(reorderedItems: ChecklistItem[]): ChecklistItem[] { + let lastTopLevelId: string | null = null; + return reorderedItems.map((item) => { + if (item.parentId === null) { + lastTopLevelId = item.id; + return item; + } + return { ...item, parentId: lastTopLevelId }; + }); + } + function handleDndConsider(e: CustomEvent>) { - // Merge reordered active items with done items items = [...e.detail.items, ...items.filter((i) => i.checked)]; } function handleDndFinalize(e: CustomEvent>) { - items = [...e.detail.items, ...items.filter((i) => i.checked)]; + const reorderedActive = recomputeParentIds(e.detail.items); + items = [...reorderedActive, ...items.filter((i) => i.checked)]; emitChange(); + + // Apply pending indent/outdent from horizontal drag (after DnD fully cleans up) + if (pendingIndent && pendingIndent.id) { + const { id, direction } = pendingIndent; + pendingIndent = null; + if (direction === 'indent') { + items = indentItem(id, items, activeItems); + } else { + items = outdentItem(id, items); + } + emitChange(); + } }
{#each activeItems as item (item.id)} -
-
+
+ +
handleDragPointerDown(e, item.id)} + class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 touch-none outline-none" + data-testid="checklist-drag-handle">
{#if doneExpanded} -
- {#each doneItems as item (item.id)} -
- toggleItem(item.id)} - class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]" - data-testid="checklist-done-checkbox" - /> - {item.text} - -
+
+ {#each doneSectionGroups as group} + {#if group.parentLabel} +
+ + {group.parentLabel.text} +
+ {/if} + {#each group.children as item (item.id)} +
+ toggleItem(item.id)} + class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]" + data-testid="checklist-done-checkbox" + /> + {item.text} + +
+ {/each} {/each}
{/if}
{/if}
+