import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { HTML_RENDER_COLUMN_WIDTH, htmlRenderFileName, htmlRenderFrameHeight, type HtmlRenderReference, } from "@t3tools/shared/htmlRender"; import { Maximize2Icon } from "lucide-react"; import { useEffect, useState } from "react"; import { useAssetUrlRefresh } from "~/assets/assetUrls"; import { useLongLivedAssetUrl } from "~/assets/longLivedAssetUrls"; import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; import { Button } from "../ui/button"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { HtmlRenderDialog } from "./HtmlRenderDialog"; import { observeResize } from "~/lib/observeResize"; const MIN_URL_LIFE_MS = 5 * 60_000; export function HtmlRenderFrame(props: { readonly environmentId: EnvironmentId; readonly htmlRender: HtmlRenderReference; readonly threadRef: ScopedThreadRef | undefined; readonly onFullscreenChange?: (fullscreen: boolean) => void; }) { const { attachmentId, title } = props.htmlRender; const [width, setWidth] = useState(HTML_RENDER_COLUMN_WIDTH); const observeBox = (box: HTMLDivElement) => { setWidth(box.clientWidth); return observeResize(box, ([entry]) => { if (entry) setWidth(entry.contentRect.width); }); }; // Client fonts can exceed the server-measured height and cause nested scrolling. const [contentHeight, setContentHeight] = useState(); const height = htmlRenderFrameHeight(props.htmlRender, width, contentHeight); const fileName = htmlRenderFileName(title); const resource = { _tag: "attachment" as const, attachmentId, fileName, mimeType: "text/html", disposition: "inline" as const, }; // The frame keeps the first URL it loads, so later mints do not reload the page. const [loaded, setLoaded] = useState<{ src: string; expiresAt: number } | null>(null); const assetUrl = useLongLivedAssetUrl(loaded === null ? props.environmentId : null, resource); if (loaded === null && assetUrl._tag === "Success") { setLoaded({ src: assetUrl.url, expiresAt: assetUrl.expiresAt }); } const src = loaded?.src ?? null; const failed = loaded === null && assetUrl._tag === "Failure"; const refresh = useAssetUrlRefresh(props.environmentId, resource); const [expandedSrc, setExpandedSrc] = useState(null); const { onFullscreenChange } = props; // Tied to the viewer's lifetime: unmounting while expanded must still release // the timeline's follow pin, which a close handler alone never reaches. const expanded = expandedSrc !== null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds the parent's fullscreen pin while expanded and releases it on collapse or unmount useEffect(() => { if (!expanded) return; onFullscreenChange?.(true); return () => onFullscreenChange?.(false); }, [expanded, onFullscreenChange]); return (
{src !== null ? ( <>
{ const url = loaded !== null && loaded.expiresAt - Date.now() > MIN_URL_LIFE_MS ? src : await refresh().catch(() => null); if (url !== null) setExpandedSrc(url); else toastManager.add({ type: "error", title: "Page unavailable", description: "Reconnect to the environment and try again.", }); }} /> } > Open full screen
{expandedSrc !== null ? ( setExpandedSrc(null)} /> ) : null} ) : failed ? (

Unable to load {title}

) : null}
); }