mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-30 17:12:58 +08:00
Compare commits
7 Commits
cloudcli-l
...
fix/video-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5ed6fdd8 | ||
|
|
dc3a928bed | ||
|
|
6b5506087c | ||
|
|
de13eed72a | ||
|
|
e39de299c3 | ||
|
|
92b5b935fd | ||
|
|
1df336ca2d |
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
63
src/components/code-editor/utils/previewableFile.ts
Normal file
63
src/components/code-editor/utils/previewableFile.ts
Normal 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)];
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user