Add initial project structure with essential files
- Created .gitignore to exclude build artifacts and dependencies. - Added index.html as the main entry point for the application. - Included LICENSE file with Apache 2.0 terms. - Initialized package.json and package-lock.json for project dependencies. - Added pnpm-lock.yaml for package management. - Created QUICKSTART.md for setup instructions. - Added README.md and README.zh-CN.md for project documentation in English and Chinese.
This commit is contained in:
@@ -0,0 +1,785 @@
|
||||
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]);
|
||||
|
||||
const srcDoc = useMemo(
|
||||
() => (source ? buildSrcdoc(source, { deck: isDeck }) : ''),
|
||||
[source, isDeck],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDeck) {
|
||||
setSlideState(null);
|
||||
return;
|
||||
}
|
||||
function onMessage(ev: MessageEvent) {
|
||||
const data = ev?.data as
|
||||
| { type?: string; active?: number; count?: number }
|
||||
| null;
|
||||
if (!data || data.type !== 'ocd: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);
|
||||
}, [isDeck]);
|
||||
|
||||
function postSlide(action: 'next' | 'prev' | 'first' | 'last') {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
if (!win) return;
|
||||
win.postMessage({ type: 'ocd: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 (!isDeck || 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);
|
||||
}, [isDeck, 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 = isDeck && 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>
|
||||
{isDeck ? (
|
||||
<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: isDeck });
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
|
||||
<span>
|
||||
{isDeck
|
||||
? 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user