~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
157 lines · 5.5 KB Raw History
// 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);
}