📐 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

@ -21,15 +21,7 @@ const Files = ({ message }: { message?: TMessage }) => {
<Image
key={file.file_id}
imagePath={file.preview ?? file.filepath ?? ''}
height={file.height ?? 1920}
width={file.width ?? 1080}
altText={file.filename ?? 'Uploaded Image'}
placeholderDimensions={{
height: `${file.height ?? 1920}px`,
width: `${file.height ?? 1080}px`,
}}
// n={imageFiles.length}
// i={i}
/>
))}
</>

View file

@ -2,26 +2,20 @@ import React, { useState, useRef, useMemo } from 'react';
import { Skeleton } from '@librechat/client';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import { apiBaseUrl } from 'librechat-data-provider';
import { cn, scaleImage } from '~/utils';
import { cn } from '~/utils';
import DialogImage from './DialogImage';
/** Max display height for chat images (Tailwind JIT class) */
const IMAGE_MAX_H = 'max-h-[45vh]' as const;
const Image = ({
imagePath,
altText,
height,
width,
placeholderDimensions,
className,
args,
}: {
imagePath: string;
altText: string;
height: number;
width: number;
placeholderDimensions?: {
height?: string;
width?: string;
};
className?: string;
args?: {
prompt?: string;
@ -33,7 +27,6 @@ const Image = ({
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const handleImageLoad = () => setIsLoaded(true);
@ -56,16 +49,6 @@ const Image = ({
return `${baseURL}${imagePath}`;
}, [imagePath]);
const { width: scaledWidth, height: scaledHeight } = useMemo(
() =>
scaleImage({
originalWidth: Number(placeholderDimensions?.width?.split('px')[0] ?? width),
originalHeight: Number(placeholderDimensions?.height?.split('px')[0] ?? height),
containerRef,
}),
[placeholderDimensions, height, width],
);
const downloadImage = async () => {
try {
const response = await fetch(absoluteImageUrl);
@ -96,7 +79,7 @@ const Image = ({
};
return (
<div ref={containerRef}>
<div>
<button
ref={triggerRef}
type="button"
@ -114,19 +97,14 @@ const Image = ({
onLoad={handleImageLoad}
visibleByDefault={true}
className={cn(
'opacity-100 transition-opacity duration-100',
'block h-auto w-auto max-w-full text-transparent transition-opacity duration-100',
IMAGE_MAX_H,
isLoaded ? 'opacity-100' : 'opacity-0',
)}
src={absoluteImageUrl}
style={{
width: `${scaledWidth}`,
height: 'auto',
color: 'transparent',
display: 'block',
}}
placeholder={
<Skeleton
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
className={cn(IMAGE_MAX_H, 'h-48 w-full max-w-lg')}
aria-label="Loading image"
aria-busy="true"
/>

View file

@ -222,19 +222,8 @@ const Part = memo(function Part({
}
} else if (part.type === ContentTypes.IMAGE_FILE) {
const imageFile = part[ContentTypes.IMAGE_FILE];
const height = imageFile.height ?? 1920;
const width = imageFile.width ?? 1080;
return (
<Image
imagePath={imageFile.filepath}
height={height}
width={width}
altText={imageFile.filename ?? 'Uploaded Image'}
placeholderDimensions={{
height: height + 'px',
width: width + 'px',
}}
/>
<Image imagePath={imageFile.filepath} altText={imageFile.filename ?? 'Uploaded Image'} />
);
}

View file

@ -52,7 +52,7 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
const [isLoaded, setIsLoaded] = useState(false);
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
const { filepath = null } = attachment as TFile & TAttachmentMetadata;
useEffect(() => {
setIsLoaded(false);
@ -76,8 +76,6 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
<Image
altText={attachment.filename || 'attachment image'}
imagePath={filepath ?? ''}
height={height ?? 0}
width={width ?? 0}
className="mb-4"
/>
</div>

View file

@ -12,11 +12,7 @@ interface LogContentProps {
attachments?: TAttachment[];
}
type ImageAttachment = TFile &
TAttachmentMetadata & {
height: number;
width: number;
};
type ImageAttachment = TFile & TAttachmentMetadata;
const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, attachments }) => {
const localize = useLocalize();
@ -35,12 +31,8 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
const nonImageAtts: TAttachment[] = [];
attachments?.forEach((attachment) => {
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage =
imageExtRegex.test(attachment.filename ?? '') &&
width != null &&
height != null &&
filepath != null;
const { filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage = imageExtRegex.test(attachment.filename ?? '') && filepath != null;
if (isImage) {
imageAtts.push(attachment as ImageAttachment);
} else {
@ -100,18 +92,13 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
))}
</div>
)}
{imageAttachments?.map((attachment, index) => {
const { width, height, filepath } = attachment;
return (
<Image
key={index}
altText={attachment.filename}
imagePath={filepath}
height={height}
width={width}
/>
);
})}
{imageAttachments?.map((attachment) => (
<Image
key={attachment.filepath}
altText={attachment.filename}
imagePath={attachment.filepath}
/>
))}
</>
);
};

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>

View file

@ -0,0 +1,200 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Image from '../Image';
jest.mock('~/utils', () => ({
cn: (...classes: unknown[]) =>
classes
.flat(Infinity)
.filter((c) => typeof c === 'string' && c.length > 0)
.join(' '),
}));
jest.mock('librechat-data-provider', () => ({
apiBaseUrl: () => '',
}));
jest.mock('react-lazy-load-image-component', () => ({
LazyLoadImage: ({
alt,
src,
className,
onLoad,
placeholder,
visibleByDefault: _visibleByDefault,
...rest
}: {
alt: string;
src: string;
className: string;
onLoad: () => void;
placeholder: React.ReactNode;
visibleByDefault?: boolean;
[key: string]: unknown;
}) => (
<div data-testid="lazy-image-wrapper">
<img
alt={alt}
src={src}
className={className}
onLoad={onLoad}
data-testid="lazy-image"
{...rest}
/>
<div data-testid="placeholder">{placeholder}</div>
</div>
),
}));
jest.mock('@librechat/client', () => ({
Skeleton: ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div data-testid="skeleton" className={className} {...props} />
),
}));
jest.mock('../DialogImage', () => ({
__esModule: true,
default: ({ isOpen, src }: { isOpen: boolean; src: string }) =>
isOpen ? <div data-testid="dialog-image" data-src={src} /> : null,
}));
describe('Image', () => {
const defaultProps = {
imagePath: '/images/test.png',
altText: 'Test image',
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('rendering', () => {
it('renders with max-h-[45vh] height constraint', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('lazy-image');
expect(img.className).toContain('max-h-[45vh]');
});
it('renders with max-w-full to prevent landscape clipping', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('lazy-image');
expect(img.className).toContain('max-w-full');
});
it('renders with w-auto and h-auto for natural aspect ratio', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('lazy-image');
expect(img.className).toContain('w-auto');
expect(img.className).toContain('h-auto');
});
it('starts with opacity-0 before image loads', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('lazy-image');
expect(img.className).toContain('opacity-0');
expect(img.className).not.toContain('opacity-100');
});
it('transitions to opacity-100 after image loads', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('lazy-image');
fireEvent.load(img);
expect(img.className).toContain('opacity-100');
});
it('applies custom className to the button wrapper', () => {
render(<Image {...defaultProps} className="mb-4" />);
const button = screen.getByRole('button');
expect(button.className).toContain('mb-4');
});
it('sets correct alt text', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('lazy-image');
expect(img).toHaveAttribute('alt', 'Test image');
});
});
describe('skeleton placeholder', () => {
it('renders skeleton with non-zero dimensions', () => {
render(<Image {...defaultProps} />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton.className).toContain('h-48');
expect(skeleton.className).toContain('w-full');
expect(skeleton.className).toContain('max-w-lg');
});
it('renders skeleton with max-h constraint', () => {
render(<Image {...defaultProps} />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton.className).toContain('max-h-[45vh]');
});
it('has accessible loading attributes', () => {
render(<Image {...defaultProps} />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toHaveAttribute('aria-label', 'Loading image');
expect(skeleton).toHaveAttribute('aria-busy', 'true');
});
});
describe('dialog interaction', () => {
it('opens dialog on button click after image loads', () => {
render(<Image {...defaultProps} />);
const img = screen.getByTestId('lazy-image');
fireEvent.load(img);
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
});
it('does not render dialog before image loads', () => {
render(<Image {...defaultProps} />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
});
it('has correct accessibility attributes on button', () => {
render(<Image {...defaultProps} />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'View Test image in dialog');
expect(button).toHaveAttribute('aria-haspopup', 'dialog');
});
});
describe('image URL resolution', () => {
it('passes /images/ paths through with base URL', () => {
render(<Image {...defaultProps} imagePath="/images/test.png" />);
const img = screen.getByTestId('lazy-image');
expect(img).toHaveAttribute('src', '/images/test.png');
});
it('passes absolute http URLs through unchanged', () => {
render(<Image {...defaultProps} imagePath="https://example.com/photo.jpg" />);
const img = screen.getByTestId('lazy-image');
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
});
it('passes data URIs through unchanged', () => {
render(<Image {...defaultProps} imagePath="data:image/png;base64,abc" />);
const img = screen.getByTestId('lazy-image');
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc');
});
it('passes non-/images/ paths through unchanged', () => {
render(<Image {...defaultProps} imagePath="/other/path.png" />);
const img = screen.getByTestId('lazy-image');
expect(img).toHaveAttribute('src', '/other/path.png');
});
});
});

View file

@ -0,0 +1,182 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen';
jest.mock('~/utils', () => ({
cn: (...classes: unknown[]) =>
classes
.flat(Infinity)
.filter((c) => typeof c === 'string' && c.length > 0)
.join(' '),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/components/Chat/Messages/Content/Image', () => ({
__esModule: true,
default: ({
altText,
imagePath,
className,
}: {
altText: string;
imagePath: string;
className?: string;
}) => (
<div
data-testid="image-component"
data-alt={altText}
data-src={imagePath}
className={className}
/>
),
}));
jest.mock('@librechat/client', () => ({
PixelCard: ({ progress }: { progress: number }) => (
<div data-testid="pixel-card" data-progress={progress} />
),
}));
jest.mock('../Parts/OpenAIImageGen/ProgressText', () => ({
__esModule: true,
default: ({ progress, error }: { progress: number; error: boolean }) => (
<div data-testid="progress-text" data-progress={progress} data-error={String(error)} />
),
}));
describe('OpenAIImageGen', () => {
const defaultProps = {
initialProgress: 0.1,
isSubmitting: true,
toolName: 'image_gen_oai',
args: '{"prompt":"a cat","quality":"high","size":"1024x1024"}',
output: null as string | null,
attachments: undefined,
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('image preloading', () => {
it('keeps Image mounted during generation (progress < 1)', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
expect(screen.getByTestId('image-component')).toBeInTheDocument();
});
it('hides Image with invisible absolute while progress < 1', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
const image = screen.getByTestId('image-component');
expect(image.className).toContain('invisible');
expect(image.className).toContain('absolute');
});
it('shows Image without hiding classes when progress >= 1', () => {
render(
<OpenAIImageGen
{...defaultProps}
initialProgress={1}
isSubmitting={false}
attachments={[
{
filename: 'cat.png',
filepath: '/images/cat.png',
conversationId: 'conv1',
} as never,
]}
/>,
);
const image = screen.getByTestId('image-component');
expect(image.className).not.toContain('invisible');
expect(image.className).not.toContain('absolute');
});
});
describe('PixelCard visibility', () => {
it('shows PixelCard when progress < 1', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
expect(screen.getByTestId('pixel-card')).toBeInTheDocument();
});
it('hides PixelCard when progress >= 1', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />);
expect(screen.queryByTestId('pixel-card')).not.toBeInTheDocument();
});
});
describe('layout classes', () => {
it('applies max-h-[45vh] to the outer container', () => {
const { container } = render(<OpenAIImageGen {...defaultProps} />);
const outerDiv = container.querySelector('[class*="max-h-"]');
expect(outerDiv?.className).toContain('max-h-[45vh]');
});
it('applies h-[45vh] w-full to inner container during loading', () => {
const { container } = render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
const innerDiv = container.querySelector('[class*="h-[45vh]"]');
expect(innerDiv).not.toBeNull();
expect(innerDiv?.className).toContain('w-full');
});
it('applies w-auto to inner container when complete', () => {
const { container } = render(
<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />,
);
const overflowDiv = container.querySelector('[class*="overflow-hidden"]');
expect(overflowDiv?.className).toContain('w-auto');
});
});
describe('args parsing', () => {
it('parses quality from args', () => {
render(<OpenAIImageGen {...defaultProps} />);
expect(screen.getByTestId('progress-text')).toBeInTheDocument();
});
it('handles invalid JSON args gracefully', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
render(<OpenAIImageGen {...defaultProps} args="invalid json" />);
expect(screen.getByTestId('image-component')).toBeInTheDocument();
consoleSpy.mockRestore();
});
it('handles object args', () => {
render(
<OpenAIImageGen
{...defaultProps}
args={{ prompt: 'a dog', quality: 'low', size: '512x512' }}
/>,
);
expect(screen.getByTestId('image-component')).toBeInTheDocument();
});
});
describe('cancellation', () => {
it('shows error state when output contains error', () => {
render(
<OpenAIImageGen
{...defaultProps}
output="Error processing tool call"
isSubmitting={false}
initialProgress={0.5}
/>,
);
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveAttribute('data-error', 'true');
});
it('shows cancelled state when not submitting and incomplete', () => {
render(<OpenAIImageGen {...defaultProps} isSubmitting={false} initialProgress={0.5} />);
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveAttribute('data-error', 'true');
});
});
});

View file

@ -29,7 +29,6 @@ export * from './share';
export * from './timestamps';
export { default as cn } from './cn';
export { default as logger } from './logger';
export { default as scaleImage } from './scaleImage';
export { default as getLoginError } from './getLoginError';
export { default as cleanupPreset } from './cleanupPreset';
export { default as buildDefaultConvo } from './buildDefaultConvo';

View file

@ -1,21 +0,0 @@
export default function scaleImage({
originalWidth,
originalHeight,
containerRef,
}: {
originalWidth?: number;
originalHeight?: number;
containerRef: React.RefObject<HTMLDivElement>;
}) {
const containerWidth = containerRef.current?.offsetWidth ?? 0;
if (containerWidth === 0 || originalWidth == null || originalHeight == null) {
return { width: 'auto', height: 'auto' };
}
const aspectRatio = originalWidth / originalHeight;
const scaledWidth = Math.min(containerWidth, originalWidth);
const scaledHeight = scaledWidth / aspectRatio;
return { width: `${scaledWidth}px`, height: `${scaledHeight}px` };
}