-
Notifications
You must be signed in to change notification settings - Fork 4
feat: upload input v3 image preview improvement #263
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,7 @@ | |
| * limitations under the License. | ||
| **/ | ||
|
|
||
| import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react'; | ||
| import React, { useState, useRef, useMemo, useCallback, useLayoutEffect } from 'react'; | ||
| import T from "i18n-react/dist/i18n-react"; | ||
| import { | ||
| Box, | ||
|
|
@@ -30,6 +30,19 @@ import ProgressiveImg from '../../progressive-img'; | |
| import file_icon from '../upload-input/file.png'; | ||
| import './index.less'; | ||
|
|
||
| const fileRowSx = { | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| py: 1.5, | ||
| mb: 1, | ||
| }; | ||
|
|
||
| const formatFileSize = (bytes) => { | ||
| if (!bytes) return '0 KB'; | ||
| if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`; | ||
| return `${Math.round(bytes / 1024)} KB`; | ||
| }; | ||
|
|
||
| const UploadInputV3 = ({ | ||
| value = [], | ||
| onRemove, | ||
|
|
@@ -54,26 +67,17 @@ const UploadInputV3 = ({ | |
| const dropzoneInstanceRef = useRef(null); | ||
| const [uploadingFiles, setUploadingFiles] = useState([]); | ||
| const [errorFiles, setErrorFiles] = useState([]); | ||
| const [filePreviews, setFilePreviews] = useState({}); | ||
|
|
||
| const getDefaultAllowedExtensions = useCallback(() => { | ||
| return mediaType && mediaType.type | ||
| ? mediaType?.type?.allowed_extensions.map((ext) => `.${ext.toLowerCase()}`).join(",") | ||
| : ''; | ||
| }, [mediaType]); | ||
|
|
||
| const getDefaultMaxSize = useCallback(() => { | ||
| return mediaType ? mediaType?.max_size / (1024 * 1024) : 100; | ||
| }, [mediaType]); | ||
| const allowedExt = useMemo(() => { | ||
| if (getAllowedExtensions) return getAllowedExtensions(); | ||
| return mediaType?.type?.allowed_extensions?.map(ext => `.${ext.toLowerCase()}`).join(',') ?? ''; | ||
| }, [getAllowedExtensions, mediaType]); | ||
|
|
||
| const allowedExt = useMemo(() => | ||
| getAllowedExtensions ? getAllowedExtensions() : getDefaultAllowedExtensions(), | ||
| [getAllowedExtensions, getDefaultAllowedExtensions] | ||
| ); | ||
|
|
||
| const maxSize = useMemo(() => | ||
| getMaxSize ? getMaxSize() : getDefaultMaxSize(), | ||
| [getMaxSize, getDefaultMaxSize] | ||
| ); | ||
| const maxSize = useMemo(() => { | ||
| if (getMaxSize) return getMaxSize(); | ||
| return mediaType ? mediaType.max_size / (1024 * 1024) : 100; | ||
| }, [getMaxSize, mediaType]); | ||
|
|
||
| const canUpload = useMemo(() => | ||
| !maxFiles || value.length < maxFiles, | ||
|
|
@@ -113,13 +117,7 @@ const UploadInputV3 = ({ | |
| media_upload: value, | ||
| }), [mediaType, value]); | ||
|
|
||
| const formatFileSize = useCallback((bytes) => { | ||
| if (!bytes) return '0 KB'; | ||
| if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`; | ||
| return `${Math.round(bytes / 1024)} KB`; | ||
| }, []); | ||
|
|
||
| const formatExtensionsDisplay = useCallback(() => { | ||
| const extDisplay = useMemo(() => { | ||
| if (!allowedExt) return ''; | ||
| const exts = allowedExt.split(',') | ||
| .map(e => e.trim().replace('.', '').toUpperCase()) | ||
|
|
@@ -131,15 +129,21 @@ const UploadInputV3 = ({ | |
|
|
||
| const handleRemove = useCallback((file) => (ev) => { | ||
| ev.preventDefault(); | ||
| const blobUrl = filePreviews[file.filename]; | ||
| if (blobUrl) { | ||
| URL.revokeObjectURL(blobUrl); | ||
| setFilePreviews(prev => { const next = { ...prev }; delete next[file.filename]; return next; }); | ||
| } | ||
| onRemove(file); | ||
| }, [onRemove]); | ||
| }, [onRemove, filePreviews]); | ||
|
|
||
| const handleDropzoneReady = useCallback((dz) => { | ||
| dropzoneInstanceRef.current = dz; | ||
| }, []); | ||
|
|
||
| const handleAddedFile = useCallback((file) => { | ||
| setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false }]); | ||
| const previewUrl = file.type?.startsWith('image/') ? URL.createObjectURL(file) : null; | ||
| setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false, previewUrl }]); | ||
| }, []); | ||
|
|
||
| const handleUploadProgress = useCallback((file, progress) => { | ||
|
|
@@ -162,14 +166,17 @@ const UploadInputV3 = ({ | |
| )); | ||
| }, []); | ||
|
|
||
| // Once the parent updates value, remove all completed files from uploadingFiles | ||
| useEffect(() => { | ||
| useLayoutEffect(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see the use case for this change, can you explain ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. useLayoutEffect runs synchronously before the browser paints, so when value updates, the completed uploading row is removed before the user sees anything. With useEffect it runs after paint, causing a one-frame flash where the file appears twice — once as "Complete" in the uploading section and once in the committed value section.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this just hides the symptom but does not address the re-render . which is caused by this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done the refactor you mention and prevValueRef, pendingPreviewsRef and the batched matching are all gone. But useLayoutEffect is still needed though: when value updates, both the completed uploading row and the new value row land in the same render, and without it the user sees them both for one frame before the cleanup runs. It's not hiding a symptom, it's preventing a real visual duplicate.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm working on the last of your comments and will update this PR with the latest code, will tag you so you can review it. |
||
| if (uploadingFiles.length === 0 || value.length === 0) return; | ||
| setUploadingFiles(prev => prev.filter(f => !f.complete)); | ||
| }, [value]); | ||
|
|
||
| const handleFileError = useCallback((file, message) => { | ||
| setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size))); | ||
| setUploadingFiles(prev => { | ||
| const entry = prev.find(f => f.name === file.name && f.size === file.size); | ||
| if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl); | ||
| return prev.filter(f => !(f.name === file.name && f.size === file.size)); | ||
| }); | ||
| setErrorFiles(prev => [...prev, { name: file.name, size: file.size, message }]); | ||
| }, []); | ||
|
|
||
|
|
@@ -184,24 +191,32 @@ const UploadInputV3 = ({ | |
| }, []); | ||
|
|
||
| const handleDeleteUploading = useCallback((file) => { | ||
| setUploadingFiles(prev => { | ||
| const entry = prev.find(f => f.name === file.name && f.size === file.size); | ||
| if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl); | ||
| return prev.filter(f => !(f.name === file.name && f.size === file.size)); | ||
| }); | ||
| if (dropzoneInstanceRef.current) { | ||
| const dzFile = dropzoneInstanceRef.current.files?.find( | ||
| f => f.name === file.name && f.size === file.size | ||
| ); | ||
| if (dzFile) dropzoneInstanceRef.current.removeFile(dzFile); | ||
| } | ||
| setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size))); | ||
| }, []); | ||
|
|
||
| const wrappedOnUploadComplete = useCallback((response, dzId, dzData) => { | ||
| // Mark fully-uploaded rows complete (covers HTTP 202 flow where handleFileCompleted was skipped). | ||
| // Guard against flipping rows whose bytes are still in flight when maxFiles > 1. | ||
| setUploadingFiles(prev => prev.map(f => (f.progress >= 100 ? { ...f, complete: true } : f))); | ||
| setUploadingFiles(prev => { | ||
| if (response?.name && response?.size) { | ||
| const entry = prev.find(f => f.size === response.size && f.previewUrl); | ||
| if (entry) setFilePreviews(p => ({ ...p, [response.name]: entry.previewUrl })); | ||
| } | ||
| return prev.map(f => (f.progress >= 100 ? { ...f, complete: true } : f)); | ||
| }); | ||
| if (onUploadComplete) onUploadComplete(response, dzId, dzData); | ||
| }, [onUploadComplete]); | ||
|
|
||
| const extDisplay = formatExtensionsDisplay(); | ||
|
|
||
| const renderDropzone = () => { | ||
| if (!postUrl) { | ||
| return ( | ||
|
|
@@ -252,13 +267,6 @@ const UploadInputV3 = ({ | |
| ); | ||
| }; | ||
|
|
||
| const fileRowSx = { | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| py: 1.5, | ||
| mb: 1, | ||
| }; | ||
|
|
||
| return ( | ||
| <Box className="upload-input-v3"> | ||
| {label && ( | ||
|
|
@@ -292,8 +300,18 @@ const UploadInputV3 = ({ | |
| key={`uploading-${index}`} | ||
| sx={fileRowSx} | ||
| > | ||
| <Box sx={{ color: 'primary.main', display: 'flex', alignItems: 'center', mr: 2, minWidth: 32 }}> | ||
| <UploadFileIcon fontSize="medium" /> | ||
| <Box sx={{ display: 'flex', alignItems: 'center', mr: 2, width: 64, height: 64, flexShrink: 0 }}> | ||
| {file.previewUrl ? ( | ||
| <ProgressiveImg | ||
| alt={file.name} | ||
| src={file.previewUrl} | ||
| placeholderSrc={file_icon} | ||
| /> | ||
| ) : ( | ||
| <Box sx={{ color: 'primary.main', display: 'flex', alignItems: 'center' }}> | ||
| <UploadFileIcon fontSize="medium" /> | ||
| </Box> | ||
| )} | ||
| </Box> | ||
|
|
||
| <Box sx={{ flex: 1, minWidth: 0 }}> | ||
|
|
@@ -377,7 +395,8 @@ const UploadInputV3 = ({ | |
| let src = file?.private_url || file?.public_url || file?.file_url; | ||
| if (src === '#') src = file?.public_url; | ||
| // custom replace for dropbox case ( download vs raw) | ||
| const previewSrc = src ? src.replace("?dl=0", "?raw=1") : filename; | ||
| const serverPreviewSrc = src ? src.replace("?dl=0", "?raw=1") : filename; | ||
| const previewSrc = filePreviews[filename] || serverPreviewSrc; | ||
|
|
||
| return ( | ||
| <Box | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add lifecycle cleanup for cached blob URLs.
filePreviewsentries are never revoked on component unmount, so object URLs can leak memory beyond the upload flow. Add a cleanup effect that revokes all remaining blob URLs.Suggested patch
Also applies to: 169-172
🤖 Prompt for AI Agents