Bladeren bron

Msg file preview (#11466)

Co-authored-by: crazywoola <427733928@qq.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Charlie.Wei 4 maanden geleden
bovenliggende
commit
bdd5869244

+ 47 - 0
web/app/components/base/file-uploader/audio-preview.tsx

@@ -0,0 +1,47 @@
+import type { FC } from 'react'
+import { createPortal } from 'react-dom'
+import { RiCloseLine } from '@remixicon/react'
+import React from 'react'
+
+import { useHotkeys } from 'react-hotkeys-hook'
+
+type AudioPreviewProps = {
+  url: string
+  title: string
+  onCancel: () => void
+}
+const AudioPreview: FC<AudioPreviewProps> = ({
+  url,
+  title,
+  onCancel,
+}) => {
+  useHotkeys('esc', onCancel)
+
+  return createPortal(
+    <div
+      className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]'
+      onClick={e => e.stopPropagation()}
+      tabIndex={-1}
+    >
+      <div>
+        <audio controls title={title} autoPlay={false} preload="metadata">
+          <source
+            type="audio/mpeg"
+            src={url}
+            className='max-w-full max-h-full'
+          />
+        </audio>
+      </div>
+      <div
+        className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer'
+        onClick={onCancel}
+      >
+        <RiCloseLine className='w-4 h-4 text-gray-500'/>
+      </div>
+    </div>
+    ,
+    document.body,
+  )
+}
+
+export default AudioPreview

+ 1 - 1
web/app/components/base/file-uploader/file-image-render.tsx

@@ -20,7 +20,7 @@ const FileImageRender = ({
     <div className={cn('border-[2px] border-effects-image-frame shadow-xs', className)}>
       <img
         className={cn('w-full h-full object-cover', showDownloadAction && 'cursor-pointer')}
-        alt={alt}
+        alt={alt || 'Preview'}
         onLoad={onLoad}
         onError={onError}
         src={imageUrl}

+ 1 - 1
web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx

@@ -37,7 +37,7 @@ const FileImageItem = ({
     <>
       <div
         className='group/file-image relative cursor-pointer'
-        onClick={() => canPreview && setImagePreviewUrl(url || '')}
+        onClick={() => canPreview && setImagePreviewUrl(base64Url || url || '')}
       >
         {
           showDeleteAction && (

+ 104 - 67
web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx

@@ -2,6 +2,7 @@ import {
   RiCloseLine,
   RiDownloadLine,
 } from '@remixicon/react'
+import { useState } from 'react'
 import {
   downloadFile,
   fileIsUploaded,
@@ -16,11 +17,15 @@ import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
 import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
 import ActionButton from '@/app/components/base/action-button'
 import Button from '@/app/components/base/button'
+import PdfPreview from '@/app/components/base/file-uploader/pdf-preview'
+import AudioPreview from '@/app/components/base/file-uploader/audio-preview'
+import VideoPreview from '@/app/components/base/file-uploader/video-preview'
 
 type FileItemProps = {
   file: FileEntity
   showDeleteAction?: boolean
   showDownloadAction?: boolean
+  canPreview?: boolean
   onRemove?: (fileId: string) => void
   onReUpload?: (fileId: string) => void
 }
@@ -30,88 +35,120 @@ const FileItem = ({
   showDownloadAction = true,
   onRemove,
   onReUpload,
+  canPreview,
 }: FileItemProps) => {
   const { id, name, type, progress, url, base64Url, isRemote } = file
+  const [previewUrl, setPreviewUrl] = useState('')
   const ext = getFileExtension(name, type, isRemote)
   const uploadError = progress === -1
 
+  let tmp_preview_url = url || base64Url
+  if (!tmp_preview_url && file?.originalFile)
+    tmp_preview_url = URL.createObjectURL(file.originalFile.slice()).toString()
+
   return (
-    <div
-      className={cn(
-        'group/file-item relative p-2 w-[144px] h-[68px] rounded-lg border-[0.5px] border-components-panel-border bg-components-card-bg shadow-xs',
-        !uploadError && 'hover:bg-components-card-bg-alt',
-        uploadError && 'border border-state-destructive-border bg-state-destructive-hover',
-        uploadError && 'hover:border-[0.5px] hover:border-state-destructive-border bg-state-destructive-hover-alt',
-      )}
-    >
-      {
-        showDeleteAction && (
-          <Button
-            className='hidden group-hover/file-item:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]'
-            onClick={() => onRemove?.(id)}
-          >
-            <RiCloseLine className='w-4 h-4 text-components-button-secondary-text' />
-          </Button>
-        )
-      }
+    <>
       <div
-        className='mb-1 h-8 line-clamp-2 system-xs-medium text-text-tertiary break-all'
-        title={name}
+        className={cn(
+          'group/file-item relative p-2 w-[144px] h-[68px] rounded-lg border-[0.5px] border-components-panel-border bg-components-card-bg shadow-xs',
+          !uploadError && 'hover:bg-components-card-bg-alt',
+          uploadError && 'border border-state-destructive-border bg-state-destructive-hover',
+          uploadError && 'hover:border-[0.5px] hover:border-state-destructive-border bg-state-destructive-hover-alt',
+        )}
       >
-        {name}
-      </div>
-      <div className='relative flex items-center justify-between'>
-        <div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
-          <FileTypeIcon
-            size='sm'
-            type={getFileAppearanceType(name, type)}
-            className='mr-1'
-          />
+        {
+          showDeleteAction && (
+            <Button
+              className='hidden group-hover/file-item:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]'
+              onClick={() => onRemove?.(id)}
+            >
+              <RiCloseLine className='w-4 h-4 text-components-button-secondary-text' />
+            </Button>
+          )
+        }
+        <div
+          className='mb-1 h-8 line-clamp-2 system-xs-medium text-text-tertiary break-all cursor-pointer'
+          title={name}
+          onClick={() => canPreview && setPreviewUrl(tmp_preview_url || '')}
+        >
+          {name}
+        </div>
+        <div className='relative flex items-center justify-between'>
+          <div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
+            <FileTypeIcon
+              size='sm'
+              type={getFileAppearanceType(name, type)}
+              className='mr-1'
+            />
+            {
+              ext && (
+                <>
+                  {ext}
+                  <div className='mx-1'>·</div>
+                </>
+              )
+            }
+            {
+              !!file.size && formatFileSize(file.size)
+            }
+          </div>
+          {
+            showDownloadAction && tmp_preview_url && (
+              <ActionButton
+                size='m'
+                className='hidden group-hover/file-item:flex absolute -right-1 -top-1'
+                onClick={(e) => {
+                  e.stopPropagation()
+                  downloadFile(tmp_preview_url || '', name)
+                }}
+              >
+                <RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' />
+              </ActionButton>
+            )
+          }
           {
-            ext && (
-              <>
-                {ext}
-                <div className='mx-1'>·</div>
-              </>
+            progress >= 0 && !fileIsUploaded(file) && (
+              <ProgressCircle
+                percentage={progress}
+                size={12}
+                className='shrink-0'
+              />
             )
           }
           {
-            !!file.size && formatFileSize(file.size)
+            uploadError && (
+              <ReplayLine
+                className='w-4 h-4 text-text-tertiary'
+                onClick={() => onReUpload?.(id)}
+              />
+            )
           }
         </div>
-        {
-          showDownloadAction && url && (
-            <ActionButton
-              size='m'
-              className='hidden group-hover/file-item:flex absolute -right-1 -top-1'
-              onClick={(e) => {
-                e.stopPropagation()
-                downloadFile(url || base64Url || '', name)
-              }}
-            >
-              <RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' />
-            </ActionButton>
-          )
-        }
-        {
-          progress >= 0 && !fileIsUploaded(file) && (
-            <ProgressCircle
-              percentage={progress}
-              size={12}
-              className='shrink-0'
-            />
-          )
-        }
-        {
-          uploadError && (
-            <ReplayLine
-              className='w-4 h-4 text-text-tertiary'
-              onClick={() => onReUpload?.(id)}
-            />
-          )
-        }
       </div>
-    </div>
+      {
+        type.split('/')[0] === 'audio' && canPreview && previewUrl && (
+          <AudioPreview
+            title={name}
+            url={previewUrl}
+            onCancel={() => setPreviewUrl('')}
+          />
+        )
+      }
+      {
+        type.split('/')[0] === 'video' && canPreview && previewUrl && (
+          <VideoPreview
+            title={name}
+            url={previewUrl}
+            onCancel={() => setPreviewUrl('')}
+          />
+        )
+      }
+      {
+        type.split('/')[1] === 'pdf' && canPreview && previewUrl && (
+          <PdfPreview url={previewUrl} onCancel={() => { setPreviewUrl('') }} />
+        )
+      }
+    </>
   )
 }
 

+ 2 - 1
web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.tsx

@@ -23,7 +23,7 @@ export const FileList = ({
   onRemove,
   showDeleteAction = true,
   showDownloadAction = false,
-  canPreview,
+  canPreview = true,
 }: FileListProps) => {
   return (
     <div className={cn('flex flex-wrap gap-2', className)}>
@@ -51,6 +51,7 @@ export const FileList = ({
               showDownloadAction={showDownloadAction}
               onRemove={onRemove}
               onReUpload={onReUpload}
+              canPreview={canPreview}
             />
           )
         })

+ 101 - 0
web/app/components/base/file-uploader/pdf-preview.tsx

@@ -0,0 +1,101 @@
+import type { FC } from 'react'
+import { createPortal } from 'react-dom'
+import 'react-pdf-highlighter/dist/style.css'
+import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
+import { t } from 'i18next'
+import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
+import React, { useState } from 'react'
+import { useHotkeys } from 'react-hotkeys-hook'
+import Loading from '@/app/components/base/loading'
+import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
+import Tooltip from '@/app/components/base/tooltip'
+
+type PdfPreviewProps = {
+  url: string
+  onCancel: () => void
+}
+
+const PdfPreview: FC<PdfPreviewProps> = ({
+  url,
+  onCancel,
+}) => {
+  const media = useBreakpoints()
+  const [scale, setScale] = useState(1)
+  const [position, setPosition] = useState({ x: 0, y: 0 })
+  const isMobile = media === MediaType.mobile
+
+  const zoomIn = () => {
+    setScale(prevScale => Math.min(prevScale * 1.2, 15))
+    setPosition({ x: position.x - 50, y: position.y - 50 })
+  }
+
+  const zoomOut = () => {
+    setScale((prevScale) => {
+      const newScale = Math.max(prevScale / 1.2, 0.5)
+      if (newScale === 1)
+        setPosition({ x: 0, y: 0 })
+      else
+        setPosition({ x: position.x + 50, y: position.y + 50 })
+
+      return newScale
+    })
+  }
+
+  useHotkeys('esc', onCancel)
+  useHotkeys('up', zoomIn)
+  useHotkeys('down', zoomOut)
+
+  return createPortal(
+    <div
+      className={`fixed inset-0 flex items-center justify-center bg-black/80 z-[1000] ${!isMobile && 'p-8'}`}
+      onClick={e => e.stopPropagation()}
+      tabIndex={-1}
+    >
+      <div
+        className='h-[95vh] w-[100vw] max-w-full max-h-full overflow-hidden'
+        style={{ transform: `scale(${scale})`, transformOrigin: 'center', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
+      >
+        <PdfLoader
+          url={url}
+          beforeLoad={<div className='flex justify-center items-center h-64'><Loading type='app' /></div>}
+        >
+          {(pdfDocument) => {
+            return (
+              <PdfHighlighter
+                pdfDocument={pdfDocument}
+                enableAreaSelection={event => event.altKey}
+                scrollRef={() => { }}
+                onScrollChange={() => { }}
+                onSelectionFinished={() => null}
+                highlightTransform={() => { return <div/> }}
+                highlights={[]}
+              />
+            )
+          }}
+        </PdfLoader>
+      </div>
+      <Tooltip popupContent={t('common.operation.zoomOut')}>
+        <div className='absolute top-6 right-24 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
+          onClick={zoomOut}>
+          <RiZoomOutLine className='w-4 h-4 text-gray-500'/>
+        </div>
+      </Tooltip>
+      <Tooltip popupContent={t('common.operation.zoomIn')}>
+        <div className='absolute top-6 right-16 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
+          onClick={zoomIn}>
+          <RiZoomInLine className='w-4 h-4 text-gray-500'/>
+        </div>
+      </Tooltip>
+      <Tooltip popupContent={t('common.operation.cancel')}>
+        <div
+          className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/8 rounded-lg backdrop-blur-[2px] cursor-pointer'
+          onClick={onCancel}>
+          <RiCloseLine className='w-4 h-4 text-gray-500'/>
+        </div>
+      </Tooltip>
+    </div>,
+    document.body,
+  )
+}
+
+export default PdfPreview

+ 45 - 0
web/app/components/base/file-uploader/video-preview.tsx

@@ -0,0 +1,45 @@
+import type { FC } from 'react'
+import { createPortal } from 'react-dom'
+import { RiCloseLine } from '@remixicon/react'
+import React from 'react'
+import { useHotkeys } from 'react-hotkeys-hook'
+
+type VideoPreviewProps = {
+  url: string
+  title: string
+  onCancel: () => void
+}
+const VideoPreview: FC<VideoPreviewProps> = ({
+  url,
+  title,
+  onCancel,
+}) => {
+  useHotkeys('esc', onCancel)
+
+  return createPortal(
+    <div
+      className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]'
+      onClick={e => e.stopPropagation()}
+      tabIndex={-1}
+    >
+      <div>
+        <video controls title={title} autoPlay={false} preload="metadata">
+          <source
+            type="video/mp4"
+            src={url}
+            className='max-w-full max-h-full'
+          />
+        </video>
+      </div>
+      <div
+        className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer'
+        onClick={onCancel}
+      >
+        <RiCloseLine className='w-4 h-4 text-gray-500'/>
+      </div>
+    </div>
+    , document.body,
+  )
+}
+
+export default VideoPreview

+ 11 - 18
web/app/components/base/image-uploader/image-preview.tsx

@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
 import { t } from 'i18next'
 import { createPortal } from 'react-dom'
 import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
+import { useHotkeys } from 'react-hotkeys-hook'
 import Tooltip from '@/app/components/base/tooltip'
 import Toast from '@/app/components/base/toast'
 
@@ -10,6 +11,8 @@ type ImagePreviewProps = {
   url: string
   title: string
   onCancel: () => void
+  onPrev?: () => void
+  onNext?: () => void
 }
 
 const isBase64 = (str: string): boolean => {
@@ -25,6 +28,8 @@ const ImagePreview: FC<ImagePreviewProps> = ({
   url,
   title,
   onCancel,
+  onPrev,
+  onNext,
 }) => {
   const [scale, setScale] = useState(1)
   const [position, setPosition] = useState({ x: 0, y: 0 })
@@ -32,7 +37,6 @@ const ImagePreview: FC<ImagePreviewProps> = ({
   const imgRef = useRef<HTMLImageElement>(null)
   const dragStartRef = useRef({ x: 0, y: 0 })
   const [isCopied, setIsCopied] = useState(false)
-  const containerRef = useRef<HTMLDivElement>(null)
 
   const openInNewTab = () => {
     // Open in a new window, considering the case when the page is inside an iframe
@@ -51,6 +55,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
       })
     }
   }
+
   const downloadImage = () => {
     // Open in a new window, considering the case when the page is inside an iframe
     if (url.startsWith('http') || url.startsWith('https')) {
@@ -188,23 +193,11 @@ const ImagePreview: FC<ImagePreviewProps> = ({
     }
   }, [handleMouseUp])
 
-  useEffect(() => {
-    const handleKeyDown = (event: KeyboardEvent) => {
-      if (event.key === 'Escape')
-        onCancel()
-    }
-
-    window.addEventListener('keydown', handleKeyDown)
-
-    // Set focus to the container element
-    if (containerRef.current)
-      containerRef.current.focus()
-
-    // Cleanup function
-    return () => {
-      window.removeEventListener('keydown', handleKeyDown)
-    }
-  }, [onCancel])
+  useHotkeys('esc', onCancel)
+  useHotkeys('up', zoomIn)
+  useHotkeys('down', zoomOut)
+  useHotkeys('left', onPrev || (() => {}))
+  useHotkeys('right', onNext || (() => {}))
 
   return createPortal(
     <div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000] image-preview-container'

+ 2 - 0
web/package.json

@@ -79,11 +79,13 @@
     "react-easy-crop": "^5.0.8",
     "react-error-boundary": "^4.0.2",
     "react-hook-form": "^7.51.4",
+    "react-hotkeys-hook": "^4.6.1",
     "react-i18next": "^12.2.0",
     "react-infinite-scroll-component": "^6.1.0",
     "react-markdown": "^8.0.6",
     "react-multi-email": "^1.0.14",
     "react-papaparse": "^4.1.0",
+    "react-pdf-highlighter": "^8.0.0-rc.0",
     "react-slider": "^2.0.4",
     "react-sortablejs": "^6.1.4",
     "react-syntax-highlighter": "^15.5.0",

File diff suppressed because it is too large
+ 622 - 823
web/yarn.lock


Some files were not shown because too many files changed in this diff