mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-10 04:28:50 +01:00
🏞️ fix: Image Preview Refactor with Accessibility Enhancements (#11217)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🔧 fix: Prevent race condition by saving user messages before final event in ResumableAgentController
- Updated the ResumableAgentController to save user messages prior to sending the final event. This change addresses a potential race condition where the client might refetch data before the database is updated.
- Removed redundant message saving logic that was previously located after the final event handling, ensuring a more reliable message processing flow.
* style: improve image preview dialogs with ChatGPT-like UX and accessibility
Refactored image preview dialogs (DialogImage and ImagePreview) to provide
a cleaner, more intuitive user experience similar to ChatGPT's implementation.
## DialogImage.tsx (generated images)
- Replaced OGDialog/OGDialogContent with direct Radix Dialog primitives
for finer control over behavior
- Full-screen dark overlay (bg-black/90) that closes on click outside image
- Restructured component so all interactive elements (close, download,
details panel buttons) are inside DialogPrimitive.Content for proper
focus trap and keyboard navigation
- Added onOpenAutoFocus to focus close button when dialog opens
- Added onCloseAutoFocus to return focus to trigger element on close
- Added triggerRef prop to enable focus restoration
- Removed animate-in/animate-out classes that caused stuttering on open
- Changed transition-all to transition-[margin] to prevent animation jank
- Added proper TypeScript types for component props
## ImagePreview.tsx (uploaded file thumbnails)
- Same Radix Dialog primitive refactor for consistent behavior
- Click-outside-to-close functionality
- Proper focus management with closeButtonRef and triggerRef
- Made button the container element to prevent focus ring clipping
- Added focus-visible ring styling for keyboard navigation visibility
## Image.tsx (image display component)
- Restructured so button is the outer container instead of being nested
inside a div with overflow-hidden (which was clipping focus ring)
- Added visible focus-visible:ring styling with ring-offset
- Added aria-haspopup="dialog" for screen reader context
- Added triggerRef and passed to DialogImage for focus restoration
## Accessibility improvements
- Keyboard navigation now works properly (Tab cycles through buttons)
- Escape key closes dialog (or resets zoom if zoomed in)
- Focus is trapped within dialog when open
- Focus returns to trigger element when dialog closes
- Visible focus indicators on image buttons when focused via keyboard
- Proper ARIA attributes (aria-label, aria-haspopup, aria-hidden)
## UX improvements
- Click anywhere outside the image to close (not just specific regions)
- No more weird scroll/navigation issues
- Instant dialog open without stuttering animations
- Clean, minimal overlay without container/header chrome
* refactor: Improve click handling in image preview dialogs
Updated the click handling logic in ImagePreview and DialogImage components to ensure that the dialog only closes when clicking directly on the overlay or content background, enhancing user experience by preventing unintended closures when interacting with child elements. Additionally, clarified comments to reflect the new behavior.
* chore: import order
This commit is contained in:
parent
019c59f10e
commit
d21dfba2ac
4 changed files with 355 additions and 242 deletions
|
|
@ -264,6 +264,14 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
|
|||
isNewConvo &&
|
||||
!wasAbortedBeforeComplete;
|
||||
|
||||
// Save user message BEFORE sending final event to avoid race condition
|
||||
// where client refetch happens before database is updated
|
||||
if (!client.skipSaveUserMessage && userMessage) {
|
||||
await saveMessage(req, userMessage, {
|
||||
context: 'api/server/controllers/agents/request.js - resumable user message',
|
||||
});
|
||||
}
|
||||
|
||||
if (!wasAbortedBeforeComplete) {
|
||||
const finalEvent = {
|
||||
final: true,
|
||||
|
|
@ -298,12 +306,6 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
|
|||
await decrementPendingRequest(userId);
|
||||
}
|
||||
|
||||
if (!client.skipSaveUserMessage && userMessage) {
|
||||
await saveMessage(req, userMessage, {
|
||||
context: 'api/server/controllers/agents/request.js - resumable user message',
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldGenerateTitle) {
|
||||
addTitle(req, {
|
||||
text,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import { Button } from '@librechat/client';
|
||||
import { Maximize2, X } from 'lucide-react';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import { OGDialog, OGDialogContent } from '@librechat/client';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import ProgressCircle from './ProgressCircle';
|
||||
import SourceIcon from './SourceIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -31,6 +32,8 @@ const ImagePreview = ({
|
|||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
setIsModalOpen(true);
|
||||
|
|
@ -45,6 +48,16 @@ const ImagePreview = ({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Handle click on background areas to close (only if clicking the overlay/content directly)
|
||||
const handleBackgroundClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
},
|
||||
[handleOpenChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
|
@ -57,6 +70,18 @@ const ImagePreview = ({
|
|||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [isModalOpen, handleOpenChange]);
|
||||
|
||||
const baseStyle: styleProps = {
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
|
|
@ -85,24 +110,25 @@ const ImagePreview = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn('relative size-14 rounded-xl', className)}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative size-14 overflow-hidden rounded-xl transition-shadow',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
aria-label={`View ${alt} in full size`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openModal();
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="size-full overflow-hidden rounded-xl"
|
||||
style={style}
|
||||
aria-label={`View ${alt} in full size`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openModal();
|
||||
}}
|
||||
/>
|
||||
{progress < 1 ? (
|
||||
<ProgressCircle
|
||||
circumference={circumference}
|
||||
|
|
@ -116,10 +142,6 @@ const ImagePreview = ({
|
|||
'absolute inset-0 flex transform-gpu cursor-pointer items-center justify-center rounded-xl transition-opacity duration-200 ease-in-out',
|
||||
isHovered ? 'bg-black/20 opacity-100' : 'opacity-0',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openModal();
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Maximize2
|
||||
|
|
@ -131,21 +153,51 @@ const ImagePreview = ({
|
|||
</div>
|
||||
)}
|
||||
<SourceIcon source={source} aria-label={source ? `Source: ${source}` : undefined} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<OGDialog open={isModalOpen} onOpenChange={handleOpenChange}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto"
|
||||
disableScroll={false}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
className="max-w-screen h-full max-h-screen w-full object-contain"
|
||||
<DialogPrimitive.Root open={isModalOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
className="fixed inset-0 z-[100] bg-black/90"
|
||||
onClick={handleBackgroundClick}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<DialogPrimitive.Content
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center outline-none"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
closeButtonRef.current?.focus();
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
triggerRef.current?.focus();
|
||||
}}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onClick={handleBackgroundClick}
|
||||
>
|
||||
{/* Close button */}
|
||||
<Button
|
||||
ref={closeButtonRef}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
variant="ghost"
|
||||
className="absolute right-4 top-4 z-20 h-10 w-10 p-0 text-white hover:bg-white/10"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-5" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
{/* Image container */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
className="max-h-[85vh] max-w-[90vw] object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Button, OGDialog, OGDialogContent, TooltipAnchor } from '@librechat/client';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Button, TooltipAnchor } from '@librechat/client';
|
||||
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
|
|
@ -13,7 +14,26 @@ const getQualityStyles = (quality: string): string => {
|
|||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage, args }) {
|
||||
export default function DialogImage({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
src = '',
|
||||
downloadImage,
|
||||
args,
|
||||
triggerRef,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
src?: string;
|
||||
downloadImage: () => void;
|
||||
args?: {
|
||||
prompt?: string;
|
||||
quality?: string;
|
||||
size?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
triggerRef?: React.RefObject<HTMLButtonElement>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [isPromptOpen, setIsPromptOpen] = useState(false);
|
||||
const [imageSize, setImageSize] = useState<string | null>(null);
|
||||
|
|
@ -26,6 +46,8 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const getImageSize = useCallback(async (url: string) => {
|
||||
try {
|
||||
|
|
@ -56,15 +78,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const getImageMaxWidth = () => {
|
||||
// On mobile (when panel overlays), use full width minus padding
|
||||
// On desktop, account for the side panel width
|
||||
if (isPromptOpen) {
|
||||
return window.innerWidth >= 640 ? 'calc(100vw - 22rem)' : 'calc(100vw - 2rem)';
|
||||
}
|
||||
return 'calc(100vw - 2rem)';
|
||||
};
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setZoom(1);
|
||||
setPanX(0);
|
||||
|
|
@ -80,7 +93,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
if (zoom > 1) {
|
||||
resetZoom();
|
||||
} else {
|
||||
// Zoom in to 2x on double click when at normal zoom
|
||||
setZoom(2);
|
||||
}
|
||||
}, [zoom, resetZoom]);
|
||||
|
|
@ -94,13 +106,11 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// Calculate zoom factor
|
||||
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.min(Math.max(zoom * zoomFactor, 1), 5);
|
||||
|
||||
if (newZoom === zoom) return;
|
||||
|
||||
// If zooming back to 1, reset pan to center the image
|
||||
if (newZoom === 1) {
|
||||
setZoom(1);
|
||||
setPanX(0);
|
||||
|
|
@ -108,11 +118,9 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
return;
|
||||
}
|
||||
|
||||
// Calculate the zoom center relative to the current viewport
|
||||
const containerCenterX = rect.width / 2;
|
||||
const containerCenterY = rect.height / 2;
|
||||
|
||||
// Calculate new pan position to zoom towards mouse cursor
|
||||
const zoomRatio = newZoom / zoom;
|
||||
const deltaX = (mouseX - containerCenterX - panX) * (zoomRatio - 1);
|
||||
const deltaY = (mouseY - containerCenterY - panY) * (zoomRatio - 1);
|
||||
|
|
@ -147,15 +155,41 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
},
|
||||
[isDragging, dragStart, zoom],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// Handle click on empty areas to close (only if clicking overlay/content directly, not children)
|
||||
const handleBackgroundClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only close if clicking directly on overlay/content background
|
||||
if (e.target !== e.currentTarget) {
|
||||
return;
|
||||
}
|
||||
// Don't close if zoomed (user might be panning)
|
||||
if (zoom > 1) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onOpenChange, zoom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && resetZoom();
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (zoom > 1) {
|
||||
resetZoom();
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [resetZoom]);
|
||||
}, [resetZoom, onOpenChange, isOpen, zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && src) {
|
||||
|
|
@ -164,7 +198,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
}
|
||||
}, [isOpen, src, getImageSize, resetZoom]);
|
||||
|
||||
// Ensure image is centered when zoom changes to 1
|
||||
useEffect(() => {
|
||||
if (zoom === 1) {
|
||||
setPanX(0);
|
||||
|
|
@ -172,7 +205,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
}
|
||||
}, [zoom]);
|
||||
|
||||
// Reset pan when panel opens/closes to maintain centering
|
||||
useEffect(() => {
|
||||
if (zoom === 1) {
|
||||
setPanX(0);
|
||||
|
|
@ -180,35 +212,75 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
}
|
||||
}, [isPromptOpen, zoom]);
|
||||
|
||||
// Lock body scroll when dialog is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const imageDetailsLabel = isPromptOpen
|
||||
? localize('com_ui_hide_image_details')
|
||||
: localize('com_ui_show_image_details');
|
||||
|
||||
// Calculate image max dimensions accounting for side panel (w-80 = 320px)
|
||||
const getImageMaxWidth = () => {
|
||||
if (isPromptOpen) {
|
||||
// On mobile, panel overlays so use full width; on desktop, subtract panel width
|
||||
return typeof window !== 'undefined' && window.innerWidth >= 640
|
||||
? 'calc(90vw - 320px)'
|
||||
: '90vw';
|
||||
}
|
||||
return '90vw';
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="h-full w-full rounded-none bg-transparent"
|
||||
disableScroll={false}
|
||||
overlayClassName="bg-surface-primary opacity-95 z-50"
|
||||
>
|
||||
<div
|
||||
className={`ease-[cubic-bezier(0.175,0.885,0.32,1.275)] absolute left-0 top-0 z-10 flex items-center justify-between p-3 transition-all duration-500 sm:p-4 ${isPromptOpen ? 'right-0 sm:right-80' : 'right-0'}`}
|
||||
<DialogPrimitive.Root open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
className="fixed inset-0 z-[100] bg-black/90"
|
||||
onClick={handleBackgroundClick}
|
||||
/>
|
||||
<DialogPrimitive.Content
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center outline-none"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
closeButtonRef.current?.focus();
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
triggerRef?.current?.focus();
|
||||
}}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onClick={handleBackgroundClick}
|
||||
>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_close')}
|
||||
render={
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0 hover:bg-surface-hover"
|
||||
aria-label={localize('com_ui_close')}
|
||||
>
|
||||
<X className="size-7 sm:size-6" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
{/* Close button - top left */}
|
||||
<div className="absolute left-4 top-4 z-20">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_close')}
|
||||
render={
|
||||
<Button
|
||||
ref={closeButtonRef}
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0 text-white hover:bg-white/10"
|
||||
aria-label={localize('com_ui_close')}
|
||||
>
|
||||
<X className="size-6" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons - top right (336px = 320px panel + 16px gap) */}
|
||||
<div
|
||||
className={`absolute top-4 z-20 flex items-center gap-2 transition-[right] duration-300 ${isPromptOpen ? 'right-[336px]' : 'right-4'}`}
|
||||
>
|
||||
{zoom > 1 && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_reset_zoom')}
|
||||
|
|
@ -216,10 +288,10 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
<Button
|
||||
onClick={resetZoom}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0"
|
||||
className="h-10 w-10 p-0 text-white hover:bg-white/10"
|
||||
aria-label={localize('com_ui_reset_zoom')}
|
||||
>
|
||||
<RotateCcw className="size-6" aria-hidden="true" />
|
||||
<RotateCcw className="size-5" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
|
@ -230,10 +302,10 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
<Button
|
||||
onClick={() => downloadImage()}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0"
|
||||
className="h-10 w-10 p-0 text-white hover:bg-white/10"
|
||||
aria-label={localize('com_ui_download')}
|
||||
>
|
||||
<ArrowDownToLine className="size-6" aria-hidden="true" />
|
||||
<ArrowDownToLine className="size-5" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
|
@ -243,143 +315,129 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
<Button
|
||||
onClick={() => setIsPromptOpen(!isPromptOpen)}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0"
|
||||
className="h-10 w-10 p-0 text-white hover:bg-white/10"
|
||||
aria-label={imageDetailsLabel}
|
||||
>
|
||||
{isPromptOpen ? (
|
||||
<PanelLeftOpen className="size-7 sm:size-6" aria-hidden="true" />
|
||||
<PanelLeftOpen className="size-5" aria-hidden="true" />
|
||||
) : (
|
||||
<PanelLeftClose className="size-7 sm:size-6" aria-hidden="true" />
|
||||
<PanelLeftClose className="size-5" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area with image */}
|
||||
<div
|
||||
className={`ease-[cubic-bezier(0.175,0.885,0.32,1.275)] flex h-full transition-all duration-500 ${isPromptOpen ? 'mr-0 sm:mr-80' : 'mr-0'}`}
|
||||
>
|
||||
{/* Image container - centered */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-1 items-center justify-center px-2 pb-4 pt-16 sm:px-4 sm:pt-20"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
style={{
|
||||
cursor: getCursor(),
|
||||
overflow: zoom > 1 ? 'hidden' : 'visible',
|
||||
minHeight: 0, // Allow flexbox to shrink
|
||||
}}
|
||||
className={`transition-[margin] duration-300 ${isPromptOpen ? 'mr-80' : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center transition-transform duration-100 ease-out"
|
||||
style={{
|
||||
transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
|
||||
transformOrigin: 'center center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
style={{ cursor: getCursor() }}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt="Image"
|
||||
className="block object-contain"
|
||||
<div
|
||||
className="transition-transform duration-100 ease-out"
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 8rem)',
|
||||
maxWidth: getImageMaxWidth(),
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side Panel */}
|
||||
<div
|
||||
className={`sm:shadow-l-lg ease-[cubic-bezier(0.175,0.885,0.32,1.275)] fixed right-0 top-0 z-20 h-full w-full transform border-l border-border-light bg-surface-primary shadow-2xl backdrop-blur-sm transition-transform duration-500 sm:w-80 sm:rounded-l-2xl ${
|
||||
isPromptOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Mobile pull handle - removed for cleaner look */}
|
||||
|
||||
<div className="h-full overflow-y-auto p-4 sm:p-6">
|
||||
{/* Mobile close button */}
|
||||
<div className="mb-4 flex items-center justify-between sm:hidden">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
{localize('com_ui_image_details')}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={() => setIsPromptOpen(false)}
|
||||
variant="ghost"
|
||||
className="h-12 w-12 p-0"
|
||||
>
|
||||
<X className="size-6" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 hidden sm:block">
|
||||
<h3 className="mb-2 text-lg font-semibold text-text-primary">
|
||||
{localize('com_ui_image_details')}
|
||||
</h3>
|
||||
<div className="mb-4 h-px bg-border-medium"></div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Prompt Section */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_prompt')}
|
||||
</h4>
|
||||
<div className="rounded-md bg-surface-tertiary p-3">
|
||||
<p className="text-sm leading-relaxed text-text-primary">
|
||||
{args?.prompt || 'No prompt available'}
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
alt="Image"
|
||||
className="block max-h-[85vh] object-contain"
|
||||
style={{
|
||||
maxWidth: getImageMaxWidth(),
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generation Settings */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_generation_settings')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-primary">{localize('com_ui_size')}:</span>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{args?.size || 'Unknown'}
|
||||
</span>
|
||||
{/* Side Panel */}
|
||||
<div
|
||||
data-side-panel
|
||||
className={`fixed right-0 top-0 z-30 h-full w-80 transform border-l border-white/10 bg-surface-primary shadow-2xl transition-transform duration-300 ${
|
||||
isPromptOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
{localize('com_ui_image_details')}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={() => setIsPromptOpen(false)}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0 sm:hidden"
|
||||
>
|
||||
<X className="size-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 h-px bg-border-medium"></div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Prompt Section */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_prompt')}
|
||||
</h4>
|
||||
<div className="rounded-md bg-surface-tertiary p-3">
|
||||
<p className="text-sm leading-relaxed text-text-primary">
|
||||
{args?.prompt || 'No prompt available'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-primary">{localize('com_ui_quality')}:</span>
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium capitalize ${getQualityStyles(args?.quality || '')}`}
|
||||
>
|
||||
{args?.quality || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-primary">
|
||||
{localize('com_ui_file_size')}:
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{imageSize || 'Loading...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Generation Settings */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_generation_settings')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-primary">{localize('com_ui_size')}:</span>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{args?.size || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-primary">
|
||||
{localize('com_ui_quality')}:
|
||||
</span>
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium capitalize ${getQualityStyles(args?.quality || '')}`}
|
||||
>
|
||||
{args?.quality || 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-primary">
|
||||
{localize('com_ui_file_size')}:
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{imageSize || 'Loading...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ 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);
|
||||
|
||||
|
|
@ -96,52 +97,52 @@ const Image = ({
|
|||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<div
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={`View ${altText} in dialog`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={cn(
|
||||
'relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md',
|
||||
'relative mt-1 flex h-auto w-full max-w-lg cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md transition-shadow',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`View ${altText} in dialog`}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<LazyLoadImage
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
visibleByDefault={true}
|
||||
className={cn(
|
||||
'opacity-100 transition-opacity duration-100',
|
||||
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}]`)}
|
||||
aria-label="Loading image"
|
||||
aria-busy="true"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
{isLoaded && (
|
||||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={absoluteImageUrl}
|
||||
downloadImage={downloadImage}
|
||||
args={args}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<LazyLoadImage
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
visibleByDefault={true}
|
||||
className={cn(
|
||||
'opacity-100 transition-opacity duration-100',
|
||||
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}]`)}
|
||||
aria-label="Loading image"
|
||||
aria-busy="true"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
{isLoaded && (
|
||||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={absoluteImageUrl}
|
||||
downloadImage={downloadImage}
|
||||
args={args}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue