如何封装一个支持图片和PDF在线预览的Vue组件
在本文中,我将介绍如何设计并实现一个Vue组件,该组件能够在线预览图片和PDF文件。我们将基于element-ui
的elImageViewer
组件进行改造,并使用vue-pdf
插件来实现PDF预览功能。本文将详细介绍从设计思路到落地实现的全过程,完整代码在文末。
设计思路
我们需要一个统一的组件来处理用户上传的图片和PDF文件,并能够在一个视图中展示它们。具体需求包括:
- 支持图片和PDF文件的在线预览。
- 支持图片和PDF文件的轮播切换。
- 支持PDF文件的翻页和下载功能。
- 支持图片的缩放、旋转等操作。
为了实现上述需求,我们选择使用element-ui
的elImageViewer
组件作为基础,扩展其功能以支持PDF文件。我们将使用vue-pdf
插件来渲染PDF文件,最终达到图中的效果。
实现步骤
1. 安装依赖
首先,我们需要安装element-ui
和vue-pdf
插件。
npm install element-ui vue-pdf
2. 创建组件
接下来,我们创建一个新的Vue组件PreviewFileViewer
,其模板结构如下:
整个组件的代码是基于 element-ui
的 elImageViewer
组件进行拓展,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
遍历我们接收过来的文件数据,然后通过判断文件的类型来分别展示 img
和 vuePdf
。每一页走马灯通过当前页文件的类型来分别展示 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
的工具函数,并定义了组件的props
、data
、computed
、watch
和methods
:
我这里 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
组件的 src
和 page
属性,以反映当前选中的文件路径和页码。
解决方案:
-
在你的组件数据中,维护一个与当前显示的 PDF 文件相关的状态,比如
currentPdfSrc
和currentPage
。 -
当轮播图的索引改变时,更新这些状态变量以反映新文件的信息。
-
将
vuePdf
组件的src
和page
属性绑定到这些状态变量上。
通过这样的修改,每当轮播图切换到新的文件时,vuePdf
组件会接收到新的 src
和 page
属性值,从而触发重新加载和显示正确的 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。onSwitch
和onClose
:回调函数,用于处理文件切换和关闭事件。initialIndex
:初始索引,默认为0。appendToBody
:是否将组件添加到body元素中。maskClosable
:是否允许点击遮罩层关闭组件。
2. 计算属性
isSingle
:判断文件列表是否只有一个文件。isFirst
:判断当前文件是否是第一个文件。isLast
:判断当前文件是否是最后一个文件。imgStyle
:当前图片的样式,根据缩放和旋转等变换设置。viewerZIndex
:获取当前组件的z-index。fileList
:处理后的文件列表,包含文件名和文件路径。
3. 方法
handleChange
:处理轮播图切换事件。downloadPdf
:下载PDF文件。changePdfPage
:PDF文件翻页。loadPdfHandler
:PDF文件加载完成事件处理。isImage
和isPDF
:判断文件是否是图片或PDF文件。hide
:关闭组件。deviceSupportInstall
和deviceSupportUninstall
:安装和卸载键盘和鼠标事件监听。handleImgLoad
和handleImgError
:图片加载完成和加载失败事件处理。handleMouseDown
:鼠标按下事件处理,用于拖动图片。handleMaskClick
:处理遮罩层点击事件。reset
:重置图片的变换参数。toggleMode
:切换图片显示模式。prev
和next
:切换到上一张和下一张文件。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