初始化模版工程
This commit is contained in:
217
components/nova-sdk/task-panel/Preview/VirtualPdfPreview.tsx
Normal file
217
components/nova-sdk/task-panel/Preview/VirtualPdfPreview.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user