web: task lines render as checkboxes where they sit in the note
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 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 4s

M304 step 5. The server already returns a derived `items` array, so the web kept
working across the last two commits — but it was rendering every list TWICE: once
as literal `- [ ] milk` bullets inside the body, and again as the separate
NoteChecklist block underneath. This is the commit that makes the body the only
place a checklist appears.

markdown.ts gains a `task` block, matched BEFORE the plain bullet — which would
otherwise swallow the marker and leave the brackets showing, the same ordering
reason `code` is matched before emphasis in INLINE_RE. Each item carries its
ordinal across the WHOLE document, because that is what an item's id means
everywhere else now; counting per block would have made the second list's
checkboxes toggle the first list's items.

The card's preview clamp is why that ordinal is safe there: it only ever drops
lines from the end, so a visible item's index is the same whether or not the body
was truncated.

MarkdownText emits a toggle rather than reaching for the store. Ticking a box
rewrites a line of someone's note, and a renderer used in several places should not
be the thing deciding that is allowed — the card passes `toggleable` and wires it,
a read-only render does not and the boxes are inert. Not wrapped in a <label>
either: on a card the text is the note's own words and clicking it opens the note,
so only the box toggles.

In the editor, the toolbar button stops revealing a section and inserts `- [ ] ` at
the caret. That makes it the one toolbar action needing no persisted note to hang
anything off — ensureDraft is gone from it, and it works on an empty compose box
the moment it opens. Enter on a task line continues the list, and on an EMPTY one
clears the marker; without that second half a list would be impossible to get out
of. Indent and bullet are carried over rather than normalised, because continuing
someone's `*` list with a `-` is an edit they did not ask for.

NoteChecklist.vue is deleted (rule 22). The store's item methods stay: they are the
repository seam the REST routes and Tauri commands both implement, not the old path.

CI cannot check any of this beyond types — there are no frontend tests, only
vue-tsc. It wants a real browser pass.
This commit is contained in:
2026-08-24 08:10:20 -04:00
parent fe1f72ae1b
commit 3cab054684
5 changed files with 144 additions and 114 deletions
+57 -28
View File
@@ -5,7 +5,6 @@ import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue";
import NoteChecklist from "./NoteChecklist.vue";
import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
@@ -31,7 +30,6 @@ const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns
// on when the note already carries items, and when someone asks for one.
const checklistOpen = ref(false);
const saving = ref(false);
const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null);
@@ -75,15 +73,6 @@ const liveNote = computed<Note>(() =>
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: draftNote.value,
);
// The checklist renders once the note has items, or once someone has asked for one.
// It sits BELOW the body rather than instead of it — a note can carry both, which is
// the whole point of the merge.
//
// Items need a persisted note to hang off, so this is a rich action like attaching a
// file: in compose it waits for the draft to be saved.
const showChecklist = computed(
() => !isCreate.value && (liveNote.value.items.length > 0 || checklistOpen.value),
);
const bodyPlaceholder = "Take a note…";
// Keep local state in sync when the edited note changes (modal reused for another note).
@@ -145,7 +134,6 @@ function resetCompose(): void {
body.value = "";
color.value = "default";
labelList.value = [];
checklistOpen.value = false;
baseline.value = { body: "", color: "default" };
uploadError.value = "";
}
@@ -233,6 +221,10 @@ onMounted(async () => {
if (el) el.selectionStart = el.selectionEnd = el.value.length;
});
// The same grammar as notes/markdown.ts, narrowed to one line so the pieces can be
// put back. See that file for why three implementations of it exist.
const TASK_LINE_RE = /^(\s*)([-*]) +\[([ xX])\](?: +(.*))?$/;
function onBodyKeydown(e: KeyboardEvent) {
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
@@ -240,6 +232,36 @@ function onBodyKeydown(e: KeyboardEvent) {
void commitAndContinue();
return;
}
// Enter on a task line starts the next one; on an EMPTY task line it clears the
// marker instead. Both halves are needed — without the second, a list would be
// impossible to get out of without deleting characters by hand.
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
const el = e.target as HTMLTextAreaElement;
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;
e.preventDefault();
if (!(match[4] ?? "").trim()) {
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.
const marker = `${match[1]}${match[2]} [ ] `;
body.value = `${before}\n${marker}${rest}`;
const caret = start + 1 + marker.length;
void nextTick(() => el.setSelectionRange(caret, caret));
}
// ---- reminder ----
@@ -285,14 +307,27 @@ function labelChip(c: string): string {
// ---- add a checklist ----
//
// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its
// body and gains a place to put items. Persists the draft first for the same reason
// attaching a file does — an item needs a note to belong to.
// Inserts `- [ ] ` at the caret. That is the whole action now: a checklist is lines of
// the body (M304), so unlike every other toolbar button this one needs NO persisted
// note to hang anything off — `ensureDraft` is gone from it, and it works on an empty
// compose box the moment it opens.
async function addChecklist() {
if (checklistOpen.value) return;
const id = await ensureDraft();
if (!id) return;
checklistOpen.value = true;
const el = bodyInput.value;
const marker = "- [ ] ";
if (!el) {
body.value = body.value ? `${body.value.replace(/\n+$/, "")}\n${marker}` : marker;
return;
}
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 ----
@@ -482,14 +517,8 @@ function revPreview(rev: NoteRevision): string {
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@keydown="onBodyKeydown"
/>
<!-- Below the body, not instead of it. -->
<NoteChecklist
v-if="showChecklist"
class="py-1"
:note-id="liveNote.id"
:items="liveNote.items"
editable
/>
<!-- No checklist component. The items are lines of the textarea above, which
is what lets a list sit between two paragraphs (M304). -->
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span
@@ -598,7 +627,7 @@ function revPreview(rev: NoteRevision): string {
</button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button
v-if="richEnabled && !liveNote.trashed && !showChecklist"
v-if="!liveNote.trashed"
type="button"
class="icon-btn"
title="Add a checklist"