Unified · Split
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { memo, useCallback, useEffect, useState } from "react"; | |
| 1 | +import { useEffect, useEffectEvent, useState } from "react"; | |
| 2 | 2 | import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; |
| 3 | 3 | import { Button } from "../ui/button"; |
| 4 | 4 | import type { ExpandedImagePreview } from "./ExpandedImagePreview"; |
| @@ -8,52 +8,80 @@ interface ExpandedImageDialogProps { · 3 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]); | |
| 11 | +export function ExpandedImageDialog({ preview, onClose }: ExpandedImageDialogProps) { | |
| 12 | + return ( | |
| 13 | + <ExpandedImageDialogContent | |
| 14 | + key={getPreviewResetKey(preview)} | |
| 15 | + preview={preview} | |
| 16 | + onClose={onClose} | |
| 17 | + /> | |
| 18 | + ); | |
| 19 | +} | |
| 21 | 20 | |
| 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 | − }, []); | |
| 21 | +function getPreviewResetKey(preview: ExpandedImagePreview): string { | |
| 22 | + return [ | |
| 23 | + preview.index, | |
| 24 | + preview.images.length, | |
| 25 | + ...preview.images.map((image) => `${image.src}\u0000${image.name}`), | |
| 26 | + ].join("\u0001"); | |
| 27 | +} | |
| 31 | 28 | |
| 32 | − useEffect(() => { | |
| 33 | − const onKeyDown = (event: globalThis.KeyboardEvent) => { | |
| 34 | − if (event.key === "Escape") { | |
| 35 | − event.preventDefault(); | |
| 36 | − event.stopPropagation(); | |
| 37 | − onClose(); | |
| 38 | − return; | |
| 39 | − } | |
| 40 | − if (preview.images.length <= 1) return; | |
| 41 | − if (event.key === "ArrowLeft") { | |
| 42 | − event.preventDefault(); | |
| 43 | − event.stopPropagation(); | |
| 44 | − navigateImage(-1); | |
| 45 | − return; | |
| 46 | − } | |
| 47 | − if (event.key !== "ArrowRight") return; | |
| 29 | +function useExpandedImageKeyboardShortcuts(input: { | |
| 30 | + readonly canNavigate: boolean; | |
| 31 | + readonly onClose: () => void; | |
| 32 | + readonly onNext: () => void; | |
| 33 | + readonly onPrevious: () => void; | |
| 34 | +}) { | |
| 35 | + const handleKeyDown = useEffectEvent((event: globalThis.KeyboardEvent) => { | |
| 36 | + if (event.key === "Escape") { | |
| 37 | + event.preventDefault(); | |
| 38 | + event.stopPropagation(); | |
| 39 | + input.onClose(); | |
| 40 | + return; | |
| 41 | + } | |
| 42 | + if (!input.canNavigate) return; | |
| 43 | + if (event.key === "ArrowLeft") { | |
| 48 | 44 | event.preventDefault(); |
| 49 | 45 | event.stopPropagation(); |
| 50 | − navigateImage(1); | |
| 51 | − }; | |
| 46 | + input.onPrevious(); | |
| 47 | + return; | |
| 48 | + } | |
| 49 | + if (event.key !== "ArrowRight") return; | |
| 50 | + event.preventDefault(); | |
| 51 | + event.stopPropagation(); | |
| 52 | + input.onNext(); | |
| 53 | + }); | |
| 54 | + | |
| 55 | + useEffect(() => { | |
| 56 | + const onKeyDown = (event: globalThis.KeyboardEvent) => handleKeyDown(event); | |
| 52 | 57 | window.addEventListener("keydown", onKeyDown); |
| 53 | 58 | return () => window.removeEventListener("keydown", onKeyDown); |
| 54 | − }, [navigateImage, onClose, preview.images.length]); | |
| 59 | + }, []); | |
| 60 | +} | |
| 55 | 61 | |
| 56 | − const item = preview.images[preview.index]; | |
| 62 | +function ExpandedImageDialogContent({ preview, onClose }: ExpandedImageDialogProps) { | |
| 63 | + const [currentIndex, setCurrentIndex] = useState(preview.index); | |
| 64 | + const images = preview.images; | |
| 65 | + const canNavigate = images.length > 1; | |
| 66 | + | |
| 67 | + const navigateImage = (direction: -1 | 1) => { | |
| 68 | + setCurrentIndex((existingIndex) => { | |
| 69 | + if (!canNavigate) return existingIndex; | |
| 70 | + const safeExistingIndex = | |
| 71 | + existingIndex >= 0 && existingIndex < images.length ? existingIndex : preview.index; | |
| 72 | + const nextIndex = (safeExistingIndex + direction + images.length) % images.length; | |
| 73 | + return nextIndex === existingIndex ? existingIndex : nextIndex; | |
| 74 | + }); | |
| 75 | + }; | |
| 76 | + | |
| 77 | + useExpandedImageKeyboardShortcuts({ | |
| 78 | + canNavigate, | |
| 79 | + onClose, | |
| 80 | + onNext: () => navigateImage(1), | |
| 81 | + onPrevious: () => navigateImage(-1), | |
| 82 | + }); | |
| 83 | + | |
| 84 | + const item = images[currentIndex]; | |
| 57 | 85 | if (!item) return null; |
| 58 | 86 | |
| 59 | 87 | return ( |
| @@ -69,7 +97,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 9 unmodified lines | ||
| 69 | 97 | aria-label="Close image preview" |
| 70 | 98 | onClick={onClose} |
| 71 | 99 | /> |
| 72 | − {preview.images.length > 1 && ( | |
| 100 | + {canNavigate && ( | |
| 73 | 101 | <Button |
| 74 | 102 | type="button" |
| 75 | 103 | size="icon" |
| @@ -100,10 +128,10 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 24 unmodified lines | ||
| 100 | 128 | /> |
| 101 | 129 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> |
| 102 | 130 | {item.name} |
| 103 | − {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""} | |
| 131 | + {canNavigate ? ` (${currentIndex + 1}/${images.length})` : ""} | |
| 104 | 132 | </p> |
| 105 | 133 | </div> |
| 106 | − {preview.images.length > 1 && ( | |
| 134 | + {canNavigate && ( | |
| 107 | 135 | <Button |
| 108 | 136 | type="button" |
| 109 | 137 | size="icon" |
| @@ -117,4 +145,4 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 7 unmodified lines | ||
| 117 | 145 | )} |
| 118 | 146 | </div> |
| 119 | 147 | ); |
| 120 | −}); | |
| 148 | +} | |
| @@ -0,0 +1,8 @@ | ||
| 1 | +{ | |
| 2 | + "hasReactScan": true, | |
| 3 | + "summary": "Profiler commits: 3 total (1 mount, 2 update), last 1.30ms", | |
| 4 | + "report": { | |
| 5 | + "reactScanReport": null, | |
| 6 | + "renderEvents": [] | |
| 7 | + } | |
| 8 | +} | |
Binary file not shown.
| @@ -0,0 +1,8 @@ | ||
| 1 | +{ | |
| 2 | + "hasReactScan": true, | |
| 3 | + "summary": "Profiler commits: 5 total (1 mount, 4 update), last 0.20ms", | |
| 4 | + "report": { | |
| 5 | + "reactScanReport": null, | |
| 6 | + "renderEvents": [] | |
| 7 | + } | |
| 8 | +} | |
Binary file not shown.