From 64008a9927943f8f8c3ed7e0febc5a3e8d49cc14 Mon Sep 17 00:00:00 2001 From: Ananovo <78636812+techotaku39@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:39:12 +0800 Subject: [PATCH 1/2] fix(web): improve fullscreen image preview toolbar layout --- .../AssistantChat/AttachmentItem.tsx | 3 +- .../messages/MessageAttachments.tsx | 1 + .../AssistantChat/messages/ToolMessage.tsx | 4 + web/src/components/ImagePreview.test.tsx | 51 ++++- web/src/components/ImagePreview.tsx | 179 ++++++++++++------ web/src/components/icons.tsx | 32 ++++ web/src/routes/sessions/file.tsx | 1 + 7 files changed, 208 insertions(+), 63 deletions(-) diff --git a/web/src/components/AssistantChat/AttachmentItem.tsx b/web/src/components/AssistantChat/AttachmentItem.tsx index 8890f3b7ce..58c3780c31 100644 --- a/web/src/components/AssistantChat/AttachmentItem.tsx +++ b/web/src/components/AssistantChat/AttachmentItem.tsx @@ -38,7 +38,7 @@ function RemoveIcon() { } export function AttachmentItem() { - const { name, status, previewUrl } = useThreadComposerAttachment() as ComposerAttachmentWithPreview + const { name, status, previewUrl, file } = useThreadComposerAttachment() as ComposerAttachmentWithPreview const isParking = useComposerParking() const isUploading = status.type === 'running' const isError = status.type === 'incomplete' @@ -50,6 +50,7 @@ export function AttachmentItem() { src={previewUrl} fileName={name} label={name} + fileSize={file?.size} galleryId="composer-attachments" buttonClassName="group h-full w-full cursor-zoom-in overflow-hidden rounded-lg text-left" imageClassName="h-full w-full object-cover transition-opacity group-hover:opacity-85" diff --git a/web/src/components/AssistantChat/messages/MessageAttachments.tsx b/web/src/components/AssistantChat/messages/MessageAttachments.tsx index 2dd66a3742..d3d95216af 100644 --- a/web/src/components/AssistantChat/messages/MessageAttachments.tsx +++ b/web/src/components/AssistantChat/messages/MessageAttachments.tsx @@ -16,6 +16,7 @@ function ImageAttachment(props: { attachment: AttachmentMetadata }) { src={attachment.previewUrl ?? ''} fileName={attachment.filename} label={attachment.filename} + fileSize={attachment.size} buttonClassName="relative overflow-hidden rounded-lg text-left cursor-zoom-in" imageClassName="max-h-48 max-w-full object-contain" caption={( diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index e977fb503e..17157ce3d0 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -68,6 +68,7 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) { const ctx = useHappyChatContext() const { t } = useTranslation() const [objectUrl, setObjectUrl] = useState(null) + const [fileSize, setFileSize] = useState(undefined) const [error, setError] = useState(null) const [imageStyle, setImageStyle] = useState(undefined) const [loadMedia, setLoadMedia] = useState(false) @@ -102,6 +103,7 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) { objectUrlRef.current = null } setObjectUrl(null) + setFileSize(undefined) setImageStyle(undefined) setError(null) @@ -114,6 +116,7 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) { } objectUrlRef.current = nextObjectUrl setObjectUrl(nextObjectUrl) + setFileSize(blob.size) if (isImage) { setImageStyle(undefined) const probe = new Image() @@ -172,6 +175,7 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) { src={objectUrl} fileName={props.block.fileName} label={props.block.fileName} + fileSize={fileSize} buttonClassName="block max-h-[min(28rem,60vh)] max-w-full cursor-zoom-in rounded-xl text-left" imageClassName="max-h-[min(28rem,60vh)] max-w-full rounded-xl object-contain" imageStyle={imageStyle} diff --git a/web/src/components/ImagePreview.test.tsx b/web/src/components/ImagePreview.test.tsx index e4b8ebc8a8..33e62c2545 100644 --- a/web/src/components/ImagePreview.test.tsx +++ b/web/src/components/ImagePreview.test.tsx @@ -5,8 +5,8 @@ import { ImagePreview } from './ImagePreview' function renderGallery() { render( <> - - + + ) } @@ -18,13 +18,58 @@ describe('ImagePreview gallery navigation', () => { fireEvent.click(screen.getByRole('button', { name: /first image/i })) const dialog = screen.getByRole('dialog', { name: 'First image' }) + expect(within(dialog).getAllByRole('button').map((button) => button.getAttribute('title'))).toEqual([ + 'Close', + 'Zoom out', + 'Reset zoom', + 'Zoom in', + 'Previous image', + 'Next image', + ]) + expect(within(dialog).getAllByRole('button') + .map((button) => button.querySelector('svg')?.getAttribute('class')) + .filter(Boolean)).toEqual([ + 'h-4 w-4', + 'h-4 w-4', + 'h-4 w-4', + 'h-4 w-4', + 'h-4 w-4', + ]) + expect(within(dialog).getByRole('button', { name: '100%' })).toHaveClass( + 'flex', + 'h-8', + 'items-center', + 'justify-center', + ) + const image = within(dialog).getByRole('img', { name: 'First image' }) + Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1200 }) + Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 700 }) + fireEvent.load(image) + const desktopInfo = dialog.querySelector('[data-image-preview-info="desktop"]') + expect(desktopInfo).not.toBeNull() + expect(desktopInfo).toHaveClass('flex', 'flex-1', 'items-center', 'max-sm:hidden') + expect(desktopInfo).toHaveTextContent('first.png') + const mobileInfo = dialog.querySelector('[data-image-preview-info="mobile"]') + expect(mobileInfo).not.toBeNull() + expect(mobileInfo).toHaveClass('hidden', 'max-sm:flex', 'border-t') + const imageMetadata = within(desktopInfo as HTMLElement).getByText('1200 × 700 px · 1.5 KB') + expect(imageMetadata).toBeInTheDocument() + expect(imageMetadata).toHaveClass('shrink-0', 'text-sm', 'text-white/60') + expect(imageMetadata.parentElement).toHaveClass('items-center', 'text-sm') + expect(imageMetadata.parentElement).toHaveAttribute('data-image-preview-info', 'desktop') + expect(dialog).toHaveClass('fixed', 'inset-0', 'flex-col') + expect(within(dialog).getByRole('button', { name: 'Zoom out' }).parentElement).toHaveAttribute('data-image-preview-controls', '') + expect(within(dialog).getByRole('button', { name: 'Zoom out' }).parentElement).toHaveClass('max-sm:order-2', 'max-sm:gap-1') expect(within(dialog).getByText('1 / 2')).toBeInTheDocument() expect(within(dialog).getByRole('button', { name: 'Previous image' })).toBeDisabled() fireEvent.click(within(dialog).getByRole('button', { name: 'Next image' })) const nextDialog = screen.getByRole('dialog', { name: 'Second image' }) - expect(within(nextDialog).getByText('second.png')).toBeInTheDocument() + const nextDesktopInfo = nextDialog.querySelector('[data-image-preview-info="desktop"]') + expect(nextDesktopInfo).not.toBeNull() + expect(nextDesktopInfo).toHaveTextContent('second.png') + expect(nextDesktopInfo).toHaveTextContent('2 KB') expect(within(nextDialog).getByText('2 / 2')).toBeInTheDocument() expect(within(nextDialog).getByRole('img', { name: 'Second image' })).toHaveAttribute('src', '/second.png') expect(within(nextDialog).getByRole('button', { name: 'Next image' })).toBeDisabled() diff --git a/web/src/components/ImagePreview.tsx b/web/src/components/ImagePreview.tsx index 084cb2bc9b..23f44481d5 100644 --- a/web/src/components/ImagePreview.tsx +++ b/web/src/components/ImagePreview.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties, type MouseEvent, type PointerEvent, type ReactNode, type SyntheticEvent, type WheelEvent } from 'react' -import { CloseIcon } from '@/components/icons' +import { ArrowLeftIcon, ArrowRightIcon, CloseIcon, MinusIcon, PlusIcon } from '@/components/icons' +import { formatFileSize } from '@/lib/file-metadata' const MIN_IMAGE_SCALE = 0.25 const MAX_IMAGE_SCALE = 8 @@ -16,6 +17,13 @@ type PreviewImage = { src: string fileName: string label: string + fileSize?: number +} + +type ImageDimensions = { + src: string + width: number + height: number } function getPointDistance(a: ImagePoint, b: ImagePoint): number { @@ -29,6 +37,12 @@ function getPointCenter(a: ImagePoint, b: ImagePoint): ImagePoint { } } +function parseFileSize(value: string | undefined): number | undefined { + if (value === undefined) return undefined + const fileSize = Number(value) + return Number.isFinite(fileSize) && fileSize >= 0 ? fileSize : undefined +} + export function ImagePreview(props: { src: string fileName: string @@ -38,10 +52,12 @@ export function ImagePreview(props: { imageStyle?: CSSProperties caption?: ReactNode galleryId?: string + fileSize?: number }) { const [viewerOpen, setViewerOpen] = useState(false) const [previewImages, setPreviewImages] = useState([]) const [previewIndex, setPreviewIndex] = useState(0) + const [imageDimensions, setImageDimensions] = useState(null) const [scale, setScale] = useState(1) const [offset, setOffset] = useState({ x: 0, y: 0 }) const scaleRef = useRef(scale) @@ -67,7 +83,8 @@ export function ImagePreview(props: { return [{ src: image.getAttribute('src') ?? image.src, fileName: trigger.dataset.imagePreviewFileName ?? image.alt, - label: trigger.dataset.imagePreviewLabel ?? image.alt + label: trigger.dataset.imagePreviewLabel ?? image.alt, + fileSize: parseFileSize(trigger.dataset.imagePreviewFileSize) }] }) const index = triggers.indexOf(event.currentTarget) @@ -251,8 +268,15 @@ export function ImagePreview(props: { const activePreview = previewImages[previewIndex] ?? { src: props.src, fileName: props.fileName, - label: props.label + label: props.label, + fileSize: props.fileSize } + const activeImageDimensions = imageDimensions?.src === activePreview.src ? imageDimensions : null + const activeImageFileSize = formatFileSize(activePreview.fileSize) + const imageMetadata = [ + activeImageDimensions ? `${activeImageDimensions.width} × ${activeImageDimensions.height} px` : null, + activeImageFileSize + ].filter(Boolean).join(' · ') const hasMultiplePreviews = previewImages.length > 1 return ( @@ -267,6 +291,7 @@ export function ImagePreview(props: { data-image-preview-file-name={props.fileName} data-image-preview-label={props.label} data-image-preview-gallery={props.galleryId ?? ''} + data-image-preview-file-size={props.fileSize !== undefined ? String(props.fileSize) : undefined} className={props.buttonClassName ?? 'group flex min-h-[18rem] w-full items-center justify-center overflow-auto rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3 text-left'} title="Click to zoom" > @@ -288,69 +313,84 @@ export function ImagePreview(props: { aria-modal="true" aria-label={activePreview.label} > -
-
{activePreview.fileName}
- {hasMultiplePreviews ? ( - <> - - - {previewIndex + 1} / {previewImages.length} - - - - ) : null} - - - +
+
+ {activePreview.fileName} + {imageMetadata ? ( + + {imageMetadata} + + ) : null} +
+
+ + + + {hasMultiplePreviews ? ( + <> + + + {previewIndex + 1} / {previewImages.length} + + + + ) : null} +
{ + const { naturalWidth, naturalHeight } = event.currentTarget + if (naturalWidth > 0 && naturalHeight > 0) { + setImageDimensions({ + src: activePreview.src, + width: naturalWidth, + height: naturalHeight + }) + } + }} className="absolute left-1/2 top-1/2 max-h-[90vh] max-w-[90vw] select-none object-contain" style={{ transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) scale(${scale})`, @@ -372,6 +422,17 @@ export function ImagePreview(props: { }} />
+
+ {activePreview.fileName} + {imageMetadata ? ( + + {imageMetadata} + + ) : null} +
) : null} diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index 67da6a2a0a..63a6614ece 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -28,6 +28,38 @@ export function CloseIcon(props: IconProps) { ) } +export function ArrowLeftIcon(props: IconProps) { + return createIcon( + , + props, + 2 + ) +} + +export function ArrowRightIcon(props: IconProps) { + return createIcon( + , + props, + 2 + ) +} + +export function MinusIcon(props: IconProps) { + return createIcon( + , + props, + 2 + ) +} + +export function PlusIcon(props: IconProps) { + return createIcon( + , + props, + 2 + ) +} + export function ShareIcon(props: IconProps) { return createIcon( , diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index 9a6cbbd95a..6315469575 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -461,6 +461,7 @@ export default function FilePage() { src={imagePreviewUrl} fileName={fileName} label={t('file.page.imagePreviewAlt', { name: fileName })} + fileSize={fileContentResult?.size} /> ) : binaryFile ? (
From 8a4a6509c86a0f945a6bfe5ce145de3868f42c7e Mon Sep 17 00:00:00 2001 From: Ananovo <78636812+techotaku39@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:23:28 +0800 Subject: [PATCH 2/2] chore: retrigger PR review