LibreChat/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx
Carolina 3a73907daa
📐 fix: Replace JS Image Scaling with CSS Viewport Constraints (#12089)
* fix: remove scaleImage function that stretched vertical images

* chore: lint

* refactor: Simplify Image Component Usage Across Chat Parts

- Removed height and width props from the Image component in various parts (Files, Part, ImageAttachment, LogContent) to streamline image rendering.
- Introduced a constant for maximum image height in the Image component for consistent styling.
- Updated related components to utilize the new simplified Image component structure, enhancing maintainability and reducing redundancy.

* refactor: Simplify LogContent and Enhance Image Component Tests

- Removed height and width properties from the ImageAttachment type in LogContent for cleaner code.
- Updated the image rendering logic to rely solely on the filepath, improving clarity.
- Enhanced the Image component tests with additional assertions for rendering behavior and accessibility.
- Introduced new tests for OpenAIImageGen to validate image preloading and progress handling, ensuring robust functionality.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-03-06 16:42:23 -05:00

150 lines
4.6 KiB
TypeScript

import { memo, useState, useEffect } from 'react';
import { imageExtRegex, Tools } from 'librechat-data-provider';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import Image from '~/components/Chat/Messages/Content/Image';
import { useAttachmentLink } from './LogLink';
import { cn } from '~/utils';
const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
const [isVisible, setIsVisible] = useState(false);
const file = attachment as TFile & TAttachmentMetadata;
const { handleDownload } = useAttachmentLink({
href: attachment.filepath ?? '',
filename: attachment.filename ?? '',
file_id: file.file_id,
user: file.user,
source: file.source,
});
const extension = attachment.filename?.split('.').pop();
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 50);
return () => clearTimeout(timer);
}, []);
if (!attachment.filepath) {
return null;
}
return (
<div
className={cn(
'file-attachment-container',
'transition-all duration-300 ease-out',
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
)}
style={{
transformOrigin: 'center top',
willChange: 'opacity, transform',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
<FileContainer
file={attachment}
onClick={handleDownload}
overrideType={extension}
containerClassName="max-w-fit"
buttonClassName="bg-surface-secondary hover:cursor-pointer hover:bg-surface-hover active:bg-surface-secondary focus:bg-surface-hover hover:border-border-heavy active:border-border-heavy"
/>
</div>
);
});
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
const [isLoaded, setIsLoaded] = useState(false);
const { filepath = null } = attachment as TFile & TAttachmentMetadata;
useEffect(() => {
setIsLoaded(false);
const timer = setTimeout(() => setIsLoaded(true), 100);
return () => clearTimeout(timer);
}, [attachment]);
return (
<div
className={cn(
'image-attachment-container',
'transition-all duration-500 ease-out',
isLoaded ? 'scale-100 opacity-100' : 'scale-[0.98] opacity-0',
)}
style={{
transformOrigin: 'center top',
willChange: 'opacity, transform',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
<Image
altText={attachment.filename || 'attachment image'}
imagePath={filepath ?? ''}
className="mb-4"
/>
</div>
);
});
export default function Attachment({ attachment }: { attachment?: TAttachment }) {
if (!attachment) {
return null;
}
if (attachment.type === Tools.web_search) {
return null;
}
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage = attachment.filename
? imageExtRegex.test(attachment.filename) && width != null && height != null && filepath != null
: false;
if (isImage) {
return <ImageAttachment attachment={attachment} />;
} else if (!attachment.filepath) {
return null;
}
return <FileAttachment attachment={attachment} />;
}
export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] }) {
if (!attachments || attachments.length === 0) {
return null;
}
const fileAttachments: TAttachment[] = [];
const imageAttachments: TAttachment[] = [];
attachments.forEach((attachment) => {
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage = attachment.filename
? imageExtRegex.test(attachment.filename) &&
width != null &&
height != null &&
filepath != null
: false;
if (isImage) {
imageAttachments.push(attachment);
} else if (attachment.type !== Tools.web_search) {
fileAttachments.push(attachment);
}
});
return (
<>
{fileAttachments.length > 0 && (
<div className="my-2 flex flex-wrap items-center gap-2.5">
{fileAttachments.map((attachment, index) =>
attachment.filepath ? (
<FileAttachment attachment={attachment} key={`file-${index}`} />
) : null,
)}
</div>
)}
{imageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap items-center">
{imageAttachments.map((attachment, index) => (
<ImageAttachment attachment={attachment} key={`image-${index}`} />
))}
</div>
)}
</>
);
}