about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/javascript/mastodon/features/ui/components/zoomable_image.js165
-rw-r--r--app/javascript/styles/mastodon/components.scss3
-rw-r--r--package.json1
-rw-r--r--yarn.lock4
4 files changed, 98 insertions, 75 deletions
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.js b/app/javascript/mastodon/features/ui/components/zoomable_image.js
index 0a0a4d41a..0cae0862d 100644
--- a/app/javascript/mastodon/features/ui/components/zoomable_image.js
+++ b/app/javascript/mastodon/features/ui/components/zoomable_image.js
@@ -1,16 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import Hammer from 'hammerjs';
 
 const MIN_SCALE = 1;
 const MAX_SCALE = 4;
-
-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 DOUBLE_TAP_SCALE = 2;
 
 const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
 
@@ -37,83 +31,97 @@ export default class ZoomableImage extends React.PureComponent {
   removers = [];
   container = null;
   image = null;
-  lastTouchEndTime = 0;
-  lastDistance = 0;
+  lastScale = null;
+  zoomCenter = null;
 
   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));
+    // register pinch event handlers to the container
+    let hammer = new Hammer.Manager(this.container, {
+      // required to make container scrollable by touch
+      touchAction: 'pan-x pan-y',
+    });
+    hammer.add(new Hammer.Pinch());
+    hammer.on('pinchstart', this.handlePinchStart);
+    hammer.on('pinchmove', this.handlePinchMove);
+    this.removers.push(() => hammer.off('pinchstart pinchmove'));
+
+    // register tap event handlers
+    hammer = new Hammer.Manager(this.image);
+    // NOTE the order of adding is also the order of gesture recognition
+    hammer.add(new Hammer.Tap({ event: 'doubletap', taps: 2 }));
+    hammer.add(new Hammer.Tap());
+    // prevent the 'tap' event handler be fired on double tap
+    hammer.get('tap').requireFailure('doubletap');
+    // NOTE 'tap' and 'doubletap' events are fired by touch and *mouse*
+    hammer.on('tap', this.handleTap);
+    hammer.on('doubletap', this.handleDoubleTap);
+    this.removers.push(() => hammer.off('tap doubletap'));
   }
 
   componentWillUnmount () {
     this.removeEventListeners();
   }
 
-  removeEventListeners () {
-    this.removers.forEach(listeners => listeners());
-    this.removers = [];
-  }
+  componentDidUpdate (prevProps, prevState) {
+    if (!this.zoomCenter) return;
 
-  handleTouchStart = e => {
-    if (e.touches.length !== 2) return;
+    const { x: cx, y: cy } = this.zoomCenter;
+    const { scale: prevScale } = prevState;
+    const { scale: nextScale } = this.state;
+    const { scrollLeft, scrollTop } = this.container;
 
-    this.lastDistance = getDistance(...e.touches);
+    // math memo:
+    // x = (scrollLeft + cx) / scrollWidth
+    // x' = (nextScrollLeft + cx) / nextScrollWidth
+    // scrollWidth = clientWidth * prevScale
+    // scrollWidth' = clientWidth * nextScale
+    // Solve x = x' for nextScrollLeft
+    const nextScrollLeft = (scrollLeft + cx) * nextScale / prevScale - cx;
+    const nextScrollTop = (scrollTop + cy) * nextScale / prevScale - cy;
+
+    this.container.scrollLeft = nextScrollLeft;
+    this.container.scrollTop = nextScrollTop;
   }
 
-  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;
+  removeEventListeners () {
+    this.removers.forEach(listeners => listeners());
+    this.removers = [];
+  }
 
-    e.preventDefault();
+  handleClick = e => {
+    // prevent the click event propagated to parent
     e.stopPropagation();
 
-    const distance = getDistance(...e.touches);
-    const midpoint = getMidpoint(...e.touches);
-    const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance);
-
-    this.zoom(scale, midpoint);
-
-    this.lastMidpoint = midpoint;
-    this.lastDistance = distance;
+    // the tap event handler is executed at the same time by touch and mouse,
+    // so we don't need to execute the onClick handler here
   }
 
-  zoom(nextScale, midpoint) {
-    const { scale } = 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;
+  handlePinchStart = () => {
+    this.lastScale = this.state.scale;
+  }
 
-    this.setState({ scale: nextScale }, () => {
-      this.container.scrollLeft = nextScrollLeft;
-      this.container.scrollTop = nextScrollTop;
-    });
+  handlePinchMove = e => {
+    const scale = clamp(MIN_SCALE, MAX_SCALE, this.lastScale * e.scale);
+    this.zoom(scale, e.center);
   }
 
-  handleClick = e => {
-    // don't propagate event to MediaModal
-    e.stopPropagation();
+  handleTap = () => {
     const handler = this.props.onClick;
     if (handler) handler();
   }
 
+  handleDoubleTap = e => {
+    if (this.state.scale === MIN_SCALE)
+      this.zoom(DOUBLE_TAP_SCALE, e.center);
+    else
+      this.zoom(MIN_SCALE, e.center);
+  }
+
+  zoom (scale, center) {
+    this.zoomCenter = center;
+    this.setState({ scale });
+  }
+
   setContainerRef = c => {
     this.container = c;
   }
@@ -126,6 +134,18 @@ export default class ZoomableImage extends React.PureComponent {
     const { alt, src } = this.props;
     const { scale } = this.state;
     const overflow = scale === 1 ? 'hidden' : 'scroll';
+    const marginStyle = {
+      position: 'absolute',
+      top: 0,
+      bottom: 0,
+      left: 0,
+      right: 0,
+      display: 'flex',
+      alignItems: 'center',
+      justifyContent: 'center',
+      transform: `scale(${scale})`,
+      transformOrigin: '0 0',
+    };
 
     return (
       <div
@@ -133,17 +153,18 @@ export default class ZoomableImage extends React.PureComponent {
         ref={this.setContainerRef}
         style={{ overflow }}
       >
-        <img
-          role='presentation'
-          ref={this.setImageRef}
-          alt={alt}
-          src={src}
-          style={{
-            transform: `scale(${scale})`,
-            transformOrigin: '0 0',
-          }}
-          onClick={this.handleClick}
-        />
+        <div
+          className='zoomable-image__margin'
+          style={marginStyle}
+        >
+          <img
+            ref={this.setImageRef}
+            role='presentation'
+            alt={alt}
+            src={src}
+            onClick={this.handleClick}
+          />
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 2b13b80a7..447e6bc8e 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1459,9 +1459,6 @@
   position: relative;
   width: 100%;
   height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
 
   img {
     max-width: $media-modal-media-max-width;
diff --git a/package.json b/package.json
index 76f665dba..dfba49afc 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
     "file-loader": "^0.11.2",
     "font-awesome": "^4.7.0",
     "glob": "^7.1.1",
+    "hammerjs": "^2.0.8",
     "http-link-header": "^0.8.0",
     "immutable": "^3.8.2",
     "imports-loader": "^0.8.0",
diff --git a/yarn.lock b/yarn.lock
index a1dd4c694..a306ebf55 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3092,6 +3092,10 @@ gzip-size@^3.0.0:
   dependencies:
     duplexer "^0.1.1"
 
+hammerjs@^2.0.8:
+  version "2.0.8"
+  resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1"
+
 handle-thing@^1.2.5:
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"