用 vue3+ts+pdfjs 做 PDF 预览?这组件难道不值得夸?🚀

前言

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>
相关推荐
跟橙姐学代码1 分钟前
Python 装饰器超详细讲解:从“看不懂”到“会使用”,一篇吃透
前端·python·ipython
pany20 分钟前
体验一款编程友好的显示器
前端·后端·程序员
Zuckjet25 分钟前
从零到百万:Notion如何用CRDT征服离线协作的终极挑战?
前端
GISBox30 分钟前
GISBox支持WMS协议的技术突破
vue.js·json·gis
ikonan30 分钟前
译:Chrome DevTools 实用技巧和窍门清单
前端·javascript
Juchecar30 分钟前
Vue3 v-if、v-show、v-for 详解及示例
前端·vue.js
ccc101834 分钟前
通过学长的分享,我学到了
前端
编辑胜编程34 分钟前
记录MCP开发表单
前端
可爱生存报告34 分钟前
vue3 vite quill-image-resize-module打包报错 Cannot set properties of undefined
前端·vite
__lll_34 分钟前
前端性能优化:Vue + Vite 全链路性能提升与打包体积压缩指南
前端·性能优化