436 lines
14 KiB
TypeScript
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;
|