fa5725c92ff1
gestures: long-press context menu (copy username/password/totp/open/trash), swipe-right-to-copy on compact, vibrate haptic, copy-flash + bottom-sheet menu styles
- time
- 2026-05-01 04:13 UTC · 5 months ago
- machine
- koh2
- parent
- cf2c58c238b2
↓ peach-browser-fa5725c9.tar.gzatlas at this save
files changed2 files
src/popup/styles/popup.css+63 −0src/popup/views/Vault.tsx+166 −1src/popup/styles/popup.css+63 −0
@@ -7143,3 +7143,66 @@71437143 body.peach-mobile.layout-expanded .peach-search-wrap {71447144 padding-left: 360px;71457145 }7146+7147+/* ── Long-press context menu + swipe-copy flash ─────────────────────── */7148+.peach-context-menu-backdrop {7149+ position: fixed;7150+ inset: 0;7151+ background: rgba(0, 0, 0, 0.45);7152+ z-index: 100;7153+ display: flex;7154+ align-items: flex-end;7155+ justify-content: center;7156+ animation: peach-ctx-fade 140ms ease both;7157+}7158+.peach-context-menu {7159+ width: min(360px, calc(100vw - 24px));7160+ margin-bottom: max(env(safe-area-inset-bottom), 12px);7161+ background: var(--peach-bg);7162+ border: 1px solid var(--peach-t3);7163+ border-radius: 14px;7164+ padding: 6px;7165+ display: flex;7166+ flex-direction: column;7167+ gap: 2px;7168+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.55);7169+ animation: peach-ctx-rise 200ms cubic-bezier(0.16, 1, 0.3, 1) both;7170+}7171+@keyframes peach-ctx-fade { from { opacity: 0; } to { opacity: 1; } }7172+@keyframes peach-ctx-rise {7173+ from { transform: translateY(16px); opacity: 0; }7174+ to { transform: translateY(0); opacity: 1; }7175+}7176+.peach-context-menu-item {7177+ background: transparent;7178+ color: var(--peach-t0);7179+ border: 0;7180+ border-radius: 10px;7181+ padding: 12px 14px;7182+ text-align: left;7183+ font: inherit;7184+ cursor: pointer;7185+}7186+.peach-context-menu-item:hover,7187+.peach-context-menu-item:focus-visible {7188+ background: var(--peach-t3);7189+}7190+.peach-context-menu-item--danger {7191+ color: var(--peach-health-bad, #d96868);7192+}7193+.peach-context-menu-item--cancel {7194+ color: var(--peach-t2);7195+ margin-top: 4px;7196+ border-top: 1px solid var(--peach-t3);7197+ border-radius: 0 0 10px 10px;7198+}7199+7200+/* Swipe-to-copy amber flash */7201+.peach-entry-row.is-copy-flash {7202+ animation: peach-copy-flash 600ms ease both;7203+}7204+@keyframes peach-copy-flash {7205+ 0% { background: transparent; }7206+ 20% { background: rgba(217, 153, 51, 0.32); }7207+ 100% { background: transparent; }7208+}src/popup/views/Vault.tsx+166 −1
@@ -2,7 +2,12 @@22 import type { VaultEntry } from "../../shared/types";33 import { extractDomain, getDisplayDomain } from "../../shared/domain";44 import { faviconUrl } from "../../shared/favicon";5+import { popupCopy } from "../copy";56 7+function vibrate(ms: number) {8+ try { navigator.vibrate?.(ms); } catch { /* unsupported */ }9+}10+611 function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {712 const src = props.domain && !props.isNote && !props.isPasskey813 ? faviconUrl(props.domain)@@ -119,7 +124,71 @@119124 const [selecting, setSelecting] = createSignal(false);120125 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());121126 const [deleting, setDeleting] = createSignal(false);127+ const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);128+ const [flashId, setFlashId] = createSignal<string | null>(null);129+ let longPressTimer: number | null = null;130+ let touchStart: { x: number; y: number; t: number } | null = null;131+ let suppressNextClick = false;122132 133+ function clearLongPressTimer() {134+ if (longPressTimer !== null) {135+ window.clearTimeout(longPressTimer);136+ longPressTimer = null;137+ }138+ }139+140+ function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {141+ if (selecting()) return;142+ const t = e.touches[0];143+ if (!t) return;144+ touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };145+ clearLongPressTimer();146+ longPressTimer = window.setTimeout(() => {147+ suppressNextClick = true;148+ vibrate(20);149+ setContextEntry(entry);150+ longPressTimer = null;151+ }, 500);152+ }153+154+ function handleRowTouchMove(e: TouchEvent) {155+ if (!touchStart) return;156+ const t = e.touches[0];157+ if (!t) return;158+ const dx = Math.abs(t.clientX - touchStart.x);159+ const dy = Math.abs(t.clientY - touchStart.y);160+ if (dx > 8 || dy > 8) clearLongPressTimer();161+ }162+163+ async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {164+ clearLongPressTimer();165+ if (!touchStart) return;166+ const t = e.changedTouches[0];167+ if (!t) { touchStart = null; return; }168+ const dx = t.clientX - touchStart.x;169+ const dy = Math.abs(t.clientY - touchStart.y);170+ const dt = Date.now() - touchStart.t;171+ touchStart = null;172+ // Right-swipe-to-copy: only on compact + login entries with a password173+ const isCompact = !document.body.classList.contains("layout-medium")174+ && !document.body.classList.contains("layout-expanded");175+ if (176+ isCompact177+ && !selecting()178+ && dx > 80179+ && dy < 30180+ && dt < 400181+ && entry.type === "login"182+ && entry.password183+ ) {184+ suppressNextClick = true;185+ vibrate(15);186+ setFlashId(entry.id);187+ window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);188+ await popupCopy(entry.password, "password");189+ }190+ }191+123192 let searchRef: HTMLInputElement | undefined;124193 let listRef: HTMLDivElement | undefined;125194 @@ -487,9 +556,15 @@487556 488557 return (489558 <button490- class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}559+ class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}${flashId() === entry.id ? " is-copy-flash" : ""}`}491560 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}561+ onTouchStart={(e) => handleRowTouchStart(entry, e)}562+ onTouchMove={handleRowTouchMove}563+ onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}564+ onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}565+ onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}492566 onClick={() => {567+ if (suppressNextClick) { suppressNextClick = false; return; }493568 if (selecting()) {494569 toggleId(entry.id);495570 } else {@@ -586,6 +661,96 @@586661 </span>587662 </Show>588663 </button>664+ </Show>665+666+ <Show when={contextEntry() !== null}>667+ <div668+ class="peach-context-menu-backdrop"669+ onClick={() => setContextEntry(null)}670+ role="presentation"671+ >672+ <div673+ class="peach-context-menu"674+ role="menu"675+ aria-label="Entry actions"676+ onClick={(e) => e.stopPropagation()}677+ >678+ <Show when={contextEntry()!.username}>679+ <button680+ role="menuitem"681+ class="peach-context-menu-item"682+ onClick={async () => {683+ const e = contextEntry();684+ if (!e?.username) return;685+ await popupCopy(e.username, "username");686+ setContextEntry(null);687+ }}688+ >689+ Copy username690+ </button>691+ </Show>692+ <Show when={contextEntry()!.password}>693+ <button694+ role="menuitem"695+ class="peach-context-menu-item"696+ onClick={async () => {697+ const e = contextEntry();698+ if (!e?.password) return;699+ await popupCopy(e.password, "password");700+ setContextEntry(null);701+ }}702+ >703+ Copy password704+ </button>705+ </Show>706+ <Show when={contextEntry()!.totp}>707+ <button708+ role="menuitem"709+ class="peach-context-menu-item"710+ onClick={async () => {711+ const e = contextEntry();712+ if (!e?.totp) return;713+ await popupCopy(e.totp, "TOTP secret");714+ setContextEntry(null);715+ }}716+ >717+ Copy TOTP secret718+ </button>719+ </Show>720+ <button721+ role="menuitem"722+ class="peach-context-menu-item"723+ onClick={() => {724+ const e = contextEntry();725+ if (e) props.onSelectEntry(e);726+ setContextEntry(null);727+ }}728+ >729+ Open730+ </button>731+ <Show when={props.onDeleteMany}>732+ <button733+ role="menuitem"734+ class="peach-context-menu-item peach-context-menu-item--danger"735+ onClick={async () => {736+ const e = contextEntry();737+ if (!e) return;738+ setContextEntry(null);739+ await props.onDeleteMany?.([e.id]);740+ }}741+ >742+ Move to trash743+ </button>744+ </Show>745+ <button746+ role="menuitem"747+ class="peach-context-menu-item peach-context-menu-item--cancel"748+ onClick={() => setContextEntry(null)}749+ >750+ Cancel751+ </button>752+ </div>753+ </div>589754 </Show>590755 </div>591756 );