📐 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>
This commit is contained in:
Carolina 2026-03-06 18:42:23 -03:00 committed by GitHub
parent 771227ecf9
commit 3a73907daa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 416 additions and 183 deletions

View file

@ -1,9 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef } from 'react';
import { PixelCard } from '@librechat/client';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import Image from '~/components/Chat/Messages/Content/Image';
import ProgressText from './ProgressText';
import { scaleImage } from '~/utils';
import { cn } from '~/utils';
const IMAGE_MAX_H = 'max-h-[45vh]' as const;
const IMAGE_FULL_H = 'h-[45vh]' as const;
export default function OpenAIImageGen({
initialProgress = 0.1,
@ -28,8 +31,6 @@ export default function OpenAIImageGen({
const cancelled = (!isSubmitting && initialProgress < 1) || error === true;
let width: number | undefined;
let height: number | undefined;
let quality: 'low' | 'medium' | 'high' = 'high';
// Parse args if it's a string
@ -41,61 +42,15 @@ export default function OpenAIImageGen({
parsedArgs = {};
}
try {
const argsObj = parsedArgs;
if (argsObj && typeof argsObj.size === 'string') {
const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10));
if (!isNaN(w) && !isNaN(h)) {
width = w;
height = h;
}
} else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) {
width = undefined;
height = undefined;
if (parsedArgs && typeof parsedArgs.quality === 'string') {
const q = parsedArgs.quality.toLowerCase();
if (q === 'low' || q === 'medium' || q === 'high') {
quality = q;
}
if (argsObj && typeof argsObj.quality === 'string') {
const q = argsObj.quality.toLowerCase();
if (q === 'low' || q === 'medium' || q === 'high') {
quality = q;
}
}
} catch (e) {
width = undefined;
height = undefined;
}
// Default to 1024x1024 if width and height are still undefined after parsing args and attachment metadata
const attachment = attachments?.[0];
const {
width: imageWidth,
height: imageHeight,
filepath = null,
filename = '',
} = (attachment as TFile & TAttachmentMetadata) || {};
let origWidth = width ?? imageWidth;
let origHeight = height ?? imageHeight;
if (origWidth === undefined || origHeight === undefined) {
origWidth = 1024;
origHeight = 1024;
}
const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' });
const containerRef = useRef<HTMLDivElement>(null);
const updateDimensions = useCallback(() => {
if (origWidth && origHeight && containerRef.current) {
const scaled = scaleImage({
originalWidth: origWidth,
originalHeight: origHeight,
containerRef,
});
setDimensions(scaled);
}
}, [origWidth, origHeight]);
const { filepath = null, filename = '' } = (attachment as TFile & TAttachmentMetadata) || {};
useEffect(() => {
if (isSubmitting) {
@ -156,45 +111,19 @@ export default function OpenAIImageGen({
}
}, [initialProgress, cancelled]);
useEffect(() => {
updateDimensions();
const resizeObserver = new ResizeObserver(() => {
updateDimensions();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [updateDimensions]);
return (
<>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
</div>
<div className="relative mb-2 flex w-full justify-start">
<div ref={containerRef} className="w-full max-w-lg">
{dimensions.width !== 'auto' && progress < 1 && (
<PixelCard
variant="default"
progress={progress}
randomness={0.6}
width={dimensions.width}
height={dimensions.height}
/>
)}
<div className={cn('relative mb-2 flex w-full max-w-lg justify-start', IMAGE_MAX_H)}>
<div className={cn('overflow-hidden', progress < 1 ? [IMAGE_FULL_H, 'w-full'] : 'w-auto')}>
{progress < 1 && <PixelCard variant="default" progress={progress} randomness={0.6} />}
<Image
altText={filename}
imagePath={filepath ?? ''}
width={Number(dimensions.width?.split('px')[0])}
height={Number(dimensions.height?.split('px')[0])}
placeholderDimensions={{ width: dimensions.width, height: dimensions.height }}
args={parsedArgs}
className={progress < 1 ? 'invisible absolute' : ''}
/>
</div>
</div>