| 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); |
| |
| |
| 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> |
| ); |
| }; |
|
|