"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useStore, selectActiveTask, type LogEntry, type RunStats } from "@/lib/store"; import { useT, type DictKey } from "@/lib/i18n"; import { previewHtml, extractHtml } from "@/lib/extract-html"; import { isDeck } from "@/lib/deck"; import { DeckViewer } from "./deck-viewer"; type PreviewTab = "preview" | "deck" | "code" | "log"; const PREVIEW_TAB_KEY: Record = { preview: "preview.tab.preview", deck: "preview.tab.deck", code: "preview.tab.code", log: "preview.tab.log", }; const STATUS_KEY: Record = { idle: "preview.status.idle", running: "preview.status.running", done: "preview.status.done", error: "preview.status.error", }; const CHIP_KEYS: DictKey[] = [ "preview.placeholder.chip.article", "preview.placeholder.chip.deck", "preview.placeholder.chip.resume", "preview.placeholder.chip.poster", "preview.placeholder.chip.xiaohongshu", "preview.placeholder.chip.twitterCard", "preview.placeholder.chip.webProto", "preview.placeholder.chip.dataReport", ]; // stable references so the selector doesn't force re-render when active task is missing const EMPTY_LOG: LogEntry[] = []; const EMPTY_STATS: RunStats = { outputBytes: 0, deltaCount: 0 }; export function PreviewPane({ iframeRef, }: { iframeRef?: React.MutableRefObject; }) { const html = useStore((s) => selectActiveTask(s)?.html ?? ""); const status = useStore((s) => selectActiveTask(s)?.status ?? "idle"); const log = useStore((s) => selectActiveTask(s)?.log ?? EMPTY_LOG); const stats = useStore((s) => selectActiveTask(s)?.stats ?? EMPTY_STATS); const activeTaskId = useStore((s) => s.activeTaskId); const templateId = useStore((s) => selectActiveTask(s)?.templateId); const setHtmlFor = useStore((s) => s.setHtmlFor); // Template example HTML — fetched lazily when no task.html exists yet, so // switching templates in the picker shows that template's pre-shipped // `example.html` immediately, without burning agent tokens. Cleared as soon // as Convert produces real html, never written to task state. const [templateExample, setTemplateExample] = useState(""); useEffect(() => { // only fetch when there's no real output yet AND the run is idle if (html || status === "running") { setTemplateExample(""); return; } if (!templateId) return; let cancelled = false; fetch(`/api/templates/${encodeURIComponent(templateId)}/example`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (cancelled) return; const exampleHtml = (data?.html ?? "") as string; setTemplateExample(exampleHtml); }) .catch(() => { if (!cancelled) setTemplateExample(""); }); return () => { cancelled = true; }; }, [templateId, html, status]); const [tab, setTab] = useState("preview"); const localRef = useRef(null); const codeRef = useRef(null); const logRef = useRef(null); const wrapRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); // Bumping this remounts the iframe, forcing a clean re-render with the current // HTML — useful when the streaming preview committed a partial render and the // final state didn't repaint, or when injected scripts/styles need to re-init. const [refreshKey, setRefreshKey] = useState(0); const refresh = useCallback(() => setRefreshKey((n) => n + 1), []); const t = useT(); // Browser-level fullscreen for the whole preview pane. The Deck tab has its // own fullscreen wired in DeckViewer; we only handle Preview / Source / Log. useEffect(() => { const onFs = () => setIsFullscreen(document.fullscreenElement === wrapRef.current); document.addEventListener("fullscreenchange", onFs); return () => document.removeEventListener("fullscreenchange", onFs); }, []); const toggleFullscreen = useCallback(() => { const el = wrapRef.current; if (!el) return; if (document.fullscreenElement === el) { document.exitFullscreen?.(); } else { el.requestFullscreen?.().catch(() => {}); } }, []); // F to toggle fullscreen (only when not on Deck tab — DeckViewer handles its own). useEffect(() => { if (tab === "deck") return; const onKey = (e: KeyboardEvent) => { if (e.target instanceof HTMLElement) { const tagName = e.target.tagName; if (tagName === "INPUT" || tagName === "TEXTAREA" || e.target.isContentEditable) return; } if (e.key === "f" || e.key === "F") { e.preventDefault(); toggleFullscreen(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [tab, toggleFullscreen]); // Effective html for this render = real task output if any, else the // template example fetched above. Downstream (deck detection, debounce, // iframe srcDoc) treats both identically — the only difference is provenance. const effectiveHtml = html || templateExample; const isPreviewingTemplate = !html && !!templateExample; // Detect deck off the cleaned (un-fenced) html — extract once for reuse. const cleaned = useMemo(() => extractHtml(effectiveHtml), [effectiveHtml]); const deckMode = useMemo(() => isDeck(cleaned), [cleaned]); // First time we see a deck, auto-promote the user to the Deck tab so the // feature is discoverable. We only do this once per task run (track the // latest html length seen as the "trigger") to avoid stealing focus if the // user explicitly switched back to the single-page preview. const autoSwitchedRef = useRef(false); useEffect(() => { if (status === "running") return; if (deckMode && !autoSwitchedRef.current && tab === "preview") { setTab("deck"); autoSwitchedRef.current = true; } if (!deckMode) autoSwitchedRef.current = false; if (!deckMode && tab === "deck") setTab("preview"); }, [deckMode, status, tab]); // Debounce srcDoc updates to ~3 fps during streaming so the iframe // doesn't reload on every delta. Last value always commits when status changes. const [debouncedHtml, setDebouncedHtml] = useState(effectiveHtml); useEffect(() => { if (status !== "running") { setDebouncedHtml(effectiveHtml); return; } const id = setTimeout(() => setDebouncedHtml(effectiveHtml), 320); return () => clearTimeout(id); }, [effectiveHtml, status]); const display = useMemo(() => previewHtml(debouncedHtml), [debouncedHtml]); useEffect(() => { if (iframeRef) iframeRef.current = localRef.current; }); // Auto-scroll code to bottom while streaming. Skip while the user is editing // (textarea is focused) so we don't yank the caret around. useEffect(() => { if (status !== "running" || !codeRef.current) return; if (document.activeElement === codeRef.current) return; codeRef.current.scrollTop = codeRef.current.scrollHeight; }, [html, status]); // Auto-scroll log to bottom useEffect(() => { if (logRef.current) { logRef.current.scrollTop = logRef.current.scrollHeight; } }, [log]); // Auto-switch to code tab when stream starts so user sees output forming const prevStatusRef = useRef(status); useEffect(() => { if (prevStatusRef.current !== "running" && status === "running" && tab === "preview" && !html) { // stay on preview but no-op; we keep preview as default for live HTML render } prevStatusRef.current = status; }, [status, tab, html]); const showMetrics = status !== "idle" || !!stats.startedAt; // Present button is meaningful for Preview / Source / Log. The Deck tab has // its own Present button inside DeckViewer; suppress ours there to avoid // two competing fullscreens. Template-only previews count as "has html" for // the Present button so users can preview at full size without converting. const canPresent = tab !== "deck" && (tab !== "preview" || !!effectiveHtml); return (
{!isFullscreen && (
{(["preview", ...(deckMode ? (["deck"] as const) : []), "code", "log"] as const).map((id) => ( ))}
{tab === "preview" && html && ( )} {canPresent && ( )}
)} {!isFullscreen && showMetrics && }
{tab === "preview" && ( <> {!effectiveHtml && } {effectiveHtml && ( <>