1import { createEffect, on, onMount } from "solid-js";23export function toHtml(raw: string): string {4 if (/<\/?[a-z]/i.test(raw)) return raw;5 return raw6 .replace(/&/g, "&")7 .replace(/</g, "<")8 .replace(/>/g, ">")9 .replace(/\n/g, "<br>");10}1112const BoldIcon = () => <span style="font-family:Inter,system-ui,sans-serif;font-weight:700;font-size:11px;">B</span>;13const ItalicIcon = () => <span style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;">I</span>;14const UnderlineIcon = () => <span style="text-decoration:underline;font-size:10px;font-family:'IBM Plex Mono',monospace;">U</span>;15const StrikeIcon = () => <span style="text-decoration:line-through;font-size:10px;font-family:'IBM Plex Mono',monospace;">S</span>;16const HeadingIcon = () => <span style="font-family:Inter,system-ui,sans-serif;font-weight:600;font-size:10px;letter-spacing:0.03em;">H</span>;1718const BulletIcon = () => (19 <svg width="12" height="11" viewBox="0 0 12 11" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">20 <circle cx="1.5" cy="2.5" r="1" fill="currentColor" stroke="none"/>21 <line x1="4" y1="2.5" x2="11" y2="2.5"/>22 <circle cx="1.5" cy="6" r="1" fill="currentColor" stroke="none"/>23 <line x1="4" y1="6" x2="11" y2="6"/>24 <circle cx="1.5" cy="9.5" r="1" fill="currentColor" stroke="none"/>25 <line x1="4" y1="9.5" x2="11" y2="9.5"/>26 </svg>27);2829const NumberedIcon = () => (30 <svg width="12" height="11" viewBox="0 0 12 11" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">31 <text x="0" y="4" font-size="4" fill="currentColor" stroke="none" font-family="monospace">1.</text>32 <line x1="4" y1="2.5" x2="11" y2="2.5"/>33 <text x="0" y="7.5" font-size="4" fill="currentColor" stroke="none" font-family="monospace">2.</text>34 <line x1="4" y1="6" x2="11" y2="6"/>35 <text x="0" y="11" font-size="4" fill="currentColor" stroke="none" font-family="monospace">3.</text>36 <line x1="4" y1="9.5" x2="11" y2="9.5"/>37 </svg>38);3940const RuleIcon = () => (41 <svg width="12" height="8" viewBox="0 0 12 8" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">42 <line x1="0" y1="4" x2="12" y2="4"/>43 <line x1="2" y1="1.5" x2="2" y2="6.5" stroke-width="0.6" opacity="0.5"/>44 <line x1="10" y1="1.5" x2="10" y2="6.5" stroke-width="0.6" opacity="0.5"/>45 </svg>46);4748export type ExecFn = (cmd: string, val?: string) => void;4950export function Toolbar(props: { exec: ExecFn; class?: string }) {51 const e = props.exec;52 function toggleHeading() {53 const val = document.queryCommandValue("formatBlock").toLowerCase();54 e("formatBlock", val === "h2" ? "p" : "h2");55 }56 return (57 <div class={props.class ?? "peach-note-toolbar"}>58 <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("bold"); }} title="Bold (⌘B)"><BoldIcon /></button>59 <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("italic"); }} title="Italic (⌘I)"><ItalicIcon /></button>60 <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("underline"); }} title="Underline (⌘U)"><UnderlineIcon /></button>61 <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("strikeThrough"); }} title="Strikethrough"><StrikeIcon /></button>62 <div class="peach-note-tool-divider" />63 <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); toggleHeading(); }} title="Heading"><HeadingIcon /></button>64 <button class="peach-note-tool-btn peach-note-tool-icon" onMouseDown={(ev) => { ev.preventDefault(); e("insertUnorderedList"); }} title="Bullet list"><BulletIcon /></button>65 <button class="peach-note-tool-btn peach-note-tool-icon" onMouseDown={(ev) => { ev.preventDefault(); e("insertOrderedList"); }} title="Numbered list"><NumberedIcon /></button>66 <div class="peach-note-tool-divider" />67 <button class="peach-note-tool-btn peach-note-tool-icon" onMouseDown={(ev) => { ev.preventDefault(); e("insertHorizontalRule"); }} title="Divider"><RuleIcon /></button>68 </div>69 );70}71727374interface NoteEditorProps {75 initialContent: string;76 saving: boolean;77 onSave: (html: string) => Promise<void>;78 onCancel: () => void;79}8081export function NoteEditor(props: NoteEditorProps) {82 let editorRef: HTMLDivElement | undefined;8384 onMount(() => {85 if (!editorRef) return;86 editorRef.innerHTML = toHtml(props.initialContent);87 editorRef.focus();88 const sel = window.getSelection();89 if (sel) {90 const range = document.createRange();91 range.selectNodeContents(editorRef);92 range.collapse(false);93 sel.removeAllRanges();94 sel.addRange(range);95 }96 });9798 function exec(cmd: string, value?: string) {99 document.execCommand(cmd, false, value);100 editorRef?.focus();101 }102103 function handleKeyDown(e: KeyboardEvent) {104 const mod = e.metaKey || e.ctrlKey;105 if (mod && e.key === "b") { e.preventDefault(); exec("bold"); return; }106 if (mod && e.key === "i") { e.preventDefault(); exec("italic"); return; }107 if (mod && e.key === "u") { e.preventDefault(); exec("underline"); return; }108 if (e.key === "Escape") { e.preventDefault(); props.onCancel(); }109 }110111 return (112 <div class="peach-note-editor">113 <div class="peach-note-editor-header">114 <button class="peach-note-editor-back" onClick={props.onCancel}115 disabled={props.saving} aria-label="Cancel">116 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor"117 stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">118 <path d="M6 1L1 6L6 11" />119 </svg>120 </button>121 <span class="peach-note-editor-label">note</span>122 <button class="peach-note-editor-save-btn"123 onClick={() => void props.onSave(editorRef?.innerHTML ?? "")}124 disabled={props.saving}>125 {props.saving ? "saving…" : "save"}126 </button>127 </div>128 <Toolbar exec={exec} />129 <div ref={editorRef} class="peach-note-editor-body"130 contenteditable="true" onKeyDown={handleKeyDown}131 aria-multiline="true" aria-label="Note content" />132 </div>133 );134}135136137138interface NoteEditorInlineProps {139 initialContent: string;140 onContentChange?: (html: string) => void;141 onBlur?: (html: string) => void;142 onReady?: (el: HTMLDivElement) => void;143 autoFocus?: boolean;144 145 entryKey?: string;146}147148export function NoteEditorInline(props: NoteEditorInlineProps) {149 let editorRef: HTMLDivElement | undefined;150151 onMount(() => {152 if (!editorRef) return;153 editorRef.innerHTML = toHtml(props.initialContent);154 if (props.autoFocus !== false) editorRef.focus();155 props.onReady?.(editorRef);156 });157158 createEffect(on(() => props.entryKey, (key, prevKey) => {159 if (prevKey === undefined || key === prevKey || !editorRef) return;160 editorRef.innerHTML = toHtml(props.initialContent);161 props.onReady?.(editorRef);162 }));163164 function exec(cmd: string, value?: string) {165 document.execCommand(cmd, false, value);166 editorRef?.focus();167 }168169 function handleKeyDown(e: KeyboardEvent) {170 const mod = e.metaKey || e.ctrlKey;171 if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }172 if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }173 if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }174 }175176 return (177 <div class="peach-note-editor-inline">178 <div ref={editorRef}179 class="peach-note-editor-body peach-note-editor-body--inline"180 contenteditable="true"181 onKeyDown={handleKeyDown}182 onInput={() => props.onContentChange?.(editorRef?.innerHTML ?? "")}183 onBlur={() => props.onBlur?.(editorRef?.innerHTML ?? "")}184 aria-multiline="true"185 aria-label="Note content"186 />187 </div>188 );189}