1import { createSignal, onCleanup, onMount, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { sendMessage } from "../../shared/bridge";4import { isNativePlatform } from "../../shared/native";5import { NoteRunes } from "../components/NoteRunes";6import { NoteToolbar, type ToolbarExec } from "../components/NoteToolbar";78const HEADER_HEIGHT = 52;9const AUTOSAVE_MS = 800;10const SAVED_DISPLAY_MS = 1500;1112interface NotesEditorProps {13 note: VaultEntry | null; 14 onBack: () => void;15 onSaved: (note: VaultEntry) => void;16 onDelete: (note: VaultEntry) => void;17 autoFocusTitle?: boolean;18}1920export function NotesEditor(props: NotesEditorProps) {21 let titleRef!: HTMLDivElement;22 let bodyRef!: HTMLDivElement;23 let contentScrollRef!: HTMLDivElement;2425 const [noteId, setNoteId] = createSignal<string | undefined>(props.note?.id);26 const [title, setTitle] = createSignal(props.note?.title ?? "");27 const [body, setBody] = createSignal(props.note?.notes ?? "");28 const [saved, setSaved] = createSignal(false);29 const [keyboardVisible, setKeyboardVisible] = createSignal(false);30 const [contentHeight, setContentHeight] = createSignal<number | null>(null);31 const [dirty, setDirty] = createSignal(false);3233 let saveTimer: number | null = null;34 let savedFadeTimer: number | null = null;3536 function scheduleAutosave() {37 setDirty(true);38 if (saveTimer) clearTimeout(saveTimer);39 saveTimer = window.setTimeout(save, AUTOSAVE_MS);40 }4142 async function save() {43 const t = title();44 const b = body();45 46 if (!t.trim() && !b.trim()) return;47 const res = await sendMessage({48 type: "SAVE_NOTE",49 payload: { id: noteId(), title: t || "Untitled", body: b },50 }) as { ok: boolean; data?: VaultEntry };51 if (res?.ok && res.data) {52 setNoteId(res.data.id);53 setDirty(false);54 showSaved();55 props.onSaved(res.data);56 }57 }5859 function showSaved() {60 setSaved(true);61 if (savedFadeTimer) clearTimeout(savedFadeTimer);62 savedFadeTimer = window.setTimeout(() => setSaved(false), SAVED_DISPLAY_MS);63 }6465 function exec(cmd: string, val?: string) {66 document.execCommand(cmd, false, val);67 bodyRef?.focus();68 scheduleAutosave();69 }7071 function handleBack() {72 73 if (dirty() && (title().trim() || body().trim())) {74 if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }75 void save();76 } else if (!noteId() && !title().trim() && !body().trim()) {77 78 }79 props.onBack();80 }8182 function handleKeyboardResize() {83 if (!isNativePlatform()) return;84 const vp = window.visualViewport;85 const viewportH = vp?.height ?? window.innerHeight;86 const kbH = window.innerHeight - viewportH;87 if (kbH > 50) {88 setKeyboardVisible(true);89 setContentHeight(viewportH - HEADER_HEIGHT);90 if (document.activeElement) {91 (document.activeElement as HTMLElement).scrollIntoView?.({ behavior: "smooth", block: "nearest" });92 }93 } else {94 setKeyboardVisible(false);95 setContentHeight(null);96 }97 }9899 onMount(() => {100 101 if (titleRef) titleRef.textContent = props.note?.title ?? "";102 if (bodyRef) bodyRef.innerHTML = props.note?.notes ?? "";103104 if (props.autoFocusTitle && titleRef) {105 setTimeout(() => {106 titleRef.focus();107 108 const range = document.createRange();109 const sel = window.getSelection();110 range.selectNodeContents(titleRef);111 range.collapse(false);112 sel?.removeAllRanges();113 sel?.addRange(range);114 }, 300);115 }116117 if (isNativePlatform()) {118 window.addEventListener("resize", handleKeyboardResize);119 }120121 onCleanup(() => {122 if (saveTimer) clearTimeout(saveTimer);123 if (savedFadeTimer) clearTimeout(savedFadeTimer);124 if (isNativePlatform()) window.removeEventListener("resize", handleKeyboardResize);125 });126 });127128 129 let swipeStartX = 0;130 let swipeStartY = 0;131 let swipeCaptured = false;132133 function handleSwipeStart(e: TouchEvent) {134 const t = e.touches[0];135 if (!t) return;136 swipeStartX = t.clientX;137 swipeStartY = t.clientY;138 swipeCaptured = false;139 }140141 function handleSwipeMove(e: TouchEvent) {142 const t = e.touches[0];143 if (!t) return;144 const dx = t.clientX - swipeStartX;145 const dy = Math.abs(t.clientY - swipeStartY);146 if (!swipeCaptured && swipeStartX < 30 && dx > 40 && dy < dx) {147 swipeCaptured = true;148 }149 }150151 function handleSwipeEnd() {152 if (swipeCaptured) {153 handleBack();154 }155 swipeCaptured = false;156 }157158 const execFn: ToolbarExec = exec;159160 return (161 <div162 class="peach-note-editor-surface"163 onTouchStart={handleSwipeStart}164 onTouchMove={handleSwipeMove}165 onTouchEnd={handleSwipeEnd}166 >167 <NoteRunes mode="editor" />168169 {}170 <div class="peach-notes-header">171 <button class="peach-notes-back" onClick={handleBack} aria-label="Back to notes list">‹</button>172 <span class="peach-notes-header-title is-breadcrumb" onClick={handleBack}>notes</span>173 <div class="peach-notes-header-actions">174 <Show when={noteId()}>175 <button176 class="peach-notes-header-btn"177 aria-label="Delete note"178 onClick={() => {179 const id = noteId();180 if (!id) return;181 182 props.onDelete({ id, title: title(), notes: body(), type: "note", createdAt: 0, modifiedAt: Date.now() } as VaultEntry);183 }}184 title="Delete note"185 >186 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">187 <polyline points="1,3.5 13,3.5" />188 <path d="M5 3.5V2h4v1.5" />189 <rect x="2.5" y="3.5" width="9" height="9" rx="1.5" />190 <line x1="5.5" y1="6.5" x2="5.5" y2="10" />191 <line x1="8.5" y1="6.5" x2="8.5" y2="10" />192 </svg>193 </button>194 </Show>195 </div>196 </div>197198 {}199 <div200 ref={contentScrollRef}201 class="peach-note-editor-scroll"202 style={contentHeight() !== null ? { height: `${contentHeight()}px` } : undefined}203 >204 {}205 <Show when={!isNativePlatform()}>206 <NoteToolbar exec={execFn} />207 </Show>208209 <div210 ref={titleRef}211 class="peach-note-title-field"212 contenteditable="true"213 data-placeholder="Title"214 onInput={() => {215 setTitle(titleRef.textContent ?? "");216 scheduleAutosave();217 }}218 onKeyDown={(e) => {219 if (e.key === "Enter") {220 e.preventDefault();221 bodyRef?.focus();222 }223 }}224 aria-label="Note title"225 />226227 <div class="peach-note-hairline" />228229 <div230 ref={bodyRef}231 class="peach-note-body-field"232 contenteditable="true"233 data-placeholder="Begin writing…"234 onInput={() => {235 setBody(bodyRef.innerHTML ?? "");236 scheduleAutosave();237 }}238 onKeyDown={(e) => {239 const mod = e.metaKey || e.ctrlKey;240 if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }241 if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }242 if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }243 }}244 aria-label="Note body"245 aria-multiline="true"246 />247 </div>248249 {}250 <div class="peach-note-saved-indicator" style={{ opacity: saved() ? "1" : "0" }}>251 saved.252 </div>253254 {}255 <Show when={isNativePlatform()}>256 <NoteToolbar257 exec={execFn}258 keyboardVisible={keyboardVisible()}259 onDismissKeyboard={() => {260 titleRef?.blur();261 bodyRef?.blur();262 }}263 />264 </Show>265 </div>266 );267}