"use client"; import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts"; import { useShallow } from "zustand/react/shallow"; import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; import { browserViewportSettingKey, resolveBrowserViewportLayout, resolveFittedBrowserViewport, } from "./browserViewportLayout"; import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; import { useBrowserViewportResize } from "./useBrowserViewportResize"; import { INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, planWebviewCrashRecovery, type WebviewCrashRecoveryState, } from "./webviewCrashRecovery"; interface ElectronWebview extends HTMLElement { src: string; partition: string; preload?: string; webpreferences?: string; getWebContentsId: () => number; executeJavaScript: (code: string, userGesture?: boolean) => Promise; } declare global { interface HTMLElementTagNameMap { webview: ElectronWebview; } } export function HostedBrowserWebview(props: { readonly threadRef: ScopedThreadRef; readonly tabId: string; readonly runtimeTabId: string; readonly initialUrl: string | null; readonly viewport: PreviewViewportSetting; readonly zoomFactor: number; }) { const { threadRef, tabId, runtimeTabId, initialUrl, viewport, zoomFactor } = props; const config = usePreviewWebviewConfig(threadRef.environmentId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); const crashRecoveryRef = useRef(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); const [aspectRatioLocked, setAspectRatioLocked] = useState(false); const presentation = useBrowserSurfaceStore( useShallow((state) => { const current = state.byTabId[runtimeTabId]; return { content: current?.content ?? null, cornerRadius: current?.cornerRadius ?? 0, fitSourceContent: current?.fitSourceContent ?? false, fittedSourceContent: current?.fittedSourceContent ?? null, rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId), visible: current?.visible ?? false, }; }), ); usePreviewBridge({ threadRef, tabId, runtimeTabId }); useEffect(() => { crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; const lease = acquireDesktopTab(runtimeTabId); tabLeaseRef.current = lease; return () => { if (tabLeaseRef.current === lease) tabLeaseRef.current = null; lease.release(); }; }, [runtimeTabId]); const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); const latestUrlRef = useRef(initialUrl); useEffect(() => { latestUrlRef.current = initialUrl; }, [initialUrl]); const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); }, []); useEffect(() => { const webview = webviewRef.current; const bridge = previewBridge; if (!webview || !config || !bridge) return; let disposed = false; let recoveryTimeout: ReturnType | null = null; const register = () => { const lease = tabLeaseRef.current; if (!lease) return; void (async () => { try { // The main-process tab and the DOM webview are created by separate // effects. Wait for the former so registration cannot race and fail // with PreviewTabNotFoundError on a fast about:blank attachment. await lease.ready; if (disposed || webviewRef.current !== webview) return; const webContentsId = webview.getWebContentsId(); if (Number.isInteger(webContentsId) && webContentsId > 0) { await bridge.registerWebview(runtimeTabId, webContentsId); } } catch { // did-attach/dom-ready will retry if the guest was not ready yet. } })(); }; const recoverGuest = () => { if (disposed || recoveryTimeout !== null) return; const recovery = planWebviewCrashRecovery(crashRecoveryRef.current, Date.now()); if (!recovery) return; crashRecoveryRef.current = recovery.state; recoveryTimeout = setTimeout(() => { recoveryTimeout = null; if (!disposed) { setRecoverySrc(latestUrlRef.current ?? initialSrc); setWebviewGeneration((generation) => generation + 1); } }, recovery.delayMs); }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); webview.addEventListener("render-process-gone", recoverGuest); register(); return () => { disposed = true; if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); }; }, [config, initialSrc, runtimeTabId, webviewGeneration]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; const viewportWidth = viewport._tag === "fill" ? null : viewport.width; const viewportHeight = viewport._tag === "fill" ? null : viewport.height; const viewportAspectRatio = viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight; const lockedAspectRatio = aspectRatioLocked && viewportAspectRatio !== null ? viewportAspectRatio : null; const handleAspectRatioChange = useCallback((aspectRatio: number | null) => { setAspectRatioLocked(aspectRatio !== null); }, []); const hiddenContentSize = presentation.content ? { width: presentation.content.width / presentation.content.scale, height: presentation.content.height / presentation.content.scale, } : null; const hiddenSize = viewport._tag !== "fill" ? { width: viewport.width * normalizedZoomFactor, height: viewport.height * normalizedZoomFactor, } : { width: hiddenContentSize?.width ?? lastRect?.width ?? 1280, height: hiddenContentSize?.height ?? lastRect?.height ?? 800, }; const containerSize = active && lastRect ? lastRect : hiddenSize; const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent; const { activeDrag, commitViewportChange, effectiveViewport, handleResizeKeyDown, handleResizePointerDown, layout: viewportLayout, } = useBrowserViewportResize({ tabId: runtimeTabId, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio: lockedAspectRatio, }); const fittedSourceViewport = presentation.fitSourceContent && lastRect ? resolveFittedBrowserViewport( viewport, presentation.fittedSourceContent, normalizedZoomFactor, ) : null; const layout = fittedSourceViewport && lastRect ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, normalizedZoomFactor) : viewportLayout; const syncContentPresentation = useCallback(() => { const wrapper = wrapperRef.current; if (!wrapper) return; useBrowserSurfaceStore.getState().presentContent(runtimeTabId, { x: layout.viewportX, y: layout.viewportY, width: layout.viewportWidth, height: layout.viewportHeight, scale: layout.viewportScale, scrollLeft: wrapper.scrollLeft, scrollTop: wrapper.scrollTop, }); }, [layout, runtimeTabId]); useEffect(() => { const frameId = window.requestAnimationFrame(syncContentPresentation); return () => window.cancelAnimationFrame(frameId); }, [syncContentPresentation]); useEffect(() => { const wrapper = wrapperRef.current; if (!wrapper) return; wrapper.scrollTo({ left: 0, top: 0 }); }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); if (!config) return null; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, cornerRadius: presentation.cornerRadius, rect: lastRect, hiddenSize, }); return (
{deviceToolbarVisible && effectiveViewport._tag !== "fill" ? ( ) : null} {active && effectiveViewport._tag !== "fill" && !fittedSourceViewport ? ( <> {activeDrag ? ( ) : null} ) : null}
); }