diff --git a/app/javascript/mastodon/features/ui/components/image_loader.jsx b/app/javascript/mastodon/features/ui/components/image_loader.jsx
deleted file mode 100644
index b1417deda7..0000000000
--- a/app/javascript/mastodon/features/ui/components/image_loader.jsx
+++ /dev/null
@@ -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 (
-
- {loading ? (
- <>
-
-
-
-
-
- >
- ) : (
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/image_modal.jsx b/app/javascript/mastodon/features/ui/components/image_modal.jsx
deleted file mode 100644
index f08ce15342..0000000000
--- a/app/javascript/mastodon/features/ui/components/image_modal.jsx
+++ /dev/null
@@ -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 (
-
- );
- }
-
-}
-
-export default injectIntl(ImageModal);
diff --git a/app/javascript/mastodon/features/ui/components/image_modal.tsx b/app/javascript/mastodon/features/ui/components/image_modal.tsx
new file mode 100644
index 0000000000..fa94cfcc3c
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/image_modal.tsx
@@ -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 (
+
+ );
+};
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx
index d69ceba539..9312805b5c 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx
@@ -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 && ;
const rightNav = media.size > 1 && ;
- 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 (
-
);
} 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}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx
index cb1e499dbb..74fe59f322 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.jsx
+++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx
@@ -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';
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx b/app/javascript/mastodon/features/ui/components/zoomable_image.jsx
deleted file mode 100644
index c4129bf260..0000000000
--- a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx
+++ /dev/null
@@ -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 (
-
-

-
- );
- }
-}
-
-export default ZoomableImage;
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.tsx b/app/javascript/mastodon/features/ui/components/zoomable_image.tsx
new file mode 100644
index 0000000000..85e29e6aea
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/zoomable_image.tsx
@@ -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 = ({
+ 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(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);
+ }
+ },
+ },
+ }));
+
+ 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 (
+
+ {!loaded && blurhash && (
+
+
+
+ )}
+
+
+
+ {!loaded && !error &&
}
+
+ );
+};
diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss
index ec6e4d0a9a..ed3fe0ee0a 100644
--- a/app/javascript/styles/mastodon/basics.scss
+++ b/app/javascript/styles/mastodon/basics.scss
@@ -63,6 +63,7 @@ body {
&.with-modals--active {
overflow-y: hidden;
+ overscroll-behavior: none;
}
}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 097a2fa20e..800cb473f7 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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;
diff --git a/config/webpack/rules/babel.js b/config/webpack/rules/babel.js
index f1b53c3606..76e41f3df0 100644
--- a/config/webpack/rules/babel.js
+++ b/config/webpack/rules/babel.js
@@ -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 = {
diff --git a/package.json b/package.json
index 2ef2e9b0a4..c6b76527c6 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/yarn.lock b/yarn.lock
index 50b6c5e720..11d9c907c3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"