import { ArrowLeftIcon, Globe2Icon, Maximize2Icon, PlugZapIcon, RefreshCwIcon, SparklesIcon, } from "lucide-react"; import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import type { SiteViewer } from "@opengeni/sdk"; import { cn } from "../../lib/cn"; import { ArtifactBadge, ArtifactButton, useArtifactLabels } from "./artifact-chrome"; import { PublishedHtmlArtifactFrame, type PublishedHtmlArtifactToolBridge, } from "./published-html-artifact-frame"; export type ArtifactSandboxProps = { html: string; height?: number | undefined; autoHeight?: boolean | undefined; theme?: "light" | "dark" | undefined; headerControls?: ReactNode | undefined; title: string; /** Hide the visible title/icon while retaining the accessible iframe title. */ showTitle?: boolean | undefined; showLiveStatus?: boolean | undefined; versionLabel?: string | undefined; className?: string | undefined; editDisabled?: boolean | undefined; onEdit?: (() => void) | undefined; toolBridge?: PublishedHtmlArtifactToolBridge | undefined; /** Who is viewing, answered to the Site's `site.viewer` over the bridge. */ viewer?: SiteViewer | undefined; connectedToolCount?: number | undefined; sourceFileCount?: number | undefined; fill?: boolean | undefined; /** * Control full screen from the host (for example from its URL). Omit to let * the frame's own expand and back buttons manage it. */ fullScreen?: boolean | undefined; /** Called when the person expands the frame or leaves full screen. */ onFullScreenChange?: ((fullScreen: boolean) => void) | undefined; /** * Hide the frame's toolbar when the host draws its own reload and full * screen controls. Full screen always shows it, for its Back button. */ showToolbar?: boolean | undefined; /** Reload the Site whenever this changes, from a host-drawn reload control. */ reloadToken?: number | undefined; }; /** * The Site/preview frame chrome shared by the console and embedding hosts: * title, version and tool badges, live status, reload, and a full-screen mode * that escapes the host's scroll containers through the modal top layer. */ export function ArtifactSandbox(props: ArtifactSandboxProps) { const labels = useArtifactLabels(); const [reloadKey, setReloadKey] = useState(0); const [ownFocused, setOwnFocused] = useState(false); const focused = props.fullScreen ?? ownFocused; const setFocused = (next: boolean) => { if (props.fullScreen === undefined) setOwnFocused(next); props.onFullScreenChange?.(next); }; const frameRef = useRef(null); const modalRef = useRef(false); useLayoutEffect(() => { const frame = frameRef.current; if (!frame) return; if (focused) { // A fixed child of the chat timeline is still clipped and layered by its // scroll pane and the sibling workspace dock. The modal top layer escapes // both while keeping the live Site iframe mounted in the same DOM node. frame.close(); frame.showModal(); modalRef.current = true; } else if (modalRef.current) { frame.close(); frame.show(); modalRef.current = false; } }, [focused]); const reload = () => { setReloadKey((value) => value + 1); }; return ( { event.preventDefault(); setFocused(false); }} className={cn( "og-root static m-0 w-full max-h-none max-w-none overflow-hidden rounded-2xl border border-border/80 bg-white p-0 text-left shadow-sm", props.className, props.fill && "flex min-h-0 flex-col", focused && "fixed inset-0 z-50 flex h-dvh w-dvw flex-col rounded-none border-0 bg-surface shadow-none", )} > ); }