Unified · Split
| @@ -147,7 +147,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; · 146 unmodified lines | |||
| 147 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | 147 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 148 | import { MessagesTimeline } from "./chat/MessagesTimeline"; | 148 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 149 | import { ChatHeader } from "./chat/ChatHeader"; | 149 | import { ChatHeader } from "./chat/ChatHeader"; |
| 150 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 150 | import { getExpandedImagePreviewKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 151 | import { NoActiveThreadState } from "./NoActiveThreadState"; | 151 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 152 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | 152 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 153 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; | 153 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| @@ -3773,7 +3773,11 @@ export default function ChatView(props: ChatViewProps) { · 3619 unmodified lines | |||
| 3773 | ) : null} | 3773 | ) : null} |
| 3774 | 3774 | ||
| 3775 | {expandedImage && ( | 3775 | {expandedImage && ( |
| 3776 | <ExpandedImageDialog preview={expandedImage} onClose={closeExpandedImage} /> | 3776 | <ExpandedImageDialog |
| 3777 | key={getExpandedImagePreviewKey(expandedImage)} | ||
| 3778 | preview={expandedImage} | ||
| 3779 | onClose={closeExpandedImage} | ||
| 3780 | /> | ||
| 3777 | )} | 3781 | )} |
| 3778 | </div> | 3782 | </div> |
| 3779 | ); | 3783 | ); |
| @@ -8,27 +8,20 @@ interface ExpandedImageDialogProps { · 7 unmodified lines | |||
| 8 | onClose: () => void; | 8 | onClose: () => void; |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | 11 | function clampImageIndex(index: number, imageCount: number): number { |
| 12 | preview: initialPreview, | 12 | if (imageCount <= 0) return 0; |
| 13 | onClose, | 13 | return Math.min(Math.max(index, 0), imageCount - 1); |
| 14 | }: ExpandedImageDialogProps) { | 14 | } |
| 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 | }, []); | ||
| 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 | useEffect(() => { | 25 | useEffect(() => { |
| 33 | const onKeyDown = (event: globalThis.KeyboardEvent) => { | 26 | const onKeyDown = (event: globalThis.KeyboardEvent) => { |
| 34 | if (event.key === "Escape") { | 27 | if (event.key === "Escape") { |
| @@ -37,7 +30,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 2 unmodified lines | |||
| 37 | onClose(); | 30 | onClose(); |
| 38 | return; | 31 | return; |
| 39 | } | 32 | } |
| 40 | if (preview.images.length <= 1) return; | 33 | if (imageCount <= 1) return; |
| 41 | if (event.key === "ArrowLeft") { | 34 | if (event.key === "ArrowLeft") { |
| 42 | event.preventDefault(); | 35 | event.preventDefault(); |
| 43 | event.stopPropagation(); | 36 | event.stopPropagation(); |
| @@ -51,9 +44,40 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 7 unmodified lines | |||
| 51 | }; | 44 | }; |
| 52 | window.addEventListener("keydown", onKeyDown); | 45 | window.addEventListener("keydown", onKeyDown); |
| 53 | return () => window.removeEventListener("keydown", onKeyDown); | 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 | if (!item) return null; | 81 | if (!item) return null; |
| 58 | 82 | ||
| 59 | return ( | 83 | return ( |
| @@ -69,14 +93,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 9 unmodified lines | |||
| 69 | aria-label="Close image preview" | 93 | aria-label="Close image preview" |
| 70 | onClick={onClose} | 94 | onClick={onClose} |
| 71 | /> | 95 | /> |
| 72 | {preview.images.length > 1 && ( | 96 | {imageCount > 1 && ( |
| 73 | <Button | 97 | <Button |
| 74 | type="button" | 98 | type="button" |
| 75 | size="icon" | 99 | size="icon" |
| 76 | variant="ghost" | 100 | variant="ghost" |
| 77 | 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" | 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 | aria-label="Previous image" | 102 | aria-label="Previous image" |
| 79 | onClick={() => navigateImage(-1)} | 103 | onClick={navigateToPreviousImage} |
| 80 | > | 104 | > |
| 81 | <ChevronLeftIcon className="size-5" /> | 105 | <ChevronLeftIcon className="size-5" /> |
| 82 | </Button> | 106 | </Button> |
| @@ -100,17 +124,17 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 17 unmodified lines | |||
| 100 | /> | 124 | /> |
| 101 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> | 125 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> |
| 102 | {item.name} | 126 | {item.name} |
| 103 | {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""} | 127 | {imageCount > 1 ? ` (${activeImageIndex + 1}/${imageCount})` : ""} |
| 104 | </p> | 128 | </p> |
| 105 | </div> | 129 | </div> |
| 106 | {preview.images.length > 1 && ( | 130 | {imageCount > 1 && ( |
| 107 | <Button | 131 | <Button |
| 108 | type="button" | 132 | type="button" |
| 109 | size="icon" | 133 | size="icon" |
| 110 | variant="ghost" | 134 | variant="ghost" |
| 111 | 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" | 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 | aria-label="Next image" | 136 | aria-label="Next image" |
| 113 | onClick={() => navigateImage(1)} | 137 | onClick={navigateToNextImage} |
| 114 | > | 138 | > |
| 115 | <ChevronRightIcon className="size-5" /> | 139 | <ChevronRightIcon className="size-5" /> |
| 116 | </Button> | 140 | </Button> |
| @@ -8,6 +8,11 @@ export interface ExpandedImagePreview { · 7 unmodified lines | |||
| 8 | index: number; | 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 | export function buildExpandedImagePreview( | 16 | export function buildExpandedImagePreview( |
| 12 | images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, | 17 | images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, |
| 13 | selectedImageId: string, | 18 | selectedImageId: string, |
Binary file not shown.
Binary file not shown.