Add double tap to zoom and swipe to dismiss to media modal in web UI ()

Co-authored-by: ChaosExAnima <ChaosExAnima@users.noreply.github.com>
This commit is contained in:
Eugen Rochko 2025-03-24 18:25:30 +01:00 committed by GitHub
parent 82acef50b0
commit 2e9b2df570
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 537 additions and 695 deletions

View file

@ -1,175 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import classNames from 'classnames';
import { LoadingBar } from 'react-redux-loading-bar';
import ZoomableImage from './zoomable_image';
export default class ImageLoader extends PureComponent {
static propTypes = {
alt: PropTypes.string,
lang: PropTypes.string,
src: PropTypes.string.isRequired,
previewSrc: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
zoomedIn: PropTypes.bool,
};
static defaultProps = {
alt: '',
lang: '',
width: null,
height: null,
};
state = {
loading: true,
error: false,
width: null,
};
removers = [];
canvas = null;
get canvasContext() {
if (!this.canvas) {
return null;
}
this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
return this._canvasContext;
}
componentDidMount () {
this.loadImage(this.props);
}
UNSAFE_componentWillReceiveProps (nextProps) {
if (this.props.src !== nextProps.src) {
this.loadImage(nextProps);
}
}
componentWillUnmount () {
this.removeEventListeners();
}
loadImage (props) {
this.removeEventListeners();
this.setState({ loading: true, error: false });
Promise.all([
props.previewSrc && this.loadPreviewCanvas(props),
this.hasSize() && this.loadOriginalImage(props),
].filter(Boolean))
.then(() => {
this.setState({ loading: false, error: false });
this.clearPreviewCanvas();
})
.catch(() => this.setState({ loading: false, error: true }));
}
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
this.canvasContext.drawImage(image, 0, 0, width, height);
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = previewSrc;
this.removers.push(removeEventListeners);
});
clearPreviewCanvas () {
const { width, height } = this.canvas;
this.canvasContext.clearRect(0, 0, width, height);
}
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = src;
this.removers.push(removeEventListeners);
});
removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
hasSize () {
const { width, height } = this.props;
return typeof width === 'number' && typeof height === 'number';
}
setCanvasRef = c => {
this.canvas = c;
if (c) this.setState({ width: c.offsetWidth });
};
render () {
const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
const { loading } = this.state;
const className = classNames('image-loader', {
'image-loader--loading': loading,
'image-loader--amorphous': !this.hasSize(),
});
return (
<div className={className}>
{loading ? (
<>
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
<LoadingBar className='loading-bar' loading={1} />
</div>
<canvas
className='image-loader__preview-canvas'
ref={this.setCanvasRef}
width={width}
height={height}
/>
</>
) : (
<ZoomableImage
alt={alt}
lang={lang}
src={src}
onClick={onClick}
width={width}
height={height}
zoomedIn={zoomedIn}
/>
)}
</div>
);
}
}

View file

@ -1,65 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import ImageLoader from './image_loader';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
class ImageModal extends PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
navigationHidden: false,
};
toggleNavigation = () => {
this.setState(prevState => ({
navigationHidden: !prevState.navigationHidden,
}));
};
render () {
const { intl, src, alt, onClose } = this.props;
const { navigationHidden } = this.state;
const navigationClassName = classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
return (
<div className='modal-root__modal media-modal'>
<div className='media-modal__closer' role='presentation' onClick={onClose} >
<ImageLoader
src={src}
width={400}
height={400}
alt={alt}
onClick={this.toggleNavigation}
/>
</div>
<div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} />
</div>
</div>
);
}
}
export default injectIntl(ImageModal);

View file

@ -0,0 +1,61 @@
import { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import { ZoomableImage } from './zoomable_image';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export const ImageModal: React.FC<{
src: string;
alt: string;
onClose: () => void;
}> = ({ src, alt, onClose }) => {
const intl = useIntl();
const [navigationHidden, setNavigationHidden] = useState(false);
const toggleNavigation = useCallback(() => {
setNavigationHidden((prevState) => !prevState);
}, [setNavigationHidden]);
const navigationClassName = classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
return (
<div className='modal-root__modal media-modal'>
<div
className='media-modal__closer'
role='presentation'
onClick={onClose}
>
<ZoomableImage
src={src}
width={400}
height={400}
alt={alt}
onClick={toggleNavigation}
/>
</div>
<div className={navigationClassName}>
<div className='media-modal__buttons'>
<IconButton
className='media-modal__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
</div>
</div>
</div>
);
};

View file

@ -22,7 +22,7 @@ import Footer from 'mastodon/features/picture_in_picture/components/footer';
import Video from 'mastodon/features/video';
import { disableSwiping } from 'mastodon/initial_state';
import ImageLoader from './image_loader';
import { ZoomableImage } from './zoomable_image';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -59,6 +59,12 @@ class MediaModal extends ImmutablePureComponent {
}));
};
handleZoomChange = (zoomedIn) => {
this.setState({
zoomedIn,
});
};
handleSwipe = (index) => {
this.setState({
index: index % this.props.media.size,
@ -165,23 +171,26 @@ class MediaModal extends ImmutablePureComponent {
const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
const content = media.map((image) => {
const content = media.map((image, idx) => {
const width = image.getIn(['meta', 'original', 'width']) || null;
const height = image.getIn(['meta', 'original', 'height']) || null;
const description = image.getIn(['translation', 'description']) || image.get('description');
if (image.get('type') === 'image') {
return (
<ImageLoader
previewSrc={image.get('preview_url')}
<ZoomableImage
src={image.get('url')}
blurhash={image.get('blurhash')}
width={width}
height={height}
alt={description}
lang={lang}
key={image.get('url')}
onClick={this.handleToggleNavigation}
zoomedIn={zoomedIn}
onDoubleClick={this.handleZoomClick}
onClose={onClose}
onZoomChange={this.handleZoomChange}
zoomedIn={zoomedIn && idx === index}
/>
);
} else if (image.get('type') === 'video') {
@ -262,7 +271,7 @@ class MediaModal extends ImmutablePureComponent {
onChangeIndex={this.handleSwipe}
onTransitionEnd={this.handleTransitionEnd}
index={index}
disabled={disableSwiping}
disabled={disableSwiping || zoomedIn}
>
{content}
</ReactSwipeableViews>

View file

@ -39,7 +39,7 @@ import {
ConfirmFollowToListModal,
ConfirmMissingAltTextModal,
} from './confirmation_modals';
import ImageModal from './image_modal';
import { ImageModal } from './image_modal';
import MediaModal from './media_modal';
import { ModalPlaceholder } from './modal_placeholder';
import VideoModal from './video_modal';

View file

@ -1,402 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const NAV_BAR_HEIGHT = 66;
const getMidpoint = (p1, p2) => ({
x: (p1.clientX + p2.clientX) / 2,
y: (p1.clientY + p2.clientY) / 2,
});
const getDistance = (p1, p2) =>
Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
// Normalizing mousewheel speed across browsers
// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
const normalizeWheel = event => {
// Reasonable defaults
const PIXEL_STEP = 10;
const LINE_HEIGHT = 40;
const PAGE_HEIGHT = 800;
let sX = 0,
sY = 0, // spinX, spinY
pX = 0,
pY = 0; // pixelX, pixelY
// Legacy
if ('detail' in event) {
sY = event.detail;
}
if ('wheelDelta' in event) {
sY = -event.wheelDelta / 120;
}
if ('wheelDeltaY' in event) {
sY = -event.wheelDeltaY / 120;
}
if ('wheelDeltaX' in event) {
sX = -event.wheelDeltaX / 120;
}
// side scrolling on FF with DOMMouseScroll
if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
sX = sY;
sY = 0;
}
pX = sX * PIXEL_STEP;
pY = sY * PIXEL_STEP;
if ('deltaY' in event) {
pY = event.deltaY;
}
if ('deltaX' in event) {
pX = event.deltaX;
}
if ((pX || pY) && event.deltaMode) {
if (event.deltaMode === 1) { // delta in LINE units
pX *= LINE_HEIGHT;
pY *= LINE_HEIGHT;
} else { // delta in PAGE units
pX *= PAGE_HEIGHT;
pY *= PAGE_HEIGHT;
}
}
// Fall-back if spin cannot be determined
if (pX && !sX) {
sX = (pX < 1) ? -1 : 1;
}
if (pY && !sY) {
sY = (pY < 1) ? -1 : 1;
}
return {
spinX: sX,
spinY: sY,
pixelX: pX,
pixelY: pY,
};
};
class ZoomableImage extends PureComponent {
static propTypes = {
alt: PropTypes.string,
lang: PropTypes.string,
src: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
zoomedIn: PropTypes.bool,
};
static defaultProps = {
alt: '',
lang: '',
width: null,
height: null,
};
state = {
scale: MIN_SCALE,
zoomMatrix: {
type: null, // 'width' 'height'
fullScreen: null, // bool
rate: null, // full screen scale rate
clientWidth: null,
clientHeight: null,
offsetWidth: null,
offsetHeight: null,
clientHeightFixed: null,
scrollTop: null,
scrollLeft: null,
translateX: null,
translateY: null,
},
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
dragged: false,
lockScroll: { x: 0, y: 0 },
lockTranslate: { x: 0, y: 0 },
};
removers = [];
container = null;
image = null;
lastTouchEndTime = 0;
lastDistance = 0;
componentDidMount () {
let handler = this.handleTouchStart;
this.container.addEventListener('touchstart', handler);
this.removers.push(() => this.container.removeEventListener('touchstart', handler));
handler = this.handleTouchMove;
// on Chrome 56+, touch event listeners will default to passive
// https://www.chromestatus.com/features/5093566007214080
this.container.addEventListener('touchmove', handler, { passive: false });
this.removers.push(() => this.container.removeEventListener('touchend', handler));
handler = this.mouseDownHandler;
this.container.addEventListener('mousedown', handler);
this.removers.push(() => this.container.removeEventListener('mousedown', handler));
handler = this.mouseWheelHandler;
this.container.addEventListener('wheel', handler);
this.removers.push(() => this.container.removeEventListener('wheel', handler));
// Old Chrome
this.container.addEventListener('mousewheel', handler);
this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
// Old Firefox
this.container.addEventListener('DOMMouseScroll', handler);
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
this._initZoomMatrix();
}
componentWillUnmount () {
this._removeEventListeners();
}
componentDidUpdate (prevProps) {
if (prevProps.zoomedIn !== this.props.zoomedIn) {
this._toggleZoom();
}
}
_removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
mouseWheelHandler = e => {
e.preventDefault();
const event = normalizeWheel(e);
if (this.state.zoomMatrix.type === 'width') {
// full width, scroll vertical
this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
} else {
// full height, scroll horizontal
this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x);
}
// lock horizontal scroll
this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x);
};
mouseDownHandler = e => {
this.setState({ dragPosition: {
left: this.container.scrollLeft,
top: this.container.scrollTop,
// Get the current mouse position
x: e.clientX,
y: e.clientY,
} });
this.image.addEventListener('mousemove', this.mouseMoveHandler);
this.image.addEventListener('mouseup', this.mouseUpHandler);
};
mouseMoveHandler = e => {
const dx = e.clientX - this.state.dragPosition.x;
const dy = e.clientY - this.state.dragPosition.y;
this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x);
this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y);
this.setState({ dragged: true });
};
mouseUpHandler = () => {
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
this.image.removeEventListener('mouseup', this.mouseUpHandler);
};
handleTouchStart = e => {
if (e.touches.length !== 2) return;
this.lastDistance = getDistance(...e.touches);
};
handleTouchMove = e => {
const { scrollTop, scrollHeight, clientHeight } = this.container;
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
// prevent propagating event to MediaModal
e.stopPropagation();
return;
}
if (e.touches.length !== 2) return;
e.preventDefault();
e.stopPropagation();
const distance = getDistance(...e.touches);
const midpoint = getMidpoint(...e.touches);
const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
this._zoom(scale, midpoint);
this.lastMidpoint = midpoint;
this.lastDistance = distance;
};
_zoom(nextScale, midpoint) {
const { scale, zoomMatrix } = 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 }, () => {
this.container.scrollLeft = nextScrollLeft;
this.container.scrollTop = nextScrollTop;
// reset the translateX/Y constantly
if (nextScale < zoomMatrix.rate) {
this.setState({
lockTranslate: {
x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
},
});
}
});
}
handleClick = e => {
// don't propagate event to MediaModal
e.stopPropagation();
const dragged = this.state.dragged;
this.setState({ dragged: false });
if (dragged) return;
const handler = this.props.onClick;
if (handler) handler();
};
handleMouseDown = e => {
e.preventDefault();
};
_initZoomMatrix = () => {
const { width, height } = this.props;
const { clientWidth, clientHeight } = this.container;
const { offsetWidth, offsetHeight } = this.image;
const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
const fullScreen = type === 'width' ? width > clientWidth : height > clientHeightFixed;
const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
const scrollTop = type === 'width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
const scrollLeft = (clientWidth - offsetWidth) / 2;
const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
this.setState({
zoomMatrix: {
type: type,
fullScreen: fullScreen,
rate: rate,
clientWidth: clientWidth,
clientHeight: clientHeight,
offsetWidth: offsetWidth,
offsetHeight: offsetHeight,
clientHeightFixed: clientHeightFixed,
scrollTop: scrollTop,
scrollLeft: scrollLeft,
translateX: translateX,
translateY: translateY,
},
});
};
_toggleZoom () {
const { scale, zoomMatrix } = this.state;
if ( scale >= zoomMatrix.rate ) {
this.setState({
scale: MIN_SCALE,
lockScroll: {
x: 0,
y: 0,
},
lockTranslate: {
x: 0,
y: 0,
},
}, () => {
this.container.scrollLeft = 0;
this.container.scrollTop = 0;
});
} else {
this.setState({
scale: zoomMatrix.rate,
lockScroll: {
x: zoomMatrix.scrollLeft,
y: zoomMatrix.scrollTop,
},
lockTranslate: {
x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
},
}, () => {
this.container.scrollLeft = zoomMatrix.scrollLeft;
this.container.scrollTop = zoomMatrix.scrollTop;
});
}
}
setContainerRef = c => {
this.container = c;
};
setImageRef = c => {
this.image = c;
};
render () {
const { alt, lang, src, width, height } = this.props;
const { scale, lockTranslate, dragged } = this.state;
const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
return (
<div
className='zoomable-image'
ref={this.setContainerRef}
style={{ overflow, cursor, userSelect: 'none' }}
>
<img
role='presentation'
ref={this.setImageRef}
alt={alt}
title={alt}
lang={lang}
src={src}
width={width}
height={height}
style={{
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
transformOrigin: '0 0',
}}
draggable={false}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
/>
</div>
);
}
}
export default ZoomableImage;

View file

@ -0,0 +1,319 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import classNames from 'classnames';
import { useSpring, animated, config } from '@react-spring/web';
import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react';
import { Blurhash } from 'mastodon/components/blurhash';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
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;
};
interface ZoomableImageProps {
alt?: string;
lang?: string;
src: string;
width: number;
height: number;
onClick?: () => void;
onDoubleClick?: () => void;
onClose?: () => void;
onZoomChange?: (zoomedIn: boolean) => void;
zoomedIn?: boolean;
blurhash?: string;
}
export const ZoomableImage: React.FC<ZoomableImageProps> = ({
alt = '',
lang = '',
src,
width,
height,
onClick,
onDoubleClick,
onClose,
onZoomChange,
zoomedIn,
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);
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);
}
},
},
}));
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 handleLoad = useCallback(() => {
setLoaded(true);
}, [setLoaded]);
const handleError = useCallback(() => {
setError(true);
}, [setError]);
return (
<div
className={classNames('zoomable-image', {
'zoomable-image--zoomed-in': zoomedIn,
'zoomable-image--error': error,
'zoomable-image--dragging': dragging,
})}
ref={containerRef}
>
{!loaded && blurhash && (
<div
className='zoomable-image__preview'
style={{
aspectRatio: `${width}/${height}`,
height: `min(${height}px, 100%)`,
}}
>
<Blurhash hash={blurhash} />
</div>
)}
<animated.img
style={style}
role='presentation'
ref={imageRef}
alt={alt}
title={alt}
lang={lang}
src={src}
width={width}
height={height}
draggable={false}
onLoad={handleLoad}
onError={handleError}
onClickCapture={handleClick}
/>
{!loaded && !error && <LoadingIndicator />}
</div>
);
};

View file

@ -63,6 +63,7 @@ body {
&.with-modals--active {
overflow-y: hidden;
overscroll-behavior: none;
}
}

View file

@ -2448,49 +2448,6 @@ a.account__display-name {
}
}
.image-loader {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
&::-webkit-scrollbar,
*::-webkit-scrollbar {
width: 0;
height: 0;
background: transparent; /* Chrome/Safari/Webkit */
}
.image-loader__preview-canvas {
max-width: $media-modal-media-max-width;
max-height: $media-modal-media-max-height;
background: url('../images/void.png') repeat;
object-fit: contain;
}
.loading-bar__container {
position: relative;
}
.loading-bar {
position: absolute;
}
&.image-loader--amorphous .image-loader__preview-canvas {
display: none;
}
}
.zoomable-image {
position: relative;
width: 100%;
@ -2498,13 +2455,61 @@ a.account__display-name {
display: flex;
align-items: center;
justify-content: center;
scrollbar-width: none;
overflow: hidden;
user-select: none;
img {
max-width: $media-modal-media-max-width;
max-height: $media-modal-media-max-height;
width: auto;
height: auto;
object-fit: contain;
outline: 1px solid var(--media-outline-color);
outline-offset: -1px;
border-radius: 8px;
touch-action: none;
}
&--zoomed-in {
z-index: 9999;
cursor: grab;
img {
outline: none;
border-radius: 0;
}
}
&--dragging {
cursor: grabbing;
}
&--error img {
visibility: hidden;
}
&__preview {
max-width: $media-modal-media-max-width;
max-height: $media-modal-media-max-height;
position: absolute;
z-index: 1;
outline: 1px solid var(--media-outline-color);
outline-offset: -1px;
border-radius: 8px;
overflow: hidden;
canvas {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: -1;
}
}
.loading-indicator {
z-index: 2;
mix-blend-mode: luminosity;
}
}
@ -5576,6 +5581,7 @@ a.status-card {
z-index: 9999;
pointer-events: none;
user-select: none;
overscroll-behavior: none;
}
.modal-root__modal {
@ -5709,7 +5715,7 @@ a.status-card {
.picture-in-picture__footer {
border-radius: 0;
background: transparent;
padding: 20px 0;
padding: 16px;
.icon-button {
color: $white;

View file

@ -4,7 +4,7 @@ const { env, settings } = require('../configuration');
// Those modules contain modern ES code that need to be transpiled for Webpack to process it
const nodeModulesToProcess = [
'@reduxjs', 'fuzzysort', 'toygrad'
'@reduxjs', 'fuzzysort', 'toygrad', '@react-spring'
];
module.exports = {

View file

@ -51,8 +51,10 @@
"@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1",
"@rails/ujs": "7.1.501",
"@react-spring/web": "^9.7.5",
"@reduxjs/toolkit": "^2.0.1",
"@svgr/webpack": "^5.5.0",
"@use-gesture/react": "^10.3.1",
"arrow-key-navigation": "^1.2.0",
"async-mutex": "^0.5.0",
"axios": "^1.4.0",

View file

@ -2748,6 +2748,7 @@ __metadata:
"@gamestdio/websocket": "npm:^0.3.2"
"@github/webauthn-json": "npm:^2.1.1"
"@rails/ujs": "npm:7.1.501"
"@react-spring/web": "npm:^9.7.5"
"@reduxjs/toolkit": "npm:^2.0.1"
"@svgr/webpack": "npm:^5.5.0"
"@testing-library/dom": "npm:^10.2.0"
@ -2783,6 +2784,7 @@ __metadata:
"@types/webpack-env": "npm:^1.18.4"
"@typescript-eslint/eslint-plugin": "npm:^8.0.0"
"@typescript-eslint/parser": "npm:^8.0.0"
"@use-gesture/react": "npm:^10.3.1"
arrow-key-navigation: "npm:^1.2.0"
async-mutex: "npm:^0.5.0"
axios: "npm:^1.4.0"
@ -3198,6 +3200,72 @@ __metadata:
languageName: node
linkType: hard
"@react-spring/animated@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/animated@npm:9.7.5"
dependencies:
"@react-spring/shared": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/f8c2473c60f39a878c7dd0fdfcfcdbc720521e1506aa3f63c9de64780694a0a73d5ccc535a5ccec3520ddb70a71cf43b038b32c18e99531522da5388c510ecd7
languageName: node
linkType: hard
"@react-spring/core@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/core@npm:9.7.5"
dependencies:
"@react-spring/animated": "npm:~9.7.5"
"@react-spring/shared": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/5bfd83dfe248cd91889f215f015d908c7714ef445740fd5afa054b27ebc7d5a456abf6c309e2459d9b5b436e78d6fda16b62b9601f96352e9130552c02270830
languageName: node
linkType: hard
"@react-spring/rafz@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/rafz@npm:9.7.5"
checksum: 10c0/8bdad180feaa9a0e870a513043a5e98a4e9b7292a9f887575b7e6fadab2677825bc894b7ff16c38511b35bfe6cc1072df5851c5fee64448d67551559578ca759
languageName: node
linkType: hard
"@react-spring/shared@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/shared@npm:9.7.5"
dependencies:
"@react-spring/rafz": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/0207eacccdedd918a2fc55e78356ce937f445ce27ad9abd5d3accba8f9701a39349b55115641dc2b39bb9d3a155b058c185b411d292dc8cc5686bfa56f73b94f
languageName: node
linkType: hard
"@react-spring/types@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/types@npm:9.7.5"
checksum: 10c0/85c05121853cacb64f7cf63a4855e9044635e1231f70371cd7b8c78bc10be6f4dd7c68f592f92a2607e8bb68051540989b4677a2ccb525dba937f5cd95dc8bc1
languageName: node
linkType: hard
"@react-spring/web@npm:^9.7.5":
version: 9.7.5
resolution: "@react-spring/web@npm:9.7.5"
dependencies:
"@react-spring/animated": "npm:~9.7.5"
"@react-spring/core": "npm:~9.7.5"
"@react-spring/shared": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/bcd1e052e1b16341a12a19bf4515f153ca09d1fa86ff7752a5d02d7c4db58e8baf80e6283e64411f1e388c65340dce2254b013083426806b5dbae38bd151e53e
languageName: node
linkType: hard
"@reduxjs/toolkit@npm:^2.0.1":
version: 2.6.1
resolution: "@reduxjs/toolkit@npm:2.6.1"
@ -4414,6 +4482,24 @@ __metadata:
languageName: node
linkType: hard
"@use-gesture/core@npm:10.3.1":
version: 10.3.1
resolution: "@use-gesture/core@npm:10.3.1"
checksum: 10c0/2e3b5c0f7fe26cdb47be3a9c2a58a6a9edafc5b2895b07d2898eda9ab5a2b29fb0098b15597baa0856907b593075cd44cc69bba4785c9cfb7b6fabaa3b52cd3e
languageName: node
linkType: hard
"@use-gesture/react@npm:^10.3.1":
version: 10.3.1
resolution: "@use-gesture/react@npm:10.3.1"
dependencies:
"@use-gesture/core": "npm:10.3.1"
peerDependencies:
react: ">= 16.8.0"
checksum: 10c0/978da66e4e7c424866ad52eba8fdf0ce93a4c8fc44f8837c7043e68c6a6107cd67e817fffb27f7db2ae871ef2f6addb0c8ddf1586f24c67b7e6aef1646c668cf
languageName: node
linkType: hard
"@webassemblyjs/ast@npm:1.9.0":
version: 1.9.0
resolution: "@webassemblyjs/ast@npm:1.9.0"