前言
需求:在项目中显示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显示。
最终实现方式:
将pdfjs-dist@2.5.207 文件放在public文件下,通过iframe引入pdfjs文件下的viewer.html实现默认浏览样式;
通过获取textLayer元素,并根据页数和点位向父级dom添加高亮标签元素实现高亮;
获取第一个高亮数据并获取对应元素,通过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); }); } },[])
最终效果
遇到问题
viewer跨域和iframe源跨域
解决方式:
删除viewer.js中的跨域判断代码
iframe的src&postMessage的第二参数&当前地址端口号同源
pdfjs-dist 3.11.174版本不兼容chrome91版本,无法识别es6代码
解决方式:
1.降低版本到2.5.207
2.将pdfjs-dist放在static,配置webpack打包编译为es5(实验未通过)
初始打开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通讯时的源是否一致,防止跨域问题)