hackdex-website/src/components/PixelImage.tsx
2025-10-21 22:23:21 -10:00

111 lines
4.2 KiB
TypeScript

"use client";
import React from "react";
type PixelImageProps = {
src: string;
alt: string;
mode?: "cover" | "contain"; // cover: fill and crop, contain: letterbox without cropping
className?: string; // applied to wrapper
imgClassName?: string; // applied to img
style?: React.CSSProperties; // wrapper style
onClick?: React.MouseEventHandler<HTMLDivElement>;
};
export default function PixelImage({ src, alt, mode = "cover", className = "", imgClassName = "", style, onClick }: PixelImageProps) {
const wrapperRef = React.useRef<HTMLDivElement | 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
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) });
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Track DPR so we can snap scales to integer device-pixel multiples even under zoom
React.useEffect(() => {
const updateDpr = () => setDevicePixelRatioState(window.devicePixelRatio || 1);
updateDpr();
window.addEventListener("resize", updateDpr);
window.addEventListener("orientationchange", updateDpr);
// Some browsers support matchMedia for resolution changes
let mm: MediaQueryList | null = null;
try {
mm = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
const onChange = () => updateDpr();
mm.addEventListener && mm.addEventListener("change", onChange);
} catch {}
return () => {
window.removeEventListener("resize", updateDpr);
window.removeEventListener("orientationchange", updateDpr);
try {
if (mm && (mm as any).removeEventListener) (mm as any).removeEventListener("change", updateDpr);
} catch {}
};
}, []);
// Determine integer scaling factor
const scale = React.useMemo(() => {
const iw = naturalSize.width;
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));
}
// 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;
return (
<div ref={wrapperRef} className={`relative overflow-hidden w-full h-full ${className}`.trim()} style={style} onClick={onClick}>
{/* We purposefully avoid Next/Image here to fully control integer scaling */}
<img
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 });
}}
className={`pointer-events-none select-none ${imgClassName}`.trim()}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: widthPx ? `${widthPx}px` : undefined,
height: heightPx ? `${heightPx}px` : undefined,
maxWidth: "max-content",
imageRendering: "pixelated",
}}
/>
</div>
);
}