forked from mirror/crumbs-original
Use e.currentTarget instead of e.target so the reference is always the contenteditable div, not a descendant <a> tag. Use el.contains() instead of strict equality to avoid linkifying while focus is still inside the editable element. https://claude.ai/code/session_01VZceWPhHMHCA6SELBdCPfN
415 lines
14 KiB
Svelte
415 lines
14 KiB
Svelte
<script lang="ts">
|
|
import XIcon from 'lucide-svelte/icons/x';
|
|
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 } from 'svelte';
|
|
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
|
import { flip } from 'svelte/animate';
|
|
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml } from '$lib/utils/checklist.js';
|
|
import LinkPopover from './LinkPopover.svelte';
|
|
|
|
interface Props {
|
|
content: string;
|
|
onChange: (content: string) => void;
|
|
}
|
|
|
|
let { content, onChange }: Props = $props();
|
|
|
|
// svelte-ignore state_referenced_locally
|
|
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
|
let doneExpanded = $state(true);
|
|
const flipDurationMs = 150;
|
|
let linkPopover = $state<{ url: string; anchor: DOMRect } | null>(null);
|
|
|
|
// Track the last content we emitted so we can distinguish self-originated
|
|
// changes from external ones (sync, history restore, etc.)
|
|
let lastEmitted = content;
|
|
|
|
// Re-parse items when content changes externally (not from our own emitChange)
|
|
$effect(() => {
|
|
if (content !== lastEmitted) {
|
|
items = parseChecklist(content);
|
|
lastEmitted = content;
|
|
}
|
|
});
|
|
|
|
let activeItems = $derived(items.filter((i) => !i.checked));
|
|
let doneItems = $derived(items.filter((i) => i.checked));
|
|
let doneCount = $derived(doneItems.length);
|
|
|
|
interface DoneSectionGroup {
|
|
parentLabel: ChecklistItem | null;
|
|
children: ChecklistItem[];
|
|
}
|
|
|
|
let doneSectionGroups = $derived.by(() => {
|
|
const groups: DoneSectionGroup[] = [];
|
|
const childrenByParent = new Map<string, ChecklistItem[]>();
|
|
const topLevelDone: ChecklistItem[] = [];
|
|
|
|
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);
|
|
lastEmitted = serialized;
|
|
onChange(serialized);
|
|
}
|
|
|
|
function toggleItem(id: string) {
|
|
items = toggleItemWithCascade(id, items);
|
|
emitChange();
|
|
}
|
|
|
|
function updateText(id: string, innerHTML: string) {
|
|
const item = items.find((i) => i.id === id);
|
|
if (item) {
|
|
item.text = unlinkifyHtml(innerHTML);
|
|
emitChange();
|
|
}
|
|
}
|
|
|
|
function addItem(afterIndex: number) {
|
|
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();
|
|
const newId = newItem.id;
|
|
setTimeout(() => {
|
|
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
|
|
}, 0);
|
|
}
|
|
|
|
function removeItem(id: string) {
|
|
if (items.length <= 1) {
|
|
if (items[0].parentId) items[0].parentId = null;
|
|
items = [...items];
|
|
emitChange();
|
|
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];
|
|
emitChange();
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent, id: string) {
|
|
const index = items.findIndex((i) => i.id === id);
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addItem(index);
|
|
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
|
|
e.preventDefault();
|
|
removeItem(id);
|
|
setTimeout(() => {
|
|
const inputs = document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]');
|
|
inputs[Math.max(0, index - 1)]?.focus();
|
|
}, 0);
|
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
const inputs = Array.from(document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]'));
|
|
const current = inputs.findIndex((el) => el.dataset.itemId === id);
|
|
const target = e.key === 'ArrowUp' ? current - 1 : current + 1;
|
|
if (target >= 0 && target < inputs.length) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Svelte action: sets innerHTML on mount and syncs from external changes only.
|
|
// While the user is typing (element is focused), we don't re-render — that would
|
|
// destroy the cursor. Linkification happens on blur via the onblur handler.
|
|
function editableContent(node: HTMLElement, text: string) {
|
|
node.innerHTML = linkifyText(text);
|
|
return {
|
|
update(newText: string) {
|
|
if (document.activeElement !== node) {
|
|
node.innerHTML = linkifyText(newText);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function handleLinkClick(e: MouseEvent) {
|
|
const target = e.target as HTMLElement;
|
|
const anchor = target.closest('a');
|
|
if (anchor) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
linkPopover = { url: anchor.href, anchor: anchor.getBoundingClientRect() };
|
|
}
|
|
}
|
|
|
|
function handlePaste(e: ClipboardEvent) {
|
|
e.preventDefault();
|
|
const text = (e.clipboardData?.getData('text/plain') ?? '').replace(/[\r\n]+/g, ' ');
|
|
const selection = window.getSelection();
|
|
if (!selection?.rangeCount) return;
|
|
const range = selection.getRangeAt(0);
|
|
range.deleteContents();
|
|
range.insertNode(document.createTextNode(text));
|
|
range.collapse(false);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
(e.target as HTMLElement).dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
// --- 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<DndEvent<ChecklistItem>>) {
|
|
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
|
}
|
|
|
|
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
|
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();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-0.5" data-testid="checklist">
|
|
<section
|
|
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: { outline: 'none' } }}
|
|
onconsider={handleDndConsider}
|
|
onfinalize={handleDndFinalize}
|
|
class="space-y-0.5"
|
|
>
|
|
{#each activeItems as item (item.id)}
|
|
<div class="group flex items-center gap-2 py-1.5 outline-none {item.parentId ? 'pl-8' : ''}"
|
|
data-testid={item.parentId ? 'checklist-child-row' : undefined}
|
|
animate:flip={{ duration: flipDurationMs }}>
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div use:dragHandle
|
|
aria-label="drag handle"
|
|
onpointerdown={(e) => 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">
|
|
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
|
</div>
|
|
<input
|
|
type="checkbox"
|
|
checked={item.checked}
|
|
onchange={() => toggleItem(item.id)}
|
|
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
|
data-testid="checklist-checkbox"
|
|
/>
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
contenteditable="true"
|
|
use:editableContent={item.text}
|
|
oninput={(e) => updateText(item.id, (e.target as HTMLElement).innerHTML)}
|
|
onkeydown={(e) => handleKeydown(e, item.id)}
|
|
onclick={handleLinkClick}
|
|
onpaste={handlePaste}
|
|
onblur={(e) => {
|
|
const el = e.currentTarget as HTMLElement;
|
|
requestAnimationFrame(() => {
|
|
if (el.isConnected && !el.contains(document.activeElement)) {
|
|
el.innerHTML = linkifyText(item.text);
|
|
}
|
|
});
|
|
}}
|
|
class="flex-1 min-w-0 bg-transparent text-sm outline-none break-words {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
|
data-placeholder="List item"
|
|
aria-placeholder="List item"
|
|
data-testid="checklist-input"
|
|
data-item-id={item.id}
|
|
role="textbox"
|
|
tabindex="0"
|
|
></div>
|
|
<button
|
|
onclick={() => removeItem(item.id)}
|
|
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
|
aria-label="Remove item"
|
|
data-testid="checklist-remove"
|
|
>
|
|
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</section>
|
|
|
|
<button
|
|
onclick={() => addItem(items.length - 1)}
|
|
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
|
data-testid="checklist-add"
|
|
>
|
|
<Plus class="h-4 w-4" />
|
|
Add item
|
|
</button>
|
|
|
|
{#if doneCount > 0}
|
|
<div class="mt-2 border-t border-[var(--border-subtle)] pt-2">
|
|
<button
|
|
onclick={() => (doneExpanded = !doneExpanded)}
|
|
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
|
|
data-testid="checklist-toggle-done"
|
|
>
|
|
<ChevronRight class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
|
|
{doneCount} done
|
|
</button>
|
|
|
|
{#if doneExpanded}
|
|
<div class="mt-1 space-y-0.5" data-testid="checklist-done-section">
|
|
{#each doneSectionGroups as group}
|
|
{#if group.parentLabel}
|
|
<div class="flex items-center gap-2 py-1.5 pl-5" data-testid="checklist-done-parent-label">
|
|
<input type="checkbox" disabled class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
|
|
<span class="text-sm text-[var(--text-muted)] break-words min-w-0">{@html linkifyText(group.parentLabel.text)}</span>
|
|
</div>
|
|
{/if}
|
|
{#each group.children as item (item.id)}
|
|
<div class="group flex items-center gap-2 py-1.5 {item.parentId ? 'pl-10' : 'pl-5'}">
|
|
<input
|
|
type="checkbox"
|
|
checked={item.checked}
|
|
onchange={() => 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"
|
|
/>
|
|
<span class="flex-1 text-sm text-[var(--text-muted)] line-through break-words min-w-0">{@html linkifyText(item.text)}</span>
|
|
<button
|
|
onclick={() => removeItem(item.id)}
|
|
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
|
aria-label="Remove item"
|
|
data-testid="checklist-done-remove"
|
|
>
|
|
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if linkPopover}
|
|
<LinkPopover url={linkPopover.url} anchor={linkPopover.anchor} onClose={() => (linkPopover = null)} />
|
|
{/if}
|
|
</div>
|
|
|