rewrite zoomable image to work correectly
Some checks failed
pl-api CI / Test for pl-api formatting (22.x) (push) Has been cancelled
pl-fe CI / Test and upload artifacts (22.x) (push) Has been cancelled
pl-fe CI / deploy (push) Has been cancelled
pl-hooks CI / Test for a successful build (22.x) (push) Has been cancelled

This commit is contained in:
2026-01-24 18:01:03 +00:00
parent f50a8ea463
commit 64172c0a45
4 changed files with 408 additions and 250 deletions

View File

@ -1,5 +1,3 @@
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';
@ -8,66 +6,8 @@ import Spinner from 'pl-fe/components/ui/spinner';
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;
};
const ZOOM_SCALE = 2;
const DOUBLE_TAP_DELAY = 300;
interface IZoomableImage {
alt?: string;
@ -93,202 +33,426 @@ const ZoomableImage: React.FC<IZoomableImage> = ({
onDoubleClick,
onClose,
onZoomChange,
zoomedIn,
zoomedIn: externalZoomedIn,
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);
// Internal zoom/pan state
const [scale, setScale] = useState(MIN_SCALE);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = 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);
}
},
},
}));
// Touch/mouse tracking
const lastTapRef = useRef<number>(0);
const dragStartRef = useRef<{ x: number; y: number; posX: number; posY: number } | null>(null);
const pinchStartRef = useRef<{ distance: number; scale: number; midX: number; midY: number } | null>(null);
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);
const isZoomed = scale > MIN_SCALE;
// Store current values in refs for use in non-passive event listeners
const scaleRef = useRef(scale);
const positionRef = useRef(position);
const isZoomedRef = useRef(isZoomed);
useEffect(() => {
scaleRef.current = scale;
}, [scale]);
useEffect(() => {
positionRef.current = position;
}, [position]);
useEffect(() => {
isZoomedRef.current = isZoomed;
}, [isZoomed]);
// Sync with external zoomedIn prop
useEffect(() => {
if (externalZoomedIn !== undefined) {
if (externalZoomedIn && scale <= MIN_SCALE) {
setScale(ZOOM_SCALE);
setPosition({ x: 0, y: 0 });
} else if (!externalZoomedIn && scale > MIN_SCALE) {
setScale(MIN_SCALE);
setPosition({ x: 0, y: 0 });
}
}
}, [externalZoomedIn]);
// Report zoom changes to parent
useEffect(() => {
onZoomChange?.(isZoomed);
}, [isZoomed, onZoomChange]);
// Calculate bounds for panning
const getBounds = useCallback((currentScale: number) => {
if (!containerRef.current || !imageRef.current || currentScale <= MIN_SCALE) {
return { minX: 0, maxX: 0, minY: 0, maxY: 0 };
}
const container = containerRef.current;
const image = imageRef.current;
const scaledWidth = image.offsetWidth * currentScale;
const scaledHeight = image.offsetHeight * currentScale;
const overflowX = Math.max(0, (scaledWidth - container.clientWidth) / 2);
const overflowY = Math.max(0, (scaledHeight - container.clientHeight) / 2);
return {
minX: -overflowX,
maxX: overflowX,
minY: -overflowY,
maxY: overflowY,
};
}, []);
// Clamp position within bounds
const clampPosition = useCallback((x: number, y: number, currentScale: number) => {
const bounds = getBounds(currentScale);
return {
x: Math.max(bounds.minX, Math.min(bounds.maxX, x)),
y: Math.max(bounds.minY, Math.min(bounds.maxY, y)),
};
}, [getBounds]);
// Toggle zoom
const toggleZoom = useCallback((originX?: number, originY?: number) => {
if (isZoomed) {
setScale(MIN_SCALE);
setPosition({ x: 0, y: 0 });
} else {
clearTimeout(doubleClickTimeoutRef.current);
doubleClickTimeoutRef.current = null;
setScale(ZOOM_SCALE);
// If origin provided, zoom towards that point
if (originX !== undefined && originY !== undefined && containerRef.current && imageRef.current) {
const image = imageRef.current;
const rect = image.getBoundingClientRect();
// Calculate offset from image center
const imageCenterX = rect.left + rect.width / 2;
const imageCenterY = rect.top + rect.height / 2;
const offsetX = (originX - imageCenterX) * (ZOOM_SCALE - 1);
const offsetY = (originY - imageCenterY) * (ZOOM_SCALE - 1);
const clamped = clampPosition(-offsetX, -offsetY, ZOOM_SCALE);
setPosition(clamped);
} else {
setPosition({ x: 0, y: 0 });
}
}
onDoubleClick?.();
}
}, [isZoomed, clampPosition, onDoubleClick]);
// Handle single tap
const handleTap = useCallback(() => {
onClick?.();
}, [onClick]);
// Get distance between two touch points
const getTouchDistance = (touches: TouchList) => {
if (touches.length < 2) return 0;
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
};
// Get midpoint between two touch points
const getTouchMidpoint = (touches: TouchList) => {
if (touches.length < 2) return { x: 0, y: 0 };
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
};
};
// Attach touch listeners with { passive: false } to allow preventDefault
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
// Pinch start
e.preventDefault();
e.stopPropagation();
const midpoint = getTouchMidpoint(e.touches);
pinchStartRef.current = {
distance: getTouchDistance(e.touches),
scale: scaleRef.current,
midX: midpoint.x,
midY: midpoint.y,
};
dragStartRef.current = null;
} else if (e.touches.length === 1) {
// Single touch - could be tap or drag
const touch = e.touches[0];
dragStartRef.current = {
x: touch.clientX,
y: touch.clientY,
posX: positionRef.current.x,
posY: positionRef.current.y,
};
pinchStartRef.current = null;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length === 2 && pinchStartRef.current) {
// Pinch zoom
e.preventDefault();
e.stopPropagation();
const newDistance = getTouchDistance(e.touches);
const pinchScale = newDistance / pinchStartRef.current.distance;
let newScale = pinchStartRef.current.scale * pinchScale;
newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale));
// Zoom towards pinch center
const midpoint = getTouchMidpoint(e.touches);
if (containerRef.current && imageRef.current) {
const image = imageRef.current;
const rect = image.getBoundingClientRect();
const imageCenterX = rect.left + rect.width / 2;
const imageCenterY = rect.top + rect.height / 2;
const dx = midpoint.x - imageCenterX;
const dy = midpoint.y - imageCenterY;
const scaleRatio = newScale / scaleRef.current;
const newX = positionRef.current.x - dx * (scaleRatio - 1);
const newY = positionRef.current.y - dy * (scaleRatio - 1);
const bounds = getBounds(newScale);
const clampedX = Math.max(bounds.minX, Math.min(bounds.maxX, newX));
const clampedY = Math.max(bounds.minY, Math.min(bounds.maxY, newY));
setScale(newScale);
setPosition({ x: clampedX, y: clampedY });
} else {
setScale(newScale);
}
} else if (e.touches.length === 1 && dragStartRef.current) {
const touch = e.touches[0];
const dx = touch.clientX - dragStartRef.current.x;
const dy = touch.clientY - dragStartRef.current.y;
if (isZoomedRef.current) {
// Pan when zoomed
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
const newX = dragStartRef.current.posX + dx;
const newY = dragStartRef.current.posY + dy;
const bounds = getBounds(scaleRef.current);
const clampedX = Math.max(bounds.minX, Math.min(bounds.maxX, newX));
const clampedY = Math.max(bounds.minY, Math.min(bounds.maxY, newY));
setPosition({ x: clampedX, y: clampedY });
} else {
// Vertical swipe to close when not zoomed
if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 10) {
setPosition({ x: 0, y: dy });
}
}
}
};
const handleTouchEnd = (e: TouchEvent) => {
if (pinchStartRef.current) {
// End of pinch
pinchStartRef.current = null;
// Snap to min scale if close
if (scaleRef.current < MIN_SCALE + 0.1) {
setScale(MIN_SCALE);
setPosition({ x: 0, y: 0 });
}
return;
}
if (!zoomedIn) {
// Swipe up/down to dismiss parent
if (last) {
if ((vy > 0.5 && dy !== 0) || Math.abs(y) > 150) {
if (dragStartRef.current && e.changedTouches.length === 1) {
const touch = e.changedTouches[0];
const dx = touch.clientX - dragStartRef.current.x;
const dy = touch.clientY - dragStartRef.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 10) {
// It was a tap
const now = Date.now();
if (now - lastTapRef.current < DOUBLE_TAP_DELAY) {
// Double tap
toggleZoom(touch.clientX, touch.clientY);
lastTapRef.current = 0;
} else {
// Single tap - wait to see if double tap follows
lastTapRef.current = now;
setTimeout(() => {
if (lastTapRef.current === now) {
handleTap();
}
}, DOUBLE_TAP_DELAY);
}
} else if (!isZoomedRef.current && Math.abs(dy) > 100) {
// Swipe to close
onClose?.();
}
void api.start({ y: 0, config: config.wobbly });
return;
} else if (dy !== 0) {
void api.start({ y, immediate: true });
return;
// Reset vertical position if not zoomed
if (!isZoomedRef.current) {
setPosition({ x: 0, y: 0 });
}
}
cancel();
return;
}
dragStartRef.current = null;
setIsDragging(false);
};
if (pinching) {
cancel();
return;
}
container.addEventListener('touchstart', handleTouchStart, { passive: false });
container.addEventListener('touchmove', handleTouchMove, { passive: false });
container.addEventListener('touchend', handleTouchEnd, { passive: true });
if (active) {
setDragging(true);
return () => {
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
};
}, [getBounds, toggleZoom, handleTap, onClose]);
// Mouse handlers for desktop
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return; // Only left click
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
posX: position.x,
posY: position.y,
};
}, [position]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!dragStartRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
if (isZoomed) {
e.preventDefault();
setIsDragging(true);
const newX = dragStartRef.current.posX + dx;
const newY = dragStartRef.current.posY + dy;
const clamped = clampPosition(newX, newY, scale);
setPosition(clamped);
}
}, [isZoomed, scale, clampPosition]);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
if (!dragStartRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 5) {
// It was a click
const now = Date.now();
if (now - lastTapRef.current < DOUBLE_TAP_DELAY) {
// Double click
toggleZoom(e.clientX, e.clientY);
lastTapRef.current = 0;
} else {
setDragging(false);
lastTapRef.current = now;
setTimeout(() => {
if (lastTapRef.current === now) {
handleTap();
}
}, DOUBLE_TAP_DELAY);
}
}
void api.start({ x, y });
},
dragStartRef.current = null;
setIsDragging(false);
}, [toggleZoom, handleTap]);
onPinch({ origin: [ox, oy], first, movement: [ms], offset: [s], memo }) {
if (!imageRef.current) {
return;
const handleMouseLeave = useCallback(() => {
dragStartRef.current = null;
setIsDragging(false);
}, []);
// Prevent context menu on long press
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (isZoomed) {
e.preventDefault();
}
}, [isZoomed]);
if (first) {
const { width, height, x, y } =
imageRef.current.getBoundingClientRect();
const tx = ox - (x + width / 2);
const ty = oy - (y + height / 2);
// Handle wheel zoom on desktop
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
memo = [style.x.get(), style.y.get(), tx, ty];
const delta = e.deltaY > 0 ? -0.2 : 0.2;
let newScale = scale + delta;
newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale));
if (newScale !== scale && containerRef.current && imageRef.current) {
const image = imageRef.current;
const rect = image.getBoundingClientRect();
const imageCenterX = rect.left + rect.width / 2;
const imageCenterY = rect.top + rect.height / 2;
const dx = e.clientX - imageCenterX;
const dy = e.clientY - imageCenterY;
const scaleRatio = newScale / scale;
const newX = position.x - dx * (scaleRatio - 1);
const newY = position.y - dy * (scaleRatio - 1);
const clamped = clampPosition(newX, newY, newScale);
setScale(newScale);
setPosition(clamped);
} else {
setScale(newScale);
if (newScale <= MIN_SCALE) {
setPosition({ x: 0, y: 0 });
}
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;
}
}, [scale, position, clampPosition]);
zoomMatrixRef.current = createZoomMatrix(
containerRef.current,
imageRef.current,
width,
height,
);
const handleLoad = useCallback(() => {
setLoaded(true);
}, []);
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 handleError = useCallback(() => {
setError(true);
}, []);
// Prevent default click behavior
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]);
// 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})`,
);
const transform = `translate(${position.x}px, ${position.y}px) scale(${scale})`;
return (
<div
className={clsx('⁂-zoomable-image', {
'⁂-zoomable-image--zoomed-in': zoomedIn,
'⁂-zoomable-image--zoomed-in': isZoomed,
'⁂-zoomable-image--error': error,
'⁂-zoomable-image--dragging': dragging,
'⁂-zoomable-image--dragging': isDragging,
})}
ref={containerRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
>
{!loaded && blurhash && (
<div
@ -302,8 +466,12 @@ const ZoomableImage: React.FC<IZoomableImage> = ({
</div>
)}
<animated.img
style={{ transform }}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<img
style={{
transform,
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
ref={imageRef}
alt={alt}
lang={lang}
@ -313,7 +481,7 @@ const ZoomableImage: React.FC<IZoomableImage> = ({
draggable={false}
onLoad={handleLoad}
onError={handleError}
onClickCapture={handleClick}
onClick={handleClick}
/>
{!loaded && !error && <Spinner />}

View File

@ -92,7 +92,7 @@ const ProfileLayout: React.FC = () => {
const showTabs = !['/following', '/followers', '/pins'].some(path => pathname.endsWith(path));
console.log(account);
// console.log(account);
return (
<>

View File

@ -1186,6 +1186,7 @@
"lightbox.next": "Next",
"lightbox.previous": "Previous",
"lightbox.view_context": "View context",
"lightbox.view_image": "View image {index}",
"lightbox.zoom_in": "Zoom to actual size",
"lightbox.zoom_out": "Zoom to fit",
"link_preview.more_from_author": "More from {name}",

View File

@ -33,6 +33,7 @@ const messages = defineMessages({
zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' },
zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' },
download: { id: 'video.download', defaultMessage: 'Download file' },
viewImage: { id: 'lightbox.view_image', defaultMessage: 'View image {index}' },
});
interface MediaModalProps {
@ -86,18 +87,8 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
handleChangeIndex(index + 1);
}, [handleChangeIndex, index]);
const [viewportDimensions, setViewportDimensions] = useState<{
width: number;
height: number;
}>({ width: 0, height: 0 });
const handleRef: RefCallback<HTMLDivElement> = useCallback((ele) => {
if (ele?.clientWidth && ele.clientHeight) {
setViewportDimensions({
width: ele.clientWidth,
height: ele.clientHeight,
});
}
const handleRef: RefCallback<HTMLDivElement> = useCallback(() => {
// Ref callback for the container
}, []);
const hasMultipleImages = media.length > 1;
@ -164,9 +155,7 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
const currentMedia = media[index];
const zoomable =
currentMedia.type === 'image' && currentMedia.meta.original &&
(currentMedia.meta.original.width > viewportDimensions.width || currentMedia.meta.original.height > viewportDimensions.height);
const zoomable = currentMedia.type === 'image';
const handleZoomClick = useCallback(() => {
setZoomedIn((prev) => !prev);
@ -417,11 +406,11 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
'size-12 flex-none overflow-hidden rounded-lg border-2 transition-all hover:scale-105',
i === index ? 'border-white' : 'border-transparent opacity-60 hover:opacity-100',
)}
aria-label={`View image ${i + 1}`}
aria-label={intl.formatMessage(messages.viewImage, { index: i + 1 })}
>
<img
src={attachment.preview_url || attachment.url}
alt={attachment.description || `Image ${i + 1}`}
alt={attachment.description || intl.formatMessage(messages.viewImage, { index: i + 1 })}
className='size-full object-cover'
/>
</button>