import { useState, useCallback, useRef, useEffect } from 'react'; import classNames from 'classnames'; import { useSpring, animated, config } from '@react-spring/web'; import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react'; import { Blurhash } from 'mastodon/components/blurhash'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; const MIN_SCALE = 1; const MAX_SCALE = 4; const DOUBLE_CLICK_THRESHOLD = 250; interface ZoomMatrix { containerWidth: number; containerHeight: number; imageWidth: number; imageHeight: number; initialScale: number; } const createZoomMatrix = ( container: HTMLElement, image: HTMLImageElement, fullWidth: number, fullHeight: number, ): ZoomMatrix => { const { clientWidth, clientHeight } = container; const { offsetWidth, offsetHeight } = image; const type = fullWidth / fullHeight < clientWidth / clientHeight ? 'width' : 'height'; const initialScale = type === 'width' ? Math.min(clientWidth, fullWidth) / offsetWidth : Math.min(clientHeight, fullHeight) / offsetHeight; return { containerWidth: clientWidth, containerHeight: clientHeight, imageWidth: offsetWidth, imageHeight: offsetHeight, initialScale, }; }; const useGesture = createUseGesture([dragAction, pinchAction]); const getBounds = (zoomMatrix: ZoomMatrix | null, scale: number) => { if (!zoomMatrix || scale === MIN_SCALE) { return { left: -Infinity, right: Infinity, top: -Infinity, bottom: Infinity, }; } const { containerWidth, containerHeight, imageWidth, imageHeight } = zoomMatrix; const bounds = { left: -Math.max(imageWidth * scale - containerWidth, 0) / 2, right: Math.max(imageWidth * scale - containerWidth, 0) / 2, top: -Math.max(imageHeight * scale - containerHeight, 0) / 2, bottom: Math.max(imageHeight * scale - containerHeight, 0) / 2, }; return bounds; }; interface ZoomableImageProps { alt?: string; lang?: string; src: string; width: number; height: number; onClick?: () => void; onDoubleClick?: () => void; onClose?: () => void; onZoomChange?: (zoomedIn: boolean) => void; zoomedIn?: boolean; blurhash?: string; } export const ZoomableImage: React.FC = ({ alt = '', lang = '', src, width, height, onClick, onDoubleClick, onClose, onZoomChange, zoomedIn, blurhash, }) => { useEffect(() => { const handler = (e: Event) => { e.preventDefault(); }; document.addEventListener('gesturestart', handler); document.addEventListener('gesturechange', handler); document.addEventListener('gestureend', handler); return () => { document.removeEventListener('gesturestart', handler); document.removeEventListener('gesturechange', handler); document.removeEventListener('gestureend', handler); }; }, []); const [dragging, setDragging] = useState(false); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(false); const containerRef = useRef(null); const imageRef = useRef(null); const doubleClickTimeoutRef = useRef | null>(); const zoomMatrixRef = useRef(null); const [style, api] = useSpring(() => ({ x: 0, y: 0, scale: 1, onRest: { scale({ value }) { if (!onZoomChange) { return; } if (value === MIN_SCALE) { onZoomChange(false); } else { onZoomChange(true); } }, }, })); useGesture( { onDrag({ pinching, cancel, active, last, offset: [x, y], velocity: [, vy], direction: [, dy], tap, }) { if (tap) { if (!doubleClickTimeoutRef.current) { doubleClickTimeoutRef.current = setTimeout(() => { onClick?.(); doubleClickTimeoutRef.current = null; }, DOUBLE_CLICK_THRESHOLD); } else { clearTimeout(doubleClickTimeoutRef.current); doubleClickTimeoutRef.current = null; onDoubleClick?.(); } return; } if (!zoomedIn) { // Swipe up/down to dismiss parent if (last) { if ((vy > 0.5 && dy !== 0) || Math.abs(y) > 150) { onClose?.(); } void api.start({ y: 0, config: config.wobbly }); return; } else if (dy !== 0) { void api.start({ y, immediate: true }); return; } cancel(); return; } if (pinching) { cancel(); return; } if (active) { setDragging(true); } else { setDragging(false); } void api.start({ x, y }); }, onPinch({ origin: [ox, oy], first, movement: [ms], offset: [s], memo }) { if (!imageRef.current) { return; } if (first) { const { width, height, x, y } = imageRef.current.getBoundingClientRect(); const tx = ox - (x + width / 2); const ty = oy - (y + height / 2); memo = [style.x.get(), style.y.get(), tx, ty]; } const x = memo[0] - (ms - 1) * memo[2]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access const y = memo[1] - (ms - 1) * memo[3]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access void api.start({ scale: s, x, y }); return memo as [number, number, number, number]; }, }, { target: imageRef, drag: { from: () => [style.x.get(), style.y.get()], filterTaps: true, bounds: () => getBounds(zoomMatrixRef.current, style.scale.get()), rubberband: true, }, pinch: { scaleBounds: { min: MIN_SCALE, max: MAX_SCALE, }, rubberband: true, }, }, ); useEffect(() => { if (!loaded || !containerRef.current || !imageRef.current) { return; } zoomMatrixRef.current = createZoomMatrix( containerRef.current, imageRef.current, width, height, ); if (!zoomedIn) { void api.start({ scale: MIN_SCALE, x: 0, y: 0 }); } else if (style.scale.get() === MIN_SCALE) { void api.start({ scale: zoomMatrixRef.current.initialScale, x: 0, y: 0 }); } }, [api, style.scale, zoomedIn, width, height, loaded]); const handleClick = useCallback((e: React.MouseEvent) => { // This handler exists to cancel the onClick handler on the media modal which would // otherwise close the modal. It cannot be used for actual click handling because // we don't know if the user is about to pan the image or not. e.preventDefault(); e.stopPropagation(); }, []); const handleLoad = useCallback(() => { setLoaded(true); }, [setLoaded]); const handleError = useCallback(() => { setError(true); }, [setError]); return (
{!loaded && blurhash && (
)} {!loaded && !error && }
); };