VUE3+VITE 移动端H5 借助canvas实现预览PDF以及双指缩放

6,490 阅读3分钟

前言

最近来了个需求,要求实现h5端pdf预览,支持滑动翻页,双指缩放,最好是不进行路由跳转,作为组件被加载出来。

调(goo)研(gle)了一番后,发现H5端预览pdf主要实现方式为以下几种:

  • 借助 npm 包实现,大部分都是基于 pdfjs 进行二次封装的。(这一实现思路主要问题是很多包不兼容 vue3 )
  • iframe 借助第三方服务实现,比如 google doc(需翻墙),office view.officeapps.live.com/op/view.asp… 优点就是快捷方便,缺点是样式喝功能不可控
  • 第三种其实和第一种差不多,只不过是直接代码下载放在了 public,主要是为了修改源码,兼容vue3

我实现的方案是用了第一种,主要是借助了 pdfjs 这个 npm 包。综合看下来,安装指定版本 @2.5.207 这一版本问题较少,兼容性也不错。

缩放是通过 style zoom属性进行双向绑定 双指缩放主要是通过 touch 事件进行的处理。

废话不多说。来看下代码方面

渲染pdf

html 方面 主要是写一个 canvas

  <div class="pdf-viewer" ref="Pdfcontent">
    <template v-for="item in totalPageNum" :key="item">
      <canvas
        class="pdf-item"
        :style="{ zoom: zoom / 10 }"
        :id="`pdf-canvas-${item}`"
        @touchstart="touchstarthandle"
        @touchmove="touchmovehandle"
        @touchend="touchendhandle"
        @touchcancel="touchendhandle"
      ></canvas>
    </template>
  </div>
</template>

js 代码

  import { reactive, toRefs, nextTick, watchEffect, ref } from 'vue';
  import * as pdfjs from 'pdfjs-dist';
  import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry';

  pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;

  export default {
    name: 'PdfViewer',
    props: {
      url: { type: String, default: '' }, // pdf文件路径
    },
    setup(props, { emit }) {
      const state = reactive({
        pdfCtx: null,
        currentPageNum: 0, // 当前页
        totalPageNum: 0,
        zoom: 10, // 目前暂时采用css方式缩放页面
        minZoom: 10, // 缩放最小值 一倍
        maxZoom: 50, // 缩放最大值 五倍
      });
      const Pdfcontent = ref(null);

      const resolvePdf = (url) => {
        const loadingTask = pdfjs.getDocument(url);
        
        loadingTask.promise.then((pdf) => {
          state.pdfCtx = pdf;
          state.totalPageNum = pdf.numPages;
          state.currentPageNum = 1;

          // 动态计算scale
          
          pdf.getPage(1).then((res) => {
            let boxWidth = Pdfcontent.value.clientWidth - 20;
            const [x1, , x2] = res._pageInfo.view;
            const pageWidth = x2 - x1;
            state.scale = (boxWidth * (state.maxZoom / 10)) / pageWidth;
          });
          
          nextTick(() => {
            renderPdf();
          });
        });
      };

      const renderPdf = (num = 1) => {
        state.pdfCtx.getPage(num).then((page) => {
          const canvas = document.getElementById(`pdf-canvas-${num}`);
          const ctx = canvas.getContext('2d');
          const viewport = page.getViewport({ scale: state.scale });
          
          // 画布大小
          
          canvas.width = viewport.width;
          canvas.height = viewport.height;
          
          // 画布的dom大小,这里是根据引用此组件的父级节点设置的
          
          const clientWidth = Pdfcontent.value.clientWidth;
          canvas.style.width = clientWidth + 'px';
          
          // 根据pdf每页的宽高比例设置canvas的高度
          
          canvas.style.height = clientWidth * (viewport.height / viewport.width) + 'px';
          page.render({
            canvasContext: ctx,
            viewport,
          });
          if (num < state.totalPageNum) {
            renderPdf(num + 1);
          } else {
            emit('onRendered');
            
            // 渲染完毕了,如果有loading 啥的可以在这里直接结束
          }
        });
      };
      
      watchEffect(() => {
        if (props.url) {
            //这里可以开启loading。如果文件过大,会有一阵子空白,

          resolvePdf(props.url);
        }
      });
      return {
        ...toRefs(state),

        Pdfcontent,
      
      };
    },
  };
</script>

这里就可以直接渲染出pdf了。

同时也解决了移动端模糊的问题,但是不确定带有公章的pdf是否能正常渲染。 此处代码主要是参(复)考(制)这个文章

接下来就是双指缩放

双指缩放

双指缩放是基于touch事件来进行处理的,核心逻辑就在于判断几个问题

  • 是不是双指触摸
  • 是缩小还是放大

这块代码主要是通过参(复)考(制)张鑫旭的博客,鑫哥YYDS,具体链接在这,讲的非常清晰,可以打开来看看。

然后我们的代码如下

    const touchstarthandle = (e) => {
        console.log('触摸开始');
        const touches = e.touches;
        const events = touches[0];
        const events2 = touches[1];
        
        // e.preventDefault();
        // 如果阻止默认事件,单指滑动就会失效
        
        state.pageX = events.pageX;
        state.pageY = events.pageY;

        state.moveable = true;
        
        if (events2) {
          state.pageX2 = events2.pageX;
          state.pageY2 = events2.pageY;
        }
        state.originzoom = state.zoom;
       
      };
      // 用来获取坐标之间的比例,判断是缩小还是放大
      
      const getDistance = (start, stop) => {
        return Math.hypot(stop.x - start.x, stop.y - start.y);
      };
      const touchmovehandle = (e) => {
        if (!state.moveable) {
          return;
        }

        const touches = e.touches;
        const events = touches[0];
        const events2 = touches[1];
        
        // 判断是否双指移动 是的话阻止默认事件
        // 否则不阻止 不然容器中的canvas无法滚动翻页
        // 如果不阻止冒泡,在 iOS 设备中双指缩放会引起整个 webview 缩放
        
        if (events2) {
          e.preventDefault();

          if (!state.pageX2) {
            state.pageX2 = events2.pageX;
          }
          if (!state.pageY2) {
            state.pageY2 = events2.pageY;
          }
          
          //获取坐标之间的比例

          var zoom =
            getDistance(
              {
                x: events.pageX,
                y: events.pageY,
              },
              {
                x: events2.pageX,
                y: events2.pageY,
              },
            ) /
            getDistance(
              {
                x: state.pageX,
                y: state.pageY,
              },
              {
                x: state.pageX2,
                y: state.pageY2,
              },
            );
          let newScale = state.originzoom * zoom;
          
          //限制缩放倍数
          
          if (newScale > 50) {
            newScale = 50;
          } else if (newScale < 5) {
            newScale = 5;
          }
          
          state.zoom = newScale;
        }
      };
      const touchendhandle = () => {
        state.moveable = false;

        delete state.pageX2;
        delete state.pageY2;
      };

全部代码

OK,到了这里,我们的功能基本上完成了,奉上全部代码,终极缝合怪

<template>
  <div class="pdf-viewer" ref="Pdfcontent">
    <template v-for="item in totalPageNum" :key="item">
      <!-- <canvas
        class="pdf-item"
        :id="`pdf-canvas-${item}`"
        :style="{ zoom: zoom / 10 }"
     
      ></canvas> -->
      <canvas
        class="pdf-item"
        :style="{ zoom: zoom / 10 }"
        :id="`pdf-canvas-${item}`"
        @touchstart="touchstarthandle"
        @touchmove="touchmovehandle"
        @touchend="touchendhandle"
        @touchcancel="touchendhandle"
      ></canvas>
    </template>
  </div>
</template>

<script>
  import { reactive, toRefs, nextTick, watchEffect, ref } from 'vue';
  import * as pdfjs from 'pdfjs-dist';
  // import {GlobalWorkerOptions, getDocument} from 'pdfjs-dist'
  import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry';
  // import { Toast } from 'vant';

  pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;

  export default {
    name: 'PdfViewer',
    props: {
      url: { type: String, default: '' }, // pdf文件路径
    },
    setup(props, { emit }) {
      const state = reactive({
        pdfCtx: null,
        currentPageNum: 0, // 当前页
        totalPageNum: 0,
        zoom: 10, // 目前暂时采用css方式缩放页面
        minZoom: 10, // 缩放最小值 一倍
        maxZoom: 50, // 缩放最大值 五倍
      });
      const Pdfcontent = ref(null);

      const resolvePdf = (url) => {
        const loadingTask = pdfjs.getDocument(url);
        loadingTask.promise.then((pdf) => {
          state.pdfCtx = pdf;
          state.totalPageNum = pdf.numPages;
          state.currentPageNum = 1;

          // 动态计算scale
          pdf.getPage(1).then((res) => {
            let boxWidth = Pdfcontent.value.clientWidth - 20;
            const [x1, , x2] = res._pageInfo.view;
            const pageWidth = x2 - x1;
            state.scale = (boxWidth * (state.maxZoom / 10)) / pageWidth;

            console.log(boxWidth, state.scale, '5');
          });
          nextTick(() => {
            renderPdf();
          });
        });
      };

      const renderPdf = (num = 1) => {
        state.pdfCtx.getPage(num).then((page) => {
          const canvas = document.getElementById(`pdf-canvas-${num}`);
          const ctx = canvas.getContext('2d');
          const viewport = page.getViewport({ scale: state.scale });
          // 画布大小,默认值是width:300px,height:150px
          canvas.width = viewport.width;
          canvas.height = viewport.height;
          // 画布的dom大小, 设置移动端,宽度设置铺满整个屏幕
          const clientWidth = Pdfcontent.value.clientWidth;
          canvas.style.width = clientWidth + 'px';
          // 根据pdf每页的宽高比例设置canvas的高度
          canvas.style.height = clientWidth * (viewport.height / viewport.width) + 'px';
          page.render({
            canvasContext: ctx,
            viewport,
          });
          if (num < state.totalPageNum) {
            renderPdf(num + 1);
          } else {
            emit('onRendered');
            // Toast.clear();
          }
        });
      };

      const touchstarthandle = (e) => {
        console.log('触摸开始');
        const touches = e.touches;
        const events = touches[0];
        const events2 = touches[1];
        // e.preventDefault();
        state.pageX = events.pageX;
        state.pageY = events.pageY;

        state.moveable = true;
        console.log('sksks');
        if (events2) {
          state.pageX2 = events2.pageX;
          state.pageY2 = events2.pageY;
        }
        state.originzoom = state.zoom;
        console.log('触摸开始', state);
      };
      const getDistance = (start, stop) => {
        return Math.hypot(stop.x - start.x, stop.y - start.y);
      };
      const touchmovehandle = (e) => {
        if (!state.moveable) {
          return;
        }

        const touches = e.touches;
        const events = touches[0];
        const events2 = touches[1];
        //双指移动 阻止默认事件,否则不阻止 不然容器中的canvas无法滚动翻页
        console.log('jssk');
        if (events2) {
          e.preventDefault();

          if (!state.pageX2) {
            state.pageX2 = events2.pageX;
          }
          if (!state.pageY2) {
            state.pageY2 = events2.pageY;
          }
          //获取坐标之间的比例

          var zoom =
            getDistance(
              {
                x: events.pageX,
                y: events.pageY,
              },
              {
                x: events2.pageX,
                y: events2.pageY,
              },
            ) /
            getDistance(
              {
                x: state.pageX,
                y: state.pageY,
              },
              {
                x: state.pageX2,
                y: state.pageY2,
              },
            );
          let newScale = state.originzoom * zoom;
          if (newScale > 50) {
            newScale = 50;
          } else if (newScale < 5) {
            newScale = 5;
          }
          console.log(state, zoom, newScale, '缩放');
          state.zoom = newScale;
        }
      };
      const touchendhandle = () => {
        state.moveable = false;

        delete state.pageX2;
        delete state.pageY2;
      };

      watchEffect(() => {
        if (props.url) {
          // Toast.loading({
          //   message: '文件加载中...',
          //   overlay: true,
          //   forbidClick: true,
          //   duration: 0,
          // });

          resolvePdf(props.url);
        }
      });
      return {
        ...toRefs(state),

        Pdfcontent,
        touchstarthandle,
        touchmovehandle,
        touchendhandle,
      };
    },
  };
</script>

<style lang="less" scoped>
  .pdf-viewer {
    padding-right: 20px;
    width: 100%;
    height: 100%;
    overflow: scroll;
    scrollbar-width: none; /* firefox */
    -ms-overflow-style: none; /* IE 10+ */
  }
  .pdf-viewer::-webkit-scrollbar {
    display: none; /* Chrome Safari */
  }
</style>

这样代码功能实现了,但其实会面临性能问题,如果加载大型pdf ,生成几十个 cavas 并绘制,性能直接爆炸,所以可以在此基础上进行优化,将递归方式改变为 虚拟渲染,先渲染在视图区域内的cavas,这是接下来待实现优化方向。

谢谢观看,有什么问题可以留言~