rewrite zoomable image to work correectly
Some checks failed
Some checks failed
This commit is contained in:
@ -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);
|
||||
} 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 (
|
||||
<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 />}
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user