mirror of https://github.com/glitch-soc/mastodon
320 lines
7.9 KiB
TypeScript
320 lines
7.9 KiB
TypeScript
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<ZoomableImageProps> = ({
|
|
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<HTMLDivElement>(null);
|
|
const imageRef = useRef<HTMLImageElement>(null);
|
|
const doubleClickTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
|
const zoomMatrixRef = useRef<ZoomMatrix | null>(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 (
|
|
<div
|
|
className={classNames('zoomable-image', {
|
|
'zoomable-image--zoomed-in': zoomedIn,
|
|
'zoomable-image--error': error,
|
|
'zoomable-image--dragging': dragging,
|
|
})}
|
|
ref={containerRef}
|
|
>
|
|
{!loaded && blurhash && (
|
|
<div
|
|
className='zoomable-image__preview'
|
|
style={{
|
|
aspectRatio: `${width}/${height}`,
|
|
height: `min(${height}px, 100%)`,
|
|
}}
|
|
>
|
|
<Blurhash hash={blurhash} />
|
|
</div>
|
|
)}
|
|
|
|
<animated.img
|
|
style={style}
|
|
role='presentation'
|
|
ref={imageRef}
|
|
alt={alt}
|
|
title={alt}
|
|
lang={lang}
|
|
src={src}
|
|
width={width}
|
|
height={height}
|
|
draggable={false}
|
|
onLoad={handleLoad}
|
|
onError={handleError}
|
|
onClickCapture={handleClick}
|
|
/>
|
|
|
|
{!loaded && !error && <LoadingIndicator />}
|
|
</div>
|
|
);
|
|
};
|