4e3d50

feat: full-popup note editor with rich text toolbar (v1.4.53)

time
2026-04-23 07:32 UTC · 6 months ago
machine
koh2
parent
772de5
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/popup.css+159 −4src/popup/views/Entry.tsx+38 −19src/popup/views/NoteEditor.tsx+112 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.4.52",4+  "version": "1.4.53",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.4.52",4+  "version": "1.4.53",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "1.4.52",3+  "version": "1.4.53",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/styles/popup.css+159 −4
@@ -457,15 +457,14 @@457457     background 400ms ease;458458 }459459 460-/* Note content */460+/* Note content (display) */461461 .peach-note-content {462462   border-top: 1px solid var(--peach-t3);463463   padding-top: 10px;464-  font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;465-  font-size: 11px;464+  font-family: "Cormorant Garamond", "Times New Roman", serif;465+  font-size: 15px;466466   line-height: 1.75;467467   color: var(--peach-t1);468-  white-space: pre-wrap;469468 }470469 471470 /* ── Inline editing ─────────────────────────────────────────────────── */@@ -2231,6 +2230,162 @@22312230   gap: 8px;22322231   font-size: 11px;22332232   color: rgba(40, 180, 80, 0.9);2233+}2234+2235+/* ── Note editor overlay ── */2236+.peach-note-editor {2237+  position: fixed;2238+  inset: 0;2239+  z-index: 200;2240+  display: flex;2241+  flex-direction: column;2242+  background:2243+    radial-gradient(120% 80% at 0% 0%, rgba(217,104,48,0.07) 0%, transparent 65%),2244+    var(--peach-bg);2245+  animation: overlay-in 180ms cubic-bezier(0.4, 0, 0.2, 1) both;2246+}2247+2248+.peach-note-editor-header {2249+  display: flex;2250+  align-items: center;2251+  gap: 10px;2252+  padding: 14px 16px 10px;2253+  border-bottom: 1px solid var(--peach-t3);2254+  flex-shrink: 0;2255+}2256+2257+.peach-note-editor-back {2258+  background: transparent;2259+  border: none;2260+  cursor: pointer;2261+  color: var(--peach-t2);2262+  padding: 4px 6px;2263+  border-radius: 6px;2264+  display: flex;2265+  align-items: center;2266+  transition: color 120ms ease, background 120ms ease;2267+  flex-shrink: 0;2268+}2269+.peach-note-editor-back:hover { color: var(--peach-t0); background: var(--peach-t4); }2270+2271+.peach-note-editor-label {2272+  flex: 1;2273+  font-family: "Cormorant Garamond", "Times New Roman", serif;2274+  font-style: italic;2275+  font-size: 15px;2276+  color: var(--peach-t1);2277+  letter-spacing: 0.02em;2278+}2279+2280+.peach-note-editor-save-btn {2281+  background: transparent;2282+  border: 1px solid rgba(217, 104, 48, 0.4);2283+  border-radius: 8px;2284+  color: var(--peach-accent);2285+  font-family: "IBM Plex Mono", monospace;2286+  font-size: 10px;2287+  letter-spacing: 0.06em;2288+  text-transform: uppercase;2289+  padding: 3px 10px;2290+  cursor: pointer;2291+  transition: background 120ms ease, border-color 120ms ease;2292+}2293+.peach-note-editor-save-btn:hover:not(:disabled) {2294+  background: rgba(217, 104, 48, 0.1);2295+  border-color: rgba(217, 104, 48, 0.65);2296+}2297+.peach-note-editor-save-btn:disabled { opacity: 0.4; cursor: not-allowed; }2298+2299+.peach-note-toolbar {2300+  display: flex;2301+  align-items: center;2302+  gap: 3px;2303+  padding: 6px 14px 5px;2304+  border-bottom: 1px solid var(--peach-t3);2305+  flex-shrink: 0;2306+}2307+2308+.peach-note-tool-btn {2309+  background: transparent;2310+  border: 1px solid transparent;2311+  border-radius: 5px;2312+  color: var(--peach-t2);2313+  font-family: "IBM Plex Mono", monospace;2314+  font-size: 10px;2315+  letter-spacing: 0.04em;2316+  padding: 2px 8px;2317+  cursor: pointer;2318+  line-height: 1.6;2319+  transition: background 100ms ease, color 100ms ease, border-color 100ms ease;2320+}2321+.peach-note-tool-btn:hover {2322+  background: var(--peach-t4);2323+  color: var(--peach-t0);2324+  border-color: var(--peach-t3);2325+}2326+2327+.peach-note-tool-bold {2328+  font-weight: 700;2329+  font-family: Inter, system-ui, sans-serif;2330+  font-size: 11px;2331+}2332+.peach-note-tool-italic {2333+  font-style: italic;2334+  font-family: "Cormorant Garamond", serif;2335+  font-size: 14px;2336+  padding: 1px 7px;2337+}2338+2339+.peach-note-tool-divider {2340+  width: 1px;2341+  height: 14px;2342+  background: var(--peach-t3);2343+  margin: 0 4px;2344+  flex-shrink: 0;2345+}2346+2347+.peach-note-editor-body {2348+  flex: 1;2349+  overflow-y: auto;2350+  padding: 16px 20px 20px;2351+  outline: none;2352+  font-family: "Cormorant Garamond", "Times New Roman", serif;2353+  font-size: 16px;2354+  line-height: 1.75;2355+  color: var(--peach-t0);2356+  scrollbar-width: none;2357+  word-break: break-word;2358+}2359+.peach-note-editor-body:empty::before {2360+  content: "write your note…";2361+  color: var(--peach-t3);2362+  font-style: italic;2363+  pointer-events: none;2364+}2365+2366+/* font size elements from execCommand */2367+.peach-note-editor-body font[size="1"],2368+.peach-note-content font[size="1"] { font-size: 11px; line-height: 1.6; }2369+.peach-note-editor-body font[size="2"],2370+.peach-note-content font[size="2"] { font-size: 13px; }2371+.peach-note-editor-body font[size="3"],2372+.peach-note-content font[size="3"] { font-size: 16px; }2373+.peach-note-editor-body font[size="4"],2374+.peach-note-content font[size="4"] { font-size: 20px; }2375+.peach-note-editor-body font[size="5"],2376+.peach-note-content font[size="5"] { font-size: 26px; line-height: 1.4; }2377+2378+/* bold / italic inside the editor and note display */2379+.peach-note-editor-body b, .peach-note-editor-body strong,2380+.peach-note-content b, .peach-note-content strong { font-weight: 700; }2381+.peach-note-editor-body em, .peach-note-editor-body i,2382+.peach-note-content em, .peach-note-content i { font-style: italic; }2383+2384+/* note display — rendered HTML, no pre-wrap */2385+.peach-note-content {2386+  overflow: auto;2387+  max-height: 340px;2388+  word-break: break-word;22342389 }22352390 22362391 /* ── Note textarea ── */

view file

src/popup/views/Entry.tsx+38 −19
@@ -5,6 +5,7 @@55 import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";66 import { sendMessage, triggerPipPulse, triggerRing } from "../App";77 import { GeneratorDrawer } from "../components/GeneratorDrawer";8+import { NoteEditor } from "./NoteEditor";89 910 type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1011 @@ -26,6 +27,7 @@2627   const [totpTick, setTotpTick] = createSignal(0);2728   const [confirmDelete, setConfirmDelete] = createSignal(false);2829   const [totpHot, setTotpHot] = createSignal(false);30+  const [noteEditing, setNoteEditing] = createSignal(false);2931   const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);3032 3133   const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);@@ -140,6 +142,25 @@140142     setShowGenerator(false);141143   }142144 145+  async function saveNote(html: string) {146+    const entry = props.entry;147+    if (!entry) return;148+    setSaving(true);149+    try {150+      await sendMessage({151+        type: "UPDATE_ENTRY",152+        payload: { id: entry.id, patch: { notes: html } },153+      });154+      triggerRing();155+      setNoteEditing(false);156+      await props.onFieldSaved();157+    } catch {158+      // silent — leave editor open159+    } finally {160+      setSaving(false);161+    }162+  }163+143164   function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {144165     switch (field) {145166       case "title":@@ -441,33 +462,22 @@441462               <Show when={entry().type === "note"}>442463                 <section class="peach-fields" style="position:relative;z-index:1;">443464                   <Show444-                    when={editingField() === "notes"}465+                    when={entry().notes}445466                     fallback={446467                       <div447468                         class="peach-note-content peach-inline-clickable"448-                        style="display:block;white-space:pre-wrap;overflow:auto;max-height:340px;cursor:text;"449-                        title="Click to edit"450-                        onClick={() => startEdit("notes", entry().notes ?? "")}469+                        style="cursor:text;"470+                        onClick={() => setNoteEditing(true)}451471                       >452-                        {entry().notes || <span style="color:var(--t2);font-style:italic;">click to add notes</span>}472+                        <span style="color:var(--peach-t2);font-style:italic;">click to add notes</span>453473                       </div>454474                     }455475                   >456476                     <div457-                      class="peach-inline-field-wrap"458-                      onFocusOut={(event) => handleEditorFocusOut("notes", event)}459-                    >460-                      <textarea461-                        class="peach-inline-textarea"462-                        value={draft()}463-                        onInput={(e) => setDraft(e.currentTarget.value)}464-                        onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}465-                        ref={(el) => requestAnimationFrame(() => el?.focus())}466-                        rows={6}467-                        disabled={saving()}468-                      />469-                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />470-                    </div>477+                      class="peach-note-content peach-inline-clickable"478+                      innerHTML={entry().notes}479+                      onClick={() => setNoteEditing(true)}480+                    />471481                   </Show>472482                 </section>473483               </Show>@@ -736,6 +746,15 @@736746           )}737747         </Show>738748       </div>749+750+      <Show when={noteEditing()}>751+        <NoteEditor752+          initialContent={props.entry?.notes ?? ""}753+          saving={saving()}754+          onSave={saveNote}755+          onCancel={() => setNoteEditing(false)}756+        />757+      </Show>739758     </>740759   );741760 }

view file

src/popup/views/NoteEditor.tsx+112 −0
@@ -0,0 +1,112 @@1+import { onMount } from "solid-js";2+3+interface NoteEditorProps {4+  initialContent: string;5+  saving: boolean;6+  onSave: (html: string) => Promise<void>;7+  onCancel: () => void;8+}9+10+function toHtml(raw: string): string {11+  if (/<\/?[a-z]/i.test(raw)) return raw;12+  return raw13+    .replace(/&/g, "&amp;")14+    .replace(/</g, "&lt;")15+    .replace(/>/g, "&gt;")16+    .replace(/\n/g, "<br>");17+}18+19+export function NoteEditor(props: NoteEditorProps) {20+  let editorRef: HTMLDivElement | undefined;21+22+  onMount(() => {23+    if (!editorRef) return;24+    editorRef.innerHTML = toHtml(props.initialContent);25+    editorRef.focus();26+    const sel = window.getSelection();27+    if (sel) {28+      const range = document.createRange();29+      range.selectNodeContents(editorRef);30+      range.collapse(false);31+      sel.removeAllRanges();32+      sel.addRange(range);33+    }34+  });35+36+  function exec(cmd: string, value?: string) {37+    document.execCommand(cmd, false, value);38+    editorRef?.focus();39+  }40+41+  function handleKeyDown(e: KeyboardEvent) {42+    const mod = e.metaKey || e.ctrlKey;43+    if (mod && e.key === "b") { e.preventDefault(); exec("bold"); return; }44+    if (mod && e.key === "i") { e.preventDefault(); exec("italic"); return; }45+    if (e.key === "Escape") { e.preventDefault(); props.onCancel(); }46+  }47+48+  return (49+    <div class="peach-note-editor">50+      <div class="peach-note-editor-header">51+        <button52+          class="peach-note-editor-back"53+          onClick={props.onCancel}54+          disabled={props.saving}55+          aria-label="Cancel"56+        >57+          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">58+            <path d="M6 1L1 6L6 11" />59+          </svg>60+        </button>61+        <span class="peach-note-editor-label">note</span>62+        <button63+          class="peach-note-editor-save-btn"64+          onClick={() => void props.onSave(editorRef?.innerHTML ?? "")}65+          disabled={props.saving}66+        >67+          {props.saving ? "saving…" : "save"}68+        </button>69+      </div>70+71+      <div class="peach-note-toolbar">72+        <button73+          class="peach-note-tool-btn peach-note-tool-bold"74+          onMouseDown={(e) => { e.preventDefault(); exec("bold"); }}75+          aria-label="Bold"76+          title="Bold (⌘B)"77+        >B</button>78+        <button79+          class="peach-note-tool-btn peach-note-tool-italic"80+          onMouseDown={(e) => { e.preventDefault(); exec("italic"); }}81+          aria-label="Italic"82+          title="Italic (⌘I)"83+        >I</button>84+        <div class="peach-note-tool-divider" />85+        <button86+          class="peach-note-tool-btn"87+          onMouseDown={(e) => { e.preventDefault(); exec("fontSize", "1"); }}88+          title="Small"89+        >sm</button>90+        <button91+          class="peach-note-tool-btn"92+          onMouseDown={(e) => { e.preventDefault(); exec("fontSize", "3"); }}93+          title="Normal"94+        >md</button>95+        <button96+          class="peach-note-tool-btn"97+          onMouseDown={(e) => { e.preventDefault(); exec("fontSize", "5"); }}98+          title="Large"99+        >lg</button>100+      </div>101+102+      <div103+        ref={editorRef}104+        class="peach-note-editor-body"105+        contenteditable="true"106+        onKeyDown={handleKeyDown}107+        aria-multiline="true"108+        aria-label="Note content"109+      />110+    </div>111+  );112+}

view file