最近在做一个文件预览功能,需要在浏览器里展示 PDF 并支持位置标记和缩放。折腾了一段时间,把实现思路整理一下。
PDF 渲染
PDF 在浏览器里渲染,直接用 @tato30/vue-pdf 这个库。它基于 pdf.js,用起来还算顺手。
状态定义
先定义几个核心状态:
const selectedFile = ref(null) // 当前选中的文件
const page = ref(1) // 当前页码
const isLoading = ref(false) // 加载状态
const zoomLevel = ref(1) // 缩放级别,默认 1
const minZoom = 0.5 // 最小缩放
const maxZoom = 3 // 最大缩放
const position = ref({ x: 0, y: 0 }) // 拖拽位置
const isDragging = ref(false) // 是否正在拖拽
const dragStart = ref({ x: 0, y: 0 }) // 拖拽起始位置
PDF URL 计算
PDF 的 URL 用 computed 动态计算,这样文件切换时会自动更新:
const currentPdfUrl = computed(() => {
if (selectedFile.value?.path?.endsWith('.pdf')) {
return getFileURL('pdf', selectedFile.value.id)
}
return ''
})
const { pdf, pages } = usePDF(currentPdfUrl)
const totalPages = computed(() => pages.value || 1)
usePDF 是个 composable,接收 PDF 的 URL,返回 pdf 对象和总页数 pages。注意这里用 computed 包装 URL,这样当 selectedFile 变化时,PDF 会自动重新加载。
PDF 监听
监听 PDF 加载完成,更新容器尺寸:
watch(pdf, (newPdf) => {
if (newPdf) {
isLoading.value = false
nextTick(updateContainerSize)
}
})
nextTick 确保 DOM 更新后再计算尺寸,不然可能拿到旧值。
模板渲染
在模板里用 VuePDF 组件渲染:
<VuePDF v-if="pdf" :pdf="pdf" :page="page" :scale="zoomLevel" />
传三个参数:pdf 对象、当前页码 page、缩放比例 zoomLevel。组件内部会渲染成 canvas,我们不用管底层细节。
文件选择逻辑
选择文件时需要重置视图和页码,并加载标注数据:
const selectFile = (file) => {
resetView() // 重置缩放和位置
page.value = 1 // 重置页码
isLoading.value = true
selectedFile.value = file
// 处理标注数据,确保页码是数字类型
if (file.risk_info?.page_info) {
annotationInfo.value = file.risk_info.page_info.map((item) => ({
...item,
page: typeof item.page === 'string' ? parseInt(item.page) : item.page,
}))
} else {
annotationInfo.value = []
}
}
这里做了类型转换,因为后端可能返回字符串页码,统一转成数字方便后续比较。
位置标记的实现
位置标记这块比较有意思。需求是在 PDF 上标出有问题的地方,比如某个区域格式不对、内容有误等等。
数据结构
每个标记包含这些信息:
page: 页码location: 位置坐标,用{x1, y1, x2, y2}表示,都是 0-1 之间的相对坐标status: 状态(error、warning、success)reason: 原因描述
坐标用相对值的好处是,不管 PDF 怎么缩放,标记都能准确对应到位置。
叠加层实现
标记不是直接画在 PDF 上的,而是用一个绝对定位的 overlay 层叠在上面。这样不会影响 PDF 本身的渲染。
<div class="pdf-content">
<VuePDF v-if="pdf" :pdf="pdf" :page="page" :scale="zoomLevel" />
<div class="annotations-overlay">
<div
v-for="annotation in currentPageAnnotations.filter((a) => a.location)"
:key="`${annotation.page}-${annotation.location.x}-${annotation.location.y}`"
class="annotation-marker"
:class="annotation.status || 'error'"
:style="getAnnotationStyle(annotation.location)"
/>
</div>
</div>
annotations-overlay 用绝对定位铺满整个 PDF 区域,pointer-events: none 让它不拦截鼠标事件。但标记本身设置 pointer-events: auto,这样鼠标悬停时能触发高亮。
位置计算
getAnnotationStyle 函数把相对坐标转成 CSS 样式:
const getAnnotationStyle = (location) => {
if (!location) return {}
return {
left: `${location.x1 * 100}%`,
top: `${location.y1 * 100}%`,
width: `${(location.x2 - location.x1) * 100}%`,
height: `${(location.y2 - location.y1) * 100}%`,
}
}
比如 x1: 0.1, x2: 0.3 就表示从左边 10% 到 30% 的区域。这样无论 PDF 怎么缩放,标记都能跟着走。
当前页过滤
只显示当前页的标记,用 computed 过滤:
const currentPageAnnotations = computed(() => {
const currentPage = parseInt(page.value)
return annotationInfo.value.filter((annotation) => {
const annotationPage = typeof annotation.page === 'string'
? parseInt(annotation.page)
: annotation.page
return annotationPage === currentPage
})
})
这里做了类型兼容,因为后端可能返回字符串页码。
标注列表联动
右侧有个标注列表,点击可以跳转到对应页面并高亮。
状态管理
const currentAnnotationIndex = ref(-1) // 当前标注索引
const highlightedAnnotation = ref(null) // 高亮的标注
const totalAnnotations = computed(() => annotationInfo.value.length)
// 是否可以切换标注
const canGoPrevAnnotation = computed(() => {
return totalAnnotations.value > 0
})
const canGoNextAnnotation = computed(() => {
return totalAnnotations.value > 0
})
切换标注逻辑
切换到下一个标注:
const nextAnnotation = () => {
if (canGoNextAnnotation.value) {
// 循环切换:最后一个切到第一个
if (currentAnnotationIndex.value >= totalAnnotations.value - 1) {
currentAnnotationIndex.value = 0
} else {
currentAnnotationIndex.value++
}
const annotation = annotationInfo.value[currentAnnotationIndex.value]
// 确保页码是数字类型
if (typeof annotation.page === 'string') {
annotation.page = parseInt(annotation.page)
}
// 如果页码不同,切换页面
if (annotation.page !== page.value) {
page.value = annotation.page
}
highlightAnnotation(annotation)
}
}
切换到上一个标注:
const prevAnnotation = () => {
if (canGoPrevAnnotation.value) {
// 如果是第一个标注,则切换到最后一个
if (currentAnnotationIndex.value <= 0) {
currentAnnotationIndex.value = totalAnnotations.value - 1
} else {
currentAnnotationIndex.value--
}
const annotation = annotationInfo.value[currentAnnotationIndex.value]
if (typeof annotation.page === 'string') {
annotation.page = parseInt(annotation.page)
}
if (annotation.page !== page.value) {
page.value = annotation.page
}
highlightAnnotation(annotation)
}
}
高亮处理
const highlightAnnotation = (annotation) => {
highlightedAnnotation.value = annotation
}
const clearHighlight = () => {
highlightedAnnotation.value = null
}
页面切换自动选择标注
监听页面变化,自动选择当前页面的第一个标注:
watch(page, (newPage) => {
const pageAnnotations = annotationInfo.value.filter((a) => a.page === newPage)
if (pageAnnotations.length > 0) {
const index = annotationInfo.value.indexOf(pageAnnotations[0])
if (index !== -1) {
currentAnnotationIndex.value = index
highlightAnnotation(pageAnnotations[0])
}
}
})
切换标注时会自动跳转到对应页面,如果标注不在当前页的话。页面切换时也会自动选中该页的第一个标注。
缩放与拖拽
缩放功能比较常规,但放大后需要支持拖拽移动,不然放大后看不到其他区域就很尴尬。
缩放实现
缩放用 CSS transform: scale(),配合 transformOrigin: 'center center' 让缩放以中心点为基准:
<div
class="pdf-content"
:style="{
transform: `scale(${zoomLevel}) translate(${position.x}px, ${position.y}px)`,
transformOrigin: 'center center',
cursor: isDragging ? 'grabbing' : zoomLevel > 1 ? 'grab' : 'default',
}"
>
zoomLevel 范围是 0.5 到 3,每次点击缩放按钮增减 0.1。缩放和位移都放在同一个 transform 里,这样性能更好。
拖拽逻辑
只有放大(zoomLevel > 1)时才允许拖拽,不然没必要。
开始拖拽
const startDrag = (event) => {
if (zoomLevel.value <= 1) return // 只有放大时才能拖动
isDragging.value = true
dragStart.value = {
x: event.clientX - position.value.x,
y: event.clientY - position.value.y,
}
}
这里有个细节:dragStart 存的是鼠标位置减去当前位移,这样拖拽时计算的是相对位移,而不是绝对位置。不然拖拽会"跳"一下。
拖拽中
const onDrag = (event) => {
if (!isDragging.value) return
event.preventDefault()
position.value = {
x: event.clientX - dragStart.value.x,
y: event.clientY - dragStart.value.y,
}
}
preventDefault 防止拖拽时选中文本。
停止拖拽
const stopDrag = () => {
isDragging.value = false
}
在 mouseup 和 mouseleave 事件中都调用,确保鼠标离开元素时也能结束拖拽。
缩放控制
const zoomIn = () => {
if (zoomLevel.value < maxZoom) {
zoomLevel.value = Math.min(maxZoom, zoomLevel.value + 0.1)
}
}
const zoomOut = () => {
if (zoomLevel.value > minZoom) {
zoomLevel.value = Math.max(minZoom, zoomLevel.value - 0.1)
}
}
每次增减 0.1,用 Math.min 和 Math.max 限制范围。
重置视图
const resetView = () => {
zoomLevel.value = 1
position.value = { x: 0, y: 0 }
}
// 切换页面时重置位置
watch(page, () => {
position.value = { x: 0, y: 0 }
})
切换页面时也会自动重置位置,避免上一页的拖拽状态影响新页面。
判断是否需要重置
const needsReset = computed(() => {
return zoomLevel.value !== 1 || position.value.x !== 0 || position.value.y !== 0
})
只有当缩放不是 1 或位置不是原点时,才显示重置按钮。
一些细节
页码控制
上一页/下一页
const prevPage = () => {
if (page.value > 1) {
page.value--
}
}
const nextPage = () => {
if (page.value < totalPages.value) {
page.value++
}
}
页码输入处理
const handlePageChange = (value) => {
if (typeof value === 'string') {
value = parseInt(value)
}
const newPage = parseInt(value)
if (isNaN(newPage) || newPage < 1) {
page.value = 1
} else if (newPage > totalPages.value) {
page.value = totalPages.value
} else {
page.value = newPage
}
}
处理边界情况:非数字、小于 1、大于总页数。
全屏模式
切换全屏
const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await contentRef.value.requestFullscreen()
} else {
await document.exitFullscreen()
}
} catch (err) {
ElMessage.error('Fullscreen mode is not supported or disabled')
}
}
监听全屏状态
const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
// 在 onMounted 中监听
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange)
})
// 在 onUnmounted 中移除监听
onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
// 确保退出组件时退出全屏
if (document.fullscreenElement) {
document.exitFullscreen()
}
})
容器尺寸监听
const updateContainerSize = () => {
if (pdfContainerRef.value) {
const container = pdfContainerRef.value
containerSize.value = {
width: container.offsetWidth,
height: container.offsetHeight,
}
}
}
// 监听窗口大小变化
onMounted(() => {
window.addEventListener('resize', updateContainerSize)
})
onUnmounted(() => {
window.removeEventListener('resize', updateContainerSize)
})
性能优化
- 标记层用
pointer-events: none,只有标记本身响应鼠标事件 - transform 用 CSS 实现,利用 GPU 加速
- 切换页面时重置位置,避免不必要的计算
- 用 computed 缓存计算结果,减少重复计算
总结
整体实现不算复杂,主要是几个点:
- PDF 渲染用现成的库,省事
- 标记用 overlay 层叠加,不污染 PDF 本身
- 坐标用相对值,适配各种缩放比例
- 缩放和拖拽配合,提升用户体验
实际用起来效果还行,标记位置准确,缩放拖拽也流畅。就是标记多的时候可能会有性能问题,不过目前还没遇到。
CSS 样式详解
样式这块也挺重要,直接影响交互体验。下面详细说说关键样式的作用。
整体布局
.content {
display: grid;
grid-template-columns: 300px 1fr; // 左侧文件列表 300px,右侧 PDF 区域自适应
margin-bottom: 8px;
height: 100%;
}
用 Grid 布局,左侧固定宽度,右侧自适应。
PDF 容器样式
.content-body {
flex: 1;
overflow: hidden; // 隐藏溢出内容
display: flex;
border-radius: 10px;
border: 2px solid #bdbdbd;
position: relative;
margin: 10px;
// 全屏模式
&.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999; // 确保在最上层
margin: 0;
border-radius: 0;
background-color: white;
}
}
全屏时用 position: fixed 铺满整个屏幕,z-index: 9999 确保在最上层。
PDF 视图区域
.pdf-view-area {
flex: 1;
position: relative;
border-right: 1px solid #dcdfe6;
display: flex;
justify-content: center; // 水平居中
align-items: center; // 垂直居中
overflow: hidden; // 隐藏溢出,配合拖拽使用
}
overflow: hidden 很重要,这样放大后拖拽时,超出容器的部分会被隐藏。
PDF 内容区域
.pdf-content {
max-width: 100%;
max-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
position: relative;
transition: transform 0.1s ease; // transform 动画,让缩放更平滑
user-select: none; // 防止拖动时选中文本
&:active {
cursor: grabbing; // 拖拽时显示抓取光标
}
// PDF canvas 样式
:deep(canvas) {
max-width: 100%;
max-height: 100%;
object-fit: contain; // 保持比例,完整显示
}
}
user-select: none 防止拖拽时选中文本,transition 让缩放动画更平滑。
标注叠加层
.annotations-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none; // 不拦截鼠标事件
.annotation-marker {
position: absolute;
border: 2px solid;
border-radius: 4px;
pointer-events: auto; // 标记本身可以响应鼠标事件
cursor: pointer;
opacity: 0.6; // 默认半透明
transition: opacity 0.3s; // 透明度过渡
&:hover {
opacity: 1; // 悬停时完全不透明
}
// 不同状态的样式
&.error {
border-color: #f56c6c;
background-color: rgba(245, 108, 108, 0.3);
}
&.warning {
border-color: #e6a23c;
background-color: rgba(230, 162, 60, 0.3);
}
&.success {
border-color: #67c23a;
background-color: rgba(103, 194, 58, 0.3);
}
}
}
pointer-events: none 让 overlay 不拦截事件,但标记本身设置 pointer-events: auto,这样只有标记能响应鼠标。用 rgba 设置半透明背景,不会完全遮挡 PDF 内容。
标注列表样式
.file-annotation {
width: 300px;
padding: 16px;
background-color: #f5f7fa;
display: flex;
flex-direction: column;
justify-content: space-between;
.annotation-list {
flex: 1;
overflow-y: auto; // 列表可滚动
margin-bottom: 16px;
.annotation-item {
background-color: #fff;
padding: 12px;
border-radius: 4px;
margin-bottom: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
transition: all 0.3s; // 所有属性过渡
&:hover {
transform: translateX(-4px); // 悬停时向左移动
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
&.highlighted {
background-color: #ecf5ff;
border-left: 4px solid #409eff; // 左侧蓝色边框
}
.annotation-status {
width: 12px;
height: 12px;
border-radius: 50%; // 圆形状态点
&.error {
background-color: #f56c6c;
}
&.warning {
background-color: #e6a23c;
}
&.success {
background-color: #67c23a;
}
}
}
}
}
标注项悬停时用 transform: translateX(-4px) 向左移动,配合阴影变化,有轻微的"浮起"效果。高亮时左侧加蓝色边框,视觉上更明显。
控制栏样式
.pdf-controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%); // 水平居中
background-color: rgba(255, 255, 255, 0.9); // 半透明白色背景
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 20px;
z-index: 100; // 确保在 PDF 上方
.controls-group {
display: flex;
align-items: center;
gap: 10px;
&.zoom-controls {
padding-left: 10px;
border-left: 1px solid #dcdfe6; // 左侧分隔线
}
.control-icon {
font-size: 20px;
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
color: #409eff;
&:hover {
background-color: #ecf5ff;
}
&.disabled {
color: #c0c4cc;
cursor: not-allowed;
&:hover {
background-color: transparent; // 禁用时不响应悬停
}
}
}
.zoom-level {
min-width: 60px;
text-align: center;
font-size: 14px;
color: #606266;
}
}
}
控制栏用 position: absolute 固定在底部,transform: translateX(-50%) 实现水平居中。半透明背景不会完全遮挡 PDF,但又能看清控制按钮。
页码输入框样式
.page-input-group {
display: flex;
align-items: center;
margin: 0 10px;
.el-input {
width: 55px;
margin-right: 5px;
:deep(.el-input__inner) {
padding: 0 8px;
text-align: center; // 文字居中
}
// 隐藏数字输入框的上下箭头
:deep(.el-input__inner::-webkit-outer-spin-button),
:deep(.el-input__inner::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:deep(.el-input__inner[type='number']) {
-moz-appearance: textfield; // Firefox 隐藏箭头
}
}
}
隐藏数字输入框的上下箭头,让界面更简洁。用 :deep() 穿透 Element Plus 的样式作用域。
文件列表样式
.files-section {
padding: 10px;
flex: 1;
display: flex;
flex-direction: column;
&.hidden-in-fullscreen {
display: none; // 全屏时隐藏
}
.file-list {
flex: 1;
overflow-y: auto; // 可滚动
border-radius: 10px;
border: 2px solid #e7e7e7;
.file-item {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 2px solid #e7e7e7;
&:hover {
background: #f5f7fa;
}
&.active {
background: #0085fc; // 选中时蓝色背景
color: #fff;
.file-icon {
color: #fff;
}
}
}
}
}
文件项选中时用蓝色背景,视觉上更明显。悬停时背景色变化,提供交互反馈。
样式技巧总结
- 定位技巧:PDF 容器用
position: relative,标注 overlay 用position: absolute叠加 - 事件穿透:overlay 用
pointer-events: none,标记用pointer-events: auto - 居中技巧:用
transform: translateX(-50%)实现绝对定位元素的水平居中 - 过渡动画:用
transition让交互更平滑,特别是 transform 和 opacity - 层级管理:用
z-index控制元素层级,控制栏和全屏模式需要较高层级 - 响应式:用
max-width和max-height配合object-fit: contain保持比例
一些其他的预览 PDF 方法
- 使用 @vue-office/pdf 库
- 微软官方的 Office Online 预览 (https://view.officeapps.live.com/op/view.aspx?src=https://501351981.github.io/vue-office/examples/dist/static/test-files/test.docx) 需要第三方域名支持