From 94c354e36dda627a999c3f4d7e74e2b09110908b Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 5 Feb 2026 17:20:32 +0800 Subject: [PATCH] feat(web): add inline PDF preview support for skill file viewer Enable PDF files to be previewed directly in the file content panel instead of showing as unsupported files requiring download. Uses the existing react-pdf-highlighter library with zoom controls and keyboard shortcuts (up/down arrows). --- .../skill/hooks/use-file-type-info.ts | 7 +- .../workflow/skill/utils/file-utils.ts | 9 +- .../skill/viewer/pdf-file-preview.tsx | 83 +++++++++++++++++++ .../skill/viewer/read-only-file-preview.tsx | 10 ++- 4 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 web/app/components/workflow/skill/viewer/pdf-file-preview.tsx diff --git a/web/app/components/workflow/skill/hooks/use-file-type-info.ts b/web/app/components/workflow/skill/hooks/use-file-type-info.ts index b582deb6c1..35d6529cfc 100644 --- a/web/app/components/workflow/skill/hooks/use-file-type-info.ts +++ b/web/app/components/workflow/skill/hooks/use-file-type-info.ts @@ -3,6 +3,7 @@ import { getFileExtension, isImageFile, isMarkdownFile, + isPdfFile, isSQLiteFile, isTextLikeFile, isVideoFile, @@ -13,6 +14,7 @@ export type FileTypeInfo = { isCodeOrText: boolean isImage: boolean isVideo: boolean + isPdf: boolean isSQLite: boolean isEditable: boolean isMediaFile: boolean @@ -27,6 +29,7 @@ export function useFileTypeInfo(fileNode: { name: string, extension?: string | n isCodeOrText: false, isImage: false, isVideo: false, + isPdf: false, isSQLite: false, isEditable: false, isMediaFile: false, @@ -38,6 +41,7 @@ export function useFileTypeInfo(fileNode: { name: string, extension?: string | n const markdown = isMarkdownFile(ext) const image = isImageFile(ext) const video = isVideoFile(ext) + const pdf = isPdfFile(ext) const sqlite = isSQLiteFile(ext) const editable = isTextLikeFile(ext) const codeOrText = editable && !markdown @@ -47,10 +51,11 @@ export function useFileTypeInfo(fileNode: { name: string, extension?: string | n isCodeOrText: codeOrText, isImage: image, isVideo: video, + isPdf: pdf, isSQLite: sqlite, isEditable: editable, isMediaFile: image || video, - isPreviewable: editable || image || video || sqlite, + isPreviewable: editable || image || video || pdf || sqlite, } }, [fileNode?.name, fileNode?.extension]) } diff --git a/web/app/components/workflow/skill/utils/file-utils.ts b/web/app/components/workflow/skill/utils/file-utils.ts index 811e9c57ae..fec1ca025c 100644 --- a/web/app/components/workflow/skill/utils/file-utils.ts +++ b/web/app/components/workflow/skill/utils/file-utils.ts @@ -5,6 +5,7 @@ const CODE_EXTENSIONS = new Set(['json', 'yaml', 'yml', 'toml', 'js', 'jsx', 'ts const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'psd', 'heic', 'heif', 'avif']) const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'webm', 'mpeg', 'mpg', 'm4v', 'avi', 'mkv', 'flv', 'wmv', '3gp']) const SQLITE_EXTENSIONS = new Set(['db', 'sqlite', 'sqlite3']) +const PDF_EXTENSIONS_SET = new Set(['pdf']) const BINARY_EXTENSIONS = new Set([ 'mp3', @@ -46,7 +47,6 @@ const BINARY_EXTENSIONS = new Set([ 'msi', 'deb', 'rpm', - 'pdf', 'doc', 'docx', 'xls', @@ -87,7 +87,6 @@ export function getFileExtension(name?: string, extension?: string): string { } const AUDIO_EXTENSIONS = ['mp3', 'm4a', 'wav', 'amr', 'mpga', 'ogg', 'flac', 'aac', 'wma', 'aiff', 'opus'] -const PDF_EXTENSIONS = ['pdf'] const EXCEL_EXTENSIONS = ['xlsx', 'xls', 'csv'] const WORD_EXTENSIONS = ['doc', 'docx'] const PPT_EXTENSIONS = ['ppt', 'pptx'] @@ -98,7 +97,7 @@ const EXTENSION_TO_ICON_TYPE = new Map( [IMAGE_EXTENSIONS, FileAppearanceTypeEnum.image], [VIDEO_EXTENSIONS, FileAppearanceTypeEnum.video], [AUDIO_EXTENSIONS, FileAppearanceTypeEnum.audio], - [PDF_EXTENSIONS, FileAppearanceTypeEnum.pdf], + [PDF_EXTENSIONS_SET, FileAppearanceTypeEnum.pdf], [MARKDOWN_EXTENSIONS, FileAppearanceTypeEnum.markdown], [EXCEL_EXTENSIONS, FileAppearanceTypeEnum.excel], [WORD_EXTENSIONS, FileAppearanceTypeEnum.word], @@ -139,6 +138,10 @@ export function isSQLiteFile(extension: string): boolean { return SQLITE_EXTENSIONS.has(extension) } +export function isPdfFile(extension: string): boolean { + return PDF_EXTENSIONS_SET.has(extension) +} + export function getFileLanguage(name: string): string { const extension = name.split('.').pop()?.toLowerCase() ?? '' diff --git a/web/app/components/workflow/skill/viewer/pdf-file-preview.tsx b/web/app/components/workflow/skill/viewer/pdf-file-preview.tsx new file mode 100644 index 0000000000..5cb7b95874 --- /dev/null +++ b/web/app/components/workflow/skill/viewer/pdf-file-preview.tsx @@ -0,0 +1,83 @@ +'use client' + +import { RiZoomInLine, RiZoomOutLine } from '@remixicon/react' +import { noop } from 'es-toolkit/function' +import * as React from 'react' +import { useState } from 'react' +import { useHotkeys } from 'react-hotkeys-hook' +import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter' +import Loading from '@/app/components/base/loading' +import 'react-pdf-highlighter/dist/style.css' + +type PdfFilePreviewProps = { + downloadUrl: string +} + +const PdfFilePreview = ({ downloadUrl }: PdfFilePreviewProps) => { + const [scale, setScale] = useState(1) + + const zoomIn = () => { + setScale(prevScale => Math.min(prevScale * 1.2, 3)) + } + + const zoomOut = () => { + setScale(prevScale => Math.max(prevScale / 1.2, 0.5)) + } + + useHotkeys('up', zoomIn) + useHotkeys('down', zoomOut) + + return ( +
+
+ + +
+ +
+
+ + +
+ )} + > + {pdfDocument => ( + false} + scrollRef={noop} + onScrollChange={noop} + onSelectionFinished={() => null} + highlightTransform={() =>
} + highlights={[]} + /> + )} + +
+
+
+ ) +} + +export default React.memo(PdfFilePreview) diff --git a/web/app/components/workflow/skill/viewer/read-only-file-preview.tsx b/web/app/components/workflow/skill/viewer/read-only-file-preview.tsx index 8d59d002f1..2856e5ef85 100644 --- a/web/app/components/workflow/skill/viewer/read-only-file-preview.tsx +++ b/web/app/components/workflow/skill/viewer/read-only-file-preview.tsx @@ -24,6 +24,11 @@ const SQLiteFilePreview = dynamic( { ssr: false, loading: () => }, ) +const PdfFilePreview = dynamic( + () => import('./pdf-file-preview'), + { ssr: false, loading: () => }, +) + type ReadOnlyFilePreviewProps = { downloadUrl: string fileName: string @@ -41,7 +46,7 @@ const ReadOnlyFilePreview = ({ () => ({ name: fileName, extension }), [fileName, extension], ) - const { isMarkdown, isCodeOrText, isImage, isVideo, isSQLite, isPreviewable } = useFileTypeInfo(fileNode) + const { isMarkdown, isCodeOrText, isImage, isVideo, isPdf, isSQLite, isPreviewable } = useFileTypeInfo(fileNode) const isTextFile = isPreviewable && (isMarkdown || isCodeOrText) const { data: textContent, isLoading: isTextLoading } = useFetchTextContent( isTextFile ? downloadUrl : undefined, @@ -78,6 +83,9 @@ const ReadOnlyFilePreview = ({ if (isSQLite) return + if (isPdf) + return + return (