From 749a60b9fdc73e73c6b518b9c54c68cecb3bb725 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 2 Apr 2026 09:31:37 -0400 Subject: [PATCH] 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 --- frontend/src/assets/prose.css | 28 +++++++++++++++++ frontend/src/utils/markdown.ts | 18 +++++++++-- frontend/src/views/NoteViewerView.vue | 43 +++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/frontend/src/assets/prose.css b/frontend/src/assets/prose.css index 74386de..b2d5a2c 100644 --- a/frontend/src/assets/prose.css +++ b/frontend/src/assets/prose.css @@ -180,6 +180,34 @@ color: var(--color-text-muted); } +/* Interactive checkboxes — marked output in the list-note viewer */ +.prose--checklist ul { + list-style: none; + padding-left: 0.25rem; +} +.prose--checklist li { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.25rem; +} +.prose--checklist li input[type="checkbox"] { + flex-shrink: 0; + accent-color: var(--color-primary); + cursor: pointer; + width: 0.95em; + height: 0.95em; + margin: 0; +} +.prose--checklist li:has(input[type="checkbox"]:checked) > p, +.prose--checklist li:has(input[type="checkbox"]:checked) { + text-decoration: line-through; + color: var(--color-text-muted); +} +.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] { + text-decoration: none; /* don't strike through the checkbox itself */ +} + /* Tiptap editor */ .tiptap-editor .ProseMirror { outline: none; diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index 98ac45e..4b3dc0c 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -38,7 +38,7 @@ const headingRenderer = { marked.use({ renderer: headingRenderer }); const PURIFY_OPTS_FULL = { - ADD_ATTR: ["data-tag", "data-title"], + ADD_ATTR: ["data-tag", "data-title", "data-task-index"], FORCE_BODY: true, }; @@ -47,10 +47,22 @@ const PURIFY_OPTS_PREVIEW = { FORCE_BODY: true, }; -export function renderMarkdown(text: string): string { +export function renderMarkdown(text: string, { interactiveCheckboxes = false } = {}): string { const stripped = stripFirstLineTags(text); const decoded = decodeEntities(stripped); - const html = marked(decoded) as string; + let html = marked(decoded) as string; + + if (interactiveCheckboxes) { + // marked renders task-list checkboxes as . + // Remove disabled and stamp a sequential data-task-index so the viewer + // can identify which item was toggled regardless of attribute order. + html = html.replace(/ disabled=""/g, ""); + let taskIdx = 0; + html = html.replace(/]*type="checkbox"[^>]*)>/g, (_m, attrs) => { + return ``; + }); + } + const withTags = linkifyTags(html); const withLinks = linkifyWikilinks(withTags); const sanitized = DOMPurify.sanitize(withLinks, PURIFY_OPTS_FULL); diff --git a/frontend/src/views/NoteViewerView.vue b/frontend/src/views/NoteViewerView.vue index ca0716e..8085785 100644 --- a/frontend/src/views/NoteViewerView.vue +++ b/frontend/src/views/NoteViewerView.vue @@ -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() {