mirror of https://github.com/msgbyte/tailchat
feat: 增加 AvatarPicker 头像选择与裁剪工具
parent
d6300c7750
commit
23c4287109
@ -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';
|
@ -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';
|
Loading…
Reference in New Issue