首页 > 其他分享 >重生归来,从 996福报 到 N+1告别职场【如何封装一个支持图片和PDF在线预览的Vue组件】

重生归来,从 996福报 到 N+1告别职场【如何封装一个支持图片和PDF在线预览的Vue组件】

时间:2024-07-18 17:28:40浏览次数:21  
标签:文件 996 const index Vue pdf 组件 PDF

如何封装一个支持图片和PDF在线预览的Vue组件

在本文中,我将介绍如何设计并实现一个Vue组件,该组件能够在线预览图片和PDF文件。我们将基于element-uielImageViewer组件进行改造,并使用vue-pdf插件来实现PDF预览功能。本文将详细介绍从设计思路到落地实现的全过程,完整代码在文末。

设计思路

我们需要一个统一的组件来处理用户上传的图片和PDF文件,并能够在一个视图中展示它们。具体需求包括:

  1. 支持图片和PDF文件的在线预览。
  2. 支持图片和PDF文件的轮播切换。
  3. 支持PDF文件的翻页和下载功能。
  4. 支持图片的缩放、旋转等操作。

为了实现上述需求,我们选择使用element-uielImageViewer组件作为基础,扩展其功能以支持PDF文件。我们将使用vue-pdf插件来渲染PDF文件,最终达到图中的效果。

请添加图片描述

实现步骤

1. 安装依赖

首先,我们需要安装element-uivue-pdf插件。

npm install element-ui vue-pdf
2. 创建组件

接下来,我们创建一个新的Vue组件PreviewFileViewer,其模板结构如下:
请添加图片描述
整个组件的代码是基于 element-uielImageViewer 组件进行拓展,elImageViewer 组件位置在我们项目中node_modules/element-ui/packages/image/src/image-viewer,想看下这个组件的原代码可以去这个位置寻找,当然如果你的项目中需要实现图片预览模块可以在 main.js 文件全局引入:

//main.js
// 引入按钮触发图片预览模块
import elImageViewer from "element-ui/packages/image/src/image-viewer";
// 注册图片预览组件
Vue.component("elImageViewer", elImageViewer);

那么我们需要在这个代码中怎样进行拓展让他支持PDF的预览呢,在模板结构中我增加了 carousel 组件的引用,主要是我想偷个懒不用自己去写太多的 CSS 代码,在 el-carousel-item 中使用 v-for 遍历我们接收过来的文件数据,然后通过判断文件的类型来分别展示 imgvuePdf。每一页走马灯通过当前页文件的类型来分别展示 img 和 pdf 的 ACTIONS,img 不用说了还是保留原组件的缩放和旋转,pdf 这里我给的是前后翻页、页码展示、重载、下载,后期现场可能还会提一个跳页的需求。

<!-- CANVAS -->
<div class="el-image-viewer__canvas">
  <!-- IMG-->
  <img v-if="isImage(file.fileName)" class="img"
    :src="file.filePath" :style="imgStyle" @load="handleImgLoad" @error="handleImgError"
    @mousedown="handleMouseDown"
  />
  <!-- PDF -->
  <div v-if="isPDF(file.fileName)" class="pdf-container">
    <vuePdf class="pdf" ref="pdf" :src="i === 0 ? file.filePath : currentPdfSrc" :page="currentPage"
      @num-pages="numPages=$event" @loaded="loadPdfHandler"
      @page-loaded="currentPage=$event"
    >
    </vuePdf>
  </div>
</div>

<!-- ACTIONS -->
<div class="el-image-viewer__btn el-image-viewer__actions">
  <div v-if="isImage(file.fileName)" 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-icon-refresh-left" @click="handleActions('anticlocelise')"></i>
    <i class="el-icon-refresh-right" @click="handleActions('clocelise')"></i>
  </div>
  <div v-if="isPDF(file.fileName)" class="el-image-viewer__actions__inner">
    <i class="el-icon-back" @click="changePdfPage(0)"></i>
    <span>{{ currentPage }} / {{ numPages }}</span>
    <i class="el-icon-right" @click="changePdfPage(1)"></i>
    <i class="el-icon-refresh-right" @click="loadPdfHandler"></i>
    <i class="el-icon-download" @click="downloadPdf(file.filePath)"></i>
  </div>
</div>
3. 脚本逻辑

在脚本部分,我们引入了vue-pdf和一些element-ui的工具函数,并定义了组件的propsdatacomputedwatchmethods
请添加图片描述
我这里 carousel 组件的 arrow 属性设置的 never,因为我觉得 carousel 自带的 arrow 有点小,还是使用 elImageViewer 组件中绘制的按钮,在翻页操作时通过 this.$refs.carousel.prev()this.$refs.carousel.next() 对走马灯进行切换。

细心的朋友可能发现了我在轮播图切换时执行了 this.currentPdfSrc = this.fileList[index].filePath 这个方法,在上面模板结构中我对 vuePdf 组件进行渲染时 src 属性使用的是我在 data 中声明的一个变量 currentPdfSrc 而不是直接 :src="file.filePath"。最开始我确实是这么做的,但是发现一个
问题:轮播图翻页时,PDF 文件没有进行更新,导致显示的是旧数据或者上一次加载的页码。

为了解决这个问题,我需要确保在轮播图切换时,vuePdf 组件能够重新加载对应的 PDF 文件。这通常涉及到重新设置 vuePdf 组件的 srcpage 属性,以反映当前选中的文件路径和页码。

解决方案

  1. 在你的组件数据中,维护一个与当前显示的 PDF 文件相关的状态,比如 currentPdfSrccurrentPage

  2. 当轮播图的索引改变时,更新这些状态变量以反映新文件的信息。

  3. vuePdf 组件的 srcpage 属性绑定到这些状态变量上。

通过这样的修改,每当轮播图切换到新的文件时,vuePdf 组件会接收到新的 srcpage 属性值,从而触发重新加载和显示正确的 PDF 文件及页码。

4. 样式设计

最后,我们为组件添加样式,以确保图片和PDF文件以合适的方式展示:
请添加图片描述

5. 使用组件

现在,我们可以在父组件中使用 PreviewFileViewer 组件。假设我们有一个文件列表,包含图片和PDF文件的信息,我们可以这样使用:

<template>
  <div>
    <button @click="showPreview">查看文件</button>
    <preview-file-viewer
      v-if="isPreviewVisible"
      :urlList="fileList"
      :initialIndex="0"
      @onClose="isPreviewVisible = false"
    />
  </div>
</template>

<script>
import PreviewFileViewer from '@/components/PreviewFileViewer.vue';

export default {
  components: { PreviewFileViewer },
  data() {
    return {
      isPreviewVisible: false,
      fileList: [
        { sImageName: 'image1.jpg', sImageId: '1' },
        { sImageName: 'document.pdf', sImageId: '2' }
      ]
    };
  },
  methods: {
    showPreview() {
      this.isPreviewVisible = true;
    }
  }
};
</script>

组件功能解析

1. 数据和属性
  • urlList:文件列表,包含文件名和文件路径。
  • zIndex:遮罩层的z-index。
  • onSwitchonClose:回调函数,用于处理文件切换和关闭事件。
  • initialIndex:初始索引,默认为0。
  • appendToBody:是否将组件添加到body元素中。
  • maskClosable:是否允许点击遮罩层关闭组件。
2. 计算属性
  • isSingle:判断文件列表是否只有一个文件。
  • isFirst:判断当前文件是否是第一个文件。
  • isLast:判断当前文件是否是最后一个文件。
  • imgStyle:当前图片的样式,根据缩放和旋转等变换设置。
  • viewerZIndex:获取当前组件的z-index。
  • fileList:处理后的文件列表,包含文件名和文件路径。
3. 方法
  • handleChange:处理轮播图切换事件。
  • downloadPdf:下载PDF文件。
  • changePdfPage:PDF文件翻页。
  • loadPdfHandler:PDF文件加载完成事件处理。
  • isImageisPDF:判断文件是否是图片或PDF文件。
  • hide:关闭组件。
  • deviceSupportInstalldeviceSupportUninstall:安装和卸载键盘和鼠标事件监听。
  • handleImgLoadhandleImgError:图片加载完成和加载失败事件处理。
  • handleMouseDown:鼠标按下事件处理,用于拖动图片。
  • handleMaskClick:处理遮罩层点击事件。
  • reset:重置图片的变换参数。
  • toggleMode:切换图片显示模式。
  • prevnext:切换到上一张和下一张文件。
  • handleActions:处理图片缩放和旋转等操作。

总结

通过以上步骤,我们成功地实现了一个支持图片和PDF在线预览的Vue组件。这个组件不仅能够处理图片的缩放、旋转和拖动,还支持PDF文件的翻页和下载功能。

在实际应用中,这个组件可以进一步扩展,例如支持更多文件类型、优化性能和提升用户体验等。希望这篇博客能够为你在开发中提供一些启发和帮助!

如果你在使用这个组件时遇到任何问题,或者有改进建议,欢迎在评论区留言讨论!

示例代码

完整的示例代码如下:

<template>
  <transition name="viewer-fade">
    <div tabindex="-1" ref="el-image-viewer__wrapper" class="el-image-viewer__wrapper" :style="{ 'z-index': viewerZIndex }">
      <!-- CAROUSEL -->>
      <el-carousel v-model="index" arrow="never" height="100vh" :autoplay="false"
        @change="handleChange" ref="carousel" indicator-position="none"
      >
        <el-carousel-item v-for="(file, i) in fileList" :key="i">
          <div class="el-image-viewer__mask" @click.self="handleMaskClick"></div>
          <!-- CLOSE -->
          <span class="el-image-viewer__btn el-image-viewer__close" @click="hide">
            <i class="el-icon-close"></i>
          </span>
          <!-- CANVAS -->
          <div class="el-image-viewer__canvas">
            <!-- IMG-->
            <img v-if="isImage(file.fileName)" class="img"
              :src="file.filePath" :style="imgStyle" @load="handleImgLoad" @error="handleImgError"
              @mousedown="handleMouseDown"
            />
            <!-- PDF -->
            <div v-if="isPDF(file.fileName)" class="pdf-container">
              <vuePdf class="pdf" ref="pdf" :src="i === 0 ? file.filePath : currentPdfSrc" :page="currentPage"
                @num-pages="numPages=$event" @loaded="loadPdfHandler"
                @page-loaded="currentPage=$event"
              >
              </vuePdf>
            </div>
          </div>
          <!-- 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 v-if="isImage(file.fileName)" 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-icon-refresh-left" @click="handleActions('anticlocelise')"></i>
              <i class="el-icon-refresh-right" @click="handleActions('clocelise')"></i>
            </div>
            <div v-if="isPDF(file.fileName)" class="el-image-viewer__actions__inner">
              <i class="el-icon-back" @click="changePdfPage(0)"></i>
              <span>{{ currentPage }} / {{ numPages }}</span>
              <i class="el-icon-right" @click="changePdfPage(1)"></i>
              <i class="el-icon-refresh-right" @click="loadPdfHandler"></i>
              <i class="el-icon-download" @click="downloadPdf(file.filePath)"></i>
            </div>
          </div>
        </el-carousel-item>
      </el-carousel>
    </div>
  </transition>
</template>
<script>
  import { on, off } from 'element-ui/src/utils/dom';
import { rafThrottle, isFirefox } from 'element-ui/src/utils/util';
import { PopupManager } from 'element-ui/src/utils/popup';
import vuePdf from 'vue-pdf'
// 模式
const Mode = {
  CONTAIN: {
    name: 'contain',
    icon: 'el-icon-full-screen'
  },
  ORIGINAL: {
    name: 'original',
    icon: 'el-icon-c-scale-to-original'
  }
};
// 兼容火狐
const mousewheelEventName = isFirefox() ? 'DOMMouseScroll' : 'mousewheel';

export default {
  name: 'previewFileViewer',
  components: { vuePdf },
  props: {
    // 图片/pdf 列表
    urlList: {
      type: Array,
      default: () => []
    },
    // 遮罩层z-index
    zIndex: {
      type: Number,
      default: 2000
    },
    // 切换回调
    onSwitch: {
      type: Function,
      default: () => {}
    },
    // 关闭回调
    onClose: {
      type: Function,
      default: () => {}
    },
    // 初始索引
    // 默认为0
    initialIndex: {
      type: Number,
      default: 0
    },
    // 是否append到body
    appendToBody: {
      type: Boolean,
      default: true
    },
    // 是否允许点击遮罩层关闭
    maskClosable: {
      type: Boolean,
      default: true
    }
  },

  data() {
    return {
      index: this.initialIndex,
      isShow: false,
      infinite: true,
      loading: false,
      mode: Mode.ORIGINAL,
      transform: {
        scale: 1,
        deg: 0,
        offsetX: 0,
        offsetY: 0,
        enableTransition: false
      },
      // pdf
      currentPdfSrc: '',
      currentPage: 1,
      numPages: 0
    };
  },
  // 计算属性
  computed: {
    // 判断是否是单张图片或单个pdf
    isSingle() {
      return this.urlList.length <= 1;
    },
    // 判断是否是第一张图片或第一页pdf
    isFirst() {
      return this.index === 0;
    },
    // 判断是否是最后一张图片或最后一页pdf
    isLast() {
      return this.index === this.urlList.length - 1;
    },
    // 当前图片样式
    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;
    },
    // 获取当前图片的z-index
    viewerZIndex() {
      const nextZIndex = PopupManager.nextZIndex();
      return this.zIndex > nextZIndex ? this.zIndex : nextZIndex;
    },
    // 获取当前页面数据
    fileList() {
      // 这里可根据自己本地数据结构进行处理;
      // 或者可以在父组件中处理好数据直接传入,那这里就不需要了直接使用父组件传过来的urlList
      return this.urlList.map(el => {
        return {
          fileName: el.sImageName,
          filePath: this.$baseUrl + "/basic/image/view/" + el.sImageId
        }
      });
    },
  },
  // 监听
  watch: {
    index: {
      handler: function(val) {
        this.reset();
        this.onSwitch(val);
      }
    },
  },
  // 挂载钩子
  mounted() {
    console.log('fileList', this.fileList)
    this.deviceSupportInstall();
    if (this.appendToBody) {
      document.body.appendChild(this.$el);
    }
    // add tabindex then wrapper can be focusable via Javascript
    // focus wrapper so arrow key can't cause inner scroll behavior underneath
    this.$refs['el-image-viewer__wrapper'].focus();
  },
  // 销毁钩子
  destroyed() {
    // if appendToBody is true, remove DOM node after destroy
    if (this.appendToBody && this.$el && this.$el.parentNode) {
      this.$el.parentNode.removeChild(this.$el);
    }
  },
  methods: {
    // 轮播图切换触发
    handleChange(index) {
      this.currentPdfSrc = this.fileList[index].filePath;
      this.currentPage = 1;
    },
    // 下载pdf
    downloadPdf(url) {
      window.open(url)
    },
    // pdf翻页
    changePdfPage(val) {
      if (val === 0 && this.currentPage > 1) {
        this.currentPage--
      }
      if (val === 1 && this.currentPage < this.numPages) {
        this.currentPage++
      }
    },
    // pdf加载完成触发
    loadPdfHandler(e) {
      this.currentPage = 1;
    },
    // 是否为图片
    isImage(file) {
      return /\.(jpg|jpeg|png|gif)$/.test(file);
    },
    // 是否为pdf
    isPDF(file) {
      return /\.pdf$/.test(file);
    },
    // 关闭
    hide() {
      this.deviceSupportUninstall();
      this.onClose();
    },
    // 键盘监听事件启动
    deviceSupportInstall() {
      this._keyDownHandler = e => {
        e.stopPropagation();
        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();
    },
    // 鼠标滚轮触发
    handleMaskClick() {
      if (this.maskClosable) {
        this.hide();
      }
    },
     // 重置图片基础配置
    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.fileList.length;
      this.index = (this.index - 1 + len) % len;
      this.$refs.carousel.prev();
    },
    // 下一张
    next() {
      if (this.isLast && !this.infinite) return;
      const len = this.fileList.length;
      this.index = (this.index + 1) % len;
      this.$refs.carousel.next();
    },
    // 图片操作区
    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;
    }
  },
};
</script>

感谢阅读!希望这篇文章对你有所帮助。

标签:文件,996,const,index,Vue,pdf,组件,PDF
From: https://blog.csdn.net/zw7518/article/details/140490260

相关文章