diff --git a/src/components/code-editor/hooks/useCodeEditorDocument.ts b/src/components/code-editor/hooks/useCodeEditorDocument.ts index b2b7acd2..dda02887 100644 --- a/src/components/code-editor/hooks/useCodeEditorDocument.ts +++ b/src/components/code-editor/hooks/useCodeEditorDocument.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { api } from '../../../utils/api'; import type { CodeEditorFile } from '../types/types'; import { isBinaryFile } from '../utils/binaryFile'; +import { getPreviewKind } from '../utils/previewableFile'; type UseCodeEditorDocumentParams = { file: CodeEditorFile; @@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume const [saveSuccess, setSaveSuccess] = useState(false); const [saveError, setSaveError] = useState(null); 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; // the fallback to `projectPath` preserves older callers that didn't yet // propagate the identifier. @@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume setLoading(true); 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 if (isBinaryFile(file.name)) { + setContent(''); setIsBinary(true); setLoading(false); return; @@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume }, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]); 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); setSaveError(null); @@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume } finally { setSaving(false); } - }, [content, filePath, fileProjectId]); + }, [content, filePath, fileProjectId, previewKind, fileName]); const handleDownload = useCallback(() => { const blob = new Blob([content], { type: 'text/plain' }); @@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume saveSuccess, saveError, isBinary, + previewKind, + fileProjectId, handleSave, handleDownload, }; diff --git a/src/components/code-editor/utils/previewableFile.ts b/src/components/code-editor/utils/previewableFile.ts new file mode 100644 index 00000000..0071de4e --- /dev/null +++ b/src/components/code-editor/utils/previewableFile.ts @@ -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 = { + // 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)]; diff --git a/src/components/code-editor/view/CodeEditor.tsx b/src/components/code-editor/view/CodeEditor.tsx index 5861ce71..588d1ec1 100644 --- a/src/components/code-editor/view/CodeEditor.tsx +++ b/src/components/code-editor/view/CodeEditor.tsx @@ -1,8 +1,9 @@ import { EditorView } from '@codemirror/view'; import { unifiedMergeView } from '@codemirror/merge'; import type { Extension } from '@codemirror/state'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; + import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument'; import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings'; @@ -11,11 +12,13 @@ import type { CodeEditorFile } from '../types/types'; import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions'; import { getEditorStyles } from '../utils/editorStyles'; import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel'; + import CodeEditorFooter from './subcomponents/CodeEditorFooter'; import CodeEditorHeader from './subcomponents/CodeEditorHeader'; import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState'; import CodeEditorSurface from './subcomponents/CodeEditorSurface'; import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile'; +import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview'; type CodeEditorProps = { file: CodeEditorFile; @@ -58,6 +61,8 @@ export default function CodeEditor({ saveSuccess, saveError, isBinary, + previewKind, + fileProjectId, handleSave, handleDownload, } = useCodeEditorDocument({ @@ -70,6 +75,29 @@ export default function CodeEditor({ return extension === 'md' || extension === 'markdown'; }, [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( () => ( 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 ( + 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 if (isBinary) { return ( @@ -197,10 +249,12 @@ export default function CodeEditor({ isSidebar={isSidebar} isFullscreen={isFullscreen} isMarkdownFile={isMarkdownFile} + isHtmlPreviewFile={isHtmlPreviewFile} markdownPreview={markdownPreview} saving={saving} saveSuccess={saveSuccess} onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)} + onOpenHtmlPreview={openHtmlPreview} onOpenSettings={() => paletteOps.openSettings('appearance')} onDownload={handleDownload} onSave={handleSave} @@ -210,6 +264,7 @@ export default function CodeEditor({ showingChanges: t('header.showingChanges'), editMarkdown: t('actions.editMarkdown'), previewMarkdown: t('actions.previewMarkdown'), + previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'), settings: t('toolbar.settings'), download: t('actions.download'), save: t('actions.save'), diff --git a/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx b/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx index 7c429a63..d16f21f5 100644 --- a/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx +++ b/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx @@ -1,4 +1,5 @@ import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react'; + import type { CodeEditorFile } from '../../types/types'; type CodeEditorHeaderProps = { @@ -6,10 +7,12 @@ type CodeEditorHeaderProps = { isSidebar: boolean; isFullscreen: boolean; isMarkdownFile: boolean; + isHtmlPreviewFile: boolean; markdownPreview: boolean; saving: boolean; saveSuccess: boolean; onToggleMarkdownPreview: () => void; + onOpenHtmlPreview: () => void; onOpenSettings: () => void; onDownload: () => void; onSave: () => void; @@ -19,6 +22,7 @@ type CodeEditorHeaderProps = { showingChanges: string; editMarkdown: string; previewMarkdown: string; + previewHtml: string; settings: string; download: string; save: string; @@ -35,10 +39,12 @@ export default function CodeEditorHeader({ isSidebar, isFullscreen, isMarkdownFile, + isHtmlPreviewFile, markdownPreview, saving, saveSuccess, onToggleMarkdownPreview, + onOpenHtmlPreview, onOpenSettings, onDownload, onSave, @@ -82,6 +88,17 @@ export default function CodeEditorHeader({ )} + {isHtmlPreviewFile && ( + + )} +