/** * 裁剪头像上传 */ import React, { Component } from 'react'; import axios, { AxiosRequestConfig } from 'axios'; import isString from 'lodash/isString'; import Cropper from 'react-easy-crop'; import Button, { ButtonProps } from '../button'; import Icon from '../icon'; import Modal, { ModalProps } from '../modal'; import message from '../message'; import Upload, { UploadProps } from '../upload'; import LocaleReceiver from '../locale-provider/LocaleReceiver'; import defaultLocale from '../locale-provider/default'; import { imageCrop } from '../locale-provider'; import ConfigContext, { ConfigContextValue } from '../config-provider/ConfigContext'; import { MAX_ZOOM, MIN_ZOOM, ZOOM_STEP } from '.'; const Dragger = Upload.Dragger; const { round } = Math; const ButtonGroup = Button.Group; function rotateFlag(rotate): boolean { return (rotate / 90) % 2 !== 0; } export interface Limit { size: number; type: string; } export interface AvatarArea { rotate: number; startX: number; startY: number; endX: number; endY: number; file?: File; } export interface AvatarUploadProps { visible: boolean; // 上传图片模态框的显示状态 onClose?: (visible: boolean) => void; // 模态框关闭时的回调 onUploadOk?: (res: any) => void; // 成功上传时的回调 uploadUrl?: string; // 上传链接 uploadFaild?: () => void; // 上传失败 uploadError?: (error: any) => void; // 上传服务器错误 handleUpload?: (area: AvatarArea) => void; // 点击上传 cropComplete?: (imageState: any) => void; // 裁剪完成 title?: string | React.ReactElement; // 上传头像标题 previewTitle?: string | React.ReactElement; // 头像预览标题 reloadTitle?: string | React.ReactElement;// 重新上传标题 uploadProps?: UploadProps; // 上传配置 modalProps?: ModalProps; // 模态框的配置 limit: Limit; // 限制内容 previewList: number[]; // 定义预览的大小 editorWidth: number; // 裁剪容器宽度 editorHeight: number; // 裁剪容器高度 rectSize: number; // 裁剪区域大小 axiosConfig?: AxiosRequestConfig; prefixCls?: string; // 自定义样式前缀 } let Avatarlocale = defaultLocale.imageCrop; export default class AvatarUploader extends Component<AvatarUploadProps, any> { static get contextType(): typeof ConfigContext { return ConfigContext; } static defaultProps = { limit: { type: 'jpeg,png,jpg', size: 1024, }, previewList: [80, 48, 34], editorWidth: 380, editorHeight: 380, rectSize: 280, }; context: ConfigContextValue; constructor(props, context: ConfigContextValue) { super(props, context); const { rectSize } = props; this.state = { submitting: false, img: null, file: '', imageStyle: { width: 0, height: 0 }, crop: { x: 0, y: 0, }, rotate: 0, zoom: 1, cropSize: rectSize, }; } zoomImage = (type): void => { let { zoom } = this.state; const { imageStyle: { width, height }, cropSize } = this.state; switch (type) { case 'add': { const newZoomVal = (zoom * 10 + ZOOM_STEP * 10) / 10; zoom = newZoomVal >= MAX_ZOOM ? MAX_ZOOM : newZoomVal; this.setState({ zoom }); break; } case 'sub': { const newZoomVal = (zoom * 10 - ZOOM_STEP * 10) / 10; zoom = newZoomVal <= MIN_ZOOM ? MIN_ZOOM : newZoomVal; this.setState({ zoom }); break; } case 'init': { const x = (width - cropSize) / 2 / width; const y = (height - cropSize) / 2 / width; this.setState({ zoom: 1, rotate: 0, crop: { x, y } }); } break; default: break; } }; handleOk = () => { const { x, y, size, rotate, file, imageStyle: { width, height }, img: { naturalWidth } } = this.state; const { uploadUrl, uploadFaild, uploadError, handleUpload, axiosConfig } = this.props; const flag = rotateFlag(rotate); const scale = naturalWidth / width; const startX = flag ? x - ((width - height) / 2) : x; const startY = flag ? y + ((width - height) / 2) : y; const QsData: AvatarArea = { rotate, startX: round(startX * scale), startY: round(startY * scale), endX: round(size * scale), endY: round(size * scale), }; const qs = JSON.stringify(QsData); const data = new FormData(); data.append('file', file); this.setState({ submitting: true }); if (uploadUrl) { let config; if (axiosConfig) { config = axiosConfig; } axios.post<any, any>(`${uploadUrl}?${qs}`, data, config) .then((res) => { if (res.success) { this.uploadOk(res); } else { message.error(Avatarlocale.avatarUploadError); this.setState({ submitting: false }); if (uploadFaild) { uploadFaild(); } } }) .catch((error) => { message.error(`${Avatarlocale.avatarServerError}${error}`); this.setState({ submitting: false }); if (uploadError) { uploadError(error); } }); } if (handleUpload) { QsData.file = file; handleUpload(QsData); } }; close() { const { onClose } = this.props; this.setState({ img: null, crop: { x: 0, y: 0, }, rotate: 0, zoom: 1, }); if (onClose) { onClose(false); } } uploadOk(res) { const { onUploadOk } = this.props; this.setState({ img: null, submitting: false, }, () => { if (onUploadOk) { onUploadOk(res); } }); } handleCancel = () => { this.close(); }; initImageSize(img, rotate = 0) { const { editorWidth, editorHeight, rectSize } = this.props; const { naturalWidth, naturalHeight } = img; const flag = rotateFlag(rotate); let width = flag ? naturalHeight : naturalWidth; let height = flag ? naturalWidth : naturalHeight; if (width < rectSize || height < rectSize) { if (width > height) { width = (width / height) * rectSize; height = rectSize; } else { height = (height / width) * rectSize; width = rectSize; } } else if (width > editorWidth || height > editorHeight) { if (width / editorWidth > height / editorHeight) { height = (height / width) * editorWidth; width = editorWidth; } else { width = (width / height) * editorHeight; height = editorHeight; } } if (flag) { const tmp = width; width = height; height = tmp; } const size = Math.min(rectSize, width, height); this.setState({ img, imageStyle: { width, height, top: (editorHeight - height) / 2, left: (editorWidth - width) / 2, transform: `rotate(${rotate}deg)`, }, size, cropSize: size, x: (width - size) / 2, y: (height - size) / 2, rotate, }); } onComplete(imgState): void { const { zoom, imageStyle: { width, height }, cropSize } = this.state; let { x, y } = imgState; x = Math.ceil(x * width / 100); y = Math.ceil(y * height / 100); const imageState = { x, y, size: cropSize / zoom }; const { cropComplete } = this.props; this.setState(imageState); if (cropComplete) { cropComplete(imageState); } } loadImage(src): void { if (typeof window !== 'undefined') { const img = new Image(); img.src = src; img.onload = (): void => { this.initImageSize(img); }; } } getPreviewProps(previewSize): object { const { size, x, y, img: { src }, rotate, imageStyle: { width, height } } = this.state; const previewScale = previewSize / size; let radius = (rotate % 360) / 90; let px = -x; let py = -y; if (radius < 0) radius += 4; if (radius === 1) { py = ((x + ((height - width) / 2)) - height) + size; px = ((height - width) / 2) - y; } else if (radius === 2) { px = (x - width) + size; py = (y - height) + size; } else if (radius === 3) { px = ((y + ((width - height) / 2)) - width) + size; py = ((width - height) / 2) - x; } return { style: { width: previewSize, height: previewSize, backgroundImage: `url('${src}')`, backgroundSize: `${width * previewScale}px ${height * previewScale}px`, backgroundPosition: `${px * previewScale}px ${py * previewScale}px`, transform: `rotate(${rotate}deg)`, }, }; } renderPreviewItem(previewSizeList) { const { prefixCls: customizePrefixCls } = this.props; const { getPrefixCls } = this.context; const prefixCls = getPrefixCls('avatar-crop-edit', customizePrefixCls); return previewSizeList.map((itemSize) => ( <div key={itemSize} className={`${prefixCls}-preview-item`}> <i {...this.getPreviewProps(itemSize)} /> <p>{`${itemSize}*${itemSize}`}</p> </div> )); } renderEditor(props) { const { img, rotate, zoom, crop, cropSize } = this.state; const { prefixCls: customizePrefixCls, previewList, editorWidth, editorHeight, previewTitle, reloadTitle } = this.props; const { getPrefixCls } = this.context; const { src } = img; const style: object = { width: editorWidth, height: editorHeight, position: 'relative' }; const isMinZoom = zoom === MIN_ZOOM; const isMaxZoom = zoom === MAX_ZOOM; const prefixCls = getPrefixCls('avatar-crop-edit', customizePrefixCls); const previewTitleFlag = isString(previewTitle) || React.isValidElement(previewTitle); const renderPreviewTitle = (): React.ReactElement | null => { if (!previewTitleFlag || !previewTitle) return null; if (isString(previewTitle)) { return ( <h5 className={`${prefixCls}-preview-title`}> <span>{previewTitle}</span> </h5> ); } return previewTitle; }; return ( <div> <div className={`${prefixCls}-wraper`}> <div className={`${prefixCls}-edit`} style={style}> <Cropper image={src} crop={crop} showGrid={false} cropSize={{ width: cropSize, height: cropSize }} zoom={zoom} minZoom={MIN_ZOOM} maxZoom={MAX_ZOOM} restrictPosition={false} rotation={rotate} aspect={1 / 1} onCropChange={(crop): void => this.setState({ crop })} onCropComplete={({ x, y }): void => this.onComplete({ x, y, cropSize })} onZoomChange={(zoom): void => { this.setState({ zoom }); }} /> </div> <div className={`${prefixCls}-preview`}> {renderPreviewTitle()} {this.renderPreviewItem(previewList)} </div> </div> <div className={`${prefixCls}-button`} style={{ width: editorWidth }}> <ButtonGroup> <Button funcType="raised" icon="zoom_in" disabled={isMaxZoom} onClick={(): void => this.zoomImage('add')} /> <Button funcType="raised" icon="zoom_out" disabled={isMinZoom} onClick={(): void => this.zoomImage('sub')} /> </ButtonGroup> <Button funcType="raised" icon="play_90" onClick={(): void => this.setState({ rotate: (rotate + 90) >= 360 ? 0 : (rotate + 90) })} /> <Button funcType="raised" onClick={(): void => this.zoomImage('init')}>1:1</Button> <Upload {...props}> <Button funcType="raised" icon="file_upload"> <span>{reloadTitle || Avatarlocale.reUpload}</span> </Button> </Upload> </div> </div> ); } getUploadProps(): UploadProps { const { limit: { size: limitSize, type }, uploadProps } = this.props; const typeLimit = type.split(',').map((item) => `image/${item}`).join(','); return { multiple: false, name: 'file', accept: typeLimit, headers: { Authorization: `bearer`, }, showUploadList: false, ...uploadProps, beforeUpload: (file) => { const { size } = file; if (size > limitSize * 1024) { message.warning(Avatarlocale.imageTooLarge); return false; } this.setState({ file }); const windowURL = window.URL || window.webkitURL; if (windowURL && windowURL.createObjectURL) { this.loadImage(windowURL.createObjectURL(file)); return false; } return false; }, onChange: ({ file }) => { const { status, response } = file; if (status === 'done') { this.loadImage(response); } else if (status === 'error') { message.error(Avatarlocale.imageUploadError); } }, }; } renderContainer() { const { prefixCls: customizePrefixCls, limit: { size: limitSize, type } } = this.props; const { img } = this.state; const { getPrefixCls } = this.context; const prefixCls = getPrefixCls('avatar-crop', customizePrefixCls); const props = this.getUploadProps(); return img ? ( this.renderEditor(props) ) : ( <Dragger className={`${prefixCls}-dragger`} {...props}> <Icon type="inbox" /> <h3 className={`${prefixCls}-dragger-text`}> <span>{Avatarlocale.imageDragHere}</span> </h3> <h4 className={`${prefixCls}-dragger-hint`}> <span>{`${Avatarlocale.pleaseUpload}${limitSize / 1024}M,${Avatarlocale.uploadType}${type}${Avatarlocale.picture}`}</span> </h4> </Dragger> ); } render() { const { visible, modalProps, title } = this.props; const { img, submitting } = this.state; const cancelButtonProps: ButtonProps = { disabled: submitting, funcType: 'raised' }; const okButtonProps: ButtonProps = { funcType: 'raised', type: 'primary', disabled: !img, loading: submitting }; return ( <LocaleReceiver componentName="imageCrop" defaultLocale={defaultLocale.imageCrop}> {(locale: imageCrop) => { Avatarlocale = locale || defaultLocale.imageCrop; return ( <Modal title={title || <span>{Avatarlocale.changeAvatar}</span>} className="avatar-modal" visible={visible} width={600} closable maskClosable={false} onOk={this.handleOk} onCancel={this.handleCancel} okButtonProps={okButtonProps} cancelButtonProps={cancelButtonProps} {...modalProps} > {this.renderContainer()} </Modal> ); }} </LocaleReceiver> ); } }