fix(ui): perf improvements in bulk upload (#8944)
This commit is contained in:
@@ -36,6 +36,7 @@ export function FileSidebar() {
|
|||||||
isInitializing,
|
isInitializing,
|
||||||
removeFile,
|
removeFile,
|
||||||
setActiveIndex,
|
setActiveIndex,
|
||||||
|
thumbnailUrls,
|
||||||
totalErrorCount,
|
totalErrorCount,
|
||||||
} = useFormsManager()
|
} = useFormsManager()
|
||||||
const { initialFiles, maxFiles } = useBulkUpload()
|
const { initialFiles, maxFiles } = useBulkUpload()
|
||||||
@@ -156,9 +157,7 @@ export function FileSidebar() {
|
|||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
className={`${baseClass}__thumbnail`}
|
className={`${baseClass}__thumbnail`}
|
||||||
fileSrc={
|
fileSrc={isImage(currentFile.type) ? thumbnailUrls[index] : undefined}
|
||||||
isImage(currentFile.type) ? URL.createObjectURL(currentFile) : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div className={`${baseClass}__fileDetails`}>
|
<div className={`${baseClass}__fileDetails`}>
|
||||||
<p className={`${baseClass}__fileName`} title={currentFile.name}>
|
<p className={`${baseClass}__fileName`} title={currentFile.name}>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useTranslation } from '../../../providers/Translation/index.js'
|
|||||||
import { getFormState } from '../../../utilities/getFormState.js'
|
import { getFormState } from '../../../utilities/getFormState.js'
|
||||||
import { hasSavePermission as getHasSavePermission } from '../../../utilities/hasSavePermission.js'
|
import { hasSavePermission as getHasSavePermission } from '../../../utilities/hasSavePermission.js'
|
||||||
import { useLoadingOverlay } from '../../LoadingOverlay/index.js'
|
import { useLoadingOverlay } from '../../LoadingOverlay/index.js'
|
||||||
|
import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
|
||||||
import { useBulkUpload } from '../index.js'
|
import { useBulkUpload } from '../index.js'
|
||||||
import { createFormData } from './createFormData.js'
|
import { createFormData } from './createFormData.js'
|
||||||
import { formsManagementReducer } from './reducer.js'
|
import { formsManagementReducer } from './reducer.js'
|
||||||
@@ -41,6 +42,7 @@ type FormsManagerContext = {
|
|||||||
errorCount: number
|
errorCount: number
|
||||||
index: number
|
index: number
|
||||||
}) => void
|
}) => void
|
||||||
|
readonly thumbnailUrls: string[]
|
||||||
readonly totalErrorCount?: number
|
readonly totalErrorCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ const Context = React.createContext<FormsManagerContext>({
|
|||||||
saveAllDocs: () => Promise.resolve(),
|
saveAllDocs: () => Promise.resolve(),
|
||||||
setActiveIndex: () => 0,
|
setActiveIndex: () => 0,
|
||||||
setFormTotalErrorCount: () => {},
|
setFormTotalErrorCount: () => {},
|
||||||
|
thumbnailUrls: [],
|
||||||
totalErrorCount: 0,
|
totalErrorCount: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -90,6 +93,40 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
|||||||
const [state, dispatch] = React.useReducer(formsManagementReducer, initialState)
|
const [state, dispatch] = React.useReducer(formsManagementReducer, initialState)
|
||||||
const { activeIndex, forms, totalErrorCount } = state
|
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 { toggleLoadingOverlay } = useLoadingOverlay()
|
||||||
const { closeModal } = useModal()
|
const { closeModal } = useModal()
|
||||||
const { collectionSlug, drawerSlug, initialFiles, onSuccess } = useBulkUpload()
|
const { collectionSlug, drawerSlug, initialFiles, onSuccess } = useBulkUpload()
|
||||||
@@ -378,6 +415,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
|||||||
saveAllDocs,
|
saveAllDocs,
|
||||||
setActiveIndex,
|
setActiveIndex,
|
||||||
setFormTotalErrorCount,
|
setFormTotalErrorCount,
|
||||||
|
thumbnailUrls: renderedThumbnails,
|
||||||
totalErrorCount,
|
totalErrorCount,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
52
packages/ui/src/elements/Thumbnail/createThumbnail.ts
Normal file
52
packages/ui/src/elements/Thumbnail/createThumbnail.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ const baseClass = 'thumbnail'
|
|||||||
import type { SanitizedCollectionConfig } from 'payload'
|
import type { SanitizedCollectionConfig } from 'payload'
|
||||||
|
|
||||||
import { File } from '../../graphics/File/index.js'
|
import { File } from '../../graphics/File/index.js'
|
||||||
|
import { useIntersect } from '../../hooks/useIntersect.js'
|
||||||
import { ShimmerEffect } from '../ShimmerEffect/index.js'
|
import { ShimmerEffect } from '../ShimmerEffect/index.js'
|
||||||
|
|
||||||
export type ThumbnailProps = {
|
export type ThumbnailProps = {
|
||||||
@@ -28,6 +29,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!fileSrc) {
|
if (!fileSrc) {
|
||||||
|
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
|
||||||
setFileExists(false)
|
setFileExists(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -72,6 +74,7 @@ export function ThumbnailComponent(props: ThumbnailComponentProps) {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!fileSrc) {
|
if (!fileSrc) {
|
||||||
|
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
|
||||||
setFileExists(false)
|
setFileExists(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user