Spaces:
Sleeping
Sleeping
| <script lang="ts"> | |
| import Modal from "./Modal.svelte"; | |
| import ExternalLinkModal from "./ExternalLinkModal.svelte"; | |
| import { onMount, onDestroy } from "svelte"; | |
| import CarbonClose from "~icons/carbon/close"; | |
| import { pendingChatInput } from "$lib/stores/pendingChatInput"; | |
| import { buildArtifactSrcdoc } from "$lib/utils/previewSrcdoc"; | |
| import { parseExternalUrl } from "$lib/utils/externalLink"; | |
| import type { ArtifactKind } from "$lib/utils/artifacts"; | |
| interface Props { | |
| html: string; | |
| /** How to render the content; html also covers raw SVG documents */ | |
| kind?: ArtifactKind; | |
| onclose?: () => void; | |
| } | |
| let { html, kind = "html", onclose }: Props = $props(); | |
| let iframeEl: HTMLIFrameElement | undefined = $state(); | |
| let channel = $state(`preview_${Math.random().toString(36).slice(2)}`); | |
| let errors: { message: string; stack?: string }[] = $state([]); | |
| let externalLinkUrl = $state<URL | null>(null); | |
| let srcdoc = $derived(buildArtifactSrcdoc(kind, html, channel)); | |
| type PreviewMessage = { | |
| type: string; | |
| channel: string; | |
| detail?: { message?: unknown; stack?: string; href?: unknown }; | |
| }; | |
| function onMessage(ev: MessageEvent) { | |
| if (!iframeEl || ev.source !== iframeEl.contentWindow) return; | |
| const raw = ev.data as unknown; | |
| if (!raw || typeof raw !== "object") return; | |
| const data = raw as Partial<PreviewMessage>; | |
| if (data.channel !== channel) return; | |
| if (data.type === "chatui.preview.openLink") { | |
| // Only honor link messages backed by a real user gesture (clicks inside | |
| // the iframe propagate activation to ancestor frames); artifact scripts | |
| // must not be able to pop the confirm without one | |
| if (navigator.userActivation && !navigator.userActivation.isActive) return; | |
| // The iframe runs untrusted generated code, so re-validate its href here | |
| externalLinkUrl = parseExternalUrl(data.detail?.href) ?? null; | |
| return; | |
| } | |
| if (data.type !== "chatui.preview.error") return; | |
| const detail = (data.detail ?? {}) as { message?: unknown; stack?: string }; | |
| errors = [...errors, { message: String(detail.message ?? "Error"), stack: detail.stack }]; | |
| } | |
| onMount(() => { | |
| window.addEventListener("message", onMessage); | |
| }); | |
| onDestroy(() => { | |
| window.removeEventListener("message", onMessage); | |
| }); | |
| function composeText(): string { | |
| const lines = errors.map((e, i) => `${i + 1}. ${e.message}${e.stack ? `\n${e.stack}` : ""}`); | |
| const summary = lines[0] ?? "Unknown error"; | |
| return errors.length > 1 | |
| ? `it's not working: ${summary} (+${errors.length - 1} more) - can you fix it?` | |
| : `it's not working: ${summary} - can you fix it?`; | |
| } | |
| // Esc/backdrop while the external-link confirm is open dismisses just the | |
| // confirm; the fullscreen preview itself stays up. (Esc reaches this Modal's | |
| // handler first because it registered its window listener before the nested | |
| // confirm modal's.) | |
| function requestClose() { | |
| if (externalLinkUrl) { | |
| externalLinkUrl = null; | |
| return; | |
| } | |
| onclose?.(); | |
| } | |
| </script> | |
| <Modal width="max-w-none max-h-none w-dvw h-dvh rounded-none!" onclose={requestClose}> | |
| <div class="relative h-dvh w-dvw"> | |
| <iframe | |
| bind:this={iframeEl} | |
| title="HTML Preview" | |
| class="h-full w-full" | |
| sandbox="allow-scripts" | |
| referrerpolicy="no-referrer" | |
| {srcdoc} | |
| ></iframe> | |
| <!-- Close button with visible container --> | |
| <button | |
| class="fixed top-4 right-6 z-50 btn flex h-7 items-center gap-1 rounded-lg border border-gray-500/60 bg-gray-800 px-2 text-xs text-white shadow-xs backdrop-blur-sm transition-none hover:border-gray-500 hover:bg-gray-700 active:shadow-inner" | |
| title="Close preview (Esc)" | |
| onclick={() => onclose?.()} | |
| > | |
| <CarbonClose class="size-3.5" /> | |
| Close preview | |
| </button> | |
| {#if errors.length > 0} | |
| <button | |
| class="fixed right-4 bottom-4 z-50 btn flex items-center gap-2 rounded-full border-2 border-red-500/60 bg-red-800/90 px-4 py-1.5 text-sm text-white shadow-lg" | |
| title="Send error to chat" | |
| onclick={() => { | |
| pendingChatInput.set(composeText()); | |
| onclose?.(); | |
| }} | |
| > | |
| <span>Error caught ({errors.length})</span> | |
| </button> | |
| {/if} | |
| </div> | |
| </Modal> | |
| {#if externalLinkUrl} | |
| <ExternalLinkModal url={externalLinkUrl} onclose={() => (externalLinkUrl = null)} /> | |
| {/if} | |