emo-online-api / emo /frontend /src /components /InteractiveBrowser.jsx
Emo Online
Deploy Emo API
dd87944
Raw
History Blame Contribute Delete
23.6 kB
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 (
<div
ref={browserRootRef}
className={`emo-browser ${embedded ? "border-0 shadow-none" : ""}`}
data-testid="interactive-browser"
data-keyboard-open={keyboardOpen ? "true" : "false"}
>
{/* Chrome toolbar */}
<div className="emo-browser-chrome">
<div className="emo-browser-dots" aria-hidden>
<span className="emo-browser-dot" style={{ background: "#ff5f57" }} />
<span className="emo-browser-dot" style={{ background: "#febc2e" }} />
<span className="emo-browser-dot" style={{ background: "#28c840" }} />
</div>
<button
type="button"
title="Actualiser"
disabled={busy}
onClick={() => runRefresh(() => browserSnapshot(sessionId))}
className="emo-icon-btn"
>
{refreshing ? (
<Loader2 size={14} className="animate-spin" style={{ color: "var(--emo-accent)" }} />
) : (
<RefreshCw size={14} />
)}
</button>
<div className="emo-browser-url-bar">
{secure ? (
<Lock size={12} className="flex-shrink-0" style={{ color: "var(--emo-success-text)" }} />
) : (
<Globe size={12} className="flex-shrink-0 text-muted-em" />
)}
<input
ref={urlInputRef}
type="url"
value={urlInput}
onChange={(e) => 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}
/>
</div>
{local?.url && (
<a
href={local.url}
target="_blank"
rel="noopener noreferrer"
className="emo-icon-btn"
title="Ouvrir dans le navigateur"
>
<ExternalLink size={14} style={{ color: "var(--emo-link)" }} />
</a>
)}
<div className="hidden sm:flex items-center gap-0.5">
{!showEmbed && (
<button
type="button"
title={keyboardFocused ? "Désactiver le clavier distant" : "Activer le clavier distant"}
disabled={busy}
onClick={() => {
if (keyboardFocused) {
setKeyboardFocused(false);
mobileInputRef.current?.blur();
containerRef.current?.blur();
} else {
activateKeyboardCapture();
}
}}
className="emo-icon-btn"
data-keyboard-toggle="true"
aria-pressed={keyboardFocused}
>
<Keyboard size={14} style={{ color: keyboardFocused ? "var(--emo-accent)" : undefined }} />
</button>
)}
<button
type="button"
title="Défiler vers le haut"
disabled={busy}
onClick={() => runScroll("up", 480)}
className="emo-icon-btn"
>
<ArrowUp size={14} />
</button>
<button
type="button"
title="Défiler vers le bas"
disabled={busy}
onClick={() => runScroll("down", 480)}
className="emo-icon-btn"
>
<ArrowDown size={14} />
</button>
</div>
<div className="emo-browser-mobile-actions sm:hidden">
<button
type="button"
title="Défiler vers le haut"
disabled={busy}
onClick={() => runScroll("up", 480)}
className="emo-icon-btn"
aria-label="Défiler vers le haut"
>
<ArrowUp size={14} />
</button>
<button
type="button"
title="Défiler vers le bas"
disabled={busy}
onClick={() => runScroll("down", 480)}
className="emo-icon-btn"
aria-label="Défiler vers le bas"
>
<ArrowDown size={14} />
</button>
</div>
</div>
{/* Viewport */}
{showEmbed ? (
<div
className="emo-browser-viewport"
data-testid="browser-video-embed"
onPointerDown={markUserActivity}
>
<iframe
key={embedUrl}
title={local?.title || videoPlatformLabel(pageUrl)}
src={embedUrl}
className="w-full border-0 block"
style={{ height: viewportHeight, background: "#000" }}
sandbox={IFRAME_SANDBOX}
allow={IFRAME_ALLOW}
allowFullScreen
/>
</div>
) : screenshotSrc ? (
<div
ref={containerRef}
tabIndex={touchKeyboardRef.current ? -1 : 0}
onFocus={() => {
markUserActivity();
setKeyboardFocused(true);
}}
onBlur={handleContainerBlur}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
if (e.target.closest(".emo-browser-keyboard-input")) return;
markUserActivity();
}}
className="emo-browser-viewport"
data-focused={keyboardFocused ? "true" : "false"}
data-compact={compact ? "true" : "false"}
data-testid="browser-keyboard-capture"
data-keyboard-focused={keyboardFocused ? "true" : "false"}
>
<input
ref={mobileInputRef}
type="text"
inputMode="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
enterKeyHint="done"
aria-label="Saisie clavier pour la page distante"
className="emo-browser-keyboard-input"
tabIndex={-1}
onFocus={() => setKeyboardFocused(true)}
onBlur={handleContainerBlur}
onKeyDown={handleKeyDown}
onBeforeInput={handleMobileBeforeInput}
onInput={handleMobileInput}
/>
{keyboardFocused && (
<div
className="emo-browser-badge emo-browser-badge--keyboard flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] font-medium"
style={{
background: "var(--emo-accent)",
color: "var(--emo-on-accent)",
boxShadow: "0 4px 12px var(--emo-glow)",
}}
>
<Keyboard size={11} />
Clavier actif
</div>
)}
<img
ref={imgRef}
src={screenshotSrc}
alt={local?.title || urlLabel(pageUrl) || "Page web"}
onClick={handleScreenshotClick}
className="w-full h-auto block select-none emo-browser-screenshot"
style={{
objectFit: "contain",
objectPosition: "top",
cursor: busy ? "wait" : "crosshair",
}}
draggable={false}
/>
{clickMark && (
<span
className="emo-browser-click-mark rounded-full animate-ping"
style={{
left: clickMark.x - 10,
top: clickMark.y - 10,
width: 20,
height: 20,
border: "2px solid var(--emo-accent)",
background: "var(--emo-accent-soft)",
}}
/>
)}
{refreshing && (
<div
className="emo-browser-badge emo-browser-badge--refresh flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium"
style={{ background: "var(--emo-surface)", border: "1px solid var(--emo-border)", color: "var(--emo-text-muted)" }}
aria-hidden
>
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--emo-accent)" }} />
Mise à jour…
</div>
)}
</div>
) : (
<div
className="emo-browser-viewport flex flex-col items-center justify-center gap-4 px-6"
style={{ minHeight: compact ? 200 : 320 }}
>
{navigating ? (
<>
<Loader2 size={32} className="animate-spin" style={{ color: "var(--emo-accent)" }} />
<p className="text-sm text-center text-muted-em">Chargement du navigateur…</p>
</>
) : (
<>
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: "var(--emo-accent-soft)" }}
>
<Globe size={24} style={{ color: "var(--emo-accent)", opacity: 0.7 }} />
</div>
<p className="text-sm px-4 text-center text-muted-em max-w-xs">
{loadError || "Appuie sur Entrée dans la barre d'URL pour ouvrir la page."}
</p>
{loadError && pageUrl && (
<button
type="button"
onClick={() => {
autoOpenedRef.current = false;
runNavigate(() => browserOpen(pageUrl, sessionId));
}}
className="emo-btn-primary px-4 py-2 text-xs"
>
Réessayer
</button>
)}
</>
)}
</div>
)}
{/* Status strip */}
<div className="emo-browser-status">
<span className="flex items-center gap-2">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: busy ? "#fbbf24" : "var(--emo-status-online)" }}
/>
{touchKeyboardRef.current ? "Clic · Toucher · Clavier" : "Clic · Molette · Clavier"}
{showEmbed && ` · ${videoPlatformLabel(pageUrl)}`}
{isStreamPage && !showEmbed && " · Flux vidéo (sync pause)"}
</span>
{local?.title && (
<span className="truncate max-w-[55%] font-medium" title={local.title}>
{local.title}
</span>
)}
</div>
</div>
);
}