fix: preview video on new tab (#933)

This commit is contained in:
Haile
2026-06-29 16:36:31 +03:00
committed by GitHub
parent b6cf33308d
commit 2ebe64f218
6 changed files with 454 additions and 2 deletions

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api'; import { api } from '../../../utils/api';
import type { CodeEditorFile } from '../types/types'; import type { CodeEditorFile } from '../types/types';
import { isBinaryFile } from '../utils/binaryFile'; import { isBinaryFile } from '../utils/binaryFile';
import { getPreviewKind } from '../utils/previewableFile';
type UseCodeEditorDocumentParams = { type UseCodeEditorDocumentParams = {
file: CodeEditorFile; file: CodeEditorFile;
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false); const [isBinary, setIsBinary] = useState(false);
// Some binaries (images, PDFs, audio, video) can be rendered natively, so the
// editor shows an inline preview instead of the generic binary placeholder.
const previewKind = getPreviewKind(file.name);
// `fileProjectId` is the DB primary key passed down from the editor sidebar; // `fileProjectId` is the DB primary key passed down from the editor sidebar;
// the fallback to `projectPath` preserves older callers that didn't yet // the fallback to `projectPath` preserves older callers that didn't yet
// propagate the identifier. // propagate the identifier.
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
setLoading(true); setLoading(true);
setIsBinary(false); setIsBinary(false);
// Natively previewable media (image/pdf/audio/video) is rendered by
// CodeEditorMediaPreview, so there is nothing to read as text here.
// Clear any buffer left over from a previously opened text file so a
// stray save can't write stale content over the binary file.
if (getPreviewKind(file.name)) {
setContent('');
setLoading(false);
return;
}
// Check if file is binary by extension // Check if file is binary by extension
if (isBinaryFile(file.name)) { if (isBinaryFile(file.name)) {
setContent('');
setIsBinary(true); setIsBinary(true);
setLoading(false); setLoading(false);
return; return;
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]); }, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
// Preview-only and binary files have no editable text buffer; never write
// them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
if (previewKind || isBinaryFile(fileName)) {
return;
}
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [content, filePath, fileProjectId]); }, [content, filePath, fileProjectId, previewKind, fileName]);
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' }); const blob = new Blob([content], { type: 'text/plain' });
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
saveSuccess, saveSuccess,
saveError, saveError,
isBinary, isBinary,
previewKind,
fileProjectId,
handleSave, handleSave,
handleDownload, handleDownload,
}; };

View File

@@ -0,0 +1,63 @@
// Some binary files can't be edited as text, but the browser can still render
// them natively (images, PDFs, audio, video). For those we show an inline
// preview instead of the generic "binary file" placeholder. Anything not listed
// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
// Single source of truth: every extension the browser can preview, mapped to the
// MIME type we apply when the server response has a missing/generic Content-Type.
// The preview kind is derived from the MIME type so the two never drift apart.
// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
// absent and keep the binary fallback.
const EXTENSION_MIME: Record<string, string> = {
// Images
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
ico: 'image/x-icon',
bmp: 'image/bmp',
avif: 'image/avif',
apng: 'image/apng',
// PDF
pdf: 'application/pdf',
// Video
mp4: 'video/mp4',
webm: 'video/webm',
ogv: 'video/ogg',
mov: 'video/quicktime',
m4v: 'video/x-m4v',
// Audio
mp3: 'audio/mpeg',
wav: 'audio/wav',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
opus: 'audio/opus',
oga: 'audio/ogg',
ogg: 'audio/ogg',
weba: 'audio/webm',
};
const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
const kindForMime = (mime: string): PreviewKind | null => {
if (mime === 'application/pdf') return 'pdf';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
return null;
};
export const getPreviewKind = (filename: string): PreviewKind | null => {
const mime = EXTENSION_MIME[extensionOf(filename)];
return mime ? kindForMime(mime) : null;
};
// MIME type to fall back to when the server returns no/generic Content-Type.
// Returns undefined for non-previewable extensions.
export const getPreviewMimeType = (filename: string): string | undefined =>
EXTENSION_MIME[extensionOf(filename)];

View File

@@ -1,8 +1,9 @@
import { EditorView } from '@codemirror/view'; import { EditorView } from '@codemirror/view';
import { unifiedMergeView } from '@codemirror/merge'; import { unifiedMergeView } from '@codemirror/merge';
import type { Extension } from '@codemirror/state'; import type { Extension } from '@codemirror/state';
import { useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument'; import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings'; import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
@@ -11,11 +12,13 @@ import type { CodeEditorFile } from '../types/types';
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions'; import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
import { getEditorStyles } from '../utils/editorStyles'; import { getEditorStyles } from '../utils/editorStyles';
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel'; import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
import CodeEditorFooter from './subcomponents/CodeEditorFooter'; import CodeEditorFooter from './subcomponents/CodeEditorFooter';
import CodeEditorHeader from './subcomponents/CodeEditorHeader'; import CodeEditorHeader from './subcomponents/CodeEditorHeader';
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState'; import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
import CodeEditorSurface from './subcomponents/CodeEditorSurface'; import CodeEditorSurface from './subcomponents/CodeEditorSurface';
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile'; import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
type CodeEditorProps = { type CodeEditorProps = {
file: CodeEditorFile; file: CodeEditorFile;
@@ -58,6 +61,8 @@ export default function CodeEditor({
saveSuccess, saveSuccess,
saveError, saveError,
isBinary, isBinary,
previewKind,
fileProjectId,
handleSave, handleSave,
handleDownload, handleDownload,
} = useCodeEditorDocument({ } = useCodeEditorDocument({
@@ -70,6 +75,29 @@ export default function CodeEditor({
return extension === 'md' || extension === 'markdown'; return extension === 'md' || extension === 'markdown';
}, [file.name]); }, [file.name]);
const isHtmlPreviewFile = useMemo(() => {
const extension = file.name.split('.').pop()?.toLowerCase();
return extension === 'html' || extension === 'htm';
}, [file.name]);
const openHtmlPreview = useCallback(() => {
const previewWindow = window.open('', '_blank');
if (!previewWindow) return;
previewWindow.opener = null;
previewWindow.document.title = file.name;
previewWindow.document.body.style.margin = '0';
const iframe = previewWindow.document.createElement('iframe');
iframe.title = file.name;
iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
iframe.srcdoc = content;
previewWindow.document.body.appendChild(iframe);
}, [content, file.name]);
const minimapExtension = useMemo( const minimapExtension = useMemo(
() => ( () => (
createMinimapExtension({ createMinimapExtension({
@@ -162,6 +190,30 @@ export default function CodeEditor({
); );
} }
// Natively previewable media (image/pdf/audio/video) is rendered inline
// instead of showing the generic "cannot be displayed" placeholder.
if (previewKind) {
return (
<CodeEditorMediaPreview
file={file}
kind={previewKind}
projectId={fileProjectId}
isSidebar={isSidebar}
isFullscreen={isFullscreen}
onClose={onClose}
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
labels={{
loading: t('filePreview.loading', 'Loading preview...'),
error: t('filePreview.error', 'Unable to display this file.'),
openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
fullscreen: t('actions.fullscreen', 'Fullscreen'),
exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
close: t('actions.close', 'Close'),
}}
/>
);
}
// Binary file display // Binary file display
if (isBinary) { if (isBinary) {
return ( return (
@@ -197,10 +249,12 @@ export default function CodeEditor({
isSidebar={isSidebar} isSidebar={isSidebar}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
isMarkdownFile={isMarkdownFile} isMarkdownFile={isMarkdownFile}
isHtmlPreviewFile={isHtmlPreviewFile}
markdownPreview={markdownPreview} markdownPreview={markdownPreview}
saving={saving} saving={saving}
saveSuccess={saveSuccess} saveSuccess={saveSuccess}
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)} onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
onOpenHtmlPreview={openHtmlPreview}
onOpenSettings={() => paletteOps.openSettings('appearance')} onOpenSettings={() => paletteOps.openSettings('appearance')}
onDownload={handleDownload} onDownload={handleDownload}
onSave={handleSave} onSave={handleSave}
@@ -210,6 +264,7 @@ export default function CodeEditor({
showingChanges: t('header.showingChanges'), showingChanges: t('header.showingChanges'),
editMarkdown: t('actions.editMarkdown'), editMarkdown: t('actions.editMarkdown'),
previewMarkdown: t('actions.previewMarkdown'), previewMarkdown: t('actions.previewMarkdown'),
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
settings: t('toolbar.settings'), settings: t('toolbar.settings'),
download: t('actions.download'), download: t('actions.download'),
save: t('actions.save'), save: t('actions.save'),

View File

@@ -1,4 +1,5 @@
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react'; import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
import type { CodeEditorFile } from '../../types/types'; import type { CodeEditorFile } from '../../types/types';
type CodeEditorHeaderProps = { type CodeEditorHeaderProps = {
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
isSidebar: boolean; isSidebar: boolean;
isFullscreen: boolean; isFullscreen: boolean;
isMarkdownFile: boolean; isMarkdownFile: boolean;
isHtmlPreviewFile: boolean;
markdownPreview: boolean; markdownPreview: boolean;
saving: boolean; saving: boolean;
saveSuccess: boolean; saveSuccess: boolean;
onToggleMarkdownPreview: () => void; onToggleMarkdownPreview: () => void;
onOpenHtmlPreview: () => void;
onOpenSettings: () => void; onOpenSettings: () => void;
onDownload: () => void; onDownload: () => void;
onSave: () => void; onSave: () => void;
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
showingChanges: string; showingChanges: string;
editMarkdown: string; editMarkdown: string;
previewMarkdown: string; previewMarkdown: string;
previewHtml: string;
settings: string; settings: string;
download: string; download: string;
save: string; save: string;
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
isSidebar, isSidebar,
isFullscreen, isFullscreen,
isMarkdownFile, isMarkdownFile,
isHtmlPreviewFile,
markdownPreview, markdownPreview,
saving, saving,
saveSuccess, saveSuccess,
onToggleMarkdownPreview, onToggleMarkdownPreview,
onOpenHtmlPreview,
onOpenSettings, onOpenSettings,
onDownload, onDownload,
onSave, onSave,
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
</button> </button>
)} )}
{isHtmlPreviewFile && (
<button
type="button"
onClick={onOpenHtmlPreview}
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={labels.previewHtml}
>
<Eye className="h-4 w-4" />
</button>
)}
<button <button
type="button" type="button"
onClick={onOpenSettings} onClick={onOpenSettings}

View File

@@ -0,0 +1,289 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../../../../utils/api';
import type { CodeEditorFile } from '../../types/types';
import { getPreviewMimeType, type PreviewKind } from '../../utils/previewableFile';
type CodeEditorMediaPreviewProps = {
file: CodeEditorFile;
kind: PreviewKind;
// DB projectId used to build the raw-content URL; falls back to projectPath
// for older callers, mirroring useCodeEditorDocument.
projectId?: string;
isSidebar: boolean;
isFullscreen: boolean;
onClose: () => void;
onToggleFullscreen: () => void;
labels: {
loading: string;
error: string;
openInNewTab: string;
fullscreen: string;
exitFullscreen: string;
close: string;
};
};
// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
const PDF_HEADER_SCAN_BYTES = 1024;
const looksLikePdf = async (blob: Blob): Promise<boolean> => {
const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
// PDFs must contain the "%PDF-" marker at the very start of the file.
return new TextDecoder('latin1').decode(header).includes('%PDF-');
};
export default function CodeEditorMediaPreview({
file,
kind,
projectId,
isSidebar,
isFullscreen,
onClose,
onToggleFullscreen,
labels,
}: CodeEditorMediaPreviewProps) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Identifies which file the current `url` was loaded for. Rendering is gated on
// this so a blob from a previously-opened file can never show under the new
// file (the editor reuses this component instance across files).
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
useEffect(() => {
if (!projectId) {
setUrl(null);
setLoadedKey(null);
setError(labels.error);
setLoading(false);
return;
}
let objectUrl: string | null = null;
const controller = new AbortController();
const loadMedia = async () => {
try {
setLoading(true);
setError(null);
setUrl(null);
// The content endpoint requires the auth header, so we fetch the bytes
// ourselves and hand the media element a blob URL instead of a bare src.
// Fetching a blob (rather than streaming) also lets <video>/<audio> seek.
const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const blob = await response.blob();
// Pick the MIME type to expose to the browser. Preserve a valid
// Content-Type from the server, but supply an extension-specific
// default when it is missing or generic (application/octet-stream),
// otherwise formats like webm/ogg/flac/svg won't render.
const fallbackMime = getPreviewMimeType(file.name);
const isGenericType = !blob.type || blob.type === 'application/octet-stream';
const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
if (kind === 'pdf') {
// The PDF renders in a same-origin <iframe>, so verify the bytes are
// really a PDF and pin the type to application/pdf. That forces the
// browser's PDF handler and prevents a mislabeled HTML/SVG file from
// executing scripts in the app's origin.
if (!(await looksLikePdf(blob))) {
throw new Error('File is not a valid PDF');
}
outType = 'application/pdf';
}
const typed = outType && outType !== blob.type ? new Blob([blob], { type: outType }) : blob;
objectUrl = URL.createObjectURL(typed);
// The cleanup may have already run (deps changed during an await), in
// which case it revoked nothing because objectUrl was still null. Don't
// publish a URL the cleanup will never revoke — drop it ourselves.
if (controller.signal.aborted) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
return;
}
setUrl(objectUrl);
setLoadedKey(sourceKey);
} catch (loadError: unknown) {
if (loadError instanceof Error && loadError.name === 'AbortError') {
return;
}
console.error('Error loading preview:', loadError);
setError(labels.error);
} finally {
setLoading(false);
}
};
loadMedia();
return () => {
controller.abort();
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [file.path, file.name, projectId, kind, sourceKey, labels.error]);
// Only expose the blob once it matches the file currently being shown, so a
// stale URL from the previous file is never rendered during a switch.
const currentUrl = url && loadedKey === sourceKey ? url : null;
// SVGs render safely inline via <img> (scripts don't execute there), but the
// open-in-new-tab link is a top-level navigation. A blob URL inherits the
// app's origin, so a user-controlled SVG with an embedded <script> would run
// as same-origin script. Withhold the new-tab action for SVGs.
const isSvg = getPreviewMimeType(file.name) === 'image/svg+xml';
const canOpenInNewTab = Boolean(currentUrl) && !isSvg;
const renderMedia = () => {
if (!currentUrl) return null;
switch (kind) {
case 'image':
return (
<img
src={currentUrl}
alt={file.name}
className="max-h-full max-w-full object-contain"
/>
);
case 'pdf':
// Not sandboxed on purpose: the browser's built-in PDF viewer refuses to
// load inside a sandboxed frame (any `sandbox` value yields a broken
// viewer). Script execution is instead prevented upstream by validating
// the PDF magic bytes and pinning the blob's MIME type to application/pdf.
return <iframe src={currentUrl} title={file.name} className="h-full w-full border-0 bg-white" />;
case 'video':
return (
<video src={currentUrl} controls className="max-h-full max-w-full" autoPlay={false}>
{labels.error}
</video>
);
case 'audio':
return (
<div className="flex w-full max-w-xl flex-col items-center gap-4 px-6">
<p className="max-w-full truncate text-sm text-muted-foreground">{file.name}</p>
<audio src={currentUrl} controls className="w-full">
{labels.error}
</audio>
</div>
);
default:
return null;
}
};
const previewBody = (
<div className="relative flex h-full w-full flex-col items-center justify-center bg-muted/30 p-2">
{loading && (
<div className="text-sm text-muted-foreground">{labels.loading}</div>
)}
{!loading && currentUrl && renderMedia()}
{!loading && !currentUrl && (
<div className="flex flex-col items-center gap-3 p-8 text-center text-muted-foreground">
<p className="text-sm">{error || labels.error}</p>
<p className="break-all text-xs">{file.path}</p>
</div>
)}
</div>
);
const headerActions = (
<div className="flex shrink-0 items-center gap-0.5">
{canOpenInNewTab && currentUrl && (
<a
href={currentUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label={labels.openInNewTab}
title={labels.openInNewTab}
>
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{!isSidebar && (
<button
type="button"
onClick={onToggleFullscreen}
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
>
{isFullscreen ? (
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
</svg>
) : (
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
)}
</button>
)}
<button
type="button"
onClick={onClose}
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label={labels.close}
title={labels.close}
>
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
const header = (
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
</div>
{headerActions}
</div>
);
if (isSidebar) {
return (
<div className="flex h-full w-full flex-col bg-background">
{header}
{previewBody}
</div>
);
}
const containerClassName = isFullscreen
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
const innerClassName = isFullscreen
? 'bg-background flex flex-col w-full h-full'
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]';
return (
<div className={containerClassName}>
<div className={innerClassName}>
{header}
{previewBody}
</div>
</div>
);
}

View File

@@ -32,5 +32,10 @@
"binaryFile": { "binaryFile": {
"title": "Binary File", "title": "Binary File",
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file." "message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
},
"filePreview": {
"loading": "Loading preview...",
"error": "Unable to display this file.",
"openInNewTab": "Open in new tab"
} }
} }