|
|
@@ -0,0 +1,269 @@
|
|
|
+import React, { useState, useRef, useEffect } from 'react';
|
|
|
+import { compressImage } from '@/client/utils/upload';
|
|
|
+
|
|
|
+// 水墨风格色彩系统
|
|
|
+const COLORS = {
|
|
|
+ ink: {
|
|
|
+ light: '#f5f3f0',
|
|
|
+ medium: '#d4c4a8',
|
|
|
+ dark: '#8b7355',
|
|
|
+ deep: '#3a2f26',
|
|
|
+ },
|
|
|
+ accent: {
|
|
|
+ green: '#5c7c5c',
|
|
|
+ blue: '#4a6b7c',
|
|
|
+ },
|
|
|
+ text: {
|
|
|
+ primary: '#2f1f0f',
|
|
|
+ secondary: '#5d4e3b',
|
|
|
+ light: '#8b7355',
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+interface AvatarCropperProps {
|
|
|
+ imageUrl: string;
|
|
|
+ onCrop: (croppedBlob: Blob) => void;
|
|
|
+ onCancel: () => void;
|
|
|
+ size?: number;
|
|
|
+}
|
|
|
+
|
|
|
+export const AvatarCropper: React.FC<AvatarCropperProps> = ({
|
|
|
+ imageUrl,
|
|
|
+ onCrop,
|
|
|
+ onCancel,
|
|
|
+ size = 300,
|
|
|
+}) => {
|
|
|
+ const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
+ const imageRef = useRef<HTMLImageElement>(null);
|
|
|
+ const [crop, setCrop] = useState({ x: 0, y: 0, size: 100 });
|
|
|
+ const [isDragging, setIsDragging] = useState(false);
|
|
|
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const img = new Image();
|
|
|
+ img.onload = () => {
|
|
|
+ const canvas = canvasRef.current;
|
|
|
+ if (!canvas) return;
|
|
|
+
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
+ if (!ctx) return;
|
|
|
+
|
|
|
+ // 设置画布大小
|
|
|
+ canvas.width = size;
|
|
|
+ canvas.height = size;
|
|
|
+
|
|
|
+ // 计算缩放比例
|
|
|
+ const scale = Math.min(size / img.width, size / img.height);
|
|
|
+ const scaledWidth = img.width * scale;
|
|
|
+ const scaledHeight = img.height * scale;
|
|
|
+
|
|
|
+ // 居中绘制
|
|
|
+ const offsetX = (size - scaledWidth) / 2;
|
|
|
+ const offsetY = (size - scaledHeight) / 2;
|
|
|
+
|
|
|
+ ctx.clearRect(0, 0, size, size);
|
|
|
+ ctx.drawImage(img, offsetX, offsetY, scaledWidth, scaledHeight);
|
|
|
+
|
|
|
+ // 设置初始裁剪区域
|
|
|
+ const initialSize = Math.min(scaledWidth, scaledHeight) * 0.8;
|
|
|
+ setCrop({
|
|
|
+ x: (size - initialSize) / 2,
|
|
|
+ y: (size - initialSize) / 2,
|
|
|
+ size: initialSize,
|
|
|
+ });
|
|
|
+ };
|
|
|
+ img.src = imageUrl;
|
|
|
+ }, [imageUrl, size]);
|
|
|
+
|
|
|
+ const handleMouseDown = (e: React.MouseEvent) => {
|
|
|
+ const rect = canvasRef.current?.getBoundingClientRect();
|
|
|
+ if (!rect) return;
|
|
|
+
|
|
|
+ const x = e.clientX - rect.left;
|
|
|
+ const y = e.clientY - rect.top;
|
|
|
+
|
|
|
+ // 检查是否在裁剪区域内
|
|
|
+ if (
|
|
|
+ x >= crop.x && x <= crop.x + crop.size &&
|
|
|
+ y >= crop.y && y <= crop.y + crop.size
|
|
|
+ ) {
|
|
|
+ setIsDragging(true);
|
|
|
+ setDragStart({ x: x - crop.x, y: y - crop.y });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleMouseMove = (e: React.MouseEvent) => {
|
|
|
+ if (!isDragging) return;
|
|
|
+
|
|
|
+ const rect = canvasRef.current?.getBoundingClientRect();
|
|
|
+ if (!rect) return;
|
|
|
+
|
|
|
+ const x = e.clientX - rect.left;
|
|
|
+ const y = e.clientY - rect.top;
|
|
|
+
|
|
|
+ setCrop(prev => ({
|
|
|
+ ...prev,
|
|
|
+ x: Math.max(0, Math.min(x - dragStart.x, size - prev.size)),
|
|
|
+ y: Math.max(0, Math.min(y - dragStart.y, size - prev.size)),
|
|
|
+ }));
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleMouseUp = () => {
|
|
|
+ setIsDragging(false);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleCrop = async () => {
|
|
|
+ const canvas = canvasRef.current;
|
|
|
+ const img = imageRef.current;
|
|
|
+ if (!canvas || !img) return;
|
|
|
+
|
|
|
+ // 创建裁剪画布
|
|
|
+ const cropCanvas = document.createElement('canvas');
|
|
|
+ cropCanvas.width = crop.size;
|
|
|
+ cropCanvas.height = crop.size;
|
|
|
+ const cropCtx = cropCanvas.getContext('2d');
|
|
|
+
|
|
|
+ if (!cropCtx) return;
|
|
|
+
|
|
|
+ // 计算原始图片的缩放比例
|
|
|
+ const scale = Math.min(size / img.naturalWidth, size / img.naturalHeight);
|
|
|
+ const scaledWidth = img.naturalWidth * scale;
|
|
|
+ const scaledHeight = img.naturalHeight * scale;
|
|
|
+ const offsetX = (size - scaledWidth) / 2;
|
|
|
+ const offsetY = (size - scaledHeight) / 2;
|
|
|
+
|
|
|
+ // 计算裁剪区域在原始图片中的位置
|
|
|
+ const sourceX = (crop.x - offsetX) / scale;
|
|
|
+ const sourceY = (crop.y - offsetY) / scale;
|
|
|
+ const sourceSize = crop.size / scale;
|
|
|
+
|
|
|
+ // 绘制裁剪区域
|
|
|
+ cropCtx.drawImage(
|
|
|
+ img,
|
|
|
+ sourceX, sourceY, sourceSize, sourceSize,
|
|
|
+ 0, 0, crop.size, crop.size
|
|
|
+ );
|
|
|
+
|
|
|
+ // 转换为圆形头像
|
|
|
+ const circleCanvas = document.createElement('canvas');
|
|
|
+ circleCanvas.width = crop.size;
|
|
|
+ circleCanvas.height = crop.size;
|
|
|
+ const circleCtx = circleCanvas.getContext('2d');
|
|
|
+
|
|
|
+ if (!circleCtx) return;
|
|
|
+
|
|
|
+ // 创建圆形裁剪
|
|
|
+ circleCtx.arc(crop.size / 2, crop.size / 2, crop.size / 2, 0, 2 * Math.PI);
|
|
|
+ circleCtx.clip();
|
|
|
+ circleCtx.drawImage(cropCanvas, 0, 0);
|
|
|
+
|
|
|
+ // 压缩并转换为blob
|
|
|
+ circleCanvas.toBlob(async (blob) => {
|
|
|
+ if (blob) {
|
|
|
+ const compressedBlob = await compressImage(
|
|
|
+ new File([blob], 'avatar.jpg', { type: 'image/jpeg' }),
|
|
|
+ 400,
|
|
|
+ 400,
|
|
|
+ 0.9
|
|
|
+ );
|
|
|
+ onCrop(compressedBlob);
|
|
|
+ }
|
|
|
+ }, 'image/jpeg', 0.9);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleWheel = (e: React.WheelEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+ const delta = e.deltaY > 0 ? -10 : 10;
|
|
|
+ setCrop(prev => {
|
|
|
+ const newSize = Math.max(50, Math.min(size, prev.size + delta));
|
|
|
+ const newX = prev.x + (prev.size - newSize) / 2;
|
|
|
+ const newY = prev.y + (prev.size - newSize) / 2;
|
|
|
+
|
|
|
+ return {
|
|
|
+ size: newSize,
|
|
|
+ x: Math.max(0, Math.min(newX, size - newSize)),
|
|
|
+ y: Math.max(0, Math.min(newY, size - newSize)),
|
|
|
+ };
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
|
+ <div
|
|
|
+ className="rounded-2xl p-6 max-w-md w-full"
|
|
|
+ style={{
|
|
|
+ backgroundColor: COLORS.ink.light,
|
|
|
+ border: `1px solid ${COLORS.ink.medium}`,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <h3 className="font-serif text-xl font-bold mb-4" style={{ color: COLORS.text.primary }}>
|
|
|
+ 调整头像
|
|
|
+ </h3>
|
|
|
+
|
|
|
+ <div className="relative mb-4">
|
|
|
+ <canvas
|
|
|
+ ref={canvasRef}
|
|
|
+ className="border rounded-lg cursor-move"
|
|
|
+ style={{
|
|
|
+ borderColor: COLORS.ink.medium,
|
|
|
+ width: '100%',
|
|
|
+ height: 'auto',
|
|
|
+ maxWidth: size,
|
|
|
+ maxHeight: size,
|
|
|
+ }}
|
|
|
+ onMouseDown={handleMouseDown}
|
|
|
+ onMouseMove={handleMouseMove}
|
|
|
+ onMouseUp={handleMouseUp}
|
|
|
+ onMouseLeave={handleMouseUp}
|
|
|
+ onWheel={handleWheel}
|
|
|
+ />
|
|
|
+ <img
|
|
|
+ ref={imageRef}
|
|
|
+ src={imageUrl}
|
|
|
+ alt="待裁剪"
|
|
|
+ className="hidden"
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 裁剪区域指示器 */}
|
|
|
+ <div
|
|
|
+ className="absolute border-2 border-dashed pointer-events-none"
|
|
|
+ style={{
|
|
|
+ left: crop.x,
|
|
|
+ top: crop.y,
|
|
|
+ width: crop.size,
|
|
|
+ height: crop.size,
|
|
|
+ borderColor: COLORS.accent.blue,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p className="text-sm mb-4" style={{ color: COLORS.text.secondary }}>
|
|
|
+ 拖动调整位置,滚轮缩放大小
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <div className="flex gap-3">
|
|
|
+ <button
|
|
|
+ onClick={onCancel}
|
|
|
+ className="flex-1 py-2 px-4 rounded-lg transition-all duration-300"
|
|
|
+ style={{
|
|
|
+ backgroundColor: COLORS.ink.medium,
|
|
|
+ color: COLORS.text.primary,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 取消
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={handleCrop}
|
|
|
+ className="flex-1 py-2 px-4 rounded-lg transition-all duration-300"
|
|
|
+ style={{
|
|
|
+ backgroundColor: COLORS.accent.blue,
|
|
|
+ color: 'white',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 确定
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|