"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 { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; import { browserViewportSettingKey } 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"; 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 initialUrl: string | null; readonly viewport: PreviewViewportSetting; readonly zoomFactor: number; }) { const { threadRef, tabId, 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 [aspectRatioLocked, setAspectRatioLocked] = useState(false); const activeRecordingTabId = useActiveBrowserRecordingTabId(); const presentation = useBrowserSurfaceStore( useShallow((state) => { const current = state.byTabId[tabId]; return { rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId), visible: current?.visible ?? false, }; }), ); usePreviewBridge({ threadRef, tabId }); useEffect(() => { if (presentation.visible || activeRecordingTabId !== tabId) return; void stopBrowserRecording(tabId).catch(() => undefined); }, [activeRecordingTabId, presentation.visible, tabId]); useEffect(() => { const lease = acquireDesktopTab(tabId); tabLeaseRef.current = lease; return () => { if (tabLeaseRef.current === lease) tabLeaseRef.current = null; lease.release(); }; }, [tabId]); 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; 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(tabId, webContentsId); } } catch { // did-attach/dom-ready will retry if the guest was not ready yet. } })(); }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); register(); return () => { disposed = true; webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); }; }, [config, tabId]); 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 hiddenSize = viewport._tag !== "fill" ? { width: viewport.width * normalizedZoomFactor, height: viewport.height * normalizedZoomFactor, } : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; const containerSize = active && lastRect ? lastRect : hiddenSize; const deviceToolbarVisible = active && viewport._tag !== "fill"; const { activeDrag, commitViewportChange, effectiveViewport, handleResizeKeyDown, handleResizePointerDown, layout, } = useBrowserViewportResize({ tabId, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio: lockedAspectRatio, }); const syncContentPresentation = useCallback(() => { const wrapper = wrapperRef.current; if (!wrapper) return; useBrowserSurfaceStore.getState().presentContent(tabId, { x: layout.viewportX, y: layout.viewportY, width: layout.viewportWidth, height: layout.viewportHeight, scale: layout.viewportScale, scrollLeft: wrapper.scrollLeft, scrollTop: wrapper.scrollTop, }); }, [layout, tabId]); 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 }); }, [tabId, viewport._tag, viewportHeight, viewportWidth]); if (!config) return null; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, rect: lastRect, hiddenSize, }); return (
{deviceToolbarVisible && effectiveViewport._tag !== "fill" ? ( ) : null} {active && effectiveViewport._tag !== "fill" ? ( <> {activeDrag ? ( ) : null} ) : null}
); }