import React, { ReactNode, CSSProperties, RefObject, ChangeEvent, DragEvent } from 'react'; import cls from 'classnames'; import PropTypes from 'prop-types'; import { noop, pick } from 'lodash'; import UploadFoundation from '@douyinfe/semi-foundation/upload/foundation'; import { strings, cssClasses } from '@douyinfe/semi-foundation/upload/constants'; import FileCard from './fileCard'; import BaseComponent from '../_base/baseComponent'; import LocaleConsumer from '../locale/localeConsumer'; import { IconUpload } from '@douyinfe/semi-icons'; import Cropper from '../cropper'; import Modal, { type ModalReactProps } from '../modal'; import type { FileItem, RenderFileItemProps, UploadListType, PromptPositionType, BeforeUploadProps, AfterUploadProps, OnChangeProps, customRequestArgs, CustomError, RenderPictureCloseProps, RenderFileListTitleProps, CropProps, } from './interface'; import { Locale } from '../locale/interface'; import '@douyinfe/semi-foundation/upload/upload.scss'; import type { CustomFile, UploadAdapter, BeforeUploadObjectResult, AfterUploadResult, FileItemStatus } from '@douyinfe/semi-foundation/upload/foundation'; import type { ValidateStatus } from '../_base/baseComponent'; import { ShowTooltip } from '../typography'; const prefixCls = cssClasses.PREFIX; export type { FileItem, FileItemStatus, RenderFileItemProps, UploadListType, PromptPositionType, BeforeUploadProps, AfterUploadProps, OnChangeProps, customRequestArgs, CustomError, BeforeUploadObjectResult, AfterUploadResult, RenderFileListTitleProps, CropProps, }; export interface UploadProps { accept?: string; action: string; afterUpload?: (object: AfterUploadProps) => AfterUploadResult; beforeUpload?: ( object: BeforeUploadProps ) => BeforeUploadObjectResult | Promise | boolean; beforeClear?: (fileList: Array) => boolean | Promise; beforeRemove?: (file: FileItem, fileList: Array) => boolean | Promise; capture?: boolean | 'user' | 'environment' | undefined; children?: ReactNode; className?: string; customRequest?: (object: customRequestArgs) => void; data?: Record | ((file: File) => Record); defaultFileList?: Array; directory?: boolean; disabled?: boolean; dragIcon?: ReactNode; dragMainText?: ReactNode; dragSubText?: ReactNode; draggable?: boolean; addOnPasting?: boolean; fileList?: Array; fileName?: string; headers?: Record | ((file: File) => Record); hotSpotLocation?: 'start' | 'end'; itemStyle?: CSSProperties; limit?: number; listType?: UploadListType; maxSize?: number; minSize?: number; multiple?: boolean; name?: string; onAcceptInvalid?: (files: File[]) => void; onChange?: (object: OnChangeProps) => void; onClear?: () => void; onDrop?: (e: Event, files: Array, fileList: Array) => void; onError?: (e: CustomError, file: CustomFile, fileList: Array, xhr: XMLHttpRequest) => void; onPastingError?: (error: Error | PermissionStatus) => void; onExceed?: (fileList: Array) => void; onFileChange?: (files: Array) => void; onOpenFileDialog?: () => void; onPreviewClick?: (fileItem: FileItem) => void; onProgress?: (percent: number, file: CustomFile, fileList: Array) => void; onRemove?: (currentFile: CustomFile, fileList: Array, currentFileItem: FileItem) => void; onRetry?: (fileItem: FileItem) => void; onSizeError?: (file: CustomFile, fileList: Array) => void; onSuccess?: (responseBody: any, file: CustomFile, fileList: Array) => void; previewFile?: (renderFileItemProps: RenderFileItemProps) => ReactNode; prompt?: ReactNode; promptPosition?: PromptPositionType; picHeight?: string | number; picWidth?: string | number; renderFileItem?: (renderFileItemProps: RenderFileItemProps) => ReactNode; renderPicInfo?: (renderFileItemProps: RenderFileItemProps) => ReactNode; renderThumbnail?: (renderFileItemProps: RenderFileItemProps) => ReactNode; renderPicPreviewIcon?: (renderFileItemProps: RenderFileItemProps) => ReactNode; renderPicClose?: (renderPicCloseProps: RenderPictureCloseProps) => ReactNode; renderFileOperation?: (fileItem: RenderFileItemProps) => ReactNode; fileListTitle?: ReactNode | ((props: RenderFileListTitleProps) => ReactNode); showClear?: boolean; showPicInfo?: boolean; // Show pic info in picture wall showReplace?: boolean; // Display replacement function showRetry?: boolean; showUploadList?: boolean; style?: CSSProperties; timeout?: number; transformFile?: (file: File) => FileItem; uploadTrigger?: 'auto' | 'custom'; validateMessage?: ReactNode; validateStatus?: ValidateStatus; withCredentials?: boolean; showTooltip?: boolean | ShowTooltip; /** * Enable image cropping. Pass `true` to use defaults, or a `CropProps` object * to customize aspect ratio, shape, output quality, etc. */ crop?: boolean | CropProps; /** * Callback invoked before opening the crop modal for each image. * Return `false` (or resolve to `false`) to skip cropping and upload directly. */ beforeCrop?: (file: File, fileList: File[]) => boolean | Promise; /** Callback invoked when cropping fails. */ onCropError?: (error: Error) => void; /** Extra props forwarded to the underlying crop Modal. */ cropModalProps?: ModalReactProps } export interface UploadState { dragAreaStatus: 'default' | 'legal' | 'illegal'; // Status of the drag zone fileList: Array; inputKey: number; // Track objectURL created by Upload (legacy, kept for compatibility) localUrls: Array; replaceIdx: number; replaceInputKey: number; // Cropper state cropperVisible: boolean; /** The image file currently being cropped */ cropperFile: File | null; /** Object URL for the image currently being cropped */ cropperSrc: string; /** Remaining image files queued for cropping (cropped one at a time) */ pendingImageFiles: File[]; /** Non-image files that bypass cropping but are uploaded together */ nonImageFiles: File[]; /** Image files already cropped, accumulated until the queue drains */ croppedFiles: File[]; /** Flag to indicate this round was triggered by a replace action */ isReplaceOperation: boolean } class Upload extends BaseComponent { static propTypes = { accept: PropTypes.string, // Limit allowed file types action: PropTypes.string.isRequired, addOnPasting: PropTypes.bool, afterUpload: PropTypes.func, beforeClear: PropTypes.func, beforeRemove: PropTypes.func, beforeUpload: PropTypes.func, children: PropTypes.node, className: PropTypes.string, customRequest: PropTypes.func, data: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), // Extra parameters attached when uploading defaultFileList: PropTypes.array, directory: PropTypes.bool, // Support folder upload disabled: PropTypes.bool, dragIcon: PropTypes.node, dragMainText: PropTypes.node, dragSubText: PropTypes.node, draggable: PropTypes.bool, fileList: PropTypes.array, // files had been uploaded fileName: PropTypes.string, // same as name, to avoid props conflict in Form.Upload headers: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), hotSpotLocation: PropTypes.oneOf(['start', 'end']), itemStyle: PropTypes.object, limit: PropTypes.number, // 最大允许上传文件个数 listType: PropTypes.oneOf(strings.LIST_TYPE), maxSize: PropTypes.number, // 文件大小限制,单位kb minSize: PropTypes.number, // 文件大小限制,单位kb multiple: PropTypes.bool, name: PropTypes.string, // file name onAcceptInvalid: PropTypes.func, onChange: PropTypes.func, onClear: PropTypes.func, onDrop: PropTypes.func, onError: PropTypes.func, onExceed: PropTypes.func, // Callback exceeding limit onFileChange: PropTypes.func, // Callback when file is selected onOpenFileDialog: PropTypes.func, onPreviewClick: PropTypes.func, onProgress: PropTypes.func, onRemove: PropTypes.func, onRetry: PropTypes.func, onSizeError: PropTypes.func, // Callback with invalid file size onSuccess: PropTypes.func, onPastingError: PropTypes.func, previewFile: PropTypes.func, // Custom preview prompt: PropTypes.node, promptPosition: PropTypes.oneOf(strings.PROMPT_POSITION), picWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), picHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), renderFileItem: PropTypes.func, renderPicPreviewIcon: PropTypes.func, renderFileOperation: PropTypes.func, renderPicClose: PropTypes.func, renderPicInfo: PropTypes.func, renderThumbnail: PropTypes.func, fileListTitle: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), showClear: PropTypes.bool, showPicInfo: PropTypes.bool, showReplace: PropTypes.bool, showRetry: PropTypes.bool, showUploadList: PropTypes.bool, // whether to show fileList style: PropTypes.object, timeout: PropTypes.number, transformFile: PropTypes.func, uploadTrigger: PropTypes.oneOf(strings.UPLOAD_TRIGGER), // auto、custom validateMessage: PropTypes.node, validateStatus: PropTypes.oneOf(strings.VALIDATE_STATUS), withCredentials: PropTypes.bool, showTooltip: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), crop: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), beforeCrop: PropTypes.func, onCropError: PropTypes.func, cropModalProps: PropTypes.object, }; static defaultProps: Partial = { defaultFileList: [], disabled: false, listType: 'list' as const, hotSpotLocation: 'end', multiple: false, onAcceptInvalid: noop, onChange: noop, beforeRemove: () => true, beforeClear: () => true, onClear: noop, onDrop: noop, onError: noop, onExceed: noop, onFileChange: noop, onOpenFileDialog: noop, onProgress: noop, onRemove: noop, onRetry: noop, onSizeError: noop, onSuccess: noop, onPastingError: noop, promptPosition: 'right' as const, showClear: true, showPicInfo: false, showReplace: false, showRetry: true, showUploadList: true, uploadTrigger: 'auto' as const, withCredentials: false, showTooltip: true, }; static FileCard = FileCard; constructor(props: UploadProps) { super(props); this.state = { fileList: props.defaultFileList || [], replaceIdx: -1, inputKey: Math.random(), replaceInputKey: Math.random(), // Status of the drag zone dragAreaStatus: 'default', localUrls: [], // Cropper state cropperVisible: false, cropperFile: null, cropperSrc: '', pendingImageFiles: [], nonImageFiles: [], croppedFiles: [], isReplaceOperation: false, }; this.foundation = new UploadFoundation(this.adapter); this.inputRef = React.createRef(); this.replaceInputRef = React.createRef(); this.cropperRef = React.createRef(); } /** * Notes: * The input parameter and return value here do not declare the type, otherwise tsc may report an error in form/fields.tsx when wrap after withField * `The types of the parameters "props" and "nextProps" are incompatible. The attribute "action" is missing in the type "Readonly", but it is required in the type "UploadProps".` * which seems to be a bug, remove props type declare here */ static getDerivedStateFromProps(props) { const { fileList } = props; if ('fileList' in props) { return { fileList: fileList || [], }; } return null; } get adapter(): UploadAdapter { return { ...super.adapter, notifyFileSelect: (files): void => this.props.onFileChange(files), notifyError: (error, fileInstance, fileList, xhr): void => this.props.onError(error, fileInstance, fileList, xhr), notifySuccess: (responseBody, file, fileList): void => this.props.onSuccess(responseBody, file, fileList), notifyProgress: (percent, file, fileList): void => this.props.onProgress(percent, file, fileList), notifyRemove: (file, fileList, fileItem): void => this.props.onRemove(file, fileList, fileItem), notifySizeError: (file, fileList): void => this.props.onSizeError(file, fileList), notifyExceed: (fileList): void => this.props.onExceed(fileList), updateFileList: (fileList, cb): void => { if (typeof cb === 'function') { this.setState({ fileList }, cb); } else { this.setState({ fileList }); } }, notifyBeforeUpload: ({ file, fileList, }): boolean | BeforeUploadObjectResult | Promise => this.props.beforeUpload({ file, fileList }), notifyAfterUpload: ({ response, file, fileList }): AfterUploadResult => this.props.afterUpload({ response, file, fileList }), resetInput: (): void => { this.setState(prevState => ({ inputKey: Math.random(), })); }, resetReplaceInput: (): void => { this.setState(prevState => ({ replaceInputKey: Math.random(), })); }, isMac: (): boolean => { return navigator.platform.toUpperCase().indexOf('MAC') >= 0; }, registerPastingHandler: (cb?: (e: KeyboardEvent | ClipboardEvent) => void): void => { // Wrap the callback to intercept cropping const wrappedCb = (e: KeyboardEvent | ClipboardEvent) => { const { crop } = this.props; // Handle keydown event (Ctrl/Cmd+V) with crop interception if (crop && e.type === 'keydown' && 'code' in e) { const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const isCombineKeydown = isMac ? e.metaKey : e.ctrlKey; if (isCombineKeydown && e.code === 'KeyV') { // Check if navigator.clipboard is available if (navigator.clipboard && typeof navigator.clipboard.read === 'function') { const permissionName = 'clipboard-read' as PermissionName; navigator.permissions .query({ name: permissionName }) .then(result => { if (result.state === 'granted' || result.state === 'prompt') { navigator.clipboard.read().then(clipboardItems => { const files: File[] = []; const processClipboardItems = async () => { for (const clipboardItem of clipboardItems) { for (const type of clipboardItem.types) { if (type.startsWith('image')) { const blob = await clipboardItem.getType(type); const buffer = await blob.arrayBuffer(); const format = type.split('/')[1]; const file = new File([buffer], `paste.${format}`, { type }); files.push(file); } } } if (files.length > 0) { const imageFiles = files.filter(file => this.isImageFile(file)); if (imageFiles.length > 0) { // Start cropping this.handleCropFiles(files); } else { // No images, let foundation handle it this.foundation.handleChange(files); } } }; processClipboardItems(); }).catch(error => { this.props.onPastingError(error); }); } }) .catch(error => { this.props.onPastingError(error); }); // Don't call original callback, we've handled it return; } } } // Otherwise, call original callback cb?.(e); }; document.body.addEventListener('keydown', wrappedCb); this.pastingCb = wrappedCb; }, unRegisterPastingHandler: (): void => { if (this.pastingCb) { document.body.removeEventListener('keydown', this.pastingCb); } }, registerPasteEventHandler: (cb?: (e: ClipboardEvent) => void): void => { // Intercept paste events when crop is enabled and the clipboard contains images; // otherwise pass through to the foundation-supplied handler unchanged. const wrappedCb = (e: ClipboardEvent) => { const { crop } = this.props; if (crop && e.clipboardData && e.clipboardData.items) { const files: File[] = []; for (let i = 0; i < e.clipboardData.items.length; i++) { const item = e.clipboardData.items[i]; if (item.kind === 'file') { const file = item.getAsFile(); if (file) { files.push(file); } } } if (files.length > 0 && files.some(file => this.isImageFile(file))) { e.preventDefault(); this.handleCropFiles(files); return; } } cb?.(e); }; document.body.addEventListener('paste', wrappedCb); this.pasteEventCb = wrappedCb; }, unRegisterPasteEventHandler: (): void => { if (this.pasteEventCb) { document.body.removeEventListener('paste', this.pasteEventCb); } }, notifyPastingError: (error): void => this.props.onPastingError(error), updateDragAreaStatus: (dragAreaStatus: string): void => this.setState({ dragAreaStatus } as { dragAreaStatus: 'default' | 'legal' | 'illegal' }), notifyChange: ({ currentFile, fileList }): void => this.props.onChange({ currentFile, fileList }), updateLocalUrls: (urls: Array): void => this.setState({ localUrls: urls }), notifyClear: (): void => this.props.onClear(), notifyPreviewClick: (file): void => this.props.onPreviewClick(file), notifyDrop: (e, files, fileList): void => this.props.onDrop(e, files, fileList), notifyAcceptInvalid: (invalidFiles): void => this.props.onAcceptInvalid(invalidFiles), notifyBeforeRemove: (file, fileList): boolean | Promise => this.props.beforeRemove(file, fileList), notifyBeforeClear: (fileList): boolean | Promise => this.props.beforeClear(fileList), }; } foundation: UploadFoundation; inputRef: RefObject = null; replaceInputRef: RefObject = null; cropperRef: RefObject = null; pastingCb: null | ((params: any) => void) = null; pasteEventCb: null | ((params: any) => void) = null; componentDidMount(): void { this.foundation.init(); } componentWillUnmount(): void { this.foundation.destroy(); } onClick = (): void => { const { inputRef, props } = this; const { onOpenFileDialog } = props; const isDisabled = Boolean(this.props.disabled); if (isDisabled || !inputRef || !inputRef.current) { return; } inputRef.current.click(); if (onOpenFileDialog && typeof onOpenFileDialog) { onOpenFileDialog(); } }; onChange = (e: ChangeEvent): void => { const { files } = e.target; const { crop } = this.props; if (crop && files && files.length > 0) { const fileArr = Array.from(files); if (fileArr.some(file => this.isImageFile(file))) { this.handleCropFiles(fileArr); return; } } this.foundation.handleChange(files); }; /** * Check if file is an image */ isImageFile = (file: File): boolean => { return file.type.startsWith('image/'); }; /** * Entry point that decides whether incoming files need cropping. * Image files are queued for sequential cropping; non-image files are kept aside * and uploaded together with the cropped results once the queue drains. */ handleCropFiles = async (files: File[], isReplaceOperation = false): Promise => { const imageFiles = files.filter(file => this.isImageFile(file)); const nonImageFiles = files.filter(file => !this.isImageFile(file)); if (imageFiles.length === 0) { // No images to crop, fall back to the normal upload path this.dispatchUpload(files, isReplaceOperation); return; } const shouldCrop = await this.shouldCropFile(imageFiles[0], files); if (!shouldCrop) { this.dispatchUpload(files, isReplaceOperation); return; } // Replace only ever consumes a single file, ignore extras for safety const queue = isReplaceOperation ? [imageFiles[0]] : imageFiles; const [first, ...rest] = queue; this.setState({ cropperVisible: true, cropperFile: first, cropperSrc: URL.createObjectURL(first), pendingImageFiles: rest, nonImageFiles: isReplaceOperation ? [] : nonImageFiles, croppedFiles: [], isReplaceOperation, }); }; /** * Run the user-provided beforeCrop hook (if any) and return whether cropping should proceed. * Errors fall back to skipping cropping so users are never blocked. */ shouldCropFile = async (file: File, files: File[]): Promise => { const { beforeCrop, onCropError } = this.props; if (!beforeCrop) { return true; } try { const result = await beforeCrop(file, files); return result !== false; } catch (error) { onCropError?.(error as Error); return false; } }; /** * Forward files to the appropriate foundation handler based on operation type. */ dispatchUpload = (files: File[], isReplaceOperation: boolean): void => { if (isReplaceOperation) { this.foundation.handleReplaceChange(files as any); } else { this.foundation.handleChange(files as any); } }; /** * Confirm the current crop. If more images are queued, advance to the next one; * otherwise dispatch the accumulated cropped files together with any non-image files. */ handleCropOk = async (): Promise => { const { cropperFile, pendingImageFiles, nonImageFiles, croppedFiles, isReplaceOperation } = this.state; const { crop, onCropError } = this.props; try { const cropperInstance = this.cropperRef.current; if (!cropperInstance || !cropperFile) { throw new Error('Cropper instance not found'); } const canvas = cropperInstance.getCropperCanvas(); const cropConfig = typeof crop === 'object' ? crop : {}; const quality = cropConfig.quality ?? 0.92; const type = cropperFile.type || 'image/png'; const blob = await new Promise((resolve, reject) => { canvas.toBlob( b => (b ? resolve(b) : reject(new Error('Failed to create blob'))), type, quality ); }); // Preserve the original filename and lastModified so downstream consumers // can keep meaningful metadata about the user-chosen file. const croppedFile = new File([blob], cropperFile.name, { type, lastModified: cropperFile.lastModified, }); const nextCropped = [...croppedFiles, croppedFile]; if (pendingImageFiles.length > 0) { // Move on to the next queued image const [next, ...rest] = pendingImageFiles; if (this.state.cropperSrc) { URL.revokeObjectURL(this.state.cropperSrc); } this.setState({ cropperFile: next, cropperSrc: URL.createObjectURL(next), pendingImageFiles: rest, croppedFiles: nextCropped, }); return; } // Queue exhausted: close cropper and dispatch results this.closeCropperAndReset(); this.dispatchUpload([...nextCropped, ...nonImageFiles], isReplaceOperation); } catch (error) { onCropError?.(error as Error); } }; /** * Cancel the current crop session, revoke object URLs and reset state. */ handleCropCancel = (): void => { this.closeCropperAndReset(); }; /** Internal helper that tears down crop state and resets the file input. */ closeCropperAndReset = (): void => { const { cropperSrc } = this.state; if (cropperSrc) { URL.revokeObjectURL(cropperSrc); } this.setState({ cropperVisible: false, cropperFile: null, cropperSrc: '', pendingImageFiles: [], nonImageFiles: [], croppedFiles: [], isReplaceOperation: false, // Reset input key so picking the same file again re-triggers onChange inputKey: Math.random(), replaceInputKey: Math.random(), }); }; replace = (index: number): void => { this.setState({ replaceIdx: index }, () => { this.replaceInputRef.current.click(); }); }; onReplaceChange = (e: ChangeEvent): void => { const { files } = e.target; const { crop } = this.props; if (crop && files && files.length > 0) { const fileArr = Array.from(files); if (fileArr.some(file => this.isImageFile(file))) { this.handleCropFiles(fileArr, true); return; } } this.foundation.handleReplaceChange(files); }; clear = (): void => { this.foundation.handleClear(); }; remove = (fileItem: FileItem): void => { this.foundation.handleRemove(fileItem); }; /** * ref method * insert files at index * @param files Array * @param index number * @returns */ insert = (files: Array, index?: number): void => { return this.foundation.insertFileToList(files, index); }; /** * ref method * manual upload by user */ upload = (): void => { this.foundation.manualUpload(); }; /** * ref method * manual open file select dialog */ openFileDialog = (): void => { this.onClick(); }; renderFile = (file: FileItem, index: number, locale: Locale['Upload']): ReactNode => { const { name, status, validateMessage, _sizeInvalid, uid } = file; const { previewFile, listType, itemStyle, showPicInfo, renderPicInfo, renderPicClose, renderPicPreviewIcon, renderFileOperation, renderFileItem, renderThumbnail, disabled, onPreviewClick, picWidth, picHeight, showTooltip, } = this.props; const onRemove = (): void => this.remove(file); const onRetry = (): void => { this.foundation.retry(file); }; const onReplace = (): void => { this.replace(index); }; const fileCardProps = { ...pick(this.props, ['showRetry', 'showReplace', '']), ...file, previewFile, listType, onRemove, onRetry, index, key: uid || `${name}${index}`, style: itemStyle, disabled, showPicInfo, renderPicInfo, renderPicPreviewIcon, renderPicClose, renderFileOperation, renderThumbnail, onReplace, onPreviewClick: typeof onPreviewClick !== 'undefined' ? (): void => this.foundation.handlePreviewClick(file) : undefined, picWidth, picHeight, showTooltip, }; if (status === strings.FILE_STATUS_UPLOAD_FAIL && !validateMessage) { fileCardProps.validateMessage = locale.fail; } if (_sizeInvalid && !validateMessage) { fileCardProps.validateMessage = locale.illegalSize; } if (typeof renderFileItem === 'undefined') { return ; } else { return renderFileItem(fileCardProps); } }; renderFileList = (): ReactNode => { const { listType } = this.props; if (listType === strings.FILE_LIST_PIC) { return this.renderFileListPic(); } if (listType === strings.FILE_LIST_DEFAULT) { return this.renderFileListDefault(); } return null; }; renderFileListPic = () => { const { showUploadList, limit, disabled, children, draggable, hotSpotLocation, picHeight, picWidth } = this.props; const { fileList: stateFileList, dragAreaStatus } = this.state; const fileList = this.props.fileList || stateFileList; const showAddTriggerInList = limit ? limit > fileList.length : true; const dragAreaBaseCls = `${prefixCls}-drag-area`; const uploadAddCls = cls(`${prefixCls}-add`, { [`${prefixCls}-picture-add`]: true, [`${prefixCls}-picture-add-disabled`]: disabled, }); const fileListCls = cls(`${prefixCls}-file-list`, { [`${prefixCls}-picture-file-list`]: true, }); const dragAreaCls = cls({ [`${dragAreaBaseCls}-legal`]: dragAreaStatus === strings.DRAG_AREA_LEGAL, [`${dragAreaBaseCls}-illegal`]: dragAreaStatus === strings.DRAG_AREA_ILLEGAL, }); const mainCls = `${prefixCls}-file-list-main`; const addContentProps = { role: 'button', className: uploadAddCls, onClick: this.onClick, style: { height: picHeight, width: picWidth } }; const containerProps = { className: fileListCls, }; const draggableProps = { onDrop: this.onDrop, onDragOver: this.onDragOver, onDragLeave: this.onDragLeave, onDragEnter: this.onDragEnter, }; if (draggable) { Object.assign(addContentProps, draggableProps, { className: cls(uploadAddCls, dragAreaCls) }); } const addContent = (
{children}
); if (!showUploadList || !fileList.length) { if (showAddTriggerInList) { return addContent; } return null; } return ( {(locale: Locale['Upload']) => (
{showAddTriggerInList && hotSpotLocation === 'start' ? addContent : null} {fileList.map((file, index) => this.renderFile(file, index, locale))} {showAddTriggerInList && hotSpotLocation === 'end' ? addContent : null}
)}
); }; renderFileListDefault = () => { const { showUploadList, limit, disabled, fileListTitle } = this.props; const { fileList: stateFileList } = this.state; const fileList = this.props.fileList || stateFileList; const fileListCls = cls(`${prefixCls}-file-list`); const titleCls = `${prefixCls}-file-list-title`; const mainCls = `${prefixCls}-file-list-main`; const showTitle = limit !== 1 && fileList.length; const showClear = this.props.showClear && !disabled; const containerProps = { className: fileListCls, }; if (!showUploadList || !fileList.length) { return null; } return ( {(locale: Locale['Upload']) => { let titleContent: ReactNode; if (typeof fileListTitle === 'function') { // 函数形式:用户完全控制标题区域 titleContent = fileListTitle({ fileList, onClear: this.clear, clearText: locale.clear, }); } else { // ReactNode 或默认值:显示标题文字和清空按钮 titleContent = ( <> {fileListTitle || locale.selectedFiles} {showClear ? ( {locale.clear} ) : null} ); } return (
{showTitle ? (
{titleContent}
) : null}
{fileList.map((file, index) => this.renderFile(file, index, locale))}
); }}
); }; onDrop = (e: DragEvent): void => { const { crop, directory, disabled } = this.props; // For disabled / directory / no-crop scenarios, defer to the foundation // so existing accept / limit / drag-area-status / notifyDrop logic still applies. if (disabled || directory || !crop) { this.foundation.handleDrop(e); return; } const files = e.dataTransfer && e.dataTransfer.files ? Array.from(e.dataTransfer.files) : []; if (files.length === 0 || !files.some(file => this.isImageFile(file))) { this.foundation.handleDrop(e); return; } // We need to short-circuit the foundation's default handling so the original files // are not uploaded before cropping. Reproduce only the side effects that would // otherwise happen: prevent default browser open, reset drag status, fire onDrop. e.preventDefault(); e.stopPropagation(); const fileList = this.state.fileList.slice(); this.setState({ dragAreaStatus: 'default' as const }); const eventForCb: Event = (e as any).nativeEvent || (e as any); this.props.onDrop(eventForCb, files, fileList); this.handleCropFiles(files); }; onDragOver = (e: DragEvent): void => { // When a drag element moves within the target element this.foundation.handleDragOver(e); }; onDragLeave = (e: DragEvent): void => { this.foundation.handleDragLeave(e); }; onDragEnter = (e: DragEvent): void => { this.foundation.handleDragEnter(e); }; renderAddContent = () => { const { draggable, children, listType, disabled } = this.props; const uploadAddCls = cls(`${prefixCls}-add`); if (listType === strings.FILE_LIST_PIC) { return null; } if (draggable) { return this.renderDragArea(); } return (
{children}
); }; renderDragArea = (): ReactNode => { const { dragAreaStatus } = this.state; const { children, dragIcon, dragMainText, dragSubText, disabled } = this.props; const dragAreaBaseCls = `${prefixCls}-drag-area`; const dragAreaCls = cls(dragAreaBaseCls, { [`${dragAreaBaseCls}-legal`]: dragAreaStatus === strings.DRAG_AREA_LEGAL, [`${dragAreaBaseCls}-illegal`]: dragAreaStatus === strings.DRAG_AREA_ILLEGAL, [`${dragAreaBaseCls}-custom`]: children, }); return ( {(locale: Locale['Upload']): ReactNode => (
{children ? ( children ) : ( <>
{dragIcon || }
{dragMainText || locale.mainText}
{dragSubText}
{dragAreaStatus === strings.DRAG_AREA_LEGAL && ( {locale.legalTips} )} {dragAreaStatus === strings.DRAG_AREA_ILLEGAL && ( {locale.illegalTips} )}
)}
)}
); }; renderCropperModal = (): ReactNode => { const { cropperVisible, cropperSrc } = this.state; const { crop, cropModalProps } = this.props; const cropConfig = typeof crop === 'object' ? crop : {}; const { style: modalStyle, bodyStyle: modalBodyStyle, ...restModalProps } = (cropModalProps || {}) as ModalReactProps; return ( {(locale: Locale['Upload']) => { const modalTitle = cropConfig.modalTitle || locale.cropTitle || '裁切图片'; const modalOkText = cropConfig.modalOkText || locale.cropOk || '确定'; const modalCancelText = cropConfig.modalCancelText || locale.cropCancel || '取消'; return ( {cropperSrc && ( )} ); }} ); }; render(): ReactNode { const { style, className, multiple, accept, disabled, children, capture, listType, prompt, promptPosition, draggable, validateMessage, validateStatus, directory, ...rest } = this.props; const uploadCls = cls( prefixCls, { [`${prefixCls}-picture`]: listType === strings.FILE_LIST_PIC, [`${prefixCls}-disabled`]: disabled, [`${prefixCls}-default`]: validateStatus === 'default', [`${prefixCls}-error`]: validateStatus === 'error', [`${prefixCls}-warning`]: validateStatus === 'warning', [`${prefixCls}-success`]: validateStatus === 'success', }, className ); const inputCls = cls(`${prefixCls}-hidden-input`); const inputReplaceCls = cls(`${prefixCls}-hidden-input-replace`); const promptCls = cls(`${prefixCls}-prompt`); const validateMsgCls = cls(`${prefixCls}-validate-message`); const dirProps = directory ? { directory: 'directory', webkitdirectory: 'webkitdirectory' } : {}; return (
{this.renderAddContent()} {prompt ? (
{prompt}
) : null} {validateMessage ? (
{validateMessage}
) : null} {this.renderFileList()} {this.renderCropperModal()}
); } } export default Upload;