Files
pdf/frontend/src/App.tsx
T
2026-06-09 10:35:12 +05:30

436 lines
14 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { Toolbar } from './components/Toolbar';
import { Sidebar } from './components/Sidebar';
import { PDFViewer } from './viewer/PDFViewer';
import type { PDFViewerRef } from './viewer/PDFViewer';
import type { Annotation } from './viewer/AnnotationLayer';
import { gatewayService } from './lib/gatewayService';
import type { DocumentInfo, SearchResult, EditOperation } from './lib/gatewayService';
import { wasmLoader } from './lib/wasmLoader';
import './App.css';
function App() {
const viewerRef = useRef<PDFViewerRef>(null);
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
const [selectedDocId, setSelectedDocId] = useState<string>('');
const [activeDoc, setActiveDoc] = useState<DocumentInfo | null>(null);
// Settings
const [zoom, setZoom] = useState<number>(1.0);
const [activeTool, setActiveTool] = useState<string>('select');
const [currentPage, setCurrentPage] = useState<number>(0);
// States
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
const [sidebarTab, setSidebarTab] = useState<'documents' | 'annotations' | 'outline'>('documents');
const [isLoading, setIsLoading] = useState<boolean>(true);
// Search State
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchResultCount, setSearchResultCount] = useState(0);
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
const handleSearch = async (query: string) => {
setSearchQuery(query);
if (!query || !selectedDocId) {
setSearchResults([]);
setSearchResultCount(0);
setSearchCurrentMatch(0);
return;
}
try {
const results = await gatewayService.searchDocument(selectedDocId, query);
setSearchResults(results);
setSearchResultCount(results.length);
setSearchCurrentMatch(0);
if (results.length > 0) {
viewerRef.current?.scrollToPage(results[0].pageIndex);
}
} catch (err) {
console.error("Search failed", err);
setSearchResults([]);
setSearchResultCount(0);
setSearchCurrentMatch(0);
}
};
const handleSearchNext = () => {
if (searchResultCount > 0) {
const nextMatch = (searchCurrentMatch + 1) % searchResultCount;
setSearchCurrentMatch(nextMatch);
viewerRef.current?.scrollToPage(searchResults[nextMatch].pageIndex);
}
};
const handleSearchPrev = () => {
if (searchResultCount > 0) {
const prevMatch = (searchCurrentMatch - 1 + searchResultCount) % searchResultCount;
setSearchCurrentMatch(prevMatch);
viewerRef.current?.scrollToPage(searchResults[prevMatch].pageIndex);
}
};
// Check Gateway Health on mount
useEffect(() => {
const checkHealth = async () => {
try {
const health = await gatewayService.getHealth();
setBackendHealthy(health.engine_available || true);
} catch {
setBackendHealthy(false);
}
};
checkHealth();
}, []);
// Fetch Documents list
useEffect(() => {
const fetchDocs = async () => {
try {
setIsLoading(true);
const docs = await gatewayService.listDocuments();
setDocuments(docs);
if (docs.length > 0) {
const defaultDoc = docs[0];
setSelectedDocId(defaultDoc.id);
setActiveDoc(defaultDoc);
}
} catch (err) {
console.error('Failed to load documents list', err);
} finally {
setIsLoading(false);
}
};
fetchDocs();
}, []);
// Load selected document metadata
useEffect(() => {
const loadDocMetadata = async () => {
if (!selectedDocId) return;
try {
setIsLoading(true);
const doc = await gatewayService.getDocument(selectedDocId);
setActiveDoc(doc);
setCurrentPage(0);
// Fetch document annotations
const backendAnnots = await gatewayService.getDocumentAnnotations(selectedDocId);
// Map backend annotations to frontend format
const frontendAnnots: Annotation[] = backendAnnots.map(a => ({
id: a.id,
type: a.type as any,
bbox: { x: a.x, y: a.y, width: a.width, height: a.height },
color: a.color,
author: a.author,
content: a.content,
timestamp: (a as any).timestamp,
pageIndex: a.pageIndex
}));
console.log("Loaded Annotations:", frontendAnnots);
setAnnotations(frontendAnnots);
} catch (err) {
console.error('Failed to load document metadata', err);
} finally {
setIsLoading(false);
}
};
loadDocMetadata();
}, [selectedDocId]);
// Load WASM Engine (Simulated Phase 0 loading)
useEffect(() => {
const loadWasm = async () => {
console.log('[WASM] Initializing Emscripten compiler context...');
const instance = await wasmLoader.loadEngine();
console.log(`[WASM] C++ Core initialized: version ${instance.version}`);
// Query build information from C++ Core running in the browser!
const buildInfo = instance.engineBuildInfo();
console.log(`[WASM] ${buildInfo}`); // Prints: "pdfengine 0.1.0 (pdfium=off) (skia=on)"
const hasSkia = instance.engineHasSkia();
if (hasSkia) {
console.log("Client-side rendering is ready with Skia WASM Canvas!");
}
};
loadWasm();
}, []);
const handleUploadStart = async (file: File) => {
try {
setIsLoading(true);
const newDoc = await gatewayService.uploadDocument(file);
setDocuments((prev) => [newDoc, ...prev]);
setSelectedDocId(newDoc.id);
setActiveDoc(newDoc);
} catch (err) {
console.error('File upload failed', err);
} finally {
setIsLoading(false);
}
};
const handleAnnotationAdded = async (newAnno: Annotation) => {
setAnnotations((prev) => [...prev, newAnno]);
setSidebarTab('annotations');
if (!selectedDocId || !activeDoc) return;
try {
setIsLoading(true);
let op: EditOperation | null = null;
if (newAnno.type === 'highlight') {
op = {
id: newAnno.id,
type: 'highlight',
pageIndex: newAnno.pageIndex ?? currentPage,
data: {
quadPoints: [{
x1: newAnno.bbox.x, y1: newAnno.bbox.y + newAnno.bbox.height, // bottom-left
x2: newAnno.bbox.x + newAnno.bbox.width, y2: newAnno.bbox.y + newAnno.bbox.height, // bottom-right
x3: newAnno.bbox.x + newAnno.bbox.width, y3: newAnno.bbox.y, // top-right
x4: newAnno.bbox.x, y4: newAnno.bbox.y // top-left
}],
color: newAnno.color || '#ffff00',
opacity: 0.5,
author: newAnno.author,
content: newAnno.content
}
};
} else if (newAnno.type === 'ink' && newAnno.paths) {
op = {
id: newAnno.id,
type: 'freehand',
pageIndex: newAnno.pageIndex ?? currentPage,
data: {
paths: newAnno.paths,
color: newAnno.color || '#3b82f6',
thickness: 2.0
}
};
} else if (newAnno.type === 'comment') {
op = {
id: newAnno.id,
type: 'comment',
pageIndex: newAnno.pageIndex ?? currentPage,
data: {
x: newAnno.bbox.x,
y: newAnno.bbox.y,
author: newAnno.author,
content: newAnno.content || '',
timestamp: newAnno.timestamp
}
};
}
if (op) {
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
const docs = await gatewayService.listDocuments();
setDocuments(docs);
setSelectedDocId(result.newDocumentId);
}
}
} catch (err) {
console.error('Failed to save annotation:', err);
} finally {
setIsLoading(false);
}
};
const handleRotateClick = async (newRotValue?: number) => {
if (!selectedDocId || !activeDoc) return;
const pageIndex = currentPage;
try {
setIsLoading(true);
const op = {
id: `rot_${Math.random().toString(36).substring(2, 11)}`,
type: 'page_rotation' as const,
pageIndex: pageIndex,
data: {
rotation: 90 as const
}
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
// Re-fetch document list so sidebar updates
const docs = await gatewayService.listDocuments();
setDocuments(docs);
// Select the new document ID
setSelectedDocId(result.newDocumentId);
}
} catch (err) {
console.error('Failed to rotate page:', err);
alert('Failed to rotate page. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
const handleDeletePage = async (pageIndex: number) => {
if (!selectedDocId || !activeDoc) return;
if (activeDoc.totalPages <= 1) {
alert("Cannot delete the only page in the document.");
return;
}
if (!confirm(`Are you sure you want to delete Page ${pageIndex + 1}? This cannot be undone.`)) {
return;
}
try {
setIsLoading(true);
const op = {
id: `del_${Math.random().toString(36).substring(2, 11)}`,
type: 'page_deletion' as const,
pageIndex: pageIndex,
data: {}
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
// Re-fetch document list so sidebar updates
const docs = await gatewayService.listDocuments();
setDocuments(docs);
// Select the new document ID
setSelectedDocId(result.newDocumentId);
// Adjust currentPage if it's out of bounds after deletion
if (currentPage >= activeDoc.totalPages - 1) {
setCurrentPage(Math.max(0, activeDoc.totalPages - 2));
}
}
} catch (err) {
console.error('Failed to delete page:', err);
alert('Failed to delete page. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
const handleReorderPage = async (pageIndex: number, destPageIndex: number) => {
if (!selectedDocId || !activeDoc) return;
if (destPageIndex < 0 || destPageIndex >= activeDoc.totalPages) return;
try {
setIsLoading(true);
const op = {
id: `reorder_${Math.random().toString(36).substring(2, 11)}`,
type: 'page_reorder' as const,
pageIndex: pageIndex,
data: {
destPageIndex: destPageIndex
}
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
// Re-fetch document list so sidebar updates
const docs = await gatewayService.listDocuments();
setDocuments(docs);
// Select the new document ID
setSelectedDocId(result.newDocumentId);
// Update current page view to follow the moved page
setCurrentPage(destPageIndex);
}
} catch (err) {
console.error('Failed to reorder page:', err);
alert('Failed to reorder page. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
return (
<div className="w-screen h-screen flex flex-col overflow-hidden bg-slate-950 font-sans text-slate-100 antialiased">
{/* Top Navigation / Toolbar */}
<Toolbar
zoom={zoom}
onZoomChange={setZoom}
rotation={0}
onRotationChange={handleRotateClick}
activeTool={activeTool}
onActiveToolChange={setActiveTool}
currentPage={currentPage}
totalPages={activeDoc?.totalPages || 1}
onUploadStart={handleUploadStart}
backendHealthy={backendHealthy}
onSearch={handleSearch}
searchResultCount={searchResultCount}
searchCurrentMatch={searchCurrentMatch}
onSearchNext={handleSearchNext}
onSearchPrev={handleSearchPrev}
/>
<div className="flex-1 w-full flex overflow-hidden">
{/* Left Interactive Sidebar */}
<Sidebar
documents={documents}
selectedDocumentId={selectedDocId}
onSelectDocument={setSelectedDocId}
annotations={annotations}
totalPages={activeDoc?.totalPages || 0}
activeTab={sidebarTab}
setActiveTab={setSidebarTab}
onNavigateToPage={(pageIndex) => {
viewerRef.current?.scrollToPage(pageIndex);
}}
onDeletePage={handleDeletePage}
onReorderPage={handleReorderPage}
/>
{/* Main PDF Scroll Viewer Area */}
{isLoading ? (
<div className="flex-1 h-full flex flex-col items-center justify-center bg-slate-900 gap-4">
<div className="relative w-14 h-14 flex items-center justify-center">
<div className="absolute w-full h-full border-4 border-indigo-500/20 rounded-full" />
<div className="absolute w-full h-full border-4 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
<p className="text-sm font-bold tracking-widest uppercase text-indigo-400 animate-pulse">Loading Document Canvas...</p>
</div>
) : activeDoc ? (
<PDFViewer
ref={viewerRef}
documentId={activeDoc.id}
totalPages={activeDoc.totalPages}
pageWidth={activeDoc.pageWidth}
pageHeight={activeDoc.pageHeight}
zoom={zoom}
pagesInfo={activeDoc.pages}
activeTool={activeTool}
annotations={annotations}
searchQuery={searchQuery}
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
onAnnotationAdded={handleAnnotationAdded}
onPageVisible={setCurrentPage}
/>
) : (
<div className="flex-1 h-full flex flex-col items-center justify-center bg-slate-900 gap-3 text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" className="w-12 h-12 text-slate-650" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414-5.414a1 1 0 01.707.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm font-bold">No active document. Please upload a PDF file.</p>
</div>
)}
</div>
</div>
);
}
export default App;