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 { NoteToolbar, type ToolbarExec } from "../components/NoteToolbar";6import { toHtml } from "./NoteEditor";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 37 38 39 40 function bodyIsBlank(html: string): boolean {41 return !html42 .replace(/<[^>]*>| | |\u200b|\ufeff/g, "")43 .replace(/\s+/g, "");44 }4546 function scheduleAutosave() {47 setDirty(true);48 if (saveTimer) clearTimeout(saveTimer);49 saveTimer = window.setTimeout(save, AUTOSAVE_MS);50 }5152 async function save() {53 const t = title();54 const b = body();55 56 if (!t.trim() && bodyIsBlank(b)) return;57 const res = await sendMessage({58 type: "SAVE_NOTE",59 payload: { id: noteId(), title: t || "Untitled", body: b },60 }) as { ok: boolean; data?: VaultEntry };61 if (res?.ok && res.data) {62 setNoteId(res.data.id);63 setDirty(false);64 showSaved();65 props.onSaved(res.data);66 }67 }6869 function showSaved() {70 setSaved(true);71 if (savedFadeTimer) clearTimeout(savedFadeTimer);72 savedFadeTimer = window.setTimeout(() => setSaved(false), SAVED_DISPLAY_MS);73 }7475 function exec(cmd: string, val?: string) {76 document.execCommand(cmd, false, val);77 78 79 80 setBody(bodyRef?.innerHTML ?? "");81 bodyRef?.focus();82 scheduleAutosave();83 }8485 async function handleBack() {86 87 88 if (dirty() && (title().trim() || !bodyIsBlank(body()))) {89 if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }90 await save();91 }92 props.onBack();93 }9495 function handleKeyboardResize() {96 if (!isNativePlatform()) return;97 const vp = window.visualViewport;98 const viewportH = vp?.height ?? window.innerHeight;99 const kbH = window.innerHeight - viewportH;100 if (kbH > 50) {101 setKeyboardVisible(true);102 setContentHeight(viewportH - HEADER_HEIGHT);103 if (document.activeElement) {104 (document.activeElement as HTMLElement).scrollIntoView?.({ behavior: "smooth", block: "nearest" });105 }106 } else {107 setKeyboardVisible(false);108 setContentHeight(null);109 }110 }111112 onMount(() => {113 114 if (titleRef) titleRef.textContent = props.note?.title ?? "";115 116 if (bodyRef) bodyRef.innerHTML = toHtml(props.note?.notes ?? "");117118 if (props.autoFocusTitle && titleRef) {119 setTimeout(() => {120 titleRef.focus();121 122 const range = document.createRange();123 const sel = window.getSelection();124 range.selectNodeContents(titleRef);125 range.collapse(false);126 sel?.removeAllRanges();127 sel?.addRange(range);128 }, 300);129 }130131 if (isNativePlatform()) {132 window.addEventListener("resize", handleKeyboardResize);133 }134135 onCleanup(() => {136 if (saveTimer) clearTimeout(saveTimer);137 if (savedFadeTimer) clearTimeout(savedFadeTimer);138 if (isNativePlatform()) window.removeEventListener("resize", handleKeyboardResize);139 });140 });141142 143 let swipeStartX = 0;144 let swipeStartY = 0;145 let swipeCaptured = false;146147 function handleSwipeStart(e: TouchEvent) {148 const t = e.touches[0];149 if (!t) return;150 swipeStartX = t.clientX;151 swipeStartY = t.clientY;152 swipeCaptured = false;153 }154155 function handleSwipeMove(e: TouchEvent) {156 const t = e.touches[0];157 if (!t) return;158 const dx = t.clientX - swipeStartX;159 const dy = Math.abs(t.clientY - swipeStartY);160 if (!swipeCaptured && swipeStartX < 30 && dx > 40 && dy < dx) {161 swipeCaptured = true;162 }163 }164165 function handleSwipeEnd() {166 if (swipeCaptured) {167 handleBack();168 }169 swipeCaptured = false;170 }171172 const execFn: ToolbarExec = exec;173174 return (175 <div176 class="peach-note-editor-surface"177 onTouchStart={handleSwipeStart}178 onTouchMove={handleSwipeMove}179 onTouchEnd={handleSwipeEnd}180 >181 {}182 <div class="peach-notes-header peach-notes-header--editor">183 <button class="peach-notes-back" onClick={handleBack} aria-label="Back to notes list">‹</button>184 <span class="peach-notes-header-title" onClick={handleBack}>notes.</span>185 <div class="peach-notes-header-actions">186 <Show when={noteId()}>187 <button188 class="peach-notes-header-btn"189 aria-label="Delete note"190 onClick={() => {191 const id = noteId();192 if (!id) return;193 194 props.onDelete({ id, title: title(), notes: body(), type: "note", createdAt: 0, modifiedAt: Date.now() } as VaultEntry);195 }}196 title="Delete note"197 >198 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">199 <polyline points="1,3.5 13,3.5" />200 <path d="M5 3.5V2h4v1.5" />201 <rect x="2.5" y="3.5" width="9" height="9" rx="1.5" />202 <line x1="5.5" y1="6.5" x2="5.5" y2="10" />203 <line x1="8.5" y1="6.5" x2="8.5" y2="10" />204 </svg>205 </button>206 </Show>207 </div>208 </div>209210 {}211 <div212 ref={contentScrollRef}213 class="peach-note-editor-scroll"214 style={contentHeight() !== null ? { height: `${contentHeight()}px` } : undefined}215 >216 {}217 <Show when={!isNativePlatform()}>218 <NoteToolbar exec={execFn} />219 </Show>220221 <div222 ref={titleRef}223 class="peach-note-title-field"224 contenteditable="true"225 data-placeholder="Title"226 onInput={() => {227 setTitle(titleRef.textContent ?? "");228 scheduleAutosave();229 }}230 onKeyDown={(e) => {231 if (e.key === "Enter") {232 e.preventDefault();233 bodyRef?.focus();234 }235 }}236 aria-label="Note title"237 />238239 <div class="peach-note-hairline" />240241 <div242 ref={bodyRef}243 class="peach-note-body-field"244 contenteditable="true"245 data-placeholder="Begin writing…"246 onInput={() => {247 setBody(bodyRef.innerHTML ?? "");248 scheduleAutosave();249 }}250 onKeyDown={(e) => {251 const mod = e.metaKey || e.ctrlKey;252 if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }253 if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }254 if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }255 }}256 aria-label="Note body"257 aria-multiline="true"258 />259 </div>260261 {}262 <div class="peach-note-saved-indicator" style={{ opacity: saved() ? "1" : "0" }}>263 saved.264 </div>265266 {}267 <Show when={isNativePlatform()}>268 <NoteToolbar269 exec={execFn}270 keyboardVisible={keyboardVisible()}271 onDismissKeyboard={() => {272 titleRef?.blur();273 bodyRef?.blur();274 }}275 />276 </Show>277 </div>278 );279}