Msg file preview (#11466)

Co-authored-by: crazywoola <427733928@qq.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
This commit is contained in:
Charlie.Wei 2024-12-10 10:53:37 +08:00 committed by GitHub
parent fc1415d705
commit bdd5869244
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 2915 additions and 3814 deletions

View File

@ -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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import {
RiCloseLine, RiCloseLine,
RiDownloadLine, RiDownloadLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useState } from 'react'
import { import {
downloadFile, downloadFile,
fileIsUploaded, 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 { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ActionButton from '@/app/components/base/action-button' import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/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 = { type FileItemProps = {
file: FileEntity file: FileEntity
showDeleteAction?: boolean showDeleteAction?: boolean
showDownloadAction?: boolean showDownloadAction?: boolean
canPreview?: boolean
onRemove?: (fileId: string) => void onRemove?: (fileId: string) => void
onReUpload?: (fileId: string) => void onReUpload?: (fileId: string) => void
} }
@ -30,88 +35,120 @@ const FileItem = ({
showDownloadAction = true, showDownloadAction = true,
onRemove, onRemove,
onReUpload, onReUpload,
canPreview,
}: FileItemProps) => { }: FileItemProps) => {
const { id, name, type, progress, url, base64Url, isRemote } = file const { id, name, type, progress, url, base64Url, isRemote } = file
const [previewUrl, setPreviewUrl] = useState('')
const ext = getFileExtension(name, type, isRemote) const ext = getFileExtension(name, type, isRemote)
const uploadError = progress === -1 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 ( 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 <div
className='mb-1 h-8 line-clamp-2 system-xs-medium text-text-tertiary break-all' className={cn(
title={name} '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> showDeleteAction && (
<div className='relative flex items-center justify-between'> <Button
<div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'> className='hidden group-hover/file-item:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]'
<FileTypeIcon onClick={() => onRemove?.(id)}
size='sm' >
type={getFileAppearanceType(name, type)} <RiCloseLine className='w-4 h-4 text-components-button-secondary-text' />
className='mr-1' </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>
{ {
ext && ( showDownloadAction && tmp_preview_url && (
<> <ActionButton
{ext} size='m'
<div className='mx-1'>·</div> 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>
) )
} }
{ {
!!file.size && formatFileSize(file.size) 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>
{
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>
</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('') }} />
)
}
</>
) )
} }

View File

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

View File

@ -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

View File

@ -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

View File

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

View File

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

File diff suppressed because it is too large Load Diff