mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-05-10 22:21:46 -05:00
111 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|
|
|