NoteEditor.tsx

8.0 KB · raw

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, "&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);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}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, Entry.tsx note view) ── */137138interface NoteEditorInlineProps {139  initialContent: string;140  onContentChange?: (html: string) => void;141  onBlur?: (html: string) => void;142  onReady?: (el: HTMLDivElement) => void;143  autoFocus?: boolean;144  /** When this key changes, the editor re-loads initialContent (e.g., switching between vault entries in-place). */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}