218 lines
6.6 KiB
TypeScript
218 lines
6.6 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { useDebounce, useSize } from 'ahooks'
|
||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||
import type { pdfjs as PdfJsType } from 'react-pdf'
|
||
import 'react-pdf/dist/Page/TextLayer.css'
|
||
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||
|
||
type ReactPdfModule = typeof import('react-pdf')
|
||
|
||
type PdfComponents = {
|
||
Document: ReactPdfModule['Document'] | null
|
||
Page: ReactPdfModule['Page'] | null
|
||
pdfjs: ReactPdfModule['pdfjs'] | null
|
||
}
|
||
|
||
type PdfLike = {
|
||
numPages: number
|
||
getPage: (pageNumber: number) => Promise<{ view: number[] }>
|
||
}
|
||
|
||
function useReactPdf() {
|
||
const [components, setComponents] = useState<PdfComponents>({
|
||
Document: null,
|
||
Page: null,
|
||
pdfjs: null,
|
||
})
|
||
|
||
useEffect(() => {
|
||
// 仅在浏览器环境下加载 react-pdf,避免 Node.js 中触发 pdf.js 的 DOM 依赖
|
||
if (typeof window === 'undefined') return
|
||
|
||
let cancelled = false
|
||
|
||
;(async () => {
|
||
try {
|
||
const mod: ReactPdfModule = await import('react-pdf')
|
||
|
||
if (cancelled) return
|
||
|
||
const { Document, Page, pdfjs } = mod
|
||
|
||
// 配置 pdf.js worker
|
||
;(pdfjs as typeof PdfJsType).GlobalWorkerOptions.workerSrc =
|
||
`https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`
|
||
|
||
setComponents({
|
||
Document,
|
||
Page,
|
||
pdfjs,
|
||
})
|
||
} catch (error) {
|
||
console.error('Failed to load react-pdf:', error)
|
||
}
|
||
})()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
return components
|
||
}
|
||
|
||
export function VirtualPdfPreview({ url }: { url: string }) {
|
||
const [numPages, setNumPages] = useState<number | null>(null)
|
||
const [renderedPages, setRenderedPages] = useState<number[]>([])
|
||
const [pageHeight, setPageHeight] = useState(0)
|
||
const [aspectRatio, setAspectRatio] = useState(0)
|
||
const [errorText, setErrorText] = useState<string | null>(null)
|
||
|
||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||
const parentRef = useRef<HTMLDivElement>(null)
|
||
const oldPageHeight = useRef(0)
|
||
|
||
const size = useSize(wrapperRef)
|
||
const containerWidth = useDebounce(size?.width, { wait: 200 })
|
||
|
||
const { Document, Page } = useReactPdf()
|
||
|
||
const virtualizer = useVirtualizer({
|
||
count: numPages || 0,
|
||
getScrollElement: () => parentRef.current,
|
||
estimateSize: () => (pageHeight || 800) + 10,
|
||
overscan: 4,
|
||
enabled: !!pageHeight,
|
||
})
|
||
|
||
useEffect(() => {
|
||
setRenderedPages([])
|
||
setErrorText(null)
|
||
}, [containerWidth, url])
|
||
|
||
useEffect(() => {
|
||
if (containerWidth && aspectRatio) {
|
||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false
|
||
const newHeight = !aspectRatio || !containerWidth ? 800 : containerWidth / aspectRatio
|
||
const lastPageIndex = oldPageHeight.current
|
||
? Number(virtualizer.scrollOffset ?? 0) / oldPageHeight.current
|
||
: 0
|
||
|
||
setPageHeight(newHeight)
|
||
oldPageHeight.current = newHeight
|
||
virtualizer.measure()
|
||
|
||
if (parentRef.current) {
|
||
setTimeout(() => {
|
||
parentRef.current?.scrollTo({
|
||
top: lastPageIndex * newHeight,
|
||
behavior: 'auto',
|
||
})
|
||
}, 100)
|
||
}
|
||
}
|
||
}, [containerWidth, aspectRatio, virtualizer])
|
||
|
||
const onDocumentLoadSuccess = async (pdf: PdfLike) => {
|
||
setErrorText(null)
|
||
setNumPages(pdf.numPages)
|
||
const pageObj = await pdf.getPage(1)
|
||
const pageWidth = pageObj.view[2]
|
||
const firstPageHeight = pageObj.view[3]
|
||
const ratio = Number((pageWidth / firstPageHeight).toFixed(2))
|
||
setAspectRatio(ratio)
|
||
setRenderedPages([])
|
||
}
|
||
|
||
const handlePageRenderSuccess = (pageNumber: number) => {
|
||
setRenderedPages(prev => (prev.includes(pageNumber) ? prev : [...prev, pageNumber]))
|
||
}
|
||
|
||
if (errorText) {
|
||
return (
|
||
<div className="h-full w-full flex items-center justify-center text-sm text-destructive px-4 text-center">
|
||
PDF 预览加载失败:{errorText}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 浏览器端尚未加载到 react-pdf,给一个轻量的占位
|
||
if (!Document || !Page) {
|
||
return (
|
||
<div className="h-full w-full flex items-center justify-center text-sm text-muted-foreground px-4 text-center">
|
||
正在加载 PDF 预览组件...
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div ref={wrapperRef} className="w-full h-full mt-[80px]">
|
||
<div
|
||
ref={parentRef}
|
||
className="h-full overflow-scroll [&_.react-pdf__message.react-pdf__message--loading]:h-full [&::-webkit-scrollbar]:hidden scroll-smooth [-webkit-overflow-scrolling:touch]"
|
||
>
|
||
<Document
|
||
file={url}
|
||
loading={
|
||
<div className="h-full w-full flex items-center justify-center text-sm text-muted-foreground">
|
||
PDF 加载中...
|
||
</div>
|
||
}
|
||
onLoadError={(error) => {
|
||
console.error('react-pdf load failed:', error)
|
||
setErrorText(error instanceof Error ? error.message : '未知错误')
|
||
}}
|
||
onLoadSuccess={onDocumentLoadSuccess}
|
||
>
|
||
<div
|
||
style={{
|
||
height: `${virtualizer.getTotalSize()}px`,
|
||
width: '100%',
|
||
position: 'relative',
|
||
}}
|
||
>
|
||
{virtualizer.getVirtualItems().map(virtualRow => (
|
||
<div
|
||
key={virtualRow.index}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100%',
|
||
transform: `translateY(${virtualRow.start}px)`,
|
||
}}
|
||
className="w-full flex justify-center duration-200 transition-transform"
|
||
>
|
||
{!renderedPages.includes(virtualRow.index + 1) && (
|
||
<div
|
||
className="absolute top-0 left-0 right-0 z-1 flex items-center justify-center bg-card/94 text-sm text-muted-foreground duration-200 transition-transform backdrop-blur-sm"
|
||
style={{
|
||
height: `${pageHeight}px`,
|
||
}}
|
||
>
|
||
PDF 渲染中...
|
||
</div>
|
||
)}
|
||
<Page
|
||
loading={
|
||
<div className="h-full w-full flex items-center justify-center text-sm text-muted-foreground">
|
||
PDF 渲染中...
|
||
</div>
|
||
}
|
||
pageNumber={virtualRow.index + 1}
|
||
width={containerWidth}
|
||
onRenderSuccess={() => {
|
||
handlePageRenderSuccess(virtualRow.index + 1)
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Document>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|