1import { createSignal, onCleanup, onMount, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { sendMessage } from "../../shared/bridge";4import { NoteToolbar, type ToolbarExec } from "../components/NoteToolbar";5import { toHtml, handleRichTextPaste } from "./NoteEditor";67const AUTOSAVE_MS = 800;8const SAVED_DISPLAY_MS = 1500;910interface NotesEditorProps {11 note: VaultEntry | null; 12 onBack: () => void;13 onSaved: (note: VaultEntry) => void;14 onDelete: (note: VaultEntry) => void;15 autoFocusTitle?: boolean;16}1718export function NotesEditor(props: NotesEditorProps) {19 let titleRef!: HTMLDivElement;20 let bodyRef!: HTMLDivElement;2122 const [noteId, setNoteId] = createSignal<string | undefined>(props.note?.id);23 const [title, setTitle] = createSignal(props.note?.title ?? "");24 const [body, setBody] = createSignal(props.note?.notes ?? "");25 const [saved, setSaved] = createSignal(false);26 const [dirty, setDirty] = createSignal(false);2728 let saveTimer: number | null = null;29 let savedFadeTimer: number | null = null;3031 32 33 34 35 function bodyIsBlank(html: string): boolean {36 return !html37 .replace(/<[^>]*>| | |\u200b|\ufeff/g, "")38 .replace(/\s+/g, "");39 }4041 function scheduleAutosave() {42 setDirty(true);43 if (saveTimer) clearTimeout(saveTimer);44 saveTimer = window.setTimeout(save, AUTOSAVE_MS);45 }4647 async function save() {48 const t = title();49 const b = body();50 51 if (!t.trim() && bodyIsBlank(b)) return;52 const res = await sendMessage({53 type: "SAVE_NOTE",54 payload: { id: noteId(), title: t || "Untitled", body: b },55 }) as { ok: boolean; data?: VaultEntry };56 if (res?.ok && res.data) {57 setNoteId(res.data.id);58 setDirty(false);59 showSaved();60 props.onSaved(res.data);61 }62 }6364 function showSaved() {65 setSaved(true);66 if (savedFadeTimer) clearTimeout(savedFadeTimer);67 savedFadeTimer = window.setTimeout(() => setSaved(false), SAVED_DISPLAY_MS);68 }6970 function exec(cmd: string, val?: string) {71 document.execCommand(cmd, false, val);72 73 74 75 setBody(bodyRef?.innerHTML ?? "");76 bodyRef?.focus();77 scheduleAutosave();78 }7980 async function handleBack() {81 82 83 if (dirty() && (title().trim() || !bodyIsBlank(body()))) {84 if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }85 await save();86 }87 props.onBack();88 }8990 onMount(() => {91 92 if (titleRef) titleRef.textContent = props.note?.title ?? "";93 94 if (bodyRef) bodyRef.innerHTML = toHtml(props.note?.notes ?? "");9596 if (props.autoFocusTitle && titleRef) {97 setTimeout(() => {98 titleRef.focus();99 100 const range = document.createRange();101 const sel = window.getSelection();102 range.selectNodeContents(titleRef);103 range.collapse(false);104 sel?.removeAllRanges();105 sel?.addRange(range);106 }, 300);107 }108109 onCleanup(() => {110 if (saveTimer) clearTimeout(saveTimer);111 if (savedFadeTimer) clearTimeout(savedFadeTimer);112 });113 });114115 116 let swipeStartX = 0;117 let swipeStartY = 0;118 let swipeCaptured = false;119120 function handleSwipeStart(e: TouchEvent) {121 const t = e.touches[0];122 if (!t) return;123 swipeStartX = t.clientX;124 swipeStartY = t.clientY;125 swipeCaptured = false;126 }127128 function handleSwipeMove(e: TouchEvent) {129 const t = e.touches[0];130 if (!t) return;131 const dx = t.clientX - swipeStartX;132 const dy = Math.abs(t.clientY - swipeStartY);133 if (!swipeCaptured && swipeStartX < 30 && dx > 40 && dy < dx) {134 swipeCaptured = true;135 }136 }137138 function handleSwipeEnd() {139 if (swipeCaptured) {140 handleBack();141 }142 swipeCaptured = false;143 }144145 const execFn: ToolbarExec = exec;146147 return (148 <div149 class="peach-note-editor-surface"150 onTouchStart={handleSwipeStart}151 onTouchMove={handleSwipeMove}152 onTouchEnd={handleSwipeEnd}153 >154 {}155 <div class="peach-notes-header peach-notes-header--editor">156 <button class="peach-notes-back" onClick={handleBack} aria-label="Back to notes list">‹</button>157 <span class="peach-notes-header-title" onClick={handleBack}>notes.</span>158 <div class="peach-notes-header-actions">159 <Show when={noteId()}>160 <button161 class="peach-notes-header-btn"162 aria-label="Delete note"163 onClick={() => {164 const id = noteId();165 if (!id) return;166 167 props.onDelete({ id, title: title(), notes: body(), type: "note", createdAt: 0, modifiedAt: Date.now() } as VaultEntry);168 }}169 title="Delete note"170 >171 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">172 <polyline points="1,3.5 13,3.5" />173 <path d="M5 3.5V2h4v1.5" />174 <rect x="2.5" y="3.5" width="9" height="9" rx="1.5" />175 <line x1="5.5" y1="6.5" x2="5.5" y2="10" />176 <line x1="8.5" y1="6.5" x2="8.5" y2="10" />177 </svg>178 </button>179 </Show>180 </div>181 </div>182183 {}184 <div185 class="peach-note-editor-scroll"186 >187 <NoteToolbar exec={execFn} />188189 <div190 ref={titleRef}191 class="peach-note-title-field"192 contenteditable="true"193 data-placeholder="Title"194 onInput={() => {195 setTitle(titleRef.textContent ?? "");196 scheduleAutosave();197 }}198 onKeyDown={(e) => {199 if (e.key === "Enter") {200 e.preventDefault();201 bodyRef?.focus();202 }203 }}204 onPaste={handleRichTextPaste}205 aria-label="Note title"206 />207208 <div class="peach-note-hairline" />209210 <div211 ref={bodyRef}212 class="peach-note-body-field"213 contenteditable="true"214 data-placeholder="Begin writing…"215 onInput={() => {216 setBody(bodyRef.innerHTML ?? "");217 scheduleAutosave();218 }}219 onKeyDown={(e) => {220 const mod = e.metaKey || e.ctrlKey;221 if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }222 if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }223 if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }224 }}225 onPaste={handleRichTextPaste}226 aria-label="Note body"227 aria-multiline="true"228 />229 </div>230231 {}232 <div class="peach-note-saved-indicator" style={{ opacity: saved() ? "1" : "0" }}>233 saved.234 </div>235236 </div>237 );238}