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:
@@ -180,6 +180,34 @@
|
|||||||
color: var(--color-text-muted);
|
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 */
|
||||||
.tiptap-editor .ProseMirror {
|
.tiptap-editor .ProseMirror {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const headingRenderer = {
|
|||||||
marked.use({ renderer: headingRenderer });
|
marked.use({ renderer: headingRenderer });
|
||||||
|
|
||||||
const PURIFY_OPTS_FULL = {
|
const PURIFY_OPTS_FULL = {
|
||||||
ADD_ATTR: ["data-tag", "data-title"],
|
ADD_ATTR: ["data-tag", "data-title", "data-task-index"],
|
||||||
FORCE_BODY: true,
|
FORCE_BODY: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,10 +47,22 @@ const PURIFY_OPTS_PREVIEW = {
|
|||||||
FORCE_BODY: true,
|
FORCE_BODY: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderMarkdown(text: string): string {
|
export function renderMarkdown(text: string, { interactiveCheckboxes = false } = {}): string {
|
||||||
const stripped = stripFirstLineTags(text);
|
const stripped = stripFirstLineTags(text);
|
||||||
const decoded = decodeEntities(stripped);
|
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 withTags = linkifyTags(html);
|
||||||
const withLinks = linkifyWikilinks(withTags);
|
const withLinks = linkifyWikilinks(withTags);
|
||||||
const sanitized = DOMPurify.sanitize(withLinks, PURIFY_OPTS_FULL);
|
const sanitized = DOMPurify.sanitize(withLinks, PURIFY_OPTS_FULL);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { useNotesStore } from "@/stores/notes";
|
import { useNotesStore } from "@/stores/notes";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import { relativeTime } from "@/composables/useRelativeTime";
|
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 type { Note } from "@/types/note";
|
||||||
import TagPill from "@/components/TagPill.vue";
|
import TagPill from "@/components/TagPill.vue";
|
||||||
import TableOfContents from "@/components/TableOfContents.vue";
|
import TableOfContents from "@/components/TableOfContents.vue";
|
||||||
@@ -79,11 +79,48 @@ watch(() => route.params.id, (newId) => {
|
|||||||
if (newId) loadNote(Number(newId));
|
if (newId) loadNote(Number(newId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isListNote = computed(() => {
|
||||||
|
const body = store.currentNote?.body ?? "";
|
||||||
|
return /^- \[[ xX]\] /m.test(body);
|
||||||
|
});
|
||||||
|
|
||||||
const renderedBody = computed(() => {
|
const renderedBody = computed(() => {
|
||||||
if (!store.currentNote) return "";
|
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) {
|
async function onBodyClick(e: MouseEvent) {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
@@ -220,8 +257,10 @@ async function convertToTask() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="body prose"
|
class="body prose"
|
||||||
|
:class="{ 'prose--checklist': isListNote }"
|
||||||
v-html="renderedBody"
|
v-html="renderedBody"
|
||||||
@click="onBodyClick"
|
@click="onBodyClick"
|
||||||
|
@change="onBodyChange"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div v-if="backlinks.length" class="backlinks">
|
<div v-if="backlinks.length" class="backlinks">
|
||||||
|
|||||||
Reference in New Issue
Block a user