({
onRequestClose,
src,
alt = 'Image',
originRect,
}: ImageModalProps)
| 44 | * trapped under that portal at the same z-index. |
| 45 | */ |
| 46 | export default function ImageModal({ |
| 47 | onRequestClose, |
| 48 | src, |
| 49 | alt = 'Image', |
| 50 | originRect, |
| 51 | }: ImageModalProps): ReactElement | null { |
| 52 | const close = () => onRequestClose?.(undefined as never); |
| 53 | const imgRef = useRef<HTMLImageElement>(null); |
| 54 | const hasPlayedRef = useRef(false); |
| 55 | |
| 56 | // FLIP: render the image at its final centered size, but start it transformed |
| 57 | // to sit over the clicked thumbnail, then animate the transform away so it |
| 58 | // visually grows from the thumbnail into the full view. |
| 59 | const playFlip = () => { |
| 60 | const img = imgRef.current; |
| 61 | if (!originRect || !img || hasPlayedRef.current) { |
| 62 | return; |
| 63 | } |
| 64 | const final = img.getBoundingClientRect(); |
| 65 | if (!final.width || !final.height) { |
| 66 | // Not laid out yet (image still loading) — onLoad will retry. |
| 67 | return; |
| 68 | } |
| 69 | hasPlayedRef.current = true; |
| 70 | |
| 71 | // A single uniform scale (not independent sx/sy) so the image keeps its |
| 72 | // aspect ratio and never warps mid-flight. `min` contains the start frame |
| 73 | // within the thumbnail's footprint, so it reads as lifting off from there. |
| 74 | const scale = Math.min( |
| 75 | originRect.width / final.width, |
| 76 | originRect.height / final.height, |
| 77 | ); |
| 78 | // Translate centers together (transform-origin is the center too), so the |
| 79 | // image grows out of where the thumbnail sat rather than from a corner. |
| 80 | const dx = |
| 81 | originRect.left + originRect.width / 2 - (final.left + final.width / 2); |
| 82 | const dy = |
| 83 | originRect.top + originRect.height / 2 - (final.top + final.height / 2); |
| 84 | // Transform scales the corner radius along with everything else, so a fixed |
| 85 | // radius would render near-square at the small start scale and then "snap" |
| 86 | // round. Pre-divide by the scale so the rendered radius stays a constant |
| 87 | // ~16px (rounded-16) for the whole animation; read the resting value off the |
| 88 | // element so it tracks the class instead of a hardcoded number. |
| 89 | const restRadius = |
| 90 | parseFloat(getComputedStyle(img).borderTopLeftRadius) || 16; |
| 91 | |
| 92 | img.style.transformOrigin = 'center center'; |
| 93 | img.style.transition = 'none'; |
| 94 | img.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`; |
| 95 | img.style.borderRadius = `${restRadius / scale}px`; |
| 96 | img.style.opacity = '1'; |
| 97 | // Two frames: let the browser paint the start state first, then transition |
| 98 | // to identity — doing both in one frame can coalesce and snap. |
| 99 | requestAnimationFrame(() => { |
| 100 | requestAnimationFrame(() => { |
| 101 | img.style.transition = |
| 102 | 'transform 300ms cubic-bezier(0.16, 1, 0.3, 1), border-radius 300ms cubic-bezier(0.16, 1, 0.3, 1)'; |
| 103 | img.style.transform = 'none'; |
nothing calls this directly
no test coverage detected