Files
pdf/frontend/src/viewer/PDFViewer.tsx
T
2026-06-08 11:28:43 +05:30

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>
);
});