Fix: Profile picture cropping supported #10703 (#11221)

### What problem does this PR solve?

Fix: Profile picture cropping supported

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
This commit is contained in:
chanx
2025-11-13 09:50:10 +08:00
committed by GitHub
parent 8ef2f79d0a
commit 83d84e90ed
4 changed files with 292 additions and 7 deletions

View File

@ -5,12 +5,14 @@ import {
forwardRef,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Modal } from './ui/modal/modal';
type AvatarUploadProps = {
value?: string;
@ -22,14 +24,24 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
function AvatarUpload({ value, onChange, tips }, ref) {
const { t } = useTranslation();
const [avatarBase64Str, setAvatarBase64Str] = useState(''); // Avatar Image base64
const [isCropModalOpen, setIsCropModalOpen] = useState(false);
const [imageToCrop, setImageToCrop] = useState<string | null>(null);
const [cropArea, setCropArea] = useState({ x: 0, y: 0, size: 200 });
const imageRef = useRef<HTMLImageElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const dragStartRef = useRef({ x: 0, y: 0 });
const [imageScale, setImageScale] = useState(1);
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 });
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
async (ev) => {
const file = ev.target?.files?.[0];
if (/\.(jpg|jpeg|png|webp|bmp)$/i.test(file?.name ?? '')) {
const str = await transformFile2Base64(file!);
setAvatarBase64Str(str);
onChange?.(str);
const str = await transformFile2Base64(file!, 1000);
setImageToCrop(str);
setIsCropModalOpen(true);
}
ev.target.value = '';
},
@ -41,17 +53,209 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
onChange?.('');
}, [onChange]);
const handleCrop = useCallback(() => {
if (!imageRef.current || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const image = imageRef.current;
if (!ctx) return;
// Set canvas size to 64x64 (avatar size)
canvas.width = 64;
canvas.height = 64;
// Draw cropped image on canvas
ctx.drawImage(
image,
cropArea.x,
cropArea.y,
cropArea.size,
cropArea.size,
0,
0,
64,
64,
);
// Convert to base64
const croppedImageBase64 = canvas.toDataURL('image/png');
setAvatarBase64Str(croppedImageBase64);
onChange?.(croppedImageBase64);
setIsCropModalOpen(false);
}, [cropArea, onChange]);
const handleCancelCrop = useCallback(() => {
setIsCropModalOpen(false);
setImageToCrop(null);
}, []);
const initCropArea = useCallback(() => {
if (!imageRef.current || !containerRef.current) return;
const image = imageRef.current;
const container = containerRef.current;
// Calculate image scale to fit container
const scale = Math.min(
container.clientWidth / image.width,
container.clientHeight / image.height,
);
setImageScale(scale);
// Calculate image offset to center it
const scaledWidth = image.width * scale;
const scaledHeight = image.height * scale;
const offsetX = (container.clientWidth - scaledWidth) / 2;
const offsetY = (container.clientHeight - scaledHeight) / 2;
setImageOffset({ x: offsetX, y: offsetY });
// Initialize crop area to center of image
const size = Math.min(scaledWidth, scaledHeight) * 0.8; // 80% of the smaller dimension
const x = (image.width - size / scale) / 2;
const y = (image.height - size / scale) / 2;
setCropArea({ x, y, size: size / scale });
}, []);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (
!isDraggingRef.current ||
!imageRef.current ||
!containerRef.current
)
return;
const image = imageRef.current;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate mouse position relative to container
const mouseX = e.clientX - containerRect.left;
const mouseY = e.clientY - containerRect.top;
// Calculate mouse position relative to image
const imageX = (mouseX - imageOffset.x) / imageScale;
const imageY = (mouseY - imageOffset.y) / imageScale;
// Calculate new crop area position based on mouse movement
let newX = imageX - dragStartRef.current.x;
let newY = imageY - dragStartRef.current.y;
// Boundary checks
newX = Math.max(0, Math.min(newX, image.width - cropArea.size));
newY = Math.max(0, Math.min(newY, image.height - cropArea.size));
setCropArea((prev) => ({
...prev,
x: newX,
y: newY,
}));
},
[cropArea.size, imageScale, imageOffset],
);
const handleMouseUp = useCallback(() => {
isDraggingRef.current = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}, [handleMouseMove]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
isDraggingRef.current = true;
if (imageRef.current && containerRef.current) {
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate mouse position relative to container
const mouseX = e.clientX - containerRect.left;
const mouseY = e.clientY - containerRect.top;
// Calculate mouse position relative to image
const imageX = (mouseX - imageOffset.x) / imageScale;
const imageY = (mouseY - imageOffset.y) / imageScale;
// Store the offset between mouse position and crop area position
dragStartRef.current = {
x: imageX - cropArea.x,
y: imageY - cropArea.y,
};
}
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[cropArea, imageScale, imageOffset],
);
const handleWheel = useCallback((e: React.WheelEvent) => {
if (!imageRef.current) return;
e.preventDefault();
const image = imageRef.current;
const delta = e.deltaY > 0 ? 0.9 : 1.1; // Zoom factor
setCropArea((prev) => {
const newSize = Math.max(
20,
Math.min(prev.size * delta, Math.min(image.width, image.height)),
);
// Adjust position to keep crop area centered
const centerRatioX = (prev.x + prev.size / 2) / image.width;
const centerRatioY = (prev.y + prev.size / 2) / image.height;
const newX = centerRatioX * image.width - newSize / 2;
const newY = centerRatioY * image.height - newSize / 2;
// Boundary checks
const boundedX = Math.max(0, Math.min(newX, image.width - newSize));
const boundedY = Math.max(0, Math.min(newY, image.height - newSize));
return {
x: boundedX,
y: boundedY,
size: newSize,
};
});
}, []);
useEffect(() => {
if (value) {
setAvatarBase64Str(value);
}
}, [value]);
useEffect(() => {
const container = containerRef.current;
setTimeout(() => {
console.log('container', container);
// initCropArea();
if (imageToCrop && container && isCropModalOpen) {
container.addEventListener(
'wheel',
handleWheel as unknown as EventListener,
{ passive: false },
);
return () => {
container.removeEventListener(
'wheel',
handleWheel as unknown as EventListener,
);
};
}
}, 100);
}, [handleWheel, containerRef.current]);
return (
<div className="flex justify-start items-end space-x-2">
<div className="relative group">
{!avatarBase64Str ? (
<div className="w-[64px] h-[64px] grid place-content-center border border-dashed bg-bg-input rounded-md">
<div className="w-[64px] h-[64px] grid place-content-center border border-dashed bg-bg-input rounded-md">
<div className="flex flex-col items-center">
<Plus />
<p>{t('common.upload')}</p>
@ -60,7 +264,7 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
) : (
<div className="w-[64px] h-[64px] relative grid place-content-center">
<Avatar className="w-[64px] h-[64px] rounded-md">
<AvatarImage className=" block" src={avatarBase64Str} alt="" />
<AvatarImage className="block" src={avatarBase64Str} alt="" />
<AvatarFallback></AvatarFallback>
</Avatar>
<div className="absolute inset-0 bg-[#000]/20 group-hover:bg-[#000]/60">
@ -93,6 +297,79 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
<div className="margin-1 text-text-secondary">
{tips ?? t('knowledgeConfiguration.photoTip')}
</div>
{/* Crop Modal */}
<Modal
open={isCropModalOpen}
onOpenChange={(open) => {
setIsCropModalOpen(open);
if (!open) {
setImageToCrop(null);
}
}}
title={t('setting.cropImage')}
size="small"
onCancel={handleCancelCrop}
onOk={handleCrop}
// footer={
// <div className="flex justify-end space-x-2">
// <Button variant="secondary" onClick={handleCancelCrop}>
// {t('common.cancel')}
// </Button>
// <Button onClick={handleCrop}>{t('common.confirm')}</Button>
// </div>
// }
>
<div className="flex flex-col items-center p-4">
{imageToCrop && (
<div className="w-full">
<div
ref={containerRef}
className="relative overflow-hidden border border-border rounded-md mx-auto bg-bg-card"
style={{
width: '300px',
height: '300px',
touchAction: 'none',
}}
// onWheel={handleWheel}
>
<img
ref={imageRef}
src={imageToCrop}
alt="To crop"
className="absolute block"
style={{
transform: `scale(${imageScale})`,
transformOrigin: 'top left',
left: `${imageOffset.x}px`,
top: `${imageOffset.y}px`,
}}
onLoad={initCropArea}
/>
{imageRef.current && (
<div
className="absolute border-2 border-white border-dashed cursor-move"
style={{
left: `${imageOffset.x + cropArea.x * imageScale}px`,
top: `${imageOffset.y + cropArea.y * imageScale}px`,
width: `${cropArea.size * imageScale}px`,
height: `${cropArea.size * imageScale}px`,
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
}}
onMouseDown={handleMouseDown}
/>
)}
</div>
<div className="flex justify-center mt-4">
<p className="text-sm text-text-secondary">
{t('setting.cropTip')}
</p>
</div>
<canvas ref={canvasRef} className="hidden" />
</div>
)}
</div>
</Modal>
</div>
);
},

View File

@ -694,6 +694,9 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
tocEnhanceTip: ` During the parsing of the document, table of contents information was generated (see the 'Enable Table of Contents Extraction' option in the General method). This allows the large model to return table of contents items relevant to the user's query, thereby using these items to retrieve related chunks and apply weighting to these chunks during the sorting process. This approach is derived from mimicking the behavioral logic of how humans search for knowledge in books.`,
},
setting: {
cropTip:
'Drag the selection area to choose the cropping position of the image, and scroll to zoom in/out',
cropImage: 'Crop image',
selectModelPlaceholder: 'Select model',
configureModelTitle: 'Configure model',
confluenceIsCloudTip:

View File

@ -684,6 +684,8 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
tocEnhanceTip: `解析文档时生成了目录信息见General方法的启用目录抽取让大模型返回和用户问题相关的目录项从而利用目录项拿到相关chunk对这些chunk在排序中进行加权。这种方法来源于模仿人类查询书本中知识的行为逻辑`,
},
setting: {
cropTip: '拖动选区可以选择要图片的裁剪位置,滚动可以放大/缩小选区',
cropImage: '剪裁图片',
selectModelPlaceholder: '请选择模型',
configureModelTitle: '配置模型',
confluenceIsCloudTip:

View File

@ -2,7 +2,10 @@ import { FileMimeType } from '@/constants/common';
import fileManagerService from '@/services/file-manager-service';
import { UploadFile } from 'antd';
export const transformFile2Base64 = (val: any): Promise<any> => {
export const transformFile2Base64 = (
val: any,
imgSize?: number,
): Promise<any> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(val);
@ -19,7 +22,7 @@ export const transformFile2Base64 = (val: any): Promise<any> => {
// Calculate compressed dimensions, set max width/height to 800px
let width = img.width;
let height = img.height;
const maxSize = 100;
const maxSize = imgSize ?? 100;
if (width > height && width > maxSize) {
height = (height * maxSize) / width;