mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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
2354 lines
80 KiB
TypeScript
2354 lines
80 KiB
TypeScript
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, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
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) });
|
||
}
|