about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
authorThibG <thib@sitedethib.com>2020-11-07 21:47:31 +0100
committerGitHub <noreply@github.com>2020-11-07 21:47:31 +0100
commitcfb16b9b70a50ec5451c9aebb2c35d3a44701311 (patch)
tree6ba298899b376d0c8862d77c1e34339a08dc3121 /app/javascript
parent5a9fc749c3eab8d3c93dd282fa89c20a5cb0e994 (diff)
parente4d62042bdb3b0d675c2367b4c48a2a48647af5e (diff)
Merge pull request #1453 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/flavours/glitch/components/column.js6
-rw-r--r--app/javascript/flavours/glitch/components/dropdown_menu.js4
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/zoomable_image.js69
-rw-r--r--app/javascript/flavours/glitch/util/dom_helpers.js4
-rw-r--r--app/javascript/flavours/glitch/util/is_mobile.js4
-rw-r--r--app/javascript/mastodon/components/column.js6
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js4
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js6
-rw-r--r--app/javascript/mastodon/features/ui/components/zoomable_image.js69
-rw-r--r--app/javascript/mastodon/is_mobile.js4
14 files changed, 130 insertions, 64 deletions
diff --git a/app/javascript/flavours/glitch/components/column.js b/app/javascript/flavours/glitch/components/column.js
index 5819d5362..c9da7d329 100644
--- a/app/javascript/flavours/glitch/components/column.js
+++ b/app/javascript/flavours/glitch/components/column.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import { scrollTop } from 'flavours/glitch/util/scroll';
 
 export default class Column extends React.PureComponent {
@@ -37,9 +37,9 @@ export default class Column extends React.PureComponent {
 
   componentDidMount () {
     if (this.props.bindToDocument) {
-      document.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     } else {
-      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
   }
 
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js
index e627ea51f..d1aba691c 100644
--- a/app/javascript/flavours/glitch/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/components/dropdown_menu.js
@@ -5,9 +5,9 @@ import IconButton from './icon_button';
 import Overlay from 'react-overlays/lib/Overlay';
 import Motion from 'flavours/glitch/util/optional_motion';
 import spring from 'react-motion/lib/spring';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 
-const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 let id = 0;
 
 class DropdownMenu extends React.PureComponent {
diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
index 89219d739..5fd904593 100644
--- a/app/javascript/flavours/glitch/features/emoji_picker/index.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -10,7 +10,7 @@ import { EmojiPicker as EmojiPickerAsync } from 'flavours/glitch/util/async-comp
 import Overlay from 'react-overlays/lib/Overlay';
 import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji';
 import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state';
 import { assetHost } from 'flavours/glitch/util/config';
@@ -109,7 +109,7 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
 let EmojiPicker, Emoji; // load asynchronously
 
 const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
-const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
 class ModifierPickerMenu extends React.PureComponent {
 
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index 2de24bea5..729ade212 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -29,7 +29,7 @@ import Icon from 'flavours/glitch/components/icon';
 import ComposePanel from './compose_panel';
 import NavigationPanel from './navigation_panel';
 
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import { scrollRight } from 'flavours/glitch/util/scroll';
 
 const componentMap = {
@@ -80,7 +80,7 @@ class ColumnsArea extends ImmutablePureComponent {
 
   componentDidMount() {
     if (!this.props.singleColumn) {
-      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
 
     this.lastIndex   = getIndex(this.context.router.history.location.pathname);
@@ -97,7 +97,7 @@ class ColumnsArea extends ImmutablePureComponent {
 
   componentDidUpdate(prevProps) {
     if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
-      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
     this.lastIndex = getIndex(this.context.router.history.location.pathname);
     this.setState({ shouldAnimate: true });
diff --git a/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js
index 2efc70890..caeeced64 100644
--- a/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js
+++ b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js
@@ -113,7 +113,8 @@ class ZoomableImage extends React.PureComponent {
   state = {
     scale: MIN_SCALE,
     zoomMatrix: {
-      type: null, // 'full-width' 'full-height'
+      type: null, // 'width' 'height'
+      fullScreen: null, // bool
       rate: null, // full screen scale rate
       clientWidth: null,
       clientHeight: null,
@@ -122,12 +123,15 @@ class ZoomableImage extends React.PureComponent {
       clientHeightFixed: null,
       scrollTop: null,
       scrollLeft: null,
+      translateX: null,
+      translateY: null,
     },
     zoomState: 'expand', // 'expand' 'compress'
     navigationHidden: false,
     dragPosition: { top: 0, left: 0, x: 0, y: 0 },
     dragged: false,
     lockScroll: { x: 0, y: 0 },
+    lockTranslate: { x: 0, y: 0 },
   }
 
   removers = [];
@@ -168,18 +172,24 @@ class ZoomableImage extends React.PureComponent {
   }
 
   componentDidUpdate () {
+    this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
+
+    if (this.state.scale === MIN_SCALE) {
+      this.container.style.removeProperty('cursor');
+    }
+  }
+
+  UNSAFE_componentWillReceiveProps () {
+    // reset when slide to next image
     if (this.props.zoomButtonHidden) {
-      this.setState({ scale: MIN_SCALE }, () => {
+      this.setState({
+        scale: MIN_SCALE,
+        lockTranslate: { x: 0, y: 0 },
+      }, () => {
         this.container.scrollLeft = 0;
         this.container.scrollTop = 0;
       });
     }
-
-    this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
-
-    if (this.state.scale === 1) {
-      this.container.style.removeProperty('cursor');
-    }
   }
 
   removeEventListeners () {
@@ -192,7 +202,7 @@ class ZoomableImage extends React.PureComponent {
 
     const event = normalizeWheel(e);
 
-    if (this.state.zoomMatrix.type === 'full-width') {
+    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 {
@@ -268,7 +278,7 @@ class ZoomableImage extends React.PureComponent {
   }
 
   zoom(nextScale, midpoint) {
-    const { scale } = this.state;
+    const { scale, zoomMatrix } = this.state;
     const { scrollLeft, scrollTop } = this.container;
 
     // math memo:
@@ -283,6 +293,15 @@ class ZoomableImage extends React.PureComponent {
     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)),
+          },
+        });
+      }
     });
   }
 
@@ -307,14 +326,18 @@ class ZoomableImage extends React.PureComponent {
     const { offsetWidth, offsetHeight } = this.image;
     const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
 
-    const type = width/height < clientWidth / clientHeightFixed ? 'full-width' : 'full-height';
-    const rate = type === 'full-width' ? clientWidth / offsetWidth : clientHeightFixed / offsetHeight;
-    const scrollTop = type === 'full-width' ?  (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
+    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,
@@ -323,6 +346,8 @@ class ZoomableImage extends React.PureComponent {
         clientHeightFixed: clientHeightFixed,
         scrollTop: scrollTop,
         scrollLeft: scrollLeft,
+        translateX: translateX,
+        translateY: translateY,
       },
     });
   }
@@ -340,6 +365,10 @@ class ZoomableImage extends React.PureComponent {
           x: 0,
           y: 0,
         },
+        lockTranslate: {
+          x: 0,
+          y: 0,
+        },
       }, () => {
         this.container.scrollLeft = 0;
         this.container.scrollTop = 0;
@@ -351,6 +380,10 @@ class ZoomableImage extends React.PureComponent {
           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;
@@ -371,15 +404,15 @@ class ZoomableImage extends React.PureComponent {
 
   render () {
     const { alt, src, width, height, intl } = this.props;
-    const { scale } = this.state;
-    const overflow = scale === 1 ? 'hidden' : 'scroll';
-    const zoomButtonSshouldHide = !this.state.navigationHidden && !this.props.zoomButtonHidden ? '' : 'media-modal__zoom-button--hidden';
+    const { scale, lockTranslate } = this.state;
+    const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
+    const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : '';
     const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
 
     return (
       <React.Fragment>
         <IconButton
-          className={`media-modal__zoom-button ${zoomButtonSshouldHide}`}
+          className={`media-modal__zoom-button ${zoomButtonShouldHide}`}
           title={zoomButtonTitle}
           icon={this.state.zoomState}
           onClick={this.handleZoomClick}
@@ -402,7 +435,7 @@ class ZoomableImage extends React.PureComponent {
             width={width}
             height={height}
             style={{
-              transform: `scale(${scale})`,
+              transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
               transformOrigin: '0 0',
             }}
             draggable={false}
diff --git a/app/javascript/flavours/glitch/util/dom_helpers.js b/app/javascript/flavours/glitch/util/dom_helpers.js
index 3e1f4a26d..d94aeb9d4 100644
--- a/app/javascript/flavours/glitch/util/dom_helpers.js
+++ b/app/javascript/flavours/glitch/util/dom_helpers.js
@@ -1,9 +1,9 @@
 //  Package imports.
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 
 //  This will either be a passive lister options object (if passive
 //  events are supported), or `false`.
-export const withPassive = detectPassiveEvents.hasSupport ? { passive: true } : false;
+export const withPassive = supportsPassiveEvents ? { passive: true } : false;
 
 //  Focuses the root element.
 export function focusRoot () {
diff --git a/app/javascript/flavours/glitch/util/is_mobile.js b/app/javascript/flavours/glitch/util/is_mobile.js
index db3c8bd80..7e584e8fa 100644
--- a/app/javascript/flavours/glitch/util/is_mobile.js
+++ b/app/javascript/flavours/glitch/util/is_mobile.js
@@ -1,4 +1,4 @@
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import { forceSingleColumn } from 'flavours/glitch/util/initial_state';
 
 const LAYOUT_BREAKPOINT = 630;
@@ -17,7 +17,7 @@ export function isMobile(width, columns) {
 const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
 
 let userTouching = false;
-let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+let listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
 function touchListener() {
   userTouching = true;
diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js
index 55e3bfd5e..239824a4f 100644
--- a/app/javascript/mastodon/components/column.js
+++ b/app/javascript/mastodon/components/column.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import { scrollTop } from '../scroll';
 
 export default class Column extends React.PureComponent {
@@ -35,9 +35,9 @@ export default class Column extends React.PureComponent {
 
   componentDidMount () {
     if (this.props.bindToDocument) {
-      document.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     } else {
-      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
   }
 
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 09e3c9df8..c6b4b1187 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -5,9 +5,9 @@ import IconButton from './icon_button';
 import Overlay from 'react-overlays/lib/Overlay';
 import Motion from '../features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 
-const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 let id = 0;
 
 class DropdownMenu extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index bac136e5e..dc4f48060 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -5,7 +5,7 @@ import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'
 import Overlay from 'react-overlays/lib/Overlay';
 import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
 import { assetHost } from 'mastodon/utils/config';
 
@@ -29,7 +29,7 @@ const messages = defineMessages({
 let EmojiPicker, Emoji; // load asynchronously
 
 const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
-const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
 class ModifierPickerMenu extends React.PureComponent {
 
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index 5223025fb..309f46290 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -5,7 +5,7 @@ import IconButton from '../../../components/icon_button';
 import Overlay from 'react-overlays/lib/Overlay';
 import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
 
@@ -21,7 +21,7 @@ const messages = defineMessages({
   change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
 });
 
-const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
 class PrivacyDropdownMenu extends React.PureComponent {
 
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index ecc0b8f0b..36a84fcbf 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -31,7 +31,7 @@ import Icon from 'mastodon/components/icon';
 import ComposePanel from './compose_panel';
 import NavigationPanel from './navigation_panel';
 
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import { scrollRight } from '../../../scroll';
 
 const componentMap = {
@@ -80,7 +80,7 @@ class ColumnsArea extends ImmutablePureComponent {
 
   componentDidMount() {
     if (!this.props.singleColumn) {
-      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
 
     this.lastIndex   = getIndex(this.context.router.history.location.pathname);
@@ -97,7 +97,7 @@ class ColumnsArea extends ImmutablePureComponent {
 
   componentDidUpdate(prevProps) {
     if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
-      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
     this.lastIndex = getIndex(this.context.router.history.location.pathname);
     this.setState({ shouldAnimate: true });
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.js b/app/javascript/mastodon/features/ui/components/zoomable_image.js
index 402196727..1cf263cb9 100644
--- a/app/javascript/mastodon/features/ui/components/zoomable_image.js
+++ b/app/javascript/mastodon/features/ui/components/zoomable_image.js
@@ -113,7 +113,8 @@ class ZoomableImage extends React.PureComponent {
   state = {
     scale: MIN_SCALE,
     zoomMatrix: {
-      type: null, // 'full-width' 'full-height'
+      type: null, // 'width' 'height'
+      fullScreen: null, // bool
       rate: null, // full screen scale rate
       clientWidth: null,
       clientHeight: null,
@@ -122,12 +123,15 @@ class ZoomableImage extends React.PureComponent {
       clientHeightFixed: null,
       scrollTop: null,
       scrollLeft: null,
+      translateX: null,
+      translateY: null,
     },
     zoomState: 'expand', // 'expand' 'compress'
     navigationHidden: false,
     dragPosition: { top: 0, left: 0, x: 0, y: 0 },
     dragged: false,
     lockScroll: { x: 0, y: 0 },
+    lockTranslate: { x: 0, y: 0 },
   }
 
   removers = [];
@@ -168,18 +172,24 @@ class ZoomableImage extends React.PureComponent {
   }
 
   componentDidUpdate () {
+    this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
+
+    if (this.state.scale === MIN_SCALE) {
+      this.container.style.removeProperty('cursor');
+    }
+  }
+
+  UNSAFE_componentWillReceiveProps () {
+    // reset when slide to next image
     if (this.props.zoomButtonHidden) {
-      this.setState({ scale: MIN_SCALE }, () => {
+      this.setState({
+        scale: MIN_SCALE,
+        lockTranslate: { x: 0, y: 0 },
+      }, () => {
         this.container.scrollLeft = 0;
         this.container.scrollTop = 0;
       });
     }
-
-    this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
-
-    if (this.state.scale === 1) {
-      this.container.style.removeProperty('cursor');
-    }
   }
 
   removeEventListeners () {
@@ -192,7 +202,7 @@ class ZoomableImage extends React.PureComponent {
 
     const event = normalizeWheel(e);
 
-    if (this.state.zoomMatrix.type === 'full-width') {
+    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 {
@@ -268,7 +278,7 @@ class ZoomableImage extends React.PureComponent {
   }
 
   zoom(nextScale, midpoint) {
-    const { scale } = this.state;
+    const { scale, zoomMatrix } = this.state;
     const { scrollLeft, scrollTop } = this.container;
 
     // math memo:
@@ -283,6 +293,15 @@ class ZoomableImage extends React.PureComponent {
     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)),
+          },
+        });
+      }
     });
   }
 
@@ -307,14 +326,18 @@ class ZoomableImage extends React.PureComponent {
     const { offsetWidth, offsetHeight } = this.image;
     const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
 
-    const type = width/height < clientWidth / clientHeightFixed ? 'full-width' : 'full-height';
-    const rate = type === 'full-width' ? clientWidth / offsetWidth : clientHeightFixed / offsetHeight;
-    const scrollTop = type === 'full-width' ?  (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
+    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,
@@ -323,6 +346,8 @@ class ZoomableImage extends React.PureComponent {
         clientHeightFixed: clientHeightFixed,
         scrollTop: scrollTop,
         scrollLeft: scrollLeft,
+        translateX: translateX,
+        translateY: translateY,
       },
     });
   }
@@ -340,6 +365,10 @@ class ZoomableImage extends React.PureComponent {
           x: 0,
           y: 0,
         },
+        lockTranslate: {
+          x: 0,
+          y: 0,
+        },
       }, () => {
         this.container.scrollLeft = 0;
         this.container.scrollTop = 0;
@@ -351,6 +380,10 @@ class ZoomableImage extends React.PureComponent {
           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;
@@ -371,15 +404,15 @@ class ZoomableImage extends React.PureComponent {
 
   render () {
     const { alt, src, width, height, intl } = this.props;
-    const { scale } = this.state;
-    const overflow = scale === 1 ? 'hidden' : 'scroll';
-    const zoomButtonSshouldHide = !this.state.navigationHidden && !this.props.zoomButtonHidden ? '' : 'media-modal__zoom-button--hidden';
+    const { scale, lockTranslate } = this.state;
+    const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
+    const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : '';
     const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
 
     return (
       <React.Fragment>
         <IconButton
-          className={`media-modal__zoom-button ${zoomButtonSshouldHide}`}
+          className={`media-modal__zoom-button ${zoomButtonShouldHide}`}
           title={zoomButtonTitle}
           icon={this.state.zoomState}
           onClick={this.handleZoomClick}
@@ -402,7 +435,7 @@ class ZoomableImage extends React.PureComponent {
             width={width}
             height={height}
             style={{
-              transform: `scale(${scale})`,
+              transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
               transformOrigin: '0 0',
             }}
             draggable={false}
diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js
index f96df1ebb..5a8c3db08 100644
--- a/app/javascript/mastodon/is_mobile.js
+++ b/app/javascript/mastodon/is_mobile.js
@@ -1,4 +1,4 @@
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 
 const LAYOUT_BREAKPOINT = 630;
 
@@ -9,7 +9,7 @@ export function isMobile(width) {
 const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
 
 let userTouching = false;
-let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+let listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
 function touchListener() {
   userTouching = true;