Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ui): performance improvements in the bulk uploader when adding lots of images at once #8944

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/ui/src/elements/BulkUpload/FileSidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function FileSidebar() {
isInitializing,
removeFile,
setActiveIndex,
thumbnailUrls,
totalErrorCount,
} = useFormsManager()
const { initialFiles, maxFiles } = useBulkUpload()
Expand Down Expand Up @@ -148,9 +149,7 @@ export function FileSidebar() {
>
<Thumbnail
className={`${baseClass}__thumbnail`}
fileSrc={
isImage(currentFile.type) ? URL.createObjectURL(currentFile) : undefined
}
fileSrc={isImage(currentFile.type) ? thumbnailUrls[index] : undefined}
/>
<div className={`${baseClass}__fileDetails`}>
<p className={`${baseClass}__fileName`} title={currentFile.name}>
Expand Down
38 changes: 38 additions & 0 deletions packages/ui/src/elements/BulkUpload/FormsManager/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useTranslation } from '../../../providers/Translation/index.js'
import { getFormState } from '../../../utilities/getFormState.js'
import { hasSavePermission as getHasSavePermission } from '../../../utilities/hasSavePermission.js'
import { useLoadingOverlay } from '../../LoadingOverlay/index.js'
import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
import { useBulkUpload } from '../index.js'
import { createFormData } from './createFormData.js'
import { formsManagementReducer } from './reducer.js'
Expand All @@ -41,6 +42,7 @@ type FormsManagerContext = {
errorCount: number
index: number
}) => void
readonly thumbnailUrls: string[]
readonly totalErrorCount?: number
}

Expand All @@ -59,6 +61,7 @@ const Context = React.createContext<FormsManagerContext>({
saveAllDocs: () => Promise.resolve(),
setActiveIndex: () => 0,
setFormTotalErrorCount: () => {},
thumbnailUrls: [],
totalErrorCount: 0,
})

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

const formsRef = React.useRef(forms)
formsRef.current = forms
const formsCount = forms.length

const thumbnailUrlsRef = React.useRef<string[]>([])
const processedFiles = React.useRef(new Set()) // Track already-processed files
const [renderedThumbnails, setRenderedThumbnails] = React.useState<string[]>([])

React.useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
;(async () => {
const newThumbnails = [...thumbnailUrlsRef.current]

for (let i = 0; i < formsCount; i++) {
const file = formsRef.current[i].formState.file.value as File

// Skip if already processed
if (processedFiles.current.has(file) || !file) {
continue
}
processedFiles.current.add(file)

// Generate thumbnail and update ref
const thumbnailUrl = await createThumbnail(file)
newThumbnails[i] = thumbnailUrl
thumbnailUrlsRef.current = newThumbnails

// Trigger re-render in batches
setRenderedThumbnails([...newThumbnails])
await new Promise((resolve) => setTimeout(resolve, 100))
}
})()
}, [formsCount, createThumbnail])

const { toggleLoadingOverlay } = useLoadingOverlay()
const { closeModal } = useModal()
const { collectionSlug, drawerSlug, initialFiles, onSuccess } = useBulkUpload()
Expand Down Expand Up @@ -378,6 +415,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
saveAllDocs,
setActiveIndex,
setFormTotalErrorCount,
thumbnailUrls: renderedThumbnails,
totalErrorCount,
}}
>
Expand Down
52 changes: 52 additions & 0 deletions packages/ui/src/elements/Thumbnail/createThumbnail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Create a thumbnail from a File object by drawing it onto an OffscreenCanvas
*/
export const createThumbnail = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image()
img.src = URL.createObjectURL(file) // Use Object URL directly

img.onload = () => {
const maxDimension = 280
let drawHeight: number, drawWidth: number

// Calculate aspect ratio
const aspectRatio = img.width / img.height

// Determine dimensions to fit within maxDimension while maintaining aspect ratio
if (aspectRatio > 1) {
// Image is wider than tall
drawWidth = maxDimension
drawHeight = maxDimension / aspectRatio
} else {
// Image is taller than wide, or square
drawWidth = maxDimension * aspectRatio
drawHeight = maxDimension
}

const canvas = new OffscreenCanvas(drawWidth, drawHeight) // Create an OffscreenCanvas
const ctx = canvas.getContext('2d')

// Draw the image onto the OffscreenCanvas with calculated dimensions
ctx.drawImage(img, 0, 0, drawWidth, drawHeight)

// Convert the OffscreenCanvas to a Blob and free up memory
canvas
.convertToBlob({ type: 'image/jpeg', quality: 0.25 })
.then((blob) => {
URL.revokeObjectURL(img.src) // Release the Object URL
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string) // Resolve as data URL
reader.onerror = reject
reader.readAsDataURL(blob)
})
.catch(reject)
}

img.onerror = (error) => {
URL.revokeObjectURL(img.src) // Release Object URL on error
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(error)
}
})
}
3 changes: 3 additions & 0 deletions packages/ui/src/elements/Thumbnail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const baseClass = 'thumbnail'
import type { SanitizedCollectionConfig } from 'payload'

import { File } from '../../graphics/File/index.js'
import { useIntersect } from '../../hooks/useIntersect.js'
import { ShimmerEffect } from '../ShimmerEffect/index.js'

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

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

React.useEffect(() => {
if (!fileSrc) {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setFileExists(false)
return
}
Expand Down
Loading