feat: 增加 AvatarPicker 头像选择与裁剪工具

pull/13/head
moonrailgun 4 years ago
parent d6300c7750
commit 23c4287109

@ -21,6 +21,7 @@
"p-min-delay": "^4.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-easy-crop": "^3.5.2",
"react-redux": "^7.2.4",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",

@ -0,0 +1,81 @@
import React, { useRef, useState } from 'react';
import { closeModal, openModal } from './Modal';
import { showToasts, t } from 'tailchat-shared';
import { Avatar } from 'antd';
import { Icon } from '@iconify/react';
import { ModalAvatarCropper } from './modals/AvatarCropper';
interface AvatarPickerProps {
className?: string;
imageUrl?: string; // 初始image url, 仅children为空时生效
onChange?: (blobUrl: string) => void;
disabled?: boolean; // 禁用选择
}
/**
*
*/
export const AvatarPicker: React.FC<AvatarPickerProps> = React.memo((props) => {
const fileRef = useRef<HTMLInputElement>(null);
const [cropUrl, setCropUrl] = useState<string>(props.imageUrl || ''); // 裁剪后并使用的url
const handleSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const reader = new FileReader();
reader.addEventListener('load', () => {
if (reader.result) {
const key = openModal(
<ModalAvatarCropper
imageUrl={reader.result.toString()}
onConfirm={(croppedImageBlobUrl) => {
closeModal(key);
setCropUrl(croppedImageBlobUrl);
if (typeof props.onChange === 'function') {
props.onChange(croppedImageBlobUrl);
}
}}
/>,
{
maskClosable: false,
closable: true,
}
);
} else {
showToasts(t('文件读取失败'), 'error');
}
});
reader.readAsDataURL(e.target.files[0]);
// 清理选中状态
e.target.files = null;
e.target.value = '';
}
};
return (
<div className={props.className}>
<div
className="cursor-pointer inline-block relative"
onClick={() => !props.disabled && fileRef.current?.click()}
>
<input
ref={fileRef}
type="file"
className="hidden"
onChange={handleSelectFile}
accept="image/*"
/>
{props.children ? (
props.children
) : (
<Avatar
size={64}
icon={<Icon className="anticon" icon="mdi:account" />}
src={cropUrl}
/>
)}
</div>
</div>
);
});
AvatarPicker.displayName = 'AvatarPicker';

@ -98,7 +98,7 @@ export const Modal: React.FC<ModalProps> = React.memo((props) => {
<Icon
className="absolute right-2.5 top-3.5 text-xl z-10"
icon="mdi:close"
onClick={handleClose}
onClick={closeModal}
/>
)}
{props.children}

@ -0,0 +1,139 @@
import Cropper from 'react-easy-crop';
import type { Area } from 'react-easy-crop/types';
import _isNil from 'lodash/isNil';
import { showToasts, t } from 'tailchat-shared';
import React, { useRef, useState } from 'react';
import { Button } from 'antd';
import { ModalWrapper } from '../Modal';
const createImage = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (error) => reject(error));
image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox
image.src = url;
});
function getRadianAngle(degreeValue: number) {
return (degreeValue * Math.PI) / 180;
}
let fileUrlTemp: string | null = null; // 缓存裁剪后的图片url
/**
*
*
* @param image
* @param crop
* @param rotation
* @param fileName
* @returns blob url
*/
function getCroppedImg(
image: HTMLImageElement,
crop: Area,
rotation = 0,
fileName = 'newFile.jpeg'
): Promise<string> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!_isNil(ctx)) {
const maxSize = Math.max(image.width, image.height);
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
// set each dimensions to double largest dimension to allow for a safe area for the
// image to rotate in without being clipped by canvas context
canvas.width = safeArea;
canvas.height = safeArea;
// translate canvas context to a central location on image to allow rotating around the center.
ctx.translate(safeArea / 2, safeArea / 2);
ctx.rotate(getRadianAngle(rotation));
ctx.translate(-safeArea / 2, -safeArea / 2);
// draw rotated image and store data.
ctx.drawImage(
image,
safeArea / 2 - image.width * 0.5,
safeArea / 2 - image.height * 0.5
);
const data = ctx.getImageData(0, 0, safeArea, safeArea);
// set canvas width to final desired crop size - this will clear existing context
canvas.width = crop.width;
canvas.height = crop.height;
// paste generated rotate image with correct offsets for x,y crop values.
ctx.putImageData(
data,
Math.round(0 - safeArea / 2 + image.width * 0.5 - crop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - crop.y)
);
}
return new Promise<string>((resolve) => {
try {
canvas.toBlob((blob) => {
if (!blob) {
// reject(new Error('Canvas is empty'));
console.error('Canvas is empty');
return;
}
(blob as any).name = fileName;
if (typeof fileUrlTemp === 'string') {
window.URL.revokeObjectURL(fileUrlTemp);
}
fileUrlTemp = window.URL.createObjectURL(blob);
resolve(fileUrlTemp);
}, 'image/jpeg');
} catch (err) {
console.error(err);
showToasts('无法正确生成图片, 可能是因为您的浏览器版本过旧', 'error');
}
});
}
/**
*
*/
export const ModalAvatarCropper: React.FC<{
imageUrl: string;
onConfirm: (croppedImageBlobUrl: string) => void;
}> = React.memo((props) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [area, setArea] = useState<Area>({ width: 0, height: 0, x: 0, y: 0 });
const handleConfirm = async () => {
const blobUrl = await getCroppedImg(
await createImage(props.imageUrl),
area
);
props.onConfirm(blobUrl);
};
return (
<ModalWrapper
className="flex flex-col"
style={{ width: '80vw', height: '80vh' }}
>
<div className="flex-1 relative mb-4">
<Cropper
image={props.imageUrl}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={(_, area) => setArea(area)}
/>
</div>
<Button type="primary" onClick={handleConfirm}>
{t('确认')}
</Button>
</ModalWrapper>
);
});
ModalAvatarCropper.displayName = 'ModalAvatarCropper';

@ -6447,6 +6447,11 @@ normalize-range@^0.1.2:
resolved "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
normalize-wheel@^1.0.1:
version "1.0.1"
resolved "https://registry.npm.taobao.org/normalize-wheel/download/normalize-wheel-1.0.1.tgz#aec886affdb045070d856447df62ecf86146ec45"
integrity sha1-rsiGr/2wRQcNhWRH32Ls+GFG7EU=
now-and-later@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/now-and-later/-/now-and-later-2.0.1.tgz#8e579c8685764a7cc02cb680380e94f43ccb1f7c"
@ -7598,6 +7603,14 @@ react-dom@^17.0.2:
object-assign "^4.1.1"
scheduler "^0.20.2"
react-easy-crop@^3.5.2:
version "3.5.2"
resolved "https://registry.nlark.com/react-easy-crop/download/react-easy-crop-3.5.2.tgz#1fc65249e82db407c8c875159589a8029a9b7a06"
integrity sha1-H8ZSSegttAfIyHUVlYmoApqbegY=
dependencies:
normalize-wheel "^1.0.1"
tslib "2.0.1"
react-error-boundary@^3.1.0:
version "3.1.3"
resolved "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.3.tgz#276bfa05de8ac17b863587c9e0647522c25e2a0b"
@ -9091,6 +9104,11 @@ tsconfig-paths@^3.9.0:
minimist "^1.2.0"
strip-bom "^3.0.0"
tslib@2.0.1:
version "2.0.1"
resolved "https://registry.nlark.com/tslib/download/tslib-2.0.1.tgz?cache=0&sync_timestamp=1628722556410&other_urls=https%3A%2F%2Fregistry.nlark.com%2Ftslib%2Fdownload%2Ftslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e"
integrity sha1-QQ6w0RPltjVkkO7HSWA3JbAhtD4=
tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0:
version "1.14.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"

Loading…
Cancel
Save