Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210)
Co-authored-by: ChaosExAnima <ChaosExAnima@users.noreply.github.com>
This commit is contained in:
parent
82acef50b0
commit
2e9b2df570
12 changed files with 537 additions and 695 deletions
|
|
@ -0,0 +1,319 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue