feat: improve browser panel tabs, navigation feedback, and layout UX
Browse files- 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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 |
-
|
| 27 |
-
if (
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 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://
|
|
|
|
| 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 |
-
|
| 89 |
-
try {
|
| 90 |
-
|
| 91 |
-
|
| 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 |
-
|
| 104 |
-
|
| 105 |
-
}, [
|
| 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 |
-
|
| 131 |
-
const
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
});
|
| 136 |
-
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
const
|
| 140 |
-
const
|
| 141 |
-
const
|
| 142 |
-
const
|
|
|
|
| 143 |
const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); navigate(url); };
|
|
|
|
| 144 |
|
| 145 |
-
function addCapturedImage(dataUrl: string, w: number, h: number) {
|
| 146 |
-
|
| 147 |
-
|
| 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-[
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
<div className="flex flex-
|
| 163 |
-
<div className="flex items-center gap-2
|
| 164 |
-
|
| 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 |
};
|