Skip to content

Commit bdd5869

Browse files
Msg file preview (langgenius#11466)
Co-authored-by: crazywoola <[email protected]> Co-authored-by: crazywoola <[email protected]>
1 parent fc1415d commit bdd5869

File tree

10 files changed

+2997
-3896
lines changed

10 files changed

+2997
-3896
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { FC } from 'react'
2+
import { createPortal } from 'react-dom'
3+
import { RiCloseLine } from '@remixicon/react'
4+
import React from 'react'
5+
6+
import { useHotkeys } from 'react-hotkeys-hook'
7+
8+
type AudioPreviewProps = {
9+
url: string
10+
title: string
11+
onCancel: () => void
12+
}
13+
const AudioPreview: FC<AudioPreviewProps> = ({
14+
url,
15+
title,
16+
onCancel,
17+
}) => {
18+
useHotkeys('esc', onCancel)
19+
20+
return createPortal(
21+
<div
22+
className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]'
23+
onClick={e => e.stopPropagation()}
24+
tabIndex={-1}
25+
>
26+
<div>
27+
<audio controls title={title} autoPlay={false} preload="metadata">
28+
<source
29+
type="audio/mpeg"
30+
src={url}
31+
className='max-w-full max-h-full'
32+
/>
33+
</audio>
34+
</div>
35+
<div
36+
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'
37+
onClick={onCancel}
38+
>
39+
<RiCloseLine className='w-4 h-4 text-gray-500'/>
40+
</div>
41+
</div>
42+
,
43+
document.body,
44+
)
45+
}
46+
47+
export default AudioPreview

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const FileImageRender = ({
2020
<div className={cn('border-[2px] border-effects-image-frame shadow-xs', className)}>
2121
<img
2222
className={cn('w-full h-full object-cover', showDownloadAction && 'cursor-pointer')}
23-
alt={alt}
23+
alt={alt || 'Preview'}
2424
onLoad={onLoad}
2525
onError={onError}
2626
src={imageUrl}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const FileImageItem = ({
3737
<>
3838
<div
3939
className='group/file-image relative cursor-pointer'
40-
onClick={() => canPreview && setImagePreviewUrl(url || '')}
40+
onClick={() => canPreview && setImagePreviewUrl(base64Url || url || '')}
4141
>
4242
{
4343
showDeleteAction && (

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

Lines changed: 104 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
RiCloseLine,
33
RiDownloadLine,
44
} from '@remixicon/react'
5+
import { useState } from 'react'
56
import {
67
downloadFile,
78
fileIsUploaded,
@@ -16,11 +17,15 @@ import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
1617
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
1718
import ActionButton from '@/app/components/base/action-button'
1819
import Button from '@/app/components/base/button'
20+
import PdfPreview from '@/app/components/base/file-uploader/pdf-preview'
21+
import AudioPreview from '@/app/components/base/file-uploader/audio-preview'
22+
import VideoPreview from '@/app/components/base/file-uploader/video-preview'
1923

2024
type FileItemProps = {
2125
file: FileEntity
2226
showDeleteAction?: boolean
2327
showDownloadAction?: boolean
28+
canPreview?: boolean
2429
onRemove?: (fileId: string) => void
2530
onReUpload?: (fileId: string) => void
2631
}
@@ -30,88 +35,120 @@ const FileItem = ({
3035
showDownloadAction = true,
3136
onRemove,
3237
onReUpload,
38+
canPreview,
3339
}: FileItemProps) => {
3440
const { id, name, type, progress, url, base64Url, isRemote } = file
41+
const [previewUrl, setPreviewUrl] = useState('')
3542
const ext = getFileExtension(name, type, isRemote)
3643
const uploadError = progress === -1
3744

45+
let tmp_preview_url = url || base64Url
46+
if (!tmp_preview_url && file?.originalFile)
47+
tmp_preview_url = URL.createObjectURL(file.originalFile.slice()).toString()
48+
3849
return (
39-
<div
40-
className={cn(
41-
'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',
42-
!uploadError && 'hover:bg-components-card-bg-alt',
43-
uploadError && 'border border-state-destructive-border bg-state-destructive-hover',
44-
uploadError && 'hover:border-[0.5px] hover:border-state-destructive-border bg-state-destructive-hover-alt',
45-
)}
46-
>
47-
{
48-
showDeleteAction && (
49-
<Button
50-
className='hidden group-hover/file-item:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]'
51-
onClick={() => onRemove?.(id)}
52-
>
53-
<RiCloseLine className='w-4 h-4 text-components-button-secondary-text' />
54-
</Button>
55-
)
56-
}
50+
<>
5751
<div
58-
className='mb-1 h-8 line-clamp-2 system-xs-medium text-text-tertiary break-all'
59-
title={name}
52+
className={cn(
53+
'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',
54+
!uploadError && 'hover:bg-components-card-bg-alt',
55+
uploadError && 'border border-state-destructive-border bg-state-destructive-hover',
56+
uploadError && 'hover:border-[0.5px] hover:border-state-destructive-border bg-state-destructive-hover-alt',
57+
)}
6058
>
61-
{name}
62-
</div>
63-
<div className='relative flex items-center justify-between'>
64-
<div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
65-
<FileTypeIcon
66-
size='sm'
67-
type={getFileAppearanceType(name, type)}
68-
className='mr-1'
69-
/>
59+
{
60+
showDeleteAction && (
61+
<Button
62+
className='hidden group-hover/file-item:flex absolute -right-1.5 -top-1.5 p-0 w-5 h-5 rounded-full z-[11]'
63+
onClick={() => onRemove?.(id)}
64+
>
65+
<RiCloseLine className='w-4 h-4 text-components-button-secondary-text' />
66+
</Button>
67+
)
68+
}
69+
<div
70+
className='mb-1 h-8 line-clamp-2 system-xs-medium text-text-tertiary break-all cursor-pointer'
71+
title={name}
72+
onClick={() => canPreview && setPreviewUrl(tmp_preview_url || '')}
73+
>
74+
{name}
75+
</div>
76+
<div className='relative flex items-center justify-between'>
77+
<div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
78+
<FileTypeIcon
79+
size='sm'
80+
type={getFileAppearanceType(name, type)}
81+
className='mr-1'
82+
/>
83+
{
84+
ext && (
85+
<>
86+
{ext}
87+
<div className='mx-1'>·</div>
88+
</>
89+
)
90+
}
91+
{
92+
!!file.size && formatFileSize(file.size)
93+
}
94+
</div>
95+
{
96+
showDownloadAction && tmp_preview_url && (
97+
<ActionButton
98+
size='m'
99+
className='hidden group-hover/file-item:flex absolute -right-1 -top-1'
100+
onClick={(e) => {
101+
e.stopPropagation()
102+
downloadFile(tmp_preview_url || '', name)
103+
}}
104+
>
105+
<RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' />
106+
</ActionButton>
107+
)
108+
}
70109
{
71-
ext && (
72-
<>
73-
{ext}
74-
<div className='mx-1'>·</div>
75-
</>
110+
progress >= 0 && !fileIsUploaded(file) && (
111+
<ProgressCircle
112+
percentage={progress}
113+
size={12}
114+
className='shrink-0'
115+
/>
76116
)
77117
}
78118
{
79-
!!file.size && formatFileSize(file.size)
119+
uploadError && (
120+
<ReplayLine
121+
className='w-4 h-4 text-text-tertiary'
122+
onClick={() => onReUpload?.(id)}
123+
/>
124+
)
80125
}
81126
</div>
82-
{
83-
showDownloadAction && url && (
84-
<ActionButton
85-
size='m'
86-
className='hidden group-hover/file-item:flex absolute -right-1 -top-1'
87-
onClick={(e) => {
88-
e.stopPropagation()
89-
downloadFile(url || base64Url || '', name)
90-
}}
91-
>
92-
<RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' />
93-
</ActionButton>
94-
)
95-
}
96-
{
97-
progress >= 0 && !fileIsUploaded(file) && (
98-
<ProgressCircle
99-
percentage={progress}
100-
size={12}
101-
className='shrink-0'
102-
/>
103-
)
104-
}
105-
{
106-
uploadError && (
107-
<ReplayLine
108-
className='w-4 h-4 text-text-tertiary'
109-
onClick={() => onReUpload?.(id)}
110-
/>
111-
)
112-
}
113127
</div>
114-
</div>
128+
{
129+
type.split('/')[0] === 'audio' && canPreview && previewUrl && (
130+
<AudioPreview
131+
title={name}
132+
url={previewUrl}
133+
onCancel={() => setPreviewUrl('')}
134+
/>
135+
)
136+
}
137+
{
138+
type.split('/')[0] === 'video' && canPreview && previewUrl && (
139+
<VideoPreview
140+
title={name}
141+
url={previewUrl}
142+
onCancel={() => setPreviewUrl('')}
143+
/>
144+
)
145+
}
146+
{
147+
type.split('/')[1] === 'pdf' && canPreview && previewUrl && (
148+
<PdfPreview url={previewUrl} onCancel={() => { setPreviewUrl('') }} />
149+
)
150+
}
151+
</>
115152
)
116153
}
117154

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const FileList = ({
2323
onRemove,
2424
showDeleteAction = true,
2525
showDownloadAction = false,
26-
canPreview,
26+
canPreview = true,
2727
}: FileListProps) => {
2828
return (
2929
<div className={cn('flex flex-wrap gap-2', className)}>
@@ -51,6 +51,7 @@ export const FileList = ({
5151
showDownloadAction={showDownloadAction}
5252
onRemove={onRemove}
5353
onReUpload={onReUpload}
54+
canPreview={canPreview}
5455
/>
5556
)
5657
})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { FC } from 'react'
2+
import { createPortal } from 'react-dom'
3+
import 'react-pdf-highlighter/dist/style.css'
4+
import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
5+
import { t } from 'i18next'
6+
import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
7+
import React, { useState } from 'react'
8+
import { useHotkeys } from 'react-hotkeys-hook'
9+
import Loading from '@/app/components/base/loading'
10+
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
11+
import Tooltip from '@/app/components/base/tooltip'
12+
13+
type PdfPreviewProps = {
14+
url: string
15+
onCancel: () => void
16+
}
17+
18+
const PdfPreview: FC<PdfPreviewProps> = ({
19+
url,
20+
onCancel,
21+
}) => {
22+
const media = useBreakpoints()
23+
const [scale, setScale] = useState(1)
24+
const [position, setPosition] = useState({ x: 0, y: 0 })
25+
const isMobile = media === MediaType.mobile
26+
27+
const zoomIn = () => {
28+
setScale(prevScale => Math.min(prevScale * 1.2, 15))
29+
setPosition({ x: position.x - 50, y: position.y - 50 })
30+
}
31+
32+
const zoomOut = () => {
33+
setScale((prevScale) => {
34+
const newScale = Math.max(prevScale / 1.2, 0.5)
35+
if (newScale === 1)
36+
setPosition({ x: 0, y: 0 })
37+
else
38+
setPosition({ x: position.x + 50, y: position.y + 50 })
39+
40+
return newScale
41+
})
42+
}
43+
44+
useHotkeys('esc', onCancel)
45+
useHotkeys('up', zoomIn)
46+
useHotkeys('down', zoomOut)
47+
48+
return createPortal(
49+
<div
50+
className={`fixed inset-0 flex items-center justify-center bg-black/80 z-[1000] ${!isMobile && 'p-8'}`}
51+
onClick={e => e.stopPropagation()}
52+
tabIndex={-1}
53+
>
54+
<div
55+
className='h-[95vh] w-[100vw] max-w-full max-h-full overflow-hidden'
56+
style={{ transform: `scale(${scale})`, transformOrigin: 'center', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
57+
>
58+
<PdfLoader
59+
url={url}
60+
beforeLoad={<div className='flex justify-center items-center h-64'><Loading type='app' /></div>}
61+
>
62+
{(pdfDocument) => {
63+
return (
64+
<PdfHighlighter
65+
pdfDocument={pdfDocument}
66+
enableAreaSelection={event => event.altKey}
67+
scrollRef={() => { }}
68+
onScrollChange={() => { }}
69+
onSelectionFinished={() => null}
70+
highlightTransform={() => { return <div/> }}
71+
highlights={[]}
72+
/>
73+
)
74+
}}
75+
</PdfLoader>
76+
</div>
77+
<Tooltip popupContent={t('common.operation.zoomOut')}>
78+
<div className='absolute top-6 right-24 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
79+
onClick={zoomOut}>
80+
<RiZoomOutLine className='w-4 h-4 text-gray-500'/>
81+
</div>
82+
</Tooltip>
83+
<Tooltip popupContent={t('common.operation.zoomIn')}>
84+
<div className='absolute top-6 right-16 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
85+
onClick={zoomIn}>
86+
<RiZoomInLine className='w-4 h-4 text-gray-500'/>
87+
</div>
88+
</Tooltip>
89+
<Tooltip popupContent={t('common.operation.cancel')}>
90+
<div
91+
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'
92+
onClick={onCancel}>
93+
<RiCloseLine className='w-4 h-4 text-gray-500'/>
94+
</div>
95+
</Tooltip>
96+
</div>,
97+
document.body,
98+
)
99+
}
100+
101+
export default PdfPreview

0 commit comments

Comments
 (0)