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' ? (
) : (
{source}
)}
{inTabPresent && source ? (
) : null}
);
}
function ImageViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
return (
{file.kind === 'sketch'
? t('fileViewer.sketchMeta', { size: humanSize(file.size) })
: t('fileViewer.imageMeta', { size: humanSize(file.size) })}
);
}
function TextViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const [text, setText] = useState(null);
const [reloadKey, setReloadKey] = useState(0);
const [copied, setCopied] = useState(false);
useEffect(() => {
setText(null);
let cancelled = false;
void fetchProjectFileText(projectId, file.name).then((t) => {
if (!cancelled) setText(t ?? '');
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, reloadKey]);
async function copy() {
if (text == null) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// best-effort fallback
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} finally {
document.body.removeChild(ta);
}
}
}
const lineCount = text ? text.split('\n').length : 0;
return (
{text === null ? (
{t('fileViewer.loading')}
) : lineCount > 0 ? (
) : (
{text}
)}
);
}
function CodeWithLines({ text }: { text: string }) {
const lines = text.split('\n');
// Trailing newline produces a phantom empty line — keep gutter aligned.
const gutter = lines.map((_, i) => `${i + 1}`).join('\n');
return (
{gutter}
{text}
);
}
function humanSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}