File size: 14,201 Bytes
c30ff62 42d26b9 807cf88 42d26b9 c30ff62 42d26b9 807cf88 c30ff62 42d26b9 807cf88 42d26b9 807cf88 42d26b9 807cf88 42d26b9 807cf88 c30ff62 6d1f25f c30ff62 807cf88 c30ff62 807cf88 c30ff62 807cf88 42d26b9 807cf88 c30ff62 42d26b9 c30ff62 42d26b9 807cf88 c30ff62 807cf88 42d26b9 c30ff62 42d26b9 c30ff62 42d26b9 807cf88 c30ff62 807cf88 c30ff62 42d26b9 6d1f25f 42d26b9 6d1f25f 42d26b9 c30ff62 6d1f25f c30ff62 6d1f25f c30ff62 6d1f25f c30ff62 42d26b9 c30ff62 6d1f25f 42d26b9 c30ff62 807cf88 c30ff62 6d1f25f 42d26b9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | 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>
);
};
|