ebcaf05625
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
/**
|
||
* PDF预览组件
|
||
* 从 FilePreview.tsx 中抽取的 PDF 专用渲染组件
|
||
*/
|
||
import { useState, useEffect, useRef, ChangeEvent } from 'react';
|
||
import { Document, Page, pdfjs } from 'react-pdf';
|
||
import { toastService } from '~/components/ui/Toast';
|
||
|
||
// 导入react-pdf的CSS样式(文本层和注释层必需)
|
||
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||
|
||
// 设置worker路径
|
||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||
|
||
/**
|
||
* 自定义样式
|
||
* 这些样式解决了PDF页面在放大时互相重叠的问题
|
||
*/
|
||
const styles = {
|
||
pdfContainer: {
|
||
display: 'flex',
|
||
flexDirection: 'column' as const,
|
||
alignItems: 'center',
|
||
width: '100%',
|
||
position: 'relative' as const,
|
||
},
|
||
pageContainer: {
|
||
display: 'flex',
|
||
flexDirection: 'column' as const,
|
||
alignItems: 'center',
|
||
width: '100%',
|
||
position: 'relative' as const,
|
||
}
|
||
};
|
||
|
||
interface PdfPreviewProps {
|
||
filePath: string; // PDF 文件路径
|
||
targetPage?: number; // 目标页码
|
||
charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息(用于高亮显示)
|
||
isStructuredView?: boolean; // 是否结构化视图
|
||
activeReviewPointResultId?: string | null; // 激活的评查点结果ID
|
||
pageOffset?: number; // 页码偏移量(用于调整 OCR 结果的页码)
|
||
onNumPagesChange?: (numPages: number) => void; // 页数变化回调
|
||
onZoomChange?: (zoomLevel: number) => void; // 缩放变化回调
|
||
}
|
||
|
||
export function PdfPreview({
|
||
filePath,
|
||
targetPage,
|
||
charPositions,
|
||
isStructuredView = false,
|
||
activeReviewPointResultId,
|
||
pageOffset = 0,
|
||
onNumPagesChange,
|
||
onZoomChange
|
||
}: PdfPreviewProps) {
|
||
// 调试日志
|
||
// console.log('[PdfPreview] 组件渲染', {
|
||
// filePath,
|
||
// targetPage,
|
||
// charPositions,
|
||
// isStructuredView,
|
||
// activeReviewPointResultId,
|
||
// pageOffset
|
||
// });
|
||
|
||
// ============ 状态管理 ============
|
||
const [numPages, setNumPages] = useState<number | null>(null);
|
||
const [zoomLevel, setZoomLevel] = useState(100);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
const [pageInputValue, setPageInputValue] = useState<string>('');
|
||
|
||
// 坐标校准参数
|
||
const [coordinateScale, setCoordinateScale] = useState(0.83); // 坐标缩放系数(默认0.83)
|
||
const [pdfOriginalWidth, setPdfOriginalWidth] = useState<number>(0); // PDF原始尺寸
|
||
const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false); // 是否已自动计算缩放
|
||
|
||
// 引用
|
||
const contentRef = useRef<HTMLDivElement>(null);
|
||
const prevTargetPageRef = useRef<number | undefined>(undefined);
|
||
|
||
// ============ 副作用 - 页数变化通知 ============
|
||
useEffect(() => {
|
||
if (numPages && onNumPagesChange) {
|
||
onNumPagesChange(numPages);
|
||
}
|
||
}, [numPages, onNumPagesChange]);
|
||
|
||
// ============ 副作用 - 缩放变化通知 ============
|
||
useEffect(() => {
|
||
if (onZoomChange) {
|
||
onZoomChange(zoomLevel);
|
||
}
|
||
}, [zoomLevel, onZoomChange]);
|
||
|
||
// ============ 副作用 - 页面跳转 ============
|
||
useEffect(() => {
|
||
if (targetPage && numPages && targetPage <= numPages) {
|
||
prevTargetPageRef.current = targetPage;
|
||
const newTargetPage = targetPage + pageOffset;
|
||
|
||
const pageElementId = `page-${newTargetPage}${isStructuredView ? '-structured' : ''}`;
|
||
const pageElement = document.getElementById(pageElementId);
|
||
|
||
if (pageElement) {
|
||
// 直接跳转,不使用滚动动画
|
||
pageElement.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||
} else {
|
||
console.warn(`未找到页面元素: ${pageElementId}`);
|
||
}
|
||
}
|
||
}, [targetPage, numPages, pageOffset, activeReviewPointResultId, isStructuredView]);
|
||
|
||
// ============ 副作用 - 重置坐标缩放计算标志 ============
|
||
useEffect(() => {
|
||
// 只在文件路径变化时重置自动计算标志
|
||
// zoom变化时不应该重新计算coordinateScale,因为它是基于PDF原始尺寸的固定值
|
||
setIsScaleAutoCalculated(false);
|
||
}, [filePath]);
|
||
|
||
// ============ PDF 加载事件处理 ============
|
||
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
|
||
setNumPages(numPages);
|
||
console.log("PDF加载成功,页数:", numPages);
|
||
};
|
||
|
||
const onDocumentLoadError = (error: Error) => {
|
||
console.error("PDF加载错误:", error);
|
||
setLoadError("PDF文档加载失败:" + (error.message || "未知错误"));
|
||
};
|
||
|
||
// ============ 缩放控制 ============
|
||
const handleZoomIn = () => {
|
||
if (zoomLevel < 200) {
|
||
setZoomLevel(prevZoom => prevZoom + 10);
|
||
}
|
||
};
|
||
|
||
const handleZoomOut = () => {
|
||
if (zoomLevel > 50) {
|
||
setZoomLevel(prevZoom => prevZoom - 10);
|
||
}
|
||
};
|
||
|
||
// ============ 页码跳转控制 ============
|
||
const handlePageInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||
const value = e.target.value.replace(/\D/g, '');
|
||
setPageInputValue(value);
|
||
};
|
||
|
||
const handlePageJump = () => {
|
||
if (!pageInputValue || !numPages) return;
|
||
|
||
const targetPageNum = parseInt(pageInputValue, 10);
|
||
|
||
if (targetPageNum > 0 && targetPageNum <= numPages) {
|
||
const pageElement = document.getElementById(`page-${targetPageNum}`);
|
||
if (pageElement) {
|
||
// 直接跳转,不使用滚动动画
|
||
pageElement.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||
setPageInputValue('');
|
||
}
|
||
} else {
|
||
toastService.warning(`请输入有效页码 (1-${numPages})`);
|
||
setPageInputValue('');
|
||
}
|
||
};
|
||
|
||
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === 'Enter') {
|
||
handlePageJump();
|
||
}
|
||
};
|
||
|
||
// ============ 滚动到顶部 ============
|
||
const handleScrollToTop = () => {
|
||
if (contentRef.current) {
|
||
// 直接跳转到顶部,不使用滚动动画
|
||
contentRef.current.scrollTo({ top: 0, behavior: 'auto' });
|
||
}
|
||
};
|
||
|
||
// ============ 计算页面间距 ============
|
||
const calculatePageMargin = (zoomFactor: number) => {
|
||
// 固定间距,不随缩放变化,避免页面偏移
|
||
return 32; // 相当于 Tailwind 的 mb-8
|
||
};
|
||
|
||
// ============ 页面加载成功处理(用于自动计算坐标缩放) ============
|
||
const onPageLoadSuccess = (page: any) => {
|
||
// 只在第一页加载时计算一次坐标缩放系数
|
||
if (page.pageNumber === 1 && !isScaleAutoCalculated) {
|
||
setTimeout(() => {
|
||
// 获取PDF原始宽度(单位:点)
|
||
const pdfOriginalWidthPt = page.view?.[2] || page.originalWidth || page.width;
|
||
|
||
// 获取实际渲染的canvas元素
|
||
const canvas = document.querySelector('.react-pdf__Page__canvas') as HTMLCanvasElement;
|
||
|
||
if (canvas && pdfOriginalWidthPt) {
|
||
// 获取canvas在屏幕上的显示宽度
|
||
const canvasDisplayWidth = canvas.offsetWidth;
|
||
|
||
// 获取当前的zoom因子
|
||
const currentScale = zoomLevel / 100;
|
||
|
||
// 计算基于100% zoom的坐标缩放系数
|
||
// 需要除以当前的zoom因子,得到100%时的真实比例
|
||
const autoScale = (canvasDisplayWidth / currentScale) / pdfOriginalWidthPt;
|
||
|
||
console.log('自动计算坐标缩放:', {
|
||
pdfOriginalWidth: pdfOriginalWidthPt,
|
||
canvasDisplayWidth,
|
||
currentZoom: zoomLevel,
|
||
currentScale,
|
||
autoScale
|
||
});
|
||
|
||
setPdfOriginalWidth(pdfOriginalWidthPt);
|
||
setCoordinateScale(autoScale);
|
||
setIsScaleAutoCalculated(true);
|
||
}
|
||
}, 200);
|
||
}
|
||
};
|
||
|
||
// ============ 处理字符位置数据,转换为高亮矩形 ============
|
||
const processCharPositionsToHighlights = () => {
|
||
if (!charPositions || charPositions.length === 0 || !targetPage) {
|
||
return null;
|
||
}
|
||
|
||
const scale = zoomLevel / 100;
|
||
|
||
// 计算所有字符的边界框,形成一个连贯的高亮区域
|
||
let minX = Infinity;
|
||
let minY = Infinity;
|
||
let maxX = -Infinity;
|
||
let maxY = -Infinity;
|
||
|
||
// 收集所有字符文本
|
||
const allChars = charPositions.map(cp => cp.char).join('');
|
||
|
||
// 遍历所有字符,找出整体的边界
|
||
charPositions.forEach(charPos => {
|
||
const box = charPos.box;
|
||
|
||
// box是一个4个点的数组: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
||
// 从所有点中找出最小和最大的坐标
|
||
box.forEach(point => {
|
||
const x = point[0];
|
||
const y = point[1];
|
||
|
||
if (x < minX) minX = x;
|
||
if (x > maxX) maxX = x;
|
||
if (y < minY) minY = y;
|
||
if (y > maxY) maxY = y;
|
||
});
|
||
});
|
||
|
||
// 计算连贯的高亮矩形,应用坐标缩放和zoom缩放
|
||
const x = minX * coordinateScale * scale;
|
||
const y = minY * coordinateScale * scale;
|
||
const width = (maxX - minX) * coordinateScale * scale;
|
||
const height = (maxY - minY) * coordinateScale * scale;
|
||
|
||
return {
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
text: allChars
|
||
};
|
||
};
|
||
|
||
// ============ 渲染所有PDF页面 ============
|
||
const renderAllPages = () => {
|
||
if (!numPages) return null;
|
||
|
||
const pages = [];
|
||
const zoomFactor = zoomLevel / 100;
|
||
const highlight = processCharPositionsToHighlights();
|
||
|
||
for (let i = 1; i <= numPages; i++) {
|
||
const pageContainerStyle = {
|
||
...styles.pageContainer,
|
||
marginBottom: `${calculatePageMargin(zoomFactor)}px`,
|
||
};
|
||
|
||
const pageId = isStructuredView ? `page-${i}-structured` : `page-${i}`;
|
||
|
||
// 计算调整后的目标页码(考虑pageOffset)
|
||
const adjustedTargetPage = targetPage ? targetPage + pageOffset : undefined;
|
||
|
||
// 判断当前页是否应该显示高亮
|
||
const shouldShowHighlight = adjustedTargetPage === i && highlight !== null;
|
||
|
||
pages.push(
|
||
<div key={i} id={pageId} style={pageContainerStyle}>
|
||
<div className="text-center text-gray-500 text-sm mb-2">第 {i} 页</div>
|
||
<div
|
||
className="page-wrapper"
|
||
style={{
|
||
position: 'relative',
|
||
display: 'inline-block',
|
||
margin: '0 auto',
|
||
}}
|
||
>
|
||
<Page
|
||
pageNumber={i}
|
||
scale={zoomLevel / 100}
|
||
devicePixelRatio={window.devicePixelRatio || 1}
|
||
renderTextLayer={false}
|
||
renderAnnotationLayer={false}
|
||
className="border border-gray-300 shadow-md"
|
||
onLoadSuccess={onPageLoadSuccess}
|
||
/>
|
||
|
||
{/* 坐标高亮层 - 渲染连贯的高亮区域 */}
|
||
{shouldShowHighlight && highlight && (
|
||
<svg
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100%',
|
||
height: '100%',
|
||
pointerEvents: 'none',
|
||
zIndex: 12
|
||
}}
|
||
>
|
||
<rect
|
||
x={highlight.x}
|
||
y={highlight.y}
|
||
width={highlight.width}
|
||
height={highlight.height}
|
||
fill="#00AA00"
|
||
fillOpacity="0.1"
|
||
stroke="#00684a"
|
||
strokeWidth="0.5"
|
||
rx="2"
|
||
>
|
||
<title>{`高亮文本: ${highlight.text}`}</title>
|
||
</rect>
|
||
</svg>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return pages;
|
||
};
|
||
|
||
// ============ 主渲染 ============
|
||
return (
|
||
<div className="pdf-preview-component">
|
||
{/* 工具栏 */}
|
||
<div className="file-preview-header px-2 text-xs sm:text-xs md:text-sm max-w-full flex items-center justify-between min-w-0">
|
||
<div className="flex items-center min-w-0 flex-shrink-0">
|
||
<i className={`${isStructuredView ? 'ri-file-list-line' : 'ri-file-text-line'} text-primary mr-2 flex-shrink-0`}></i>
|
||
<span className="font-medium text-primary truncate max-w-[120px]" title={isStructuredView ? '模板预览' : '文件预览'}>
|
||
{isStructuredView ? '模板预览' : '文件预览'}
|
||
</span>
|
||
</div>
|
||
<div className="file-preview-actions flex items-center ml-2 min-w-0 flex-1 justify-end overflow-hidden gap-2">
|
||
<button
|
||
className="flex items-center justify-center px-2 py-1 text-xs text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 flex-shrink-0 outline-none"
|
||
onClick={handleScrollToTop}
|
||
title="返回顶部"
|
||
>
|
||
<i className="ri-arrow-up-double-line text-sm"></i>
|
||
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">返回顶部</span>
|
||
</button>
|
||
<button
|
||
className="flex items-center justify-center w-7 h-7 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 flex-shrink-0 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
|
||
onClick={handleZoomIn}
|
||
title="放大"
|
||
disabled={zoomLevel >= 200}
|
||
>
|
||
<i className="ri-zoom-in-line text-sm"></i>
|
||
</button>
|
||
<button
|
||
className="flex items-center justify-center w-7 h-7 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 flex-shrink-0 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
|
||
onClick={handleZoomOut}
|
||
title="缩小"
|
||
disabled={zoomLevel <= 50}
|
||
>
|
||
<i className="ri-zoom-out-line text-sm"></i>
|
||
</button>
|
||
{/* 页码跳转控件
|
||
<div className="inline-flex items-center flex-shrink-0 gap-1">
|
||
<input
|
||
type="text"
|
||
className="w-12 h-7 px-2 text-xs text-center text-gray-700 bg-white border border-gray-300 rounded outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-colors duration-200"
|
||
placeholder="页码"
|
||
value={pageInputValue}
|
||
onChange={handlePageInputChange}
|
||
onKeyDown={handlePageInputKeyDown}
|
||
/>
|
||
<button
|
||
className="flex items-center justify-center w-7 h-7 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
|
||
onClick={handlePageJump}
|
||
disabled={!numPages}
|
||
title="跳转到页面"
|
||
>
|
||
<i className="ri-arrow-right-line text-sm"></i>
|
||
</button>
|
||
{numPages && (
|
||
<span className="ml-1 text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
|
||
/ {numPages}
|
||
</span>
|
||
)}
|
||
</div> */}
|
||
{/* 页码跳转控件 */}
|
||
<div className="inline-flex items-center bg-white border border-gray-300 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 flex-shrink-0 overflow-hidden">
|
||
<div className="flex items-center px-2 py-1">
|
||
<i className="ri-file-list-line text-sm text-gray-400 mr-1.5"></i>
|
||
<input
|
||
type="text"
|
||
className="w-10 h-5 px-1 text-xs text-center text-gray-700 bg-transparent border-0 outline-none focus:text-primary transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
placeholder="页码"
|
||
value={pageInputValue}
|
||
onChange={handlePageInputChange}
|
||
onKeyDown={handlePageInputKeyDown}
|
||
/>
|
||
{numPages && (
|
||
<span className="text-xs text-gray-400 mx-0.5">/</span>
|
||
)}
|
||
{numPages && (
|
||
<span className="text-xs text-gray-500 font-medium min-w-[1.5rem] text-center">
|
||
{numPages}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<button
|
||
className="flex items-center justify-center h-7 px-2.5 text-white bg-primary hover:bg-primary-hover transition-colors duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-primary border-l border-primary-hover/20"
|
||
onClick={handlePageJump}
|
||
disabled={!numPages}
|
||
title="跳转"
|
||
>
|
||
<i className="ri-skip-forward-mini-line text-sm"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<span className="text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap flex-shrink-0">
|
||
比例:{zoomLevel}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* PDF内容区域 */}
|
||
<div
|
||
className="file-preview-content"
|
||
ref={contentRef}
|
||
style={{
|
||
maxHeight: 'calc(100vh - 150px)',
|
||
overflowY: 'auto',
|
||
overflowX: 'auto',
|
||
}}
|
||
>
|
||
<div
|
||
className="pdf-interactive-container"
|
||
style={{
|
||
position: 'relative',
|
||
height: '100%',
|
||
width: '100%',
|
||
display: 'block',
|
||
textAlign: 'center',
|
||
padding: 0
|
||
}}
|
||
>
|
||
{loadError ? (
|
||
<div className="text-red-500 p-4">
|
||
<p>{loadError}</p>
|
||
</div>
|
||
) : (
|
||
<div
|
||
style={{
|
||
...styles.pdfContainer,
|
||
width: '100%',
|
||
overflow: 'visible'
|
||
}}
|
||
>
|
||
<Document
|
||
file={`/api/pdf-proxy?path=${encodeURIComponent(filePath)}`}
|
||
onLoadSuccess={onDocumentLoadSuccess}
|
||
onLoadError={onDocumentLoadError}
|
||
className="w-full"
|
||
error={<div className="text-red-500">PDF文档加载失败,请检查链接或网络连接。</div>}
|
||
noData={<div>无数据</div>}
|
||
loading={<div className="text-center py-10">PDF加载中...</div>}
|
||
>
|
||
{renderAllPages()}
|
||
</Document>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|