前言
Hello~大家好。我是秋天的一阵风
在日常的开发工作中,PDF 预览功能是许多系统不可或缺的一部分。市面上虽然有不少现成的 PDF 预览组件,功能也还算丰富,但在实际项目中,我发现它们很难完全契合我们公司自己的 UX 设计规范。而且这些第三方封装的组件,可定制化程度往往比较低,想要做一些个性化的调整,简直是难上加难。
鉴于此,我索性决定基于 vue3、ts 和 pdfjs,自己封装一个 PDF 预览组件。经过一番努力,这个组件不仅满足了公司的 UX 设计要求,还集成了不少实用的功能。下面就来详细介绍一下这个组件。
✨ 温馨提示✨
📌 注意啦注意啦!本组件的 "最佳拍档" 是pdfjs-dist的2.16.105 版本哦~
📌 为了丝滑运行,建议大家使用这个版本或者兼容版本 😜
一、组件功能概览
这个 PDF 预览组件包含了众多实用功能,能满足日常 PDF 查看的大部分需求。
-
它有直观的页面导航功能,能清晰显示当前页码和总页数,方便用户快速了解阅读进度;
-
支持全屏展示,让用户能沉浸式查看 PDF 内容;具备页面旋转功能,可根据需要向左或向右旋转页面;
-
能显示或隐藏注释和文本层,适应不同的阅读需求;
-
还可以查看文档信息,包括标题、作者、页数等详细内容;支持打印和下载 PDF 文件,方便用户对文档进行进一步处理;
-
左侧侧边栏可在缩略图和大纲之间切换,便于快速定位页面。
二、重点功能详细介绍
1. 页面选择与缩略图功能
页面选择和缩略图功能是方便用户快速定位到目标页面的重要手段。在组件中,左侧侧边栏默认展示的是 PDF 的缩略图,每个缩略图对应着 PDF 中的一页。
当用户点击某个缩略图时,就会跳转到对应的页面。同时,当前正在查看的页面,其缩略图会有明显的样式标识,让用户清楚知道自己所处的位置。
关键代码如下:
js
// 点击缩略图跳页
const scrollToPage = async (pageNum) => {
if (pageNum < 1 || pageNum > totalPages.value) return;
if (!pdfViewerRef.value) return;
pdfViewerRef.value.currentPageNumber = pageNum;
};
// 设置缩略图引用
const setThumbnailRef = (el, pageNum) => {
if (el) {
thumbnailRefs.value[pageNum] = el;
}
};
// 渲染缩略图
const renderThumbnails = async () => {
if (!pdfDoc.value) return;
for (let i = 1; i <= totalPages.value; i++) {
const page = await pdfDoc.value.getPage(i);
const viewport = page.getViewport({ scale: 1 });
const targetWidth = 78;
const scale = targetWidth / viewport.width;
const thumbnailViewport = page.getViewport({ scale });
const canvas = thumbnailRefs.value[i];
if (!canvas) continue;
const context = canvas.getContext("2d", { willReadFrequently: true });
canvas.width = Math.round(thumbnailViewport.width);
canvas.height = Math.round(thumbnailViewport.height);
await page.render({
canvasContext: context,
viewport: thumbnailViewport,
}).promise;
}
};
在模板中,通过循环生成缩略图的 canvas 元素,并绑定点击事件实现跳页功能:
html
<div v-for="pageNum in totalPages" :key="pageNum" class="thumbnail" :class="{ active: pageNum === currentVisiblePage }" @click="scrollToPage(pageNum)">
<canvas :ref="(el) => setThumbnailRef(el, pageNum)" style="height: 110px" />
</div>
2. 缩放功能
缩放功能能让用户根据自己的阅读习惯和需求,调整 PDF 页面的大小。组件预设了 0.6、0.8、1.0、1.5、2.0 这几个常用的缩放比例,用户可以直接选择,也可以通过放大和缩小按钮逐步调整。
关键代码如下:
js
// 预设缩放比例
const presetScales = [0.6, 0.8, 1.0, 1.5, 2.0];
const currentScaleIndex = ref(2); // 默认100%对应的索引
// 设置预设缩放比例
const setPresetScale = (scaleValue) => {
if (!pdfViewerRef.value) return;
pdfViewerRef.value.currentScale = scaleValue;
scale.value = scaleValue;
// 更新当前缩放索引
const index = presetScales.indexOf(scaleValue);
if (index !== -1) {
currentScaleIndex.value = index;
}
};
// 放大
const zoomIn = async () => {
if (!pdfViewerRef.value) return;
const currentIndex = currentScaleIndex.value;
if (currentIndex < presetScales.length - 1) {
const nextIndex = currentIndex + 1;
const nextScale = presetScales[nextIndex];
pdfViewerRef.value.currentScale = nextScale;
scale.value = nextScale;
currentScaleIndex.value = nextIndex;
} else {
ElMessage.warning("已到最大缩放比例");
}
};
// 缩小
const zoomOut = async () => {
if (!pdfViewerRef.value) return;
const currentIndex = currentScaleIndex.value;
if (currentIndex > 0) {
const prevIndex = currentIndex - 1;
const prevScale = presetScales[prevIndex];
pdfViewerRef.value.currentScale = prevScale;
scale.value = prevScale;
currentScaleIndex.value = prevIndex;
} else {
ElMessage.warning("已到最小缩放比例");
}
};
在模板中,展示当前缩放比例,并提供缩放选择和按钮:
html
<div class="scale-display">
<span class="current-scale">{{ currentScalePercent }}%</span>
<el-dropdown @command="setPresetScale" trigger="click">
<span class="scale-dropdown">
<el-icon :size="12">
<ArrowDown />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(scaleValue, index) in presetScales"
:key="index"
:command="scaleValue"
:class="{ 'active-scale': index === currentScaleIndex }"
>
{{ Math.round(scaleValue * 100) }}%
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-tooltip placement="top">
<template #content>放大文档 </template>
<div class="toolbar-item">
<el-icon :size="16" @click="zoomIn">
<Plus />
</el-icon>
</div>
</el-tooltip>
<el-tooltip placement="top">
<template #content>缩小文档 </template>
<div class="toolbar-item zoom-out">
<el-icon :size="16" @click="zoomOut">
<Minus />
</el-icon>
</div>
</el-tooltip>
3. 搜索内容高亮功能
搜索内容高亮功能让用户能快速在 PDF 中找到所需的关键信息。用户输入搜索文本后,组件会在 PDF 中查找所有匹配的内容,并将其高亮显示,同时显示匹配的总数和当前所在的位置,用户还可以通过上下按钮切换查看不同的匹配结果。
关键代码如下:
js
// 关键字搜索相关变量
const searchText = ref("");
const totalMatches = ref(0);
const currentSearchIndex = ref(0);
const isSearching = ref(false);
// 搜索功能
const searchInPDF = async () => {
if (!findControllerRef.value) return;
const query = searchText.value.trim();
if (!query) {
// 清除
eventBusRef.value?.dispatch("find", {
type: "find",
query: "",
caseSensitive: false,
entireWord: false,
highlightAll: false,
findPrevious: undefined,
});
totalMatches.value = 0;
currentSearchIndex.value = 0;
return;
}
isSearching.value = true;
try {
// 首次查找
eventBusRef.value?.dispatch("find", {
type: "find",
query,
caseSensitive: false,
entireWord: false,
highlightAll: true,
findPrevious: undefined,
});
} catch (error) {
console.error("搜索失败:", error);
ElMessage.error("搜索失败");
} finally {
isSearching.value = false;
}
};
// 下一个搜索结果
const nextSearchResult = () => {
if (!eventBusRef.value || !searchText.value.trim()) return;
eventBusRef.value.dispatch("find", {
type: "again",
query: searchText.value.trim(),
caseSensitive: false,
entireWord: false,
highlightAll: true,
findPrevious: false,
});
if (totalMatches.value > 0) {
const cur = currentSearchIndex.value || 0;
currentSearchIndex.value = (cur % totalMatches.value) + 1;
}
};
// 上一个搜索结果
const prevSearchResult = () => {
if (!eventBusRef.value || !searchText.value.trim()) return;
eventBusRef.value.dispatch("find", {
type: "again",
query: searchText.value.trim(),
caseSensitive: false,
entireWord: false,
highlightAll: true,
findPrevious: true,
});
if (totalMatches.value > 0) {
const cur = currentSearchIndex.value || 1;
currentSearchIndex.value =
((cur - 2 + totalMatches.value) % totalMatches.value) + 1;
}
};
// 清除搜索
const clearSearch = () => {
searchText.value = "";
totalMatches.value = 0;
currentSearchIndex.value = 0;
eventBusRef.value?.dispatch("find", {
type: "find",
query: "",
caseSensitive: false,
entireWord: false,
highlightAll: false,
findPrevious: undefined,
});
};
在模板中,提供搜索输入框和操作按钮:
js
<div class="search-container">
<div class="search-input-box">
<div class="search-input-wrapper">
<el-input
v-model="searchText"
placeholder="输入搜索文本"
class="search-input"
clearable
@keyup.enter="searchInPDF"
@input="handleInputUpdate"
/>
</div>
<span class="search-count" v-if="totalMatches > 0">
{{ currentSearchIndex }} / {{ totalMatches }}
</span>
</div>
<div class="search-controls" v-if="totalMatches > 0">
<el-button
size="small"
@click="prevSearchResult"
:disabled="totalMatches === 0"
>
<el-icon :size="14">
<ArrowUp />
</el-icon>
</el-button>
<el-button
size="small"
@click="nextSearchResult"
:disabled="totalMatches === 0"
>
<el-icon :size="14">
<ArrowDown />
</el-icon>
</el-button>
</div>
<el-button
type="primary"
size="small"
@click="searchInPDF"
:loading="isSearching"
class="search-button"
>
搜索
</el-button>
</div>
三、总结
这款基于 vue3 + ts + pdfjs 的 PDF 预览组件,是为了满足公司特定的 UX 设计需求而诞生的。它不仅包含了页面导航、全屏展示、旋转、注释和文本层控制、文档信息查看、打印、下载等丰富功能,还在页面选择与缩略图、缩放、搜索内容高亮等关键功能上做了细致的实现。
四、完整代码:
js
<script setup>
import {
ref,
onMounted,
onUnmounted,
shallowRef,
computed,
nextTick,
watch,
} from "vue";
import * as pdfjsLib from "pdfjs-dist/build/pdf";
import {
EventBus,
PDFLinkService,
PDFViewer,
PDFFindController,
} from "pdfjs-dist/web/pdf_viewer";
import "pdfjs-dist/web/pdf_viewer.css";
import { Loading } from "@element-plus/icons-vue";
import { h } from "vue";
import {
Close,
ArrowRight,
ArrowLeft,
Expand,
CloseBold,
Plus,
Minus,
ArrowUp,
ArrowDown,
Download,
Printer,
Refresh,
InfoFilled,
View,
Hide,
} from "@element-plus/icons-vue";
import { defineEmits } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
const indicator = h(Loading, {
style: {
fontSize: "48px",
},
class: "is-loading",
});
let loading = ref(false);
const getWorkerSrc = () => {
if (import.meta.env.PROD) {
return "/assets/js/pdf.worker.min.js";
}
return new URL("pdfjs-dist/build/pdf.worker.min.js", import.meta.url).href;
};
pdfjsLib.GlobalWorkerOptions.workerSrc = getWorkerSrc();
const props = defineProps({
src: {
type: String,
required: true,
},
pageGap: {
type: Number,
default: 20,
},
});
const container = ref(null);
const pagesContainer = ref(null);
const viewerRef = ref(null);
const pdfViewerRef = shallowRef(null);
const eventBusRef = shallowRef(null);
const linkServiceRef = shallowRef(null);
const findControllerRef = shallowRef(null);
const isViewerDocumentBound = ref(false);
const thumbnailRefs = ref({});
const baseScaleRef = ref(1);
const zoomStep = 0.5; // 每次缩放步长
const maxZoomSteps = 2; // 最多放大/缩小次数
const pdfDoc = shallowRef(null);
const totalPages = ref(0);
const scale = ref(1.0);
const isFullscreen = ref(false);
const currentPageNumber = ref(1);
let isThumbnailsShow = ref(true); // 默认展开缩略图
// 预设缩放比例
const presetScales = [0.6, 0.8, 1.0, 1.5, 2.0];
const currentScaleIndex = ref(2); // 默认100%对应的索引
const handleThumbnailsShow = () => {
isThumbnailsShow.value = !isThumbnailsShow.value;
};
// 设置预设缩放比例
const setPresetScale = (scaleValue) => {
if (!pdfViewerRef.value) return;
pdfViewerRef.value.currentScale = scaleValue;
scale.value = scaleValue;
// 更新当前缩放索引
const index = presetScales.indexOf(scaleValue);
if (index !== -1) {
currentScaleIndex.value = index;
}
};
// 获取当前缩放比例百分比
const currentScalePercent = computed(() => {
return Math.round(scale.value * 100);
});
// 新增功能相关变量
const rotation = ref(0); // 页面旋转角度
const showAnnotations = ref(true); // 是否显示注释
const showTextLayer = ref(true); // 是否显示文本层
const hasAnnotations = ref(false); // 文档是否包含注释
const hasTextContent = ref(false); // 文档是否包含文本内容
const documentInfo = ref(null); // 文档信息
const outline = ref(null); // 文档大纲
const showOutline = ref(false); // 是否显示大纲
const currentOutlineItem = ref(null); // 当前大纲项
const isPrinting = ref(false); // 是否正在打印
const isDownloading = ref(false); // 是否正在下载
// 计算当前可见页
const currentVisiblePage = computed(() => currentPageNumber.value);
// 页面旋转功能
const rotatePages = (direction) => {
if (!pdfViewerRef.value) return;
const viewer = pdfViewerRef.value;
const step = direction === "right" ? 90 : -90;
const current =
typeof viewer.pagesRotation === "number" ? viewer.pagesRotation : 0;
const newRotation = (((current + step) % 360) + 360) % 360;
viewer.pagesRotation = newRotation; // 官方 API,会触发所有页重渲染
rotation.value = newRotation;
};
// 切换注释显示
const toggleAnnotations = () => {
if (!pdfDoc.value || !hasAnnotations.value) {
ElMessage.warning('此PDF文档不包含注释内容');
return;
}
showAnnotations.value = !showAnnotations.value;
if (pdfViewerRef.value) {
pdfViewerRef.value.annotationMode = showAnnotations.value ? 2 : 0;
// 重新设置文档以应用注释模式变化
pdfViewerRef.value.setDocument(pdfDoc.value);
ElMessage.success(showAnnotations.value ? '已显示注释' : '已隐藏注释');
}
};
// 切换文本层显示
const toggleTextLayer = () => {
if (!pdfDoc.value || !hasTextContent.value) {
ElMessage.warning('此PDF文档不包含可选择的文本内容');
return;
}
showTextLayer.value = !showTextLayer.value;
if (pdfViewerRef.value) {
pdfViewerRef.value.textLayerMode = showTextLayer.value ? 2 : 0;
// 重新设置文档以应用文本层模式变化
pdfViewerRef.value.setDocument(pdfDoc.value);
ElMessage.success(showTextLayer.value ? '已显示文本层' : '已隐藏文本层');
}
};
// 获取文档信息
const formatBytes = (bytes) => {
if (bytes == null) return "未知文件大小";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = Number(bytes);
let idx = 0;
while (size >= 1024 && idx < units.length - 1) {
size /= 1024;
idx += 1;
}
const fixed = size < 10 && idx > 0 ? 1 : 0;
return `${size.toFixed(fixed)} ${units[idx]}`;
};
const getDocumentInfo = async () => {
if (!pdfDoc.value) return;
try {
const { info, metadata } = await pdfDoc.value.getMetadata();
let fileSizeText = "未知文件大小";
try {
const { length } = await pdfDoc.value.getDownloadInfo();
fileSizeText = formatBytes(length);
} catch (_) {
// 某些来源可能不支持 getDownloadInfo
}
const metaGet =
typeof metadata?.get === "function"
? metadata.get.bind(metadata)
: () => undefined;
documentInfo.value = {
title: info?.Title || metaGet("dc:title") || "未知标题",
author: info?.Author || metaGet("dc:creator") || "未知作者",
subject: info?.Subject || metaGet("dc:subject") || "未知主题",
creator: info?.Creator || "未知创建者",
producer: info?.Producer || "未知生产者",
creationDate: info?.CreationDate || "",
modificationDate: info?.ModDate || "",
pageCount: totalPages.value,
fileSize: fileSizeText,
};
} catch (error) {
console.error("获取文档信息失败:", error);
}
};
// 获取文档大纲
const getDocumentOutline = async () => {
if (!pdfDoc.value) return;
try {
const outlineData = await pdfDoc.value.getOutline();
outline.value = outlineData;
} catch (error) {
console.error("获取文档大纲失败:", error);
}
};
// 跳转到大纲项
const goToOutlineItem = (item) => {
if (!pdfViewerRef.value || !item.dest) return;
try {
pdfViewerRef.value.linkService.navigateTo(item.dest);
currentOutlineItem.value = item;
} catch (error) {
console.error("跳转大纲项失败:", error);
}
};
// 打印PDF
const printPDF = async () => {
if (!pdfDoc.value) return;
try {
isPrinting.value = true;
// 创建打印窗口
const printWindow = window.open("", "_blank");
if (!printWindow) {
ElMessage.error("无法打开打印窗口,请检查浏览器弹窗设置");
return;
}
// 构建打印内容
let printContent = "<html><head><title>打印PDF</title>";
printContent +=
"<style>body{margin:0;padding:20px;} .page{page-break-after:always;margin-bottom:20px;} .page:last-child{page-break-after:avoid;}</style></head><body>";
// 渲染所有页面到canvas
for (let i = 1; i <= totalPages.value; i++) {
const page = await pdfDoc.value.getPage(i);
const viewport = page.getViewport({
scale: 1.5,
rotation: rotation.value,
});
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({
canvasContext: context,
viewport: viewport,
}).promise;
printContent += `<div class="page"><img src="${canvas.toDataURL()}" style="width:100%;height:auto;" /></div>`;
}
printContent += "</body></html>";
printWindow.document.write(printContent);
printWindow.document.close();
// 等待图片加载完成后打印
printWindow.onload = () => {
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 500);
};
} catch (error) {
console.error("打印失败:", error);
ElMessage.error("打印失败");
} finally {
isPrinting.value = false;
}
};
// 下载PDF
const downloadPDF = async () => {
if (!props.src) return;
try {
isDownloading.value = true;
const response = await fetch(props.src);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
// 从URL中提取文件名
const fileName = props.src.split("/").pop() || "document.pdf";
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success("下载开始");
} catch (error) {
console.error("下载失败:", error);
ElMessage.error("下载失败");
} finally {
isDownloading.value = false;
}
};
// 显示文档信息对话框
const showDocumentInfo = async () => {
if (!documentInfo.value) {
await getDocumentInfo();
}
if (documentInfo.value) {
ElMessageBox.alert(
`<div style="text-align: left;">
<p><strong>标题:</strong>${documentInfo.value.title}</p>
<p><strong>作者:</strong>${documentInfo.value.author}</p>
<p><strong>主题:</strong>${documentInfo.value.subject}</p>
<p><strong>创建者:</strong>${documentInfo.value.creator}</p>
<p><strong>生产者:</strong>${documentInfo.value.producer}</p>
<p><strong>创建日期:</strong>${documentInfo.value.creationDate}</p>
<p><strong>修改日期:</strong>${documentInfo.value.modificationDate}</p>
<p><strong>页数:</strong>${documentInfo.value.pageCount}</p>
<p><strong>文件大小:</strong>${documentInfo.value.fileSize}</p>
</div>`,
"文档信息",
{
dangerouslyUseHTMLString: true,
confirmButtonText: "确定",
}
);
}
};
// 初始化 PDF
const initPDF = async () => {
try {
loading.value = true;
await nextTick();
// 确保容器存在且样式满足要求
if (!pagesContainer.value || !viewerRef.value) {
await nextTick();
}
if (pagesContainer.value) {
const c = pagesContainer.value;
const style = c.style;
if (!style.position || style.position !== "absolute") {
style.position = "absolute";
style.top = "0";
style.right = "0";
style.bottom = "0";
style.left = "0";
}
}
// 初始化 viewer
const eventBus = new EventBus();
const linkService = new PDFLinkService({ eventBus });
const findController = new PDFFindController({ eventBus, linkService });
eventBusRef.value = eventBus;
linkServiceRef.value = linkService;
findControllerRef.value = findController;
const pdfViewer = new PDFViewer({
container: pagesContainer.value,
viewer: viewerRef.value,
eventBus,
linkService,
findController,
textLayerMode: showTextLayer.value ? 2 : 0,
annotationMode: showAnnotations.value ? 2 : 0,
useOnlyCssZoom: true,
});
pdfViewerRef.value = pdfViewer;
linkService.setViewer(pdfViewer);
eventBus.on("pagesinit", () => {
// 初始化缩放并跳首页
pdfViewer.currentScaleValue = "page-width";
scale.value = pdfViewer.currentScale;
baseScaleRef.value = pdfViewer.currentScale;
// 设置默认缩放比例为100%
const defaultScale = 1.0;
pdfViewer.currentScale = defaultScale;
scale.value = defaultScale;
currentScaleIndex.value = 2; // 100%对应的索引
pdfViewer.currentPageNumber = 1;
});
eventBus.on("pagechanging", (evt) => {
currentPageNumber.value = evt.pageNumber || 1;
});
// 查找相关事件(统计与当前索引)
eventBus.on("updatefindmatchescount", (evt) => {
const matchesCount = evt?.matchesCount || {};
if (typeof matchesCount.total === "number") {
totalMatches.value = matchesCount.total;
}
if (typeof matchesCount.current === "number") {
currentSearchIndex.value = matchesCount.current;
}
});
eventBus.on("updatefindcontrolstate", (evt) => {
// 该事件用于状态更新,部分版本不提供 matchIdx;
// current/total 已在 updatefindmatchescount 中更新,无需处理。
});
const loadingTask = pdfjsLib.getDocument({
url: props.src,
cMapUrl: "/cmaps/",
cMapPacked: true,
});
const doc = await loadingTask.promise;
pdfDoc.value = doc;
totalPages.value = doc.numPages;
// 立即绑定文档到主 viewer
pdfViewer.setDocument(doc);
linkService.setDocument(doc);
isViewerDocumentBound.value = true;
// 先结束 loading,让缩略图区域渲染出 canvas,再绘制缩略图
loading.value = false;
await nextTick();
await renderThumbnails();
// 获取文档信息和大纲
await getDocumentInfo();
await getDocumentOutline();
// 检测文档是否包含文本层和注释
await detectDocumentFeatures();
} catch (error) {
loading.value = false;
ElMessage.error("PDF加载失败");
console.error("PDF加载失败:", error);
}
};
// 检测文档特性
const detectDocumentFeatures = async () => {
if (!pdfDoc.value) return;
try {
// 检测是否有文本内容(通过第一页)
const firstPage = await pdfDoc.value.getPage(1);
const textContent = await firstPage.getTextContent();
hasTextContent.value = textContent && textContent.items && textContent.items.length > 0;
// 检测是否有注释
hasAnnotations.value = pdfDoc.value.annotationStorage && pdfDoc.value.annotationStorage.size > 0;
console.log('PDF文档特性检测:', {
hasTextContent: hasTextContent.value,
hasAnnotations: hasAnnotations.value,
textItemsCount: hasTextContent.value ? textContent.items.length : 0,
annotationCount: hasAnnotations.value ? pdfDoc.value.annotationStorage.size : 0
});
// 如果文档没有文本内容,禁用文本层切换并提示
if (!hasTextContent.value) {
showTextLayer.value = false;
ElMessage.info('此PDF文档不包含可选择的文本内容,文本层功能已禁用');
}
// 如果文档没有注释,禁用注释切换并提示
if (!hasAnnotations.value) {
showAnnotations.value = false;
ElMessage.info('此PDF文档不包含注释内容,注释功能已禁用');
}
} catch (error) {
console.error('检测文档特性失败:', error);
// 如果检测失败,默认禁用这些功能
hasTextContent.value = false;
hasAnnotations.value = false;
showTextLayer.value = false;
showAnnotations.value = false;
}
};
// 渲染缩略图
const renderThumbnails = async () => {
if (!pdfDoc.value) return;
for (let i = 1; i <= totalPages.value; i++) {
const page = await pdfDoc.value.getPage(i);
const viewport = page.getViewport({ scale: 1 });
const targetWidth = 78;
const scale = targetWidth / viewport.width;
const thumbnailViewport = page.getViewport({ scale });
const canvas = thumbnailRefs.value[i];
if (!canvas) continue;
const context = canvas.getContext("2d", { willReadFrequently: true });
canvas.width = Math.round(thumbnailViewport.width);
canvas.height = Math.round(thumbnailViewport.height);
await page.render({
canvasContext: context,
viewport: thumbnailViewport,
}).promise;
}
};
// 设置缩略图引用
const setThumbnailRef = (el, pageNum) => {
if (el) {
thumbnailRefs.value[pageNum] = el;
}
};
// 点击缩略图仅进行跳页
const scrollToPage = async (pageNum) => {
if (pageNum < 1 || pageNum > totalPages.value) return;
if (!pdfViewerRef.value) return;
pdfViewerRef.value.currentPageNumber = pageNum;
};
// 滚动事件由 PDFViewer 处理
const zoomIn = async () => {
if (!pdfViewerRef.value) return;
const currentIndex = currentScaleIndex.value;
if (currentIndex < presetScales.length - 1) {
const nextIndex = currentIndex + 1;
const nextScale = presetScales[nextIndex];
pdfViewerRef.value.currentScale = nextScale;
scale.value = nextScale;
currentScaleIndex.value = nextIndex;
} else {
ElMessage.warning("已到最大缩放比例");
}
};
const zoomOut = async () => {
if (!pdfViewerRef.value) return;
const currentIndex = currentScaleIndex.value;
if (currentIndex > 0) {
const prevIndex = currentIndex - 1;
const prevScale = presetScales[prevIndex];
pdfViewerRef.value.currentScale = prevScale;
scale.value = prevScale;
currentScaleIndex.value = prevIndex;
} else {
ElMessage.warning("已到最小缩放比例");
}
};
// 全屏切换
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
container.value.requestFullscreen().catch((err) => {
console.error("全屏失败:", err);
});
isFullscreen.value = true;
} else {
document.exitFullscreen();
isFullscreen.value = false;
}
};
// 监听缩放同步
watch(scale, (val) => {
if (pdfViewerRef.value && pdfViewerRef.value.currentScale !== val) {
pdfViewerRef.value.currentScale = val;
}
});
// 关键字搜索
const searchText = ref("");
const totalMatches = ref(0);
const currentSearchIndex = ref(0);
const isSearching = ref(false);
// 搜索功能
const searchInPDF = async () => {
if (!findControllerRef.value) return;
const query = searchText.value.trim();
if (!query) {
// 清除
eventBusRef.value?.dispatch("find", {
type: "find",
query: "",
caseSensitive: false,
entireWord: false,
highlightAll: false,
findPrevious: undefined,
});
totalMatches.value = 0;
currentSearchIndex.value = 0;
return;
}
isSearching.value = true;
try {
// 首次查找
eventBusRef.value?.dispatch("find", {
type: "find",
query,
caseSensitive: false,
entireWord: false,
highlightAll: true,
findPrevious: undefined,
});
} catch (error) {
console.error("搜索失败:", error);
ElMessage.error("搜索失败");
} finally {
isSearching.value = false;
}
};
const nextSearchResult = () => {
if (!eventBusRef.value || !searchText.value.trim()) return;
eventBusRef.value.dispatch("find", {
type: "again",
query: searchText.value.trim(),
caseSensitive: false,
entireWord: false,
highlightAll: true,
findPrevious: false,
});
if (totalMatches.value > 0) {
const cur = currentSearchIndex.value || 0;
currentSearchIndex.value = (cur % totalMatches.value) + 1;
}
};
// 导航到上一个结果
const prevSearchResult = () => {
if (!eventBusRef.value || !searchText.value.trim()) return;
eventBusRef.value.dispatch("find", {
type: "again",
query: searchText.value.trim(),
caseSensitive: false,
entireWord: false,
highlightAll: true,
findPrevious: true,
});
if (totalMatches.value > 0) {
const cur = currentSearchIndex.value || 1;
currentSearchIndex.value =
((cur - 2 + totalMatches.value) % totalMatches.value) + 1;
}
};
// 清除搜索
const clearSearch = () => {
searchText.value = "";
totalMatches.value = 0;
currentSearchIndex.value = 0;
eventBusRef.value?.dispatch("find", {
type: "find",
query: "",
caseSensitive: false,
entireWord: false,
highlightAll: false,
findPrevious: undefined,
});
};
const handleInputUpdate = (val) => {
if (val === "") {
clearSearch();
}
};
onMounted(() => {
initPDF();
// 监听查找结果计数与当前选中
eventBusRef.value?.on?.("updatefindmatchescount", (evt) => {
if (evt?.matchesCount?.total != null) {
totalMatches.value = evt.matchesCount.total;
}
});
eventBusRef.value?.on?.("updatefindcontrolstate", (evt) => {
if (typeof evt?.matchIdx === "number") {
currentSearchIndex.value = evt.matchIdx + 1;
}
});
});
onUnmounted(() => {
if (pdfDoc.value) {
pdfDoc.value.destroy();
}
});
const emit = defineEmits(["close"]);
const close = () => {
emit("close");
};
watch(showOutline, async (val) => {
if (val === false) {
// 切回缩略图时,若画布为空,重绘一次
await nextTick();
// 简单检查第1页canvas是否有大小,没有则重绘全部
const first = thumbnailRefs.value[1];
if (!first || !first.width || !first.height) {
await renderThumbnails();
}
}
});
</script>
<template>
<div class="pdf-viewer-container" ref="container">
<!-- 顶部工具栏 -->
<div class="pdf-toolbar flex-center">
<el-tooltip v-if="!isThumbnailsShow" placement="top">
<template #content>展开目录</template>
<div class="toolbar-item">
<el-icon :size="16" @click="handleThumbnailsShow()">
<ArrowRight />
</el-icon>
</div>
</el-tooltip>
<el-tooltip v-if="isThumbnailsShow" placement="top">
<template #content>收起目录</template>
<div class="toolbar-item">
<el-icon
:size="16"
style="cursor: pointer"
@click="handleThumbnailsShow()"
>
<ArrowLeft />
</el-icon>
</div>
</el-tooltip>
<div class="page-info">{{ currentVisiblePage }} / {{ totalPages }}</div>
<el-tooltip placement="top">
<template #content>{{
isFullscreen ? "取消全屏" : "全屏展示文档"
}}</template>
<div class="toolbar-item full-screen">
<el-icon :size="16" @click="toggleFullscreen">
<Expand v-if="!isFullscreen" />
<CloseBold v-else />
</el-icon>
</div>
</el-tooltip>
<el-tooltip placement="top">
<template #content>放大文档 </template>
<div class="toolbar-item">
<el-icon :size="16" @click="zoomIn">
<Plus />
</el-icon>
</div>
</el-tooltip>
<!-- 缩放比例显示和选择 -->
<div class="scale-display">
<span class="current-scale">{{ currentScalePercent }}%</span>
<el-dropdown @command="setPresetScale" trigger="click">
<span class="scale-dropdown">
<el-icon :size="12">
<ArrowDown />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(scaleValue, index) in presetScales"
:key="index"
:command="scaleValue"
:class="{ 'active-scale': index === currentScaleIndex }"
>
{{ Math.round(scaleValue * 100) }}%
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-tooltip placement="top">
<template #content>缩小文档 </template>
<div class="toolbar-item zoom-out">
<el-icon :size="16" @click="zoomOut">
<Minus />
</el-icon>
</div>
</el-tooltip>
<!-- 页面旋转功能 -->
<el-tooltip placement="top">
<template #content>向左旋转</template>
<div class="toolbar-item">
<el-icon :size="16" @click="rotatePages('left')">
<Refresh />
</el-icon>
</div>
</el-tooltip>
<el-tooltip placement="top">
<template #content>向右旋转</template>
<div class="toolbar-item">
<el-icon :size="16" @click="rotatePages('right')">
<Refresh />
</el-icon>
</div>
</el-tooltip>
<!-- 显示控制功能 -->
<el-tooltip placement="top">
<template #content>{{
showAnnotations ? "隐藏注释" : "显示注释"
}}</template>
<div class="toolbar-item" :class="{ 'disabled': !showAnnotations && !hasAnnotations }">
<el-icon :size="16" @click="toggleAnnotations">
<InfoFilled />
</el-icon>
</div>
</el-tooltip>
<el-tooltip placement="top">
<template #content>{{
showTextLayer ? "隐藏文本层" : "显示文本层"
}}</template>
<div class="toolbar-item" :class="{ 'disabled': !showTextLayer && !hasTextContent }">
<el-icon :size="16" @click="toggleTextLayer">
<View v-if="showTextLayer" />
<Hide v-else />
</el-icon>
</div>
</el-tooltip>
<!-- 文档信息 -->
<el-tooltip placement="top">
<template #content>文档信息</template>
<div class="toolbar-item">
<el-icon :size="16" @click="showDocumentInfo">
<InfoFilled />
</el-icon>
</div>
</el-tooltip>
<!-- 打印和下载功能 -->
<el-tooltip placement="top">
<template #content>打印文档</template>
<div class="toolbar-item">
<el-icon
:size="16"
@click="printPDF"
:class="{ 'is-loading': isPrinting }"
>
<Printer />
</el-icon>
</div>
</el-tooltip>
<el-tooltip placement="top">
<template #content>下载文档</template>
<div class="toolbar-item">
<el-icon
:size="16"
@click="downloadPDF"
:class="{ 'is-loading': isDownloading }"
>
<Download />
</el-icon>
</div>
</el-tooltip>
<div class="search-container">
<div class="search-input-box">
<div class="search-input-wrapper">
<el-input
v-model="searchText"
placeholder="输入搜索文本"
class="search-input"
clearable
@keyup.enter="searchInPDF"
@input="handleInputUpdate"
/>
</div>
<span class="search-count" v-if="totalMatches > 0">
{{ currentSearchIndex }} / {{ totalMatches }}
</span>
</div>
<div class="search-controls" v-if="totalMatches > 0">
<el-button
size="small"
@click="prevSearchResult"
:disabled="totalMatches === 0"
>
<el-icon :size="14">
<ArrowUp />
</el-icon>
</el-button>
<el-button
size="small"
@click="nextSearchResult"
:disabled="totalMatches === 0"
>
<el-icon :size="14">
<ArrowDown />
</el-icon>
</el-button>
</div>
<el-button
type="primary"
size="small"
@click="searchInPDF"
:loading="isSearching"
class="search-button"
>
搜索
</el-button>
</div>
<el-button type="text" size="small" class="close-button" @click="close">
<el-icon :size="16">
<Close />
</el-icon>
</el-button>
</div>
<div class="pdf-content">
<el-loading
v-if="loading"
:indicator="indicator"
text="Loading..."
class="spin-container flex-column flex-center"
/>
<!-- 左侧侧边栏 -->
<div
v-show="!loading"
class="pdf-sidebar"
:class="{ 'pdf-sidebar-visible': isThumbnailsShow }"
>
<!-- 侧边栏切换按钮 -->
<div class="sidebar-tabs">
<div
class="tab-item"
:class="{ active: !showOutline }"
@click="showOutline = false"
>
缩略图
</div>
<div
class="tab-item"
:class="{ active: showOutline }"
@click="showOutline = true"
>
大纲
</div>
</div>
<!-- 缩略图内容 -->
<div v-show="!showOutline" class="thumbnails-content">
<div
v-for="pageNum in totalPages"
:key="pageNum"
class="thumbnail"
:class="{ active: pageNum === currentVisiblePage }"
@click="scrollToPage(pageNum)"
>
<canvas
:ref="(el) => setThumbnailRef(el, pageNum)"
style="height: 110px"
/>
</div>
</div>
<!-- 大纲内容 -->
<div v-show="showOutline" class="outline-content">
<div v-if="outline && outline.length > 0" class="outline-list">
<div
v-for="(item, index) in outline"
:key="index"
class="outline-item"
:class="{ active: currentOutlineItem === item }"
@click="goToOutlineItem(item)"
>
<span class="outline-title">{{ item.title }}</span>
<span class="outline-page" v-if="item.dest"
>第{{ item.dest[0].num }}页</span
>
</div>
</div>
<div v-else class="no-outline">
<p>此文档没有大纲</p>
</div>
</div>
</div>
<!-- 右侧主内容 - 多页容器 -->
<div class="pdf-pages-wrapper">
<div
class="pdf-pages-container"
:class="{
'with-thumbs': isThumbnailsShow,
}"
ref="pagesContainer"
:style="{
position: 'absolute',
top: '0',
right: '0',
bottom: '0',
left: '0',
'--page-gap': pageGap + 'px',
}"
>
<div class="pdfViewer" ref="viewerRef"></div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.pdf-viewer-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
z-index: 10;
background-color: #f0f0f0;
overflow: hidden;
border-left: 1px solid #e2e4e7;
}
.pdf-toolbar {
padding: 10px;
background-color: #fff;
display: flex;
align-items: center;
z-index: 10;
font-size: 12px;
color: #132035;
.toolbar-item {
cursor: pointer;
position: relative;
top: 1px;
&.disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
background: transparent;
}
.el-icon {
color: #c0c4cc;
}
}
}
.toolbar-item:hover {
background: #fafafa;
}
.search-container {
display: flex;
align-items: center;
margin: 0 15px;
.search-input-box {
display: flex;
align-items: center;
}
.search-input-wrapper {
position: relative;
margin-right: 5px;
.search-input {
padding: 4px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
width: 150px;
font-size: 12px;
&:focus {
outline: none;
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
}
}
.search-count {
position: relative;
top: 1px;
font-size: 12px;
color: #999;
pointer-events: none;
}
.search-controls {
display: flex;
margin: 0 5px;
button {
margin: 0 2px;
padding: 0 8px;
}
}
.search-button {
padding: 0 12px;
}
}
}
.page-info {
margin: 0 10px;
}
.full-screen {
margin-left: 20px;
margin-right: 20px;
&::before {
content: " ";
display: inline-block;
width: 1px;
height: 16px;
background: #e2e4e7;
position: relative;
left: -10px;
top: 2px;
}
&::after {
content: " ";
display: inline-block;
width: 1px;
height: 16px;
background: #e2e4e7;
position: relative;
left: 10px;
top: 2px;
}
}
.zoom-out {
margin-left: 10px;
}
.scale-display {
display: flex;
align-items: center;
margin: 0 15px;
padding: 4px 8px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
border: 1px solid var(--el-border-color-light);
.current-scale {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-right: 8px;
min-width: 40px;
text-align: center;
}
.scale-dropdown {
cursor: pointer;
padding: 2px 4px;
border-radius: 2px;
display: flex;
align-items: center;
&:hover {
background-color: var(--el-fill-color);
}
.el-icon {
color: var(--el-text-color-regular);
}
}
}
.close-button {
margin-left: auto;
padding: 4px;
border-radius: 4px;
&:hover {
background-color: var(--el-fill-color-light);
}
.el-icon {
color: #4e5969;
}
}
.pdf-content {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
.spin-container {
height: 100%;
width: 100%;
}
}
.pdf-pages-wrapper {
position: relative;
flex: 1;
overflow: hidden;
display: flex;
min-height: 0; /* 允许内部绝对定位容器撑满 */
}
.pdf-sidebar {
width: 0;
overflow-y: auto;
scrollbar-width: none;
background-color: #e0e0e0;
transform: translateX(0);
opacity: 0;
pointer-events: none;
border-right: 0;
transition: width 0.3s ease-in-out, opacity 0.3s ease-in-out;
&.pdf-sidebar-visible {
width: 106px;
opacity: 1;
pointer-events: auto;
padding: 14px;
}
&:not(.pdf-sidebar-visible) {
padding: 0;
}
.sidebar-tabs {
display: none;
}
.thumbnails-content {
padding: 0; /* 已在可见时由容器统一加 padding */
}
.outline-content {
padding: 0 0 14px 0; /* 若后续启用大纲,这里保留最小内边距 */
.outline-list {
.outline-item {
padding: 8px 12px;
margin-bottom: 4px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s ease;
&:hover {
background-color: #e8f4fd;
}
&.active {
background-color: var(--el-color-primary);
color: white;
}
.outline-title {
display: block;
font-size: 12px;
margin-bottom: 2px;
}
.outline-page {
display: block;
font-size: 10px;
opacity: 0.8;
}
}
}
.no-outline {
text-align: center;
color: #999;
font-size: 12px;
padding: 20px 0;
p {
margin: 0;
}
}
}
}
.thumbnail {
margin-bottom: 10px;
cursor: pointer;
border: 2px solid transparent;
}
.thumbnail.active {
border-color: var(--el-color-primary);
}
.thumbnail canvas {
width: 100%;
height: auto;
display: block;
}
.pdf-pages-container {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow-y: auto;
overflow-x: auto;
padding: 0 40px;
transition: padding-left 0.3s ease-in-out;
}
.pdf-page-wrapper {
position: relative;
display: flex;
justify-content: center;
background-color: white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
/* 让 pdf.js 内置 viewer 居中显示每页 */
.pdfViewer {
display: block;
}
:deep(.pdfViewer .page) {
margin: 0 auto var(--page-gap, 20px) auto;
border: 0;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
:deep(.pdfViewer .page .textLayer) {
transform-origin: 0 0 !important;
}
:deep(.pdfViewer .page .textLayer .highlight) {
padding: 0 2.5px !important;
}
.is-loading {
animation: rotating 2s linear infinite;
}
@keyframes rotating {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 缩放比例下拉菜单样式 */
:deep(.active-scale) {
color: var(--el-color-primary) !important;
font-weight: 500;
}
/* 工具栏按钮样式优化 */
.toolbar-item {
.el-icon {
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
&.is-loading {
animation: rotating 2s linear infinite;
}
}
}
/* 功能按钮组样式 */
.toolbar-item + .toolbar-item {
margin-left: 8px;
}
</style>