Unified · Split
| @@ -147,7 +147,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; · 146 unmodified lines | ||
| 147 | 147 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 148 | 148 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 149 | 149 | import { ChatHeader } from "./chat/ChatHeader"; |
| 150 | −import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | |
| 150 | +import { getExpandedImagePreviewKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | |
| 151 | 151 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 152 | 152 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 153 | 153 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| @@ -3773,7 +3773,11 @@ export default function ChatView(props: ChatViewProps) { · 3619 unmodified lines | ||
| 3773 | 3773 | ) : null} |
| 3774 | 3774 | |
| 3775 | 3775 | {expandedImage && ( |
| 3776 | − <ExpandedImageDialog preview={expandedImage} onClose={closeExpandedImage} /> | |
| 3776 | + <ExpandedImageDialog | |
| 3777 | + key={getExpandedImagePreviewKey(expandedImage)} | |
| 3778 | + preview={expandedImage} | |
| 3779 | + onClose={closeExpandedImage} | |
| 3780 | + /> | |
| 3777 | 3781 | )} |
| 3778 | 3782 | </div> |
| 3779 | 3783 | ); |
| @@ -8,27 +8,20 @@ interface ExpandedImageDialogProps { · 7 unmodified lines | ||
| 8 | 8 | onClose: () => void; |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | −export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | |
| 12 | − preview: initialPreview, | |
| 13 | − onClose, | |
| 14 | −}: ExpandedImageDialogProps) { | |
| 15 | − const [preview, setPreview] = useState(initialPreview); | |
| 16 | − | |
| 17 | − // Sync when the parent hands us a new preview reference. | |
| 18 | − useEffect(() => { | |
| 19 | − setPreview(initialPreview); | |
| 20 | − }, [initialPreview]); | |
| 21 | − | |
| 22 | − const navigateImage = useCallback((direction: -1 | 1) => { | |
| 23 | − setPreview((existing) => { | |
| 24 | − if (existing.images.length <= 1) return existing; | |
| 25 | − const nextIndex = | |
| 26 | − (existing.index + direction + existing.images.length) % existing.images.length; | |
| 27 | − if (nextIndex === existing.index) return existing; | |
| 28 | − return { ...existing, index: nextIndex }; | |
| 29 | − }); | |
| 30 | − }, []); | |
| 11 | +function clampImageIndex(index: number, imageCount: number): number { | |
| 12 | + if (imageCount <= 0) return 0; | |
| 13 | + return Math.min(Math.max(index, 0), imageCount - 1); | |
| 14 | +} | |
| 31 | 15 | |
| 16 | +function useExpandedImageDialogKeyboardNavigation({ | |
| 17 | + imageCount, | |
| 18 | + navigateImage, | |
| 19 | + onClose, | |
| 20 | +}: { | |
| 21 | + imageCount: number; | |
| 22 | + navigateImage: (direction: -1 | 1) => void; | |
| 23 | + onClose: () => void; | |
| 24 | +}) { | |
| 32 | 25 | useEffect(() => { |
| 33 | 26 | const onKeyDown = (event: globalThis.KeyboardEvent) => { |
| 34 | 27 | if (event.key === "Escape") { |
| @@ -37,7 +30,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 2 unmodified lines | ||
| 37 | 30 | onClose(); |
| 38 | 31 | return; |
| 39 | 32 | } |
| 40 | − if (preview.images.length <= 1) return; | |
| 33 | + if (imageCount <= 1) return; | |
| 41 | 34 | if (event.key === "ArrowLeft") { |
| 42 | 35 | event.preventDefault(); |
| 43 | 36 | event.stopPropagation(); |
| @@ -51,9 +44,40 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 7 unmodified lines | ||
| 51 | 44 | }; |
| 52 | 45 | window.addEventListener("keydown", onKeyDown); |
| 53 | 46 | return () => window.removeEventListener("keydown", onKeyDown); |
| 54 | − }, [navigateImage, onClose, preview.images.length]); | |
| 47 | + }, [imageCount, navigateImage, onClose]); | |
| 48 | +} | |
| 49 | + | |
| 50 | +export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | |
| 51 | + preview, | |
| 52 | + onClose, | |
| 53 | +}: ExpandedImageDialogProps) { | |
| 54 | + const imageCount = preview.images.length; | |
| 55 | + const [imageIndex, setImageIndex] = useState(() => clampImageIndex(preview.index, imageCount)); | |
| 56 | + const activeImageIndex = clampImageIndex(imageIndex, imageCount); | |
| 57 | + | |
| 58 | + const navigateImage = useCallback( | |
| 59 | + (direction: -1 | 1) => { | |
| 60 | + setImageIndex((existing) => { | |
| 61 | + if (imageCount <= 1) return existing; | |
| 62 | + return (existing + direction + imageCount) % imageCount; | |
| 63 | + }); | |
| 64 | + }, | |
| 65 | + [imageCount], | |
| 66 | + ); | |
| 67 | + const navigateToPreviousImage = useCallback(() => { | |
| 68 | + navigateImage(-1); | |
| 69 | + }, [navigateImage]); | |
| 70 | + const navigateToNextImage = useCallback(() => { | |
| 71 | + navigateImage(1); | |
| 72 | + }, [navigateImage]); | |
| 73 | + | |
| 74 | + useExpandedImageDialogKeyboardNavigation({ | |
| 75 | + imageCount, | |
| 76 | + navigateImage, | |
| 77 | + onClose, | |
| 78 | + }); | |
| 55 | 79 | |
| 56 | − const item = preview.images[preview.index]; | |
| 80 | + const item = preview.images[activeImageIndex]; | |
| 57 | 81 | if (!item) return null; |
| 58 | 82 | |
| 59 | 83 | return ( |
| @@ -69,14 +93,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 9 unmodified lines | ||
| 69 | 93 | aria-label="Close image preview" |
| 70 | 94 | onClick={onClose} |
| 71 | 95 | /> |
| 72 | − {preview.images.length > 1 && ( | |
| 96 | + {imageCount > 1 && ( | |
| 73 | 97 | <Button |
| 74 | 98 | type="button" |
| 75 | 99 | size="icon" |
| 76 | 100 | variant="ghost" |
| 77 | 101 | className="absolute left-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6" |
| 78 | 102 | aria-label="Previous image" |
| 79 | − onClick={() => navigateImage(-1)} | |
| 103 | + onClick={navigateToPreviousImage} | |
| 80 | 104 | > |
| 81 | 105 | <ChevronLeftIcon className="size-5" /> |
| 82 | 106 | </Button> |
| @@ -100,17 +124,17 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 17 unmodified lines | ||
| 100 | 124 | /> |
| 101 | 125 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> |
| 102 | 126 | {item.name} |
| 103 | − {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""} | |
| 127 | + {imageCount > 1 ? ` (${activeImageIndex + 1}/${imageCount})` : ""} | |
| 104 | 128 | </p> |
| 105 | 129 | </div> |
| 106 | − {preview.images.length > 1 && ( | |
| 130 | + {imageCount > 1 && ( | |
| 107 | 131 | <Button |
| 108 | 132 | type="button" |
| 109 | 133 | size="icon" |
| 110 | 134 | variant="ghost" |
| 111 | 135 | className="absolute right-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6" |
| 112 | 136 | aria-label="Next image" |
| 113 | − onClick={() => navigateImage(1)} | |
| 137 | + onClick={navigateToNextImage} | |
| 114 | 138 | > |
| 115 | 139 | <ChevronRightIcon className="size-5" /> |
| 116 | 140 | </Button> |
| @@ -8,6 +8,11 @@ export interface ExpandedImagePreview { · 7 unmodified lines | ||
| 8 | 8 | index: number; |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | +export function getExpandedImagePreviewKey(preview: ExpandedImagePreview): string { | |
| 12 | + const selectedImage = preview.images[preview.index]; | |
| 13 | + return `${preview.index}:${preview.images.length}:${selectedImage?.src ?? ""}`; | |
| 14 | +} | |
| 15 | + | |
| 11 | 16 | export function buildExpandedImagePreview( |
| 12 | 17 | images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, |
| 13 | 18 | selectedImageId: string, |
Binary file not shown.
Binary file not shown.