343 lines
10 KiB
TypeScript
343 lines
10 KiB
TypeScript
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
|
import { CanvasLayer } from './CanvasLayer';
|
|
import { SelectionLayer } from './SelectionLayer';
|
|
import { AnnotationLayer } from './AnnotationLayer';
|
|
import type { Annotation } from './AnnotationLayer';
|
|
import { OverlayLayer } from './OverlayLayer';
|
|
import { SearchOverlayLayer } from './SearchOverlayLayer';
|
|
import type { Rect } from '../lib/coordinateMapping';
|
|
import { gatewayService } from '../lib/gatewayService';
|
|
import type { SearchResult, PageInfo } from '../lib/gatewayService';
|
|
|
|
interface PDFViewerProps {
|
|
documentId: string;
|
|
totalPages: number;
|
|
pageWidth: number;
|
|
pageHeight: number;
|
|
zoom: number;
|
|
pagesInfo?: PageInfo[];
|
|
activeTool: string;
|
|
annotations: Annotation[];
|
|
searchQuery?: string;
|
|
searchResults?: SearchResult[];
|
|
searchCurrentMatch?: number;
|
|
onAnnotationAdded?: (anno: Annotation) => void;
|
|
onPageVisible?: (pageIndex: number) => void;
|
|
}
|
|
|
|
interface PageLayout {
|
|
index: number;
|
|
width: number;
|
|
height: number;
|
|
top: number;
|
|
}
|
|
|
|
const generateUniqueId = () => `anno_${Math.random().toString(36).substring(2, 11)}`;
|
|
|
|
export interface PDFViewerRef {
|
|
scrollToPage: (pageIndex: number) => void;
|
|
}
|
|
|
|
export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|
documentId,
|
|
totalPages,
|
|
pageWidth,
|
|
pageHeight,
|
|
zoom,
|
|
pagesInfo,
|
|
activeTool,
|
|
annotations,
|
|
searchQuery,
|
|
searchResults,
|
|
searchCurrentMatch,
|
|
onAnnotationAdded,
|
|
onPageVisible,
|
|
}, ref) => {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
|
|
const [renderedPages, setRenderedPages] = useState<string[]>([]);
|
|
const [containerHeight, setContainerHeight] = useState(800);
|
|
|
|
// Reset cached page renders when switching documents
|
|
useEffect(() => {
|
|
setRenderedPages([]);
|
|
}, [documentId]);
|
|
|
|
// Use provided dimensions or fallback to Letter size
|
|
const basePageWidth = pageWidth || 612;
|
|
const basePageHeight = pageHeight || 792;
|
|
|
|
const pageGap = 24; // space between pages
|
|
|
|
// Calculate layout coordinates for all pages sequentially
|
|
const pageLayouts = useMemo(() => {
|
|
const layouts: PageLayout[] = [];
|
|
let currentTop = 0;
|
|
|
|
for (let i = 0; i < totalPages; i++) {
|
|
const pageInfo = pagesInfo?.[i];
|
|
const w = pageInfo ? pageInfo.width : basePageWidth;
|
|
const h = pageInfo ? pageInfo.height : basePageHeight;
|
|
|
|
layouts.push({
|
|
index: i,
|
|
width: w * zoom,
|
|
height: h * zoom,
|
|
top: currentTop,
|
|
});
|
|
|
|
currentTop += (h * zoom) + pageGap;
|
|
}
|
|
return layouts;
|
|
}, [totalPages, zoom, pagesInfo]);
|
|
|
|
const totalContentHeight = useMemo(() => {
|
|
if (pageLayouts.length === 0) return 0;
|
|
const lastPage = pageLayouts[pageLayouts.length - 1];
|
|
return lastPage.top + lastPage.height + pageGap;
|
|
}, [pageLayouts]);
|
|
|
|
// Handle scroll events to track viewport and update virtualized window
|
|
const handleScroll = () => {
|
|
if (!containerRef.current) return;
|
|
setScrollPosition({
|
|
scrollLeft: containerRef.current.scrollLeft,
|
|
scrollTop: containerRef.current.scrollTop,
|
|
});
|
|
};
|
|
|
|
React.useImperativeHandle(ref, () => ({
|
|
scrollToPage: (pageIndex: number) => {
|
|
if (containerRef.current && pageLayouts[pageIndex]) {
|
|
containerRef.current.scrollTop = pageLayouts[pageIndex].top;
|
|
}
|
|
}
|
|
}));
|
|
|
|
useEffect(() => {
|
|
const updateSize = () => {
|
|
if (containerRef.current) {
|
|
setContainerHeight(containerRef.current.clientHeight);
|
|
}
|
|
};
|
|
window.addEventListener('resize', updateSize);
|
|
updateSize();
|
|
return () => window.removeEventListener('resize', updateSize);
|
|
}, []);
|
|
|
|
// Compute which pages are currently visible (virtualized rendering)
|
|
const visiblePages = useMemo(() => {
|
|
const viewportTop = scrollPosition.scrollTop;
|
|
const viewportBottom = viewportTop + containerHeight;
|
|
|
|
// Buffer range: render 1 page before and 1 page after visible bounds
|
|
const buffer = 1;
|
|
const visible: PageLayout[] = [];
|
|
|
|
let currentVisiblePageIdx = 0;
|
|
let maxVisibleArea = 0;
|
|
|
|
pageLayouts.forEach((layout) => {
|
|
const pageTop = layout.top;
|
|
const pageBottom = pageTop + layout.height;
|
|
|
|
// Overlap calculation
|
|
const visibleTop = Math.max(viewportTop, pageTop);
|
|
const visibleBottom = Math.min(viewportBottom, pageBottom);
|
|
const overlapHeight = Math.max(0, visibleBottom - visibleTop);
|
|
|
|
if (overlapHeight > maxVisibleArea) {
|
|
maxVisibleArea = overlapHeight;
|
|
currentVisiblePageIdx = layout.index;
|
|
}
|
|
|
|
const isInside = (pageBottom >= viewportTop - (layout.height * buffer)) &&
|
|
(pageTop <= viewportBottom + (layout.height * buffer));
|
|
|
|
if (isInside) {
|
|
visible.push(layout);
|
|
}
|
|
});
|
|
|
|
// Notify parent of the primary page visible on screen
|
|
onPageVisible?.(currentVisiblePageIdx);
|
|
|
|
return visible;
|
|
}, [pageLayouts, scrollPosition.scrollTop, containerHeight, onPageVisible]);
|
|
|
|
// Load the rendered SVG/Image URL for each visible page
|
|
useEffect(() => {
|
|
let active = true;
|
|
const fetchPageImages = async () => {
|
|
const missingPages = visiblePages.filter((page) => !renderedPages[page.index]);
|
|
if (missingPages.length === 0) return;
|
|
|
|
const renders = await Promise.all(
|
|
missingPages.map(async (page) => {
|
|
const url = await gatewayService.renderPage({
|
|
documentId,
|
|
pageIndex: page.index,
|
|
zoom,
|
|
rotation: 0, // already rotated physically on backend
|
|
});
|
|
return { index: page.index, url };
|
|
})
|
|
);
|
|
|
|
if (!active) return;
|
|
|
|
setRenderedPages((prev) => {
|
|
const next = [...prev];
|
|
renders.forEach(({ index, url }) => {
|
|
next[index] = url;
|
|
});
|
|
return next;
|
|
});
|
|
};
|
|
|
|
fetchPageImages();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [visiblePages, documentId, zoom, renderedPages]);
|
|
|
|
const handleTextSelection = (text: string, bbox: Rect) => {
|
|
if (activeTool === 'highlight') {
|
|
const newAnno: Annotation = {
|
|
id: generateUniqueId(),
|
|
type: 'highlight',
|
|
bbox: {
|
|
x: bbox.x / zoom,
|
|
y: bbox.y / zoom,
|
|
width: bbox.width / zoom,
|
|
height: bbox.height / zoom,
|
|
},
|
|
author: 'Current User',
|
|
content: text,
|
|
};
|
|
onAnnotationAdded?.(newAnno);
|
|
}
|
|
};
|
|
|
|
const [isPanning, setIsPanning] = useState(false);
|
|
const [panStart, setPanStart] = useState({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (activeTool === 'pan' && containerRef.current) {
|
|
setIsPanning(true);
|
|
setPanStart({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
scrollLeft: containerRef.current.scrollLeft,
|
|
scrollTop: containerRef.current.scrollTop,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (isPanning && containerRef.current) {
|
|
const dx = e.clientX - panStart.x;
|
|
const dy = e.clientY - panStart.y;
|
|
containerRef.current.scrollLeft = panStart.scrollLeft - dx;
|
|
containerRef.current.scrollTop = panStart.scrollTop - dy;
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (isPanning) {
|
|
setIsPanning(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
onScroll={handleScroll}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
className={`viewer-viewport ${activeTool === 'pan' ? (isPanning ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
|
|
>
|
|
<div
|
|
className="viewer-content-container"
|
|
style={{ height: `${totalContentHeight}px` }}
|
|
>
|
|
{visiblePages.map((page) => {
|
|
const imageUrl = renderedPages[page.index] || '';
|
|
|
|
return (
|
|
<div
|
|
key={page.index}
|
|
className="page-container shadow-premium"
|
|
style={{
|
|
top: `${page.top}px`,
|
|
width: `${page.width}px`,
|
|
height: `${page.height}px`,
|
|
}}
|
|
>
|
|
{imageUrl ? (
|
|
<>
|
|
{/* Base PDF Canvas Layer */}
|
|
<CanvasLayer
|
|
pageIndex={page.index}
|
|
imageUrl={imageUrl}
|
|
zoom={zoom}
|
|
rotation={0} // already rotated physically on backend
|
|
width={page.width}
|
|
height={page.height}
|
|
/>
|
|
|
|
{/* Highlight/Comment Annotation Layer */}
|
|
<AnnotationLayer
|
|
pageIndex={page.index}
|
|
width={page.width}
|
|
height={page.height}
|
|
zoom={zoom}
|
|
annotations={annotations}
|
|
/>
|
|
|
|
{/* Text Selection Dragging Layer */}
|
|
<SelectionLayer
|
|
pageIndex={page.index}
|
|
width={page.width}
|
|
height={page.height}
|
|
zoom={zoom}
|
|
onTextSelected={(text, bbox) => handleTextSelection(text, bbox)}
|
|
/>
|
|
|
|
{/* signature/ink overlay tool layer */}
|
|
<OverlayLayer
|
|
pageIndex={page.index}
|
|
width={page.width}
|
|
height={page.height}
|
|
activeTool={activeTool}
|
|
zoom={zoom}
|
|
onAnnotationAdded={onAnnotationAdded}
|
|
/>
|
|
|
|
{/* Search highlights overlay */}
|
|
<SearchOverlayLayer
|
|
pageIndex={page.index}
|
|
width={page.width}
|
|
height={page.height}
|
|
zoom={zoom}
|
|
searchQuery={searchQuery || ''}
|
|
searchResults={searchResults}
|
|
searchCurrentMatch={searchCurrentMatch}
|
|
/>
|
|
</>
|
|
) : (
|
|
<div className="page-loading-state">
|
|
<div className="spinner" />
|
|
<span className="page-loading-label">Loading Page {page.index + 1}...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|