1import { createEffect, on, onMount } from "solid-js";234567const 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, "&")29 .replace(/</g, "<")30 .replace(/>/g, ">")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}979899100interface NoteEditorInlineProps {101 initialContent: string;102 onContentChange?: (html: string) => void;103 onBlur?: (html: string) => void;104 onReady?: (el: HTMLDivElement) => void;105 autoFocus?: boolean;106 107 entryKey?: string;108}109110111112113114115export function handleRichTextPaste(e: ClipboardEvent) {116 e.preventDefault();117 const text = e.clipboardData?.getData("text/plain") ?? "";118 document.execCommand("insertText", false, text);119}120121export function NoteEditorInline(props: NoteEditorInlineProps) {122 let editorRef: HTMLDivElement | undefined;123124 onMount(() => {125 if (!editorRef) return;126 editorRef.innerHTML = toHtml(props.initialContent);127 if (props.autoFocus !== false) editorRef.focus();128 props.onReady?.(editorRef);129 });130131 createEffect(on(() => props.entryKey, (key, prevKey) => {132 if (prevKey === undefined || key === prevKey || !editorRef) return;133 editorRef.innerHTML = toHtml(props.initialContent);134 props.onReady?.(editorRef);135 }));136137 function exec(cmd: string, value?: string) {138 document.execCommand(cmd, false, value);139 editorRef?.focus();140 }141142 function handleKeyDown(e: KeyboardEvent) {143 const mod = e.metaKey || e.ctrlKey;144 145 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={handleRichTextPaste}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}