Add inline PDF file preview
This commit is contained in:
@@ -42,7 +42,7 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
|||||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
|
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
|
||||||
import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile, isPdfFile } from '@/lib/toolHelpers';
|
||||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||||
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||||
@@ -826,6 +826,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const [fileError, setFileError] = React.useState<string | null>(null);
|
const [fileError, setFileError] = React.useState<string | null>(null);
|
||||||
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
|
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
|
||||||
const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState('');
|
const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState('');
|
||||||
|
const [pdfAssetAuthReadyKey, setPdfAssetAuthReadyKey] = React.useState('');
|
||||||
|
|
||||||
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
|
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
@@ -1695,6 +1696,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
const selectedIsImage = isImageFile(node.path);
|
const selectedIsImage = isImageFile(node.path);
|
||||||
const isSvg = node.path.toLowerCase().endsWith('.svg');
|
const isSvg = node.path.toLowerCase().endsWith('.svg');
|
||||||
|
const selectedIsPdf = isPdfFile(node.path);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setShowMobilePageContent(true);
|
setShowMobilePageContent(true);
|
||||||
@@ -1717,6 +1719,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedIsPdf) {
|
||||||
|
setFileContent('');
|
||||||
|
setDraftContent('');
|
||||||
|
setLoadedFilePath(node.path);
|
||||||
|
setFileLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setFileLoading(true);
|
setFileLoading(true);
|
||||||
|
|
||||||
const outsideFileGrant = getOutsideFileGrant(node.path);
|
const outsideFileGrant = getOutsideFileGrant(node.path);
|
||||||
@@ -2191,6 +2201,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
|
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
|
||||||
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
|
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
|
||||||
|
const isSelectedPdf = Boolean(selectedFile?.path && isPdfFile(selectedFile.path));
|
||||||
const pendingNavigationTargetPath = React.useMemo(
|
const pendingNavigationTargetPath = React.useMemo(
|
||||||
() => normalizePath(pendingFileNavigation?.path ?? ''),
|
() => normalizePath(pendingFileNavigation?.path ?? ''),
|
||||||
[pendingFileNavigation?.path],
|
[pendingFileNavigation?.path],
|
||||||
@@ -2202,21 +2213,22 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
&& selectedFilePath === pendingNavigationTargetPath
|
&& selectedFilePath === pendingNavigationTargetPath
|
||||||
&& !fileLoading
|
&& !fileLoading
|
||||||
&& !fileError
|
&& !fileError
|
||||||
&& !isSelectedImage,
|
&& !isSelectedImage
|
||||||
|
&& !isSelectedPdf,
|
||||||
);
|
);
|
||||||
|
|
||||||
const displaySelectedPath = React.useMemo(() => {
|
const displaySelectedPath = React.useMemo(() => {
|
||||||
return getDisplayPath(root, selectedFilePath);
|
return getDisplayPath(root, selectedFilePath);
|
||||||
}, [selectedFilePath, root]);
|
}, [selectedFilePath, root]);
|
||||||
|
|
||||||
const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0);
|
const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && !isSelectedPdf && fileContent.length > 0);
|
||||||
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
|
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
|
||||||
const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
|
const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && !isSelectedPdf && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
|
||||||
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
|
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
|
||||||
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
|
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
|
||||||
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
|
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
|
||||||
const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path));
|
const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path));
|
||||||
const isTextFile = Boolean(selectedFile && !isSelectedImage);
|
const isTextFile = Boolean(selectedFile && !isSelectedImage && !isSelectedPdf);
|
||||||
const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview');
|
const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview');
|
||||||
const staticLanguageExtension = React.useMemo(
|
const staticLanguageExtension = React.useMemo(
|
||||||
() => (selectedFilePath ? languageByExtension(selectedFilePath) : null),
|
() => (selectedFilePath ? languageByExtension(selectedFilePath) : null),
|
||||||
@@ -2554,7 +2566,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileError || isSelectedImage) {
|
if (fileError || isSelectedImage || isSelectedPdf) {
|
||||||
setPendingFileNavigation(null);
|
setPendingFileNavigation(null);
|
||||||
pendingNavigationCycleRef.current = { key: '', attempts: 0 };
|
pendingNavigationCycleRef.current = { key: '', attempts: 0 };
|
||||||
return;
|
return;
|
||||||
@@ -2625,6 +2637,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
fileError,
|
fileError,
|
||||||
fileLoading,
|
fileLoading,
|
||||||
isSelectedImage,
|
isSelectedImage,
|
||||||
|
isSelectedPdf,
|
||||||
loadedFilePath,
|
loadedFilePath,
|
||||||
handleSelectFile,
|
handleSelectFile,
|
||||||
pendingFileNavigation,
|
pendingFileNavigation,
|
||||||
@@ -2654,7 +2667,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileLoading || loadedFilePath !== targetPath || fileError || isSelectedImage) {
|
if (fileLoading || loadedFilePath !== targetPath || fileError || isSelectedImage || isSelectedPdf) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2674,6 +2687,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
fileLoading,
|
fileLoading,
|
||||||
handleSelectFile,
|
handleSelectFile,
|
||||||
isSelectedImage,
|
isSelectedImage,
|
||||||
|
isSelectedPdf,
|
||||||
loadedFilePath,
|
loadedFilePath,
|
||||||
pendingFileFocusPath,
|
pendingFileFocusPath,
|
||||||
root,
|
root,
|
||||||
@@ -2814,6 +2828,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
|
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
|
||||||
|
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
|
||||||
|
: '';
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!imageAssetAuthKey) {
|
if (!imageAssetAuthKey) {
|
||||||
setImageAssetAuthReadyKey('');
|
setImageAssetAuthReadyKey('');
|
||||||
@@ -2835,6 +2853,32 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
|
const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!pdfAssetAuthKey) {
|
||||||
|
setPdfAssetAuthReadyKey('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setPdfAssetAuthReadyKey('');
|
||||||
|
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
|
||||||
|
.then((token) => {
|
||||||
|
if (!cancelled && token) setPdfAssetAuthReadyKey(pdfAssetAuthKey);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
||||||
|
setPdfAssetAuthReadyKey(pdfAssetAuthKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [pdfAssetAuthKey, t]);
|
||||||
|
|
||||||
|
const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey);
|
||||||
|
|
||||||
const imageSrc = selectedFile?.path && isSelectedImage
|
const imageSrc = selectedFile?.path && isSelectedImage
|
||||||
? (runtime.isDesktop
|
? (runtime.isDesktop
|
||||||
? (isSelectedSvg
|
? (isSelectedSvg
|
||||||
@@ -2849,6 +2893,24 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
}) : ''))
|
}) : ''))
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const pdfSrc = selectedFile?.path && isSelectedPdf && pdfAssetAuthReadyKey === pdfAssetAuthKey
|
||||||
|
? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
|
||||||
|
path: selectedFile.path,
|
||||||
|
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
|
||||||
|
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const renderPdfPreview = React.useCallback((file: FileNode) => (
|
||||||
|
<div className="h-full overflow-hidden bg-[var(--surface-background)]">
|
||||||
|
<iframe
|
||||||
|
src={pdfSrc}
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
title={file.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
), [pdfSrc]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -3045,7 +3107,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{!isSelectedImage && (
|
{!isSelectedImage && !isSelectedPdf && (
|
||||||
<>
|
<>
|
||||||
{withTooltip(wrapLines ? t('filesView.editor.disableLineWrap') : t('filesView.editor.enableLineWrap'),
|
{withTooltip(wrapLines ? t('filesView.editor.disableLineWrap') : t('filesView.editor.enableLineWrap'),
|
||||||
<Button
|
<Button
|
||||||
@@ -3537,7 +3599,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
||||||
{!selectedFile ? (
|
{!selectedFile ? (
|
||||||
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
|
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
|
||||||
) : (fileLoading || isImageAssetAuthLoading) ? (
|
) : (fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? (
|
||||||
suppressFileLoadingIndicator
|
suppressFileLoadingIndicator
|
||||||
? <div className="p-3" />
|
? <div className="p-3" />
|
||||||
: (
|
: (
|
||||||
@@ -3556,6 +3618,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
|
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : isSelectedPdf ? (
|
||||||
|
renderPdfPreview(selectedFile)
|
||||||
) : selectedFile && isDrawio && drawioViewMode === 'preview' ? (
|
) : selectedFile && isDrawio && drawioViewMode === 'preview' ? (
|
||||||
<div className="h-full overflow-hidden" style={{ minHeight: '400px' }}>
|
<div className="h-full overflow-hidden" style={{ minHeight: '400px' }}>
|
||||||
<DiagramEditor
|
<DiagramEditor
|
||||||
@@ -3881,7 +3945,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
{renderFloatingFileControls({ exitFullscreenOnly: true })}
|
{renderFloatingFileControls({ exitFullscreenOnly: true })}
|
||||||
</div>
|
</div>
|
||||||
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
||||||
{(fileLoading || isImageAssetAuthLoading) ? (
|
{(fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? (
|
||||||
suppressFileLoadingIndicator
|
suppressFileLoadingIndicator
|
||||||
? <div className="p-4" />
|
? <div className="p-4" />
|
||||||
: (
|
: (
|
||||||
@@ -3900,6 +3964,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
className="max-w-full max-h-full object-contain rounded-md border border-border/30 bg-primary/10"
|
className="max-w-full max-h-full object-contain rounded-md border border-border/30 bg-primary/10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : isSelectedPdf ? (
|
||||||
|
renderPdfPreview(selectedFile)
|
||||||
) : isMarkdown && getMdViewMode() === 'preview' ? (
|
) : isMarkdown && getMdViewMode() === 'preview' ? (
|
||||||
<div className="h-full overflow-auto p-4">
|
<div className="h-full overflow-auto p-4">
|
||||||
{fileContent.length > 500 * 1024 && (
|
{fileContent.length > 500 * 1024 && (
|
||||||
|
|||||||
@@ -693,6 +693,11 @@ export function isImageFile(filePath: string): boolean {
|
|||||||
return IMAGE_EXTENSIONS.includes(ext || '');
|
return IMAGE_EXTENSIONS.includes(ext || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPdfFile(filePath: string): boolean {
|
||||||
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||||
|
return ext === 'pdf';
|
||||||
|
}
|
||||||
|
|
||||||
export function getImageMimeType(filePath: string): string {
|
export function getImageMimeType(filePath: string): string {
|
||||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||||
const mimeMap: Record<string, string> = {
|
const mimeMap: Record<string, string> = {
|
||||||
|
|||||||
@@ -840,6 +840,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
|||||||
'.ico': 'image/x-icon',
|
'.ico': 'image/x-icon',
|
||||||
'.bmp': 'image/bmp',
|
'.bmp': 'image/bmp',
|
||||||
'.avif': 'image/avif',
|
'.avif': 'image/avif',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
};
|
};
|
||||||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user