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:
1 parent
b9f797789f
commit
4b5e193d9f
5 files changed
+143
-9
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user