fix: BrowserPanel deterministic child webview sync without relying on transitionend
Browse files- 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(() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
return browserInitPromise;
|
| 25 |
}
|
| 26 |
-
// Start pre-init immediately on module load
|
| 27 |
-
ensureBrowserInit();
|
| 28 |
|
| 29 |
export const BrowserPanel = () => {
|
| 30 |
-
const { isBrowserOpen, setIsBrowserOpen,
|
| 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
|
| 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
|
| 45 |
const el = contentRef.current;
|
| 46 |
-
if (!el) return;
|
| 47 |
const rect = el.getBoundingClientRect();
|
| 48 |
-
if (rect.width
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
setWebviewVisible(true);
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
-
}, []);
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
useEffect(() => {
|
| 56 |
-
ensureBrowserInit().then(snap => {
|
| 57 |
if (snap?.active) setActiveTabId(snap.active);
|
|
|
|
| 58 |
});
|
| 59 |
}, []);
|
| 60 |
|
| 61 |
-
//
|
| 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 |
-
//
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
//
|
| 72 |
useEffect(() => {
|
| 73 |
-
if (isBrowserOpen)
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
// Resize sync — reposition webview when window resizes
|
| 86 |
useEffect(() => {
|
| 87 |
-
if (
|
| 88 |
-
|
| 89 |
-
window.addEventListener('
|
| 90 |
-
return () =>
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 101 |
-
setCanGoForward(active.can_go_forward
|
| 102 |
-
setActiveTabId(snap.active);
|
| 103 |
}
|
| 104 |
});
|
| 105 |
return () => { unlisten.then(fn => fn()); };
|
| 106 |
}, []);
|
| 107 |
|
| 108 |
-
const navigate = (targetUrl: string) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 134 |
-
{url && <button type="button" onClick={() =>
|
| 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 |
-
{
|
| 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]">
|
| 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 |
)}
|