Browse Source

Merge branch 'from/develop/tusooa/media-touch-actions' into 'develop'

Be able to scroll and pan media in media modal

See merge request pleroma/pleroma-fe!1403
HJ 2 years ago
parent
commit
89efb0d2f4

+ 6 - 4
CHANGELOG.md

@@ -36,19 +36,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Attachments are truncated just like post contents
 - Media modal now also displays description and counter position in gallery (i.e. 1/5)
 - Ability to rearrange order of attachments when uploading
+- Enabled users to zoom and pan images in media viewer with mouse and touch
+
 
 ## [2.4.2] - 2022-01-09
-### Added 
+### Added
 - Added Apply and Reset buttons to the bottom of theme tab to minimize UI travel
 - Implemented user option to always show floating New Post button (normally mobile-only)
-- Display reasons for instance specific policies 
+- Display reasons for instance specific policies
 - Added functionality to cancel follow request
 
 ### Fixed
 - Fixed link to external profile not working on user profiles
-- Fixed mobile shoutbox display 
+- Fixed mobile shoutbox display
 - Fixed favicon badge not working in Chrome
-- Escape html more properly in subject/display name 
+- Escape html more properly in subject/display name
 
 
 ## [2.4.0] - 2021-08-08

+ 1 - 0
package.json

@@ -22,6 +22,7 @@
     "@fortawesome/free-regular-svg-icons": "5.15.1",
     "@fortawesome/free-solid-svg-icons": "5.15.1",
     "@fortawesome/vue-fontawesome": "2.0.0",
+    "@kazvmoe-infra/pinch-zoom-element": "^1.2.0",
     "body-scroll-lock": "2.6.4",
     "chromatism": "3.0.0",
     "cropperjs": "1.4.3",

+ 43 - 25
src/components/media_modal/media_modal.js

@@ -1,32 +1,45 @@
 import StillImage from '../still-image/still-image.vue'
 import VideoAttachment from '../video_attachment/video_attachment.vue'
 import Modal from '../modal/modal.vue'
-import fileTypeService from '../../services/file_type/file_type.service.js'
+import PinchZoom from '../pinch_zoom/pinch_zoom.vue'
+import SwipeClick from '../swipe_click/swipe_click.vue'
 import GestureService from '../../services/gesture_service/gesture_service'
 import Flash from 'src/components/flash/flash.vue'
+import fileTypeService from '../../services/file_type/file_type.service.js'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faChevronLeft,
   faChevronRight,
-  faCircleNotch
+  faCircleNotch,
+  faTimes
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
   faChevronLeft,
   faChevronRight,
-  faCircleNotch
+  faCircleNotch,
+  faTimes
 )
 
 const MediaModal = {
   components: {
     StillImage,
     VideoAttachment,
+    PinchZoom,
+    SwipeClick,
     Modal,
     Flash
   },
   data () {
     return {
-      loading: false
+      loading: false,
+      swipeDirection: GestureService.DIRECTION_LEFT,
+      swipeThreshold: () => {
+        const considerableMoveRatio = 1 / 4
+        return window.innerWidth * considerableMoveRatio
+      },
+      pinchZoomMinScale: 1,
+      pinchZoomScaleResetLimit: 1.2
     }
   },
   computed: {
@@ -52,32 +65,26 @@ const MediaModal = {
       return this.currentMedia ? this.getType(this.currentMedia) : null
     }
   },
-  created () {
-    this.mediaSwipeGestureRight = GestureService.swipeGesture(
-      GestureService.DIRECTION_RIGHT,
-      this.goPrev,
-      50
-    )
-    this.mediaSwipeGestureLeft = GestureService.swipeGesture(
-      GestureService.DIRECTION_LEFT,
-      this.goNext,
-      50
-    )
-  },
   methods: {
     getType (media) {
       return fileTypeService.fileType(media.mimetype)
     },
-    mediaTouchStart (e) {
-      GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
-      GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
-    },
-    mediaTouchMove (e) {
-      GestureService.updateSwipe(e, this.mediaSwipeGestureRight)
-      GestureService.updateSwipe(e, this.mediaSwipeGestureLeft)
-    },
     hide () {
-      this.$store.dispatch('closeMediaViewer')
+      // HACK: Closing immediately via a touch will cause the click
+      // to be processed on the content below the overlay
+      const transitionTime = 100 // ms
+      setTimeout(() => {
+        this.$store.dispatch('closeMediaViewer')
+      }, transitionTime)
+    },
+    hideIfNotSwiped (event) {
+      // If we have swiped over SwipeClick, do not trigger hide
+      const comp = this.$refs.swipeClick
+      if (!comp) {
+        this.hide()
+      } else {
+        comp.$gesture.click(event)
+      }
     },
     goPrev () {
       if (this.canNavigate) {
@@ -102,6 +109,17 @@ const MediaModal = {
     onImageLoaded () {
       this.loading = false
     },
+    handleSwipePreview (offsets) {
+      this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 })
+    },
+    handleSwipeEnd (sign) {
+      this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
+      if (sign > 0) {
+        this.goNext()
+      } else if (sign < 0) {
+        this.goPrev()
+      }
+    },
     handleKeyupEvent (e) {
       if (this.showing && e.keyCode === 27) { // escape
         this.hide()

+ 114 - 33
src/components/media_modal/media_modal.vue

@@ -2,20 +2,38 @@
   <Modal
     v-if="showing"
     class="media-modal-view"
-    @backdropClicked="hide"
+    @backdropClicked="hideIfNotSwiped"
   >
-    <img
+    <SwipeClick
       v-if="type === 'image'"
-      :class="{ loading }"
-      class="modal-image"
-      :src="currentMedia.url"
-      :alt="currentMedia.description"
-      :title="currentMedia.description"
-      @touchstart.stop="mediaTouchStart"
-      @touchmove.stop="mediaTouchMove"
-      @click="hide"
-      @load="onImageLoaded"
+      ref="swipeClick"
+      class="modal-image-container"
+      :direction="swipeDirection"
+      :threshold="swipeThreshold"
+      @preview-requested="handleSwipePreview"
+      @swipe-finished="handleSwipeEnd"
+      @swipeless-clicked="hide"
     >
+      <PinchZoom
+        ref="pinchZoom"
+        class="modal-image-container-inner"
+        selector=".modal-image"
+        reach-min-scale-strategy="reset"
+        stop-propagate-handled="stop-propgate-handled"
+        :allow-pan-min-scale="pinchZoomMinScale"
+        :min-scale="pinchZoomMinScale"
+        :reset-to-min-scale-limit="pinchZoomScaleResetLimit"
+      >
+        <img
+          :class="{ loading }"
+          class="modal-image"
+          :src="currentMedia.url"
+          :alt="currentMedia.description"
+          :title="currentMedia.description"
+          @load="onImageLoaded"
+        >
+      </PinchZoom>
+    </SwipeClick>
     <VideoAttachment
       v-if="type === 'video'"
       class="modal-image"
@@ -40,25 +58,36 @@
     <button
       v-if="canNavigate"
       :title="$t('media_modal.previous')"
-      class="modal-view-button-arrow modal-view-button-arrow--prev"
+      class="modal-view-button modal-view-button-arrow modal-view-button-arrow--prev"
       @click.stop.prevent="goPrev"
     >
       <FAIcon
-        class="arrow-icon"
+        class="button-icon arrow-icon"
         icon="chevron-left"
       />
     </button>
     <button
       v-if="canNavigate"
       :title="$t('media_modal.next')"
-      class="modal-view-button-arrow modal-view-button-arrow--next"
+      class="modal-view-button modal-view-button-arrow modal-view-button-arrow--next"
       @click.stop.prevent="goNext"
     >
       <FAIcon
-        class="arrow-icon"
+        class="button-icon arrow-icon"
         icon="chevron-right"
       />
     </button>
+    <button
+      class="modal-view-button modal-view-button-hide"
+      :title="$t('media_modal.hide')"
+      @click.stop.prevent="hide"
+    >
+      <FAIcon
+        class="button-icon"
+        icon="times"
+      />
+    </button>
+
     <span
       v-if="description"
       class="description"
@@ -86,11 +115,17 @@
 <script src="./media_modal.js"></script>
 
 <style lang="scss">
+$modal-view-button-icon-height: 3em;
+$modal-view-button-icon-half-height: calc(#{$modal-view-button-icon-height} / 2);
+$modal-view-button-icon-width: 3em;
+$modal-view-button-icon-margin: 0.5em;
+
 .modal-view.media-modal-view {
   z-index: 1001;
   flex-direction: column;
 
-  .modal-view-button-arrow {
+  .modal-view-button-arrow,
+  .modal-view-button-hide {
     opacity: 0.75;
 
     &:focus,
@@ -103,6 +138,7 @@
       opacity: 1;
     }
   }
+  overflow: hidden;
 }
 
 .media-modal-view {
@@ -115,6 +151,29 @@
     }
   }
 
+  .modal-image-container {
+    display: flex;
+    overflow: hidden;
+    align-items: center;
+    flex-direction: column;
+    max-width: 100%;
+    max-height: 100%;
+    width: 100%;
+    height: 100%;
+    flex-grow: 1;
+    justify-content: center;
+
+    &-inner {
+      width: 100%;
+      height: 100%;
+      flex-grow: 1;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+
   .description,
   .counter {
     /* Hardcoded since background is also hardcoded */
@@ -134,9 +193,8 @@
   }
 
   .modal-image {
-    max-width: 90%;
-    max-height: 90%;
-    box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
+    max-width: 100%;
+    max-height: 100%;
     image-orientation: from-image; // NOTE: only FF supports this
     animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
 
@@ -159,13 +217,7 @@
     }
   }
 
-  .modal-view-button-arrow {
-    position: absolute;
-    display: block;
-    top: 50%;
-    margin-top: -50px;
-    width: 70px;
-    height: 100px;
+  .modal-view-button {
     border: 0;
     padding: 0;
     opacity: 0;
@@ -175,14 +227,33 @@
     overflow: visible;
     cursor: pointer;
     transition: opacity 333ms cubic-bezier(.4,0,.22,1);
+    height: $modal-view-button-icon-height;
+    width: $modal-view-button-icon-width;
 
-    .arrow-icon {
+    .button-icon {
       position: absolute;
-      top: 35px;
-      height: 30px;
-      width: 32px;
+      height: $modal-view-button-icon-height;
+      width: $modal-view-button-icon-width;
       font-size: 14px;
-      line-height: 30px;
+      line-height: $modal-view-button-icon-height;
+      color: #FFF;
+      text-align: center;
+      background-color: rgba(0,0,0,.3);
+    }
+  }
+
+  .modal-view-button-arrow {
+    position: absolute;
+    display: block;
+    top: 50%;
+    margin-top: $modal-view-button-icon-half-height;
+    width: $modal-view-button-icon-width;
+    height: $modal-view-button-icon-height;
+
+    .arrow-icon {
+      position: absolute;
+      top: 0;
+      line-height: $modal-view-button-icon-height;
       color: #FFF;
       text-align: center;
       background-color: rgba(0,0,0,.3);
@@ -191,16 +262,26 @@
     &--prev {
       left: 0;
       .arrow-icon {
-        left: 6px;
+        left: $modal-view-button-icon-margin;
       }
     }
 
     &--next {
       right: 0;
       .arrow-icon {
-        right: 6px;
+        right: $modal-view-button-icon-margin;
       }
     }
   }
+
+  .modal-view-button-hide {
+    position: absolute;
+    top: 0;
+    right: 0;
+    .button-icon {
+      top: $modal-view-button-icon-margin;
+      right: $modal-view-button-icon-margin;
+    }
+  }
 }
 </style>

+ 13 - 0
src/components/pinch_zoom/pinch_zoom.js

@@ -0,0 +1,13 @@
+import PinchZoom from '@kazvmoe-infra/pinch-zoom-element'
+
+export default {
+  methods: {
+    setTransform ({ scale, x, y }) {
+      this.$el.setTransform({ scale, x, y })
+    }
+  },
+  created () {
+    // Make lint happy
+    (() => PinchZoom)()
+  }
+}

+ 11 - 0
src/components/pinch_zoom/pinch_zoom.vue

@@ -0,0 +1,11 @@
+<template>
+  <pinch-zoom
+    class="pinch-zoom-parent"
+    v-bind="$attrs"
+    v-on="$listeners"
+  >
+    <slot />
+  </pinch-zoom>
+</template>
+
+<script src="./pinch_zoom.js"></script>

+ 84 - 0
src/components/swipe_click/swipe_click.js

@@ -0,0 +1,84 @@
+import GestureService from '../../services/gesture_service/gesture_service'
+
+/**
+ * props:
+ *   direction: a vector that indicates the direction of the intended swipe
+ *   threshold: the minimum distance in pixels the swipe has moved on `direction'
+ *              for swipe-finished() to have a non-zero sign
+ *   perpendicularTolerance: see gesture_service
+ *
+ * Events:
+ *   preview-requested(offsets)
+ *     Emitted when the pointer has moved.
+ *     offsets: the offsets from the start of the swipe to the current cursor position
+ *
+ *   swipe-canceled()
+ *     Emitted when the swipe has been canceled due to a pointercancel event.
+ *
+ *   swipe-finished(sign: 0|-1|1)
+ *     Emitted when the swipe has finished.
+ *     sign: if the swipe does not meet the threshold, 0
+ *           if the swipe meets the threshold in the positive direction, 1
+ *           if the swipe meets the threshold in the negative direction, -1
+ *
+ *   swipeless-clicked()
+ *     Emitted when there is a click without swipe.
+ *     This and swipe-finished() cannot be emitted for the same pointerup event.
+ */
+const SwipeClick = {
+  props: {
+    direction: {
+      type: Array
+    },
+    threshold: {
+      type: Function,
+      default: () => 30
+    },
+    perpendicularTolerance: {
+      type: Number,
+      default: 1.0
+    }
+  },
+  methods: {
+    handlePointerDown (event) {
+      this.$gesture.start(event)
+    },
+    handlePointerMove (event) {
+      this.$gesture.move(event)
+    },
+    handlePointerUp (event) {
+      this.$gesture.end(event)
+    },
+    handlePointerCancel (event) {
+      this.$gesture.cancel(event)
+    },
+    handleNativeClick (event) {
+      this.$gesture.click(event)
+    },
+    preview (offsets) {
+      this.$emit('preview-requested', offsets)
+    },
+    end (sign) {
+      this.$emit('swipe-finished', sign)
+    },
+    click () {
+      this.$emit('swipeless-clicked')
+    },
+    cancel () {
+      this.$emit('swipe-canceled')
+    }
+  },
+  created () {
+    this.$gesture = new GestureService.SwipeAndClickGesture({
+      direction: this.direction,
+      threshold: this.threshold,
+      perpendicularTolerance: this.perpendicularTolerance,
+      swipePreviewCallback: this.preview,
+      swipeEndCallback: this.end,
+      swipeCancelCallback: this.cancel,
+      swipelessClickCallback: this.click
+    })
+  }
+}
+
+export default SwipeClick

+ 14 - 0
src/components/swipe_click/swipe_click.vue

@@ -0,0 +1,14 @@
+<template>
+  <div
+    v-bind="$attrs"
+    @pointerdown="handlePointerDown"
+    @pointermove="handlePointerMove"
+    @pointerup="handlePointerUp"
+    @pointercancel="handlePointerCancel"
+    @click="handleNativeClick"
+  >
+    <slot />
+  </div>
+</template>
+
+<script src="./swipe_click.js"></script>

+ 2 - 1
src/i18n/en.json

@@ -119,7 +119,8 @@
   "media_modal": {
     "previous": "Previous",
     "next": "Next",
-    "counter": "{current} / {total}"
+    "counter": "{current} / {total}",
+    "hide": "Close media viewer"
   },
   "nav": {
     "about": "About",

+ 2 - 0
src/main.js

@@ -45,6 +45,8 @@ Vue.use(VueClickOutside)
 Vue.use(PortalVue)
 Vue.use(VBodyScrollLock)
 
+Vue.config.ignoredElements = ['pinch-zoom']
+
 Vue.component('FAIcon', FontAwesomeIcon)
 Vue.component('FALayers', FontAwesomeLayers)
 

+ 135 - 2
src/services/gesture_service/gesture_service.js

@@ -4,9 +4,15 @@ const DIRECTION_RIGHT = [1, 0]
 const DIRECTION_UP = [0, -1]
 const DIRECTION_DOWN = [0, 1]
 
+const BUTTON_LEFT = 0
+
 const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]]
 
-const touchEventCoord = e => ([e.touches[0].screenX, e.touches[0].screenY])
+const touchCoord = touch => [touch.screenX, touch.screenY]
+
+const touchEventCoord = e => touchCoord(e.touches[0])
+
+const pointerEventCoord = e => [e.clientX, e.clientY]
 
 const vectorLength = v => Math.sqrt(v[0] * v[0] + v[1] * v[1])
 
@@ -61,6 +67,132 @@ const updateSwipe = (event, gesture) => {
   gesture._swiping = false
 }
 
+class SwipeAndClickGesture {
+  // swipePreviewCallback(offsets: Array[Number])
+  //   offsets: the offset vector which the underlying component should move, from the starting position
+  // swipeEndCallback(sign: 0|-1|1)
+  //   sign: if the swipe does not meet the threshold, 0
+  //         if the swipe meets the threshold in the positive direction, 1
+  //         if the swipe meets the threshold in the negative direction, -1
+  constructor ({
+    direction,
+    // swipeStartCallback
+    swipePreviewCallback,
+    swipeEndCallback,
+    swipeCancelCallback,
+    swipelessClickCallback,
+    threshold = 30,
+    perpendicularTolerance = 1.0,
+    disableClickThreshold = 1
+  }) {
+    const nop = () => {}
+    this.direction = direction
+    this.swipePreviewCallback = swipePreviewCallback || nop
+    this.swipeEndCallback = swipeEndCallback || nop
+    this.swipeCancelCallback = swipeCancelCallback || nop
+    this.swipelessClickCallback = swipelessClickCallback || nop
+    this.threshold = typeof threshold === 'function' ? threshold : () => threshold
+    this.disableClickThreshold = typeof disableClickThreshold === 'function' ? disableClickThreshold : () => disableClickThreshold
+    this.perpendicularTolerance = perpendicularTolerance
+    this._reset()
+  }
+
+  _reset () {
+    this._startPos = [0, 0]
+    this._pointerId = -1
+    this._swiping = false
+    this._swiped = false
+    this._preventNextClick = false
+  }
+
+  start (event) {
+    // Only handle left click
+    if (event.button !== BUTTON_LEFT) {
+      return
+    }
+
+    this._startPos = pointerEventCoord(event)
+    this._pointerId = event.pointerId
+    this._swiping = true
+    this._swiped = false
+  }
+
+  move (event) {
+    if (this._swiping && this._pointerId === event.pointerId) {
+      this._swiped = true
+
+      const coord = pointerEventCoord(event)
+      const delta = deltaCoord(this._startPos, coord)
+
+      this.swipePreviewCallback(delta)
+    }
+  }
+
+  cancel (event) {
+    if (!this._swiping || this._pointerId !== event.pointerId) {
+      return
+    }
+
+    this.swipeCancelCallback()
+  }
+
+  end (event) {
+    if (!this._swiping) {
+      return
+    }
+
+    if (this._pointerId !== event.pointerId) {
+      return
+    }
+
+    this._swiping = false
+
+    // movement too small
+    const coord = pointerEventCoord(event)
+    const delta = deltaCoord(this._startPos, coord)
+
+    const sign = (() => {
+      if (vectorLength(delta) < this.threshold()) {
+        return 0
+      }
+      // movement is opposite from direction
+      const isPositive = dotProduct(delta, this.direction) > 0
+
+      // movement perpendicular to direction is too much
+      const towardsDir = project(delta, this.direction)
+      const perpendicularDir = perpendicular(this.direction)
+      const towardsPerpendicular = project(delta, perpendicularDir)
+      if (
+        vectorLength(towardsDir) * this.perpendicularTolerance <
+          vectorLength(towardsPerpendicular)
+      ) {
+        return 0
+      }
+
+      return isPositive ? 1 : -1
+    })()
+
+    if (this._swiped) {
+      this.swipeEndCallback(sign)
+    }
+    this._reset()
+    // Only a mouse will fire click event when
+    // the end point is far from the starting point
+    // so for other kinds of pointers do not check
+    // whether we have swiped
+    if (vectorLength(delta) >= this.disableClickThreshold() && event.pointerType === 'mouse') {
+      this._preventNextClick = true
+    }
+  }
+
+  click (event) {
+    if (!this._preventNextClick) {
+      this.swipelessClickCallback()
+    }
+    this._reset()
+  }
+}
+
 const GestureService = {
   DIRECTION_LEFT,
   DIRECTION_RIGHT,
@@ -68,7 +200,8 @@ const GestureService = {
   DIRECTION_DOWN,
   swipeGesture,
   beginSwipe,
-  updateSwipe
+  updateSwipe,
+  SwipeAndClickGesture
 }
 
 export default GestureService

+ 12 - 0
yarn.lock

@@ -916,6 +916,13 @@
   resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.0.tgz#63da3e459147cebb0a8d58eed81d6071db9f5973"
   integrity sha512-N3VKw7KzRfOm8hShUVldpinlm13HpvLBQgT63QS+aCrIRLwjoEUXY5Rcmttbfb6HkzZaeqjLqd/aZCQ53UjQpg==
 
+"@kazvmoe-infra/pinch-zoom-element@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@kazvmoe-infra/pinch-zoom-element/-/pinch-zoom-element-1.2.0.tgz#eb3ca34c53b4410c689d60aca02f4a497ce84aba"
+  integrity sha512-HBrhH5O/Fsp2bB7EGTXzCsBAVcMjknSagKC5pBdGpKsF8meHISR0kjDIdw4YoE0S+0oNMwJ6ZUZyIBrdywxPPw==
+  dependencies:
+    pointer-tracker "^2.0.3"
+
 "@nodelib/fs.scandir@2.1.3":
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b"
@@ -6995,6 +7002,11 @@ pngjs@^5.0.0:
   resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
   integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
 
+pointer-tracker@^2.0.3:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/pointer-tracker/-/pointer-tracker-2.4.0.tgz#78721c2d2201486db11ec1094377f03023b621b3"
+  integrity sha512-pWI2tpaM/XNtc9mUTv42Rmjf6mkHvE8LT5DDEq0G7baPNhxNM9E3CepubPplSoSLk9E5bwQrAMyDcPVmJyTW4g==
+
 portal-vue@2.1.7:
   version "2.1.7"
   resolved "https://registry.yarnpkg.com/portal-vue/-/portal-vue-2.1.7.tgz#ea08069b25b640ca08a5b86f67c612f15f4e4ad4"