feat: interactive checkboxes in list note viewer

- renderMarkdown() accepts interactiveCheckboxes option: removes disabled=""
  and stamps data-task-index on each checkbox in the marked HTML output
- NoteViewerView detects list notes by body content (- [ ] / - [x] pattern)
  and passes interactiveCheckboxes: true when rendering
- onBodyChange() handles checkbox change events: toggles the matching line
  in the body, optimistically updates the store, then PATCHes the note
- prose.css adds .prose--checklist rules for marked output: no bullet,
  flex row, accent-color, line-through on checked items via :has()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 09:31:37 -04:00
parent aec7a910f0
commit 749a60b9fd
3 changed files with 84 additions and 5 deletions
+41 -2
View File
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { renderMarkdown } from "@/utils/markdown";
import { relativeTime } from "@/composables/useRelativeTime";
import { apiPost, apiGet } from "@/api/client";
import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
@@ -79,11 +79,48 @@ watch(() => route.params.id, (newId) => {
if (newId) loadNote(Number(newId));
});
const isListNote = computed(() => {
const body = store.currentNote?.body ?? "";
return /^- \[[ xX]\] /m.test(body);
});
const renderedBody = computed(() => {
if (!store.currentNote) return "";
return renderMarkdown(store.currentNote.body);
return renderMarkdown(store.currentNote.body, { interactiveCheckboxes: isListNote.value });
});
async function onBodyChange(e: Event) {
const target = e.target as HTMLInputElement;
if (target.type !== "checkbox" || !store.currentNote) return;
const index = parseInt(target.dataset.taskIndex ?? "", 10);
if (isNaN(index)) return;
let taskIdx = 0;
const newBody = store.currentNote.body.split("\n").map(line => {
const stripped = line.trimStart();
if (stripped.startsWith("- [ ] ") || stripped.startsWith("- [x] ") || stripped.startsWith("- [X] ")) {
if (taskIdx === index) {
const indent = line.length - stripped.length;
const wasChecked = !stripped.startsWith("- [ ] ");
taskIdx++;
return " ".repeat(indent) + (wasChecked ? "- [ ] " : "- [x] ") + stripped.slice(6);
}
taskIdx++;
}
return line;
}).join("\n");
// Optimistic update so the checkbox state doesn't snap back
store.currentNote.body = newBody;
try {
await apiPatch(`/api/notes/${store.currentNote.id}`, { body: newBody });
} catch {
await store.fetchNote(store.currentNote.id);
}
}
async function onBodyClick(e: MouseEvent) {
const target = e.target as HTMLElement;
@@ -220,8 +257,10 @@ async function convertToTask() {
</div>
<div
class="body prose"
:class="{ 'prose--checklist': isListNote }"
v-html="renderedBody"
@click="onBodyClick"
@change="onBodyChange"
></div>
<div v-if="backlinks.length" class="backlinks">