mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Fix issues with PixelImage rendering
This commit is contained in:
@@ -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<HTMLDivElement | null>(null);
|
||||
const imgRef = React.useRef<HTMLImageElement | null>(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<number>(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 (
|
||||
<div ref={wrapperRef} className={`relative overflow-hidden w-full h-full ${className}`.trim()} style={style} onClick={onClick}>
|
||||
<div ref={setRef} className={`relative overflow-hidden w-full h-full ${className}`.trim()} style={style} onClick={onClick}>
|
||||
{/* Loading spinner */}
|
||||
{!isReady && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
opacity: isReady ? 0 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<AiOutlineLoading3Quarters
|
||||
className="animate-spin color-[var(--foreground)] opacity-50"
|
||||
size={32}
|
||||
aria-label="Loading image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* We purposefully avoid Next/Image here to fully control integer scaling */}
|
||||
<img
|
||||
ref={setImgRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
onLoad={(e) => {
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user