import React, { useCallback, useEffect, useRef, useState } from "react"; import { ExternalLink, Loader2, RefreshCw, ArrowDown, ArrowUp, Keyboard, Globe, Lock, } from "lucide-react"; import { toast } from "sonner"; import { browserClickAt, browserOpen, browserScroll, browserSnapshot, browserKeyFire, } from "../lib/browserSession"; import { mapImageClickToViewport } from "../lib/browserClickCoords"; import { formatApiError } from "../lib/api"; import { IFRAME_ALLOW, IFRAME_SANDBOX } from "../lib/iframePreview"; import { isVideoStreamUrl, videoEmbedUrl, videoPlatformLabel, } from "../lib/sitePreview"; import { prefersTouchKeyboard, useVisualViewportKeyboard, } from "../lib/useVisualViewportKeyboard"; const SPECIAL_KEYS = new Set([ "Enter", "Tab", "Escape", "Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "Space", ]); const SCROLL_THROTTLE_MS = 16; const KEY_SNAPSHOT_MS = 80; const LIVE_SYNC_MS = 2500; /** Pause sync après interaction utilisateur (regarder vidéo, cliquer, etc.) */ const USER_ACTIVITY_PAUSE_MS = 120_000; function mapDomKey(e) { if (e.ctrlKey || e.metaKey || e.altKey) return null; if (e.key === " ") return "Space"; if (SPECIAL_KEYS.has(e.key)) return e.key; if (e.key.length === 1) return { text: e.key }; return null; } function urlLabel(raw) { if (!raw) return ""; try { const u = new URL(raw); const path = u.pathname === "/" ? "" : u.pathname; return `${u.hostname}${path}${u.search}`; } catch { return raw.replace(/^https?:\/\//, ""); } } function isSecureUrl(raw) { try { return new URL(raw).protocol === "https:"; } catch { return false; } } export default function InteractiveBrowser({ frame, sessionId: sessionIdProp, onFrameUpdate, compact = false, autoOpen = true, embedded = false, liveSync = true, }) { const sessionId = sessionIdProp || frame?.session_id || "default"; const imgRef = useRef(null); const containerRef = useRef(null); const browserRootRef = useRef(null); const mobileInputRef = useRef(null); const urlInputRef = useRef(null); const touchKeyboardRef = useRef(prefersTouchKeyboard()); const scrollAccumRef = useRef(0); const scrollTimerRef = useRef(null); const scrollPendingRef = useRef(false); const keySnapshotTimerRef = useRef(null); const flushScrollAccumRef = useRef(() => {}); const actionGenRef = useRef(0); const liveSyncGenRef = useRef(0); const userActivityUntilRef = useRef(0); const embedHostRef = useRef( typeof window !== "undefined" ? window.location.hostname : "localhost", ); const [local, setLocal] = useState(frame || null); const [navigating, setNavigating] = useState(false); const [refreshing, setRefreshing] = useState(false); const [loadError, setLoadError] = useState(""); const [urlInput, setUrlInput] = useState(frame?.url || ""); const [clickMark, setClickMark] = useState(null); const [keyboardFocused, setKeyboardFocused] = useState(false); const { inset: keyboardInset, open: keyboardOpen } = useVisualViewportKeyboard({ enabled: keyboardFocused, }); const autoOpenedRef = useRef(false); const pageUrl = local?.url || frame?.url || ""; const embedUrl = videoEmbedUrl(pageUrl, embedHostRef.current); const showEmbed = Boolean(embedUrl); const isStreamPage = isVideoStreamUrl(pageUrl); const viewportW = local?.viewport_width || frame?.viewport_width || 1280; const viewportH = local?.viewport_height || frame?.viewport_height || 900; const secure = isSecureUrl(pageUrl); useEffect(() => { if (!frame) return; setLocal((prev) => { const same = frame.url === prev?.url && frame.screenshot_base64 === prev?.screenshot_base64 && frame.title === prev?.title && (frame.elements?.length || 0) === (prev?.elements?.length || 0); if (same) return prev; return { ...prev, ...frame }; }); if (frame.url) setUrlInput(frame.url); }, [frame]); const hasValidScreenshot = (data) => { const b64 = data?.screenshot_base64; if (!b64 || typeof b64 !== "string") return false; if (b64.includes("truncated") || b64.includes("[screenshot:")) return false; return b64.length > 500; }; const applyFrame = useCallback( (next) => { if (!next) return; setLocal(next); setUrlInput(next.url || ""); setLoadError(""); onFrameUpdate?.(next); }, [onFrameUpdate], ); const runNavigate = useCallback( async (fn) => { setNavigating(true); setLoadError(""); try { const next = await fn(); if (next) applyFrame(next); return next; } catch (e) { const msg = formatApiError(e, "Action navigateur échouée"); setLoadError(msg); toast.error(msg); return null; } finally { setNavigating(false); } }, [applyFrame], ); const runRefresh = useCallback( async (fn) => { const gen = ++actionGenRef.current; setRefreshing(true); try { const next = await fn(); if (next && gen === actionGenRef.current) applyFrame(next); return next; } catch (e) { toast.error(formatApiError(e, "Action navigateur échouée")); return null; } finally { if (gen === actionGenRef.current) setRefreshing(false); } }, [applyFrame], ); const markUserActivity = useCallback(() => { userActivityUntilRef.current = Date.now() + USER_ACTIVITY_PAUSE_MS; }, []); const runSilentSync = useCallback(async () => { if (showEmbed || isStreamPage || navigating || refreshing || document.hidden) return; if (Date.now() < userActivityUntilRef.current) return; const gen = ++liveSyncGenRef.current; try { const next = await browserSnapshot(sessionId); if (!next || gen !== liveSyncGenRef.current) return; setLocal((prev) => { const unchanged = prev?.url === next.url && prev?.screenshot_base64 === next.screenshot_base64 && prev?.title === next.title; if (unchanged) return prev; const merged = { ...prev, ...next }; onFrameUpdate?.(merged); return merged; }); if (next.url) setUrlInput(next.url); } catch { /* sync silencieux — pas de toast */ } }, [showEmbed, isStreamPage, navigating, refreshing, sessionId, onFrameUpdate]); useEffect(() => { if (!liveSync || showEmbed || isStreamPage || !hasValidScreenshot(local)) return undefined; const id = setInterval(runSilentSync, LIVE_SYNC_MS); const onVis = () => { if (!document.hidden) runSilentSync(); }; document.addEventListener("visibilitychange", onVis); return () => { clearInterval(id); document.removeEventListener("visibilitychange", onVis); }; }, [liveSync, showEmbed, isStreamPage, local?.screenshot_base64, runSilentSync]); const scheduleKeySnapshot = useCallback(() => { if (keySnapshotTimerRef.current) clearTimeout(keySnapshotTimerRef.current); keySnapshotTimerRef.current = setTimeout(() => { keySnapshotTimerRef.current = null; runRefresh(() => browserSnapshot(sessionId)); }, KEY_SNAPSHOT_MS); }, [runRefresh, sessionId]); const runScroll = useCallback( async (direction, amount) => { if (scrollPendingRef.current) return; scrollPendingRef.current = true; try { await runRefresh(() => browserScroll(sessionId, direction, amount)); } finally { scrollPendingRef.current = false; if (Math.abs(scrollAccumRef.current) >= 8 && !scrollTimerRef.current) { scrollTimerRef.current = setTimeout( () => flushScrollAccumRef.current(), SCROLL_THROTTLE_MS, ); } } }, [sessionId, runRefresh], ); const flushScrollAccum = useCallback(() => { scrollTimerRef.current = null; const delta = scrollAccumRef.current; scrollAccumRef.current = 0; if (Math.abs(delta) < 8) return; const direction = delta > 0 ? "down" : "up"; const amount = Math.round(Math.min(Math.abs(delta) * 1.2, 720)); runScroll(direction, amount); }, [runScroll]); flushScrollAccumRef.current = flushScrollAccum; const handleWheel = useCallback( (e) => { e.preventDefault(); e.stopPropagation(); if (navigating) return; scrollAccumRef.current += e.deltaY; if (scrollTimerRef.current) return; scrollTimerRef.current = setTimeout( () => flushScrollAccumRef.current(), SCROLL_THROTTLE_MS, ); }, [navigating], ); useEffect(() => { const el = containerRef.current; if (!el || !hasValidScreenshot(local)) return undefined; el.addEventListener("wheel", handleWheel, { passive: false }); return () => el.removeEventListener("wheel", handleWheel); }, [handleWheel, local?.screenshot_base64]); useEffect(() => { return () => { if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); if (keySnapshotTimerRef.current) clearTimeout(keySnapshotTimerRef.current); }; }, []); useEffect(() => { if (!autoOpen || !pageUrl || showEmbed || hasValidScreenshot(local) || navigating || autoOpenedRef.current) return; autoOpenedRef.current = true; runNavigate(() => browserOpen(pageUrl, sessionId)); }, [autoOpen, pageUrl, showEmbed, local?.screenshot_base64, navigating, runNavigate, sessionId]); const fireKeyPayload = useCallback( (payload) => { browserKeyFire(sessionId, payload).catch(() => {}); scheduleKeySnapshot(); }, [sessionId, scheduleKeySnapshot], ); const handleKeyDown = useCallback( (e) => { if (!keyboardFocused || navigating) return; if (urlInputRef.current && document.activeElement === urlInputRef.current) return; const mapped = mapDomKey(e); if (!mapped) return; e.preventDefault(); e.stopPropagation(); const payload = typeof mapped === "string" ? { key: mapped } : { text: mapped.text }; fireKeyPayload(payload); }, [keyboardFocused, navigating, fireKeyPayload], ); useEffect(() => { if (!keyboardFocused) return undefined; const onKey = (e) => { if (document.activeElement !== containerRef.current) return; handleKeyDown(e); }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); }, [keyboardFocused, handleKeyDown]); const handleMobileBeforeInput = useCallback( (e) => { if (!keyboardFocused || navigating) return; if (e.inputType === "deleteContentBackward") { e.preventDefault(); fireKeyPayload({ key: "Backspace" }); } }, [keyboardFocused, navigating, fireKeyPayload], ); const handleMobileInput = useCallback( (e) => { if (!keyboardFocused || navigating) return; const { value } = e.target; if (!value) return; for (const char of value) { if (char) fireKeyPayload({ text: char }); } e.target.value = ""; }, [keyboardFocused, navigating, fireKeyPayload], ); const activateKeyboardCapture = useCallback(() => { if (navigating || showEmbed) return; markUserActivity(); setKeyboardFocused(true); if (touchKeyboardRef.current && mobileInputRef.current) { mobileInputRef.current.focus({ preventScroll: true }); return; } containerRef.current?.focus({ preventScroll: true }); }, [navigating, showEmbed, markUserActivity]); const handleContainerBlur = () => { requestAnimationFrame(() => { const active = document.activeElement; const root = containerRef.current; if (root?.contains(active)) return; if (mobileInputRef.current === active) return; if (urlInputRef.current === active) return; setKeyboardFocused(false); }); }; useEffect(() => { const root = browserRootRef.current; if (!root) return undefined; root.style.setProperty("--emo-keyboard-inset", `${keyboardInset}px`); return () => root.style.removeProperty("--emo-keyboard-inset"); }, [keyboardInset]); const screenshotSrc = hasValidScreenshot(local) ? `data:image/jpeg;base64,${local.screenshot_base64}` : null; const handleScreenshotClick = (e) => { if (navigating || !imgRef.current) return; markUserActivity(); activateKeyboardCapture(); const coords = mapImageClickToViewport( imgRef.current, e.clientX, e.clientY, viewportW, viewportH, ); if (!coords) return; const rect = imgRef.current.getBoundingClientRect(); setClickMark({ x: e.clientX - rect.left, y: e.clientY - rect.top, }); setTimeout(() => setClickMark(null), 600); runRefresh(() => browserClickAt(coords.x, coords.y, sessionId)); }; const busy = navigating; if (!pageUrl && !screenshotSrc && !showEmbed) return null; const viewportHeight = compact ? 280 : 520; return (
{/* Chrome toolbar */}
{secure ? ( ) : ( )} setUrlInput(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter" && urlInput.trim()) { runNavigate(() => browserOpen(urlInput.trim(), sessionId)); } }} onFocus={() => { setKeyboardFocused(false); mobileInputRef.current?.blur(); }} placeholder="https://…" title={local?.title || pageUrl} className="emo-browser-url-input" disabled={busy} />
{local?.url && ( )}
{!showEmbed && ( )}
{/* Viewport */} {showEmbed ? (