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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user