feat(web): notes render as markdown — markdown-it+DOMPurify pipeline, rich paste

Objective 6 (web half). Storage/sync stay raw markdown (KOReader shows
literal source, accepted). Notes-grade syntax: headings, emphasis/
strikethrough, code, links, lists, blockquotes, GFM tables, reference
links, footnotes (markdown-it-footnote); images/raw HTML/math excluded
(html:false posture + FORBID img/style/form + default safe-scheme URI
check). Display sites: annotations-drawer note rows and highlight note
lines render pre-sanitized HTML (x-html); render-time sanitize only.

Rich paste: both note textareas intercept paste; a text/html clipboard
flavor converts via turndown (gfm tables incl. a headingless-table rule
emitting pipe syntax; images blanked) and inserts at the caret; plain
pastics fall through to the default paste unchanged.

Verified live in Brave: bold/italic/strike/code/lists/quote/table/
footnote-ref/reference-link render; <script> renders inert literal
text with no alert; javascript: hrefs absent from the DOM; paste of
rich HTML lands markdown at the caret; plain pastes untouched.
This commit is contained in:
John O'Keefe committed 2026-10-04 13:25:17 -04:00
1 parent b9f797789f
commit 4b5e193d9f
5 files changed
+143 -9

No files matched your search

+20 -1
View File
@@ -5,6 +5,7 @@ import { Alpine } from "../alpine";
import { loadSettings, saveSettings } from "./settings-manager";
import { getToken } from "../storage";
import { showToast } from "../toast";
import { renderNoteHtml, richPasteToMarkdown } from "../markdown";
import {
extractPdfPages,
searchPdfPages,
@@ -435,6 +436,7 @@ document.addEventListener("alpine:init", () => {
id: string;
text: string;
note: string;
noteHtml: string;
color: string;
cfi: string;
cfiEnd: string;
@@ -443,7 +445,7 @@ document.addEventListener("alpine:init", () => {
pdfPage: number;
pdfRects: number[][];
}[],
noteItems: [] as { id: string; content: string; positionLabel: string }[],
noteItems: [] as { id: string; content: string; contentHtml: string; positionLabel: string }[],
annotationsTab: "highlights" as string,
newNoteText: "",
// Mirrors the server-side validator cap on note content
@@ -1556,6 +1558,7 @@ document.addEventListener("alpine:init", () => {
id: r.id,
text: r.selection_text ?? "",
note: r.note_text ?? "",
noteHtml: renderNoteHtml(r.note_text ?? ""),
color: r.color ?? "#ffff00",
cfi,
cfiEnd,
@@ -1642,6 +1645,7 @@ document.addEventListener("alpine:init", () => {
this.noteItems = (rows as any[]).map((r) => ({
id: r.id,
content: r.content ?? "",
contentHtml: renderNoteHtml(r.content ?? ""),
positionLabel: r.position ?? "",
}));
}
@@ -2021,6 +2025,21 @@ document.addEventListener("alpine:init", () => {
noteCountLabel(text: string): string {
return `${text.length.toLocaleString()}/${this.noteMaxLength.toLocaleString()}`;
},
// Rich clipboard (text/html) converts to markdown source at the caret;
// plain-only clipboards fall through to the browser default paste.
richNotePaste(event: ClipboardEvent) {
const markdown = richPasteToMarkdown(event);
if (markdown === null) return;
event.preventDefault();
const el = event.target as HTMLTextAreaElement | null;
if (!el) return;
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? start;
el.value = el.value.slice(0, start) + markdown + el.value.slice(end);
const caret = start + markdown.length;
el.setSelectionRange(caret, caret);
el.dispatchEvent(new Event("input", { bubbles: true }));
},
async addNote(content: string) {
const token = getToken();
if (!token || !this.mediaItemId || !content.trim()) return;