d94d9e

feat: hoist note formatting toolbar inline into tabs row + inline Entry.tsx note editor (v1.4.56)

time
2026-04-23 14:13 UTC · 6 months ago
machine
koh2
parent
fd0858
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/popup.css+55 −8src/popup/views/Add.tsx+24 −12src/popup/views/Entry.tsx+42 −52src/popup/views/NoteEditor.tsx+21 −9
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.4.55",4+  "version": "1.4.56",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.55",4+  "version": "1.4.56",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.55",3+  "version": "1.4.56",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/styles/popup.css+55 −8
@@ -1885,9 +1885,10 @@18851885 }18861886 .peach-entry-body::-webkit-scrollbar { display: none; }18871887 1888-/* Type-toggle pills (new entry only) */1888+/* Type-toggle pills + inline note toolbar */18891889 .peach-new-type-row {18901890   display: flex;1891+  align-items: center;18911892   gap: 6px;18921893   margin-bottom: 2px;18931894 }@@ -2358,22 +2359,68 @@23582359   pointer-events: none;23592360 }23602361 2361-/* Inline variant (Add.tsx) */2362+/* Inline variant (Add.tsx, Entry.tsx note) — toolbar hoisted to parent row */23622363 .peach-note-editor-inline {23632364   border: 1px solid var(--peach-t3);23642365   border-radius: 8px;23652366   overflow: hidden;2367+  display: flex;2368+  flex-direction: column;2369+  flex: 1;2370+  min-height: 0;23662371 }2367-.peach-note-editor-inline .peach-note-toolbar {2368-  border-bottom: 1px solid var(--peach-t3);2369-  background: var(--peach-t4);2370-  padding: 5px 10px 4px;2371-}23722372 .peach-note-editor-body--inline {2373-  min-height: 250px;2373+  flex: 1;2374+  min-height: 200px;23742375   font-size: 15px;23752376   padding: 10px 14px 14px;23762377   background: var(--peach-t4);2378+}2379+2380+/* Right-aligned inline toolbar sitting inside .peach-new-type-row (Add.tsx)2381+   or .peach-entry-note-toolbar-row (Entry.tsx). Reuses .peach-note-tool-btn2382+   and .peach-note-tool-divider button-level styles. */2383+.peach-note-toolbar-inline {2384+  display: flex;2385+  align-items: center;2386+  gap: 3px;2387+  margin-left: auto;2388+  padding-left: 10px;2389+  border-left: 1px solid var(--peach-t3);2390+  min-height: 22px;2391+}2392+2393+/* Entry.tsx toolbar row (no tabs, toolbar only) */2394+.peach-entry-note-toolbar-row {2395+  display: flex;2396+  align-items: center;2397+  margin: 4px 0 6px;2398+  position: relative;2399+  z-index: 1;2400+}2401+.peach-entry-note-toolbar-row .peach-note-toolbar-inline {2402+  border-left: none;2403+  padding-left: 0;2404+  margin-left: 0;2405+}2406+2407+/* Note body — fills available vertical space in Add.tsx and Entry.tsx */2408+.peach-note-body {2409+  display: flex;2410+  flex-direction: column;2411+  flex: 1;2412+  min-height: 0;2413+  margin: 4px 0 0;2414+}2415+.peach-entry-body--new .peach-note-body {2416+  margin: 4px 12px 0;2417+}2418+2419+/* Add.tsx body is a grid by default; swap to a flex column when a note is2420+   active so the note body can stretch and pin the tags row to the bottom. */2421+.peach-entry-body--new:has(.peach-note-body) {2422+  display: flex;2423+  flex-direction: column;23772424 }23782425 23792426 /* Rich content element styles (editor + display) */

view file

src/popup/views/Add.tsx+24 −12
@@ -2,7 +2,7 @@22 import type { VaultEntry } from "../../shared/types";33 import { sendMessage } from "../App";44 import { parseOtpauthURI } from "../../crypto/totp";5-import { NoteEditorInline } from "./NoteEditor";5+import { NoteEditorInline, Toolbar } from "./NoteEditor";66 import { GeneratorDrawer } from "../components/GeneratorDrawer";77 88 interface AddProps {@@ -34,7 +34,13 @@3434 3535   const isNoteEntry = () => props.entry?.type === "note";3636   const [selectedType, setSelectedType] = createSignal<"login" | "note">(isNoteEntry() ? "note" : "login");37+  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);3738 39+  function noteExec(cmd: string, val?: string) {40+    document.execCommand(cmd, false, val);41+    noteEditorEl()?.focus();42+  }43+3844   const canSave = () => {3945     if (!title().trim()) return false;4046     if (selectedType() === "login" && !password().trim()) return false;@@ -174,17 +180,22 @@174180       {/* ── Body — mirrors Entry.tsx field-card structure ── */}175181       <div class="peach-entry-body peach-entry-body--new">176182 177-        {/* Type toggle — only for new entries without a fixed type */}178-        <Show when={!props.entry}>183+        {/* Type toggle + inline note formatting toolbar */}184+        <Show when={!props.entry || selectedType() === "note"}>179185           <div class="peach-new-type-row">180-            <button181-              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}182-              onClick={() => setSelectedType("login")}183-            >login</button>184-            <button185-              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}186-              onClick={() => setSelectedType("note")}187-            >note</button>186+            <Show when={!props.entry}>187+              <button188+                class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}189+                onClick={() => setSelectedType("login")}190+              >login</button>191+              <button192+                class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}193+                onClick={() => setSelectedType("note")}194+              >note</button>195+            </Show>196+            <Show when={selectedType() === "note"}>197+              <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />198+            </Show>188199           </div>189200         </Show>190201 @@ -315,10 +326,11 @@315326 316327         {/* ── Note content ── */}317328         <Show when={selectedType() === "note"}>318-          <div style="margin:4px 12px 0">329+          <div class="peach-note-body">319330             <NoteEditorInline320331               initialContent={notes()}321332               onContentChange={setNotes}333+              onReady={(el) => setNoteEditorEl(el)}322334             />323335           </div>324336         </Show>

view file

src/popup/views/Entry.tsx+42 −52
@@ -1,12 +1,11 @@11 import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2-import { Portal } from "solid-js/web";32 import type { VaultEntry } from "../../shared/types";43 import { getDisplayDomain } from "../../shared/domain";54 import { faviconUrl } from "../../shared/favicon";65 import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";76 import { sendMessage, triggerPipPulse, triggerRing } from "../App";87 import { GeneratorDrawer } from "../components/GeneratorDrawer";9-import { NoteEditor } from "./NoteEditor";8+import { NoteEditorInline, Toolbar } from "./NoteEditor";109 1110 type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1211 @@ -28,9 +27,37 @@2827   const [totpTick, setTotpTick] = createSignal(0);2928   const [confirmDelete, setConfirmDelete] = createSignal(false);3029   const [totpHot, setTotpHot] = createSignal(false);31-  const [noteEditing, setNoteEditing] = createSignal(false);3230   const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);31+  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);32+  let lastSavedNote: string | null = null;3333 34+  function noteExec(cmd: string, val?: string) {35+    document.execCommand(cmd, false, val);36+    noteEditorEl()?.focus();37+  }38+39+  async function saveNoteInline(html: string) {40+    const entry = props.entry;41+    if (!entry) return;42+    const currentStored = entry.notes ?? "";43+    if (html === currentStored) return;44+    if (lastSavedNote !== null && html === lastSavedNote) return;45+    lastSavedNote = html;46+    setSaving(true);47+    try {48+      await sendMessage({49+        type: "UPDATE_ENTRY",50+        payload: { id: entry.id, patch: { notes: html } },51+      });52+      triggerRing();53+      await props.onFieldSaved();54+    } catch {55+      lastSavedNote = null;56+    } finally {57+      setSaving(false);58+    }59+  }60+3461   const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);3562   onCleanup(() => window.clearInterval(interval));3663 @@ -143,25 +170,6 @@143170     setShowGenerator(false);144171   }145172 146-  async function saveNote(html: string) {147-    const entry = props.entry;148-    if (!entry) return;149-    setSaving(true);150-    try {151-      await sendMessage({152-        type: "UPDATE_ENTRY",153-        payload: { id: entry.id, patch: { notes: html } },154-      });155-      triggerRing();156-      setNoteEditing(false);157-      await props.onFieldSaved();158-    } catch {159-      // silent — leave editor open160-    } finally {161-      setSaving(false);162-    }163-  }164-165173   function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {166174     switch (field) {167175       case "title":@@ -459,27 +467,19 @@459467                 </div>460468               </Show>461469 462-              {/* ── Note content ─────────────────────────────────── */}470+              {/* ── Note toolbar + inline editor ─────────────────── */}463471               <Show when={entry().type === "note"}>464-                <section class="peach-fields" style="position:relative;z-index:1;">465-                  <Show466-                    when={entry().notes}467-                    fallback={468-                      <div469-                        class="peach-note-content peach-inline-clickable"470-                        style="cursor:text;"471-                        onClick={() => setNoteEditing(true)}472-                      >473-                        <span style="color:var(--peach-t2);font-style:italic;">click to add notes</span>474-                      </div>475-                    }476-                  >477-                    <div478-                      class="peach-note-content peach-inline-clickable"479-                      innerHTML={entry().notes}480-                      onClick={() => setNoteEditing(true)}481-                    />482-                  </Show>472+                <div class="peach-entry-note-toolbar-row">473+                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />474+                </div>475+                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">476+                  <NoteEditorInline477+                    entryKey={entry().id}478+                    initialContent={entry().notes ?? ""}479+                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}480+                    onBlur={saveNoteInline}481+                    autoFocus={false}482+                  />483483                 </section>484484               </Show>485485 @@ -748,16 +748,6 @@748748         </Show>749749       </div>750750 751-      <Show when={noteEditing()}>752-        <Portal mount={document.getElementById("hd-shell") ?? document.body}>753-          <NoteEditor754-            initialContent={props.entry?.notes ?? ""}755-            saving={saving()}756-            onSave={saveNote}757-            onCancel={() => setNoteEditing(false)}758-          />759-        </Portal>760-      </Show>761751     </>762752   );763753 }

view file

src/popup/views/NoteEditor.tsx+21 −9
@@ -1,4 +1,4 @@1-import { onMount } from "solid-js";1+import { createEffect, on, onMount } from "solid-js";22 33 export function toHtml(raw: string): string {44   if (/<\/?[a-z]/i.test(raw)) return raw;@@ -45,16 +45,16 @@4545   </svg>4646 );4747 48-type ExecFn = (cmd: string, val?: string) => void;48+export type ExecFn = (cmd: string, val?: string) => void;4949 50-function Toolbar(props: { exec: ExecFn }) {50+export function Toolbar(props: { exec: ExecFn; class?: string }) {5151   const e = props.exec;5252   function toggleHeading() {5353     const val = document.queryCommandValue("formatBlock").toLowerCase();5454     e("formatBlock", val === "h2" ? "p" : "h2");5555   }5656   return (57-    <div class="peach-note-toolbar">57+    <div class={props.class ?? "peach-note-toolbar"}>5858       <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("bold"); }} title="Bold (⌘B)"><BoldIcon /></button>5959       <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("italic"); }} title="Italic (⌘I)"><ItalicIcon /></button>6060       <button class="peach-note-tool-btn" onMouseDown={(ev) => { ev.preventDefault(); e("underline"); }} title="Underline (⌘U)"><UnderlineIcon /></button>@@ -133,11 +133,16 @@133133   );134134 }135135 136-/* ── Inline editor (Add.tsx) ── */136+/* ── Inline editor (Add.tsx, Entry.tsx note view) ── */137137 138138 interface NoteEditorInlineProps {139139   initialContent: string;140-  onContentChange: (html: string) => void;140+  onContentChange?: (html: string) => void;141+  onBlur?: (html: string) => void;142+  onReady?: (el: HTMLDivElement) => void;143+  autoFocus?: boolean;144+  /** When this key changes, the editor re-loads initialContent (e.g., switching between vault entries in-place). */145+  entryKey?: string;141146 }142147 143148 export function NoteEditorInline(props: NoteEditorInlineProps) {@@ -146,9 +151,16 @@146151   onMount(() => {147152     if (!editorRef) return;148153     editorRef.innerHTML = toHtml(props.initialContent);149-    editorRef.focus();154+    if (props.autoFocus !== false) editorRef.focus();155+    props.onReady?.(editorRef);150156   });151157 158+  createEffect(on(() => props.entryKey, (key, prevKey) => {159+    if (prevKey === undefined || key === prevKey || !editorRef) return;160+    editorRef.innerHTML = toHtml(props.initialContent);161+    props.onReady?.(editorRef);162+  }));163+152164   function exec(cmd: string, value?: string) {153165     document.execCommand(cmd, false, value);154166     editorRef?.focus();@@ -163,12 +175,12 @@163175 164176   return (165177     <div class="peach-note-editor-inline">166-      <Toolbar exec={exec} />167178       <div ref={editorRef}168179         class="peach-note-editor-body peach-note-editor-body--inline"169180         contenteditable="true"170181         onKeyDown={handleKeyDown}171-        onInput={() => props.onContentChange(editorRef?.innerHTML ?? "")}182+        onInput={() => props.onContentChange?.(editorRef?.innerHTML ?? "")}183+        onBlur={() => props.onBlur?.(editorRef?.innerHTML ?? "")}172184         aria-multiline="true"173185         aria-label="Note content"174186       />

view file