2026-02-10 09:48:44 +00:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-04-10 01:14:00 +00:00
|
|
|
|
// 离线部署环境不依赖 unpkg 等外部静态资源。
|
|
|
|
|
|
const pdfOptions = useMemo(() => undefined, [])
|
2026-02-10 09:48:44 +00:00
|
|
|
|
|
|
|
|
|
|
// 根据 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
|