From 64172c0a4585918a529e098183445be4adef23d8 Mon Sep 17 00:00:00 2001 From: matty Date: Sat, 24 Jan 2026 18:01:03 +0000 Subject: [PATCH] rewrite zoomable image to work correectly --- .../features/ui/components/zoomable-image.tsx | 632 +++++++++++------- packages/pl-fe/src/layouts/profile-layout.tsx | 2 +- packages/pl-fe/src/locales/en.json | 1 + packages/pl-fe/src/modals/media-modal.tsx | 23 +- 4 files changed, 408 insertions(+), 250 deletions(-) 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 b3ad4b55e..c70cbb38b 100644 --- a/packages/pl-fe/src/features/ui/components/zoomable-image.tsx +++ b/packages/pl-fe/src/features/ui/components/zoomable-image.tsx @@ -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 = ({ 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(null); const imageRef = useRef(null); - const doubleClickTimeoutRef = useRef | null>(); - const zoomMatrixRef = useRef(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(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); - } else { - clearTimeout(doubleClickTimeoutRef.current); - doubleClickTimeoutRef.current = null; - onDoubleClick?.(); - } + const isZoomed = scale > MIN_SCALE; - 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, - }, - }, - ); + // 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(() => { - if (!loaded || !containerRef.current || !imageRef.current) { - return; + 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 }; } - zoomMatrixRef.current = createZoomMatrix( - containerRef.current, - imageRef.current, - width, - height, - ); + const container = containerRef.current; + const image = imageRef.current; - 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 }); + 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 { + 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 }); + } } - }, [api, style.scale, zoomedIn, width, height, loaded]); + 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 (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?.(); + } + + // Reset vertical position if not zoomed + if (!isZoomedRef.current) { + setPosition({ x: 0, y: 0 }); + } + } + + dragStartRef.current = null; + setIsDragging(false); + }; + + container.addEventListener('touchstart', handleTouchStart, { passive: false }); + container.addEventListener('touchmove', handleTouchMove, { passive: false }); + container.addEventListener('touchend', handleTouchEnd, { passive: 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 { + lastTapRef.current = now; + setTimeout(() => { + if (lastTapRef.current === now) { + handleTap(); + } + }, DOUBLE_TAP_DELAY); + } + } + + dragStartRef.current = null; + setIsDragging(false); + }, [toggleZoom, handleTap]); + + 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]); + + // Handle wheel zoom on desktop + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + + 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 }); + } + } + }, [scale, position, clampPosition]); + + const handleLoad = useCallback(() => { + setLoaded(true); + }, []); + + 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 (
{!loaded && blurhash && (
= ({
)} - = ({ draggable={false} onLoad={handleLoad} onError={handleError} - onClickCapture={handleClick} + onClick={handleClick} /> {!loaded && !error && } diff --git a/packages/pl-fe/src/layouts/profile-layout.tsx b/packages/pl-fe/src/layouts/profile-layout.tsx index d8b71172c..eab66f2a4 100644 --- a/packages/pl-fe/src/layouts/profile-layout.tsx +++ b/packages/pl-fe/src/layouts/profile-layout.tsx @@ -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 ( <> diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index a1e285863..284e469b2 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -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}", diff --git a/packages/pl-fe/src/modals/media-modal.tsx b/packages/pl-fe/src/modals/media-modal.tsx index 95c494a0d..a4a2e6be9 100644 --- a/packages/pl-fe/src/modals/media-modal.tsx +++ b/packages/pl-fe/src/modals/media-modal.tsx @@ -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 = (props) => { handleChangeIndex(index + 1); }, [handleChangeIndex, index]); - const [viewportDimensions, setViewportDimensions] = useState<{ - width: number; - height: number; - }>({ width: 0, height: 0 }); - - const handleRef: RefCallback = useCallback((ele) => { - if (ele?.clientWidth && ele.clientHeight) { - setViewportDimensions({ - width: ele.clientWidth, - height: ele.clientHeight, - }); - } + const handleRef: RefCallback = useCallback(() => { + // Ref callback for the container }, []); const hasMultipleImages = media.length > 1; @@ -164,9 +155,7 @@ const MediaModal: React.FC = (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 = (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 })} > {attachment.description