import { useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../i18n'; import { fetchProjectFileText, projectFileUrl } from '../providers/registry'; import { exportAsHtml, exportAsPdf, exportAsZip } from '../runtime/exports'; import { buildSrcdoc } from '../runtime/srcdoc'; import { saveTemplate } from '../state/projects'; import type { ProjectFile } from '../types'; import { Icon } from './Icon'; interface Props { projectId: string; file: ProjectFile; liveHtml?: string; isDeck?: boolean; onExportAsPptx?: ((fileName: string) => void) | undefined; streaming?: boolean; } export function FileViewer({ projectId, file, liveHtml, isDeck, onExportAsPptx, streaming, }: Props) { if (file.kind === 'html') { return ( ); } if (file.kind === 'image') { return ; } if (file.kind === 'sketch') { return ; } if (file.kind === 'text' || file.kind === 'code') { return ; } return ; } function BinaryViewer({ projectId, file, }: { projectId: string; file: ProjectFile; }) { const t = useT(); return (
{t('fileViewer.binaryMeta', { size: humanSize(file.size) })}
{t('fileViewer.binaryNote', { size: file.size })}
); } function HtmlViewer({ projectId, file, liveHtml, isDeck, onExportAsPptx, streaming, }: { projectId: string; file: ProjectFile; liveHtml?: string; isDeck: boolean; onExportAsPptx?: ((fileName: string) => void) | undefined; streaming: boolean; }) { const t = useT(); const [mode, setMode] = useState<'preview' | 'source'>('preview'); const [source, setSource] = useState(liveHtml ?? null); const [zoom, setZoom] = useState(100); const [presentMenuOpen, setPresentMenuOpen] = useState(false); const [shareMenuOpen, setShareMenuOpen] = useState(false); // Template save UX. We surface a transient "Saved" pill in the share // menu so the user gets feedback without a noisy toast layer. const [savingTemplate, setSavingTemplate] = useState(false); const [templateNote, setTemplateNote] = useState(null); const [inTabPresent, setInTabPresent] = useState(false); const [reloadKey, setReloadKey] = useState(0); // Slide deck nav state: the iframe posts the active index + total count // back to the host every time a slide settles. Host renders prev/next // controls in the toolbar and reflects the count beside them. const [slideState, setSlideState] = useState<{ active: number; count: number } | null>(null); const previewBodyRef = useRef(null); const iframeRef = useRef(null); const shareRef = useRef(null); useEffect(() => { if (liveHtml !== undefined) { setSource(liveHtml); return; } setSource(null); let cancelled = false; void fetchProjectFileText(projectId, file.name).then((text) => { if (!cancelled) setSource(text); }); return () => { cancelled = true; }; }, [projectId, file.name, file.mtime, liveHtml, reloadKey]); // Detect deck-shaped HTML even when the project's skill didn't declare // `mode: deck`. Freeform projects often produce a deck because the user // asked for one in plain prose; without this, prev/next and Present // never surface and the deck becomes a static, unnavigable preview. const looksLikeDeck = useMemo(() => { if (!source) return false; return /class\s*=\s*['"][^'"]*\bslide\b/i.test(source); }, [source]); const effectiveDeck = isDeck || looksLikeDeck; const srcDoc = useMemo( () => (source ? buildSrcdoc(source, { deck: effectiveDeck }) : ''), [source, effectiveDeck], ); useEffect(() => { if (!effectiveDeck) { setSlideState(null); return; } function onMessage(ev: MessageEvent) { const data = ev?.data as | { type?: string; active?: number; count?: number } | null; if (!data || data.type !== 'od:slide-state') return; if (typeof data.active !== 'number' || typeof data.count !== 'number') return; setSlideState({ active: data.active, count: data.count }); } window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, [effectiveDeck]); function postSlide(action: 'next' | 'prev' | 'first' | 'last') { const win = iframeRef.current?.contentWindow; if (!win) return; win.postMessage({ type: 'od:slide', action }, '*'); } // Keyboard nav on the host, so the user can press ←/→ even when focus // is on the chat composer or any other host control. useEffect(() => { if (!effectiveDeck || mode !== 'preview') return; function onKey(e: KeyboardEvent) { const target = e.target as HTMLElement | null; if (target) { const tag = target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return; } if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); postSlide('next'); } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); postSlide('prev'); } else if (e.key === 'Home') { e.preventDefault(); postSlide('first'); } else if (e.key === 'End') { e.preventDefault(); postSlide('last'); } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [effectiveDeck, mode]); useEffect(() => { if (!presentMenuOpen) return; const onPointer = (e: MouseEvent) => { const target = e.target as HTMLElement | null; if (!target) return; if (target.closest('.present-wrap')) return; setPresentMenuOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPresentMenuOpen(false); }; document.addEventListener('mousedown', onPointer); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onPointer); document.removeEventListener('keydown', onKey); }; }, [presentMenuOpen]); useEffect(() => { if (!shareMenuOpen) return; const onDocClick = (e: MouseEvent) => { if (!shareRef.current) return; if (!shareRef.current.contains(e.target as Node)) setShareMenuOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setShareMenuOpen(false); }; document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [shareMenuOpen]); useEffect(() => { if (!inTabPresent) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setInTabPresent(false); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [inTabPresent]); function openInNewTab() { if (!source) return; const blob = new Blob([source], { type: 'text/html' }); const url = URL.createObjectURL(blob); window.open(url, '_blank', 'noopener,noreferrer'); setTimeout(() => URL.revokeObjectURL(url), 60_000); } // Snapshot this project as a reusable template. The daemon snapshots // EVERY html/text/code file in the project (not just the file open in // the viewer), so the template captures the whole design, not a single // page. Surfaced here in the Share menu because that's where the user's // share / export mental model already lives. async function handleSaveAsTemplate() { setShareMenuOpen(false); const defaultName = file.name.replace(/\.html?$/i, '') || t('fileViewer.templateNameDefault'); const name = window.prompt(t('fileViewer.templateNamePrompt'), defaultName); if (!name || !name.trim()) return; const description = window.prompt( t('fileViewer.templateDescPrompt'), '', ); setSavingTemplate(true); setTemplateNote(null); try { const tpl = await saveTemplate({ name: name.trim(), description: description?.trim() || undefined, sourceProjectId: projectId, }); setTemplateNote( tpl ? t('fileViewer.savedTemplate', { name: tpl.name }) : t('fileViewer.savedTemplateFail'), ); } finally { setSavingTemplate(false); // Auto-clear the note so the menu doesn't keep stale state next open. setTimeout(() => setTemplateNote(null), 4000); } } function presentInThisTab() { setPresentMenuOpen(false); setInTabPresent(true); } function presentFullscreen() { setPresentMenuOpen(false); const el = previewBodyRef.current; if (el && typeof el.requestFullscreen === 'function') { el.requestFullscreen().catch(() => setInTabPresent(true)); } else { setInTabPresent(true); } } function presentNewTab() { setPresentMenuOpen(false); openInNewTab(); } function bumpZoom(delta: number) { setZoom((z) => Math.max(25, Math.min(200, z + delta))); } const showPresent = effectiveDeck && source !== null; const canShare = source !== null; const exportTitle = file.name.replace(/\.html?$/i, '') || file.name; const canPptx = canShare && Boolean(onExportAsPptx) && !streaming; const previewScale = zoom / 100; return (
{effectiveDeck ? ( {slideState ? `${slideState.active + 1} / ${slideState.count}` : '— / —'} ) : null}
{showPresent ? (
{presentMenuOpen ? (
) : null}
) : null} {canShare ? (
{shareMenuOpen ? (
) : null}
) : null}
{source === null ? (
{t('fileViewer.loading')}
) : mode === 'preview' ? (