open-design/apps/web/src/components/FileViewer.tsx
jiakeboge 2473ab9567
fix(web): add copy buttons for FileViewer code blocks (#471)
* fix(web): add copy buttons for FileViewer code blocks

* fix(web): harden FileViewer markdown copy controls

* fix(web): restore focus after clipboard fallback

* test(e2e): restore execCommand after markdown copy tests
2026-05-05 09:09:39 +08:00

2354 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
import { MarkdownRenderer, artifactRendererRegistry } from '../artifacts/renderer-registry';
import { renderMarkdownToSafeHtml } from '../artifacts/markdown';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import {
checkDeploymentLink,
deployProjectFile,
fetchDeployConfig,
fetchProjectDeployments,
fetchProjectFilePreview,
fetchProjectFileText,
projectFileUrl,
projectRawUrl,
updateDeployConfig,
} from '../providers/registry';
import type { ProjectFilePreview } from '../providers/registry';
import {
exportAsHtml,
exportAsJsx,
exportAsMd,
exportAsPdf,
exportProjectAsZip,
exportReactComponentAsHtml,
exportReactComponentAsZip,
openSandboxedPreviewInNewTab,
} from '../runtime/exports';
import { buildReactComponentSrcdoc } from '../runtime/react-component';
import { buildSrcdoc } from '../runtime/srcdoc';
import { parseForceInline, shouldUrlLoadHtmlPreview } from './file-viewer-render-mode';
import { saveTemplate } from '../state/projects';
import type { DeployConfigResponse, DeployProjectFileResponse, ProjectFile } from '../types';
import { Icon } from './Icon';
import {
liveSnapshotForComment,
overlayBoundsFromSnapshot,
targetFromSnapshot,
type PreviewCommentSnapshot,
} from '../comments';
import type { PreviewComment, PreviewCommentTarget } from '../types';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
type SlideState = { active: number; count: number };
const htmlPreviewSlideState = new Map<string, SlideState>();
const MARKDOWN_CODE_BLOCK_ATTR = 'data-markdown-code-block';
const MARKDOWN_COPY_BLOCK_ATTR = 'data-copy-code-block';
const MARKDOWN_COPY_BUTTON_CLASS = 'markdown-code-copy';
const MARKDOWN_COPY_TOAST_CLASS = 'markdown-code-toast';
async function copyTextToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
return document.execCommand('copy');
} catch {
return false;
} finally {
document.body.removeChild(ta);
if (priorFocus?.isConnected) {
try {
priorFocus.focus({ preventScroll: true });
} catch {
priorFocus.focus();
}
}
}
}
}
function decorateMarkdownCodeBlocks(html: string): string {
let blockIndex = 0;
return html.replace(/<pre\b([^>]*)>([\s\S]*?)<\/pre>/g, (_match, attrs: string, content: string) => {
const blockId = String(blockIndex++);
return `<div class="markdown-code-block" ${MARKDOWN_CODE_BLOCK_ATTR}="${blockId}"><pre${attrs}>${content}</pre></div>`;
});
}
function setMarkdownCodeBlockCopiedState(block: HTMLElement, copied: boolean, t: TranslateFn) {
const button = block.querySelector<HTMLButtonElement>(`.${MARKDOWN_COPY_BUTTON_CLASS}`);
if (!button) return;
const label = copied ? t('fileViewer.copied') : t('fileViewer.copy');
button.textContent = label;
button.setAttribute('aria-label', label);
button.title = t('fileViewer.copyTitle');
const existingToast = block.querySelector(`.${MARKDOWN_COPY_TOAST_CLASS}`);
if (copied) {
if (existingToast instanceof HTMLElement) {
existingToast.textContent = t('fileViewer.copied');
return;
}
const toast = document.createElement('span');
toast.className = MARKDOWN_COPY_TOAST_CLASS;
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.textContent = t('fileViewer.copied');
button.insertAdjacentElement('afterend', toast);
return;
}
existingToast?.remove();
}
function ensureMarkdownCodeBlockControls(root: HTMLElement, t: TranslateFn) {
for (const block of root.querySelectorAll<HTMLElement>(`[${MARKDOWN_CODE_BLOCK_ATTR}]`)) {
let button = block.querySelector<HTMLButtonElement>(`.${MARKDOWN_COPY_BUTTON_CLASS}`);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.className = MARKDOWN_COPY_BUTTON_CLASS;
const blockId = block.getAttribute(MARKDOWN_CODE_BLOCK_ATTR) ?? '';
button.setAttribute(MARKDOWN_COPY_BLOCK_ATTR, blockId);
block.prepend(button);
}
setMarkdownCodeBlockCopiedState(block, false, t);
}
}
interface Props {
projectId: string;
file: ProjectFile;
liveHtml?: string;
isDeck?: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined;
streaming?: boolean;
previewComments?: PreviewComment[];
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>;
}
export function FileViewer({
projectId,
file,
liveHtml,
isDeck,
onExportAsPptx,
streaming,
previewComments = [],
onSavePreviewComment,
onRemovePreviewComment,
}: Props) {
const rendererMatch = artifactRendererRegistry.resolve({
file,
isDeckHint: Boolean(isDeck),
});
if (rendererMatch?.renderer.id === 'html' || rendererMatch?.renderer.id === 'deck-html') {
return (
<HtmlViewer
projectId={projectId}
file={file}
liveHtml={liveHtml}
isDeck={rendererMatch.renderer.id === 'deck-html'}
onExportAsPptx={onExportAsPptx}
streaming={Boolean(streaming)}
previewComments={previewComments}
onSavePreviewComment={onSavePreviewComment}
onRemovePreviewComment={onRemovePreviewComment}
/>
);
}
if (rendererMatch?.renderer.id === 'react-component') {
return <ReactComponentViewer projectId={projectId} file={file} />;
}
if (rendererMatch?.renderer.id === 'markdown') {
return <MarkdownViewer projectId={projectId} file={file} />;
}
if (rendererMatch?.renderer.id === 'svg') {
return <SvgViewer projectId={projectId} file={file} />;
}
if (file.kind === 'image') {
return <ImageViewer projectId={projectId} file={file} />;
}
if (file.kind === 'video') {
return <VideoViewer projectId={projectId} file={file} />;
}
if (file.kind === 'audio') {
return <AudioViewer projectId={projectId} file={file} />;
}
if (file.kind === 'sketch') {
return <ImageViewer projectId={projectId} file={file} />;
}
if (file.kind === 'text' || file.kind === 'code') {
return <TextViewer projectId={projectId} file={file} />;
}
if (
file.kind === 'pdf' ||
file.kind === 'document' ||
file.kind === 'presentation' ||
file.kind === 'spreadsheet'
) {
return <DocumentPreviewViewer projectId={projectId} file={file} />;
}
return <BinaryViewer projectId={projectId} file={file} />;
}
function FileActions({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
return (
<div className="viewer-toolbar-actions">
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
download={file.name}
>
{t('fileViewer.download')}
</a>
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
target="_blank"
rel="noreferrer noopener"
>
{t('fileViewer.open')}
</a>
</div>
);
}
function CommentPopover({
target,
existing,
draft,
onDraft,
onClose,
onSave,
onRemove,
t,
}: {
target: PreviewCommentSnapshot;
existing: PreviewComment | null;
draft: string;
onDraft: (value: string) => void;
onClose: () => void;
onSave: (attach: boolean) => void | Promise<void>;
onRemove: (commentId: string) => void | Promise<void>;
t: TranslateFn;
}) {
return (
<div className="comment-popover" data-testid="comment-popover">
<div className="comment-popover-head">
<div>
<strong>{target.elementId}</strong>
<span>{target.label}</span>
</div>
<button type="button" className="ghost" onClick={onClose}>
{t('common.close')}
</button>
</div>
<textarea
data-testid="comment-popover-input"
value={draft}
placeholder={t('chat.comments.placeholder')}
onChange={(event) => onDraft(event.target.value)}
/>
<div className="comment-popover-actions">
{existing ? (
<button type="button" className="comment-popover-remove" onClick={() => onRemove(existing.id)}>
{t('chat.comments.remove')}
</button>
) : <span />}
<button
type="button"
className="primary"
data-testid="comment-add-send"
disabled={!draft.trim()}
onClick={() => void onSave(true)}
>
{existing ? t('chat.comments.updateSend') : t('chat.comments.addSend')}
</button>
</div>
</div>
);
}
function CommentPreviewOverlays({
comments,
liveTargets,
hoveredTarget,
activeTarget,
scale,
onOpenComment,
}: {
comments: PreviewComment[];
liveTargets: Map<string, PreviewCommentSnapshot>;
hoveredTarget: PreviewCommentSnapshot | null;
activeTarget: PreviewCommentSnapshot | null;
scale: number;
onOpenComment: (comment: PreviewComment, snapshot: PreviewCommentSnapshot) => void;
}) {
const visibleComments = comments
.map((comment, index) => ({
comment,
index,
snapshot: liveSnapshotForComment(comment, liveTargets),
}))
.filter((item): item is { comment: PreviewComment; index: number; snapshot: PreviewCommentSnapshot } =>
Boolean(item.snapshot),
);
const targetOverlay = activeTarget ?? hoveredTarget;
return (
<div className="comment-overlay-layer" aria-hidden={false}>
{visibleComments.map(({ comment, index, snapshot }) => {
const bounds = overlayBoundsFromSnapshot(snapshot, scale);
return (
<div
key={comment.id}
className="comment-saved-marker"
style={{
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
}}
data-testid={`comment-saved-marker-${comment.elementId}`}
>
<div className="comment-saved-outline" />
<button
type="button"
className="comment-saved-pin"
onClick={() => onOpenComment(comment, snapshot)}
title={`${comment.elementId}: ${comment.note}`}
aria-label={`Open comment for ${comment.elementId}`}
>
{index + 1}
</button>
</div>
);
})}
{targetOverlay ? (
<CommentTargetOverlay
snapshot={targetOverlay}
scale={scale}
selected={Boolean(activeTarget)}
/>
) : null}
</div>
);
}
function CommentTargetOverlay({
snapshot,
scale,
selected,
}: {
snapshot: PreviewCommentSnapshot;
scale: number;
selected: boolean;
}) {
const bounds = overlayBoundsFromSnapshot(snapshot, scale);
const width = Math.round(snapshot.position.width);
const height = Math.round(snapshot.position.height);
return (
<div
className={`comment-target-overlay${selected ? ' selected' : ''}`}
style={{
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
}}
data-testid="comment-target-overlay"
>
<div className="comment-target-tooltip">
<strong>{snapshot.elementId}</strong>
<span>{snapshot.label}</span>
<span>{width} × {height}</span>
</div>
</div>
);
}
function ReactComponentViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const [mode, setMode] = useState<'preview' | 'source'>('preview');
const [source, setSource] = useState<string | null>(null);
const [srcDoc, setSrcDoc] = useState('');
const [reloadKey, setReloadKey] = useState(0);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const shareRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setSource(null);
let cancelled = false;
void fetchProjectFileText(projectId, file.name).then((text) => {
if (!cancelled) setSource(text ?? '');
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, reloadKey]);
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]);
const exportTitle = file.name.replace(/\.(jsx|tsx)$/i, '') || file.name;
const sourceExtension = file.name.toLowerCase().endsWith('.tsx') ? '.tsx' : '.jsx';
useEffect(() => {
if (source === null) {
setSrcDoc('');
return;
}
let cancelled = false;
const buildSrcDoc = () => {
const nextSrcDoc = buildReactComponentSrcdoc(source, { title: exportTitle });
if (!cancelled) setSrcDoc(nextSrcDoc);
};
if (source.length > 100_000) {
setSrcDoc('');
const timeout = window.setTimeout(buildSrcDoc, 0);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}
buildSrcDoc();
return () => {
cancelled = true;
};
}, [source, exportTitle]);
return (
<div className="viewer react-component-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<button
type="button"
className="icon-only"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reload')}
aria-label={t('fileViewer.reloadAria')}
>
<Icon name="reload" size={14} />
</button>
<span className="viewer-meta">
{t('fileViewer.reactMeta', { size: humanSize(file.size) })}
</span>
</div>
<div className="viewer-toolbar-actions">
<div className="viewer-tabs">
<button
type="button"
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
onClick={() => setMode('preview')}
>
{t('fileViewer.preview')}
</button>
<button
type="button"
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
onClick={() => setMode('source')}
>
{t('fileViewer.source')}
</button>
</div>
{source !== null ? (
<>
<span className="viewer-divider" aria-hidden />
<div className="share-menu" ref={shareRef}>
<button
type="button"
className="viewer-action primary"
aria-haspopup="menu"
aria-expanded={shareMenuOpen}
onClick={() => setShareMenuOpen((v) => !v)}
>
<span>{t('fileViewer.shareLabel')}</span>
<Icon name="chevron-down" size={11} />
</button>
{shareMenuOpen ? (
<div className="share-menu-popover" role="menu">
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportAsJsx(source, exportTitle, sourceExtension);
}}
>
<span className="share-menu-icon"><Icon name="file-code" size={14} /></span>
<span>{t('fileViewer.exportJsx')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportReactComponentAsHtml(source, exportTitle);
}}
>
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
<span>{t('fileViewer.exportReactHtml')}</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportReactComponentAsZip(source, exportTitle, sourceExtension);
}}
>
<span className="share-menu-icon"><Icon name="download" size={14} /></span>
<span>{t('fileViewer.exportZip')}</span>
</button>
</div>
) : null}
</div>
</>
) : null}
</div>
</div>
<div className="viewer-body">
{source === null || (mode === 'preview' && !srcDoc) ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : mode === 'preview' ? (
<iframe
data-testid="react-component-preview-frame"
title={file.name}
sandbox="allow-scripts"
srcDoc={srcDoc}
/>
) : (
<CodeWithLines text={source} />
)}
</div>
</div>
);
}
function BinaryViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
return (
<div className="viewer binary-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.binaryMeta', { size: humanSize(file.size) })}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body">
<div className="viewer-empty">
{t('fileViewer.binaryNote', { size: file.size })}
</div>
</div>
</div>
);
}
function DocumentPreviewViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const [preview, setPreview] = useState<ProjectFilePreview | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
setPreview(null);
void fetchProjectFilePreview(projectId, file.name).then((next) => {
if (!cancelled) {
setPreview(next);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime]);
return (
<div className="viewer document-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{documentMetaLabel(file, t)} · {humanSize(file.size)}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body">
{loading ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : preview ? (
<div className="document-preview">
<h2>{preview.title}</h2>
{preview.sections.map((section, idx) => (
<section key={`${section.title}-${idx}`}>
<h3>{section.title}</h3>
{section.lines.map((line, lineIdx) => (
<p key={`${lineIdx}-${line}`}>{line}</p>
))}
</section>
))}
</div>
) : (
<div className="viewer-empty">{t('fileViewer.previewUnavailable')}</div>
)}
</div>
</div>
);
}
function HtmlViewer({
projectId,
file,
liveHtml,
isDeck,
onExportAsPptx,
streaming,
previewComments = [],
onSavePreviewComment,
onRemovePreviewComment,
}: {
projectId: string;
file: ProjectFile;
liveHtml?: string;
isDeck: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined;
streaming: boolean;
previewComments?: PreviewComment[];
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>;
}) {
const t = useT();
const [mode, setMode] = useState<'preview' | 'source'>('preview');
const [source, setSource] = useState<string | null>(liveHtml ?? null);
const [inlinedSource, setInlinedSource] = useState<string | null>(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<string | null>(null);
const [deployment, setDeployment] = useState<DeployProjectFileResponse | null>(null);
const [deployModalOpen, setDeployModalOpen] = useState(false);
const [deployConfig, setDeployConfig] = useState<DeployConfigResponse | null>(null);
const [deploying, setDeploying] = useState(false);
const [deployPhase, setDeployPhase] = useState<'idle' | 'deploying' | 'preparing-link'>('idle');
const [savingDeployConfig, setSavingDeployConfig] = useState(false);
const [deployError, setDeployError] = useState<string | null>(null);
const [deployResult, setDeployResult] = useState<DeployProjectFileResponse | null>(null);
const [copiedDeployLink, setCopiedDeployLink] = useState(false);
const [vercelToken, setVercelToken] = useState('');
const [teamId, setTeamId] = useState('');
const [teamSlug, setTeamSlug] = useState('');
const [inTabPresent, setInTabPresent] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [commentMode, setCommentMode] = useState(false);
// Opt back into the legacy inline-asset srcDoc path via `?forceInline=1`
// on the host page. Lets users escape-hatch around the URL-load default
// for non-deck HTML that depends on the in-iframe localStorage shim.
const forceInline = useMemo(
() => (typeof window === 'undefined' ? false : parseForceInline(window.location.search)),
[],
);
const [activeCommentTarget, setActiveCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
const [hoveredCommentTarget, setHoveredCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
const [liveCommentTargets, setLiveCommentTargets] = useState<Map<string, PreviewCommentSnapshot>>(() => new Map());
const [commentDraft, setCommentDraft] = useState('');
const previewStateKey = `${projectId}:${file.name}`;
// 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<SlideState | null>(
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
);
const previewBodyRef = useRef<HTMLDivElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const shareRef = useRef<HTMLDivElement | null>(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]);
useEffect(() => {
let cancelled = false;
setDeployResult(null);
setDeployError(null);
setCopiedDeployLink(false);
setDeployPhase('idle');
void fetchProjectDeployments(projectId).then((items) => {
if (cancelled) return;
const current = items.find(
(item) => item.fileName === file.name && item.providerId === 'vercel-self',
);
setDeployment(current ?? null);
setDeployResult(current ?? null);
});
return () => {
cancelled = true;
};
}, [projectId, file.name]);
// 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 previewSource = inlinedSource ?? source;
// When we URL-load the iframe directly, skip every in-host inlining /
// srcDoc-rebuilding step. The browser does the asset resolution itself,
// which is the whole point of the URL-load path.
const useUrlLoadPreview = shouldUrlLoadHtmlPreview({
mode,
isDeck: effectiveDeck,
commentMode,
forceInline,
});
const previewSrcUrl = useMemo(
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}`,
[projectId, file.name, file.mtime, reloadKey],
);
useEffect(() => {
setInlinedSource(null);
if (useUrlLoadPreview) return;
if (!source || effectiveDeck || !hasRelativeAssetRefs(source)) return;
let cancelled = false;
void inlineRelativeAssets(source, projectId, file.name).then((next) => {
if (!cancelled) setInlinedSource(next);
});
return () => {
cancelled = true;
};
}, [source, effectiveDeck, projectId, file.name, useUrlLoadPreview]);
const srcDoc = useMemo(
() => (previewSource ? buildSrcdoc(previewSource, {
deck: effectiveDeck,
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
commentBridge: commentMode,
}) : ''),
[previewSource, effectiveDeck, projectId, file.name, previewStateKey, commentMode],
);
useEffect(() => {
if (!effectiveDeck) {
setSlideState(null);
return;
}
setSlideState(htmlPreviewSlideState.get(previewStateKey) ?? null);
function onMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
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;
const next = { active: data.active, count: data.count };
htmlPreviewSlideState.set(previewStateKey, next);
setSlideState(next);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [effectiveDeck, previewStateKey]);
useEffect(() => {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od:comment-mode', enabled: commentMode }, '*');
}, [commentMode, srcDoc]);
useEffect(() => {
setActiveCommentTarget(null);
setHoveredCommentTarget(null);
setLiveCommentTargets(new Map());
setCommentDraft('');
}, [file.name]);
useEffect(() => {
if (!commentMode) {
setActiveCommentTarget(null);
setHoveredCommentTarget(null);
setLiveCommentTargets(new Map());
return;
}
const snapshotFromData = (data: Partial<PreviewCommentSnapshot>): PreviewCommentSnapshot => ({
filePath: file.name,
elementId: String(data.elementId || ''),
selector: String(data.selector || ''),
label: String(data.label || ''),
text: String(data.text || ''),
position: {
x: Number(data.position?.x) || 0,
y: Number(data.position?.y) || 0,
width: Number(data.position?.width) || 0,
height: Number(data.position?.height) || 0,
},
htmlHint: String(data.htmlHint || ''),
});
function onMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as (Partial<PreviewCommentSnapshot> & {
type?: string;
targets?: Array<Partial<PreviewCommentSnapshot>>;
}) | null;
if (!data?.type) return;
if (data.type === 'od:comment-targets' && Array.isArray(data.targets)) {
const next = new Map<string, PreviewCommentSnapshot>();
data.targets.forEach((item) => {
const snapshot = snapshotFromData(item);
if (snapshot.elementId) next.set(snapshot.elementId, snapshot);
});
setLiveCommentTargets(next);
setActiveCommentTarget((current) => (
current ? next.get(current.elementId) ?? null : null
));
setHoveredCommentTarget((current) => (
current ? next.get(current.elementId) ?? null : null
));
return;
}
if (data.type === 'od:comment-leave') {
setHoveredCommentTarget(null);
return;
}
if (data.type === 'od:comment-hover') {
const snapshot = snapshotFromData(data);
if (!snapshot.elementId) return;
setHoveredCommentTarget(snapshot);
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
return;
}
if (data.type === 'od:comment-target') {
const snapshot = snapshotFromData(data);
if (!snapshot.elementId) return;
const existing = previewComments.find((comment) => comment.elementId === snapshot.elementId);
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
setCommentDraft(existing?.note ?? '');
}
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [commentMode, file.name, previewComments]);
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;
openSandboxedPreviewInNewTab(source, exportTitle, {
deck: effectiveDeck,
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
});
}
// 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);
}
}
async function openDeployModal() {
setShareMenuOpen(false);
setDeployModalOpen(true);
setDeployError(null);
setCopiedDeployLink(false);
setDeployPhase('idle');
const [config, deployments] = await Promise.all([
fetchDeployConfig(),
fetchProjectDeployments(projectId),
]);
if (config) {
setDeployConfig(config);
setVercelToken(config.tokenMask || '');
setTeamId(config.teamId || '');
setTeamSlug(config.teamSlug || '');
}
const current = deployments.find(
(item) => item.fileName === file.name && item.providerId === 'vercel-self',
);
setDeployment(current ?? null);
setDeployResult(current ?? null);
}
async function saveDeployConfig() {
setSavingDeployConfig(true);
setDeployError(null);
try {
const config = await updateDeployConfig({
token: vercelToken,
teamId,
teamSlug,
});
if (!config) throw new Error(t('fileViewer.deployConfigSaveFailed'));
setDeployConfig(config);
setVercelToken(config.tokenMask || '');
setTeamId(config.teamId || '');
setTeamSlug(config.teamSlug || '');
return config;
} catch (err) {
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployConfigSaveFailed'));
return null;
} finally {
setSavingDeployConfig(false);
}
}
async function deployToVercel() {
setDeploying(true);
setDeployPhase('deploying');
setDeployError(null);
setCopiedDeployLink(false);
try {
const typedToken = vercelToken.trim();
const hasNewToken = typedToken && typedToken !== deployConfig?.tokenMask;
const needsConfigSave =
hasNewToken ||
teamId.trim() !== (deployConfig?.teamId || '') ||
teamSlug.trim() !== (deployConfig?.teamSlug || '') ||
!deployConfig?.configured;
if (needsConfigSave) {
const nextConfig = await saveDeployConfig();
if (!nextConfig?.configured) {
throw new Error(t('fileViewer.vercelTokenRequired'));
}
}
setDeployPhase('preparing-link');
const next = await deployProjectFile(projectId, file.name);
setDeployment(next);
setDeployResult(next);
} catch (err) {
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployFailed'));
} finally {
setDeploying(false);
setDeployPhase('idle');
}
}
async function retryDeploymentLink() {
const current = deployResult || deployment;
if (!current?.id) return;
setDeployError(null);
setDeployPhase('preparing-link');
try {
const next = await checkDeploymentLink(projectId, current.id);
setDeployment(next);
setDeployResult(next);
} catch (err) {
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployFailed'));
} finally {
setDeployPhase('idle');
}
}
async function copyDeployLink(url: string) {
const safeUrl = url.trim();
if (!safeUrl) return;
try {
await navigator.clipboard.writeText(safeUrl);
} catch {
const textarea = document.createElement('textarea');
textarea.value = safeUrl;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
setCopiedDeployLink(true);
window.setTimeout(() => setCopiedDeployLink(false), 1800);
}
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;
const activeDeployment = deployResult || deployment;
const activeDeployedUrl = activeDeployment?.url?.trim() || '';
const activeDeploymentReady = activeDeployment?.status === 'ready';
const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed';
const activeDeploymentProtected = activeDeployment?.status === 'protected';
const activeDeploymentNeedsRetry = activeDeploymentDelayed || activeDeploymentProtected;
const copyDeployLabel = copiedDeployLink
? t('fileViewer.copied')
: t('fileViewer.copyDeployLink');
return (
<div className="viewer html-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<button
type="button"
className="icon-only"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reload')}
aria-label={t('fileViewer.reloadAria')}
>
<Icon name="reload" size={14} />
</button>
{effectiveDeck ? (
<span
className="deck-nav"
role="group"
aria-label={t('fileViewer.slideNavAria')}
>
<button
type="button"
className="icon-only"
onClick={() => postSlide('prev')}
title={t('fileViewer.previousSlide')}
aria-label={t('fileViewer.previousSlide')}
disabled={slideState !== null && slideState.active <= 0}
>
<Icon name="chevron-right" size={14} style={{ transform: 'rotate(180deg)' }} />
</button>
<span className="deck-nav-counter">
{slideState
? `${slideState.active + 1} / ${slideState.count}`
: '— / —'}
</span>
<button
type="button"
className="icon-only"
onClick={() => postSlide('next')}
title={t('fileViewer.nextSlide')}
aria-label={t('fileViewer.nextSlide')}
disabled={
slideState !== null &&
slideState.active >= slideState.count - 1
}
>
<Icon name="chevron-right" size={14} />
</button>
</span>
) : null}
<button
type="button"
className="viewer-toggle"
disabled
data-coming-soon="true"
title={t('fileViewer.tweaks')}
aria-pressed={false}
onClick={(e) => e.preventDefault()}
>
<Icon name="tweaks" size={13} />
<span>{t('fileViewer.tweaks')}</span>
<span className="switch" aria-hidden />
</button>
</div>
<div className="viewer-toolbar-actions">
<div className="viewer-tabs">
<button
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
onClick={() => setMode('preview')}
>
{t('fileViewer.preview')}
</button>
<button
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
onClick={() => setMode('source')}
>
{t('fileViewer.source')}
</button>
</div>
<span className="viewer-divider" aria-hidden />
<button
className={`viewer-action${commentMode ? ' active' : ''}`}
type="button"
data-testid="comment-mode-toggle"
title={t('fileViewer.comment')}
onClick={() => setCommentMode((v) => !v)}
>
<Icon name="comment" size={13} />
<span>{t('fileViewer.comment')}</span>
</button>
<button
className="viewer-action"
type="button"
disabled
data-coming-soon="true"
title={t('fileViewer.edit')}
>
<Icon name="edit" size={13} />
<span>{t('fileViewer.edit')}</span>
</button>
<button
className="viewer-action"
type="button"
disabled
data-coming-soon="true"
title={t('fileViewer.draw')}
>
<Icon name="draw" size={13} />
<span>{t('fileViewer.draw')}</span>
</button>
<span className="viewer-divider" aria-hidden />
<button
type="button"
className="icon-only"
onClick={() => bumpZoom(-25)}
title={t('fileViewer.zoomOut')}
aria-label={t('fileViewer.zoomOut')}
>
<Icon name="minus" size={14} />
</button>
<button
type="button"
className="viewer-action"
onClick={() => setZoom(100)}
title={t('fileViewer.resetZoom')}
style={{ minWidth: 60 }}
>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
</button>
<button
type="button"
className="icon-only"
onClick={() => bumpZoom(25)}
title={t('fileViewer.zoomIn')}
aria-label={t('fileViewer.zoomIn')}
>
<Icon name="plus" size={14} />
</button>
<span className="viewer-divider" aria-hidden />
{showPresent ? (
<div className="present-wrap">
<button
className="viewer-action present-trigger"
aria-haspopup="menu"
aria-expanded={presentMenuOpen}
onClick={() => setPresentMenuOpen((v) => !v)}
>
<Icon name="present" size={13} />
<span>{t('fileViewer.present')}</span>
<Icon name="chevron-down" size={11} />
</button>
{presentMenuOpen ? (
<div className="present-menu" role="menu">
<button role="menuitem" onClick={presentInThisTab}>
<span className="present-icon"><Icon name="eye" size={13} /></span>{' '}
{t('fileViewer.presentInTab')}
</button>
<button role="menuitem" onClick={presentFullscreen}>
<span className="present-icon"><Icon name="play" size={13} /></span>{' '}
{t('fileViewer.presentFullscreen')}
</button>
<button role="menuitem" onClick={presentNewTab}>
<span className="present-icon"><Icon name="share" size={13} /></span>{' '}
{t('fileViewer.presentNewTab')}
</button>
</div>
) : null}
</div>
) : null}
{canShare ? (
<div className="share-menu" ref={shareRef}>
<button
className="viewer-action primary"
aria-haspopup="menu"
aria-expanded={shareMenuOpen}
onClick={() => setShareMenuOpen((v) => !v)}
>
<span>{t('fileViewer.shareLabel')}</span>
<Icon name="chevron-down" size={11} />
</button>
{shareMenuOpen ? (
<div className="share-menu-popover" role="menu">
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportAsPdf(source ?? '', exportTitle, { deck: effectiveDeck });
}}
>
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
<span>
{effectiveDeck
? t('fileViewer.exportPdfAllSlides')
: t('fileViewer.exportPdf')}
</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
disabled={!canPptx}
title={
onExportAsPptx
? streaming
? t('fileViewer.exportPptxBusy')
: t('fileViewer.exportPptxHint')
: t('fileViewer.exportPptxNa')
}
onClick={() => {
setShareMenuOpen(false);
if (onExportAsPptx) onExportAsPptx(file.name);
}}
>
<span className="share-menu-icon"><Icon name="present" size={14} /></span>
<span>{t('fileViewer.exportPptx') + '…'}</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
void exportProjectAsZip({
projectId,
filePath: file.name,
fallbackHtml: source ?? '',
fallbackTitle: exportTitle,
});
}}
>
<span className="share-menu-icon"><Icon name="download" size={14} /></span>
<span>{t('fileViewer.exportZip')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportAsHtml(source ?? '', exportTitle);
}}
>
<span className="share-menu-icon"><Icon name="file-code" size={14} /></span>
<span>{t('fileViewer.exportHtml')}</span>
</button>
{/* Export as Markdown — pass-through download of the
artifact source with a `.md` extension. No conversion
runs; the file body is identical to the Source view.
Useful for piping the artifact into markdown-aware
tooling (LLM context windows, vault apps). See
issue #279. */}
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportAsMd(source ?? '', exportTitle);
}}
>
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
<span>{t('fileViewer.exportMd')}</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
disabled={savingTemplate}
onClick={() => {
void handleSaveAsTemplate();
}}
>
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
<span>
{savingTemplate
? t('fileViewer.savingTemplate')
: templateNote
? templateNote
: t('fileViewer.saveAsTemplate')}
</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
void openDeployModal();
}}
>
<span className="share-menu-icon"><Icon name="upload" size={14} /></span>
<span>
{activeDeployedUrl
? t('fileViewer.redeployToVercel')
: t('fileViewer.deployToVercel')}
</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
disabled={!activeDeployedUrl}
onClick={() => {
setShareMenuOpen(false);
void copyDeployLink(activeDeployedUrl);
}}
>
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
<span>
{copyDeployLabel}
</span>
</button>
</div>
) : null}
</div>
) : null}
</div>
</div>
<div className="viewer-body" ref={previewBodyRef}>
{source === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : mode === 'preview' ? (
<div className="comment-preview-layer">
<div className="comment-frame-clip">
<div
style={{
width: `${100 / previewScale}%`,
height: `${100 / previewScale}%`,
transform: `scale(${previewScale})`,
transformOrigin: '0 0',
}}
>
{useUrlLoadPreview ? (
<iframe
ref={iframeRef}
data-testid="artifact-preview-frame"
data-od-render-mode="url-load"
title={file.name}
sandbox="allow-scripts"
src={previewSrcUrl}
/>
) : (
<iframe
ref={iframeRef}
data-testid="artifact-preview-frame"
data-od-render-mode="srcdoc"
title={file.name}
sandbox="allow-scripts"
srcDoc={srcDoc}
/>
)}
</div>
</div>
{commentMode ? (
<CommentPreviewOverlays
comments={previewComments}
liveTargets={liveCommentTargets}
hoveredTarget={hoveredCommentTarget}
activeTarget={activeCommentTarget}
scale={previewScale}
onOpenComment={(comment, snapshot) => {
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setCommentDraft(comment.note);
}}
/>
) : null}
{commentMode && activeCommentTarget ? (
<CommentPopover
target={activeCommentTarget}
existing={previewComments.find((comment) => comment.elementId === activeCommentTarget.elementId) ?? null}
draft={commentDraft}
onDraft={setCommentDraft}
onClose={() => setActiveCommentTarget(null)}
onSave={async (attach) => {
if (!commentDraft.trim() || !onSavePreviewComment) return;
const saved = await onSavePreviewComment(targetFromSnapshot(activeCommentTarget), commentDraft.trim(), attach);
if (saved) setActiveCommentTarget(null);
}}
onRemove={async (commentId) => {
if (!onRemovePreviewComment) return;
await onRemovePreviewComment(commentId);
setActiveCommentTarget(null);
}}
t={t}
/>
) : null}
</div>
) : (
<pre className="viewer-source">{source}</pre>
)}
</div>
{inTabPresent && source ? (
<div
className="present-overlay"
role="dialog"
aria-label={t('fileViewer.exitPresentation')}
>
<button
className="present-exit"
onClick={() => setInTabPresent(false)}
aria-label={t('fileViewer.exitPresentation')}
>
<Icon name="close" size={13} /> {t('fileViewer.exitPresentation')}
</button>
{useUrlLoadPreview ? (
<iframe
title="present"
sandbox="allow-scripts"
data-od-render-mode="url-load"
src={previewSrcUrl}
/>
) : (
<iframe
title="present"
sandbox="allow-scripts"
data-od-render-mode="srcdoc"
srcDoc={srcDoc}
/>
)}
</div>
) : null}
{deployModalOpen ? (
<div className="modal-backdrop" role="presentation">
<div className="modal deploy-modal" role="dialog" aria-modal="true">
<div className="modal-head">
<div className="kicker">VERCEL</div>
<h2>{t('fileViewer.deployModalTitle')}</h2>
<p className="subtitle">{t('fileViewer.deployModalSubtitle')}</p>
</div>
<div className="deploy-form">
<div className="field-label-row">
<label htmlFor="vercel-token">{t('fileViewer.vercelToken')}</label>
<a
href="https://vercel.com/account/settings/tokens"
target="_blank"
rel="noreferrer noopener"
>
{t('fileViewer.vercelTokenGetLink')}
</a>
</div>
<input
id="vercel-token"
type="password"
value={vercelToken}
placeholder={t('fileViewer.vercelTokenPlaceholder')}
onChange={(e) => setVercelToken(e.target.value)}
/>
<div className="deploy-config-actions">
<button
type="button"
className="ghost-link button-like"
disabled={savingDeployConfig}
onClick={() => {
void saveDeployConfig();
}}
>
{savingDeployConfig ? t('fileViewer.savingConfig') : t('fileViewer.save')}
</button>
</div>
{deployConfig?.configured ? (
<p className="hint">{t('fileViewer.vercelTokenReuseHint')}</p>
) : null}
<div className="deploy-field-grid">
<label>
<span>{t('fileViewer.vercelTeamId')}</span>
<input
value={teamId}
placeholder={t('fileViewer.optional')}
onChange={(e) => setTeamId(e.target.value)}
/>
</label>
<label>
<span>{t('fileViewer.vercelTeamSlug')}</span>
<input
value={teamSlug}
placeholder={t('fileViewer.optional')}
onChange={(e) => setTeamSlug(e.target.value)}
/>
</label>
</div>
<p className="hint">{t('fileViewer.vercelPreviewOnly')}</p>
{deployError ? <p className="deploy-error">{deployError}</p> : null}
{activeDeployedUrl ? (
<div
className={`deploy-result ${
activeDeploymentProtected ? 'protected' : activeDeploymentDelayed ? 'delayed' : 'ready'
}`}
>
<div className="deploy-result-label">
{activeDeploymentProtected
? t('fileViewer.deployLinkProtectedLabel')
: activeDeploymentDelayed
? t('fileViewer.deployLinkPreparingLabel')
: t('fileViewer.deployResultLabel')}
</div>
{activeDeploymentNeedsRetry ? (
<p className="deploy-result-message">
{activeDeploymentProtected
? t('fileViewer.deployLinkProtected')
: t('fileViewer.deployLinkDelayed')}
</p>
) : null}
<a href={activeDeployedUrl} target="_blank" rel="noreferrer noopener">
{activeDeployedUrl}
</a>
<div className="deploy-result-actions">
{activeDeploymentNeedsRetry ? (
<button
type="button"
className="viewer-action"
disabled={deployPhase === 'preparing-link'}
onClick={() => {
void retryDeploymentLink();
}}
>
{deployPhase === 'preparing-link'
? t('fileViewer.preparingPublicLink')
: t('fileViewer.retryLink')}
</button>
) : null}
<button
type="button"
className="viewer-action"
onClick={() => {
void copyDeployLink(activeDeployedUrl);
}}
>
<Icon name="copy" size={14} />
<span>{copyDeployLabel}</span>
</button>
<a
className={`ghost-link ${activeDeploymentReady ? '' : 'disabled'}`}
href={activeDeploymentReady ? activeDeployedUrl : undefined}
target="_blank"
rel="noreferrer noopener"
aria-disabled={!activeDeploymentReady}
>
<Icon name="upload" size={14} />
{t('fileViewer.open')}
</a>
</div>
</div>
) : null}
</div>
<div className="modal-foot">
<button
type="button"
className="ghost-link button-like"
onClick={() => setDeployModalOpen(false)}
>
{t('common.cancel')}
</button>
<button
type="button"
className="viewer-action primary"
disabled={deploying || savingDeployConfig || deployPhase !== 'idle'}
onClick={() => {
void deployToVercel();
}}
>
{deployPhase === 'deploying'
? t('fileViewer.deployingToVercel')
: deployPhase === 'preparing-link'
? t('fileViewer.preparingPublicLink')
: t('fileViewer.deployToVercel')}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function baseDirFor(fileName: string): string {
const idx = fileName.lastIndexOf('/');
return idx >= 0 ? fileName.slice(0, idx + 1) : '';
}
function hasRelativeAssetRefs(html: string): boolean {
const attr = /\s(?:src|href)\s*=\s*["']([^"']+)["']/gi;
let match: RegExpExecArray | null;
while ((match = attr.exec(html)) !== null) {
const value = match[1]?.trim();
if (!value) continue;
if (/^(?:https?:|data:|blob:|mailto:|tel:|#|\/)/i.test(value)) continue;
return true;
}
return false;
}
async function inlineRelativeAssets(
html: string,
projectId: string,
fileName: string,
): Promise<string> {
const replacements: Array<Promise<{ from: string; to: string } | null>> = [];
const links = html.match(/<link\b[^>]*>/gi) ?? [];
for (const tag of links) {
const rel = readHtmlAttr(tag, 'rel');
const href = readHtmlAttr(tag, 'href');
if (!rel || !/\bstylesheet\b/i.test(rel) || !href) continue;
replacements.push(
fetchProjectRelativeText(projectId, fileName, href).then((css) =>
css == null
? null
: {
from: tag,
to:
`<style data-od-inline-asset="${escapeHtmlAttr(href)}">\n` +
`${css.replace(/<\/style/gi, '<\\/style')}\n</style>`,
},
),
);
}
const scripts = html.match(/<script\b[^>]*\bsrc\s*=\s*["'][^"']+["'][^>]*>\s*<\/script>/gi) ?? [];
for (const tag of scripts) {
const src = readHtmlAttr(tag, 'src');
if (!src) continue;
replacements.push(
fetchProjectRelativeText(projectId, fileName, src).then((js) => {
if (js == null) return null;
const open = tag.match(/^<script\b[^>]*>/i)?.[0] ?? '<script>';
const attrs = open
.replace(/^<script/i, '')
.replace(/>$/i, '')
.replace(/\ssrc\s*=\s*(['"])[\s\S]*?\1/i, '');
return {
from: tag,
to: `<script${attrs}>\n${js.replace(/<\/script/gi, '<\\/script')}\n</script>`,
};
}),
);
}
const resolved = (await Promise.all(replacements)).filter(
(item): item is { from: string; to: string } => item !== null,
);
return resolved.reduce((next, { from, to }) => next.replace(from, () => to), html);
}
async function fetchProjectRelativeText(
projectId: string,
ownerFileName: string,
assetRef: string,
): Promise<string | null> {
const filePath = resolveProjectRelativePath(ownerFileName, assetRef);
if (!filePath) return null;
try {
const resp = await fetch(projectRawUrl(projectId, filePath));
if (!resp.ok) return null;
return await resp.text();
} catch {
return null;
}
}
function resolveProjectRelativePath(ownerFileName: string, assetRef: string): string | null {
if (/^(?:https?:|data:|blob:|mailto:|tel:|#|\/)/i.test(assetRef)) return null;
try {
const url = new URL(assetRef, `https://od.local/${baseDirFor(ownerFileName)}`);
if (url.origin !== 'https://od.local') return null;
return decodeURIComponent(url.pathname.replace(/^\/+/, ''));
} catch {
return null;
}
}
function readHtmlAttr(tag: string, name: string): string | null {
const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'));
return match?.[2] ?? null;
}
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function ImageViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
return (
<div className="viewer image-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{file.kind === 'sketch'
? t('fileViewer.sketchMeta', { size: humanSize(file.size) })
: t('fileViewer.imageMeta', { size: humanSize(file.size) })}
</span>
</div>
<div className="viewer-toolbar-actions">
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
download={file.name}
>
{t('fileViewer.download')}
</a>
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
target="_blank"
rel="noreferrer noopener"
>
{t('fileViewer.open')}
</a>
</div>
</div>
<div className="viewer-body image-body">
<img alt={file.name} src={url} />
</div>
</div>
);
}
function VideoViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
return (
<div className="viewer video-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.videoMeta', { size: humanSize(file.size) })}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body video-body">
<video src={url} controls playsInline preload="metadata" />
</div>
</div>
);
}
function AudioViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
return (
<div className="viewer audio-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.audioMeta', { size: humanSize(file.size) })}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body audio-body">
<div className="audio-card">
<Icon name="mic" size={28} />
<div className="audio-card-name">{file.name}</div>
<audio src={url} controls preload="metadata" />
</div>
</div>
</div>
);
}
type SvgViewerMode = 'preview' | 'source';
interface SvgViewerProps {
projectId: string;
file: ProjectFile;
initialMode?: SvgViewerMode;
initialSource?: string | null | undefined;
}
export function SvgViewer({
projectId,
file,
initialMode = 'preview',
initialSource,
}: SvgViewerProps) {
const t = useT();
const [mode, setMode] = useState<SvgViewerMode>(initialMode);
const [source, setSource] = useState<string | null>(initialSource ?? null);
const [loadingSource, setLoadingSource] = useState(false);
const [sourceError, setSourceError] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}`;
useEffect(() => {
if (mode !== 'source') return;
if (initialSource !== undefined && reloadKey === 0) return;
let cancelled = false;
setLoadingSource(true);
setSourceError(false);
void fetchProjectFileText(projectId, file.name, {
cache: 'no-store',
cacheBustKey: `${Math.round(file.mtime)}-${reloadKey}`,
}).then((next) => {
if (cancelled) return;
if (next === null) {
setSource('');
setSourceError(true);
} else {
setSource(next);
}
setLoadingSource(false);
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, initialSource, mode, reloadKey]);
return (
<div className="viewer svg-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.imageMeta', { size: humanSize(file.size) })}
</span>
</div>
<div className="viewer-toolbar-actions">
<div className="viewer-tabs">
<button
type="button"
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
aria-pressed={mode === 'preview'}
onClick={() => setMode('preview')}
>
{t('fileViewer.preview')}
</button>
<button
type="button"
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
aria-pressed={mode === 'source'}
onClick={() => setMode('source')}
>
{t('fileViewer.source')}
</button>
</div>
<span className="viewer-divider" aria-hidden />
<button
type="button"
className="viewer-action"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reloadDisk')}
>
<Icon name="reload" size={13} />
<span>{t('fileViewer.reload')}</span>
</button>
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
download={file.name}
>
{t('fileViewer.download')}
</a>
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
target="_blank"
rel="noreferrer noopener"
>
{t('fileViewer.open')}
</a>
</div>
</div>
<div className={`viewer-body ${mode === 'preview' ? 'image-body' : ''}`}>
{mode === 'preview' ? (
<img alt={file.name} src={url} />
) : loadingSource ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : sourceError ? (
<div className="viewer-empty">{t('fileViewer.previewUnavailable')}</div>
) : (
<pre className="viewer-source">{source ?? ''}</pre>
)}
</div>
</div>
);
}
function TextViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const [text, setText] = useState<string | null>(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 (
<div className="viewer text-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left" />
<div className="viewer-toolbar-actions">
<button
type="button"
className="viewer-action"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reloadDisk')}
>
<Icon name="reload" size={13} />
<span>{t('fileViewer.reload')}</span>
</button>
<button
type="button"
className="viewer-action"
disabled
title={t('fileViewer.saveDisabled')}
>
<Icon name="check" size={13} />
<span>{t('fileViewer.save')}</span>
</button>
<button
type="button"
className="viewer-action"
onClick={() => void copy()}
title={t('fileViewer.copyTitle')}
>
<Icon name={copied ? 'check' : 'copy'} size={13} />
<span>{copied ? t('fileViewer.copied') : t('fileViewer.copy')}</span>
</button>
</div>
</div>
<div className="viewer-body">
{text === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : lineCount > 0 ? (
<CodeWithLines text={text} />
) : (
<pre className="viewer-source">{text}</pre>
)}
</div>
</div>
);
}
function MarkdownViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const [text, setText] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [copied, setCopied] = useState(false);
const markdownArticleRef = useRef<HTMLElement | null>(null);
const copyBlockTimerRef = useRef<number | null>(null);
const copiedMarkdownBlockRef = useRef<HTMLElement | null>(null);
const status = file.artifactManifest?.status ?? 'complete';
const isStreaming = status === 'streaming';
const isError = status === 'error';
useEffect(() => {
setText(null);
copiedMarkdownBlockRef.current = null;
if (copyBlockTimerRef.current) {
window.clearTimeout(copyBlockTimerRef.current);
copyBlockTimerRef.current = null;
}
let cancelled = false;
void fetchProjectFileText(projectId, file.name).then((next) => {
if (!cancelled) setText(next ?? '');
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, reloadKey]);
useEffect(() => {
return () => {
copiedMarkdownBlockRef.current = null;
if (copyBlockTimerRef.current) {
window.clearTimeout(copyBlockTimerRef.current);
}
};
}, []);
async function copy() {
if (text == null) return;
const didCopy = await copyTextToClipboard(text);
if (didCopy) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
}
const html = useMemo(() => {
if (text === null) return null;
const renderPartial = MarkdownRenderer.renderPartial ?? renderMarkdownToSafeHtml;
return decorateMarkdownCodeBlocks(renderPartial(text));
}, [text]);
useEffect(() => {
const article = markdownArticleRef.current;
if (!article) return;
ensureMarkdownCodeBlockControls(article, t);
if (copiedMarkdownBlockRef.current?.isConnected) {
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, true, t);
}
}, [html, t]);
async function handleMarkdownBodyClick(event: ReactMouseEvent<HTMLElement>) {
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest<HTMLButtonElement>(`button[${MARKDOWN_COPY_BLOCK_ATTR}]`);
if (!button) return;
const block = button.closest('.markdown-code-block');
if (!(block instanceof HTMLElement)) return;
const pre = block.querySelector('pre');
if (!pre) return;
const didCopy = await copyTextToClipboard(pre.textContent ?? '');
if (!didCopy) return;
if (copiedMarkdownBlockRef.current && copiedMarkdownBlockRef.current !== block) {
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, false, t);
}
copiedMarkdownBlockRef.current = block;
setMarkdownCodeBlockCopiedState(block, true, t);
if (copyBlockTimerRef.current) {
window.clearTimeout(copyBlockTimerRef.current);
}
copyBlockTimerRef.current = window.setTimeout(() => {
if (copiedMarkdownBlockRef.current) {
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, false, t);
}
copiedMarkdownBlockRef.current = null;
copyBlockTimerRef.current = null;
}, 1800);
}
return (
<div className="viewer text-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
{isStreaming ? <span className="viewer-meta">{t('fileViewer.markdownStreamingMeta')}</span> : null}
{isError ? <span className="viewer-meta">{t('fileViewer.markdownErrorMeta')}</span> : null}
</div>
<div className="viewer-toolbar-actions">
<button
type="button"
className="viewer-action"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reloadDisk')}
>
<Icon name="reload" size={13} />
<span>{t('fileViewer.reload')}</span>
</button>
<button
type="button"
className="viewer-action"
onClick={() => void copy()}
title={t('fileViewer.copyTitle')}
>
<Icon name={copied ? 'check' : 'copy'} size={13} />
<span>{copied ? t('fileViewer.copied') : t('fileViewer.copy')}</span>
</button>
</div>
</div>
<div className="viewer-body">
{html === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : (
<>
{isStreaming ? <div className="markdown-status">{t('fileViewer.markdownStreamingStatus')}</div> : null}
{isError ? <div className="markdown-status markdown-status-error">{t('fileViewer.markdownErrorStatus')}</div> : null}
{/* Safe by contract: renderMarkdownToSafeHtml escapes raw HTML and rejects unsafe link protocols. */}
<article
ref={markdownArticleRef}
className="markdown-rendered"
onClick={(event) => void handleMarkdownBodyClick(event)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</>
)}
</div>
</div>
);
}
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 (
<pre className="code-viewer">
<code className="gutter" aria-hidden>
{gutter}
</code>
<code className="lines">{text}</code>
</pre>
);
}
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`;
}
function documentMetaLabel(file: ProjectFile, t: TranslateFn): string {
if (file.kind === 'pdf') return t('fileViewer.pdfMeta');
if (file.kind === 'document') return t('fileViewer.documentMeta');
if (file.kind === 'presentation') return t('fileViewer.presentationMeta');
if (file.kind === 'spreadsheet') return t('fileViewer.spreadsheetMeta');
return t('fileViewer.binaryMeta', { size: humanSize(file.size) });
}