272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||
import { Document, Page, pdfjs } from 'react-pdf'
|
||
import { Button, Space, InputNumber, message, Spin } from 'antd'
|
||
import {
|
||
ZoomInOutlined,
|
||
ZoomOutOutlined,
|
||
VerticalAlignTopOutlined,
|
||
LeftOutlined,
|
||
RightOutlined,
|
||
} from '@ant-design/icons'
|
||
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||
import 'react-pdf/dist/Page/TextLayer.css'
|
||
import './VirtualPDFViewer.css'
|
||
|
||
// 配置 PDF.js worker
|
||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
|
||
|
||
function VirtualPDFViewer({ url, filename }) {
|
||
const [numPages, setNumPages] = useState(null)
|
||
const [scale, setScale] = useState(1.0)
|
||
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // 默认 A4
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const [visiblePages, setVisiblePages] = useState(new Set([1]))
|
||
const containerRef = useRef(null)
|
||
const pageRefs = useRef({})
|
||
|
||
// 使用 useMemo 避免不必要的重新加载
|
||
const fileConfig = useMemo(() => ({ url }), [url])
|
||
|
||
// Memoize PDF.js options to prevent unnecessary reloads
|
||
const pdfOptions = useMemo(() => ({
|
||
cMapUrl: 'https://unpkg.com/pdfjs-dist@5.4.296/cmaps/',
|
||
cMapPacked: true,
|
||
standardFontDataUrl: 'https://unpkg.com/pdfjs-dist@5.4.296/standard_fonts/',
|
||
}), [])
|
||
|
||
// 根据 PDF 实际宽高和缩放比例计算页面高度
|
||
const pageHeight = useMemo(() => {
|
||
// 计算内容高度:缩放后的 PDF 高度 + 上下 padding (40px) + 页码文字区域 (20px)
|
||
return Math.ceil(pdfOriginalSize.height * scale) + 60
|
||
}, [scale, pdfOriginalSize.height])
|
||
|
||
const onDocumentLoadError = (error) => {
|
||
console.error('[PDF] Document load error:', error)
|
||
message.error('PDF文件加载失败')
|
||
}
|
||
|
||
|
||
|
||
// Handle scroll to update visible pages
|
||
const handleScroll = useCallback(() => {
|
||
if (!containerRef.current || !numPages) return
|
||
|
||
const container = containerRef.current
|
||
const scrollTop = container.scrollTop
|
||
const containerHeight = container.clientHeight
|
||
|
||
// Calculate which pages are visible
|
||
// Add small tolerance (1px) to handle browser scroll precision issues
|
||
const pageIndex = scrollTop / pageHeight
|
||
let firstVisiblePage = Math.max(1, Math.ceil(pageIndex + 0.001))
|
||
|
||
// Special case: if scrolled to bottom, show last page
|
||
const isAtBottom = scrollTop + containerHeight >= container.scrollHeight - 1
|
||
if (isAtBottom) {
|
||
firstVisiblePage = numPages
|
||
}
|
||
|
||
const lastVisiblePage = Math.min(numPages, Math.ceil((scrollTop + containerHeight) / pageHeight))
|
||
|
||
|
||
|
||
// Add buffer pages (2 before and 2 after)
|
||
const newVisiblePages = new Set()
|
||
for (let i = Math.max(1, firstVisiblePage - 2); i <= Math.min(numPages, lastVisiblePage + 2); i++) {
|
||
newVisiblePages.add(i)
|
||
}
|
||
|
||
setVisiblePages(newVisiblePages)
|
||
setCurrentPage(firstVisiblePage)
|
||
}, [numPages, pageHeight])
|
||
|
||
const onDocumentLoadSuccess = useCallback(async (pdf) => {
|
||
setNumPages(pdf.numPages)
|
||
|
||
try {
|
||
// 获取第一页的原始尺寸,用于计算初始缩放
|
||
const page = await pdf.getPage(1)
|
||
const viewport = page.getViewport({ scale: 1.0 })
|
||
const { width, height } = viewport
|
||
setPdfOriginalSize({ width, height })
|
||
|
||
// 自动适应宽度:仅当 PDF 宽度超过容器时才进行缩放
|
||
if (containerRef.current) {
|
||
const containerWidth = containerRef.current.clientWidth - 40 // 减去左右内边距
|
||
if (width > containerWidth) {
|
||
const autoScale = Math.floor((containerWidth / width) * 10) / 10 // 保留一位小数
|
||
setScale(Math.min(Math.max(autoScale, 0.5), 1.0)) // 限制缩放比例,最高不超 1.0
|
||
} else {
|
||
setScale(1.0) // 宽度足够则保持 100%
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Error calculating initial scale:', err)
|
||
}
|
||
|
||
// Initially show first 3 pages
|
||
setVisiblePages(new Set([1, 2, 3]))
|
||
|
||
// Trigger scroll calculation after a short delay to ensure DOM is ready
|
||
setTimeout(() => {
|
||
handleScroll()
|
||
}, 200)
|
||
}, [handleScroll])
|
||
|
||
// Attach scroll listener
|
||
useEffect(() => {
|
||
const container = containerRef.current
|
||
if (!container) return
|
||
|
||
container.addEventListener('scroll', handleScroll)
|
||
return () => container.removeEventListener('scroll', handleScroll)
|
||
}, [handleScroll, numPages, pageHeight])
|
||
|
||
const zoomIn = () => {
|
||
setScale((prev) => Math.min(prev + 0.2, 3.0))
|
||
}
|
||
|
||
const zoomOut = () => {
|
||
setScale((prev) => Math.max(prev - 0.2, 0.5))
|
||
}
|
||
|
||
const handlePageChange = (value) => {
|
||
if (value >= 1 && value <= numPages && containerRef.current) {
|
||
const scrollTop = (value - 1) * pageHeight
|
||
const container = containerRef.current
|
||
container.scrollTo({ top: scrollTop, behavior: 'auto' })
|
||
|
||
// Manually trigger handleScroll after scrolling to ensure page number updates
|
||
setTimeout(() => {
|
||
handleScroll()
|
||
}, 50)
|
||
}
|
||
}
|
||
|
||
const scrollToTop = () => {
|
||
if (containerRef.current) {
|
||
containerRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="virtual-pdf-viewer-container">
|
||
{/* 工具栏 */}
|
||
<div className="pdf-toolbar">
|
||
<Space>
|
||
<Button
|
||
icon={<LeftOutlined />}
|
||
onClick={() => handlePageChange(currentPage - 1)}
|
||
disabled={currentPage <= 1}
|
||
size="small"
|
||
/>
|
||
<Space.Compact>
|
||
<InputNumber
|
||
min={1}
|
||
max={numPages || 1}
|
||
value={currentPage}
|
||
onChange={handlePageChange}
|
||
size="small"
|
||
style={{ width: 60 }}
|
||
/>
|
||
<Button size="small" disabled>
|
||
/ {numPages || 0}
|
||
</Button>
|
||
</Space.Compact>
|
||
<Button
|
||
icon={<RightOutlined />}
|
||
onClick={() => handlePageChange(currentPage + 1)}
|
||
disabled={currentPage >= (numPages || 0)}
|
||
size="small"
|
||
/>
|
||
<Button
|
||
icon={<VerticalAlignTopOutlined />}
|
||
onClick={scrollToTop}
|
||
size="small"
|
||
disabled={currentPage === 1}
|
||
>
|
||
回到顶部
|
||
</Button>
|
||
</Space>
|
||
|
||
<Space>
|
||
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
|
||
缩小
|
||
</Button>
|
||
<span style={{ minWidth: 50, textAlign: 'center' }}>
|
||
{Math.round(scale * 100)}%
|
||
</span>
|
||
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
|
||
放大
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
|
||
{/* PDF内容区 - 自定义虚拟滚动 */}
|
||
<div className="pdf-content" ref={containerRef}>
|
||
<Document
|
||
file={fileConfig}
|
||
onLoadSuccess={onDocumentLoadSuccess}
|
||
onLoadError={onDocumentLoadError}
|
||
loading={
|
||
<div className="pdf-loading">
|
||
<Spin size="large" />
|
||
<div style={{ marginTop: 16 }}>正在加载PDF...</div>
|
||
</div>
|
||
}
|
||
error={<div className="pdf-error">PDF加载失败,请稍后重试</div>}
|
||
options={pdfOptions}
|
||
>
|
||
{numPages && (
|
||
<div style={{ height: numPages * pageHeight, position: 'relative' }}>
|
||
{Array.from({ length: numPages }, (_, index) => {
|
||
const pageNumber = index + 1
|
||
const isVisible = visiblePages.has(pageNumber)
|
||
|
||
return (
|
||
<div
|
||
key={pageNumber}
|
||
ref={el => pageRefs.current[pageNumber] = el}
|
||
className="pdf-page-wrapper"
|
||
style={{
|
||
position: 'absolute',
|
||
top: index * pageHeight,
|
||
left: 0,
|
||
right: 0,
|
||
height: pageHeight,
|
||
}}
|
||
>
|
||
{isVisible ? (
|
||
<>
|
||
<Page
|
||
pageNumber={pageNumber}
|
||
scale={scale}
|
||
renderTextLayer={true}
|
||
renderAnnotationLayer={true}
|
||
loading={
|
||
<div className="pdf-page-loading">
|
||
<Spin size="small" />
|
||
<div>加载第 {pageNumber} 页...</div>
|
||
</div>
|
||
}
|
||
/>
|
||
<div className="pdf-page-number">第 {pageNumber} 页</div>
|
||
</>
|
||
) : (
|
||
<div className="pdf-page-placeholder">
|
||
<div>第 {pageNumber} 页</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</Document>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default VirtualPDFViewer
|