NoteEditor.tsx

7.6 KB · raw

1import { createEffect, on, onMount } from "solid-js";23// M15/THREAT-006: allow-list sanitizer for note bodies. The editor writes4// arbitrary HTML directly to innerHTML; without this any note body could5// inject iframe/base/meta/form/script. Allow-list is intentional — adding6// to it requires reviewing the attack surface of the new tag.7const ALLOWED_NOTE_TAGS = new Set([8  "b", "i", "u", "s", "strong", "em",9  "h2", "h3", "blockquote", "ul", "ol", "li", "br", "hr", "p",10]);1112function sanitizeNoteElement(el: Element): void {13  for (const child of Array.from(el.children)) {14    if (!ALLOWED_NOTE_TAGS.has(child.tagName.toLowerCase())) {15      child.replaceWith(document.createTextNode(child.textContent ?? ""));16    } else {17      while (child.attributes.length > 0) {18        child.removeAttribute(child.attributes[0]!.name);19      }20      sanitizeNoteElement(child);21    }22  }23}2425export function toHtml(raw: string): string {26  if (!/<\/?[a-z]/i.test(raw)) {27    return raw28      .replace(/&/g, "&amp;")29      .replace(/</g, "&lt;")30      .replace(/>/g, "&gt;")31      .replace(/\n/g, "<br>");32  }33  const doc = new DOMParser().parseFromString(raw, "text/html");34  sanitizeNoteElement(doc.body);35  return doc.body.innerHTML;36}3738const BoldIcon = () => <span style="font-family:Inter,system-ui,sans-serif;font-weight:700;font-size:11px;">B</span>;39const ItalicIcon = () => <span style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;">I</span>;40const UnderlineIcon = () => <span style="text-decoration:underline;font-size:10px;font-family:'IBM Plex Mono',monospace;">U</span>;41const StrikeIcon = () => <span style="text-decoration:line-through;font-size:10px;font-family:'IBM Plex Mono',monospace;">S</span>;42const HeadingIcon = () => <span style="font-family:Inter,system-ui,sans-serif;font-weight:600;font-size:10px;letter-spacing:0.03em;">H</span>;4344const BulletIcon = () => (45  <svg width="12" height="11" viewBox="0 0 12 11" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">46    <circle cx="1.5" cy="2.5" r="1" fill="currentColor" stroke="none"/>47    <line x1="4" y1="2.5" x2="11" y2="2.5"/>48    <circle cx="1.5" cy="6" r="1" fill="currentColor" stroke="none"/>49    <line x1="4" y1="6" x2="11" y2="6"/>50    <circle cx="1.5" cy="9.5" r="1" fill="currentColor" stroke="none"/>51    <line x1="4" y1="9.5" x2="11" y2="9.5"/>52  </svg>53);5455const NumberedIcon = () => (56  <svg width="12" height="11" viewBox="0 0 12 11" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">57    <text x="0" y="4" font-size="4" fill="currentColor" stroke="none" font-family="monospace">1.</text>58    <line x1="4" y1="2.5" x2="11" y2="2.5"/>59    <text x="0" y="7.5" font-size="4" fill="currentColor" stroke="none" font-family="monospace">2.</text>60    <line x1="4" y1="6" x2="11" y2="6"/>61    <text x="0" y="11" font-size="4" fill="currentColor" stroke="none" font-family="monospace">3.</text>62    <line x1="4" y1="9.5" x2="11" y2="9.5"/>63  </svg>64);6566const RuleIcon = () => (67  <svg width="12" height="8" viewBox="0 0 12 8" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">68    <line x1="0" y1="4" x2="12" y2="4"/>69    <line x1="2" y1="1.5" x2="2" y2="6.5" stroke-width="0.6" opacity="0.5"/>70    <line x1="10" y1="1.5" x2="10" y2="6.5" stroke-width="0.6" opacity="0.5"/>71  </svg>72);7374export type ExecFn = (cmd: string, val?: string) => void;7576export function Toolbar(props: { exec: ExecFn; class?: string }) {77  const e = props.exec;78  function toggleHeading() {79    const val = document.queryCommandValue("formatBlock").toLowerCase();80    e("formatBlock", val === "h2" ? "p" : "h2");81  }82  return (83    <div class={props.class ?? "peach-note-toolbar"}>84      <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("bold"); }} title="Bold (⌘B)"><BoldIcon /></button>85      <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("italic"); }} title="Italic (⌘I)"><ItalicIcon /></button>86      <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("underline"); }} title="Underline (⌘U)"><UnderlineIcon /></button>87      <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("strikeThrough"); }} title="Strikethrough"><StrikeIcon /></button>88      <div class="peach-note-tool-divider" />89      <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); toggleHeading(); }} title="Heading"><HeadingIcon /></button>90      <button class="peach-note-tool-btn peach-note-tool-icon" onMouseDown={(ev) => { ev.preventDefault(); e("insertUnorderedList"); }} title="Bullet list"><BulletIcon /></button>91      <button class="peach-note-tool-btn peach-note-tool-icon" onMouseDown={(ev) => { ev.preventDefault(); e("insertOrderedList"); }} title="Numbered list"><NumberedIcon /></button>92      <div class="peach-note-tool-divider" />93      <button class="peach-note-tool-btn peach-note-tool-icon" onMouseDown={(ev) => { ev.preventDefault(); e("insertHorizontalRule"); }} title="Divider"><RuleIcon /></button>94    </div>95  );96}9798/* ── Inline editor (Add.tsx, Entry.tsx note view) ── */99100interface NoteEditorInlineProps {101  initialContent: string;102  onContentChange?: (html: string) => void;103  onBlur?: (html: string) => void;104  onReady?: (el: HTMLDivElement) => void;105  autoFocus?: boolean;106  /** When this key changes, the editor re-loads initialContent (e.g., switching between vault entries in-place). */107  entryKey?: string;108}109110export function NoteEditorInline(props: NoteEditorInlineProps) {111  let editorRef: HTMLDivElement | undefined;112113  onMount(() => {114    if (!editorRef) return;115    editorRef.innerHTML = toHtml(props.initialContent);116    if (props.autoFocus !== false) editorRef.focus();117    props.onReady?.(editorRef);118  });119120  createEffect(on(() => props.entryKey, (key, prevKey) => {121    if (prevKey === undefined || key === prevKey || !editorRef) return;122    editorRef.innerHTML = toHtml(props.initialContent);123    props.onReady?.(editorRef);124  }));125126  function exec(cmd: string, value?: string) {127    document.execCommand(cmd, false, value);128    editorRef?.focus();129  }130131  // Paste as plain text. The live editor writes innerHTML directly and only132  // load-time content is sanitized (toHtml); pasted HTML would otherwise carry133  // foreign inline styles (white-space:pre kills wrapping) and disallowed tags.134  // insertText keeps the body clean and wrapping intact, and fires `input` so135  // onContentChange persists the change.136  function handlePaste(e: ClipboardEvent) {137    e.preventDefault();138    const text = e.clipboardData?.getData("text/plain") ?? "";139    document.execCommand("insertText", false, text);140  }141142  function handleKeyDown(e: KeyboardEvent) {143    const mod = e.metaKey || e.ctrlKey;144    // Only intercept shortcuts for non-alphanumeric keys to avoid swallowing145    // letter input. Letter keys (a–z, etc.) must always pass through freely.146    const isLetterKey = e.key.length === 1;147    if (isLetterKey) return;148    if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }149    if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }150    if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }151  }152153  return (154    <div class="peach-note-editor-inline">155      <div ref={editorRef}156        class="peach-note-editor-body peach-note-editor-body--inline"157        contenteditable="true"158        onKeyDown={handleKeyDown}159        onPaste={handlePaste}160        onInput={() => props.onContentChange?.(editorRef?.innerHTML ?? "")}161        onBlur={() => props.onBlur?.(editorRef?.innerHTML ?? "")}162        aria-multiline="true"163        aria-label="Note content"164      />165    </div>166  );167}