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
+28 -1
View File
@@ -3,7 +3,12 @@ import { computed } from "vue";
import { parseMarkdown } from "../notes/markdown"; import { parseMarkdown } from "../notes/markdown";
import MarkdownInline from "./MarkdownInline.vue"; import MarkdownInline from "./MarkdownInline.vue";
const props = defineProps<{ text: string }>(); const props = defineProps<{ text: string; toggleable?: boolean }>();
// Ticking a box rewrites a line of the note's body, which is a thing only the owner
// of that note can do — so this renders the checkbox and hands the intent up rather
// than reaching for the store itself. The card wires it; a read-only render does not
// pass `toggleable` and the boxes are inert.
const emit = defineEmits<{ toggle: [index: number, checked: boolean] }>();
const blocks = computed(() => parseMarkdown(props.text)); const blocks = computed(() => parseMarkdown(props.text));
</script> </script>
@@ -19,6 +24,28 @@ const blocks = computed(() => parseMarkdown(props.text));
> >
<MarkdownInline :tokens="b.inline ?? []" /> <MarkdownInline :tokens="b.inline ?? []" />
</blockquote> </blockquote>
<div v-else-if="b.type === 'task'" class="flex flex-col gap-1">
<div v-for="(it, j) in b.items ?? []" :key="j" class="flex items-start gap-2">
<!-- Not wrapped in a <label>: on a card the text is the note's own words and
clicking it opens the note, so only the box itself toggles. `.stop` for
the same reason — the card is a click target underneath. -->
<input
type="checkbox"
class="mt-0.5 h-4 w-4 shrink-0 accent-brand"
:checked="b.tasks?.[j]?.checked ?? false"
:disabled="!toggleable"
:aria-label="toggleable ? 'Toggle item' : undefined"
@click.stop
@change="emit('toggle', b.tasks?.[j]?.index ?? 0, ($event.target as HTMLInputElement).checked)"
/>
<span
class="min-w-0 flex-1"
:class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''"
>
<MarkdownInline :tokens="it" />
</span>
</div>
</div>
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5"> <ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li> <li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
</ul> </ul>
+14 -9
View File
@@ -13,7 +13,6 @@ import type { Note } from "../stores/notes";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue"; import LinkPreview from "./LinkPreview.vue";
import MarkdownText from "./MarkdownText.vue"; import MarkdownText from "./MarkdownText.vue";
import NoteChecklist from "./NoteChecklist.vue";
import { import {
cardIdAt, cardIdAt,
draggingId, draggingId,
@@ -94,6 +93,15 @@ const bodyPreview = computed(() => {
return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…"; return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…";
}); });
/** Tick a box without opening the note — the common gesture on a board.
*
* The index is the item's ordinal in the WHOLE body, which survives the preview
* clamp above because that only ever drops lines from the end. `updateItem` takes
* it as the item id, which is exactly what an id is now (M304). */
function toggleTask(index: number, checked: boolean) {
void notes.updateItem(props.note.id, String(index), { checked });
}
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a // --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
@@ -263,7 +271,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
blank and the link is never unreachable. --> blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" /> <LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300"> <div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" /> <MarkdownText :text="bodyPreview" toggleable @toggle="toggleTask" />
</div> </div>
<p <p
v-if="!note.body && !note.items.length && !note.attachments.length" v-if="!note.body && !note.items.length && !note.attachments.length"
@@ -279,13 +287,10 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact /> <LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
</div> </div>
<NoteChecklist <!-- No separate checklist block any more. A checklist is lines of the body (M304),
v-if="note.items.length" so MarkdownText above draws it in place — which is what lets a list sit between
:class="note.body ? 'mt-2' : ''" two paragraphs instead of always after them. Rendering both would have shown
:note-id="note.id" every list twice. -->
:items="note.items"
@click="emit('open', note)"
/>
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1"> <div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
<span <span
-75
View File
@@ -1,75 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { useNotesStore, type ChecklistItem } from "../stores/notes";
const props = defineProps<{ noteId: string; items: ChecklistItem[]; editable?: boolean }>();
const notes = useNotesStore();
const newItem = ref("");
async function addItem() {
const text = newItem.value.trim();
if (!text) return;
await notes.addItem(props.noteId, text);
newItem.value = "";
}
function toggle(item: ChecklistItem) {
void notes.updateItem(props.noteId, item.id, { checked: !item.checked });
}
function editText(item: ChecklistItem, value: string) {
if (value !== item.text) void notes.updateItem(props.noteId, item.id, { text: value });
}
function remove(item: ChecklistItem) {
void notes.deleteItem(props.noteId, item.id);
}
</script>
<template>
<div class="flex flex-col gap-1">
<div v-for="item in items" :key="item.id" class="group/item flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="item.checked"
@change="toggle(item)"
@click.stop
/>
<input
v-if="editable"
:value="item.text"
class="min-w-0 flex-1 bg-transparent text-sm outline-none"
:class="item.checked ? 'text-neutral-400 line-through' : ''"
@change="editText(item, ($event.target as HTMLInputElement).value)"
/>
<span
v-else
class="min-w-0 flex-1 truncate text-sm"
:class="item.checked ? 'text-neutral-400 line-through' : 'text-neutral-700 dark:text-neutral-300'"
>{{ item.text }}</span
>
<button
v-if="editable"
type="button"
class="hover-reveal text-neutral-300 opacity-0 hover:text-neutral-600 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="remove(item)"
>
×
</button>
</div>
<form v-if="editable" class="mt-1 flex items-center gap-2" @submit.prevent="addItem">
<span class="h-4 w-4 shrink-0" />
<input
v-model="newItem"
type="text"
placeholder="+ List item"
class="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-neutral-400"
/>
</form>
<p v-if="!editable && items.length === 0" class="text-sm italic text-neutral-400">Empty checklist</p>
</div>
</template>
+57 -28
View File
@@ -5,7 +5,6 @@ import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue"; import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue"; import LinkPreview from "./LinkPreview.vue";
import NoteChecklist from "./NoteChecklist.vue";
import { fromLocalInput, toLocalInput } from "../notes/datetime"; import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph"; import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion"; 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) // 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 // 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. // on when the note already carries items, and when someone asks for one.
const checklistOpen = ref(false);
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); 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) ? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: 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…"; const bodyPlaceholder = "Take a note…";
// Keep local state in sync when the edited note changes (modal reused for another 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 = ""; body.value = "";
color.value = "default"; color.value = "default";
labelList.value = []; labelList.value = [];
checklistOpen.value = false;
baseline.value = { body: "", color: "default" }; baseline.value = { body: "", color: "default" };
uploadError.value = ""; uploadError.value = "";
} }
@@ -233,6 +221,10 @@ onMounted(async () => {
if (el) el.selectionStart = el.selectionEnd = el.value.length; 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) { function onBodyKeydown(e: KeyboardEvent) {
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). // Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
if (isCreate.value && e.key === "Enter" && e.shiftKey) { if (isCreate.value && e.key === "Enter" && e.shiftKey) {
@@ -240,6 +232,36 @@ function onBodyKeydown(e: KeyboardEvent) {
void commitAndContinue(); void commitAndContinue();
return; 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 ---- // ---- reminder ----
@@ -285,14 +307,27 @@ function labelChip(c: string): string {
// ---- add a checklist ---- // ---- add a checklist ----
// //
// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its // Inserts `- [ ] ` at the caret. That is the whole action now: a checklist is lines of
// body and gains a place to put items. Persists the draft first for the same reason // the body (M304), so unlike every other toolbar button this one needs NO persisted
// attaching a file does — an item needs a note to belong to. // 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() { async function addChecklist() {
if (checklistOpen.value) return; const el = bodyInput.value;
const id = await ensureDraft(); const marker = "- [ ] ";
if (!id) return; if (!el) {
checklistOpen.value = true; 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 ---- // ---- 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" class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@keydown="onBodyKeydown" @keydown="onBodyKeydown"
/> />
<!-- Below the body, not instead of it. --> <!-- No checklist component. The items are lines of the textarea above, which
<NoteChecklist is what lets a list sit between two paragraphs (M304). -->
v-if="showChecklist"
class="py-1"
:note-id="liveNote.id"
:items="liveNote.items"
editable
/>
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1"> <div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span <span
@@ -598,7 +627,7 @@ function revPreview(rev: NoteRevision): string {
</button> </button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" /> <input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button <button
v-if="richEnabled && !liveNote.trashed && !showChecklist" v-if="!liveNote.trashed"
type="button" type="button"
class="icon-btn" class="icon-btn"
title="Add a checklist" title="Add a checklist"
+45 -1
View File
@@ -13,10 +13,22 @@ export interface InlineToken {
// Flat (non-discriminated) shape on purpose — keeps template type-checking simple. // Flat (non-discriminated) shape on purpose — keeps template type-checking simple.
export interface Block { export interface Block {
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre"; type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre" | "task";
inline?: InlineToken[]; inline?: InlineToken[];
items?: InlineToken[][]; items?: InlineToken[][];
value?: string; value?: string;
/** `task` only: one entry per `items` entry, parallel by position. */
tasks?: TaskMeta[];
}
export interface TaskMeta {
/** This item's ordinal among ALL task lines in the body, in document order.
* That is the id the server and the native clients address an item by, so a
* checkbox can be toggled straight from it. Counted across blocks, not within
* one, and unaffected by the card truncating the body — the card only ever
* drops lines from the END. */
index: number;
checked: boolean;
} }
// Order matters: code is matched before emphasis so its contents aren't re-parsed; // Order matters: code is matched before emphasis so its contents aren't re-parsed;
@@ -44,11 +56,24 @@ export function parseInline(text: string): InlineToken[] {
return tokens; return tokens;
} }
// A checklist item: the third implementation of one grammar, alongside
// core/src/local/derive.rs and src/thoughtsync/notes/checklist.py. A difference
// between any two of them is a checklist that changes shape when it syncs (M304).
//
// `-` and `*` only, deliberately, even though the `ul` matcher below also takes `+`.
// The other two implementations do not take `+`, and one grammar in three places has
// to be one grammar; a `+ [ ] x` line renders as an ordinary bullet everywhere,
// which is at least consistent.
const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/;
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[] = [];
let paragraph: string[] = []; let paragraph: string[] = [];
let i = 0; let i = 0;
// Runs across the whole document, not per block, because that is what the item's
// id means everywhere else.
let taskIndex = 0;
const flushPara = () => { const flushPara = () => {
if (paragraph.length) { if (paragraph.length) {
@@ -96,6 +121,25 @@ export function parseMarkdown(text: string): Block[] {
continue; continue;
} }
// Task list, BEFORE the plain bullet below — which would otherwise swallow
// `- [ ] x` as an ordinary list item and leave the brackets showing. Same
// ordering reason as `code` being matched before emphasis in INLINE_RE.
if (TASK_RE.test(line)) {
flushPara();
const items: InlineToken[][] = [];
const tasks: TaskMeta[] = [];
while (i < lines.length) {
const m = TASK_RE.exec(lines[i]);
if (!m) break;
items.push(parseInline(m[2] ?? ""));
tasks.push({ index: taskIndex, checked: m[1] !== " " });
taskIndex++;
i++;
}
blocks.push({ type: "task", items, tasks });
continue;
}
// Unordered list: -, *, or + then a space. // Unordered list: -, *, or + then a space.
if (/^\s*[-*+]\s+/.test(line)) { if (/^\s*[-*+]\s+/.test(line)) {
flushPara(); flushPara();