web: the editor draws the checklist too
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Successful in 38s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m34s
Desktop (Tauri) / Update manifest (push) Successful in 3s

2992's other half. The browser was the last surface still showing `- [ ] ` as
markup: cards rendered and ticked checkboxes, the editor did not.

Same shape as Android, deliberately. notes/blocks.ts mirrors EditorBlock.kt —
splitBlocks, joinBlocks, afterEnter, withoutIndex, plusTask — because the two
editors should behave alike and the cheapest way to keep them that way is for the
code to read alike. `body` becomes a computed over the blocks, so every save,
baseline check and draft still reads the one markdown string they always did.

markdown.ts now exports parseTaskLine and renderTaskLine, and parseMarkdown uses
the former. The read view and the editor's block split had been matching the same
grammar through two separate copies of one regex; now they agree by construction.

Two places the web can do better than Compose, and does:

  * Backspace at the start of an empty item removes it. A browser sends a real
    keydown for Backspace; an Android soft keyboard sends an IME delete that never
    surfaces as one, which is why that surface only has Enter-on-empty.
  * Prose fields size to their text — rows="1" plus a scrollHeight fit, which beats
    guessing a row count that is wrong the moment a line wraps.

KNOWN, and the same on both surfaces: typing `- [ ] ` by hand into a prose block
leaves it prose until the note is reopened. Blocks are split when the editor loads,
not re-derived per keystroke — re-splitting mid-type would move the caret. The
toolbar button is the intended path. Converting on blur would fix it and is worth
doing to BOTH editors at once rather than letting them drift.
This commit is contained in:
2026-08-26 07:53:13 -04:00
parent 44b3bcb2b2
commit 96a6f6e691
3 changed files with 291 additions and 77 deletions
+153 -70
View File
@@ -10,6 +10,14 @@ import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion"; import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes"; import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors"; import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
import {
afterEnter,
type EditorBlock,
joinBlocks,
plusTask,
splitBlocks,
withoutIndex,
} from "../notes/blocks";
// One modal editor for BOTH composing and editing — a single surface (the board's // One modal editor for BOTH composing and editing — a single surface (the board's
// "Take a note…" bar just opens this in compose mode). `note` = the note being edited, // "Take a note…" bar just opens this in compose mode). `note` = the note being edited,
@@ -24,7 +32,14 @@ const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void
const notes = useNotesStore(); const notes = useNotesStore();
const noteId = ref<string | null>(props.note?.id ?? null); const noteId = ref<string | null>(props.note?.id ?? null);
const body = ref(props.note?.body ?? props.initialBody); // BLOCKS rather than one string, because a checklist item is drawn as a real checkbox
// and a widget cannot live inside a <textarea>. The note is still one markdown body —
// see notes/blocks.ts — and `body` is what every save, baseline and draft still reads.
const blocks = ref<EditorBlock[]>(splitBlocks(props.note?.body ?? props.initialBody));
const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
const color = ref<NoteColor>(props.note?.color ?? "default"); const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []); const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2) // Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
@@ -32,7 +47,34 @@ const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// on when the note already carries items, and when someone asks for one. // on when the note already carries items, and when someone asks for one.
const saving = ref(false); const saving = ref(false);
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null); // One element per block, keyed by the block's id — the only thing about a block that
// survives one being inserted above it. Focus is asked for by id and honoured after
// the render that created the field, since an element that does not exist yet cannot
// take it.
const blockEls = new Map<number, HTMLTextAreaElement | HTMLInputElement>();
function setBlockEl(id: number, el: unknown): void {
if (el) blockEls.set(id, el as HTMLTextAreaElement);
else blockEls.delete(id);
}
async function focusBlock(id: number | null): Promise<void> {
if (id === null) return;
await nextTick();
const el = blockEls.get(id);
el?.focus();
if (el) el.selectionStart = el.selectionEnd = el.value.length;
}
/** A prose field sized to its text. `rows="1"` plus this beats guessing a row count,
* which is wrong the moment a line wraps. */
function grow(el: HTMLTextAreaElement): void {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
function growAll(): void {
for (const el of blockEls.values()) {
if (el instanceof HTMLTextAreaElement) grow(el);
}
}
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref(""); const uploadError = ref("");
@@ -80,7 +122,7 @@ watch(
() => props.note, () => props.note,
(n) => { (n) => {
noteId.value = n?.id ?? null; noteId.value = n?.id ?? null;
body.value = n?.body ?? ""; setBody(n?.body ?? "");
color.value = (n?.color ?? "default") as NoteColor; color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : []; labelList.value = n ? [...n.labels] : [];
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor }; baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
@@ -131,7 +173,7 @@ async function flush(): Promise<void> {
function resetCompose(): void { function resetCompose(): void {
noteId.value = null; noteId.value = null;
body.value = ""; setBody("");
color.value = "default"; color.value = "default";
labelList.value = []; labelList.value = [];
baseline.value = { body: "", color: "default" }; baseline.value = { body: "", color: "default" };
@@ -145,7 +187,9 @@ async function commitAndContinue(): Promise<void> {
await flush(); await flush();
resetCompose(); resetCompose();
await nextTick(); await nextTick();
bodyInput.value?.focus(); growAll();
const first = blocks.value[0];
await focusBlock(first ? first.id : null);
} }
// ---- open/close animation (M7) ---- // ---- open/close animation (M7) ----
// //
@@ -215,53 +259,66 @@ function onBackdropMousedown(): void {
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
const el = bodyInput.value; growAll();
el?.focus(); // The LAST block, with the caret after its text: opening a note means continuing it,
// Put the caret after any seeded text (type-to-compose) so typing continues cleanly. // and type-to-compose seeds text that should be typed straight on from.
if (el) el.selectionStart = el.selectionEnd = el.value.length; const last = blocks.value[blocks.value.length - 1];
await focusBlock(last ? last.id : null);
}); });
// The same grammar as notes/markdown.ts, narrowed to one line so the pieces can be /** Editing one block: replace its text, leave every other block alone. */
// put back. See that file for why three implementations of it exist. function setText(index: number, text: string): void {
const TASK_LINE_RE = /^(\s*)([-*]) +\[([ xX])\](?: +(.*))?$/; const out = [...blocks.value];
out[index] = { ...out[index], text };
blocks.value = out;
}
function onBodyKeydown(e: KeyboardEvent) { function setChecked(index: number, checked: boolean): void {
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). const out = [...blocks.value];
out[index] = { ...out[index], checked };
blocks.value = out;
}
function onProseInput(index: number, e: Event): void {
const el = e.target as HTMLTextAreaElement;
setText(index, el.value);
grow(el);
}
/** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */
function onProseKeydown(e: KeyboardEvent): void {
if (isCreate.value && e.key === "Enter" && e.shiftKey) { if (isCreate.value && e.key === "Enter" && e.shiftKey) {
e.preventDefault(); e.preventDefault();
void commitAndContinue(); void commitAndContinue();
return; }
} }
// Enter on a task line starts the next one; on an EMPTY task line it clears the /** Enter on an item makes the next one; on an EMPTY item it ends the list. */
// marker instead. Both halves are needed — without the second, a list would be function onTaskEnter(index: number): void {
// impossible to get out of without deleting characters by hand. const next = afterEnter(blocks.value, index);
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; blocks.value = next.blocks;
const el = e.target as HTMLTextAreaElement; void focusBlock(next.focus);
const start = el.selectionStart ?? 0; }
// A selection means Enter is replacing something; let the browser do it.
if (start !== el.selectionEnd) return;
const before = body.value.slice(0, start);
const rest = body.value.slice(start);
// Only at the END of a line. Mid-line, Enter splits the line, which is what anyone
// pressing it there meant.
if (rest !== "" && !rest.startsWith("\n")) return;
const lineStart = before.lastIndexOf("\n") + 1;
const match = TASK_LINE_RE.exec(before.slice(lineStart));
if (!match) return;
/**
* Backspace at the very start of an EMPTY item removes it.
*
* Worth having on the web where Android's is not: a browser sends a real keydown for
* Backspace, while an Android soft keyboard sends an IME delete that never surfaces as
* one. Enter-on-empty ends a list on both surfaces; this is the extra way out that
* only one of them can offer.
*/
function onTaskBackspace(index: number, e: KeyboardEvent): void {
const el = e.target as HTMLInputElement;
if (el.value !== "" || el.selectionStart !== 0) return;
e.preventDefault(); e.preventDefault();
if (!(match[4] ?? "").trim()) { removeBlock(index);
body.value = body.value.slice(0, lineStart) + rest;
void nextTick(() => el.setSelectionRange(lineStart, lineStart));
return;
} }
// The bullet and indent are carried over, not normalised: continuing someone's
// `*` list with a `-` would be an edit they did not ask for. function removeBlock(index: number): void {
const marker = `${match[1]}${match[2]} [ ] `; const next = withoutIndex(blocks.value, index);
body.value = `${before}\n${marker}${rest}`; blocks.value = next.blocks;
const caret = start + 1 + marker.length; void focusBlock(next.focus);
void nextTick(() => el.setSelectionRange(caret, caret));
} }
// ---- reminder ---- // ---- reminder ----
@@ -307,27 +364,17 @@ function labelChip(c: string): string {
// ---- add a checklist ---- // ---- add a checklist ----
// //
// Inserts `- [ ] ` at the caret. That is the whole action now: a checklist is lines of // Appends an empty item and puts the caret in it. Unlike every other toolbar button
// the body (M304), so unlike every other toolbar button this one needs NO persisted // this one needs NO persisted note to hang anything off — a checklist is part of the
// note to hang anything off — `ensureDraft` is gone from it, and it works on an empty // body (M304), so it works on an empty compose box the moment it opens.
// compose box the moment it opens. //
async function addChecklist() { // Appends rather than inserting at the caret because a block editor has no single
const el = bodyInput.value; // caret to insert at: the field that had focus may not be the one being looked at by
const marker = "- [ ] "; // the time this runs.
if (!el) { function addChecklist(): void {
body.value = body.value ? `${body.value.replace(/\n+$/, "")}\n${marker}` : marker; const next = plusTask(blocks.value);
return; blocks.value = next.blocks;
} void focusBlock(next.focus);
const start = el.selectionStart ?? body.value.length;
const before = body.value.slice(0, start);
// Start a new line unless the caret already sits at the beginning of one — a marker
// in the middle of a sentence is not a list item, it is a typo.
const prefix = before === "" || before.endsWith("\n") ? "" : "\n";
body.value = `${before}${prefix}${marker}${body.value.slice(start)}`;
const caret = start + prefix.length + marker.length;
await nextTick();
el.focus();
el.setSelectionRange(caret, caret);
} }
// ---- attachments ---- // ---- attachments ----
@@ -406,7 +453,7 @@ async function restoreRevisionAt(revId: string) {
const id = noteId.value; const id = noteId.value;
if (!id) return; if (!id) return;
const updated = await notes.restoreRevision(id, revId); const updated = await notes.restoreRevision(id, revId);
body.value = updated.body; setBody(updated.body);
color.value = updated.color; color.value = updated.color;
baseline.value = { body: updated.body, color: updated.color }; baseline.value = { body: updated.body, color: updated.color };
void loadRevisions(); // the pre-restore state became a new revision void loadRevisions(); // the pre-restore state became a new revision
@@ -509,14 +556,50 @@ function revPreview(rev: NoteRevision): string {
/> />
</div> </div>
<textarea <!-- The body, as fields and checkboxes rather than as markup. A checklist
ref="bodyInput" item is a real input; a run of prose is one textarea, so typing a
v-model="body" paragraph still feels like typing a paragraph. -->
rows="8" <div class="flex flex-col gap-1">
:placeholder="bodyPlaceholder" <template v-for="(block, i) in blocks" :key="block.id">
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400" <div v-if="block.checked !== null" class="group/item flex items-center gap-2">
@keydown="onBodyKeydown" <input
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="block.checked"
:aria-label="block.text || 'Checklist item'"
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
/> />
<input
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
type="text"
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
:class="block.checked ? 'text-neutral-400 line-through' : ''"
@input="setText(i, ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="onTaskEnter(i)"
@keydown.backspace="onTaskBackspace(i, $event)"
/>
<button
type="button"
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="removeBlock(i)"
>
×
</button>
</div>
<textarea
v-else
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
rows="1"
:placeholder="i === 0 ? bodyPlaceholder : ''"
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onProseInput(i, $event)"
@keydown="onProseKeydown"
/>
</template>
</div>
<!-- No checklist component. The items are lines of the textarea above, which <!-- No checklist component. The items are lines of the textarea above, which
is what lets a list sit between two paragraphs (M304). --> is what lets a list sit between two paragraphs (M304). -->
+110
View File
@@ -0,0 +1,110 @@
// The editor's shape for a note body: a list of blocks rather than one string.
//
// The note is still one markdown body underneath (M304) — this is a rendering and
// input shape, and nothing below the editor can tell it exists. `joinBlocks` puts the
// string back together on every edit, and for a body already in canonical form it
// returns exactly what `splitBlocks` was handed.
//
// Why blocks at all: a checklist item has to be a real `<input type="checkbox">`, and
// a widget cannot live inside a `<textarea>`. Only a `contenteditable` could hold one,
// and that is a different editor with a different set of problems.
//
// A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
// like typing a paragraph, and a separate field under every sentence would break the
// caret mid-sentence. Only a checklist item earns a block, because only a checklist
// item needs a widget.
//
// The mirror of android/.../ui/EditorBlock.kt, deliberately: the two editors should
// behave the same, and the cheapest way to keep them that way is for the shapes to
// read alike.
import { parseTaskLine, renderTaskLine } from "./markdown";
export interface EditorBlock {
/** Stable across edits, so Vue keeps a field's caret when a block is inserted above
* it. Content cannot serve as the key — two empty items are identical and neither
* is the other. */
id: number;
text: string;
/** null for prose; ticked-or-not for a checklist item. */
checked: boolean | null;
}
/** Split a body into blocks, numbering them from `firstId`. */
export function splitBlocks(body: string, firstId = 0): EditorBlock[] {
const out: EditorBlock[] = [];
const prose: string[] = [];
let id = firstId;
const flushProse = () => {
if (prose.length) {
out.push({ id: id++, text: prose.join("\n"), checked: null });
prose.length = 0;
}
};
for (const line of (body ?? "").split("\n")) {
const task = parseTaskLine(line);
if (task) {
flushProse();
out.push({ id: id++, text: task.text, checked: task.checked });
} else {
prose.push(line);
}
}
flushProse();
// Never empty: an empty note still needs one field to type into.
return out.length ? out : [{ id, text: "", checked: null }];
}
/** The body those blocks stand for. */
export function joinBlocks(blocks: EditorBlock[]): string {
return blocks.map((b) => (b.checked === null ? b.text : renderTaskLine(b.text, b.checked))).join("\n");
}
/** An id nothing else is using. Monotonic within a session, which is all it has to be. */
export function nextId(blocks: EditorBlock[]): number {
return blocks.reduce((max, b) => Math.max(max, b.id), -1) + 1;
}
/**
* What Enter does on a checklist item, and which block should hold the caret after.
*
* On an item with words in it, a new empty item below. On an EMPTY one, the item
* becomes prose — which is how a list ENDS, and the only way to get a paragraph after
* one. Without that half a list is impossible to get out of.
*
* Appends rather than splitting at the caret: splitting an item in two is a rarity,
* and the caret is at the end for every ordinary use of that key.
*/
export function afterEnter(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number } {
const block = blocks[index];
const out = [...blocks];
if (!block.text.trim()) {
out[index] = { ...block, text: "", checked: null };
return { blocks: out, focus: block.id };
}
const id = nextId(blocks);
out.splice(index + 1, 0, { id, text: "", checked: false });
return { blocks: out, focus: id };
}
/**
* Drop a block, leaving at least one field to type into.
*
* Focus goes to the block above — or, when the first one was removed, to whichever
* takes its place. `index - 1` alone is -1 there, which would leave nothing focused.
*/
export function withoutIndex(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number | null } {
const kept = blocks.filter((_, i) => i !== index);
const fallback: EditorBlock[] = [{ id: nextId(blocks), text: "", checked: null }];
const remaining = kept.length ? kept : fallback;
return { blocks: remaining, focus: remaining[Math.max(0, index - 1)]?.id ?? null };
}
/** One more empty checklist item at the end, and the id to put the caret in. */
export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus: number } {
const id = nextId(blocks);
return { blocks: [...blocks, { id, text: "", checked: false }], focus: id };
}
+26 -5
View File
@@ -66,6 +66,27 @@ export function parseInline(text: string): InlineToken[] {
// which is at least consistent. // which is at least consistent.
const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/; const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/;
export interface TaskLine {
checked: boolean;
text: string;
}
/** One task line's parts, or null when the line is prose.
*
* Exported so the EDITOR's block split (notes/blocks.ts) and this read-view parser
* agree by construction rather than by comment. One grammar, one matcher. */
export function parseTaskLine(line: string): TaskLine | null {
const m = TASK_RE.exec(line);
return m ? { checked: m[1] !== " ", text: m[2] ?? "" } : null;
}
/** One item as the body line that stores it, in canonical form — `- [x] `, lowercase,
* and no trailing space when the item is empty so a round trip does not grow it. */
export function renderTaskLine(text: string, checked: boolean): string {
const mark = checked ? "x" : " ";
return text ? `- [${mark}] ${text}` : `- [${mark}]`;
}
export function parseMarkdown(text: string): Block[] { export function parseMarkdown(text: string): Block[] {
const lines = (text ?? "").split("\n"); const lines = (text ?? "").split("\n");
const blocks: Block[] = []; const blocks: Block[] = [];
@@ -124,15 +145,15 @@ export function parseMarkdown(text: string): Block[] {
// Task list, BEFORE the plain bullet below — which would otherwise swallow // Task list, BEFORE the plain bullet below — which would otherwise swallow
// `- [ ] x` as an ordinary list item and leave the brackets showing. Same // `- [ ] x` as an ordinary list item and leave the brackets showing. Same
// ordering reason as `code` being matched before emphasis in INLINE_RE. // ordering reason as `code` being matched before emphasis in INLINE_RE.
if (TASK_RE.test(line)) { if (parseTaskLine(line)) {
flushPara(); flushPara();
const items: InlineToken[][] = []; const items: InlineToken[][] = [];
const tasks: TaskMeta[] = []; const tasks: TaskMeta[] = [];
while (i < lines.length) { while (i < lines.length) {
const m = TASK_RE.exec(lines[i]); const task = parseTaskLine(lines[i]);
if (!m) break; if (!task) break;
items.push(parseInline(m[2] ?? "")); items.push(parseInline(task.text));
tasks.push({ index: taskIndex, checked: m[1] !== " " }); tasks.push({ index: taskIndex, checked: task.checked });
taskIndex++; taskIndex++;
i++; i++;
} }