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
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:
@@ -3,7 +3,12 @@ import { computed } from "vue";
|
||||
import { parseMarkdown } from "../notes/markdown";
|
||||
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));
|
||||
</script>
|
||||
|
||||
@@ -19,6 +24,28 @@ const blocks = computed(() => parseMarkdown(props.text));
|
||||
>
|
||||
<MarkdownInline :tokens="b.inline ?? []" />
|
||||
</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">
|
||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
||||
</ul>
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { Note } from "../stores/notes";
|
||||
import Icon from "./Icon.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import MarkdownText from "./MarkdownText.vue";
|
||||
import NoteChecklist from "./NoteChecklist.vue";
|
||||
import {
|
||||
cardIdAt,
|
||||
draggingId,
|
||||
@@ -94,6 +93,15 @@ const bodyPreview = computed(() => {
|
||||
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);
|
||||
|
||||
// --- 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. -->
|
||||
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
|
||||
<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>
|
||||
<p
|
||||
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 />
|
||||
</div>
|
||||
|
||||
<NoteChecklist
|
||||
v-if="note.items.length"
|
||||
:class="note.body ? 'mt-2' : ''"
|
||||
:note-id="note.id"
|
||||
:items="note.items"
|
||||
@click="emit('open', note)"
|
||||
/>
|
||||
<!-- No separate checklist block any more. A checklist is lines of the body (M304),
|
||||
so MarkdownText above draws it in place — which is what lets a list sit between
|
||||
two paragraphs instead of always after them. Rendering both would have shown
|
||||
every list twice. -->
|
||||
|
||||
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -13,10 +13,22 @@ export interface InlineToken {
|
||||
|
||||
// Flat (non-discriminated) shape on purpose — keeps template type-checking simple.
|
||||
export interface Block {
|
||||
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre";
|
||||
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre" | "task";
|
||||
inline?: InlineToken[];
|
||||
items?: InlineToken[][];
|
||||
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;
|
||||
@@ -44,11 +56,24 @@ export function parseInline(text: string): InlineToken[] {
|
||||
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[] {
|
||||
const lines = (text ?? "").split("\n");
|
||||
const blocks: Block[] = [];
|
||||
let paragraph: string[] = [];
|
||||
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 = () => {
|
||||
if (paragraph.length) {
|
||||
@@ -96,6 +121,25 @@ export function parseMarkdown(text: string): Block[] {
|
||||
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.
|
||||
if (/^\s*[-*+]\s+/.test(line)) {
|
||||
flushPara();
|
||||
|
||||
Reference in New Issue
Block a user