Skip to content

Commit 03331de

Browse files
authored
fix(ui): perf improvements in bulk upload (#8944)
1 parent d64946c commit 03331de

File tree

4 files changed

+95
-3
lines changed

4 files changed

+95
-3
lines changed

packages/ui/src/elements/BulkUpload/FileSidebar/index.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function FileSidebar() {
3636
isInitializing,
3737
removeFile,
3838
setActiveIndex,
39+
thumbnailUrls,
3940
totalErrorCount,
4041
} = useFormsManager()
4142
const { initialFiles, maxFiles } = useBulkUpload()
@@ -156,9 +157,7 @@ export function FileSidebar() {
156157
>
157158
<Thumbnail
158159
className={`${baseClass}__thumbnail`}
159-
fileSrc={
160-
isImage(currentFile.type) ? URL.createObjectURL(currentFile) : undefined
161-
}
160+
fileSrc={isImage(currentFile.type) ? thumbnailUrls[index] : undefined}
162161
/>
163162
<div className={`${baseClass}__fileDetails`}>
164163
<p className={`${baseClass}__fileName`} title={currentFile.name}>

packages/ui/src/elements/BulkUpload/FormsManager/index.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useTranslation } from '../../../providers/Translation/index.js'
1616
import { getFormState } from '../../../utilities/getFormState.js'
1717
import { hasSavePermission as getHasSavePermission } from '../../../utilities/hasSavePermission.js'
1818
import { useLoadingOverlay } from '../../LoadingOverlay/index.js'
19+
import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
1920
import { useBulkUpload } from '../index.js'
2021
import { createFormData } from './createFormData.js'
2122
import { formsManagementReducer } from './reducer.js'
@@ -41,6 +42,7 @@ type FormsManagerContext = {
4142
errorCount: number
4243
index: number
4344
}) => void
45+
readonly thumbnailUrls: string[]
4446
readonly totalErrorCount?: number
4547
}
4648

@@ -59,6 +61,7 @@ const Context = React.createContext<FormsManagerContext>({
5961
saveAllDocs: () => Promise.resolve(),
6062
setActiveIndex: () => 0,
6163
setFormTotalErrorCount: () => {},
64+
thumbnailUrls: [],
6265
totalErrorCount: 0,
6366
})
6467

@@ -90,6 +93,40 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
9093
const [state, dispatch] = React.useReducer(formsManagementReducer, initialState)
9194
const { activeIndex, forms, totalErrorCount } = state
9295

96+
const formsRef = React.useRef(forms)
97+
formsRef.current = forms
98+
const formsCount = forms.length
99+
100+
const thumbnailUrlsRef = React.useRef<string[]>([])
101+
const processedFiles = React.useRef(new Set()) // Track already-processed files
102+
const [renderedThumbnails, setRenderedThumbnails] = React.useState<string[]>([])
103+
104+
React.useEffect(() => {
105+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
106+
;(async () => {
107+
const newThumbnails = [...thumbnailUrlsRef.current]
108+
109+
for (let i = 0; i < formsCount; i++) {
110+
const file = formsRef.current[i].formState.file.value as File
111+
112+
// Skip if already processed
113+
if (processedFiles.current.has(file) || !file) {
114+
continue
115+
}
116+
processedFiles.current.add(file)
117+
118+
// Generate thumbnail and update ref
119+
const thumbnailUrl = await createThumbnail(file)
120+
newThumbnails[i] = thumbnailUrl
121+
thumbnailUrlsRef.current = newThumbnails
122+
123+
// Trigger re-render in batches
124+
setRenderedThumbnails([...newThumbnails])
125+
await new Promise((resolve) => setTimeout(resolve, 100))
126+
}
127+
})()
128+
}, [formsCount, createThumbnail])
129+
93130
const { toggleLoadingOverlay } = useLoadingOverlay()
94131
const { closeModal } = useModal()
95132
const { collectionSlug, drawerSlug, initialFiles, onSuccess } = useBulkUpload()
@@ -378,6 +415,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
378415
saveAllDocs,
379416
setActiveIndex,
380417
setFormTotalErrorCount,
418+
thumbnailUrls: renderedThumbnails,
381419
totalErrorCount,
382420
}}
383421
>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Create a thumbnail from a File object by drawing it onto an OffscreenCanvas
3+
*/
4+
export const createThumbnail = (file: File): Promise<string> => {
5+
return new Promise((resolve, reject) => {
6+
const img = new Image()
7+
img.src = URL.createObjectURL(file) // Use Object URL directly
8+
9+
img.onload = () => {
10+
const maxDimension = 280
11+
let drawHeight: number, drawWidth: number
12+
13+
// Calculate aspect ratio
14+
const aspectRatio = img.width / img.height
15+
16+
// Determine dimensions to fit within maxDimension while maintaining aspect ratio
17+
if (aspectRatio > 1) {
18+
// Image is wider than tall
19+
drawWidth = maxDimension
20+
drawHeight = maxDimension / aspectRatio
21+
} else {
22+
// Image is taller than wide, or square
23+
drawWidth = maxDimension * aspectRatio
24+
drawHeight = maxDimension
25+
}
26+
27+
const canvas = new OffscreenCanvas(drawWidth, drawHeight) // Create an OffscreenCanvas
28+
const ctx = canvas.getContext('2d')
29+
30+
// Draw the image onto the OffscreenCanvas with calculated dimensions
31+
ctx.drawImage(img, 0, 0, drawWidth, drawHeight)
32+
33+
// Convert the OffscreenCanvas to a Blob and free up memory
34+
canvas
35+
.convertToBlob({ type: 'image/jpeg', quality: 0.25 })
36+
.then((blob) => {
37+
URL.revokeObjectURL(img.src) // Release the Object URL
38+
const reader = new FileReader()
39+
reader.onload = () => resolve(reader.result as string) // Resolve as data URL
40+
reader.onerror = reject
41+
reader.readAsDataURL(blob)
42+
})
43+
.catch(reject)
44+
}
45+
46+
img.onerror = (error) => {
47+
URL.revokeObjectURL(img.src) // Release Object URL on error
48+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
49+
reject(error)
50+
}
51+
})
52+
}

packages/ui/src/elements/Thumbnail/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const baseClass = 'thumbnail'
88
import type { SanitizedCollectionConfig } from 'payload'
99

1010
import { File } from '../../graphics/File/index.js'
11+
import { useIntersect } from '../../hooks/useIntersect.js'
1112
import { ShimmerEffect } from '../ShimmerEffect/index.js'
1213

1314
export type ThumbnailProps = {
@@ -28,6 +29,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
2829

2930
React.useEffect(() => {
3031
if (!fileSrc) {
32+
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
3133
setFileExists(false)
3234
return
3335
}
@@ -72,6 +74,7 @@ export function ThumbnailComponent(props: ThumbnailComponentProps) {
7274

7375
React.useEffect(() => {
7476
if (!fileSrc) {
77+
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
7578
setFileExists(false)
7679
return
7780
}

0 commit comments

Comments
 (0)