From b435f773439699adab10b1d6d98f74262e0b5836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sun, 4 Jan 2026 16:27:58 +0100 Subject: [PATCH] pl-fe: port ZoomableImage from upstream Mastodon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- packages/pl-fe/package.json | 1 + .../features/ui/components/image-loader.tsx | 2 +- .../features/ui/components/zoomable-image.tsx | 402 +++++++++++++----- packages/pl-fe/src/styles/new/modals.scss | 28 ++ pnpm-lock.yaml | 18 + 5 files changed, 336 insertions(+), 115 deletions(-) diff --git a/packages/pl-fe/package.json b/packages/pl-fe/package.json index fdec41fb6..fb234ef25 100644 --- a/packages/pl-fe/package.json +++ b/packages/pl-fe/package.json @@ -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", diff --git a/packages/pl-fe/src/features/ui/components/image-loader.tsx b/packages/pl-fe/src/features/ui/components/image-loader.tsx index 668071d54..d1ef2a163 100644 --- a/packages/pl-fe/src/features/ui/components/image-loader.tsx +++ b/packages/pl-fe/src/features/ui/components/image-loader.tsx @@ -11,7 +11,7 @@ interface IImageLoader { previewSrc?: string; width?: number; height?: number; - onClick?: React.MouseEventHandler; + onClick?: () => void; } class ImageLoader extends React.PureComponent { diff --git a/packages/pl-fe/src/features/ui/components/zoomable-image.tsx b/packages/pl-fe/src/features/ui/components/zoomable-image.tsx index af14eb2c6..b3ad4b55e 100644 --- a/packages/pl-fe/src/features/ui/components/zoomable-image.tsx +++ b/packages/pl-fe/src/features/ui/components/zoomable-image.tsx @@ -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 { +const ZoomableImage: React.FC = ({ + alt = '', + lang = '', + src, + width, + height, + onClick, + onDoubleClick, + onClose, + onZoomChange, + zoomedIn, + blurhash, +}) => { + useEffect(() => { + const handler = (e: Event) => { + e.preventDefault(); + }; - static defaultProps = { - alt: '', - width: null, - height: null, - }; + document.addEventListener('gesturestart', handler); + document.addEventListener('gesturechange', handler); + document.addEventListener('gestureend', handler); - state = { - scale: MIN_SCALE, - }; + 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(null); + const imageRef = useRef(null); + const doubleClickTimeoutRef = useRef | null>(); + const zoomMatrixRef = useRef(null); - componentWillUnmount() { - this.container?.removeEventListener('touchstart', this.handleTouchStart); - this.container?.removeEventListener('touchend', this.handleTouchMove); - } + 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); + } + }, + }, + })); - handleTouchStart = (e: TouchEvent) => { - if (e.touches.length !== 2) return; - const [p1, p2] = Array.from(e.touches); + 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?.(); + } - this.lastDistance = getDistance(p1, p2); - }; + return; + } - handleTouchMove = (e: TouchEvent) => { - if (!this.container) return; + if (!zoomedIn) { + // Swipe up/down to dismiss parent + if (last) { + if ((vy > 0.5 && dy !== 0) || Math.abs(y) > 150) { + onClose?.(); + } - const { scrollTop, scrollHeight, clientHeight } = this.container; - if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) { - // prevent propagating event to MediaModal - e.stopPropagation(); + 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; } - if (e.touches.length !== 2) 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; - }; + // 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})`, + ); - 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'; - - return ( -
- {alt} + {!loaded && blurhash && ( +
-
- ); - } + > + +
+ )} -} + + + {!loaded && !error && } + + ); +}; export { ZoomableImage as default }; diff --git a/packages/pl-fe/src/styles/new/modals.scss b/packages/pl-fe/src/styles/new/modals.scss index 82260321c..cc3ee14a8 100644 --- a/packages/pl-fe/src/styles/new/modals.scss +++ b/packages/pl-fe/src/styles/new/modals.scss @@ -4,4 +4,32 @@ .⁂-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; + } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f92f247b..bf6f70c08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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