NoteEditor.tsx

6.1 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/* ── Inline editor (Add.tsx, Entry.tsx note view) ── */7374interface NoteEditorInlineProps {75  initialContent: string;76  onContentChange?: (html: string) => void;77  onBlur?: (html: string) => void;78  onReady?: (el: HTMLDivElement) => void;79  autoFocus?: boolean;80  /** When this key changes, the editor re-loads initialContent (e.g., switching between vault entries in-place). */81  entryKey?: string;82}8384export function NoteEditorInline(props: NoteEditorInlineProps) {85  let editorRef: HTMLDivElement | undefined;8687  onMount(() => {88    if (!editorRef) return;89    editorRef.innerHTML = toHtml(props.initialContent);90    if (props.autoFocus !== false) editorRef.focus();91    props.onReady?.(editorRef);92  });9394  createEffect(on(() => props.entryKey, (key, prevKey) => {95    if (prevKey === undefined || key === prevKey || !editorRef) return;96    editorRef.innerHTML = toHtml(props.initialContent);97    props.onReady?.(editorRef);98  }));99100  function exec(cmd: string, value?: string) {101    document.execCommand(cmd, false, value);102    editorRef?.focus();103  }104105  function handleKeyDown(e: KeyboardEvent) {106    const mod = e.metaKey || e.ctrlKey;107    // Only intercept shortcuts for non-alphanumeric keys to avoid swallowing108    // letter input. Letter keys (a–z, etc.) must always pass through freely.109    const isLetterKey = e.key.length === 1;110    if (isLetterKey) return;111    if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }112    if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }113    if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }114  }115116  return (117    <div class="peach-note-editor-inline">118      <div ref={editorRef}119        class="peach-note-editor-body peach-note-editor-body--inline"120        contenteditable="true"121        onKeyDown={handleKeyDown}122        onInput={() => props.onContentChange?.(editorRef?.innerHTML ?? "")}123        onBlur={() => props.onBlur?.(editorRef?.innerHTML ?? "")}124        aria-multiline="true"125        aria-label="Note content"126      />127    </div>128  );129}