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. -->
- +

document.removeEventListener("mousedown", onDocMousedown)) - +

-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); -} - - - 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" /> - - +