npm install pdfjs-dist
// components/PDFViewer.jsx
import React, { useState, useRef, useEffect } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
import 'pdfjs-dist/build/pdf.worker.entry';
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;
const PDFViewer = ({
file,
onLoadSuccess,
onLoadError,
scale = 1.2,
rotation = 0
}) => {
const [pdfDocument, setPdfDocument] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [scaleValue, setScaleValue] = useState(scale);
const [rotationValue, setRotationValue] = useState(rotation);
const [isLoading, setIsLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [currentSearchIndex, setCurrentSearchIndex] = useState(0);
const [pageRendering, setPageRendering] = useState(false);
const canvasRef = useRef(null);
const containerRef = useRef(null);
const searchRef = useRef(null);
// 初始化PDF
useEffect(() => {
if (!file) return;
const loadPDF = async () => {
try {
setIsLoading(true);
let pdf;
if (typeof file === 'string') {
// 如果是URL
pdf = await pdfjsLib.getDocument({
url: file,
cMapUrl: 'https://unpkg.com/pdfjs-dist@3.11.174/cmaps/',
cMapPacked: true,
}).promise;
} else if (file instanceof ArrayBuffer) {
// 如果是ArrayBuffer
pdf = await pdfjsLib.getDocument({
data: file,
cMapUrl: 'https://unpkg.com/pdfjs-dist@3.11.174/cmaps/',
cMapPacked: true,
}).promise;
} else if (file instanceof Blob) {
// 如果是Blob
const arrayBuffer = await file.arrayBuffer();
pdf = await pdfjsLib.getDocument({
data: arrayBuffer,
cMapUrl: 'https://unpkg.com/pdfjs-dist@3.11.174/cmaps/',
cMapPacked: true,
}).promise;
} else {
throw new Error('Unsupported file type');
}
setPdfDocument(pdf);
setTotalPages(pdf.numPages);
if (onLoadSuccess) {
onLoadSuccess(pdf);
}
} catch (error) {
console.error('Error loading PDF:', error);
if (onLoadError) {
onLoadError(error);
}
} finally {
setIsLoading(false);
}
};
loadPDF();
}, [file]);
// 渲染页面
const renderPage = async (pageNum) => {
if (!pdfDocument || pageRendering) return;
try {
setPageRendering(true);
const page = await pdfDocument.getPage(pageNum);
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
const container = containerRef.current;
// 计算缩放后的尺寸
const viewport = page.getViewport({
scale: scaleValue,
rotation: rotationValue
});
canvas.height = viewport.height;
canvas.width = viewport.width;
// 调整容器大小
if (container) {
container.style.width = `${viewport.width}px`;
container.style.height = `${viewport.height}px`;
}
// 渲染页面
const renderContext = {
canvasContext: context,
viewport: viewport
};
await page.render(renderContext).promise;
setCurrentPage(pageNum);
} catch (error) {
console.error('Error rendering page:', error);
} finally {
setPageRendering(false);
}
};
// 页码变化时重新渲染
useEffect(() => {
if (pdfDocument && currentPage) {
renderPage(currentPage);
}
}, [pdfDocument, currentPage, scaleValue, rotationValue]);
// 导航功能
const goToPrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const goToNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
const goToPage = (pageNumber) => {
const pageNum = Math.max(1, Math.min(pageNumber, totalPages));
setCurrentPage(pageNum);
};
// 缩放功能
const zoomIn = () => {
setScaleValue(prev => Math.min(prev + 0.2, 3));
};
const zoomOut = () => {
setScaleValue(prev => Math.max(prev - 0.2, 0.5));
};
const zoomTo = (value) => {
setScaleValue(Math.max(0.5, Math.min(value, 3)));
};
// 旋转功能
const rotate = (degrees = 90) => {
setRotationValue((prev) => (prev + degrees) % 360);
};
// 搜索功能
const searchInPDF = async () => {
if (!pdfDocument || !searchText.trim()) return;
const results = [];
for (let i = 1; i <= totalPages; i++) {
const page = await pdfDocument.getPage(i);
const textContent = await page.getTextContent();
textContent.items.forEach((item) => {
if (item.str.toLowerCase().includes(searchText.toLowerCase())) {
results.push({
page: i,
text: item.str,
index: results.length
});
}
});
}
setSearchResults(results);
setCurrentSearchIndex(0);
if (results.length > 0) {
goToPage(results[0].page);
}
};
const goToNextSearchResult = () => {
if (searchResults.length === 0) return;
const nextIndex = (currentSearchIndex + 1) % searchResults.length;
setCurrentSearchIndex(nextIndex);
goToPage(searchResults[nextIndex].page);
};
const goToPrevSearchResult = () => {
if (searchResults.length === 0) return;
const prevIndex = (currentSearchIndex - 1 + searchResults.length) % searchResults.length;
setCurrentSearchIndex(prevIndex);
goToPage(searchResults[prevIndex].page);
};
// 下载功能
const downloadPDF = () => {
if (!file) return;
if (typeof file === 'string') {
// 如果是URL,直接下载
const link = document.createElement('a');
link.href = file;
link.download = 'document.pdf';
link.click();
} else if (file instanceof Blob) {
// 如果是Blob,创建下载链接
const url = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = url;
link.download = 'document.pdf';
link.click();
URL.revokeObjectURL(url);
}
};
// 打印功能
const printPDF = () => {
if (!pdfDocument) return;
// 打开新窗口打印
const printWindow = window.open('');
printWindow.document.write(`
<html>
<head>
<title>打印PDF</title>
</head>
<body>
<iframe
src="${typeof file === 'string' ? file : ''}"
style="width:100%;height:100%;border:none;"
></iframe>
<script>
window.onload = function() {
setTimeout(() => {
window.print();
}, 1000);
}
</script>
</body>
</html>
`);
};
// 缩略图功能组件
const ThumbnailView = () => {
const [thumbnails, setThumbnails] = useState([]);
useEffect(() => {
if (!pdfDocument) return;
const loadThumbnails = async () => {
const thumbs = [];
for (let i = 1; i <= Math.min(totalPages, 20); i++) {
const page = await pdfDocument.getPage(i);
const viewport = page.getViewport({ scale: 0.2 });
const canvas = document.createElement('canvas');
canvas.height = viewport.height;
canvas.width = viewport.width;
const context = canvas.getContext('2d');
await page.render({
canvasContext: context,
viewport: viewport
}).promise;
thumbs.push({
page: i,
dataUrl: canvas.toDataURL()
});
}
setThumbnails(thumbs);
};
loadThumbnails();
}, [pdfDocument]);
if (thumbnails.length === 0) return null;
return (
<div style={{
position: 'absolute',
left: 0,
top: 0,
width: '200px',
backgroundColor: '#f5f5f5',
padding: '10px',
overflowY: 'auto',
height: '100%',
borderRight: '1px solid #ddd'
}}>
<h4>缩略图</h4>
{thumbnails.map(thumb => (
<div
key={thumb.page}
onClick={() => goToPage(thumb.page)}
style={{
marginBottom: '10px',
cursor: 'pointer',
border: currentPage === thumb.page ? '2px solid #1890ff' : '1px solid #ddd',
borderRadius: '4px',
overflow: 'hidden'
}}
>
<img
src={thumb.dataUrl}
alt={`Page ${thumb.page}`}
style={{ width: '100%', height: 'auto' }}
/>
<div style={{
textAlign: 'center',
padding: '2px',
fontSize: '12px',
backgroundColor: currentPage === thumb.page ? '#1890ff' : '#fff',
color: currentPage === thumb.page ? '#fff' : '#666'
}}>
{thumb.page}
</div>
</div>
))}
</div>
);
};
return (
<div className="pdf-viewer-container" style={{
position: 'relative',
width: '100%',
height: '100vh',
backgroundColor: '#525659'
}}>
{/* 工具栏 */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: '#333',
color: 'white',
padding: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
zIndex: 100
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{/* 导航按钮 */}
<button
onClick={goToPrevPage}
disabled={currentPage <= 1 || isLoading}
style={buttonStyle}
>
上一页
</button>
<span style={{ minWidth: '80px', textAlign: 'center' }}>
{isLoading ? '加载中...' : `${currentPage} / ${totalPages}`}
</span>
<button
onClick={goToNextPage}
disabled={currentPage >= totalPages || isLoading}
style={buttonStyle}
>
下一页
</button>
{/* 页码跳转 */}
<input
type="number"
min="1"
max={totalPages}
value={currentPage}
onChange={(e) => goToPage(parseInt(e.target.value) || 1)}
style={{
width: '60px',
padding: '4px',
margin: '0 5px'
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{/* 缩放控制 */}
<button onClick={zoomOut} style={buttonStyle}>-</button>
<select
value={scaleValue.toFixed(1)}
onChange={(e) => zoomTo(parseFloat(e.target.value))}
style={{ padding: '4px' }}
>
<option value="0.5">50%</option>
<option value="0.75">75%</option>
<option value="1.0">100%</option>
<option value="1.25">125%</option>
<option value="1.5">150%</option>
<option value="2.0">200%</option>
</select>
<button onClick={zoomIn} style={buttonStyle}>+</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{/* 旋转 */}
<button onClick={() => rotate(90)} style={buttonStyle}>
旋转
</button>
{/* 搜索 */}
<input
ref={searchRef}
type="text"
placeholder="搜索..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && searchInPDF()}
style={{ padding: '4px', width: '120px' }}
/>
{searchResults.length > 0 && (
<span style={{ fontSize: '12px' }}>
{currentSearchIndex + 1} / {searchResults.length}
</span>
)}
{/* 功能按钮 */}
<button onClick={downloadPDF} style={buttonStyle}>
下载
</button>
<button onClick={printPDF} style={buttonStyle}>
打印
</button>
</div>
</div>
{/* 主要内容区域 */}
<div style={{
position: 'absolute',
top: '50px',
bottom: 0,
left: 0,
right: 0,
display: 'flex'
}}>
{/* 缩略图侧边栏 */}
<ThumbnailView />
{/* PDF显示区域 */}
<div style={{
flex: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
overflow: 'auto',
padding: '20px'
}}>
{isLoading ? (
<div style={{ color: 'white' }}>加载PDF中...</div>
) : (
<div ref={containerRef} style={{ position: 'relative' }}>
<canvas ref={canvasRef} />
{pageRendering && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: 'white',
backgroundColor: 'rgba(0,0,0,0.5)',
padding: '10px',
borderRadius: '5px'
}}>
渲染中...
</div>
)}
</div>
)}
</div>
</div>
{/* 搜索导航 */}
{searchResults.length > 0 && (
<div style={{
position: 'absolute',
bottom: 0,
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0,0,0,0.8)',
color: 'white',
padding: '10px',
borderRadius: '5px 5px 0 0',
display: 'flex',
gap: '10px',
alignItems: 'center'
}}>
<button onClick={goToPrevSearchResult} style={buttonStyle}>
上一个
</button>
<span>搜索结果: {searchResults[currentSearchIndex]?.text}</span>
<button onClick={goToNextSearchResult} style={buttonStyle}>
下一个
</button>
<button
onClick={() => setSearchResults([])}
style={{ ...buttonStyle, marginLeft: '10px' }}
>
关闭
</button>
</div>
)}
</div>
);
};
// 按钮样式
const buttonStyle = {
padding: '6px 12px',
backgroundColor: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
};
export default PDFViewer;
// App.jsx
import React, { useState } from 'react';
import PDFViewer from './components/PDFViewer';
import './App.css';
function App() {
const [pdfFile, setPdfFile] = useState(null);
const [pdfUrl, setPdfUrl] = useState('');
const handleFileChange = (event) => {
const file = event.target.files[0];
if (file && file.type === 'application/pdf') {
setPdfFile(file);
setPdfUrl('');
} else {
alert('请选择PDF文件');
}
};
const handleUrlSubmit = () => {
if (pdfUrl) {
setPdfFile(null);
setPdfUrl(pdfUrl);
}
};
return (
<div className="App">
<div style={{
padding: '20px',
backgroundColor: '#f0f0f0',
marginBottom: '20px'
}}>
<h1>PDF预览组件</h1>
<div style={{ marginBottom: '20px' }}>
<h3>上传本地PDF文件:</h3>
<input
type="file"
accept=".pdf"
onChange={handleFileChange}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<h3>或输入PDF URL:</h3>
<div style={{ display: 'flex', gap: '10px' }}>
<input
type="text"
placeholder="输入PDF URL"
value={pdfUrl}
onChange={(e) => setPdfUrl(e.target.value)}
style={{ flex: 1, padding: '8px' }}
/>
<button onClick={handleUrlSubmit}>加载URL</button>
</div>
<small>示例URL: https://example.com/document.pdf</small>
</div>
</div>
{pdfFile || pdfUrl ? (
<PDFViewer
file={pdfFile || pdfUrl}
onLoadSuccess={(pdf) => {
console.log('PDF加载成功,总页数:', pdf.numPages);
}}
onLoadError={(error) => {
console.error('PDF加载失败:', error);
alert('PDF加载失败,请检查文件或URL');
}}
scale={1.2}
rotation={0}
/>
) : (
<div style={{
textAlign: 'center',
padding: '100px',
color: '#666'
}}>
<h2>请上传PDF文件或输入PDF URL</h2>
<p>支持本地文件上传和在线PDF预览</p>
</div>
)}
</div>
);
}
export default App;
/* App.css */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.App {
height: 100vh;
display: flex;
flex-direction: column;
}
.pdf-viewer-container {
flex: 1;
position: relative;
}
button {
padding: 8px 16px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
button:hover {
background-color: #40a9ff;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
input, select {
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
}
input:focus, select:focus {
outline: none;
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
// 更多高级功能的hooks
// hooks/usePDFAnnotations.js
import { useState, useRef } from 'react';
export const usePDFAnnotations = () => {
const [annotations, setAnnotations] = useState([]);
const [isDrawing, setIsDrawing] = useState(false);
const [drawingTool, setDrawingTool] = useState(null);
const [selectedAnnotation, setSelectedAnnotation] = useState(null);
const addAnnotation = (annotation) => {
setAnnotations([...annotations, annotation]);
};
const removeAnnotation = (id) => {
setAnnotations(annotations.filter(ann => ann.id !== id));
};
const updateAnnotation = (id, updates) => {
setAnnotations(annotations.map(ann =>
ann.id === id ? { ...ann, ...updates } : ann
));
};
return {
annotations,
addAnnotation,
removeAnnotation,
updateAnnotation,
isDrawing,
setIsDrawing,
drawingTool,
setDrawingTool,
selectedAnnotation,
setSelectedAnnotation
};
};
基本功能:
搜索功能:
缩略图:
文件操作:
状态管理:
这个组件提供了完整的PDF预览功能,可以根据具体需求进行扩展和样式调整。