Files
ragflow/web/src/components/avatar-upload.tsx
balibabu 3c5a3e5fb4 Feat: Integrate the name, avatar, and description of chat and search into a single component. (#14008)
### What problem does this PR solve?

Feat: Integrate the name, avatar, and description of chat and search
into a single component.
### Type of change


- [x] New Feature (non-breaking change which adds functionality)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Inline-editable avatar, name, and description fields
  * Expandable content blocks in search results
  * New RAGFlow heading/logo component

* **Refactor**
* Replaced scattered form fields with a composed Avatar/Name/Description
component
  * Mindmap drawer converted to a sheet-based drawer and layout cleanup
* Simplified search page controls and layout; improved scroll viewport
handling

* **Chores**
* Added/updated English and Chinese localization keys (placeholders,
view more/less)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Yingfeng <yingfeng.zhang@gmail.com>
2026-04-09 18:51:45 +08:00

400 lines
13 KiB
TypeScript

import { combineRefs } from '@/lib/utils';
import { transformFile2Base64 } from '@/utils/file-util';
import { LucidePencil, LucidePlus, LucideX } from 'lucide-react';
import {
ChangeEventHandler,
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 { Modal } from './ui/modal/modal';
type AvatarUploadProps = {
value?: string;
onChange?: (value: string) => void;
tips?: string;
uploadInputTestId?: string;
removeButtonTestId?: string;
cropModalTestId?: string;
cropModalOkButtonTestId?: string;
};
export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
function AvatarUpload(
{
value,
onChange,
tips,
uploadInputTestId,
removeButtonTestId,
cropModalTestId,
cropModalOkButtonTestId,
},
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 innerInputRef = useRef<HTMLInputElement | null>(null);
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!, 1000);
setImageToCrop(str);
setIsCropModalOpen(true);
}
ev.target.value = '';
},
[],
);
const handleRemove = useCallback(() => {
setAvatarBase64Str('');
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;
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]);
return (
<div className="flex justify-start items-end space-x-2">
<div className="relative group">
<input
placeholder=""
type="file"
title=""
accept="image/*"
className="peer/input size-0 absolute top-0 left-0 opacity-0 pointer-events-none"
onChange={handleChange}
ref={combineRefs(ref, innerInputRef)}
data-testid={uploadInputTestId}
tabIndex={-1}
/>
{!avatarBase64Str ? (
<Button
variant="dashed"
size="icon"
className="size-16 flex flex-col items-center gap-1 !bg-transparent"
type="button"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
innerInputRef.current?.click();
}}
>
<LucidePlus className="size-4" />
<span>{t('common.upload')}</span>
</Button>
) : (
<div className="size-16 relative grid place-content-center">
<Button
variant="transparent"
size="icon"
type="button"
className="group/button size-full p-0 transition-all relative gap-0 overflow-hidden"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
innerInputRef.current?.click();
}}
>
<Avatar className="size-full rounded-none">
<AvatarImage className="block" src={avatarBase64Str} alt="" />
<AvatarFallback />
</Avatar>
<div
className="
absolute inset-0 flex items-center justify-center
bg-black/40 opacity-0 transition-opacity
group-hover/button:opacity-100 group-focus-visible/button:opacity-100"
>
<LucidePencil className="size-5 opacity-75" />
</div>
</Button>
<Button
onClick={handleRemove}
size="icon"
className="border-background focus-visible:border-background absolute -top-2 -right-2 size-6 rounded-full border-2 shadow-none z-10"
aria-label="Remove image"
type="button"
data-testid={removeButtonTestId}
>
<LucideX className="size-3" />
</Button>
</div>
)}
</div>
<div className="ms-1 text-xs 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}
testId={cropModalTestId}
okButtonTestId={cropModalOkButtonTestId}
// 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>
);
},
);