asdf98 commited on
Commit
807cf88
·
verified ·
1 Parent(s): 941978a

fix: BrowserPanel deterministic child webview sync without relying on transitionend

Browse files
Files changed (1) hide show
  1. src/components/BrowserPanel.tsx +96 -52
src/components/BrowserPanel.tsx CHANGED
@@ -15,111 +15,158 @@ const bookmarks = [
15
  { name: 'Line of Action', icon: '🏃', url: 'https://line-of-action.com' },
16
  ];
17
 
18
- // Pre-initialize browser on app start (background, no UI)
19
  let browserInitPromise: Promise<any> | null = null;
20
  function ensureBrowserInit() {
21
  if (!browserInitPromise) {
22
- browserInitPromise = invoke<any>('browser_init', { layout: { x: 0, y: 0, width: 1, height: 1 } }).catch(() => null);
 
 
 
 
23
  }
24
  return browserInitPromise;
25
  }
26
- // Start pre-init immediately on module load
27
- ensureBrowserInit();
28
 
29
  export const BrowserPanel = () => {
30
- const { isBrowserOpen, setIsBrowserOpen, pan, zoom, setImages, isWhisperBrowser } = useAppStore();
31
  const [url, setUrl] = useState('https://unsplash.com');
32
  const [isFullscreen, setIsFullscreen] = useState(false);
33
  const [canGoBack, setCanGoBack] = useState(false);
34
  const [canGoForward, setCanGoForward] = useState(false);
35
  const [activeTabId, setActiveTabId] = useState<string | null>(null);
36
  const [webviewVisible, setWebviewVisible] = useState(false);
37
- const inputRef = useRef<HTMLInputElement>(null);
38
  const contentRef = useRef<HTMLDivElement>(null);
 
 
 
39
  const hideWebview = useCallback(() => {
 
 
 
 
40
  invoke('browser_set_visible', { visible: false, layout: { x: 0, y: 0, width: 1, height: 1 } }).catch(() => {});
41
  setWebviewVisible(false);
42
  }, []);
43
 
44
- const showWebview = useCallback(() => {
45
  const el = contentRef.current;
46
- if (!el) return;
47
  const rect = el.getBoundingClientRect();
48
- if (rect.width > 50 && rect.height > 50) {
49
- invoke('browser_set_visible', { visible: true, layout: { x: Math.round(rect.left), y: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) } }).catch(() => {});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  setWebviewVisible(true);
 
 
51
  }
52
- }, []);
53
 
54
- // Get tab ID from pre-init
 
 
 
 
 
 
 
 
55
  useEffect(() => {
56
- ensureBrowserInit().then(snap => {
57
  if (snap?.active) setActiveTabId(snap.active);
 
58
  });
59
  }, []);
60
 
61
- // CRITICAL: Hide webview IMMEDIATELY when panel closes
62
- // Show webview ONLY after the slide-in CSS transition completes
63
  useEffect(() => {
64
  if (!isBrowserOpen) {
65
- // Panel closing — hide webview RIGHT NOW, don't wait for animation
66
  hideWebview();
 
67
  }
68
- // When opening: we wait for onTransitionEnd to show the webview
69
- }, [isBrowserOpen, hideWebview]);
 
 
 
 
 
 
70
 
71
- // Also hide during fullscreen toggle (panel resizes)
72
  useEffect(() => {
73
- if (isBrowserOpen) hideWebview();
74
- // showWebview will be called by onTransitionEnd
75
- }, [isFullscreen]);
76
-
77
- // After CSS transition ends, if panel is open, position and show the webview
78
- const handleTransitionEnd = useCallback(() => {
79
- if (isBrowserOpen) {
80
- // Small delay to ensure layout is settled
81
- requestAnimationFrame(() => { requestAnimationFrame(() => { showWebview(); }); });
82
- }
83
- }, [isBrowserOpen, showWebview]);
 
 
 
 
 
 
84
 
85
- // Resize sync — reposition webview when window resizes
86
  useEffect(() => {
87
- if (!isBrowserOpen || !webviewVisible) return;
88
- const onResize = () => showWebview();
89
- window.addEventListener('resize', onResize);
90
- return () => window.removeEventListener('resize', onResize);
91
- }, [isBrowserOpen, webviewVisible, showWebview]);
 
 
 
92
 
93
- // Listen for tab state updates from Rust
94
  useEffect(() => {
95
  const unlisten = listen<any>('browser://tabs', (event) => {
96
  const snap = event.payload;
97
  const active = snap.tabs?.find((t: any) => t.id === snap.active);
98
  if (active) {
99
  setUrl(active.url || '');
100
- setCanGoBack(active.can_go_back || false);
101
- setCanGoForward(active.can_go_forward || false);
102
- setActiveTabId(snap.active);
103
  }
104
  });
105
  return () => { unlisten.then(fn => fn()); };
106
  }, []);
107
 
108
- const navigate = (targetUrl: string) => { if (activeTabId) invoke('tab_navigate', { tabId: activeTabId, url: targetUrl }).catch(() => {}); };
 
 
 
 
109
  const goBack = () => { if (activeTabId) invoke('tab_back', { tabId: activeTabId }).catch(() => {}); };
110
  const goForward = () => { if (activeTabId) invoke('tab_forward', { tabId: activeTabId }).catch(() => {}); };
111
  const reload = () => { if (activeTabId) invoke('tab_reload', { tabId: activeTabId }).catch(() => {}); };
112
  const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); navigate(url); };
113
 
114
- // Panel width classes
115
  const widthClass = isFullscreen ? 'w-[100vw]' : isWhisperBrowser ? 'w-[320px] min-w-[280px]' : 'w-[50vw] min-w-[400px] max-w-[700px]';
116
 
117
  return (
118
  <div
119
- onTransitionEnd={handleTransitionEnd}
120
  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}`}
121
  >
122
- {/* Browser Chrome - URL bar + nav + bookmarks */}
123
  {!isWhisperBrowser ? (
124
  <div className="flex flex-col bg-[#1C1C1E] z-10 px-4 pt-4 pb-2 shrink-0">
125
  <div className="flex items-center gap-2 mb-3">
@@ -130,25 +177,24 @@ export const BrowserPanel = () => {
130
  </div>
131
  <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">
132
  <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>
133
- <input ref={inputRef} 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} />
134
- {url && <button type="button" onClick={() => { setUrl(''); inputRef.current?.focus(); }} className="absolute right-2.5 text-[#808080] hover:text-white"><X size={14} /></button>}
135
  </form>
136
  <div className="flex items-center gap-0.5 ml-1">
137
  <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>
138
  <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>
139
  </div>
140
  </div>
141
- {/* Bookmarks strip */}
142
  <div className="flex items-center gap-2 overflow-x-auto pb-2 hide-scrollbar">
143
  {bookmarks.map((bm, i) => (
144
  <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">
145
  <span>{bm.icon}</span><span>{bm.name}</span>
146
  </button>
147
  ))}
 
148
  </div>
149
  </div>
150
  ) : (
151
- /* Whisper mode - minimal chrome */
152
  <div className="flex items-center gap-1 p-2 bg-[#1C1C1E] shrink-0 border-b border-[#3A3A3E]">
153
  <button onClick={goBack} disabled={!canGoBack} className={`w-6 h-6 flex items-center justify-center rounded ${canGoBack ? 'text-[#A0A0A0] hover:text-white' : 'text-[#404040]'}`}><ArrowLeft size={12} /></button>
154
  <form onSubmit={handleSubmit} className="flex-1 relative">
@@ -158,14 +204,12 @@ export const BrowserPanel = () => {
158
  </div>
159
  )}
160
 
161
- {/* Content area the native Tauri child webview is positioned exactly over this div */}
162
- <div ref={contentRef} className="flex-1 bg-[#0A0A0B] relative">
163
- {/* Placeholder shown while webview is loading/transitioning */}
164
  {!webviewVisible && (
165
  <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
166
  <div className="text-center text-[#404040]">
167
  <Globe size={28} className="mx-auto mb-2 opacity-30 animate-pulse" />
168
- <p className="text-[11px]">Loading browser...</p>
169
  </div>
170
  </div>
171
  )}
 
15
  { name: 'Line of Action', icon: '🏃', url: 'https://line-of-action.com' },
16
  ];
17
 
 
18
  let browserInitPromise: Promise<any> | null = null;
19
  function ensureBrowserInit() {
20
  if (!browserInitPromise) {
21
+ browserInitPromise = invoke<any>('browser_init', { layout: { x: 0, y: 0, width: 1, height: 1 } }).catch((err) => {
22
+ console.error('[BrowserPanel] browser_init failed', err);
23
+ browserInitPromise = null;
24
+ return null;
25
+ });
26
  }
27
  return browserInitPromise;
28
  }
 
 
29
 
30
  export const BrowserPanel = () => {
31
+ const { isBrowserOpen, setIsBrowserOpen, isWhisperBrowser } = useAppStore();
32
  const [url, setUrl] = useState('https://unsplash.com');
33
  const [isFullscreen, setIsFullscreen] = useState(false);
34
  const [canGoBack, setCanGoBack] = useState(false);
35
  const [canGoForward, setCanGoForward] = useState(false);
36
  const [activeTabId, setActiveTabId] = useState<string | null>(null);
37
  const [webviewVisible, setWebviewVisible] = useState(false);
38
+ const [isBrowserReady, setIsBrowserReady] = useState(false);
39
  const contentRef = useRef<HTMLDivElement>(null);
40
+ const syncTimerRef = useRef<number | null>(null);
41
+ const resizeObserverRef = useRef<ResizeObserver | null>(null);
42
+
43
  const hideWebview = useCallback(() => {
44
+ if (syncTimerRef.current) {
45
+ window.clearTimeout(syncTimerRef.current);
46
+ syncTimerRef.current = null;
47
+ }
48
  invoke('browser_set_visible', { visible: false, layout: { x: 0, y: 0, width: 1, height: 1 } }).catch(() => {});
49
  setWebviewVisible(false);
50
  }, []);
51
 
52
+ const computeLayout = useCallback(() => {
53
  const el = contentRef.current;
54
+ if (!el) return null;
55
  const rect = el.getBoundingClientRect();
56
+ if (rect.width < 80 || rect.height < 80) return null;
57
+ // Child webviews are positioned in window-client CSS/logical pixels.
58
+ return {
59
+ x: Math.round(rect.left),
60
+ y: Math.round(rect.top),
61
+ width: Math.round(rect.width),
62
+ height: Math.round(rect.height),
63
+ };
64
+ }, []);
65
+
66
+ const syncWebviewNow = useCallback(async () => {
67
+ if (!isBrowserOpen) return;
68
+ const layout = computeLayout();
69
+ if (!layout) return;
70
+ await ensureBrowserInit();
71
+ try {
72
+ await invoke('browser_set_visible', { visible: true, layout });
73
  setWebviewVisible(true);
74
+ } catch (err) {
75
+ console.error('[BrowserPanel] browser_set_visible(true) failed', err);
76
  }
77
+ }, [isBrowserOpen, computeLayout]);
78
 
79
+ const scheduleSync = useCallback((delay = 0) => {
80
+ if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
81
+ syncTimerRef.current = window.setTimeout(() => {
82
+ // Two RAFs let CSS transform/width/layout settle before measuring.
83
+ requestAnimationFrame(() => requestAnimationFrame(() => syncWebviewNow()));
84
+ }, delay);
85
+ }, [syncWebviewNow]);
86
+
87
+ // Pre-init once after component mount, not at module import time.
88
  useEffect(() => {
89
+ ensureBrowserInit().then((snap) => {
90
  if (snap?.active) setActiveTabId(snap.active);
91
+ setIsBrowserReady(true);
92
  });
93
  }, []);
94
 
95
+ // Open/close lifecycle. Do not rely on transitionend: it is unreliable when width/fullscreen changes.
 
96
  useEffect(() => {
97
  if (!isBrowserOpen) {
 
98
  hideWebview();
99
+ return;
100
  }
101
+ // Hide while panel is sliding, then show after normal transition duration.
102
+ hideWebview();
103
+ scheduleSync(380);
104
+ // Robust retries for slow WebView2 / layout / DPI edge cases.
105
+ const t1 = window.setTimeout(() => scheduleSync(0), 650);
106
+ const t2 = window.setTimeout(() => scheduleSync(0), 1200);
107
+ return () => { window.clearTimeout(t1); window.clearTimeout(t2); };
108
+ }, [isBrowserOpen, hideWebview, scheduleSync]);
109
 
110
+ // Fullscreen/whisper width changes do not necessarily fire transitionend. Force resync.
111
  useEffect(() => {
112
+ if (!isBrowserOpen) return;
113
+ hideWebview();
114
+ scheduleSync(120);
115
+ const t = window.setTimeout(() => scheduleSync(0), 400);
116
+ return () => window.clearTimeout(t);
117
+ }, [isFullscreen, isWhisperBrowser, isBrowserOpen, hideWebview, scheduleSync]);
118
+
119
+ // ResizeObserver catches panel/content size changes and keeps child webview glued to panel.
120
+ useEffect(() => {
121
+ if (!contentRef.current) return;
122
+ resizeObserverRef.current?.disconnect();
123
+ resizeObserverRef.current = new ResizeObserver(() => {
124
+ if (isBrowserOpen) scheduleSync(20);
125
+ });
126
+ resizeObserverRef.current.observe(contentRef.current);
127
+ return () => resizeObserverRef.current?.disconnect();
128
+ }, [isBrowserOpen, scheduleSync]);
129
 
 
130
  useEffect(() => {
131
+ const onResizeOrScroll = () => { if (isBrowserOpen) scheduleSync(20); };
132
+ window.addEventListener('resize', onResizeOrScroll);
133
+ window.addEventListener('scroll', onResizeOrScroll, true);
134
+ return () => {
135
+ window.removeEventListener('resize', onResizeOrScroll);
136
+ window.removeEventListener('scroll', onResizeOrScroll, true);
137
+ };
138
+ }, [isBrowserOpen, scheduleSync]);
139
 
 
140
  useEffect(() => {
141
  const unlisten = listen<any>('browser://tabs', (event) => {
142
  const snap = event.payload;
143
  const active = snap.tabs?.find((t: any) => t.id === snap.active);
144
  if (active) {
145
  setUrl(active.url || '');
146
+ setCanGoBack(Boolean(active.can_go_back));
147
+ setCanGoForward(Boolean(active.can_go_forward));
148
+ setActiveTabId(snap.active || null);
149
  }
150
  });
151
  return () => { unlisten.then(fn => fn()); };
152
  }, []);
153
 
154
+ const navigate = async (targetUrl: string) => {
155
+ const snap = await ensureBrowserInit();
156
+ const tabId = activeTabId || snap?.active;
157
+ if (tabId) invoke('tab_navigate', { tabId, url: targetUrl }).catch(console.error);
158
+ };
159
  const goBack = () => { if (activeTabId) invoke('tab_back', { tabId: activeTabId }).catch(() => {}); };
160
  const goForward = () => { if (activeTabId) invoke('tab_forward', { tabId: activeTabId }).catch(() => {}); };
161
  const reload = () => { if (activeTabId) invoke('tab_reload', { tabId: activeTabId }).catch(() => {}); };
162
  const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); navigate(url); };
163
 
 
164
  const widthClass = isFullscreen ? 'w-[100vw]' : isWhisperBrowser ? 'w-[320px] min-w-[280px]' : 'w-[50vw] min-w-[400px] max-w-[700px]';
165
 
166
  return (
167
  <div
 
168
  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}`}
169
  >
 
170
  {!isWhisperBrowser ? (
171
  <div className="flex flex-col bg-[#1C1C1E] z-10 px-4 pt-4 pb-2 shrink-0">
172
  <div className="flex items-center gap-2 mb-3">
 
177
  </div>
178
  <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">
179
  <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>
180
+ <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} />
181
+ {url && <button type="button" onClick={() => setUrl('')} className="absolute right-2.5 text-[#808080] hover:text-white"><X size={14} /></button>}
182
  </form>
183
  <div className="flex items-center gap-0.5 ml-1">
184
  <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>
185
  <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>
186
  </div>
187
  </div>
 
188
  <div className="flex items-center gap-2 overflow-x-auto pb-2 hide-scrollbar">
189
  {bookmarks.map((bm, i) => (
190
  <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">
191
  <span>{bm.icon}</span><span>{bm.name}</span>
192
  </button>
193
  ))}
194
+ <button className="flex items-center justify-center w-7 h-7 rounded-md bg-white/5 hover:bg-white/10 text-[#808080]"><Plus size={14} /></button>
195
  </div>
196
  </div>
197
  ) : (
 
198
  <div className="flex items-center gap-1 p-2 bg-[#1C1C1E] shrink-0 border-b border-[#3A3A3E]">
199
  <button onClick={goBack} disabled={!canGoBack} className={`w-6 h-6 flex items-center justify-center rounded ${canGoBack ? 'text-[#A0A0A0] hover:text-white' : 'text-[#404040]'}`}><ArrowLeft size={12} /></button>
200
  <form onSubmit={handleSubmit} className="flex-1 relative">
 
204
  </div>
205
  )}
206
 
207
+ <div ref={contentRef} className="flex-1 bg-[#0A0A0B] relative overflow-hidden">
 
 
208
  {!webviewVisible && (
209
  <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
210
  <div className="text-center text-[#404040]">
211
  <Globe size={28} className="mx-auto mb-2 opacity-30 animate-pulse" />
212
+ <p className="text-[11px]">{isBrowserReady ? 'Positioning browser...' : 'Starting browser...'}</p>
213
  </div>
214
  </div>
215
  )}