Files
open-design/src/components/FileViewer.tsx
T
Tom Huang 6f6bf31dd2 Refactor project name from "Open Claude Design" to "Open Design" (#1)
* Refactor project name from "Open Claude Design" to "Open Design"

- Updated project name in package.json, package-lock.json, and README files.
- Changed CLI commands and references from "ocd" to "od".
- Adjusted file structure references in documentation and code to reflect new naming conventions.
- Enhanced .gitignore to include new runtime data files.
- Updated metadata in LICENSE file to match new project name.

* Add contributing guidelines in English and Chinese

- Introduced CONTRIBUTING.md and CONTRIBUTING.zh-CN.md to provide clear instructions for contributors.
- Outlined contribution types, local setup instructions, and merging criteria for skills and design systems.
- Enhanced README files to reference the new contributing guidelines.
2026-04-28 16:03:35 +08:00

796 lines
26 KiB
TypeScript

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