Skip to content

Commit 10ec587

Browse files
Managed via Tartzzj3720
authored andcommitted
Resize chat image previews naturally
Co-authored-by: 3720 <zuozijian1994@gmail.com>
1 parent e3446d0 commit 10ec587

6 files changed

Lines changed: 194 additions & 13 deletions

File tree

web/src/embedded/chat/components/markdown/overrides/img.tsx

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { Menu } from '@/utils/webview-context-menu';
2-
import { useCallback, useState } from 'react';
2+
import { cn } from '@/utils/cn';
3+
import {
4+
useCallback,
5+
useState,
6+
type CSSProperties,
7+
type SyntheticEvent,
8+
} from 'react';
9+
import { fitImagePreviewSize } from '../../messages/image-preview-size';
310

411
/**
512
* Custom image component with loading animation and error handling.
@@ -8,10 +15,17 @@ export const CueStreamdownImg = ({
815
src,
916
alt,
1017
className,
18+
onLoad,
19+
onError,
20+
style,
1121
...props
1222
}: React.ComponentProps<'img'>) => {
1323
const [imageLoaded, setImageLoaded] = useState(false);
1424
const [error, setError] = useState<string | null>(null);
25+
const [naturalSize, setNaturalSize] = useState<{
26+
width: number;
27+
height: number;
28+
} | null>(null);
1529

1630
const handleContextMenu = useCallback(
1731
(event: React.MouseEvent<HTMLImageElement>) => {
@@ -31,6 +45,43 @@ export const CueStreamdownImg = ({
3145
[src]
3246
);
3347

48+
const handleLoad = useCallback(
49+
(event: SyntheticEvent<HTMLImageElement>) => {
50+
const image = event.currentTarget;
51+
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
52+
setNaturalSize({
53+
width: image.naturalWidth,
54+
height: image.naturalHeight,
55+
});
56+
}
57+
setImageLoaded(true);
58+
onLoad?.(event);
59+
},
60+
[onLoad]
61+
);
62+
63+
const handleError = useCallback(
64+
(event: SyntheticEvent<HTMLImageElement>) => {
65+
setError('Failed to load image');
66+
onError?.(event);
67+
},
68+
[onError]
69+
);
70+
71+
const previewSize = naturalSize
72+
? fitImagePreviewSize(naturalSize.width, naturalSize.height)
73+
: null;
74+
const imageStyle = {
75+
...style,
76+
...(previewSize && naturalSize
77+
? {
78+
width: previewSize.width,
79+
maxHeight: previewSize.height,
80+
aspectRatio: `${naturalSize.width} / ${naturalSize.height}`,
81+
}
82+
: {}),
83+
} satisfies CSSProperties;
84+
3485
// Error state
3586
if (error) {
3687
return (
@@ -48,13 +99,14 @@ export const CueStreamdownImg = ({
4899
src={src}
49100
alt={alt || 'Generated image'}
50101
onContextMenu={handleContextMenu}
51-
className={`
52-
${className || ''}
53-
transition-opacity duration-500 ease-out
54-
${imageLoaded ? 'opacity-100' : 'opacity-0'}
55-
`}
56-
onLoad={() => setImageLoaded(true)}
57-
onError={() => setError('Failed to load image')}
102+
className={cn(
103+
'inline-block h-auto max-w-full rounded-lg object-contain transition-opacity duration-500 ease-out',
104+
className,
105+
imageLoaded ? 'opacity-100' : 'opacity-0'
106+
)}
107+
style={imageStyle}
108+
onLoad={handleLoad}
109+
onError={handleError}
58110
{...props}
59111
/>
60112
);

web/src/embedded/chat/components/messages/assistant-message.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ function renderContentBlock(
255255
mimeType={content.mimeType ?? undefined}
256256
sourcePath={content.fileRef?.path}
257257
environmentId={content.fileRef?.environmentId ?? undefined}
258-
className="max-h-80 w-fit"
258+
className="w-fit"
259259
data-artifact={key}
260260
/>
261261
) : null;

web/src/embedded/chat/components/messages/attachment-image.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import {
55
resolveAttachmentDisplayURL,
66
} from '@/utils/agent-file-url';
77
import { hasNativeJSBridge } from '@/utils/bridge-runtime';
8-
import { useCallback, useEffect, useRef, useState } from 'react';
8+
import {
9+
useCallback,
10+
useEffect,
11+
useRef,
12+
useState,
13+
type CSSProperties,
14+
} from 'react';
915
import { Spinner } from '../loading/spinner';
1016
import { Menu } from '@/utils/webview-context-menu';
1117
import { EyeSFSymbolMedium } from '@/assets/sf-symbols/medium/eye';
@@ -14,8 +20,10 @@ import {
1420
previewAttachmentSource,
1521
previewSourceRectForElement,
1622
} from './file-reference-actions';
23+
import { fitImagePreviewSize } from './image-preview-size';
1724

1825
type PreviewButtonTone = 'dark' | 'light';
26+
type NaturalImageSize = { width: number; height: number };
1927

2028
function detectPreviewButtonTone(image: HTMLImageElement): PreviewButtonTone {
2129
const naturalWidth = image.naturalWidth;
@@ -95,6 +103,7 @@ export const AttachmentImage = ({
95103
sourcePath,
96104
environmentId,
97105
className,
106+
style,
98107
...props
99108
}: {
100109
className?: string;
@@ -110,6 +119,7 @@ export const AttachmentImage = ({
110119
const [resolvedSrc, setResolvedSrc] = useState<string | null>(null);
111120
const [previewButtonTone, setPreviewButtonTone] =
112121
useState<PreviewButtonTone>('dark');
122+
const [naturalSize, setNaturalSize] = useState<NaturalImageSize | null>(null);
113123
const figureRef = useRef<HTMLElement | null>(null);
114124
const imageRef = useRef<HTMLImageElement | null>(null);
115125

@@ -229,6 +239,7 @@ export const AttachmentImage = ({
229239
setStatus('loading');
230240
setResolvedSrc(null);
231241
setPreviewButtonTone('dark');
242+
setNaturalSize(null);
232243
resolveUrl();
233244

234245
return () => {
@@ -247,6 +258,12 @@ export const AttachmentImage = ({
247258
return;
248259
}
249260

261+
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
262+
setNaturalSize({
263+
width: image.naturalWidth,
264+
height: image.naturalHeight,
265+
});
266+
}
250267
setPreviewButtonTone(detectPreviewButtonTone(image));
251268
if (resolvedSrc) {
252269
preparePreviewAsset(resolvedSrc, fileName, mimeType);
@@ -330,6 +347,22 @@ export const AttachmentImage = ({
330347
]
331348
);
332349

350+
const previewSize = naturalSize
351+
? fitImagePreviewSize(naturalSize.width, naturalSize.height)
352+
: null;
353+
const imageStyle =
354+
previewSize && naturalSize
355+
? ({
356+
width: previewSize.width,
357+
maxHeight: previewSize.height,
358+
aspectRatio: `${naturalSize.width} / ${naturalSize.height}`,
359+
} satisfies CSSProperties)
360+
: undefined;
361+
const figureStyle = {
362+
...style,
363+
...(previewSize ? { width: previewSize.width } : {}),
364+
} satisfies CSSProperties;
365+
333366
return (
334367
<figure
335368
ref={node => {
@@ -338,9 +371,10 @@ export const AttachmentImage = ({
338371
onContextMenu={handleContextMenu}
339372
className={cn(
340373
'group/image overflow-hidden rounded-lg border border-black/10 dark:border-white/20 relative',
341-
'min-h-24 w-fit',
374+
'inline-flex min-h-[120px] min-w-[160px] max-w-full items-center justify-center bg-black/5 dark:bg-white/5',
342375
className
343376
)}
377+
style={figureStyle}
344378
data-source-path={sourcePath}
345379
{...props}
346380
>
@@ -403,9 +437,10 @@ export const AttachmentImage = ({
403437
alt="Uploaded attachment"
404438
className={cn(
405439
'attachment-image',
406-
'h-full max-h-[inherit] w-auto object-contain transition-opacity duration-200',
440+
'block h-auto max-w-full object-contain transition-opacity duration-200',
407441
status !== 'loaded' && 'opacity-0'
408442
)}
443+
style={imageStyle}
409444
loading={isDataUrl ? undefined : 'lazy'}
410445
decoding="async"
411446
onLoad={handleLoad}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
export type ImagePreviewBounds = {
2+
minWidth: number;
3+
minHeight: number;
4+
maxWidth: number;
5+
maxHeight: number;
6+
};
7+
8+
export type ImagePreviewSize = {
9+
width: number;
10+
height: number;
11+
scale: number;
12+
};
13+
14+
export const imagePreviewBounds: ImagePreviewBounds = {
15+
minWidth: 160,
16+
minHeight: 120,
17+
maxWidth: 640,
18+
maxHeight: 520,
19+
};
20+
21+
export function fitImagePreviewSize(
22+
naturalWidth: number,
23+
naturalHeight: number,
24+
bounds: ImagePreviewBounds = imagePreviewBounds
25+
): ImagePreviewSize | null {
26+
if (
27+
!isPositiveFiniteNumber(naturalWidth) ||
28+
!isPositiveFiniteNumber(naturalHeight)
29+
) {
30+
return null;
31+
}
32+
33+
const maxScale = Math.min(
34+
bounds.maxWidth / naturalWidth,
35+
bounds.maxHeight / naturalHeight
36+
);
37+
let scale = 1;
38+
39+
if (maxScale < 1) {
40+
scale = maxScale;
41+
} else if (
42+
naturalWidth < bounds.minWidth ||
43+
naturalHeight < bounds.minHeight
44+
) {
45+
const minScale = Math.max(
46+
bounds.minWidth / naturalWidth,
47+
bounds.minHeight / naturalHeight
48+
);
49+
scale = Math.min(minScale, maxScale);
50+
}
51+
52+
return {
53+
width: Math.max(1, Math.round(naturalWidth * scale)),
54+
height: Math.max(1, Math.round(naturalHeight * scale)),
55+
scale,
56+
};
57+
}
58+
59+
function isPositiveFiniteNumber(value: number): boolean {
60+
return Number.isFinite(value) && value > 0;
61+
}

web/src/embedded/chat/components/messages/user-message.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export const UserMessage = ({
232232
mimeType={image.mimeType ?? undefined}
233233
sourcePath={image.fileRef?.path}
234234
environmentId={image.fileRef?.environmentId ?? undefined}
235-
className="h-24 shrink-0"
235+
className="shrink-0"
236236
/>
237237
) : null
238238
)}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { fitImagePreviewSize } from '../../src/embedded/chat/components/messages/image-preview-size';
3+
4+
describe('fitImagePreviewSize', () => {
5+
it('keeps images at their natural size when they are inside the preview bounds', () => {
6+
expect(fitImagePreviewSize(400, 300)).toMatchObject({
7+
width: 400,
8+
height: 300,
9+
scale: 1,
10+
});
11+
});
12+
13+
it('scales oversized images down to fit the maximum preview bounds', () => {
14+
expect(fitImagePreviewSize(1436, 1240)).toMatchObject({
15+
width: 602,
16+
height: 520,
17+
});
18+
});
19+
20+
it('scales tiny images up to satisfy the minimum preview bounds', () => {
21+
expect(fitImagePreviewSize(80, 60)).toMatchObject({
22+
width: 160,
23+
height: 120,
24+
});
25+
});
26+
27+
it('preserves aspect ratio and lets max bounds win for very tall images', () => {
28+
expect(fitImagePreviewSize(300, 1200)).toMatchObject({
29+
width: 130,
30+
height: 520,
31+
});
32+
});
33+
});

0 commit comments

Comments
 (0)