import React, { useEffect, useRef, useState, useCallback } from "react"; const ZOOM_STEP = 1.1; const SCROLL_PAN_STEP = 75; const CLASSNAME = "Lightbox"; const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`; const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`; export enum DisplayMode { ORIGINAL = "ORIGINAL", FIT_XY = "FIT_XY", FIT_X = "FIT_X", } export enum ScrollMode { ZOOM = "ZOOM", PAN_Y = "PAN_Y", } interface IProps { src: string; displayMode: DisplayMode; scaleUp: boolean; scrollMode: ScrollMode; resetPosition?: boolean; zoom: number; setZoom: (v: number) => void; onLeft: () => void; onRight: () => void; } export const LightboxImage: React.FC = ({ src, onLeft, onRight, displayMode, scaleUp, scrollMode, zoom, setZoom, resetPosition, }) => { const [defaultZoom, setDefaultZoom] = useState(1); const [moving, setMoving] = useState(false); const [positionX, setPositionX] = useState(0); const [positionY, setPositionY] = useState(0); const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); const [boxWidth, setBoxWidth] = useState(0); const [boxHeight, setBoxHeight] = useState(0); const mouseDownEvent = useRef(); const resetPositionRef = useRef(resetPosition); const container = React.createRef(); const startPoints = useRef([0, 0]); const pointerCache = useRef[]>([]); const prevDiff = useRef(); useEffect(() => { const box = container.current; if (box) { setBoxWidth(box.offsetWidth); setBoxHeight(box.offsetHeight); } }, [container]); useEffect(() => { let mounted = true; const img = new Image(); function onLoad() { if (mounted) { setWidth(img.width); setHeight(img.height); } } img.onload = onLoad; img.src = src; return () => { mounted = false; }; }, [src]); useEffect(() => { // don't set anything until we have the heights if (!width || !height || !boxWidth || !boxHeight) { return; } if (!scaleUp && width < boxWidth && height < boxHeight) { setDefaultZoom(1); setPositionX(0); setPositionY(0); return; } // set initial zoom level based on options let xZoom: number; let yZoom: number; let newZoom = 1; let newPositionY = 0; switch (displayMode) { case DisplayMode.FIT_XY: xZoom = boxWidth / width; yZoom = boxHeight / height; if (!scaleUp) { xZoom = Math.min(xZoom, 1); yZoom = Math.min(yZoom, 1); } newZoom = Math.min(xZoom, yZoom); break; case DisplayMode.FIT_X: newZoom = boxWidth / width; if (!scaleUp) { newZoom = Math.min(newZoom, 1); } break; case DisplayMode.ORIGINAL: newZoom = 1; break; } // Center image from container's center const newPositionX = Math.min((boxWidth - width) / 2, 0); // if fitting to screen, then centre, other if (displayMode === DisplayMode.FIT_XY) { newPositionY = Math.min((boxHeight - height) / 2, 0); } else { // otherwise, align top of image with container newPositionY = Math.min((height * newZoom - height) / 2, 0); } setDefaultZoom(newZoom); setPositionX(newPositionX); setPositionY(newPositionY); }, [width, height, boxWidth, boxHeight, displayMode, scaleUp]); const calculateTopPosition = useCallback(() => { // Center image from container's center const newPositionX = Math.min((boxWidth - width) / 2, 0); let newPositionY: number; if (zoom * defaultZoom * height > boxHeight) { newPositionY = (height * zoom * defaultZoom - height) / 2; } else { newPositionY = Math.min((boxHeight - height) / 2, 0); } return [newPositionX, newPositionY]; }, [boxWidth, width, boxHeight, height, zoom, defaultZoom]); useEffect(() => { if (resetPosition !== resetPositionRef.current) { resetPositionRef.current = resetPosition; const [x, y] = calculateTopPosition(); setPositionX(x); setPositionY(y); } }, [resetPosition, resetPositionRef, calculateTopPosition]); function getScrollMode(ev: React.WheelEvent) { if (ev.shiftKey) { switch (scrollMode) { case ScrollMode.ZOOM: return ScrollMode.PAN_Y; case ScrollMode.PAN_Y: return ScrollMode.ZOOM; } } return scrollMode; } function onContainerScroll(ev: React.WheelEvent) { // don't zoom if mouse isn't over image if (getScrollMode(ev) === ScrollMode.PAN_Y) { onImageScroll(ev); } } function onImageScroll(ev: React.WheelEvent) { const percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; const minY = (defaultZoom * height - height) / 2 - defaultZoom * height + 1; const maxY = (defaultZoom * height - height) / 2 + boxHeight - 1; let newPositionY = positionY + (ev.deltaY < 0 ? SCROLL_PAN_STEP : -SCROLL_PAN_STEP); switch (getScrollMode(ev)) { case ScrollMode.ZOOM: setZoom(zoom * percent); break; case ScrollMode.PAN_Y: // ensure image doesn't go offscreen newPositionY = Math.max(newPositionY, minY); newPositionY = Math.min(newPositionY, maxY); setPositionY(newPositionY); ev.stopPropagation(); break; } } function onImageMouseOver(ev: React.MouseEvent) { if (!moving) return; if (!ev.buttons) { setMoving(false); return; } const posX = ev.pageX - startPoints.current[0]; const posY = ev.pageY - startPoints.current[1]; startPoints.current = [ev.pageX, ev.pageY]; setPositionX(positionX + posX); setPositionY(positionY + posY); } function onImageMouseDown(ev: React.MouseEvent) { startPoints.current = [ev.pageX, ev.pageY]; setMoving(true); mouseDownEvent.current = ev.nativeEvent; } function onImageMouseUp(ev: React.MouseEvent) { if ( !mouseDownEvent.current || ev.timeStamp - mouseDownEvent.current.timeStamp > 200 ) { // not a click - ignore return; } // must be a click if ( ev.pageX !== startPoints.current[0] || ev.pageY !== startPoints.current[1] ) { return; } if (ev.nativeEvent.offsetX >= (ev.target as HTMLElement).offsetWidth / 2) { onRight(); } else { onLeft(); } } function onTouchStart(ev: React.TouchEvent) { ev.preventDefault(); if (ev.touches.length === 1) { startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; setMoving(true); } } function onTouchMove(ev: React.TouchEvent) { if (!moving) return; if (ev.touches.length === 1) { const posX = ev.touches[0].pageX - startPoints.current[0]; const posY = ev.touches[0].pageY - startPoints.current[1]; startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; setPositionX(positionX + posX); setPositionY(positionY + posY); } } function onPointerDown(ev: React.PointerEvent) { // replace pointer event with the same id, if applicable pointerCache.current = pointerCache.current.filter( (e) => e.pointerId !== ev.pointerId ); pointerCache.current.push(ev); prevDiff.current = undefined; } function onPointerUp(ev: React.PointerEvent) { for (let i = 0; i < pointerCache.current.length; i++) { if (pointerCache.current[i].pointerId === ev.pointerId) { pointerCache.current.splice(i, 1); break; } } } function onPointerMove(ev: React.PointerEvent) { // find the event in the cache const cachedIndex = pointerCache.current.findIndex( (c) => c.pointerId === ev.pointerId ); if (cachedIndex !== -1) { pointerCache.current[cachedIndex] = ev; } // compare the difference between the two pointers if (pointerCache.current.length === 2) { const ev1 = pointerCache.current[0]; const ev2 = pointerCache.current[1]; const diffX = Math.abs(ev1.clientX - ev2.clientX); const diffY = Math.abs(ev1.clientY - ev2.clientY); const diff = Math.sqrt(diffX ** 2 + diffY ** 2); if (prevDiff.current !== undefined) { const diffDiff = diff - prevDiff.current; const factor = (Math.abs(diffDiff) / 20) * 0.1 + 1; if (diffDiff > 0) { setZoom(zoom * factor); } else if (diffDiff < 0) { setZoom((zoom * 1) / factor); } } prevDiff.current = diff; } } return (
onContainerScroll(e)} > {defaultZoom ? ( {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} onImageScroll(e)} onMouseDown={(e) => onImageMouseDown(e)} onMouseUp={(e) => onImageMouseUp(e)} onMouseMove={(e) => onImageMouseOver(e)} onTouchStart={(e) => onTouchStart(e)} onTouchMove={(e) => onTouchMove(e)} onPointerDown={(e) => onPointerDown(e)} onPointerUp={(e) => onPointerUp(e)} onPointerMove={(e) => onPointerMove(e)} /> ) : undefined}
); };