From 0b26337440088d2c46b73292aa397e070d11a933 Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Mon, 22 Dec 2025 18:55:20 -1000 Subject: [PATCH] Fix issues with PixelImage rendering --- src/components/PixelImage.tsx | 196 +++++++++++++++++++++++++++++++--- 1 file changed, 180 insertions(+), 16 deletions(-) diff --git a/src/components/PixelImage.tsx b/src/components/PixelImage.tsx index 7623331..42becb0 100644 --- a/src/components/PixelImage.tsx +++ b/src/components/PixelImage.tsx @@ -1,6 +1,9 @@ "use client"; import React from "react"; +import { AiOutlineLoading3Quarters } from "react-icons/ai"; + +const MAX_ATTEMPTS = 3; type PixelImageProps = { src: string; @@ -14,23 +17,112 @@ type PixelImageProps = { export default function PixelImage({ src, alt, mode = "cover", className = "", imgClassName = "", style, onClick }: PixelImageProps) { const wrapperRef = React.useRef(null); + const imgRef = React.useRef(null); const [containerSize, setContainerSize] = React.useState<{ width: number; height: number }>({ width: 0, height: 0 }); const [naturalSize, setNaturalSize] = React.useState<{ width: number; height: number }>({ width: 0, height: 0 }); const [devicePixelRatioState, setDevicePixelRatioState] = React.useState(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1); - // Observe container size + // Helper to check and set natural size from image + const checkImageNaturalSize = React.useCallback((img: HTMLImageElement | null) => { + if (!img) return false; + const iw = img.naturalWidth || 0; + const ih = img.naturalHeight || 0; + if (iw > 0 && ih > 0) { + setNaturalSize({ width: iw, height: ih }); + return true; + } + return false; + }, []); + + // Helper to measure and update container size + const measureContainer = React.useCallback(() => { + if (!wrapperRef.current) return false; + const cr = wrapperRef.current.getBoundingClientRect(); + const width = Math.max(0, cr.width); + const height = Math.max(0, cr.height); + + // Only update if we have valid dimensions, or if this is the initial measurement + setContainerSize((prev) => { + if (width > 0 && height > 0) { + return { width, height }; + } else if (prev.width === 0 && prev.height === 0) { + // Allow setting to 0 if we haven't had dimensions yet (initial state) + return { width, height }; + } else { + // Preserve previous valid dimensions if we measured 0 + return prev; + } + }); + + return width > 0 && height > 0; + }, []); + + // Observe container size with ResizeObserver and measure after mount React.useEffect(() => { if (!wrapperRef.current) return; const el = wrapperRef.current; - const ro = new ResizeObserver((entries) => { - for (const entry of entries) { - const cr = entry.contentRect; - setContainerSize({ width: Math.max(0, cr.width), height: Math.max(0, cr.height) }); + + const ro = new ResizeObserver(() => { + const gotValidDimensions = measureContainer(); + + // If ResizeObserver fired but we still don't have dimensions, retry after a delay + // This handles the refresh case where ResizeObserver fires before layout is complete + if (!gotValidDimensions) { + setTimeout(() => { + if (el) { + const retryCr = el.getBoundingClientRect(); + if (retryCr.width > 0 && retryCr.height > 0) { + measureContainer(); + } else { + // Try one more time with another delay + setTimeout(() => measureContainer(), 50); + } + } + }, 50); } }); ro.observe(el); - return () => ro.disconnect(); - }, []); + + // Measure immediately using requestAnimationFrame to catch layout as soon as it's ready + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Double RAF ensures we're after layout paint + if (wrapperRef.current === el) { + measureContainer(); + } + }); + }); + + // Also try with a small setTimeout as backup + const timeout = setTimeout(() => { + if (wrapperRef.current === el) { + measureContainer(); + } + }, 10); + + return () => { + ro.disconnect(); + clearTimeout(timeout); + }; + }, [measureContainer]); + + // Periodically check if image has loaded (fallback for cached images where onLoad might not fire properly) + React.useEffect(() => { + if (!imgRef.current || naturalSize.width > 0) return; + + let attempts = 0; + const maxAttempts = 20; + const checkInterval = setInterval(() => { + attempts++; + if (imgRef.current && checkImageNaturalSize(imgRef.current)) { + clearInterval(checkInterval); + } else if (attempts >= maxAttempts) { + clearInterval(checkInterval); + } + }, 50); + + return () => clearInterval(checkInterval); + }, [checkImageNaturalSize, naturalSize.width]); // Track DPR so we can snap scales to integer device-pixel multiples even under zoom React.useEffect(() => { @@ -60,36 +152,106 @@ export default function PixelImage({ src, alt, mode = "cover", className = "", i const ih = naturalSize.height; const cw = containerSize.width; const ch = containerSize.height; + if (iw <= 0 || ih <= 0 || cw <= 0 || ch <= 0) return 1; + const scaleX = cw / iw; const scaleY = ch / ih; + if (mode === "cover") { const required = Math.max(scaleX, scaleY); // Snap UP to the nearest step to ensure we cover the container return Math.max(1, Math.ceil(required)); + } else { + // contain + const allowed = Math.min(scaleX, scaleY); + // Snap DOWN to nearest step to avoid overflow + return Math.max(1, Math.floor(allowed)); } - // contain - const allowed = Math.min(scaleX, scaleY); - // Snap DOWN to nearest step to avoid overflow - return Math.max(1, Math.floor(allowed)); }, [naturalSize, containerSize, mode, devicePixelRatioState]); const widthPx = naturalSize.width > 0 ? naturalSize.width * scale : undefined; const heightPx = naturalSize.height > 0 ? naturalSize.height * scale : undefined; + // Check if we have both dimensions ready to prevent visual "pop" + const isReady = naturalSize.width > 0 && naturalSize.height > 0 && containerSize.width > 0 && containerSize.height > 0; + + // Ref assignment - measure immediately when element is mounted + const setRef = React.useCallback((el: HTMLDivElement | null) => { + wrapperRef.current = el; + if (el) { + // Measure immediately using requestAnimationFrame to ensure layout is complete + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Double RAF ensures we're after layout paint + if (wrapperRef.current === el) { + measureContainer(); + } + }); + }); + } + }, [measureContainer]); + + // Set image ref and check if already loaded (for cached images on refresh) + const setImgRef = React.useCallback((img: HTMLImageElement | null) => { + imgRef.current = img; + if (img && img.complete) { + checkImageNaturalSize(img); + } + }, [checkImageNaturalSize]); + return ( -
+
+ {/* Loading spinner */} + {!isReady && ( +
+ +
+ )} + {/* We purposefully avoid Next/Image here to fully control integer scaling */} {alt} { const img = e.currentTarget; - // Guard against zero values which can happen briefly - const iw = img.naturalWidth || 0; - const ih = img.naturalHeight || 0; - setNaturalSize({ width: iw, height: ih }); + + // Function to check and set natural size, retrying if needed + const checkAndSetNaturalSize = (attempt = 0) => { + const iw = img.naturalWidth || 0; + const ih = img.naturalHeight || 0; + + if (iw > 0 && ih > 0) { + setNaturalSize({ width: iw, height: ih }); + // Re-check container size when image loads (handles cached images on refresh) + setTimeout(() => { + const gotValidDimensions = measureContainer(); + if (!gotValidDimensions) { + setTimeout(() => measureContainer(), 50); + } + }, 10); + } else if (attempt < MAX_ATTEMPTS) { + // Retry if natural dimensions aren't ready yet (can happen with cached images on refresh) + setTimeout(() => checkAndSetNaturalSize(attempt + 1), 10 * (attempt + 1)); + } else { + setNaturalSize({ width: 0, height: 0 }); + } + }; + + checkAndSetNaturalSize(); }} className={`pointer-events-none select-none ${imgClassName}`.trim()} style={{ @@ -101,6 +263,8 @@ export default function PixelImage({ src, alt, mode = "cover", className = "", i height: heightPx ? `${heightPx}px` : undefined, maxWidth: "max-content", imageRendering: "pixelated", + opacity: isReady ? 1 : 0, + transition: isReady ? "opacity 0.1s" : "none", }} />