前言

  • 需求:在项目中显示pdf文件并根据page和position显示高亮,首次进入页面时默认滚动到第一高亮页。样式使用pdf默认样式👉

  • 前提:react16+webpack

  • 实现方式:pdfjs-dist 或 react-pdf

*版本:*pdfjs-dist@2.5.207

使用原因:@2.5.207 版本为.js文件,未使用mjs,不需要配置webpack,可兼容chrome66及以上版本。

安装pdfjs-dist依赖,通过引用方式使用,需要引入worker加载解析pdf文件 👉pdfjsLib.GlobalWorkerOptions.workerSrc =''; 部署可能会出现worker跨域的问题,需要将worker放在与pdfjs同目录下防止跨域,所以最终选择了直接放文件放在public下通过嵌入iframe显示。

最终实现方式:

  1. 将pdfjs-dist@2.5.207 文件放在public文件下,通过iframe引入pdfjs文件下的viewer.html实现默认浏览样式;

  2. 通过获取textLayer元素,并根据页数和点位向父级dom添加高亮标签元素实现高亮;

  3. 获取第一个高亮数据并获取对应元素,通过scrollIntoView定位滚动到当前页。

实现代码

## 全部代码

import { useEffect, useRef } from 'react';

/**
 * @param {string} pdfUrl pdf文件路径
 * @param {Array} highlightData 高亮数据【page/position[x,y,width,hight]】
 * @returns 
 */
const PDFViewer = ({ pdfUrl = '', highlightData = [] }) => {
 
  // refs
  const iframeRef = useRef(null);

  // 获取源路径 - 根据自己项目进行优化
  const getTargetOrigin = (type = 0) => {
    let locationOrigin = location.origin;
    let pdfdist = `/pdfjs-2.5.207-dist/web/viewer.html?file=`;
    let path = `/packages${pdfdist}`;
    let localPath = pdfdist;

    if (locationOrigin.includes('localhost')) {
      return type ? '*' : localPath;
    }
    if (locationOrigin.includes('xxx.20.xx.xx')) {
      return type ? locationOrigin + ':xxxx' : locationOrigin + ':xxxx' + path;
    }
    if (locationOrigin.includes('xx.xxx.15.xxx')) {
      return type ? locationOrigin + '/xxxx' : locationOrigin + '/xxxx' + path;
    }
    if (
      locationOrigin.includes('xx.xxx') ||
      locationOrigin.includes('xx.xxxx.xx')
    ) {
      return type ? locationOrigin + '/xxxx' : locationOrigin + '/xxxx' + path;
    }
  };

  // 初始默认滚动到第一个高亮页
  const getFirstHighlightPage = () => {
    let key = 'page';
    let sortHighlightData = highlightData?.sort((a, b) => a[key] - b[key]);
    let firstPage = sortHighlightData?.[0]?.[key]; // 获取第一个高亮页
    return firstPage;
  };

   /**
   * 发送高亮指令
   * @param {number|string} page 
   * @param {boolean} firstPage 
   */
  const highlightArea = (page, firstPage = true) => {
    let iframeWindow = iframeRef.current.contentWindow;
    let lightData = [];
    let key = 'page';
    
    // 筛选出符合当前页的高亮位置
    highlightData.filter(item => {
      if (item[key] == page) {
        lightData.push(item);
      }
      return item[key] == page;
    });

    try {
      let targetOrigin = getTargetOrigin(1);

      // 发送消息到 iframe 进行高亮
      if (lightData) {
        iframeWindow.postMessage(
          {
            action: 'highlightArea',
            highlightData: lightData,
            firstPage,
          },
          targetOrigin,
        );
      }
    } catch (error) {
      console.error('Error sending message:', error);
    }
  };

  // 初始默认滚动到第一个高亮位置
  const initScrollToFirstHighlight = (iframeDocument, isFirstPage) => {
    if (isFirstPage) {
      let firstPage = getFirstHighlightPage();
      const targetPage = iframeDocument.querySelector(
        `.page:nth-child(${firstPage})`,
      );
      if (targetPage) {
        targetPage.scrollIntoView({ behavior: 'smooth' });
      }
    }
  };

// 高两块显示部分
  const highlightAreaFn = event => {
    const { highlightData, firstPage } = event.data;
    
    // 无高亮内容直接返回
    let hlLength = highlightData.length;
    if (hlLength === 0) {
      return;
    }

    let iframeDocument = iframeRef.current.contentDocument;

    if (iframeDocument) {
      initScrollToFirstHighlight(iframeDocument, firstPage);

      let key = 'page';
      let pageNum = highlightData?.[0]?.[key];
      let pageDomToNumberDom = iframeDocument.querySelector(
        `.page:nth-child(${pageNum})`,
      );

      let textLayerDom = pageDomToNumberDom?.querySelector('.textLayer');

      if (textLayerDom) {
        const tagSpan = textLayerDom.querySelector('#myLightSpan');
        if (!tagSpan) {
          for (let index = 0; index < highlightData.length; index++) {
            const element = highlightData[index];
            let key = 'textBbox';
            let areaPosition = element?.[key];

            let position =
              typeof areaPosition === 'string'
                ? JSON.parse(areaPosition)
                : areaPosition;
            const [x, y, height, width] = position;

            // 创建一个新的 span 来覆盖高亮区域
            const highlight = document.createElement('span');
            highlight.setAttribute('id', 'myLightSpan');
            highlight.style.position = 'absolute';
            highlight.style.left = `${x * 100}%`;
            highlight.style.top = `${y * 100}%`;
            highlight.style.width = `${width * 100}%`;
            highlight.style.height = `${height * 100}%`;
            highlight.style.backgroundColor = 'rgb(255, 255, 0)'; // 高亮颜色
            highlight.style.zIndex = -1;  // 设置层级低于文字,鼠标选中文字可选
            // 将高亮区域添加到文本层
            textLayerDom?.appendChild(highlight);
          }
        }
      }
    }
  };

  // 监听pdf页面宽高变化
  const resizeObserver = new ResizeObserver(entries => {
    let iframeDocument = iframeRef.current.contentDocument;
    let nearestPage = [];
    for (let entry of entries) {
      const { width, height } = entry.contentRect;
      const pageNum = entry.target.getAttribute('data-page-number');

      let pageDomToNumberDom = iframeDocument.querySelector(
        `.page:nth-child(${pageNum})`,
      );
      let textLayerDom = pageDomToNumberDom.querySelector('.textLayer');

      if (textLayerDom) {
        nearestPage?.push(pageNum);
      }
    }

    nearestPage?.forEach(pageNum => {
      highlightArea(+pageNum, false);
    });
  });

  // 监听页面滚动变化
  const observer = new IntersectionObserver(
    entries => {
      entries.forEach(entry => {
        const pageNum = entry.target.getAttribute('data-page-number');
        if (entry.isIntersecting) {
          // 页面进入视口时,向iframe发送高亮信息
          highlightArea(pageNum, false);
        }
      });
    },
    {
      threshold: 0.2, // 页面超过50%进入视口时触发
    },
  );

  //

  useEffect(() => {
    let iframeWindow = iframeRef.current.contentWindow;

    // 添加事件监听器
    iframeWindow.onload = function() {
      iframeWindow.addEventListener('message', highlightAreaFn);
    };
    iframeWindow.onload();

    setTimeout(() => {
      let iframeContent = iframeRef.current.contentDocument;

      let viewDom = iframeContent.querySelector('#viewer');

      const pages = iframeContent.querySelectorAll('.page');

      pages.forEach(page => {
        observer.observe(page);

        // 开始监听目标元素的尺寸变化
        resizeObserver.observe(page);
      });

      // 初始化时,默认滚动到第一高亮页
      let firstPage = getFirstHighlightPage();

      highlightArea(firstPage, true);
    }, 1000);

    return () => {
      pages.forEach(page => {
        observer.unobserve(page);
        resizeObserver.unobserve(page);
      });
    };
  }, []);

  return (
    <iframe
      ref={iframeRef}
      src={`${getTargetOrigin()}${encodeURIComponent(pdfUrl)}`}
      width="100%"
      height="100%"
      style={{ border: 'none' }}
    ></iframe>
  );
};
export default PDFViewer;

## 通过iframe容器显示pdf文件

通过iframe容器使用pdfjs-web中的viewer.html显示pdf文件 src={`/pdfjs-3.11.174-dist/web/viewer.html?file=${pdfUrl}} 注意: 因为pdfjs-dist文件放在public下,打包部署线上后的pdfjs位置可能会发生变化,找到dist包确认viewer.html位置。必须与当前同源 即ip+viewer.html路径+ ?file= + pdf文件路径

<iframe 
    ref={iframeRef} 
    src={`${getTargetOrigin()}${encodeURIComponent(pdfUrl)}`} 
    width="100%" 
    height="100%" 
    style={{ border: 'none' }} 
></iframe>

## 在父页面和 iframe 中使用 postMessage 来发送和接收数据

1.同源 iframe同源策略要求父页面和 iframe 页面来自相同的域、协议和端口。如果父页面和 iframe 页面同源,你可以直接通过 JavaScript 获取 iframe 的内容。

2.如果父页面和 iframe 页面来自不同的源(即跨域),你不能直接访问 iframe 中的内容,因为这会违反同源策略。跨域访问受到浏览器的严格限制。

  • 同源 iframe:可以直接通过 iframe.contentDocument 或 iframe.contentWindow.document 访问 iframe 的内容。

  • 跨域 iframe:由于同源策略限制,无法直接访问 iframe 内容。可以使用 postMessage API 进行跨域通信,或通过服务器设置 CORS 来访问资源。

已经配置同源可以直接通过document.querySelector('iframe')获取dom,我直接使用了postMessage方式。

useEffect(() => { 
    let iframeWindow = iframeRef.current.contentWindow; 
    // 添加事件监听器 - 接收信息
    iframeWindow.onload = function() { 
    iframeWindow.addEventListener('message', highlightAreaFn); 
    }; 
    iframeWindow.onload();
},[])

highlightAreaFn为监听到postMessage发送的数据后执行,向textLayer Dom添加高亮span; highlightArea发送高亮指令,参数为高亮内容和是否为首个高亮;初始挂载时发送一次进行渲染首高亮页并滚动到当前页;因为pdfjs性能优化,仅加载附近5页,在监听视口当前页面时也需要发送一次视口页高亮;由于2.5.207 版本每次缩放会重新渲染pdf页,导致初始渲染的高亮也被销毁,所以需要在监听pdf页宽放发生变化时,发送一次高亮渲染。

  // 发送高亮指令
  const highlightArea = (page, firstPage = true) => {
    let iframeWindow = iframeRef.current.contentWindow;
    let lightData = [];
    let key = 'page';
    
    // 获取同page的高亮
    highlightData.filter(item => {
      if (item[key] == page) {
        lightData.push(item);
      }
      return item[key] == page;
    });
    
    try {
      let targetOrigin = getTargetOrigin(1);

      // 发送消息到 iframe 进行高亮
      if (lightData) {
        iframeWindow.postMessage(
          {
            action: 'highlightArea',
            highlightData: lightData,
            firstPage,
          },
          targetOrigin,
        );
      }
    } catch (error) {
      console.error('Error sending message:', error);
    }
  };

## highlightAreaFn 接收高亮数据进行显示

  const highlightAreaFn = event => {
    const { highlightData, firstPage } = event.data;

    let hlLength = highlightData.length;
    if (hlLength === 0) {
      return;
    }

    let iframeDocument = iframeRef.current.contentDocument;
    if (iframeDocument) {
    // 首次自动滚动到第一个高亮页
      initScrollToFirstHighlight(iframeDocument, firstPage);

      let key = 'page';
      let pageNum = highlightData?.[0]?.[key];
      console.log('pageNum', pageNum);

      let pageDomToNumberDom = iframeDocument.querySelector(
        `.page:nth-child(${pageNum})`,
      );

      let textLayerDom = pageDomToNumberDom?.querySelector('.textLayer');

      initTextLayerObserver(textLayerDom);

      if (textLayerDom) {
        const tagSpan = textLayerDom.querySelector('#myLightSpan');
        if (!tagSpan) {
          for (let index = 0; index < highlightData.length; index++) {
            const element = highlightData[index];
            let key = 'textBbox';
            let areaPosition = element?.[key];

            let position =
              typeof areaPosition === 'string'
                ? JSON.parse(areaPosition)
                : areaPosition;
            const [x, y, height, width] = position;

            // 创建一个新的 span 来覆盖高亮区域
            const highlight = document.createElement('span');
            highlight.setAttribute('id', 'myLightSpan');
            highlight.style.position = 'absolute';
            highlight.style.left = `${x * 100}%`;
            highlight.style.top = `${y * 100}%`;
            highlight.style.width = `${width * 100}%`;
            highlight.style.height = `${height * 100}%`;
            highlight.style.backgroundColor = 'rgb(255, 255, 0)'; // 高亮颜色
            highlight.style.zIndex = -1;
            console.log('highlight-高两块', highlight, 'position', position);

            // 将高亮区域添加到文本层
            textLayerDom?.appendChild(highlight);
          }
        }
      }
    }

  };

## 页面滚动&缩放时渲染高亮

pdfjs的性能优化不能渲染所有页,仅渲染视口上下一共五页,随意需要判断当前视口所在pdf文件页来添加高亮。 pdf页面缩放时,2.5.207 版本会销毁当前页面,重新根据缩放渲染页面,导致放在dom中的高亮也被销毁,故需要重新渲染。

  // 监听页面滚动变化
  const observer = new IntersectionObserver(
    entries => {
      entries.forEach(entry => {
        const pageNum = entry.target.getAttribute('data-page-number');
        if (entry.isIntersecting) {
          // 页面进入视口时,向iframe发送高亮信息
          highlightArea(pageNum, false);
        }
      });
    },
    {
      threshold: 0.2, // 页面超过50%进入视口时触发
    },
  );

  // 监听pdf页面宽高变化
  const resizeObserver = new ResizeObserver(entries => {
    let iframeDocument = iframeRef.current.contentDocument;
    let nearestPage = [];
    for (let entry of entries) {
      const { width, height } = entry.contentRect;
      const pageNum = entry.target.getAttribute('data-page-number');
      let pageDomToNumberDom = iframeDocument.querySelector(
        `.page:nth-child(${pageNum})`,
      );
      let textLayerDom = pageDomToNumberDom?.querySelector('.textLayer');
      // 由于pdfjs仅渲染最近几页,可以根据有无page的dom来判断当前视口页数进行渲染高亮。
      if (textLayerDom) {
        nearestPage?.push(pageNum);
      }
    }
    nearestPage?.forEach(pageNum => {
      highlightArea(+pageNum, false);
    });
  });

 useEffect(() => {
    let iframeContent = iframeRef.current.contentDocument;
    let viewDom = iframeContent.querySelector('#viewer');
    const pages = iframeContent.querySelectorAll('.page');
     // 向每一页添加监听
     pages.forEach(page => {
        observer.observe(page);
        // 开始监听目标元素的尺寸变化 
        resizeObserver.observe(page);
      });
     return{
        // 销毁
        pages.forEach(page => {
        observer.unobserve(page);
        resizeObserver.unobserve(page);
      });
     }
 },[])

最终效果

image.png

遇到问题

  1. viewer跨域和iframe源跨域

解决方式:

  • 删除viewer.js中的跨域判断代码

  • iframe的src&postMessage的第二参数&当前地址端口号同源

  1. pdfjs-dist 3.11.174版本不兼容chrome91版本,无法识别es6代码

解决方式:

  • 1.降低版本到2.5.207

  • 2.将pdfjs-dist放在static,配置webpack打包编译为es5(实验未通过)

  1. 初始打开pdf页面,默认滚动到第一个高亮页,缩略图出现时会使滚动位置不准确

原因:缩略图的滚动和高亮自动滚动都是使用scrollIntoView,当同页面元素使用多个scrollIntoView可能会导致滚动位置不准确。

解决方式:

  • 1.localStorage.removeItem('pdfjs.history');移除pdfjs的本地存储,默认每次都不打开缩略图

  • 2.将scrollIntoView更换成scrollTo

引入pdfjs方式(不完全仅参考)

单页&缩放&未用viewer

import * as pdfjsLib from 'pdfjs-dist/webpack';
import { useEffect, useRef, useState } from 'react';
import styles from '../index.module.less';

// 通过 CDN 引入 worker
pdfjsLib.GlobalWorkerOptions.workerSrc =
  'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.worker.min.js';

interface CanvasItem {
  height: number;
  width: number;
}

let newHighlights = [
  {
    page: 2,
    textContent: 'PDFDEMO',
    textType: 'line',
    textBbox: [90, 753.891, 50, 12],
    // [left,bottom,width,height]
  },
  {
    page: 2,
    textContent: '倒数第三个是如何发达发达地方和',
    textType: 'line',
    textBbox: [90, 566.691, 228, 12],
  },
  {
    page: 4,
    textContent: '突然也就特价公开',
    textType: 'line',
    textBbox: [218.759, 98.69, 108, 12],
  },
];

const PDFDemo = props => {
  const { pdfUrl = '', highlightData = [], type } = props;

  const pdfFile = pdfUrl; // 你的PDF文件地址
  // const pdfFile = require('../pdfFile.pdf').default; // 你的PDF文件地址

  // refs
  const containerRef = useRef<any>(null);
  const pdfCanvasRef = useRef<any>(null);
  const highlightCanvasRef = useRef<any>(null);
  const pageRef = useRef(1);
  // 基础状态
  let pdfDoc: any = null;
  const [currentPdfDoc, setCurrentPdfDoc] = useState<any>();
  const [cpdfCtx, setCpdfCtx] = useState<any>(null);
  const [highlightPdfCtx, setHighlightPdfCtx] = useState<any>(null);
  let [currentPage, setCurrentPage] = useState<number>(1);
  const [totalPages, setTotalPages] = useState(0);
  const [highlights, setHighlights] = useState<any>([...newHighlights]);
  let scale = 1.5;

  // canvas contexts
  let pdfCtx = null;
  let highlightCtx: any = null;

  // 获取第一个高亮page
  const getFirstHighlightPage = () => {
    let key = type === 'liu' ? 'page_id' : 'page';
    let firstPage = highlightData?.sort((a, b) => a[key] - b[key])?.[0]
      ?.page_id;
    setCurrentPage(firstPage || 1);
    return firstPage;
  };

  useEffect(() => {
    pageRef.current = currentPage; // 每次 count 更新时更新 pageRef
  }, [currentPage]);

  // 组件挂载
  useEffect(() => {
    pdfCtx = pdfCanvasRef?.current.getContext('2d');
    highlightCtx = highlightCanvasRef?.current.getContext('2d');
    // fetchHighlights();
    loadPDF();
    // setupTextSelection();
    setCpdfCtx(pdfCtx);
    setHighlightPdfCtx(highlightCtx);
    setupZoomWheel();

    return () => {
      // 组件卸载时清空画布
      highlightCtx.clearRect(
        0,
        0,
        pdfCanvasRef?.current.width,
        pdfCanvasRef?.current.height,
      );

      // 释放Canvas资源
      pdfCanvasRef.current.width = 0;
      pdfCanvasRef.current.height = 0;
    };
  }, []);

  //   监听props变化
  useEffect(() => {
    let newPdfUrl = props.pdfUrl;
    if (newPdfUrl) {
      loadPDF();
    }
  }, [props.pdfUrl, props.highlightData]);

  // PDF 加载方法
  const loadPDF = async () => {
    try {
      const loadingTask = pdfjsLib.getDocument(pdfFile);
      // const loadingTask = pdfjsLib.getDocument(props.pdfUrl);
      pdfDoc = await loadingTask.promise;
      setCurrentPdfDoc(pdfDoc);
      setTotalPages(pdfDoc.numPages);
      // console.log('currentPage', currentPage);
      let firstPage = getFirstHighlightPage(); // 获取第一个高亮page
      await renderPage(firstPage, pdfDoc);
    } catch (error) {
      console.error('Error loading PDF:', error);
    }
  };

  // 渲染页面
  const renderPage = async (cpage?: number, pdfDocs?: any) => {
    let pdfDoc = pdfDocs || currentPdfDoc;
    if (!pdfDoc) return;
    try {
      // 获取页面
      const page = await pdfDoc.getPage(cpage || currentPage);

      // 获取视口
      const viewport = page.getViewport({ scale });

      // 设置 canvas 尺寸
      const pdfCanvas: CanvasItem = pdfCanvasRef?.current;
      const highlightCanvas = highlightCanvasRef?.current;
      pdfCanvas['height'] = viewport?.height;
      pdfCanvas['width'] = viewport?.width;

      highlightCanvas['height'] = viewport?.height;
      highlightCanvas['width'] = viewport?.width;

      // 渲染 PDF 内容
      const renderContext = {
        canvasContext: pdfCtx || cpdfCtx,
        viewport,
      };

      await page.render(renderContext).promise;

      const textObj = await page.getTextContent();

      // 渲染高亮
      renderHighlights(viewport, cpage || currentPage);
    } catch (error) {
      console.error('Error rendering page:', error);
    }
  };

  // 渲染高亮
  const renderHighlights = (viewport, page) => {
    let HLContext = highlightPdfCtx || highlightCtx;
    if (HLContext) {
      HLContext?.clearRect(
        0,
        0,
        highlightCanvasRef?.current.width,
        highlightCanvasRef?.current.height,
      );
      let key = type === 'liu' ? 'page_id' : 'page';
      const currentHighlights = highlightData.filter(h => h[key] === page);

      HLContext.fillStyle = 'rgba(255, 255, 0, 0.5)';

      // 设置矩形的边框颜色
      //   HLContext.strokeStyle = 'rgba(21, 255, 0, 0.5)';
      //   HLContext.lineWidth = 2; // 边框线宽

      currentHighlights.forEach((highlight: any, index) => {
        let position = highlight?.object_bbox || highlight?.textBbox;
        let textBbox =
          typeof position === 'string'
            ? JSON.parse(highlight?.object_bbox)
            : highlight?.object_bbox;

        // "[0.05,0.11,0.58,0.87]"
        //  [left(X),TOP(Y),height.width]
        const [x1, y1, x2, y2] = textBbox;
 
        const x = x1 * viewport.width;
        const y = y1 * viewport.height;
        const w = y2 * viewport.width;
        const h = x2 * viewport.height;
        const rect = [
          x,
          y,
          w,
          h,
        ];
        HLContext.fillRect(rect[0], rect[1], rect[2], rect[3]);

        // 绘制矩形的边框
        // HLContext.strokeRect(rect[0], rect[1], rect[2], rect[3]);
      });
    }
  };

  // 页面导航
  const prevPage = async () => {
    if (currentPage > 1) {
      let newPage = currentPage - 1;
      setCurrentPage(newPage);
      await renderPage(newPage);
    }
  };

  const nextPage = async () => {
    if (currentPage < totalPages) {
      let newPage = currentPage + 1;
      setCurrentPage(newPage);
      await renderPage(newPage);
    }
  };

  // 缩放功能
  const zoomIn = async () => {
    scale *= 1.1;
    await renderPage();
  };

  const zoomOut = async () => {
    scale /= 1.1;
    await renderPage();
  };

  // 缩放滚轮控制
  const setupZoomWheel = () => {
    window.addEventListener(
      'wheel',
      e => {
        if (e.ctrlKey) {
          e.preventDefault();
          if (e.deltaY < 0) {
            zoomIn();
          } else {
            zoomOut();
          }
        }
      },
      { passive: false },
    );
  };

  return (
    <>
      <div id="pdf_viewer"></div>
      <div className={styles['pdf-viewer']}>
        <div className={styles['control-bar']}>
          <button onClick={prevPage} disabled={currentPage <= 1}>
            上一页
          </button>
          <span style={{ color: '#fff', margin: '0 10px' }}>
            {currentPage} / {totalPages}
          </span>
          <button onClick={nextPage} disabled={currentPage >= totalPages}>
            下一页
          </button>
          <button onClick={zoomIn} disabled={currentPage >= totalPages}>
            放大
          </button>
          <button onClick={zoomOut} disabled={currentPage >= totalPages}>
            缩小
          </button>
        </div>
        <div className={styles['pdf-container']} ref={containerRef}>
          <canvas ref={pdfCanvasRef}></canvas>
          <canvas
            ref={highlightCanvasRef}
            className={styles['highlight-layer']}
          ></canvas>
        </div>
      </div>
    </>
  );
};

export default PDFDemo;

多页&仿viewer样式&缩放无高亮&性能渲染问题

import * as pdfjsLib from 'pdfjs-dist/webpack';
import React, { useEffect, useRef, useState } from 'react';
import PDFWorker from 'pdfjs-dist/build/pdf.worker';
// import PDFWorker from './pdfWorker.mjs';
// import PDFWorker from '@/services/pdf.worker.min.js';
console.log('PDFWorker', PDFWorker)
// 设置 pdf.js 工作线程的路径
// pdfjsLib.GlobalWorkerOptions.workerSrc =
//   'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.worker.min.js';
// pdfjsLib.GlobalWorkerOptions.workerSrc = PDFWorker;

// 假设你知道要高亮的区域的坐标和页数
const highlightAreas = [
  {
    page: 10,
    textBbox: [0.3, 0.3, 0.3, 0.3],
  },
  {
    page: 3,
    textBbox: [0.5, 0.1, 0.58, 0.87],
  },
  {
    page: 5,
    textBbox: [0.0594, 0.1159, 0.5886, 0.8735],
  },
];

const PDFDist = ({ file, highlightData = [], type = '' }) => {
  const [currentPage, setCurrentPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);
  const [pages, setPages] = useState([]); // 存储渲染的页面内容
  const pdfDoc = useRef(null);
  const canvasHeightRef = useRef(null);
  const currentHeightLightPageRef = useRef(null);
  const heightLightPageListRef = useRef(null);
  const scaleRef = useRef(1.65);
  const canvasContext = useRef(null);
  const thumbnailsRef = useRef([]); // 存储每页的缩略图
  const clickImgRef = useRef(null);

  let debounceTimeout;
  const debounceDelay = 200; // 200ms 延时

  /**
   * 添加高亮效果
   * @param {number} pageNumber 当前页码
   * @param {*} viewport 视图
   * @param {*} ctx  canvas上下文
   */
  const addHighlight = (pageNumber, viewport, ctx) => {
    ctx.globalAlpha = 0.5; // 设置高亮透明度
    ctx.fillStyle = 'yellow'; // 设置高亮颜色

    // 绘制高亮矩形
    highlightData.forEach(area => {
      let key = type === 'liu' ? 'page_id' : 'page';
      // const { page, position } = area;
      let page = area[key];

      // object_bbox: "[0.0594,0.1159,0.5886,0.8735]"

      let areaPosition = area?.object_bbox || area?.textBbox;
      let position =
        typeof areaPosition === 'string'
          ? JSON.parse(areaPosition)
          : areaPosition;
      if (page === pageNumber) {
        const [x1, y1, x2, y2] = position;

        const start_X = x1 * viewport.width;
        const start_Y = y1 * viewport.height;
        const w = y2 * viewport.width;
        const h = x2 * viewport.height;

        let rectPosition = [
          start_X,
          start_Y,
          w,
          h,
        ];
        let defalutRect = [
          position?.[0] * viewport.scale,
          position?.[1] * viewport.scale,
          position?.[2] * viewport.scale,
          position?.[3] * viewport.scale,
        ];
        // 将 PDF 坐标转换为 canvas 坐标
        ctx.fillRect(...rectPosition);
      }
    });
    ctx.globalAlpha = 1.0; // 恢复透明度
  };
  // 生成每一页的缩略图
  const createThumbnail = async (pageNumber, scale = 0.2) => {
    const page = await pdfDoc.current.getPage(pageNumber);
    const viewport = page.getViewport({ scale });
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.width = viewport.width;
    canvas.height = viewport.height;
    const renderContext = {
      canvasContext: context,
      viewport: viewport,
    };
    await page.render(renderContext).promise;
    return canvas.toDataURL(); // 返回缩略图的 base64 图片
  };

  // 获取第一个高亮页
  const getFirstHighlightPage = () => {
    let key = type === 'liu' ? 'page_id' : 'page';
    let sortHighlightData = highlightData?.sort((a, b) => a[key] - b[key]);
    let firstPage = sortHighlightData?.[0]?.[key];

    let pageList = [];
    sortHighlightData?.map(item => {
      pageList.push(item[key]);
    });
    heightLightPageListRef.current = pageList;

    setCurrentPage(firstPage || 1);
    currentHeightLightPageRef.current = firstPage || 1;
    pageScrollTop(firstPage, canvasHeightRef.current);
    return firstPage;
  };
  //  创建canvas
  const createCanvas = (canvasId, container, pageNumber, viewport) => {
    const canvas = document.createElement('canvas');

    canvas.id = `${canvasId}-${pageNumber}`;
    canvas.style.border = 'solid 15px #525659';

    // 设置下边距
    // canvas.style.marginBottom = '30px';
    canvas.style.margin = '0 auto';

    document.getElementById(container).appendChild(canvas);

    const context = canvas.getContext('2d');
    canvas.width = viewport.width;
    canvas.height = viewport.height;
    return { context, canvas };
  };

  const removeAllCanvases = () => {
    const canvases = document.querySelectorAll('canvas');

    canvases.forEach(canvas => {
      canvas.remove(); // 删除canvas元素
    });
  };

  // 渲染页面并生成缩略图
  const renderPage = async (totalPages, pdf) => {
    // 清空所有旧的canvas元素
    removeAllCanvases();

    const newPages = [];
    const newThumbnails = [];
    for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
      // 渲染每一页
      const page = await pdf.getPage(pageNumber);

      const viewport = page.getViewport({ scale: +scaleRef.current });
      let canvasResult = createCanvas(
        'pdfcanvas',
        'containerId',
        pageNumber,
        viewport,
      );
      const { context, canvas } = canvasResult;
      canvasContext.current = canvasResult;

      // 渲染当前页面到 canvas
      await page.render({ canvasContext: context, viewport }).promise;
      newPages.push(canvas);
      // 添加高亮效果
      addHighlight(pageNumber, viewport, context);

      // 生成缩略图
      const thumbnailData = await createThumbnail(pageNumber);
      newThumbnails.push(thumbnailData);

      canvasHeightRef.current = canvas.height;
    }

    thumbnailsRef.current = newThumbnails;
    setPages(newPages);
  };

  const loadPDF = async () => {
    const loadingTask = pdfjsLib.getDocument(file);
    const pdf = await loadingTask.promise;
    let totalPages = pdf.numPages;
    setTotalPages(totalPages);
    pdfDoc.current = pdf;
    await renderPage(totalPages, pdf);
    getFirstHighlightPage(); // 获取并设置第一个高亮页
  };

  const pageScrollTop = (page, canvasHeight) => {
    let scrollTop = (page - 1) * (canvasHeight + 20) + page * 10;

    const pdfPage = document.getElementById('pdfPage');
    pdfPage.scrollTo({ top: scrollTop, behavior: 'smooth' });
  };

  const hlPrevPage = () => {
    handleHighlightPage('-', highlightData, currentHeightLightPageRef.current);
  };
  const hlNextPage = () => {
    handleHighlightPage('+', highlightData, currentHeightLightPageRef.current);
  };

  const handleScroll = () => {
    const pdfPage = document.getElementById('pdfPage');
    const getCurrentPage = e => {
      let scrollTop = e.target.scrollTop;
      let canvasHeight = canvasHeightRef.current;
      let pageNumber = Math.floor(scrollTop / canvasHeight) + 1;
      setCurrentPage(pageNumber);
    };
    pdfPage.onscroll = function(e) {
      getCurrentPage(e);
    };
  };

  const onThumbnailClick = pageNumber => {
    setCurrentPage(pageNumber);
    pageScrollTop(pageNumber, canvasHeightRef.current);

    clickImgRef.current = pageNumber;
  };

  // 高亮页的上一页、下一页
  const handleHighlightPage = (key, hlList, cpage) => {
    let keys = type === 'liu' ? 'page_id' : 'page';
    let sorthlList = hlList?.sort((a, b) => a[keys] - b[keys]);
    let sorthlListAllIndex = sorthlList?.length - 1; // 高亮页最大索引
    let index = sorthlList?.findIndex(item => item[keys] === cpage);

    let hlPrevPageButton = document.getElementById('hlPrevPage');
    let hlNextPageButton = document.getElementById('hlNextPage');
    switch (key) {
      case '-':
        if (index > 0) {
          let prevPage = hlList[index - 1]?.[keys];
          currentHeightLightPageRef.current = prevPage;
          pageScrollTop(prevPage, canvasHeightRef.current);
          hlNextPageButton.disabled = false;
        } else {
          hlPrevPageButton.disabled = true;
        }
        break;
      case '+':
        if (index < sorthlListAllIndex) {
          let nextPage = hlList[index + 1]?.[keys];
          currentHeightLightPageRef.current = nextPage;
          pageScrollTop(nextPage, canvasHeightRef.current);
          hlPrevPageButton.disabled = false;
        } else {
          hlNextPageButton.disabled = true;
        }
        break;
      default:
        break;
    }
  };

  useEffect(() => {
    loadPDF();
    handleScroll();
  }, [file]);

  // 滚动到顶部或底部
  const quickScrolling = type => {
    const pdfPage = document.getElementById('pdfPage');

    if (type === 'top') {
      pdfPage.scrollTo({ top: 0, behavior: 'smooth' });
    }
    if (type === 'bottom') {
      console.log('最底部', pdfPage, pdfPage?.scrollHeight);

      pdfPage.scrollTo({ top: pdfPage?.scrollHeight, behavior: 'smooth' });
    }
  };

  const clearTimeoutFn = () => {
    // 清除上次的定时器
    clearTimeout(debounceTimeout);
  };

  // 缩放功能
  const zoomIn = () => {
    clearTimeoutFn();
    // 设置新的定时器
    debounceTimeout = setTimeout(async () => {
      let scaleVal = scaleRef.current * 1.1;
      scaleRef.current = scaleVal.toFixed(2);
      window.requestAnimationFrame(() =>
        renderPage(totalPages, pdfDoc.current),
      );
      // await renderPage(totalPages, pdfDoc.current);
    }, debounceDelay);
  };

  const zoomOut = () => {
    clearTimeoutFn();
    debounceTimeout = setTimeout(async () => {
      let scaleMin = scaleRef.current / 1.1 <= 0.2;
      if (scaleMin) return false;
      let scaleVal = scaleMin ? 0.2 : scaleRef.current / 1.1;
      scaleRef.current = scaleVal.toFixed(2);
      window.requestAnimationFrame(() =>
        renderPage(totalPages, pdfDoc.current),
      );
      // await renderPage(totalPages, pdfDoc.current);
    });
  };

  const divStyle = {
    width: '200px',
    display: 'flex',
    justifyContent: 'space-around',
    alignItems: 'center',
  };
  const divStyle2 = {
    width: '100px',
    display: 'flex',
    justifyContent: 'space-around',
    alignItems: 'center',
  };
  const divStyle3 = {
    width: '340px',
    display: 'flex',
    alignItems: 'center',
    paddingLeft: '220px',
    justifyContent: 'space-evenly',
  };

  const barDivStyle = {
    backgroundColor: '#323639',
    height: '50px',
    width: '100%',
    position: 'fixed',
    top: '0px',
    left: '0px',
    zIndex: '999',

    display: 'flex',
    justifyContent: 'space-between',
    alignItems: 'center',

    padding: '0 55px',
  };

  const loading = {
    position: 'fixed',
    top: 0,
    left: 0,
    width: '100%',
    height: '100%',
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
    zIndex: 9999,
  };

  return (
    <>
      {/* <div id="loading" class="loading">
        <div style={{ ...loading }}></div>
        <p>Loading...</p>
      </div> */}
      <div>
        <div style={barDivStyle}>
          <div style={{ ...divStyle2, justifyContent: 'flex-start' }}>
            <span style={{ color: '#fff', margin: '0 10px' }}>
              {currentPage} / {totalPages}
            </span>
          </div>

          <div style={divStyle3}>
            <button onClick={zoomIn}>+</button>
            <button onClick={zoomOut}>-</button>
          </div>

          <div style={divStyle}>
            <button id="hlPrevPage" onClick={hlPrevPage}>
              ⬅
            </button>

            <button id="hlNextPage" onClick={hlNextPage}>
              ➡
            </button>
            <button onClick={() => quickScrolling('top')}>⬆</button>
            <button onClick={() => quickScrolling('bottom')}>⬇</button>
          </div>
        </div>
        <div
          style={{
            display: 'flex',
            marginTop: '50px',
            height: '100vh',
            justifyContent: 'center',
          }}
        >
          {/* 左侧预览框 */}
          <div>
            <div
              style={{
                width: '180px',
                height: '100%',
                overflowY: 'scroll',
                borderRight: '1px solid #ddd',
                position: 'fixed',
                top: '0',
                left: '0',
                padding: '60px 30px 0 30px',
              }}
            >
              {thumbnailsRef.current.map((thumbnail, index) => (
                <div
                  key={index}
                  onClick={() => onThumbnailClick(index + 1)}
                  style={{
                    cursor: 'pointer',
                    marginBottom: '10px',
                    padding: '5px',
                  }}
                >
                  <img
                    id={`img_${index + 1}`}
                    src={thumbnail}
                    alt={`Thumbnail for page ${index + 1}`}
                    style={{
                      width: '100%',
                      border:
                        clickImgRef.current === index + 1
                          ? 'solid 3px #ffff7f'
                          : 'none',
                    }}
                  />
                </div>
              ))}
            </div>
          </div>

          {/* 右侧 PDF 内容 */}
          <div
            id="pdfPage"
            style={{
              marginLeft: '160px',
              height: '100%',
              overflowY: 'auto',
              width: '100%',
            }}
          >
            <div
              id="containerId"
              style={{ position: 'relative', margin: '10px' }}
            />
          </div>
        </div>
      </div>
    </>
  );
};

export default PDFDist;

总结:

实现pdf高亮和定位方式:

方式一:安装react-pdfjs或pdfjs-dist,使用方法实现pdf高亮和定位思路:

首先渲染pdf文件,然后根据页数和位置通过canvas绘制高亮,获取第一高亮页数或dom,通过 pdfPage.scrollTo()或targetPage.scrollIntoView({ behavior: 'smooth' });滚动到页面。

方式二:将pdfjs-dist文件放在public下,通过iframe嵌入实现

首先在iframe的src中找到public下的viewer.html,通过?传参给file,将pdf文件路径通过file传入viewer.html来显示视图,获取viewer中的dom,通过页数和位置向textLayer追加高亮块来实现高亮;通过第一高亮页数获取对应dom,通过scrollIntoView自动定位到第一高亮页。

(注意:打包线上的viewer的文件路径是否一致,以及postmessage与iframe通讯时的源是否一致,防止跨域问题)