asdf98 commited on
Commit
5f55e07
·
verified ·
1 Parent(s): ddfea23

feat: improve browser panel tabs, navigation feedback, and layout UX

Browse files
Files changed (1) hide show
  1. src/components/BrowserPanel.tsx +65 -105
src/components/BrowserPanel.tsx CHANGED
@@ -1,6 +1,6 @@
1
- import { X, ArrowLeft, ArrowRight, RotateCw, Plus, Globe, Maximize2, Minimize2, ShieldCheck, Lock, Camera, Scissors } from 'lucide-react';
2
  import { useAppStore } from '../store';
3
- import { useState, useRef, useEffect, useCallback } from 'react';
4
  import { invoke } from '@tauri-apps/api/core';
5
  import { listen } from '@tauri-apps/api/event';
6
 
@@ -15,41 +15,27 @@ const bookmarks = [
15
  { name: 'Line of Action', icon: '🏃', url: 'https://line-of-action.com' },
16
  ];
17
 
18
- // FIX: Track initialization state more carefully.
19
- // The promise is only cached while in-flight. Once resolved, subsequent calls
20
- // always invoke browser_init (which is idempotent - returns existing snapshot if tabs exist).
21
- // This ensures we never serve a stale snapshot from the initial call.
22
- let browserInitInFlight: Promise<any> | null = null;
23
  let browserInitDone = false;
24
-
 
 
 
25
  function ensureBrowserInit() {
26
- // If already initialized, just call browser_init which returns current snapshot (idempotent)
27
- if (browserInitDone) {
28
- return invoke<any>('browser_init', { layout: { x: 0, y: 0, width: 1, height: 1 } });
29
- }
30
- // If first call is in-flight, return the same promise to avoid double-init
31
- if (browserInitInFlight) {
32
- return browserInitInFlight;
33
- }
34
- browserInitInFlight = invoke<any>('browser_init', { layout: { x: 0, y: 0, width: 1, height: 1 } })
35
- .then((result) => {
36
- browserInitDone = true;
37
- browserInitInFlight = null;
38
- return result;
39
- })
40
- .catch((err) => {
41
- console.error('[BrowserPanel] browser_init failed', err);
42
- // FIX: Reset so next attempt retries instead of returning cached failure
43
- browserInitInFlight = null;
44
- browserInitDone = false;
45
- return null;
46
- });
47
  return browserInitInFlight;
48
  }
49
 
50
  export const BrowserPanel = () => {
51
  const { isBrowserOpen, setIsBrowserOpen, isWhisperBrowser, setImages, pan, zoom } = useAppStore();
52
- const [url, setUrl] = useState('https://unsplash.com');
 
53
  const [isFullscreen, setIsFullscreen] = useState(false);
54
  const [canGoBack, setCanGoBack] = useState(false);
55
  const [canGoForward, setCanGoForward] = useState(false);
@@ -63,6 +49,16 @@ export const BrowserPanel = () => {
63
  const [snipCurrent, setSnipCurrent] = useState<{x:number;y:number} | null>(null);
64
  const contentRef = useRef<HTMLDivElement>(null);
65
  const syncTimerRef = useRef<number | null>(null);
 
 
 
 
 
 
 
 
 
 
66
 
67
  const computeLayout = useCallback(() => {
68
  const el = contentRef.current;
@@ -74,9 +70,7 @@ export const BrowserPanel = () => {
74
 
75
  const hideWebview = useCallback(() => {
76
  if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
77
- invoke('browser_hide_all').catch(() => {
78
- invoke('browser_set_visible', { visible: false, layout: { x: 0, y: 0, width: 1, height: 1 } }).catch(() => {});
79
- });
80
  setWebviewVisible(false);
81
  }, []);
82
 
@@ -85,92 +79,58 @@ export const BrowserPanel = () => {
85
  const layout = computeLayout();
86
  if (!layout) return;
87
  const snap = await ensureBrowserInit();
88
- if (snap?.active && !activeTabId) setActiveTabId(snap.active);
89
- try {
90
- await invoke('browser_set_visible', { visible: true, layout });
91
- setWebviewVisible(true);
92
- } catch (err) {
93
- console.error('[BrowserPanel] browser_set_visible failed', err);
94
- }
95
- }, [isBrowserOpen, isSnipping, computeLayout, activeTabId]);
96
 
97
  const scheduleSync = useCallback((delay = 0) => {
98
  if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
99
  syncTimerRef.current = window.setTimeout(() => requestAnimationFrame(() => syncWebviewNow()), delay);
100
  }, [syncWebviewNow]);
101
 
102
- useEffect(() => {
103
- ensureBrowserInit().then((snap) => { if (snap?.active) setActiveTabId(snap.active); setIsBrowserReady(true); });
104
- return () => hideWebview();
105
- }, [hideWebview]);
106
-
107
- useEffect(() => {
108
- if (!isBrowserOpen) { hideWebview(); return; }
109
- scheduleSync(50);
110
- const timers = [160, 300, 450, 700, 1100].map(ms => window.setTimeout(() => scheduleSync(0), ms));
111
- return () => timers.forEach(t => window.clearTimeout(t));
112
- }, [isBrowserOpen, scheduleSync, hideWebview]);
113
-
114
- useEffect(() => {
115
- if (!isBrowserOpen) return;
116
- hideWebview();
117
- scheduleSync(80);
118
- const timers = [220, 420, 800].map(ms => window.setTimeout(() => scheduleSync(0), ms));
119
- return () => timers.forEach(t => window.clearTimeout(t));
120
- }, [isFullscreen, isWhisperBrowser, isBrowserOpen, hideWebview, scheduleSync]);
121
-
122
- useEffect(() => {
123
- const ro = new ResizeObserver(() => { if (isBrowserOpen && !isSnipping) scheduleSync(20); });
124
- if (contentRef.current) ro.observe(contentRef.current);
125
- const onResize = () => { if (isBrowserOpen && !isSnipping) scheduleSync(20); };
126
- window.addEventListener('resize', onResize);
127
- return () => { ro.disconnect(); window.removeEventListener('resize', onResize); };
128
- }, [isBrowserOpen, isSnipping, scheduleSync]);
129
 
130
- useEffect(() => {
131
- const unlisten = listen<any>('browser://tabs', (event) => {
132
- const snap = event.payload;
133
- const active = snap.tabs?.find((t: any) => t.id === snap.active);
134
- if (active) { setUrl(active.url || ''); setCanGoBack(Boolean(active.can_go_back)); setCanGoForward(Boolean(active.can_go_forward)); setActiveTabId(snap.active || null); }
135
- });
136
- return () => { unlisten.then(fn => fn()); };
137
- }, []);
138
-
139
- const navigate = async (targetUrl: string) => { const snap = await ensureBrowserInit(); const tabId = activeTabId || snap?.active; if (tabId) invoke('tab_navigate', { tabId, url: targetUrl }).catch(console.error); };
140
- const goBack = () => { if (activeTabId) invoke('tab_back', { tabId: activeTabId }).catch(() => {}); };
141
- const goForward = () => { if (activeTabId) invoke('tab_forward', { tabId: activeTabId }).catch(() => {}); };
142
- const reload = () => { if (activeTabId) invoke('tab_reload', { tabId: activeTabId }).catch(() => {}); };
 
143
  const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); navigate(url); };
 
144
 
145
- function addCapturedImage(dataUrl: string, w: number, h: number) {
146
- const ratio = Math.max(0.1, w / h); const tw = Math.min(w, 800);
147
- setImages(prev => [...prev, { id: crypto.randomUUID(), url: dataUrl, sourceUrl: url, x: (-pan.x + window.innerWidth / 2 - tw / 2) / zoom, y: (-pan.y + window.innerHeight / 2 - (tw / ratio) / 2) / zoom, width: Math.round(tw), height: Math.round(tw / ratio), aspectRatio: ratio }]);
148
- }
149
-
150
- const captureViewport = async () => { const layout = computeLayout(); if (!layout || isCapturing) return; setIsCapturing(true); try { const dataUrl = await invoke<string>('browser_capture_viewport', { layout }); addCapturedImage(dataUrl, layout.width, layout.height); } catch (err) { console.error('[BrowserPanel] viewport capture failed', err); } finally { setIsCapturing(false); } };
151
- const startSnip = async () => { const layout = computeLayout(); if (!layout || isCapturing || isSnipping) return; setIsCapturing(true); try { const frame = await invoke<string>('browser_capture_viewport', { layout }); hideWebview(); setFrozenFrame(frame); setIsSnipping(true); } catch (err) { console.error('[BrowserPanel] snip prep failed', err); } finally { setIsCapturing(false); } };
152
  const cancelSnip = () => { setIsSnipping(false); setFrozenFrame(null); setSnipStart(null); setSnipCurrent(null); scheduleSync(0); };
153
  const onSnipDown = (e: React.PointerEvent) => { const r = e.currentTarget.getBoundingClientRect(); setSnipStart({x:e.clientX-r.left,y:e.clientY-r.top}); setSnipCurrent({x:e.clientX-r.left,y:e.clientY-r.top}); };
154
  const onSnipMove = (e: React.PointerEvent) => { if (!snipStart) return; const r = e.currentTarget.getBoundingClientRect(); setSnipCurrent({x:e.clientX-r.left,y:e.clientY-r.top}); };
155
- const onSnipUp = (e: React.PointerEvent) => { if (!snipStart || !snipCurrent || !frozenFrame) { cancelSnip(); return; } const r = e.currentTarget.getBoundingClientRect(); const ex=e.clientX-r.left, ey=e.clientY-r.top; const sx=Math.min(snipStart.x,ex), sy=Math.min(snipStart.y,ey); const sw=Math.abs(ex-snipStart.x), sh=Math.abs(ey-snipStart.y); if (sw<20||sh<20){cancelSnip();return;} const img=new Image(); img.onload=()=>{const scaleX=img.width/r.width, scaleY=img.height/r.height; const canvas=document.createElement('canvas'); canvas.width=Math.round(sw*scaleX); canvas.height=Math.round(sh*scaleY); const ctx=canvas.getContext('2d'); if(ctx){ctx.drawImage(img,Math.round(sx*scaleX),Math.round(sy*scaleY),canvas.width,canvas.height,0,0,canvas.width,canvas.height); addCapturedImage(canvas.toDataURL('image/png'),sw,sh);} cancelSnip();}; img.onerror=cancelSnip; img.src=frozenFrame; };
156
  useEffect(() => { if (!isSnipping) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') cancelSnip(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [isSnipping]);
157
 
158
- const widthClass = isFullscreen ? 'w-[100vw]' : isWhisperBrowser ? 'w-[320px] min-w-[280px]' : 'w-[50vw] min-w-[400px] max-w-[700px]';
159
-
160
- return (
161
- <div className={`absolute right-0 top-0 h-full bg-[#1C1C1E] shadow-2xl flex flex-col z-[60] transform transition-transform duration-[350ms] ease-[cubic-bezier(0.16,1,0.3,1)] ${isBrowserOpen ? 'translate-x-0' : 'translate-x-full'} ${widthClass}`}>
162
- <div className="flex flex-col bg-[#1C1C1E] z-10 px-4 pt-4 pb-2 shrink-0">
163
- <div className="flex items-center gap-2 mb-3">
164
- <div className="flex items-center gap-0.5"><button onClick={goBack} disabled={!canGoBack} className={`w-8 h-8 flex items-center justify-center rounded-md transition-colors ${canGoBack ? 'text-[#A0A0A0] hover:text-white hover:bg-white/5' : 'text-[#404040] cursor-default'}`}><ArrowLeft size={16} /></button><button onClick={goForward} disabled={!canGoForward} className={`w-8 h-8 flex items-center justify-center rounded-md transition-colors ${canGoForward ? 'text-[#A0A0A0] hover:text-white hover:bg-white/5' : 'text-[#404040] cursor-default'}`}><ArrowRight size={16} /></button><button onClick={reload} className="w-8 h-8 flex items-center justify-center text-[#A0A0A0] hover:text-white rounded-md hover:bg-white/5"><RotateCw size={14} /></button></div>
165
- <form onSubmit={handleSubmit} className="flex-1 relative flex items-center bg-black/40 rounded-lg border border-white/5 focus-within:border-[#0A84FF]/40 transition-colors"><div className="absolute left-3 flex items-center gap-1.5 pointer-events-none"><ShieldCheck size={13} className="text-emerald-500" /><Lock size={12} className="text-[#808080]" /></div><input type="text" value={url} onChange={e => setUrl(e.target.value)} className="w-full bg-transparent text-[#E0E0E0] pl-14 pr-8 py-2 text-[13px] outline-none" spellCheck={false} />{url && <button type="button" onClick={() => setUrl('')} className="absolute right-2.5 text-[#808080] hover:text-white"><X size={14} /></button>}</form>
166
- <div className="flex items-center gap-0.5 ml-1"><button onClick={startSnip} disabled={isCapturing || isSnipping} className={`w-8 h-8 flex items-center justify-center rounded-md transition-colors ${isSnipping ? 'bg-[#FFD60A]/20 text-[#FFD60A]' : 'text-[#A0A0A0] hover:text-[#FFD60A] hover:bg-white/5'} ${isCapturing ? 'opacity-50 cursor-not-allowed' : ''}`} title="Area Snip"><Scissors size={16} /></button><button onClick={captureViewport} disabled={isCapturing} className={`w-8 h-8 flex items-center justify-center rounded-md transition-colors text-[#A0A0A0] hover:text-[#0A84FF] hover:bg-white/5 ${isCapturing ? 'opacity-50 cursor-not-allowed' : ''}`} title="Full Web Clip"><Camera size={16} className={isCapturing ? 'animate-pulse' : ''} /></button><button onClick={() => setIsFullscreen(!isFullscreen)} className="w-8 h-8 flex items-center justify-center text-[#A0A0A0] hover:text-white rounded-md hover:bg-white/5">{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button><button onClick={() => setIsBrowserOpen(false)} className="w-8 h-8 flex items-center justify-center text-[#A0A0A0] hover:text-white rounded-md hover:bg-white/5"><X size={18} /></button></div>
167
- </div>
168
- {!isWhisperBrowser && <div className="flex items-center gap-2 overflow-x-auto pb-2 hide-scrollbar">{bookmarks.map((bm, i) => <button key={i} onClick={() => navigate(bm.url)} className="flex items-center gap-1.5 px-3 py-1.5 bg-white/5 hover:bg-white/10 border border-white/5 rounded-md text-[11px] font-medium text-white whitespace-nowrap transition-colors"><span>{bm.icon}</span><span>{bm.name}</span></button>)}</div>}
169
- </div>
170
- <div ref={contentRef} className="flex-1 bg-[#0A0A0B] relative overflow-hidden">
171
- {!webviewVisible && <div className="absolute inset-0 flex items-center justify-center pointer-events-none"><div className="text-center text-[#404040]"><Globe size={28} className="mx-auto mb-2 opacity-30 animate-pulse" /><p className="text-[11px]">{isBrowserReady ? 'Positioning browser...' : 'Starting browser...'}</p></div></div>}
172
- {isSnipping && frozenFrame && <div className="absolute inset-0 z-[100] cursor-crosshair select-none" onPointerDown={onSnipDown} onPointerMove={onSnipMove} onPointerUp={onSnipUp} onPointerCancel={cancelSnip}><img src={frozenFrame} className="absolute inset-0 w-full h-full object-fill pointer-events-none" draggable={false} /><div className="absolute inset-0 bg-black/25 pointer-events-none" />{snipStart && snipCurrent && <div className="absolute border-2 border-dashed border-white/90" style={{ left: Math.min(snipStart.x, snipCurrent.x), top: Math.min(snipStart.y, snipCurrent.y), width: Math.abs(snipCurrent.x - snipStart.x), height: Math.abs(snipCurrent.y - snipStart.y), boxShadow: '0 0 0 9999px rgba(0,0,0,0.5)' }} />}<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-[#FFD60A] text-black text-xs font-bold px-3 py-1.5 rounded-full shadow-lg pointer-events-none">Drag to select area • Esc to cancel</div></div>}
173
  </div>
 
174
  </div>
175
- );
 
176
  };
 
1
+ import { X, ArrowLeft, ArrowRight, RotateCw, Plus, Globe, Maximize2, Minimize2, ShieldCheck, Lock, Camera, Scissors, ExternalLink } from 'lucide-react';
2
  import { useAppStore } from '../store';
3
+ import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
4
  import { invoke } from '@tauri-apps/api/core';
5
  import { listen } from '@tauri-apps/api/event';
6
 
 
15
  { name: 'Line of Action', icon: '🏃', url: 'https://line-of-action.com' },
16
  ];
17
 
18
+ type BrowserTab = { id: string; title: string; url: string; loading: boolean; can_go_back?: boolean; can_go_forward?: boolean; pinned?: boolean; favicon?: string | null };
19
+ type BrowserSnapshot = { tabs: BrowserTab[]; active: string | null; can_restore?: boolean };
20
+ let browserInitInFlight: Promise<BrowserSnapshot | null> | null = null;
 
 
21
  let browserInitDone = false;
22
+ function toast(msg: string) { window.dispatchEvent(new CustomEvent('lumaref:toast', { detail: msg })); }
23
+ function compactTitle(tab: BrowserTab) { const title = (tab.title || '').trim(); if (title && title !== 'New Tab') return title; try { return new URL(tab.url).hostname.replace(/^www\./, '') || 'New Tab'; } catch { return tab.url || 'New Tab'; } }
24
+ function ensureHttpUrl(input: string) { const v = input.trim(); if (!v) return 'https://duckduckgo.com'; if (/^https?:\/\//i.test(v)) return v; if (/^[\w.-]+\.[a-z]{2,}([/:?#].*)?$/i.test(v)) return `https://${v}`; return v; }
25
+ function hiddenLayout() { return { x: 0, y: 0, width: 1, height: 1 }; }
26
  function ensureBrowserInit() {
27
+ if (browserInitDone) return invoke<BrowserSnapshot>('browser_init', { layout: hiddenLayout() });
28
+ if (browserInitInFlight) return browserInitInFlight;
29
+ browserInitInFlight = invoke<BrowserSnapshot>('browser_init', { layout: hiddenLayout() })
30
+ .then((result) => { browserInitDone = true; browserInitInFlight = null; return result; })
31
+ .catch((err) => { console.error('[BrowserPanel] browser_init failed', err); browserInitInFlight = null; browserInitDone = false; toast(`Browser failed to start: ${err}`); return null; });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  return browserInitInFlight;
33
  }
34
 
35
  export const BrowserPanel = () => {
36
  const { isBrowserOpen, setIsBrowserOpen, isWhisperBrowser, setImages, pan, zoom } = useAppStore();
37
+ const [url, setUrl] = useState('https://duckduckgo.com');
38
+ const [tabs, setTabs] = useState<BrowserTab[]>([]);
39
  const [isFullscreen, setIsFullscreen] = useState(false);
40
  const [canGoBack, setCanGoBack] = useState(false);
41
  const [canGoForward, setCanGoForward] = useState(false);
 
49
  const [snipCurrent, setSnipCurrent] = useState<{x:number;y:number} | null>(null);
50
  const contentRef = useRef<HTMLDivElement>(null);
51
  const syncTimerRef = useRef<number | null>(null);
52
+ const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) || null, [tabs, activeTabId]);
53
+
54
+ const applySnapshot = useCallback((snap: BrowserSnapshot | null | undefined) => {
55
+ if (!snap) return;
56
+ setTabs(Array.isArray(snap.tabs) ? snap.tabs : []);
57
+ const active = snap.tabs?.find(t => t.id === snap.active) || snap.tabs?.[0];
58
+ if (active) {
59
+ setUrl(active.url || ''); setCanGoBack(Boolean(active.can_go_back)); setCanGoForward(Boolean(active.can_go_forward)); setActiveTabId(active.id);
60
+ }
61
+ }, []);
62
 
63
  const computeLayout = useCallback(() => {
64
  const el = contentRef.current;
 
70
 
71
  const hideWebview = useCallback(() => {
72
  if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
73
+ invoke('browser_hide_all').catch(() => invoke('browser_set_visible', { visible: false, layout: hiddenLayout() }).catch(() => {}));
 
 
74
  setWebviewVisible(false);
75
  }, []);
76
 
 
79
  const layout = computeLayout();
80
  if (!layout) return;
81
  const snap = await ensureBrowserInit();
82
+ applySnapshot(snap);
83
+ try { await invoke('browser_set_visible', { visible: true, layout }); setWebviewVisible(true); }
84
+ catch (err) { console.error('[BrowserPanel] browser_set_visible failed', err); toast(`Browser layout failed: ${err}`); }
85
+ }, [isBrowserOpen, isSnipping, computeLayout, applySnapshot]);
 
 
 
 
86
 
87
  const scheduleSync = useCallback((delay = 0) => {
88
  if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
89
  syncTimerRef.current = window.setTimeout(() => requestAnimationFrame(() => syncWebviewNow()), delay);
90
  }, [syncWebviewNow]);
91
 
92
+ useEffect(() => { ensureBrowserInit().then((snap) => { applySnapshot(snap); setIsBrowserReady(true); }); return () => hideWebview(); }, [hideWebview, applySnapshot]);
93
+ useEffect(() => { if (!isBrowserOpen) { hideWebview(); return; } scheduleSync(50); const timers = [160, 300, 450, 700, 1100].map(ms => window.setTimeout(() => scheduleSync(0), ms)); return () => timers.forEach(t => window.clearTimeout(t)); }, [isBrowserOpen, scheduleSync, hideWebview]);
94
+ useEffect(() => { if (!isBrowserOpen) return; hideWebview(); scheduleSync(80); const timers = [220, 420, 800].map(ms => window.setTimeout(() => scheduleSync(0), ms)); return () => timers.forEach(t => window.clearTimeout(t)); }, [isFullscreen, isWhisperBrowser, isBrowserOpen, hideWebview, scheduleSync]);
95
+ useEffect(() => { const ro = new ResizeObserver(() => { if (isBrowserOpen && !isSnipping) scheduleSync(20); }); if (contentRef.current) ro.observe(contentRef.current); const onResize = () => { if (isBrowserOpen && !isSnipping) scheduleSync(20); }; window.addEventListener('resize', onResize); return () => { ro.disconnect(); window.removeEventListener('resize', onResize); }; }, [isBrowserOpen, isSnipping, scheduleSync]);
96
+ useEffect(() => { const unlisten = listen<BrowserSnapshot>('browser://tabs', (event) => applySnapshot(event.payload)); return () => { unlisten.then(fn => fn()); }; }, [applySnapshot]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
+ const navigate = async (targetUrl: string) => {
99
+ const target = ensureHttpUrl(targetUrl);
100
+ const snap = await ensureBrowserInit();
101
+ const tabId = activeTabId || snap?.active;
102
+ if (!tabId) return toast('No browser tab available');
103
+ try { applySnapshot(await invoke<BrowserSnapshot>('tab_navigate', { tabId, url: target })); }
104
+ catch (err) { toast(`Navigation failed: ${err}`); }
105
+ };
106
+ const createTab = async (targetUrl = 'https://duckduckgo.com') => { const layout = computeLayout() || hiddenLayout(); try { applySnapshot(await invoke<BrowserSnapshot>('tab_create', { url: targetUrl, layout })); scheduleSync(20); } catch (err) { toast(`New tab failed: ${err}`); } };
107
+ const activateTab = async (tabId: string) => { const layout = computeLayout() || hiddenLayout(); try { applySnapshot(await invoke<BrowserSnapshot>('tab_activate', { tabId, layout })); scheduleSync(20); } catch (err) { toast(`Tab switch failed: ${err}`); } };
108
+ const closeTab = async (tabId: string) => { const layout = computeLayout() || hiddenLayout(); try { applySnapshot(await invoke<BrowserSnapshot>('tab_close', { tabId, layout })); scheduleSync(20); } catch (err) { toast(`Close tab failed: ${err}`); } };
109
+ const goBack = async () => { if (!activeTabId) return; try { await invoke('tab_back', { tabId: activeTabId }); } catch (err) { toast(`Back failed: ${err}`); } };
110
+ const goForward = async () => { if (!activeTabId) return; try { await invoke('tab_forward', { tabId: activeTabId }); } catch (err) { toast(`Forward failed: ${err}`); } };
111
+ const reload = async () => { if (!activeTabId) return; try { await invoke('tab_reload', { tabId: activeTabId }); } catch (err) { toast(`Reload failed: ${err}`); } };
112
  const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); navigate(url); };
113
+ const copyCurrentUrl = () => { if (!url) return; navigator.clipboard.writeText(url).then(() => toast('URL copied')).catch(() => {}); };
114
 
115
+ function addCapturedImage(dataUrl: string, w: number, h: number) { const ratio = Math.max(0.1, w / h); const tw = Math.min(w, 800); setImages(prev => [...prev, { id: crypto.randomUUID(), url: dataUrl, sourceUrl: url, x: (-pan.x + window.innerWidth / 2 - tw / 2) / zoom, y: (-pan.y + window.innerHeight / 2 - (tw / ratio) / 2) / zoom, width: Math.round(tw), height: Math.round(tw / ratio), aspectRatio: ratio }]); }
116
+ const captureViewport = async () => { const layout = computeLayout(); if (!layout || isCapturing) return; setIsCapturing(true); try { const dataUrl = await invoke<string>('browser_capture_viewport', { layout }); addCapturedImage(dataUrl, layout.width, layout.height); toast('Web clip added to board'); } catch (err) { console.error('[BrowserPanel] viewport capture failed', err); toast(`Capture failed: ${err}`); } finally { setIsCapturing(false); } };
117
+ const startSnip = async () => { const layout = computeLayout(); if (!layout || isCapturing || isSnipping) return; setIsCapturing(true); try { const frame = await invoke<string>('browser_capture_viewport', { layout }); hideWebview(); setFrozenFrame(frame); setIsSnipping(true); } catch (err) { console.error('[BrowserPanel] snip prep failed', err); toast(`Snip failed: ${err}`); } finally { setIsCapturing(false); } };
 
 
 
 
118
  const cancelSnip = () => { setIsSnipping(false); setFrozenFrame(null); setSnipStart(null); setSnipCurrent(null); scheduleSync(0); };
119
  const onSnipDown = (e: React.PointerEvent) => { const r = e.currentTarget.getBoundingClientRect(); setSnipStart({x:e.clientX-r.left,y:e.clientY-r.top}); setSnipCurrent({x:e.clientX-r.left,y:e.clientY-r.top}); };
120
  const onSnipMove = (e: React.PointerEvent) => { if (!snipStart) return; const r = e.currentTarget.getBoundingClientRect(); setSnipCurrent({x:e.clientX-r.left,y:e.clientY-r.top}); };
121
+ const onSnipUp = (e: React.PointerEvent) => { if (!snipStart || !snipCurrent || !frozenFrame) { cancelSnip(); return; } const r = e.currentTarget.getBoundingClientRect(); const ex=e.clientX-r.left, ey=e.clientY-r.top; const sx=Math.min(snipStart.x,ex), sy=Math.min(snipStart.y,ey); const sw=Math.abs(ex-snipStart.x), sh=Math.abs(ey-snipStart.y); if (sw<20||sh<20){cancelSnip();return;} const img=new Image(); img.onload=()=>{const scaleX=img.width/r.width, scaleY=img.height/r.height; const canvas=document.createElement('canvas'); canvas.width=Math.round(sw*scaleX); canvas.height=Math.round(sh*scaleY); const ctx=canvas.getContext('2d'); if(ctx){ctx.drawImage(img,Math.round(sx*scaleX),Math.round(sy*scaleY),canvas.width,canvas.height,0,0,canvas.width,canvas.height); addCapturedImage(canvas.toDataURL('image/png'),sw,sh); toast('Snip added to board');} cancelSnip();}; img.onerror=cancelSnip; img.src=frozenFrame; };
122
  useEffect(() => { if (!isSnipping) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') cancelSnip(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [isSnipping]);
123
 
124
+ const widthClass = isFullscreen ? 'w-[calc(100vw-16px)]' : isWhisperBrowser ? 'w-[320px] min-w-[280px]' : 'w-[50vw] min-w-[400px] max-w-[720px]';
125
+ return <div className={`absolute right-0 top-0 h-full bg-[var(--panel-bg)] shadow-2xl flex flex-col z-[60] transform transition-transform duration-300 ease-out border-l border-[var(--panel-border)] ${isBrowserOpen ? 'translate-x-0' : 'translate-x-full'} ${widthClass}`}>
126
+ <div className="flex flex-col bg-[var(--panel-bg)] z-10 px-3 pt-3 pb-2 shrink-0 border-b border-[var(--panel-border)]">
127
+ {!isWhisperBrowser && <div className="flex items-center gap-1 mb-2 overflow-x-auto hide-scrollbar pr-1"><button onClick={() => createTab()} className="w-7 h-7 shrink-0 rounded-md flex items-center justify-center text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] hover:bg-white/5" title="New tab"><Plus size={15}/></button>{tabs.map(tab => <button key={tab.id} onClick={() => activateTab(tab.id)} className={`group max-w-[150px] min-w-[92px] h-7 px-2 rounded-md flex items-center gap-1.5 text-[11px] border transition-colors ${tab.id === activeTabId ? 'bg-[var(--accent)]/12 border-[var(--accent)]/30 text-[var(--ui-primary)]' : 'bg-white/[0.03] border-transparent text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] hover:bg-white/[0.06]'}`} title={tab.url}><Globe size={10} className={tab.loading ? 'animate-pulse text-[var(--accent)]' : ''}/><span className="truncate flex-1 text-left">{compactTitle(tab)}</span>{tabs.length > 1 && <span onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }} className="opacity-0 group-hover:opacity-100 hover:text-[#FF453A]"><X size={11}/></span>}</button>)}</div>}
128
+ <div className="flex items-center gap-2"><div className="flex items-center gap-0.5"><button onClick={goBack} disabled={!canGoBack} className={`w-8 h-8 flex items-center justify-center rounded-md transition-colors ${canGoBack ? 'text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] hover:bg-white/5' : 'text-white/20 cursor-default'}`}><ArrowLeft size={16} /></button><button onClick={goForward} disabled={!canGoForward} className={`w-8 h-8 flex items-center justify-center rounded-md transition-colors ${canGoForward ? 'text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] hover:bg-white/5' : 'text-white/20 cursor-default'}`}><ArrowRight size={16} /></button><button onClick={reload} className="w-8 h-8 flex items-center justify-center text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] rounded-md hover:bg-white/5"><RotateCw size={14} className={activeTab?.loading ? 'animate-spin' : ''} /></button></div>
129
+ <form onSubmit={handleSubmit} className="flex-1 relative flex items-center bg-black/35 rounded-lg border border-[var(--panel-border)] focus-within:border-[var(--accent)]/60 transition-colors"><div className="absolute left-3 flex items-center gap-1.5 pointer-events-none"><ShieldCheck size={13} className="text-emerald-500" /><Lock size={12} className="text-[var(--ui-secondary)]" /></div><input type="text" value={url} onChange={e => setUrl(e.target.value)} className="w-full bg-transparent text-[var(--ui-primary)] pl-14 pr-14 py-2 text-[13px] outline-none" spellCheck={false} />{url && <button type="button" onClick={() => setUrl('')} className="absolute right-8 text-[var(--ui-secondary)] hover:text-[var(--ui-primary)]"><X size={14} /></button>}<button type="button" onClick={copyCurrentUrl} className="absolute right-2 text-[var(--ui-secondary)] hover:text-[var(--ui-primary)]" title="Copy URL"><ExternalLink size={13}/></button></form>
130
+ <div className="flex items-center gap-0.5 ml-1"><button onClick={startSnip} disabled={isCapturing || isSnipping} className={`w-8 h-8 flex items-center justify-center rounded-md transition-colors ${isSnipping ? 'bg-[#FFD60A]/20 text-[#FFD60A]' : 'text-[var(--ui-secondary)] hover:text-[#FFD60A] hover:bg-white/5'} ${isCapturing ? 'opacity-50 cursor-not-allowed' : ''}`} title="Area Snip"><Scissors size={16} /></button><button onClick={captureViewport} disabled={isCapturing} className={`w-8 h-8 flex items-center justify-center rounded-md transition-colors text-[var(--ui-secondary)] hover:text-[var(--accent)] hover:bg-white/5 ${isCapturing ? 'opacity-50 cursor-not-allowed' : ''}`} title="Full Web Clip"><Camera size={16} className={isCapturing ? 'animate-pulse' : ''} /></button><button onClick={() => setIsFullscreen(!isFullscreen)} className="w-8 h-8 flex items-center justify-center text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] rounded-md hover:bg-white/5">{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button><button onClick={() => setIsBrowserOpen(false)} className="w-8 h-8 flex items-center justify-center text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] rounded-md hover:bg-white/5"><X size={18} /></button></div>
 
 
 
 
 
 
 
 
131
  </div>
132
+ {!isWhisperBrowser && <div className="flex items-center gap-2 overflow-x-auto pt-2 hide-scrollbar">{bookmarks.map((bm, i) => <button key={i} onClick={() => navigate(bm.url)} className="flex items-center gap-1.5 px-2.5 py-1.5 bg-white/5 hover:bg-white/10 border border-white/5 rounded-md text-[11px] font-medium text-[var(--ui-primary)] whitespace-nowrap transition-colors"><span>{bm.icon}</span><span>{bm.name}</span></button>)}</div>}
133
  </div>
134
+ <div ref={contentRef} className="flex-1 bg-[#0A0A0B] relative overflow-hidden"><div className="absolute top-2 left-2 z-[2] pointer-events-none text-[10px] text-white/30 bg-black/30 rounded px-1.5 py-0.5">{activeTab ? compactTitle(activeTab) : 'Browser'}</div>{!webviewVisible && <div className="absolute inset-0 flex items-center justify-center pointer-events-none"><div className="text-center text-[#404040]"><Globe size={28} className="mx-auto mb-2 opacity-30 animate-pulse" /><p className="text-[11px]">{isBrowserReady ? 'Positioning browser...' : 'Starting browser...'}</p></div></div>}{isSnipping && frozenFrame && <div className="absolute inset-0 z-[100] cursor-crosshair select-none" onPointerDown={onSnipDown} onPointerMove={onSnipMove} onPointerUp={onSnipUp} onPointerCancel={cancelSnip}><img src={frozenFrame} className="absolute inset-0 w-full h-full object-fill pointer-events-none" draggable={false} /><div className="absolute inset-0 bg-black/25 pointer-events-none" />{snipStart && snipCurrent && <div className="absolute border-2 border-dashed border-white/90" style={{ left: Math.min(snipStart.x, snipCurrent.x), top: Math.min(snipStart.y, snipCurrent.y), width: Math.abs(snipCurrent.x - snipStart.x), height: Math.abs(snipCurrent.y - snipStart.y), boxShadow: '0 0 0 9999px rgba(0,0,0,0.5)' }} />}<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-[#FFD60A] text-black text-xs font-bold px-3 py-1.5 rounded-full shadow-lg pointer-events-none">Drag to select area • Esc to cancel</div></div>}</div>
135
+ </div>;
136
  };