// The task lists in what someone wrote: `- [ ] a thing to do`, drawn as // checkboxes, which its author ticks on the page by rewriting the text. import { tick as settled } from 'svelte'; import type { Attachment } from 'svelte/attachments'; /** A list item that starts with a box, inside any quotes: the box's mark is group 2. */ const TASK = /^((?:[ \t]*>)*[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+)\[([ xX])\](?=[ \t]|$)/; /** A line that opens or closes fenced code, inside any quotes. */ const FENCE = /^(?:[ \t]*>)*[ \t]{0,3}(`{3,}|~{3,})/; /** What ends a task's own text: a list under it, or another block. */ const BLOCKS = new Set([ 'UL', 'OL', 'P', 'PRE', 'BLOCKQUOTE', 'DIV', 'TABLE', 'HR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ]); /** Where each task's mark is in `text`, in the order they are drawn, leaving out what is in fenced code. */ export function tasks(text: string): number[] { const out: number[] = []; let fence = ''; let at = 0; for (const line of text.split('\n')) { const opens = FENCE.exec(line); if (fence) { if (opens && opens[1][0] === fence[0] && opens[1].length >= fence.length) fence = ''; } else if (opens) { fence = opens[1]; } else { const task = TASK.exec(line); if (task) out.push(at + task[1].length + 1); } at += line.length + 1; } return out; } /** `text` with its task `n` (from 0) ticked, or unticked; undefined when it has no such task. */ export function ticked(text: string, n: number, done: boolean): string | undefined { const at = tasks(text)[n]; if (at === undefined) return undefined; return text.slice(0, at) + (done ? 'x' : ' ') + text.slice(at + 1); } /** Whether each task in `text` is ticked. */ const marks = (text: string) => tasks(text).map((at) => text[at] !== ' '); /** How its author's ticks are kept: `write` saves a body, throwing why it could not; `settle` reads the page anew once the last is saved; `failed` says why one was not. */ export type Ticking = { write: (body: string) => Promise; settle: () => Promise; failed: (why: string) => void; }; /** The ticks of one piece of writing: the body as ticked on the page, the body last saved, and whether a save is under way. */ type Ticks = { shown: string; saved: string; saving: boolean }; const kept = new WeakMap(); /** * The task boxes drawn from `text` as `html`: each one's own text, up to * a list under it, wrapped so a ticked task is struck through. With * `ticking`, its author's, the boxes tick, as does clicking a task's text, * at once: the body each tick makes is saved behind them, one save at a * time and the newest last, and the page read anew once all are saved. A * save that fails puts the boxes back as last saved. Left as drawn, * disabled, when the boxes are not the tasks this finds in `text`, so * that no tick ever rewrites another line. */ export function tasked(text: string, html: string, ticking?: Ticking): Attachment { return (el) => { void html; const boxes = () => [...el.querySelectorAll('input[type=checkbox]')]; if (!kept.get(el)?.saving) kept.set(el, { shown: text, saved: text, saving: false }); const n = tasks(text).length; let live = false; void settled().then(() => { const all = boxes(); for (const box of all) wrap(box); live = !!ticking && all.length === n; el.classList.toggle('tickable', live); if (live) all.forEach((b) => (b.disabled = false)); }); /** Saves what is shown until what was saved is it, then reads the page anew. */ async function save(t: Ticks, ticking: Ticking) { if (t.saving) return; t.saving = true; while (t.saved !== t.shown) { const body = t.shown; try { await ticking.write(body); t.saved = body; } catch (e) { t.shown = t.saved; const back = marks(t.saved); boxes().forEach((b, i) => (b.checked = back[i] ?? b.checked)); ticking.failed(e instanceof Error ? e.message : String(e)); } } t.saving = false; await ticking.settle(); } const changed = (e: Event) => { const t = kept.get(el); const box = e.target as HTMLInputElement; if (!live || !ticking || !t) return; const next = ticked(t.shown, boxes().indexOf(box), box.checked); if (next === undefined) return; t.shown = next; void save(t, ticking); }; /** A click on a task's text ticks its box, unless it follows a link or ends a selection. */ const clicked = (e: MouseEvent) => { const on = e.target as Element; const said = on.closest('.task-text'); if (!live || !said || on.closest('a') || !getSelection()?.isCollapsed) return; (said.previousElementSibling as HTMLInputElement | null)?.click(); }; el.addEventListener('change', changed); el.addEventListener('click', clicked); return () => { el.removeEventListener('change', changed); el.removeEventListener('click', clicked); }; }; } /** Wraps what follows `box` in its item, up to a block, as its task's text, once: the space between them left out, so the strike starts at the text. */ function wrap(box: HTMLInputElement) { if (box.nextElementSibling?.classList.contains('task-text')) return; let at: Node = box; const gap = box.nextSibling; if (gap instanceof Text) { const space = gap.data.length - gap.data.trimStart().length; if (space < gap.data.length) gap.splitText(space); if (space) at = gap; } const span = document.createElement('span'); span.className = 'task-text'; while ( at.nextSibling && !(at.nextSibling instanceof Element && BLOCKS.has(at.nextSibling.tagName)) ) { span.append(at.nextSibling); } (at as ChildNode).after(span); }