chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Task boxes tick at once, by their text too, and strike through what is done
harness/e2e/flows.spec.mjs+5−2
| @@ -263,8 +263,11 @@ test('ticking a task in what was written', async () => { | ||
await expect(box).toBeEnabled() | ||
await box.check() | ||
await expect(box).toBeChecked() | ||
await expect.poll(async () => (await records('bob', 'dev.knotless.issue')).map((r) => r.value.body).join('\n')).toContain('- [ ] Try an empty file\n- [x] Try a file with no newline') | ||
await anew(page, (expect) => expect(comment(page, 'for a file with no newline at its end?').getByRole('checkbox').nth(1)).toBeChecked()) | ||
// Its text ticks it too, at once, while the first is still saving. | ||
await comment(page, 'for a file with no newline at its end?').getByText('Try an empty file').click() | ||
await expect(comment(page, 'for a file with no newline at its end?').getByRole('checkbox').nth(0)).toBeChecked() | ||
await expect.poll(async () => (await records('bob', 'dev.knotless.issue')).map((r) => r.value.body).join('\n')).toContain('- [x] Try an empty file\n- [x] Try a file with no newline') | ||
await anew(page, (expect) => expect(comment(page, 'for a file with no newline at its end?').getByRole('checkbox').nth(0)).toBeChecked()) | ||
}) | ||
| ||
test('deleting a comment', async () => { | ||
web/src/lib/organisms/Comment.svelte+7−6
| @@ -6,14 +6,15 @@ | ||
import { ago } from '#lib/format.ts'; | ||
import Time from '#lib/atoms/Time.svelte'; | ||
import Person from '#lib/molecules/Person.svelte'; | ||
import { tickable } from '#lib/tasks.ts'; | ||
import { tasked, type Ticking } from '#lib/tasks.ts'; | ||
/** | ||
* Something said on a pull or an issue: who said it, when, whether they | ||
* changed it since, and what; `end` beside who said it, then `menu`, of | ||
* what can be done with it, and `below` under what it says, for what | ||
* others made of it. `id` is its anchor on the page. With `body`, what | ||
* `html` was drawn from, and `tick`, its author's, its task boxes can be | ||
* ticked, `tick` writing the body each makes. | ||
* others made of it. `id` is its anchor on the page. Its task boxes | ||
* strike through what they tick; with `body`, what `html` was drawn | ||
* from, and `tick`, its author's, they can be ticked, `tick` keeping | ||
* the body each makes. | ||
*/ | ||
let { | ||
id, | ||
| @@ -29,7 +30,7 @@ | ||
below, | ||
body, | ||
tick | ||
}: { id?: string; author: string; did?: string; avatar?: string | null; said: string; createdAt: string; updatedAt?: string | null; html: string; end?: Snippet; menu?: Snippet; below?: Snippet; body?: string | null; tick?: (body: string) => Promise<string | undefined> } = $props(); | ||
}: { id?: string; author: string; did?: string; avatar?: string | null; said: string; createdAt: string; updatedAt?: string | null; html: string; end?: Snippet; menu?: Snippet; below?: Snippet; body?: string | null; tick?: Ticking } = $props(); | ||
</script> | ||
| ||
<div class="box comment" {id}> | ||
| @@ -39,6 +40,6 @@ | ||
{#if end}<span class="end">{@render end()}</span>{/if} | ||
{#if menu}<span class:end={!end}>{@render menu()}</span>{/if} | ||
</div> | ||
<div class="prose" use:diagrams={html} use:fences={html} {@attach body && tick ? tickable(body, html, tick) : undefined}>{@html html}</div> | ||
<div class="prose" use:diagrams={html} use:fences={html} {@attach tasked(body ?? '', html, body ? tick : undefined)}>{@html html}</div> | ||
{#if below}{@render below()}{/if} | ||
</div> | ||
web/src/lib/tasks.ts+121−61
| @@ -1,79 +1,139 @@ | ||
// 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"; | ||
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]|$)/; | ||
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; | ||
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); | ||
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>(); | ||
| ||
/** | ||
* Lets the boxes drawn from `text`'s tasks, as `html`, be ticked, each | ||
* tick giving `tick` the text rewritten to say it; the boxes wait while | ||
* it writes, and one it fails goes back. Left as drawn, disabled, when | ||
* the boxes are not the tasks this finds in `text`, so that no tick ever | ||
* rewrites another line. | ||
* 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 tickable( | ||
text: string, | ||
html: string, | ||
tick: (text: string) => Promise<string | undefined>, | ||
): Attachment<HTMLElement> { | ||
return (el) => { | ||
void html; | ||
const boxes = () => [ | ||
...el.querySelectorAll<HTMLInputElement>("li > input[type=checkbox]"), | ||
]; | ||
const enable = (on: boolean) => boxes().forEach((b) => (b.disabled = !on)); | ||
const n = tasks(text).length; | ||
void settled().then(() => { | ||
if (boxes().length === n) enable(true); | ||
}); | ||
const changed = async (e: Event) => { | ||
const box = e.target as HTMLInputElement; | ||
const all = boxes(); | ||
const next = ticked(text, all.indexOf(box), box.checked); | ||
if (next === undefined || all.length !== n) return; | ||
enable(false); | ||
if (await tick(next)) box.checked = !box.checked; | ||
enable(true); | ||
}; | ||
el.addEventListener("change", changed); | ||
return () => el.removeEventListener("change", changed); | ||
}; | ||
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); | ||
} | ||
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/+page.svelte+10−3
| @@ -10,7 +10,8 @@ | ||
import type * as setItemState from '#lib/lexicons/dev/knotless/setItemState.ts'; | ||
import type { Mention } from '#lib/lexicons/dev/knotless/getThread.ts'; | ||
import type { AtUriString } from '@atproto/lex'; | ||
import { goto } from '$app/navigation'; | ||
import { goto, invalidateAll } from '$app/navigation'; | ||
import type { Ticking } from '#lib/tasks.ts'; | ||
import { call } from '#lib/data.ts'; | ||
import { checkIcon, stateIcon } from '#lib/icons.ts'; | ||
import { LOGIN } from '#lib/login.ts'; | ||
| @@ -133,6 +134,12 @@ | ||
if (!failed) editing = ''; | ||
return failed; | ||
}); | ||
/** How ticks of the task boxes in what `on` says are kept: each saved as it comes, the page read anew once the last is. */ | ||
const ticking = (on: AtUriString): Ticking => ({ | ||
write: (body) => call<typeof editItem.main>('dev.knotless.editItem', { repo, subject: on, body }), | ||
settle: () => invalidateAll(), | ||
failed: (why) => (problem = why) | ||
}); | ||
const setState = (state: 'open' | 'closed') => doing('state', () => act(() => call<typeof setItemState.main>('dev.knotless.setItemState', { repo, subject, state }))); | ||
const label = (name: string, removed: boolean) => act(() => call<typeof labelItem.main>('dev.knotless.labelItem', { repo, subject, label: name, removed })); | ||
async function hide(on: AtUriString, shown = false) { | ||
| @@ -233,7 +240,7 @@ | ||
{#if editing === 'body'} | ||
<Composer in repo={named} label="Update {noun}" text={t.body ?? ''} placeholder="What it is about" rows={14} send={(body, _, images) => edit(subject, { body, images })} cancel={() => (editing = '')} /> | ||
{:else if t.html !== undefined} | ||
<Comment author={d.author.handle} did={d.author.did} avatar={d.author.avatar} said="opened" createdAt={d.createdAt} updatedAt={d.updatedAt} html={t.html} body={t.body} tick={me === d.author.did ? (body) => edit(subject, { body }) : undefined}> | ||
<Comment author={d.author.handle} did={d.author.did} avatar={d.author.avatar} said="opened" createdAt={d.createdAt} updatedAt={d.updatedAt} html={t.html} body={t.body} tick={me === d.author.did ? ticking(subject) : undefined}> | ||
{#snippet menu()}<ActionMenu actions={actions(subject, d.author.did, `this ${noun}`, 'body')} />{/snippet} | ||
{#snippet below()}<ReactionBar repo={r.uri} subject={d.uri} reactions={t.reactions} in={!!viewer} />{/snippet} | ||
</Comment> | ||
| @@ -249,7 +256,7 @@ | ||
{@const on = e.said.uri} | ||
<Composer in repo={named} label="Update comment" text={e.said.body ?? ''} send={(body, _, images) => edit(on, { body, images })} cancel={() => (editing = '')} /> | ||
{:else} | ||
<Comment id={e.said.uri ? `comment-${e.said.uri.split('/').pop()}` : undefined} author={e.said.author.handle} did={e.said.author.did} avatar={e.said.author.avatar} said={e.said.verdict ? 'reviewed' : 'commented'} createdAt={e.said.createdAt} updatedAt={e.said.updatedAt} html={e.said.html} body={e.said.body} tick={me === e.said.author.did && e.said.uri ? (body) => edit(e.said.uri as AtUriString, { body }) : undefined}> | ||
<Comment id={e.said.uri ? `comment-${e.said.uri.split('/').pop()}` : undefined} author={e.said.author.handle} did={e.said.author.did} avatar={e.said.author.avatar} said={e.said.verdict ? 'reviewed' : 'commented'} createdAt={e.said.createdAt} updatedAt={e.said.updatedAt} html={e.said.html} body={e.said.body} tick={me === e.said.author.did && e.said.uri ? ticking(e.said.uri) : undefined}> | ||
{#snippet end()}<Verdict verdict={e.said.verdict} earlier={earlier(e.said)} />{/snippet} | ||
{#snippet menu()}<ActionMenu actions={actions(e.said.uri, e.said.author.did, 'this comment', e.said.uri ?? '')} />{/snippet} | ||
{#snippet below()} | ||
web/src/app.css+2−1
| @@ -569,7 +569,8 @@ a.row:hover{background:var(--hover)} | ||
.prose h2{font-size:var(--text-xl)} | ||
.prose h3{font-size:var(--text-lg);margin:var(--space-5) 0 var(--space-2)} | ||
.prose p,.prose ul,.prose ol,.prose table,.prose pre,.prose blockquote{margin:0 0 var(--space-4)} | ||
.prose li:has(>input[type=checkbox]:first-child){list-style:none}.prose li>input[type=checkbox]:first-child{margin:0 var(--space-1-5) 0 calc(-1 * var(--space-5))} | ||
.prose li:has(>input[type=checkbox]:first-child,>p:first-child>input[type=checkbox]:first-child){list-style:none}.prose li>input[type=checkbox]:first-child,.prose li>p:first-child>input[type=checkbox]:first-child{margin:0 var(--space-1-5) 0 calc(-1 * var(--space-5))} | ||
.prose input[type=checkbox]:checked~.task-text{color:var(--mute);text-decoration:line-through}.prose.tickable input[type=checkbox],.prose.tickable .task-text{cursor:pointer} | ||
.prose a{color:var(--fg);text-decoration:underline;text-decoration-color:var(--link-line);text-underline-offset:var(--underline-offset)} | ||
.prose a:hover{text-decoration:underline} | ||
.prose img{max-width:100%;height:auto} | ||