/** * Canvas file-node preview surfaces. * * A file node used to show an icon and a "download it" hint for everything that * was not an image. These components render the node's actual content: * * - {@link CanvasFileBody} is the in-node preview (small, scrollable); * - {@link CanvasFileOverlay} is the full-screen reader opened by the node's * hover toolbar, the context menu, or a double click on the body. * * Images, PDFs and media play straight from the asset URL (the host serves * those inline and with byte ranges). Everything else asks the host for a * bounded, structured preview through `/canvas/file/preview` and reconstructs * the original document: Markdown renders as rich text, HTML runs in a * script-less sandboxed iframe, SVG shows as a browser image, DOCX lays out * its headings / lists / tables, and PPTX becomes one card per slide with its * embedded pictures. Text, CSV, XLSX and ZIP listings never need a download. */ import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { AlertTriangle, Archive, Check, Copy, Download, ExternalLink, FileText, FileSpreadsheet, Film, Image as ImageIcon, Loader2, Minus, Music, Plus, RotateCcw, X, } from 'lucide-react' import type { CanvasAssetRef, CanvasDocBlock, CanvasDocRun, CanvasFileKind, CanvasFilePreview, CanvasSlidePreview, } from '../protocol.ts' import type { ImageGenApi } from './api.ts' import { errorMessage, tt } from './helpers.ts' import type { ImageGenKey } from './locales.ts' import { renderMarkdown } from './markdown.tsx' import css from './canvas-workspace.module.css' /** Coarse bucket of one file asset (falling back to the MIME/extension). */ export function fileKindOfAsset(asset: CanvasAssetRef): CanvasFileKind { const declared = asset.kind === 'file' ? asset.name ?? '' : '' const mime = asset.mime.split(';')[0]!.trim().toLowerCase() const extension = /\.([a-z0-9]+)$/i.exec(declared)?.[1]?.toLowerCase() ?? '' if (mime === 'application/pdf' || extension === 'pdf') return 'pdf' if (mime.startsWith('image/')) return 'image' if (mime.startsWith('audio/')) return 'audio' if (mime.startsWith('video/')) return 'video' if (/openxmlformats|vnd\.ms-|vnd\.oasis/.test(mime) || ['pptx', 'docx', 'xlsx', 'ppt', 'doc', 'xls', 'odt', 'odp', 'ods'].includes(extension)) return 'office' if (mime === 'application/zip' || ['zip', '7z', 'rar', 'gz', 'tar'].includes(extension)) return 'archive' if (mime.startsWith('text/') || mime === 'application/json' || ['md', 'markdown', 'csv', 'tsv', 'log', 'json', 'yaml', 'yml', 'xml', 'txt'].includes(extension)) return 'text' return 'other' } export function fileKindLabel(kind: CanvasFileKind): string { return tt(`canvas.skills.fileKind.${kind}` as ImageGenKey) } /** Human-readable byte count for file nodes and the file picker. */ export function fileSizeLabel(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) return tt('canvas.skills.fileSizeUnknown') if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB` return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB` } /** Media types the browser renders from the asset URL alone. */ export function directMediaOf(mime: string): 'image' | 'pdf' | 'audio' | 'video' | undefined { const value = mime.split(';')[0]!.trim().toLowerCase() if (/^image\/(png|jpeg|webp|gif|bmp)$/.test(value)) return 'image' if (value === 'application/pdf') return 'pdf' if (value.startsWith('audio/')) return 'audio' if (value.startsWith('video/')) return 'video' return undefined } /** Asset URL that asks the host for an inline (non-attachment) response. */ export function inlineAssetUrl(asset: CanvasAssetRef): string { const separator = asset.url.includes('?') ? '&' : '?' return `${asset.url}${separator}inline=1` } /** Previews are immutable (content-addressed), so one module cache is safe. */ const previewCache = new Map() const previewInflight = new Map>() function loadPreview(api: ImageGenApi, asset: CanvasAssetRef): Promise { const cached = previewCache.get(asset.assetId) if (cached !== undefined) return Promise.resolve(cached) const running = previewInflight.get(asset.assetId) if (running !== undefined) return running const request = api.canvasFilePreview({ assetId: asset.assetId, name: asset.name, mime: asset.mime }) .then(preview => { previewCache.set(asset.assetId, preview); return preview }) .finally(() => { previewInflight.delete(asset.assetId) }) previewInflight.set(asset.assetId, request) return request } interface PreviewState { preview?: CanvasFilePreview loading: boolean error?: string } /** Load one asset's structured preview once, shared by node body and overlay. */ export function useFilePreview(api: ImageGenApi, asset: CanvasAssetRef, enabled: boolean): PreviewState & { reload: () => void } { const cached = previewCache.get(asset.assetId) const [state, setState] = useState(() => cached === undefined ? { loading: enabled } : { preview: cached, loading: false }) const [nonce, setNonce] = useState(0) useEffect(() => { if (!enabled || asset.assetId === '') return if (nonce === 0 && previewCache.has(asset.assetId)) { const cached = previewCache.get(asset.assetId) setState(previous => previous.preview === undefined ? { preview: cached, loading: false } : previous) return } let cancelled = false setState(previous => previous.preview === undefined ? { loading: true } : { ...previous, loading: true }) void loadPreview(api, asset) .then(preview => { if (!cancelled) setState({ preview, loading: false }) }) .catch(error => { if (!cancelled) setState({ loading: false, error: errorMessage(error) }) }) return () => { cancelled = true } }, [api, asset, enabled, nonce]) const reload = useCallback((): void => { previewCache.delete(asset.assetId) setNonce(value => value + 1) }, [asset.assetId]) return { ...state, reload } } function formatLabel(preview: CanvasFilePreview | undefined, fileKind: CanvasFileKind): string { // Every kind except media and archive carries a format bucket in the UI. if (preview !== undefined && preview.kind !== 'media' && preview.kind !== 'archive') { return preview.format.toUpperCase() } return fileKindLabel(fileKind) } function DownloadButton({ asset, title, label }: { asset: CanvasAssetRef; title: string; label: string }): React.JSX.Element { return } /** CSV / TSV / XLSX grid, shared by the node body and the overlay. */ function PreviewTable({ rows, expanded }: { rows: string[][]; expanded: boolean }): React.JSX.Element { const [head, ...body] = rows if (head === undefined) return
{tt('canvas.preview.empty')}
return
{head.map((cell, index) => )} {body.map((row, rowIndex) => {head.map((_, columnIndex) => )} )}
{cell}
{row[columnIndex] ?? ''}
} /** ZIP central-directory listing. */ function PreviewArchive({ entries }: { entries: Array<{ name: string; size: number; dir: boolean }> }): React.JSX.Element { if (entries.length === 0) return
{tt('canvas.preview.empty')}
return
    {entries.map((entry, index) =>
  • )}
} /** HTML document in a script-less sandboxed iframe (opaque origin, no forms). */ function PreviewHtml({ html, title }: { html: string; title: string }): React.JSX.Element { return