Co-authored-by: crazywoola <427733928@qq.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>tags/0.14.0
| @@ -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 | |||
| @@ -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} | |||
| @@ -37,7 +37,7 @@ const FileImageItem = ({ | |||
| <> | |||
| <div | |||
| className='group/file-image relative cursor-pointer' | |||
| onClick={() => canPreview && setImagePreviewUrl(url || '')} | |||
| onClick={() => canPreview && setImagePreviewUrl(base64Url || url || '')} | |||
| > | |||
| { | |||
| showDeleteAction && ( | |||
| @@ -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('') }} /> | |||
| ) | |||
| } | |||
| </> | |||
| ) | |||
| } | |||
| @@ -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} | |||
| /> | |||
| ) | |||
| }) | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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' | |||
| @@ -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", | |||