diff --git a/frontend/src/components/MarkdownText.vue b/frontend/src/components/MarkdownText.vue
index 30719fe..8d56158 100644
--- a/frontend/src/components/MarkdownText.vue
+++ b/frontend/src/components/MarkdownText.vue
@@ -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));
@@ -19,6 +24,28 @@ const blocks = computed(() => parseMarkdown(props.text));
>
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue
index b6ef1bf..58fff4b 100644
--- a/frontend/src/components/NoteCard.vue
+++ b/frontend/src/components/NoteCard.vue
@@ -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(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. -->
-
diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue
index e73cfc3..c41f7ec 100644
--- a/frontend/src/components/NoteEditor.vue
+++ b/frontend/src/components/NoteEditor.vue
@@ -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(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(null);
const bodyInput = ref(null);
@@ -75,15 +73,6 @@ const liveNote = computed(() =>
? (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"
/>
-
-
+