pl-fe: port ZoomableImage from upstream Mastodon

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-01-04 16:27:58 +01:00
parent cf08ea95ce
commit b435f77343
5 changed files with 336 additions and 115 deletions

View File

@ -72,6 +72,7 @@
"@transfem-org/sfm-js": "^0.24.6",
"@twemoji/svg": "^15.0.0",
"@uidotdev/usehooks": "^2.4.1",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-react": "^4.3.4",
"@yornaath/batshit": "^0.11.1",
"abortcontroller-polyfill": "^1.7.8",

View File

@ -11,7 +11,7 @@ interface IImageLoader {
previewSrc?: string;
width?: number;
height?: number;
onClick?: React.MouseEventHandler;
onClick?: () => void;
}
class ImageLoader extends React.PureComponent<IImageLoader> {

View File

@ -1,150 +1,324 @@
import React from 'react';
import { useSpring, animated, config, to } from '@react-spring/web';
import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react';
import clsx from 'clsx';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import Blurhash from 'pl-fe/components/blurhash';
import Spinner from 'pl-fe/components/ui/spinner';
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const DOUBLE_CLICK_THRESHOLD = 250;
type Point = { x: number; y: number };
interface ZoomMatrix {
containerWidth: number;
containerHeight: number;
imageWidth: number;
imageHeight: number;
initialScale: number;
}
const getMidpoint = (p1: React.Touch, p2: React.Touch): Point => ({
x: (p1.clientX + p2.clientX) / 2,
y: (p1.clientY + p2.clientY) / 2,
});
const createZoomMatrix = (
container: HTMLElement,
image: HTMLImageElement,
fullWidth: number,
fullHeight: number,
): ZoomMatrix => {
const { clientWidth, clientHeight } = container;
const { offsetWidth, offsetHeight } = image;
const getDistance = (p1: React.Touch, p2: React.Touch): number =>
Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
const type =
fullWidth / fullHeight < clientWidth / clientHeight ? 'width' : 'height';
const clamp = (min: number, max: number, value: number): number => Math.min(max, Math.max(min, value));
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 IZoomableImage {
alt?: string;
lang?: string;
src: string;
onClick?: React.MouseEventHandler;
width: number;
height: number;
onClick?: () => void;
onDoubleClick?: () => void;
onClose?: () => void;
onZoomChange?: (zoomedIn: boolean) => void;
zoomedIn?: boolean;
blurhash?: string;
}
class ZoomableImage extends React.PureComponent<IZoomableImage> {
static defaultProps = {
alt: '',
width: null,
height: null,
const ZoomableImage: React.FC<IZoomableImage> = ({
alt = '',
lang = '',
src,
width,
height,
onClick,
onDoubleClick,
onClose,
onZoomChange,
zoomedIn,
blurhash,
}) => {
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
};
state = {
scale: MIN_SCALE,
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);
};
}, []);
container: HTMLDivElement | null = null;
image: HTMLImageElement | null = null;
lastDistance = 0;
const [dragging, setDragging] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
componentDidMount() {
this.container?.addEventListener('touchstart', this.handleTouchStart);
// on Chrome 56+, touch event listeners will default to passive
// https://www.chromestatus.com/features/5093566007214080
this.container?.addEventListener('touchmove', this.handleTouchMove, { passive: false });
}
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const doubleClickTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
const zoomMatrixRef = useRef<ZoomMatrix | null>(null);
componentWillUnmount() {
this.container?.removeEventListener('touchstart', this.handleTouchStart);
this.container?.removeEventListener('touchend', this.handleTouchMove);
}
handleTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 2) return;
const [p1, p2] = Array.from(e.touches);
this.lastDistance = getDistance(p1, p2);
};
handleTouchMove = (e: TouchEvent) => {
if (!this.container) return;
const { scrollTop, scrollHeight, clientHeight } = this.container;
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
// prevent propagating event to MediaModal
e.stopPropagation();
const [style, api] = useSpring(() => ({
x: 0,
y: 0,
scale: 1,
onRest: {
scale({ value }) {
if (!onZoomChange) {
return;
}
if (e.touches.length !== 2) 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 [p1, p2] = Array.from(e.touches);
const distance = getDistance(p1, p2);
const midpoint = getMidpoint(p1, p2);
const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance);
const handleLoad = useCallback(() => {
setLoaded(true);
}, [setLoaded]);
this.zoom(scale, midpoint);
const handleError = useCallback(() => {
setError(true);
}, [setError]);
this.lastDistance = distance;
};
zoom(nextScale: number, midpoint: Point) {
if (!this.container) return;
const { scale } = this.state;
const { scrollLeft, scrollTop } = this.container;
// math memo:
// x = (scrollLeft + midpoint.x) / scrollWidth
// x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
// scrollWidth = clientWidth * scale
// scrollWidth' = clientWidth * nextScale
// Solve x = x' for nextScrollLeft
const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
this.setState({ scale: nextScale }, () => {
if (!this.container) return;
this.container.scrollLeft = nextScrollLeft;
this.container.scrollTop = nextScrollTop;
});
}
handleClick: React.MouseEventHandler = e => {
// don't propagate event to MediaModal
e.stopPropagation();
const handler = this.props.onClick;
if (handler) handler(e);
};
setContainerRef = (c: HTMLDivElement) => {
this.container = c;
};
setImageRef = (c: HTMLImageElement) => {
this.image = c;
};
render() {
const { alt, src } = this.props;
const { scale } = this.state;
const overflow = scale === 1 ? 'hidden' : 'scroll';
// Convert the default style transform to a matrix transform to work around
// Safari bug https://github.com/mastodon/mastodon/issues/35042
const transform = to(
[style.scale, style.x, style.y],
(s, x, y) => `matrix(${s}, 0, 0, ${s}, ${x}, ${y})`,
);
return (
<div
className='relative flex size-full items-center justify-center'
ref={this.setContainerRef}
style={{ overflow }}
className={clsx('⁂-zoomable-image', {
'⁂-zoomable-image--zoomed-in': zoomedIn,
'⁂-zoomable-image--error': error,
'⁂-zoomable-image--dragging': dragging,
})}
ref={containerRef}
>
<img
className='size-auto max-h-[80%] max-w-full object-contain shadow-2xl'
role='presentation'
ref={this.setImageRef}
alt={alt}
title={alt}
src={src}
{!loaded && blurhash && (
<div
className='⁂-zoomable-image__preview'
style={{
transform: `scale(${scale})`,
transformOrigin: '0 0',
aspectRatio: `${width}/${height}`,
height: `min(${height}px, 100%)`,
}}
onClick={this.handleClick}
>
<Blurhash hash={blurhash} />
</div>
)}
<animated.img
style={{ transform }}
ref={imageRef}
alt={alt}
lang={lang}
src={src}
width={width}
height={height}
draggable={false}
onLoad={handleLoad}
onError={handleError}
onClickCapture={handleClick}
/>
{!loaded && !error && <Spinner />}
</div>
);
}
}
};
export { ZoomableImage as default };

View File

@ -5,3 +5,31 @@
.-hotkey-modal {
@apply max-w-4xl;
}
.-zoomable-image {
@apply relative flex size-full items-center justify-center;
&--zoomed-in {
@apply cursor-grab z-[9999];
}
&--error img {
@apply hidden;
}
&--dragging {
@apply cursor-grabbing;
}
&__preview {
@apply size-auto max-h-[80%] max-w-full absolute z-[1] overflow-hidden;
canvas {
@apply absolute size-full object-cover z-[-1];
}
}
img {
@apply size-auto max-h-[80%] max-w-full object-contain shadow-2xl;
}
}

18
pnpm-lock.yaml generated
View File

@ -217,6 +217,9 @@ importers:
'@uidotdev/usehooks':
specifier: ^2.4.1
version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@use-gesture/react':
specifier: ^10.3.1
version: 10.3.1(react@18.3.1)
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0(vite@5.4.21(@types/node@22.17.0)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.44.0))
@ -2874,6 +2877,14 @@ packages:
cpu: [x64]
os: [win32]
'@use-gesture/core@10.3.1':
resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==}
'@use-gesture/react@10.3.1':
resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==}
peerDependencies:
react: '>= 16.8.0'
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@ -9548,6 +9559,13 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@use-gesture/core@10.3.1': {}
'@use-gesture/react@10.3.1(react@18.3.1)':
dependencies:
'@use-gesture/core': 10.3.1
react: 18.3.1
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.17.0)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.44.0))':
dependencies:
'@babel/core': 7.28.0