musealpha / src /components /BrowserPanel.tsx
asdf98's picture
fix: BrowserPanel close uses browser_hide_all to park every child webview
6d1f25f verified
import { X, ArrowLeft, ArrowRight, RotateCw, Plus, Globe, Maximize2, Minimize2, ShieldCheck, Lock, Camera, Scissors } from 'lucide-react';
import { useAppStore } from '../store';
import { useState, useRef, useEffect, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
const bookmarks = [
{ name: 'ArtStation', icon: '🎨', url: 'https://artstation.com' },
{ name: 'Pinterest', icon: '📌', url: 'https://pinterest.com' },
{ name: 'Unsplash', icon: '📷', url: 'https://unsplash.com' },
{ name: 'Sketchfab', icon: '📦', url: 'https://sketchfab.com' },
{ name: 'Google Images', icon: '🔍', url: 'https://images.google.com' },
{ name: 'PolyHaven', icon: '🌍', url: 'https://polyhaven.com' },
{ name: 'Anatomy360', icon: '🦴', url: 'https://anatomy360.info' },
{ name: 'Line of Action', icon: '🏃', url: 'https://line-of-action.com' },
];
let browserInitPromise: Promise<any> | null = null;
function ensureBrowserInit() {
if (!browserInitPromise) {
browserInitPromise = invoke<any>('browser_init', { layout: { x: 0, y: 0, width: 1, height: 1 } }).catch((err) => {
console.error('[BrowserPanel] browser_init failed', err);
browserInitPromise = null;
return null;
});
}
return browserInitPromise;
}
export const BrowserPanel = () => {
const { isBrowserOpen, setIsBrowserOpen, isWhisperBrowser, setImages, pan, zoom } = useAppStore();
const [url, setUrl] = useState('https://unsplash.com');
const [isFullscreen, setIsFullscreen] = useState(false);
const [canGoBack, setCanGoBack] = useState(false);
const [canGoForward, setCanGoForward] = useState(false);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [webviewVisible, setWebviewVisible] = useState(false);
const [isBrowserReady, setIsBrowserReady] = useState(false);
const [isCapturing, setIsCapturing] = useState(false);
const [isSnipping, setIsSnipping] = useState(false);
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
const [snipStart, setSnipStart] = useState<{x:number;y:number} | null>(null);
const [snipCurrent, setSnipCurrent] = useState<{x:number;y:number} | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const syncTimerRef = useRef<number | null>(null);
const computeLayout = useCallback(() => {
const el = contentRef.current;
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.width < 80 || rect.height < 80) return null;
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
}, []);
const hideWebview = useCallback(() => {
if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
// Hard-hide all tabs, not just active tab. Native child webviews are independent
// of the React shell and can otherwise remain visible after drawer close.
invoke('browser_hide_all').catch(() => {
invoke('browser_set_visible', { visible: false, layout: { x: 0, y: 0, width: 1, height: 1 } }).catch(() => {});
});
setWebviewVisible(false);
}, []);
const syncWebviewNow = useCallback(async () => {
if (!isBrowserOpen || isSnipping) return;
const layout = computeLayout();
if (!layout) return;
const snap = await ensureBrowserInit();
if (snap?.active && !activeTabId) setActiveTabId(snap.active);
try {
await invoke('browser_set_visible', { visible: true, layout });
setWebviewVisible(true);
} catch (err) {
console.error('[BrowserPanel] browser_set_visible failed', err);
}
}, [isBrowserOpen, isSnipping, computeLayout, activeTabId]);
const scheduleSync = useCallback((delay = 0) => {
if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
syncTimerRef.current = window.setTimeout(() => requestAnimationFrame(() => syncWebviewNow()), delay);
}, [syncWebviewNow]);
useEffect(() => {
ensureBrowserInit().then((snap) => { if (snap?.active) setActiveTabId(snap.active); setIsBrowserReady(true); });
return () => hideWebview();
}, [hideWebview]);
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]);
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]);
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]);
useEffect(() => {
const unlisten = listen<any>('browser://tabs', (event) => {
const snap = event.payload;
const active = snap.tabs?.find((t: any) => t.id === snap.active);
if (active) { setUrl(active.url || ''); setCanGoBack(Boolean(active.can_go_back)); setCanGoForward(Boolean(active.can_go_forward)); setActiveTabId(snap.active || null); }
});
return () => { unlisten.then(fn => fn()); };
}, []);
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); };
const goBack = () => { if (activeTabId) invoke('tab_back', { tabId: activeTabId }).catch(() => {}); };
const goForward = () => { if (activeTabId) invoke('tab_forward', { tabId: activeTabId }).catch(() => {}); };
const reload = () => { if (activeTabId) invoke('tab_reload', { tabId: activeTabId }).catch(() => {}); };
const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); navigate(url); };
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 }]);
}
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); } };
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); } };
const cancelSnip = () => { setIsSnipping(false); setFrozenFrame(null); setSnipStart(null); setSnipCurrent(null); scheduleSync(0); };
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}); };
const onSnipMove = (e: React.PointerEvent) => { if (!snipStart) return; const r = e.currentTarget.getBoundingClientRect(); setSnipCurrent({x:e.clientX-r.left,y:e.clientY-r.top}); };
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; };
useEffect(() => { if (!isSnipping) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') cancelSnip(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [isSnipping]);
const widthClass = isFullscreen ? 'w-[100vw]' : isWhisperBrowser ? 'w-[320px] min-w-[280px]' : 'w-[50vw] min-w-[400px] max-w-[700px]';
return (
<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}`}>
<div className="flex flex-col bg-[#1C1C1E] z-10 px-4 pt-4 pb-2 shrink-0">
<div className="flex items-center gap-2 mb-3">
<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>
<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>
<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>
</div>
{!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>}
</div>
<div ref={contentRef} className="flex-1 bg-[#0A0A0B] relative overflow-hidden">
{!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>
</div>
);
};