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
+28
View File
@@ -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;
+15 -3
View File
@@ -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 <input ... disabled="" ...>.
// 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(/<input ([^>]*type="checkbox"[^>]*)>/g, (_m, attrs) => {
return `<input data-task-index="${taskIdx++}" ${attrs}>`;
});
}
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
const sanitized = DOMPurify.sanitize(withLinks, PURIFY_OPTS_FULL);
+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">