瀏覽代碼

Image: Add big Image preview feature (#16333) (#16605)

luckyCao 6 年之前
父節點
當前提交
1b1c1beadc

+ 30 - 0
examples/docs/en-US/image.md

@@ -108,6 +108,34 @@ Besides the native features of img, support lazy load, custom placeholder and lo
 ```
 :::
 
+### Image Preview
+
+:::demo allow big image preview by setting `previewSrcList` prop.
+```html
+<div class="demo-image__preview">
+  <el-image 
+    style="width: 100px; height: 100px"
+    :src="url" 
+    :preview-src-list="srcList">
+  </el-image>
+</div>
+
+<script>
+  export default {
+    data() {
+      return {
+        url: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+        srcList: [
+          'https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg',
+          'https://fuss10.elemecdn.com/1/8e/aeffeb4de74e2fde4bd74fc7b4486jpeg.jpeg'
+        ]
+      }
+    }
+  }
+</script>
+```
+:::
+
 ### Attributes
 | Attribute | Description | Type  | Accepted values | Default   |
 |---------- |-------- |---------- |-------------  |-------- |
@@ -117,6 +145,8 @@ Besides the native features of img, support lazy load, custom placeholder and lo
 | referrer-policy | Native referrerPolicy | string | - | - |
 | lazy | Whether to use lazy load | boolean | — | false |
 | scroll-container | The container to add scroll listener when using lazy load | string / HTMLElement | — | The nearest parent container whose overflow property is auto or scroll |
+| preview-src-list | allow big image preview | Array | — | - |
+| z-index | set image preview z-index | Number | — | 2000 |
 
 ### Events
 | Event Name | Description | Parameters |

+ 30 - 0
examples/docs/es/image.md

@@ -110,6 +110,34 @@ Además de las características nativas de img, soporte de carga perezosa, marca
 ```
 :::
 
+### Image Preview
+
+:::demo allow big image preview by setting `previewSrcList` prop.
+```html
+<div class="demo-image__preview">
+  <el-image 
+    style="width: 100px; height: 100px"
+    :src="url" 
+    :preview-src-list="srcList">
+  </el-image>
+</div>
+
+<script>
+  export default {
+    data() {
+      return {
+        url: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+        srcList: [
+          'https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg',
+          'https://fuss10.elemecdn.com/1/8e/aeffeb4de74e2fde4bd74fc7b4486jpeg.jpeg'
+        ]
+      }
+    }
+  }
+</script>
+```
+:::
+
 ### Atributos
 | Atributo | Descripción | Tipo | Valores aceptados | Por defecto |
 |---------- |-------- |---------- |-------------  |-------- |
@@ -119,6 +147,8 @@ Además de las características nativas de img, soporte de carga perezosa, marca
 | referrer-policy | referrerPolicy nativo | string | - | - |
 | lazy | si se usara lazy load | boolean | — | false |
 | scroll-container | El contenedor para añadir el scroll listener cuando se utiliza lazy load | string / HTMLElement | — | El contenedor padre más cercano cuya propiedad de desbordamiento es auto o scroll |
+| preview-src-list | allow big image preview | Array | — | - |
+| z-index | set image preview z-index | Number | — | 2000 |
 
 ### Eventos
 | Nombre del evento | Descripción | Parámetros |

+ 30 - 0
examples/docs/fr-FR/image.md

@@ -109,6 +109,34 @@ En plus des propriétés natives de img, ce composant supporte le lazy loading,
 ```
 :::
 
+### Image Preview
+
+:::demo allow big image preview by setting `previewSrcList` prop.
+```html
+<div class="demo-image__preview">
+  <el-image 
+    style="width: 100px; height: 100px"
+    :src="url" 
+    :preview-src-list="srcList">
+  </el-image>
+</div>
+
+<script>
+  export default {
+    data() {
+      return {
+        url: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+        srcList: [
+          'https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg',
+          'https://fuss10.elemecdn.com/1/8e/aeffeb4de74e2fde4bd74fc7b4486jpeg.jpeg'
+        ]
+      }
+    }
+  }
+</script>
+```
+:::
+
 ### Attributs
 | Attribut | Description | Type  | Valeurs acceptées | Défaut   |
 |---------- |-------- |---------- |-------------  |-------- |
@@ -118,6 +146,8 @@ En plus des propriétés natives de img, ce composant supporte le lazy loading,
 | referrer-policy | Attribut referrerPolicy natif.| string | - | - |
 | lazy | Si le lazy loading doit être utilisé. | boolean | — | false |
 | scroll-container | Le conteneur auquel ajouter le listener du scroll en mode lazy loading. | string / HTMLElement | — | Le conteneur parent le plus proche avec la propriété overflow à auto ou scroll. |
+| preview-src-list | allow big image preview | Array | — | - |
+| z-index | set image preview z-index | Number | — | 2000 |
 
 ### Évènements
 | Nom | Description | Paramètres |

+ 30 - 0
examples/docs/zh-CN/image.md

@@ -108,6 +108,34 @@
 ```
 :::
 
+### 大图预览
+
+:::demo 可通过 `previewSrcList` 开启预览大图的功能。
+```html
+<div class="demo-image__preview">
+  <el-image 
+    style="width: 100px; height: 100px"
+    :src="url" 
+    :preview-src-list="srcList">
+  </el-image>
+</div>
+
+<script>
+  export default {
+    data() {
+      return {
+        url: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
+        srcList: [
+          'https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg',
+          'https://fuss10.elemecdn.com/1/8e/aeffeb4de74e2fde4bd74fc7b4486jpeg.jpeg'
+        ]
+      }
+    }
+  }
+</script>
+```
+:::
+
 ### Attributes
 | 参数      | 说明    | 类型      | 可选值       | 默认值   |
 |---------- |-------- |---------- |-------------  |-------- |
@@ -117,6 +145,8 @@
 | referrer-policy | 原生 referrerPolicy | string | - | - |
 | lazy | 是否开启懒加载 | boolean | — | false |
 | scroll-container | 开启懒加载后,监听 scroll 事件的容器 | string / HTMLElement | — | 最近一个 overflow 值为 auto 或 scroll 的父元素 |
+| preview-src-list | 开启图片预览功能 | Array | — | - |
+| z-index | 设置图片预览的 z-index | Number | — | 2000 |
 
 ### Events
 | 事件名称      | 说明    | 回调参数      |

+ 296 - 0
packages/image/src/image-viewer.vue

@@ -0,0 +1,296 @@
+<template>
+  <transition name="viewer-fade">
+    <div class="el-image-viewer__wrapper" :style="{ 'z-index': zIndex }">
+      <div class="el-image-viewer__mask"></div>
+      <!-- CLOSE -->
+      <span class="el-image-viewer__btn el-image-viewer__close" @click="hide">
+        <i class="el-icon-circle-close"></i>
+      </span>
+      <!-- ARROW -->
+      <template v-if="!isSingle">
+        <span
+          class="el-image-viewer__btn el-image-viewer__prev"
+          :class="{ 'is-disabled': !infinite && isFirst }"
+          @click="prev">
+          <i class="el-icon-arrow-left"/>
+        </span>
+        <span
+          class="el-image-viewer__btn el-image-viewer__next"
+          :class="{ 'is-disabled': !infinite && isLast }"
+          @click="next">
+          <i class="el-icon-arrow-right"/>
+        </span>
+      </template>
+      <!-- ACTIONS -->
+      <div class="el-image-viewer__btn el-image-viewer__actions">
+        <div class="el-image-viewer__actions__inner">
+          <i class="el-icon-zoom-out" @click="handleActions('zoomOut')"></i>
+          <i class="el-icon-zoom-in" @click="handleActions('zoomIn')"></i>
+          <i class="el-image-viewer__actions__divider"></i>
+          <i :class="mode.icon" @click="toggleMode"></i>
+          <i class="el-image-viewer__actions__divider"></i>
+          <i class="el-icon-refresh-left" @click="handleActions('anticlocelise')"></i>
+          <i class="el-icon-refresh-right" @click="handleActions('clocelise')"></i>
+        </div>
+      </div>
+      <!-- CANVAS -->
+      <div class="el-image-viewer__canvas">
+        <img
+          v-for="(url, i) in urlList"
+          v-if="i === index"
+          ref="img"
+          class="el-image-viewer__img"
+          :key="url"
+          :src="currentImg"
+          :style="imgStyle"
+          @load="handleImgLoad"
+          @error="handleImgError"
+          @mousedown="handleMouseDown">
+      </div>
+    </div>
+  </transition>
+</template>
+
+<script>
+import { on, off } from 'element-ui/src/utils/dom';
+import { rafThrottle } from 'element-ui/src/utils/util';
+
+const Mode = {
+  CONTAIN: {
+    name: 'contain',
+    icon: 'el-icon-full-screen'
+  },
+  ORIGINAL: {
+    name: 'original',
+    icon: 'el-icon-c-scale-to-original'
+  }
+};
+
+const isFirefox = !!window.navigator.userAgent.match(/firefox/i);
+const mousewheelEventName = isFirefox ? 'DOMMouseScroll' : 'mousewheel';
+
+export default {
+  name: 'elImageViewer',
+
+  props: {
+    urlList: {
+      type: Array,
+      default: () => []
+    },
+    zIndex: {
+      type: Number,
+      default: 2000
+    },
+    onSwitch: {
+      type: Function,
+      default: () => {}
+    },
+    onClose: {
+      type: Function,
+      default: () => {}
+    }
+  },
+
+  data() {
+    return {
+      index: 0,
+      isShow: false,
+      infinite: true,
+      loading: false,
+      mode: Mode.CONTAIN,
+      transform: {
+        scale: 1,
+        deg: 0,
+        offsetX: 0,
+        offsetY: 0,
+        enableTransition: false
+      }
+    };
+  },
+  computed: {
+    isSingle() {
+      return this.urlList.length <= 1;
+    },
+    isFirst() {
+      return this.index === 0;
+    },
+    isLast() {
+      return this.index === this.urlList.length - 1;
+    },
+    currentImg() {
+      return this.urlList[this.index];
+    },
+    imgStyle() {
+      const { scale, deg, offsetX, offsetY, enableTransition } = this.transform;
+      const style = {
+        transform: `scale(${scale}) rotate(${deg}deg)`,
+        transition: enableTransition ? 'transform .3s' : '',
+        'margin-left': `${offsetX}px`,
+        'margin-top': `${offsetY}px`
+      };
+      if (this.mode === Mode.CONTAIN) {
+        style.maxWidth = style.maxHeight = '100%';
+      }
+      return style;
+    }
+  },
+  watch: {
+    index: {
+      handler: function(val) {
+        this.reset();
+        this.onSwitch(val);
+      }
+    },
+    currentImg(val) {
+      this.$nextTick(_ => {
+        const $img = this.$refs.img[0];
+        if (!$img.complete) {
+          this.loading = true;
+        }
+      });
+    }
+  },
+  methods: {
+    hide() {
+      this.deviceSupportUninstall();
+      this.onClose();
+    },
+    deviceSupportInstall() {
+      this._keyDownHandler = rafThrottle(e => {
+        const keyCode = e.keyCode;
+        switch (keyCode) {
+          // ESC
+          case 27:
+            this.hide();
+            break;
+          // SPACE
+          case 32:
+            this.toggleMode();
+            break;
+          // LEFT_ARROW
+          case 37:
+            this.prev();
+            break;
+          // UP_ARROW
+          case 38:
+            this.handleActions('zoomIn');
+            break;
+          // RIGHT_ARROW
+          case 39:
+            this.next();
+            break;
+          // DOWN_ARROW
+          case 40:
+            this.handleActions('zoomOut');
+            break;
+        }
+      });
+      this._mouseWheelHandler = rafThrottle(e => {
+        const delta = e.wheelDelta ? e.wheelDelta : -e.detail;
+        if (delta > 0) {
+          this.handleActions('zoomIn', {
+            zoomRate: 0.015,
+            enableTransition: false
+          });
+        } else {
+          this.handleActions('zoomOut', {
+            zoomRate: 0.015,
+            enableTransition: false
+          });
+        }
+      });
+      on(document, 'keydown', this._keyDownHandler);
+      on(document, mousewheelEventName, this._mouseWheelHandler);
+    },
+    deviceSupportUninstall() {
+      off(document, 'keydown', this._keyDownHandler);
+      off(document, mousewheelEventName, this._mouseWheelHandler);
+      this._keyDownHandler = null;
+      this._mouseWheelHandler = null;
+    },
+    handleImgLoad(e) {
+      this.loading = false;
+    },
+    handleImgError(e) {
+      this.loading = false;
+      e.target.alt = '加载失败';
+    },
+    handleMouseDown(e) {
+      if (this.loading || e.button !== 0) return;
+
+      const { offsetX, offsetY } = this.transform;
+      const startX = e.pageX;
+      const startY = e.pageY;
+      this._dragHandler = rafThrottle(ev => {
+        this.transform.offsetX = offsetX + ev.pageX - startX;
+        this.transform.offsetY = offsetY + ev.pageY - startY;
+      });
+      on(document, 'mousemove', this._dragHandler);
+      on(document, 'mouseup', ev => {
+        off(document, 'mousemove', this._dragHandler);
+      });
+
+      e.preventDefault();
+    },
+    reset() {
+      this.transform = {
+        scale: 1,
+        deg: 0,
+        offsetX: 0,
+        offsetY: 0,
+        enableTransition: false
+      };
+    },
+    toggleMode() {
+      if (this.loading) return;
+
+      const modeNames = Object.keys(Mode);
+      const modeValues = Object.values(Mode);
+      const index = modeValues.indexOf(this.mode);
+      const nextIndex = (index + 1) % modeNames.length;
+      this.mode = Mode[modeNames[nextIndex]];
+      this.reset();
+    },
+    prev() {
+      if (this.isFirst && !this.infinite) return;
+      const len = this.urlList.length;
+      this.index = (this.index - 1 + len) % len;
+    },
+    next() {
+      if (this.isLast && !this.infinite) return;
+      const len = this.urlList.length;
+      this.index = (this.index + 1) % len;
+    },
+    handleActions(action, options = {}) {
+      if (this.loading) return;
+      const { zoomRate, rotateDeg, enableTransition } = {
+        zoomRate: 0.2,
+        rotateDeg: 90,
+        enableTransition: true,
+        ...options
+      };
+      const { transform } = this;
+      switch (action) {
+        case 'zoomOut':
+          if (transform.scale > 0.2) {
+            transform.scale = parseFloat((transform.scale - zoomRate).toFixed(3));
+          }
+          break;
+        case 'zoomIn':
+          transform.scale = parseFloat((transform.scale + zoomRate).toFixed(3));
+          break;
+        case 'clocelise':
+          transform.deg += rotateDeg;
+          break;
+        case 'anticlocelise':
+          transform.deg -= rotateDeg;
+          break;
+      }
+      transform.enableTransition = enableTransition;
+    }
+  },
+  mounted() {
+    this.deviceSupportInstall();
+  }
+};
+</script>

+ 29 - 3
packages/image/src/main.vue

@@ -11,13 +11,16 @@
       class="el-image__inner"
       v-bind="$attrs"
       v-on="$listeners"
+      @click="clickHandler"
       :src="src"
       :style="imageStyle"
-      :class="{ 'el-image__inner--center': alignCenter }">
+      :class="{ 'el-image__inner--center': alignCenter, 'el-image__preview': preview }">
+    <image-viewer :z-index="zIndex" v-if="preview && showViewer" :on-close="closeViewer" :url-list="previewSrcList"/>
   </div>
 </template>
 
 <script>
+  import ImageViewer from './image-viewer';
   import Locale from 'element-ui/src/mixins/locale';
   import { on, off, getScrollContainer, isInContainer } from 'element-ui/src/utils/dom';
   import { isString, isHtmlElement } from 'element-ui/src/utils/types';
@@ -39,11 +42,23 @@
     mixins: [Locale],
     inheritAttrs: false,
 
+    components: {
+      ImageViewer
+    },
+
     props: {
       src: String,
       fit: String,
       lazy: Boolean,
-      scrollContainer: {}
+      scrollContainer: {},
+      previewSrcList: {
+        type: Array,
+        default: () => []
+      },
+      zIndex: {
+        type: Number,
+        default: 2000
+      }
     },
 
     data() {
@@ -52,7 +67,8 @@
         error: false,
         show: !this.lazy,
         imageWidth: 0,
-        imageHeight: 0
+        imageHeight: 0,
+        showViewer: false
       };
     },
 
@@ -68,6 +84,10 @@
       },
       alignCenter() {
         return !this.$isServer && !isSupportObjectFit() && this.fit !== ObjectFit.FILL;
+      },
+      preview() {
+        const { previewSrcList } = this;
+        return Array.isArray(previewSrcList) && previewSrcList.length > 0;
       }
     },
 
@@ -188,6 +208,12 @@
           default:
             return {};
         }
+      },
+      clickHandler() {
+        this.showViewer = true;
+      },
+      closeViewer() {
+        this.showViewer = false;
       }
     }
   };

+ 135 - 0
packages/theme-chalk/src/image.scss

@@ -39,4 +39,139 @@
     color: $--color-text-placeholder;
     vertical-align: middle;
   }
+
+  @include e(preview) {
+    cursor: pointer;
+  }
+}
+
+
+@include b(image-viewer) {
+
+
+  @include e(wrapper) {
+    position: fixed;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+  }
+
+  @include e(btn) {
+    position: absolute;
+    z-index: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 50%;
+    opacity: .8;
+    cursor: pointer;
+    box-sizing: border-box;
+    user-select: none;
+  }
+
+  @include e(close) {
+    top: 40px;
+    right: 40px;
+    width: 40px;
+    height: 40px;
+    font-size: 40px;
+  }
+
+  @include e(canvas) {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  @include e(actions) {
+    left: 50%;
+    bottom: 30px;
+    transform: translateX(-50%);
+    width: 282px;
+    height: 44px;
+    padding: 0 23px;
+    background-color: #606266;
+    border-color: #fff;
+    border-radius: 22px;
+
+    @include e(actions__inner) {
+      width: 100%;
+      height: 100%;
+      text-align: justify;
+      cursor: default;
+      font-size: 23px;
+      color: #fff;
+      display: flex;
+      align-items: center;
+      justify-content: space-around;
+    }
+  }
+
+  @include e(prev){
+    top: 50%;
+    transform: translateY(-50%);
+    width: 44px;
+    height: 44px;
+    font-size: 24px;
+    color: #fff;
+    background-color: #606266;
+    border-color: #fff;
+    left: 40px;
+  }
+
+  @include e(next){
+    top: 50%;
+    transform: translateY(-50%);
+    width: 44px;
+    height: 44px;
+    font-size: 24px;
+    color: #fff;
+    background-color: #606266;
+    border-color: #fff;
+    right: 40px;
+    text-indent: 2px;
+  }
+
+  @include e(mask) {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    opacity: .5;
+    background: #000;
+  }
+}
+
+.viewer-fade-enter-active {
+  animation: viewer-fade-in .3s;
+}
+
+.viewer-fade-leave-active {
+  animation: viewer-fade-out .3s;
+}
+
+@keyframes viewer-fade-in {
+  0% {
+    transform: translate3d(0, -20px, 0);
+    opacity: 0;
+  }
+  100% {
+    transform: translate3d(0, 0, 0);
+    opacity: 1;
+  }
+}
+
+@keyframes viewer-fade-out {
+  0% {
+    transform: translate3d(0, 0, 0);
+    opacity: 1;
+  }
+  100% {
+    transform: translate3d(0, -20px, 0);
+    opacity: 0;
+  }
 }

+ 19 - 0
src/utils/util.js

@@ -216,3 +216,22 @@ export const isEmpty = function(val) {
 
   return false;
 };
+
+export function rafThrottle(fn) {
+  let locked = false;
+  return function(...args) {
+    if (locked) return;
+    locked = true;
+    window.requestAnimationFrame(_ => {
+      fn.apply(this, args);
+      locked = false;
+    });
+  };
+}
+
+export function objToArray(obj) {
+  if (Array.isArray(obj)) {
+    return obj;
+  }
+  return isEmpty(obj) ? [] : [obj];
+}

+ 21 - 0
test/unit/specs/image.spec.js

@@ -108,5 +108,26 @@ describe('Image', () => {
     await wait();
     expect(result).to.exist;
   });
+
+  it('big image preview', async() => {
+    vm = createVue({
+      template: `
+        <el-image :src="src" :preview-src-list="srcList"></el-image>
+      `,
+      data() {
+        return {
+          src: src,
+          srcList: [src]
+        };
+      }
+    }, true);
+    await wait();
+    vm.$el.querySelector('.el-image__inner').click();
+    await wait();
+    expect(vm.$el.querySelector('.el-image-viewer__wrapper')).to.exist;
+    vm.$el.querySelector('.el-image-viewer__close').click();
+    await wait(1000);
+    expect(vm.$el.querySelector('.el-image-viewer__wrapper')).to.not.exist;
+  });
 });
 

+ 4 - 0
types/image.d.ts

@@ -34,4 +34,8 @@ export declare class ElImage extends ElementUIComponent {
   referrerPolicy: string
 
   $slots: ImageSlots
+
+  previewSrcList: string[]
+
+  zIndex: number
 }