Unified · Split
| @@ -1,4 +1,4 @@ | |||
| 1 | import { memo, useCallback, useEffect, useState } from "react"; | 1 | import { useEffect, useEffectEvent, useState } from "react"; |
| 2 | import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; | 2 | import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; |
| 3 | import { Button } from "../ui/button"; | 3 | import { Button } from "../ui/button"; |
| 4 | import type { ExpandedImagePreview } from "./ExpandedImagePreview"; | 4 | import type { ExpandedImagePreview } from "./ExpandedImagePreview"; |
| @@ -8,52 +8,80 @@ interface ExpandedImageDialogProps { · 3 unmodified lines | |||
| 8 | onClose: () => void; | 8 | onClose: () => void; |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | 11 | export function ExpandedImageDialog({ preview, onClose }: ExpandedImageDialogProps) { |
| 12 | preview: initialPreview, | 12 | return ( |
| 13 | onClose, | 13 | <ExpandedImageDialogContent |
| 14 | }: ExpandedImageDialogProps) { | 14 | key={getPreviewResetKey(preview)} |
| 15 | const [preview, setPreview] = useState(initialPreview); | 15 | preview={preview} |
| 16 | 16 | onClose={onClose} | |
| 17 | // Sync when the parent hands us a new preview reference. | 17 | /> |
| 18 | useEffect(() => { | 18 | ); |
| 19 | setPreview(initialPreview); | 19 | } |
| 20 | }, [initialPreview]); | ||
| 21 | 20 | ||
| 22 | const navigateImage = useCallback((direction: -1 | 1) => { | 21 | function getPreviewResetKey(preview: ExpandedImagePreview): string { |
| 23 | setPreview((existing) => { | 22 | return [ |
| 24 | if (existing.images.length <= 1) return existing; | 23 | preview.index, |
| 25 | const nextIndex = | 24 | preview.images.length, |
| 26 | (existing.index + direction + existing.images.length) % existing.images.length; | 25 | ...preview.images.map((image) => `${image.src}\u0000${image.name}`), |
| 27 | if (nextIndex === existing.index) return existing; | 26 | ].join("\u0001"); |
| 28 | return { ...existing, index: nextIndex }; | 27 | } |
| 29 | }); | ||
| 30 | }, []); | ||
| 31 | 28 | ||
| 32 | useEffect(() => { | 29 | function useExpandedImageKeyboardShortcuts(input: { |
| 33 | const onKeyDown = (event: globalThis.KeyboardEvent) => { | 30 | readonly canNavigate: boolean; |
| 34 | if (event.key === "Escape") { | 31 | readonly onClose: () => void; |
| 35 | event.preventDefault(); | 32 | readonly onNext: () => void; |
| 36 | event.stopPropagation(); | 33 | readonly onPrevious: () => void; |
| 37 | onClose(); | 34 | }) { |
| 38 | return; | 35 | const handleKeyDown = useEffectEvent((event: globalThis.KeyboardEvent) => { |
| 39 | } | 36 | if (event.key === "Escape") { |
| 40 | if (preview.images.length <= 1) return; | 37 | event.preventDefault(); |
| 41 | if (event.key === "ArrowLeft") { | 38 | event.stopPropagation(); |
| 42 | event.preventDefault(); | 39 | input.onClose(); |
| 43 | event.stopPropagation(); | 40 | return; |
| 44 | navigateImage(-1); | 41 | } |
| 45 | return; | 42 | if (!input.canNavigate) return; |
| 46 | } | 43 | if (event.key === "ArrowLeft") { |
| 47 | if (event.key !== "ArrowRight") return; | ||
| 48 | event.preventDefault(); | 44 | event.preventDefault(); |
| 49 | event.stopPropagation(); | 45 | event.stopPropagation(); |
| 50 | navigateImage(1); | 46 | input.onPrevious(); |
| 51 | }; | 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 | window.addEventListener("keydown", onKeyDown); | 57 | window.addEventListener("keydown", onKeyDown); |
| 53 | return () => window.removeEventListener("keydown", onKeyDown); | 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 | if (!item) return null; | 85 | if (!item) return null; |
| 58 | 86 | ||
| 59 | return ( | 87 | return ( |
| @@ -69,7 +97,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 9 unmodified lines | |||
| 69 | aria-label="Close image preview" | 97 | aria-label="Close image preview" |
| 70 | onClick={onClose} | 98 | onClick={onClose} |
| 71 | /> | 99 | /> |
| 72 | {preview.images.length > 1 && ( | 100 | {canNavigate && ( |
| 73 | <Button | 101 | <Button |
| 74 | type="button" | 102 | type="button" |
| 75 | size="icon" | 103 | size="icon" |
| @@ -100,10 +128,10 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 24 unmodified lines | |||
| 100 | /> | 128 | /> |
| 101 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> | 129 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> |
| 102 | {item.name} | 130 | {item.name} |
| 103 | {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""} | 131 | {canNavigate ? ` (${currentIndex + 1}/${images.length})` : ""} |
| 104 | </p> | 132 | </p> |
| 105 | </div> | 133 | </div> |
| 106 | {preview.images.length > 1 && ( | 134 | {canNavigate && ( |
| 107 | <Button | 135 | <Button |
| 108 | type="button" | 136 | type="button" |
| 109 | size="icon" | 137 | size="icon" |
| @@ -117,4 +145,4 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ · 7 unmodified lines | |||
| 117 | )} | 145 | )} |
| 118 | </div> | 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.