johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// 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<unknown>;
settle: () => Promise<unknown>;
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<HTMLElement, Ticks>();
/**
* 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<HTMLElement> {
return (el) => {
void html;
const boxes = () => [...el.querySelectorAll<HTMLInputElement>('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);
}