import React, {Suspense} from 'react'; import {FormItem, FormControlProps, FormBaseControl} from './Item'; import 'cropperjs/dist/cropper.css'; const Cropper = React.lazy(() => import('react-cropper')); import DropZone from 'react-dropzone'; import {FileRejection} from 'react-dropzone'; import 'blueimp-canvastoblob'; import find from 'lodash/find'; import qs from 'qs'; import {Payload} from '../../types'; import {buildApi} from '../../utils/api'; import {createObject, qsstringify, guid, isEmpty} from '../../utils/helper'; import {Icon} from '../../components/icons'; import Button from '../../components/Button'; import accepts from 'attr-accept'; import {getNameFromUrl} from './InputFile'; import ImageComponent, {ImageThumbProps} from '../Image'; import {TranslateFn} from '../../locale'; import {dataMapping} from '../../utils/tpl-builtin'; import { SchemaApi, SchemaClassName, SchemaTokenizeableString, SchemaUrlPath } from '../../Schema'; import {filter} from '../../utils/tpl'; /** * Image 图片上传控件 * 文档:https://baidu.gitee.io/amis/docs/components/form/image */ export interface ImageControlSchema extends FormBaseControl { /** * 指定为图片上传控件 */ type: 'input-image'; /** * 默认展示图片的链接 */ src?: SchemaUrlPath; /** * 默认展示图片的类名 */ imageClassName?: string; /** * 配置接收的图片类型 * * 建议直接填写文件后缀 * 如:.txt,.csv * * 多个类型用逗号隔开。 */ accept?: string; /** * 默认都是通过用户选择图片后上传返回图片地址,如果开启此选项,则可以允许用户图片地址。 */ allowInput?: boolean; /** * 是否自动开始上传 */ autoUpload?: boolean; /** * 选择图片按钮的 CSS 类名 */ btnClassName?: SchemaClassName; /** * 上传按钮的 CSS 类名 */ btnUploadClassName?: SchemaClassName; /** * @deprecated */ compress?: boolean; /** * @deprecated */ compressOptions?: { maxHeight?: number; maxWidth?: number; }; crop?: | boolean | { /** * 默认 `1` 即 `1:1` * * 留空将不限制 */ aspectRatio?: number; guides?: boolean; dragMode?: string; viewMode?: number; rotatable?: boolean; scalable?: boolean; }; /** * 是否允许二次裁剪。 */ reCropable?: boolean; /** * 是否隐藏上传按钮 */ hideUploadButton?: boolean; /** * 限制图片大小,超出不让上传。 */ limit?: { /** * 比率不对时的提示文字 */ aspectRatioLabel?: string; /** * 限制比率 */ aspectRatio?: number; /** * 限制图片高度 */ height?: number; /** * 限制图片宽度 */ width?: number; /** * 限制图片最大高度 */ maxHeight?: number; /** * 限制图片最大宽度 */ maxWidth?: number; /** * 限制图片最小高度 */ minHeight?: number; /** * 限制图片最小宽度 */ minWidth?: number; }; /** * 最多的个数 */ maxLength?: number; /** * 默认没有限制,当设置后,文件大小大于此值将不允许上传。 */ maxSize?: number; /** * 默认 `/api/upload` 如果想自己存储,请设置此选项。 */ receiver?: SchemaApi; /** * 默认为 false, 开启后,允许用户输入压缩选项。 * * @deprecated */ showCompressOptions?: boolean; /** * 是否为多选 */ multiple?: boolean; /** * 单选模式:当用户选中某个选项时,选项中的 value 将被作为该表单项的值提交,否则,整个选项对象都会作为该表单项的值提交。 * 多选模式:选中的多个选项的 `value` 会通过 `delimiter` 连接起来,否则直接将以数组的形式提交值。 */ joinValues?: boolean; /** * 分割符 */ delimiter?: string; /** * 开启后将选中的选项 value 的值封装为数组,作为当前表单项的值。 */ extractValue?: boolean; /** * 清除时设置的值 */ resetValue?: any; /** * 缩路图展示模式 */ thumbMode?: 'w-full' | 'h-full' | 'contain' | 'cover'; /** * 缩路图展示比率。 */ thumbRatio?: '1:1' | '4:3' | '16:9'; /** * 上传后把其他字段同步到表单内部。 */ autoFill?: { [propName: string]: SchemaTokenizeableString; }; /** * 默认占位图图片地址 */ frameImage?: SchemaUrlPath; /** * 是否开启固定尺寸 */ fixedSize?: boolean; /** * 固定尺寸的 CSS类名 */ fixedSizeClassName?: SchemaClassName; } let preventEvent = (e: any) => e.stopPropagation(); export interface ImageProps extends FormControlProps, Omit< ImageControlSchema, 'type' | 'className' | 'descriptionClassName' | 'inputClassName' > { onImageEnlarge?: ( info: Pick & { index?: number; list?: Array< Pick >; } ) => void; } export interface ImageState { uploading: boolean; locked: boolean; lockedReason?: string; files: Array; crop?: any; error?: string; cropFile?: FileValue; submitOnChange?: boolean; frameImageWidth?: number; } export interface FileValue { value?: any; state: 'init' | 'error' | 'pending' | 'uploading' | 'uploaded' | 'invalid'; url?: string; error?: string; info?: { width: number; height: number; len?: number; }; [propName: string]: any; } export interface FileX extends File { id?: string | number; preview?: string; state?: 'init' | 'error' | 'pending' | 'uploading' | 'uploaded' | 'invalid'; progress?: number; [propName: string]: any; } export default class ImageControl extends React.Component< ImageProps, ImageState > { static defaultProps = { limit: undefined, accept: 'image/jpeg, image/jpg, image/png, image/gif', receiver: '/api/upload', hideUploadButton: false, placeholder: 'Image.placeholder', joinValues: true, extractValue: false, delimiter: ',', autoUpload: true, multiple: false }; static formatFileSize( size: number | string, units = [' B', ' KB', ' M', ' G'] ) { size = parseInt(size as string, 10) || 0; while (size > 1024 && units.length > 1) { size /= 1024; units.shift(); } return size.toFixed(2) + units[0]; } static valueToFile( value: string | object, props?: ImageProps ): FileValue | undefined { return value ? { ...(typeof value === 'string' ? { value, url: value, id: guid() } : value), state: 'init' } : undefined; } static sizeInfo( width: number | undefined, height: number | undefined, __: TranslateFn ): string { if (!width) { return __('Image.height', {height: height}); } else if (!height) { return __('Image.width', {width: width}); } return __('Image.size', {width, height}); } state: ImageState = { uploading: false, locked: false, files: [] }; files: Array = []; cropper = React.createRef(); dropzone = React.createRef(); frameImageRef = React.createRef(); current: FileValue | FileX | null = null; resolve?: (value?: any) => void; emitValue: any; unmounted = false; constructor(props: ImageProps) { super(props); const value: string | Array | FileValue = props.value; const multiple = props.multiple; const joinValues = props.joinValues; const delimiter = props.delimiter as string; let files: Array = []; if (value) { // files = (multiple && Array.isArray(value) ? value : joinValues ? (value as string).split(delimiter) : [value]) files = (Array.isArray(value) ? value : joinValues && typeof value === 'string' && multiple ? (value as string).split(delimiter) : [value] ) .map(item => ImageControl.valueToFile(item) as FileValue) .filter(item => item); } this.state = { ...this.state, files: (this.files = files), crop: this.buildCrop(props), frameImageWidth: 0 }; this.sendFile = this.sendFile.bind(this); this.removeFile = this.removeFile.bind(this); this.handleDrop = this.handleDrop.bind(this); this.handleClick = this.handleClick.bind(this); this.handleClick = this.handleClick.bind(this); this.handleCrop = this.handleCrop.bind(this); this.handleDropRejected = this.handleDropRejected.bind(this); this.cancelCrop = this.cancelCrop.bind(this); this.handleImageLoaded = this.handleImageLoaded.bind(this); this.handleFrameImageLoaded = this.handleFrameImageLoaded.bind(this); this.startUpload = this.startUpload.bind(this); this.stopUpload = this.stopUpload.bind(this); this.toggleUpload = this.toggleUpload.bind(this); this.tick = this.tick.bind(this); this.onChange = this.onChange.bind(this); this.addFiles = this.addFiles.bind(this); this.handleSelect = this.handleSelect.bind(this); this.handlePaste = this.handlePaste.bind(this); this.syncAutoFill = this.syncAutoFill.bind(this); } componentDidMount() { this.syncAutoFill(); } componentDidUpdate(prevProps: ImageProps) { const props = this.props; if (prevProps.value !== props.value && this.emitValue !== props.value) { const value: string | Array | FileValue = props.value; const multiple = props.multiple; const joinValues = props.joinValues; const delimiter = props.delimiter as string; let files: Array = []; if (value) { files = (Array.isArray(value) ? value : joinValues && typeof value === 'string' ? (value as string).split(delimiter) : [value] ) .map(item => { let obj = ImageControl.valueToFile(item, props) as FileValue; let org; if ( obj && (org = find( this.files, item => (item as FileValue).value === obj.value )) ) { obj = { ...org, ...obj, id: org.id || obj.id }; } return obj; }) .filter(item => item); } this.setState( { files: (this.files = files) }, this.syncAutoFill ); } if (prevProps.crop !== props.crop) { this.setState({ crop: this.buildCrop(props) }); } } componentWillUnmount() { this.unmounted = true; } buildCrop(props: ImageProps) { let crop = props.crop; const __ = this.props.translate; if (crop && props.multiple) { props.env && props.env.alert && props.env.alert(__('Image.configError')); return null; } if (crop === true) { crop = {}; } if (crop) { crop = { aspectRatio: undefined, // 默认不限制 guides: true, dragMode: 'move', viewMode: 1, rotatable: false, scalable: false, ...crop }; } return crop; } handleDropRejected( rejectedFiles: FileRejection[], evt: React.DragEvent ) { if (evt.type !== 'change' && evt.type !== 'drop') { return; } const {multiple, env, accept, translate: __} = this.props; const files = rejectedFiles.map(fileRejection => ({ ...fileRejection.file, state: 'invalid', id: guid(), name: fileRejection.file.name })); // this.setState({ // files: this.files = multiple // ? this.files.concat(files) // : this.files.length // ? this.files // : files.slice(0, 1) // }); env.alert( __('File.invalidType', { files: files.map((file: any) => `「${file.name}」`).join(' '), accept }) ); } startUpload(retry: boolean = false) { if (this.state.uploading) { return; } this.setState( { uploading: true, locked: true, files: (this.files = this.files.map(file => { if (retry && file.state === 'error') { file.state = 'pending'; file.progress = 0; } return file; })) }, this.tick ); } toggleUpload() { return this.state.uploading ? this.stopUpload() : this.startUpload(); } stopUpload() { if (!this.state.uploading) { return; } this.setState({ uploading: false }); } tick() { if (this.current || !this.state.uploading) { return; } const env = this.props.env; const __ = this.props.translate; const file = find(this.files, item => item.state === 'pending') as FileX; if (file) { this.current = file; file.state = 'uploading'; this.setState( { files: (this.files = this.files.concat()) }, () => this.sendFile( file as FileX, (error, file, obj) => { const files = this.files.concat(); const idx = files.indexOf(file); if (!~idx) { return; } let newFile: FileX | FileValue = file; if (error) { newFile.state = file.state !== 'uploading' ? file.state : 'error'; newFile.error = error; if (!this.props.multiple && newFile.state === 'invalid') { files.splice(idx, 1); this.current = null; return this.setState( { files: (this.files = files), error: error }, this.tick ); } env.notify('error', error || __('File.errorRetry')); } else { newFile = { name: file.name, ...obj, preview: file.preview } as FileValue; } files.splice(idx, 1, newFile); this.current = null; this.setState( { files: (this.files = files) }, this.tick ); }, progress => { const files = this.files.concat(); const idx = files.indexOf(file); if (!~idx) { return; } // file 是个非 File 对象,先不copy了直接改。 file.progress = progress; this.setState({ files: (this.files = files) }); } ) ); } else { this.setState( { uploading: false, locked: false }, () => { this.onChange(!!this.resolve); if (this.resolve) { this.resolve( this.files.some(file => file.state === 'error') ? __('File.errorRetry') : null ); this.resolve = undefined; } } ); } } removeFile(file: FileValue, index: number) { const files = this.files.concat(); files.splice(index, 1); this.setState( { files: (this.files = files) }, this.onChange ); } previewImage(file: FileX, index: number, e: React.MouseEvent) { const {onImageEnlarge} = this.props; if (onImageEnlarge) { const files = this.files; e.preventDefault(); onImageEnlarge({ src: (file.preview || file.url) as string, originalSrc: (file.preview || file.url) as string, index, list: files.map(file => ({ src: (file.preview || file.url) as string, originalSrc: (file.preview || file.url) as string, title: file.name || getNameFromUrl(file.value || file.url) })) }); } } editImage(index: number) { const files = this.files; this.setState({ cropFile: { preview: files[index].preview || (files[index].url as string), state: 'init' } }); } onChange(changeImmediately?: boolean) { const { multiple, onChange, joinValues, extractValue, delimiter, valueField } = this.props; const files = this.files.filter( file => file.state == 'uploaded' || file.state == 'init' ); let newValue: any = files.length ? joinValues ? files[0].value : files[0] : ''; if (multiple) { newValue = joinValues ? files.map(item => item.value).join(delimiter) : extractValue ? files.map(item => item.value) : files; } else { newValue = joinValues ? newValue.value || newValue : extractValue ? newValue[valueField || 'value'] : newValue; } onChange((this.emitValue = newValue || ''), undefined, changeImmediately); this.syncAutoFill(); } syncAutoFill() { const {autoFill, multiple, onBulkChange} = this.props; if (!isEmpty(autoFill)) { const files = this.state.files.filter( file => ~['uploaded', 'init', 'ready'].indexOf(file.state as string) ); const toSync = dataMapping( autoFill, multiple ? { items: files } : files[0] ); onBulkChange(toSync); } } handleSelect() { this.dropzone.current && this.dropzone.current.open(); } handleRetry(index: number) { const files = this.files.concat(); const file = files[index]; if (file.state !== 'invalid' && file.state !== 'error') { return; } file.state = 'pending'; file.progress = 0; this.setState( { files: files }, this.startUpload ); } handleDrop(files: Array) { const {multiple, crop} = this.props; if (crop && !multiple) { const file = files[0] as FileValue; if (!file.preview || !file.url) { file.preview = window.URL.createObjectURL(file); } return this.setState({ cropFile: file }); } this.addFiles(files); } handlePaste(e: React.ClipboardEvent) { const event = e.nativeEvent as any; const files: Array = []; const items = event.clipboardData.items; const accept = this.props.accept || '*'; [].slice.call(items).forEach((item: DataTransferItem) => { let blob: FileX; if ( item.kind !== 'file' || !(blob = item.getAsFile() as File) || !accepts(blob, accept) ) { return; } blob.id = guid(); files.push(blob); }); this.handleDrop(files); } handleCrop() { this.cropper.current!.getCroppedCanvas().toBlob((file: File) => { this.addFiles([file]); this.setState({ cropFile: undefined, locked: false, lockedReason: '' }); }); } cancelCrop() { this.setState( { cropFile: undefined, locked: false, lockedReason: '' }, this.onChange ); } addFiles(files: Array) { if (!files.length) { return; } const {multiple, maxLength, maxSize, accept, translate: __} = this.props; let currentFiles = this.files; if (!multiple && currentFiles.length) { currentFiles = []; } const allowed = (multiple ? maxLength ? maxLength : files.length + currentFiles.length : 1) - currentFiles.length; const inputFiles: Array = []; [].slice.call(files, 0, allowed).forEach((file: FileX) => { if (maxSize && file.size > maxSize) { this.props.env.alert( __('File.maxSize', { filename: file.name, actualSize: ImageControl.formatFileSize(file.size), maxSize: ImageControl.formatFileSize(maxSize) }) ); return; } file.state = 'pending'; file.id = guid(); if (!file.preview || !file.url) { file.preview = URL.createObjectURL(file); } inputFiles.push(file); }); if (!inputFiles.length) { return; } this.setState( { error: undefined, files: (this.files = currentFiles.concat(inputFiles)), locked: true }, () => { const {autoUpload} = this.props; if (autoUpload) { this.startUpload(); } } ); } sendFile( file: FileX, cb: (error: null | string, file: FileX, obj?: FileValue) => void, onProgress: (progress: number) => void ) { const {limit, translate: __} = this.props; if (!limit) { return this._upload(file, cb, onProgress); } const image = new Image(); image.onload = () => { const width = image.width; const height = image.height; let error = ''; if ( (limit.width && limit.width != width) || (limit.height && limit.height != height) ) { error = __('Image.sizeNotEqual', { info: ImageControl.sizeInfo(limit.width, limit.height, __) }); } else if ( (limit.maxWidth && limit.maxWidth < width) || (limit.maxHeight && limit.maxHeight < height) ) { error = __('Image.limitMax', { info: ImageControl.sizeInfo(limit.maxWidth, limit.maxHeight, __) }); } else if ( (limit.minWidth && limit.minWidth > width) || (limit.minHeight && limit.minHeight > height) ) { error = __('Image.limitMin', { info: ImageControl.sizeInfo(limit.minWidth, limit.minHeight, __) }); } else if ( limit.aspectRatio && Math.abs(width / height - limit.aspectRatio) > 0.01 ) { error = __(limit.aspectRatioLabel || 'Image.limitRatio', { ratio: (+limit.aspectRatio).toFixed(2) }); } if (error) { file.state = 'invalid'; cb(error, file); } else { this._upload(file, cb, onProgress); } }; image.src = (file.preview || file.url) as string; } _upload( file: Blob, cb: (error: null | string, file: Blob, obj?: FileValue) => void, onProgress: (progress: number) => void ) { const __ = this.props.translate; this._send(file, this.props.receiver as string, {}, onProgress) .then((ret: Payload) => { if (ret.status) { throw new Error(ret.msg || __('File.errorRetry')); } const obj: FileValue = { ...ret.data, state: 'uploaded' }; obj.value = obj.value || obj.url; cb(null, file, obj); }) .catch(error => cb(error.message || __('File.errorRetry'), file)); } _send( file: Blob, receiver: string, params: object, onProgress: (progress: number) => void ): Promise { const fd = new FormData(); const data = this.props.data; const api = buildApi(receiver, createObject(data, params), { method: 'post' }); const fileField = this.props.fileField || 'file'; const idx = api.url.indexOf('?'); if (~idx && params) { params = { ...qs.parse(api.url.substring(idx + 1)), ...params }; api.url = api.url.substring(0, idx) + '?' + qsstringify(params); } else if (params) { api.url += '?' + qsstringify(params); } if (api.data) { qsstringify(api.data) .split('&') .forEach(item => { let parts = item.split('='); fd.append(parts[0], decodeURIComponent(parts[1])); }); } // Note: File类型字段放在后面,可以支持第三方云存储鉴权 fd.append(fileField, file, (file as File).name); const env = this.props.env; if (!env || !env.fetcher) { throw new Error('fetcher is required'); } return env.fetcher(api, fd, { method: 'post', onUploadProgress: (event: {loaded: number; total: number}) => onProgress(event.loaded / event.total) }); } handleClick() { (this.refs.dropzone as any).open(); } handleImageLoaded(index: number, e: React.UIEvent) { const imgDom = e.currentTarget; const img = new Image(); img.onload = () => { delete (img as any).onload; const files = this.files.concat(); const file = files[index]; if (!file) { return; } file.info = { ...file.info, width: img.width, height: img.height }; files.splice(index, 1, file); const needUploading = !!( this.current || find(files, file => file.state === 'pending') ); this.unmounted || this.setState( { files: (this.files = files) }, !needUploading ? this.onChange : undefined ); }; img.src = imgDom.src; } handleFrameImageLoaded(e: React.UIEvent) { const imgDom = e.currentTarget; const img = new Image(); const {clientHeight} = this.frameImageRef.current; const _this = this; img.onload = function () { const ratio = (this as any).width / (this as any).height; const finalWidth = (ratio * (clientHeight - 2)).toFixed(2); _this.setState({ frameImageWidth: +finalWidth }); }; img.src = imgDom.src; } validate(): any { const __ = this.props.translate; if (this.state.locked && this.state.lockedReason) { return this.state.lockedReason; } else if (this.state.cropFile) { return new Promise(resolve => { this.resolve = resolve; this.handleCrop(); }); } else if ( this.state.uploading || this.files.some(item => item.state === 'pending') ) { return new Promise(resolve => { this.resolve = resolve; this.startUpload(); }); } else if (this.files.some(item => item.state === 'error')) { return __('File.errorRetry'); } } render() { const { className, classnames: cx, placeholder, disabled, multiple, accept, maxLength, autoUpload, hideUploadButton, thumbMode, thumbRatio, reCropable, frameImage, fixedSize, fixedSizeClassName, translate: __ } = this.props; const { files, error, crop, uploading, cropFile, frameImageWidth } = this.state; let frameImageStyle: any = {}; if (fixedSizeClassName && frameImageWidth && fixedSize) { frameImageStyle.width = frameImageWidth; } const filterFrameImage = filter(frameImage, this.props.data, '| raw'); const hasPending = files.some(file => file.state == 'pending'); return (
{cropFile ? (
...
}>
) : ( {({ getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject, isFocused }) => (
{isDragActive || isDragAccept || isDragReject ? (
{__('Image.dragDrop')}
) : ( <> {files && files.length ? files.map((file, key) => (
{file.state === 'invalid' || file.state === 'error' ? ( <>

{__('File.repick')}

) : file.state === 'uploading' ? ( <>

{__('File.uploading')}

) : ( <> {file.info ? ( [
{file.info.width} x{' '} {file.info.height}
, file.info.len ? (
{ImageControl.formatFileSize( file.info.len )}
) : null ] ) : (
...
)} {!!crop && reCropable !== false && !disabled ? ( ) : null} {!disabled ? ( ) : null} } /> )}
)) : null} {(multiple && (!maxLength || files.length < maxLength)) || (!multiple && !files.length) ? ( ) : null} {!autoUpload && !hideUploadButton && files.length ? ( ) : null} {error ? (
{error}
) : null} )}
)}
)} ); } } @FormItem({ type: 'input-image', sizeMutable: false }) export class ImageControlRenderer extends ImageControl {}