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