NoteEditor.tsx

7.3 KB · raw

1import { onMount } from "solid-js";23export function toHtml(raw: string): string {4  if (/<\/?[a-z]/i.test(raw)) return raw;5  return raw6    .replace(/&/g, "&amp;")7    .replace(/</g, "&lt;")8    .replace(/>/g, "&gt;")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);4748type ExecFn = (cmd: string, val?: string) => void;4950function Toolbar(props: { exec: ExecFn }) {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="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}7172/* ── Full-popup overlay editor (Entry.tsx) ── */7374interface 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}135136/* ── Inline editor (Add.tsx) ── */137138interface NoteEditorInlineProps {139  initialContent: string;140  onContentChange: (html: string) => void;141}142143export function NoteEditorInline(props: NoteEditorInlineProps) {144  let editorRef: HTMLDivElement | undefined;145146  onMount(() => {147    if (!editorRef) return;148    editorRef.innerHTML = toHtml(props.initialContent);149    editorRef.focus();150  });151152  function exec(cmd: string, value?: string) {153    document.execCommand(cmd, false, value);154    editorRef?.focus();155  }156157  function handleKeyDown(e: KeyboardEvent) {158    const mod = e.metaKey || e.ctrlKey;159    if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }160    if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }161    if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }162  }163164  return (165    <div class="peach-note-editor-inline">166      <Toolbar exec={exec} />167      <div ref={editorRef}168        class="peach-note-editor-body peach-note-editor-body--inline"169        contenteditable="true"170        onKeyDown={handleKeyDown}171        onInput={() => props.onContentChange(editorRef?.innerHTML ?? "")}172        aria-multiline="true"173        aria-label="Note content"174      />175    </div>176  );177}