Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
287 changes: 282 additions & 5 deletions web/src/components/avatar-upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = '';
},
Expand All @@ -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>
Expand All @@ -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">
Expand Down Expand Up @@ -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>
);
},
Expand Down
3 changes: 3 additions & 0 deletions web/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions web/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,8 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于
tocEnhanceTip: `解析文档时生成了目录信息(见General方法的‘启用目录抽取’),让大模型返回和用户问题相关的目录项,从而利用目录项拿到相关chunk,对这些chunk在排序中进行加权。这种方法来源于模仿人类查询书本中知识的行为逻辑`,
},
setting: {
cropTip: '拖动选区可以选择要图片的裁剪位置,滚动可以放大/缩小选区',
cropImage: '剪裁图片',
selectModelPlaceholder: '请选择模型',
configureModelTitle: '配置模型',
confluenceIsCloudTip:
Expand Down
7 changes: 5 additions & 2 deletions web/src/utils/file-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down