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
2026-10-04 13:25:17 -04:00
parent b9f797789f
commit 4b5e193d9f
5 changed files with 143 additions and 9 deletions
+11 -7
View File
@@ -178,7 +178,9 @@ templ Reader(user User, metadata ReaderMetadata) {
rows="3"
class="reader-note-input"
placeholder="Note…"
@paste="richNotePaste"
></textarea>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">Markdown supported — rich-text paste converts to markdown</p>
<div
class="text-xs mt-0.5 text-right"
x-show="selectionPopover.note.length > 0"
@@ -634,7 +636,7 @@ templ ReaderAnnotationsDrawer() {
class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700"
>
<span class="block text-sm truncate" :style="'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'" x-text="hl.text"></span>
<span class="block text-xs mt-0.5 truncate pl-2" style="color: var(--text-secondary)" x-show="hl.note" x-text="'📝 ' + hl.note"></span>
<span class="block text-xs mt-0.5 truncate pl-2" style="color: var(--text-secondary)" x-show="hl.note" x-html="'📝 ' + hl.noteHtml"></span>
</a>
<button
@click="deleteHighlightById(hl.id)"
@@ -657,7 +659,9 @@ templ ReaderAnnotationsDrawer() {
rows="2"
class="reader-note-input"
placeholder="Add a note at the current position…"
@paste="richNotePaste"
></textarea>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">Markdown supported — rich-text paste converts to markdown</p>
<div
class="text-xs mt-0.5 text-right"
x-show="newNoteText.length > 0"
@@ -672,12 +676,12 @@ templ ReaderAnnotationsDrawer() {
+ Add Note
</button>
<div class="space-y-2">
<template x-for="note in noteItems" :key="note.id">
<div class="reader-hl-row group">
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
<span class="block text-sm truncate" x-text="note.content"></span>
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
</a>
<template x-for="note in noteItems" :key="note.id">
<div class="reader-hl-row group">
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
<span class="block text-sm truncate reader-note-rich" x-html="note.contentHtml"></span>
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
</a>
<button
@click="deleteNoteById(note.id)"
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"