Files
pdf/frontend/src/App.tsx
T

681 lines
28 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { TopBar } from './components/TopBar';
import { ToolRail } from './components/ToolRail';
import { Toolbar } from './components/Toolbar';
import { InspectorPanel } from './components/InspectorPanel';
import type { InspectorTab } from './components/InspectorPanel';
import { SignatureModal } from './components/SignatureModal';
import { AboutModal } from './components/AboutModal';
import { ToastViewport } from './components/ui';
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
import { PDFViewer } from './viewer/PDFViewer';
import type { PDFViewerRef } from './viewer/PDFViewer';
import type { Annotation } from './viewer/AnnotationLayer';
import type { EditableRun, ReflowParagraphPayload } from './viewer/TextEditLayer';
import { gatewayService, PasswordError } from './lib/gatewayService';
import { PasswordModal } from './components/PasswordModal';
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
import { viewportRectToPdf } from './lib/coordinateMapping';
import type { Rect } from './lib/coordinateMapping';
import { toast } from './lib/toast';
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools';
import type { ToolId, ToolSettings } from './lib/tools';
const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`;
function App() {
const viewerRef = useRef<PDFViewerRef>(null);
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
const [activeDoc, setActiveDoc] = useState<DocumentInfo | null>(null);
const [hist, setHist] = useState<{ stack: string[]; index: number }>({ stack: [], index: -1 });
const selectedDocId = hist.index >= 0 ? hist.stack[hist.index] : '';
const canUndo = hist.index > 0;
const canRedo = hist.index < hist.stack.length - 1;
const preservePageRef = useRef(false);
const permissions = activeDoc?.permissions ?? null;
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
const denyToast = (label: string) =>
toast(`${label} is not permitted by this document's restrictions`, 'error');
const disabledTools = new Set<ToolId>();
if (!can('canAnnotate'))
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
if (!can('canModify')) (['edit_text', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t));
const disabledToolsRef = useRef(disabledTools);
disabledToolsRef.current = disabledTools;
const [zoom, setZoom] = useState(1.0);
const [activeTool, setActiveTool] = useState<ToolId>('select');
const [toolSettings, setToolSettings] = useState<ToolSettings>(DEFAULT_TOOL_SETTINGS);
const [currentPage, setCurrentPage] = useState(0);
const [isInspectorOpen, setIsInspectorOpen] = useState(true);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
const [fonts, setFonts] = useState<FontInfo[]>([]);
const [outline, setOutline] = useState<OutlineItem[]>([]);
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
const [engineReady, setEngineReady] = useState(false);
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('pages');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
const [aboutModalOpen, setAboutModalOpen] = useState(false);
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null);
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
const [searchWholeWords, setSearchWholeWords] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
const openDocument = useCallback((id: string) => setHist({ stack: [id], index: 0 }), []);
const pushHistory = (id: string) =>
setHist((h) => ({ stack: [...h.stack.slice(0, h.index + 1), id], index: h.index + 1 }));
const undo = () => {
if (!canUndo) return;
preservePageRef.current = true;
setHist((h) => ({ ...h, index: Math.max(0, h.index - 1) }));
toast('Undo', 'info', 1200);
};
const redo = () => {
if (!canRedo) return;
preservePageRef.current = true;
setHist((h) => ({ ...h, index: Math.min(h.stack.length - 1, h.index + 1) }));
toast('Redo', 'info', 1200);
};
useEffect(() => {
gatewayService.getHealth()
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
.catch(() => setBackendHealthy(false));
}, []);
useEffect(() => {
(async () => {
try {
setIsLoading(true);
const docs = await gatewayService.listDocuments();
setDocuments(docs);
if (docs.length > 0) openDocument(docs[0].id);
} catch (e) {
console.error('Failed to load documents', e);
} finally {
setIsLoading(false);
}
})();
}, [openDocument]);
useEffect(() => {
if (!selectedDocId) return;
let active = true;
(async () => {
try {
setIsLoading(true);
const [doc, backendAnnots, meta, fontList, outlineList] = await Promise.all([
gatewayService.getDocument(selectedDocId),
gatewayService.getDocumentAnnotations(selectedDocId),
gatewayService.getDocumentMetadata(selectedDocId),
gatewayService.getDocumentFonts(selectedDocId),
gatewayService.getOutline(selectedDocId),
]);
if (!active) return;
setActiveDoc(doc);
setMetadata(meta);
setFonts(fontList);
setOutline(outlineList);
setAnnotations(backendAnnots.map((a): Annotation => ({
id: a.id,
type: a.type,
bbox: { x: a.x, y: a.y, width: a.width, height: a.height },
color: a.color,
author: a.author,
content: a.content,
timestamp: a.timestamp,
pageIndex: a.pageIndex,
paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined,
fieldName: a.fieldName,
fieldValue: a.fieldValue,
fieldType: a.fieldType,
fieldFlags: a.fieldFlags,
fieldOptions: a.fieldOptions,
})));
if (preservePageRef.current) preservePageRef.current = false;
else setCurrentPage(0);
} catch (e) {
console.error('Failed to load document', e);
} finally {
if (active) setIsLoading(false);
}
})();
return () => { active = false; };
}, [selectedDocId]);
useEffect(() => {
const t = setTimeout(async () => {
if (!searchQuery || !selectedDocId) {
setSearchResults([]);
setSearchCurrentMatch(0);
return;
}
try {
const results = await gatewayService.searchDocument(selectedDocId, searchQuery, searchCaseSensitive, searchWholeWords);
setSearchResults(results);
setSearchCurrentMatch(0);
if (results.length > 0) viewerRef.current?.scrollToPage(results[0].pageIndex);
} catch {
setSearchResults([]);
}
}, searchQuery ? 300 : 0);
return () => clearTimeout(t);
}, [searchQuery, selectedDocId, searchCaseSensitive, searchWholeWords]);
const selectSearchMatch = (i: number) => {
if (i < 0 || i >= searchResults.length) return;
setSearchCurrentMatch(i);
viewerRef.current?.scrollToPage(searchResults[i].pageIndex);
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const typing = ['INPUT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable;
const mod = e.ctrlKey || e.metaKey;
if (typing) return;
if (mod && e.key.toLowerCase() === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; }
if (mod && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); return; }
if (mod && e.key.toLowerCase() === 'f') { e.preventDefault(); setInspectorTab('search'); return; }
if (mod) return;
const tool = TOOL_SHORTCUTS[e.key.toLowerCase()];
if (tool) {
if (disabledToolsRef.current.has(tool)) { denyToast('This tool'); return; }
setActiveTool(tool);
if (tool === 'signature' && !pendingSignature) setSignatureModalOpen(true);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canUndo, canRedo, pendingSignature]);
const adoptNewDocument = (newDocumentId: string) => {
preservePageRef.current = true;
pushHistory(newDocumentId);
gatewayService.listDocuments().then(setDocuments).catch(() => {});
};
const applyOps = async (ops: EditOperation[], successMsg?: string) => {
if (!selectedDocId) return;
setIsSaving(true);
try {
const result = await gatewayService.applyEdits(selectedDocId, ops);
if (result.success) {
adoptNewDocument(result.newDocumentId);
if (successMsg) toast(successMsg, 'success');
}
} catch (e) {
console.error('Edit failed', e);
toast('Edit failed — check the gateway connection', 'error');
} finally {
setIsSaving(false);
}
};
const pageHeightPts = (pageIndex: number) => activeDoc?.pages?.[pageIndex]?.height ?? activeDoc?.pageHeight ?? 792;
const handleAnnotationAdded = (a: Annotation) => {
if (!can('canAnnotate')) { denyToast('Annotations'); return; }
setAnnotations((prev) => [...prev, a]);
if (inspectorTab !== 'notes') setInspectorTab('notes');
const page = a.pageIndex ?? currentPage;
if (a.type === 'highlight') {
applyOps([{
id: a.id, type: 'highlight', pageIndex: page,
data: {
quadPoints: [{
x1: a.bbox.x, y1: a.bbox.y + a.bbox.height,
x2: a.bbox.x + a.bbox.width, y2: a.bbox.y + a.bbox.height,
x3: a.bbox.x + a.bbox.width, y3: a.bbox.y,
x4: a.bbox.x, y4: a.bbox.y,
}],
color: a.color || '#ffff00', opacity: a.opacity ?? 0.5,
author: a.author, content: a.content,
},
}]);
} else if (a.type === 'ink' && a.paths) {
applyOps([{
id: a.id, type: 'freehand', pageIndex: page,
data: { paths: a.paths, color: a.color || '#2563eb', thickness: a.thickness ?? 2 },
}]);
} else if (a.type === 'comment') {
applyOps([{
id: a.id, type: 'comment', pageIndex: page,
data: { x: a.bbox.x, y: a.bbox.y, author: a.author, content: a.content || '', timestamp: a.timestamp },
}]);
}
};
const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => {
if (!can('canAnnotate')) { denyToast('Text decorations'); return; }
const quadPoints = lines.map((line) => {
const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom;
return { x1: lx, y1: ly + lh, x2: lx + lw, y2: ly + lh, x3: lx + lw, y3: ly, x4: lx, y4: ly };
});
const newAnnos = lines.map(line => ({
id: rid('locdec'),
type,
pageIndex,
bbox: { x: line.x / zoom, y: line.y / zoom, width: line.width / zoom, height: line.height / zoom },
color,
author: 'Current User',
} as Annotation));
setAnnotations(prev => [...prev, ...newAnnos]);
applyOps([{ id: rid('decor'), type, pageIndex, data: { quadPoints, color, author: 'Current User' } }]);
};
const handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => {
if (!can('canAnnotate')) { denyToast('Adding text'); setActiveTool('select'); return; }
const pdf = viewportRectToPdf(rectPts, 1, pageHeightPts(pageIndex));
applyOps([{
id: rid('txt'), type: 'text_overlay', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text, fontSize: toolSettings.fontSize, fontFamily: 'Helvetica', color: toolSettings.textColor },
}], 'Text box added');
setActiveTool('select');
};
const handleEditText = (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => {
if (!can('canModify')) { denyToast('Editing text'); setActiveTool('select'); return; }
applyOps([{
id: rid('edit'), type: 'replace_text', pageIndex,
data: {
objectIndices: run.objectIndices,
text: newText,
internalFontId: run.internalFontId,
fontSize: run.fontSize,
disableJustify: !!disableJustify,
},
}], 'Text updated');
setActiveTool('select');
};
const handleReflowParagraph = (pageIndex: number, payload: ReflowParagraphPayload) => {
if (!can('canModify')) { denyToast('Editing text'); setActiveTool('select'); return; }
applyOps([{ id: rid('reflow'), type: 'reflow_paragraph', pageIndex, data: payload }], 'Text reflowed');
setActiveTool('select');
};
const handlePlaceStamp = (pageIndex: number, point: { x: number; y: number }) => {
if (!activeStamp) return;
if (!can('canAnnotate')) { denyToast('Stamping'); return; }
const fontSize = 22;
const width = Math.max(60, activeStamp.label.length * fontSize * 0.62);
const height = fontSize * 1.5;
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
applyOps([{
id: rid('stamp'), type: 'text_overlay', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text: activeStamp.label, fontSize, fontFamily: 'Helvetica-Bold', color: activeStamp.color },
}], `Stamp “${activeStamp.label}” placed`);
};
const handlePlaceSignature = (pageIndex: number, point: { x: number; y: number }) => {
if (!pendingSignature) return;
if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; }
const width = 160;
const height = width / (pendingSignature.aspect || 3);
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
applyOps([{
id: rid('sig'), type: 'image_overlay', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, imageData: pendingSignature.url },
}], 'Signature placed');
setActiveTool('select');
};
const handleRotate = () => {
if (!activeDoc) return;
if (!can('canAssemble')) { denyToast('Rotating pages'); return; }
applyOps([{ id: rid('rot'), type: 'page_rotation', pageIndex: currentPage, data: { rotation: 90 } }], 'Page rotated');
};
const handleDeletePage = (pageIndex: number) => {
if (!activeDoc) return;
if (!can('canAssemble')) { denyToast('Deleting pages'); return; }
if (activeDoc.totalPages <= 1) { toast('Cannot delete the only page', 'error'); return; }
setConfirmState({
title: 'Delete page?',
message: `Page ${pageIndex + 1} will be removed from this document.`,
confirmLabel: 'Delete page', danger: true,
onConfirm: () => {
applyOps([{ id: rid('del'), type: 'page_deletion', pageIndex, data: {} }], 'Page deleted');
if (currentPage >= activeDoc.totalPages - 1) setCurrentPage(Math.max(0, activeDoc.totalPages - 2));
},
});
};
const handleReorderPage = (from: number, to: number) => {
if (!activeDoc || to < 0 || to >= activeDoc.totalPages) return;
if (!can('canAssemble')) { denyToast('Reordering pages'); return; }
applyOps([{ id: rid('reorder'), type: 'page_reorder', pageIndex: from, data: { destPageIndex: to } }], 'Page moved');
setCurrentPage(to);
};
const handleRedactArea = (pageIndex: number, bounds: Rect) => {
if (!activeDoc) return;
if (!can('canModify')) { denyToast('Redaction'); return; }
const pdf = viewportRectToPdf(bounds, zoom, pageHeightPts(pageIndex));
setConfirmState({
title: 'Redact area?',
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone after export.',
confirmLabel: 'Redact', danger: true,
onConfirm: () => {
applyOps([{
id: rid('redact'), type: 'redaction', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#ffffff' },
}], 'Area redacted');
setActiveTool('select');
},
});
};
const handleUpload = async (file: File, password = '') => {
try {
setIsLoading(true);
const newDoc = await gatewayService.uploadDocument(file, password);
setDocuments((prev) => [newDoc, ...prev]);
openDocument(newDoc.id);
toast(`Opened ${newDoc.filename}`, 'success');
setPasswordPrompt(null);
} catch (e) {
if (e instanceof PasswordError) {
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
} else {
console.error('Upload failed', e);
toast('Upload failed', 'error');
}
} finally {
setIsLoading(false);
}
};
const handleExport = async () => {
if (!activeDoc) return;
if (!can('canCopy')) { denyToast('Exporting'); return; }
try {
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
toast('Exported', 'success');
} catch (e) {
console.error('Export failed', e);
toast('Export failed', 'error');
}
};
const handlePrint = async () => {
if (!activeDoc) return;
if (!can('canPrint')) { denyToast('Printing'); return; }
try {
toast('Preparing print...', 'info');
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
iframe.onload = () => {
setTimeout(() => {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
}, 100);
};
document.body.appendChild(iframe);
} catch (e) {
console.error('Print failed', e);
toast('Print failed', 'error');
}
};
const toggleInspector = () => {
setIsInspectorOpen((prev) => {
const next = !prev;
setTimeout(() => {
const w = activeDoc?.pageWidth || 612;
const inspectorWidth = next ? 322 : 0;
const avail = window.innerWidth - inspectorWidth - 48;
setZoom(Math.max(0.25, Math.min(3, avail / w)));
}, 50);
return next;
});
};
const fitWidth = () => {
const w = activeDoc?.pageWidth || 612;
const inspectorWidth = isInspectorOpen ? 322 : 0;
const avail = window.innerWidth - inspectorWidth - 48 ;
setZoom(Math.max(0.25, Math.min(3, avail / w)));
};
const navigateToAnnotation = (a: Annotation) => {
if (a.pageIndex !== undefined) viewerRef.current?.scrollToPage(a.pageIndex);
};
const handleDeleteAnnotation = (a: Annotation) => {
setAnnotations((prev) => prev.filter((x) => x.id !== a.id));
applyOps([{
id: rid('delanno'), type: 'delete_annotation', pageIndex: a.pageIndex ?? currentPage,
data: { annotationId: a.id },
}], 'Annotation deleted');
};
const handleUpdateAnnotation = (a: Annotation) => {
setAnnotations((prev) => prev.map((x) => x.id === a.id ? a : x));
applyOps([{
id: rid('updanno'), type: 'update_annotation', pageIndex: a.pageIndex ?? currentPage,
data: {
annotationId: a.id,
x: a.bbox.x,
y: a.bbox.y,
width: a.bbox.width,
height: a.bbox.height,
color: a.color,
thickness: a.thickness,
text: a.content,
},
}], 'Annotation updated');
};
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
<TopBar
documentName={activeDoc?.filename}
backendHealthy={backendHealthy}
engineReady={engineReady}
zoom={zoom}
onZoomChange={setZoom}
onFitWidth={fitWidth}
currentPage={currentPage}
totalPages={activeDoc?.totalPages || 1}
onGoToPage={(p) => viewerRef.current?.scrollToPage(p)}
canUndo={canUndo}
canRedo={canRedo}
onUndo={undo}
onRedo={redo}
isSaving={isSaving}
isDirtySaved={hist.stack.length > 1}
onRotate={handleRotate}
onExport={handleExport}
onPrint={handlePrint}
canPrint={can('canPrint')}
canExport={can('canCopy')}
canAssemble={can('canAssemble')}
onUpload={handleUpload}
isInspectorOpen={isInspectorOpen}
onToggleInspector={toggleInspector}
/>
<div className="flex min-h-0 flex-1">
<ToolRail
activeTool={activeTool}
onToolChange={setActiveTool}
hasSignature={!!pendingSignature}
onOpenSignature={() => setSignatureModalOpen(true)}
onOpenAbout={() => setAboutModalOpen(true)}
disabledTools={disabledTools}
/>
<div className="flex min-w-0 flex-1 flex-col">
<Toolbar
activeTool={activeTool}
settings={toolSettings}
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
onOpenSignature={() => setSignatureModalOpen(true)}
hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null}
onSelectStamp={(label, color) => setActiveStamp({ label, color })}
/>
<div className="relative min-h-0 flex-1">
{isLoading && !activeDoc ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
<div className="spinner" />
<p className="text-[12px] font-semibold uppercase tracking-wide text-[#98a1ad]">Loading</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}
toolSettings={toolSettings}
hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null}
annotations={annotations}
canCopy={can('canCopy')}
onFieldChange={(id, value, i) => {
if (!can('canFillForms')) { denyToast('Filling form fields'); return; }
applyOps([{
id,
type: 'update_field',
pageIndex: i,
data: { value }
} as any]);
}}
searchQuery={searchQuery}
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationUpdate={handleUpdateAnnotation}
onAnnotationClick={() => {
setInspectorTab('notes');
if (!isInspectorOpen) setIsInspectorOpen(true);
}}
onPageVisible={setCurrentPage}
onRedactArea={handleRedactArea}
onPlaceText={handlePlaceText}
onEditText={handleEditText}
onReflowParagraph={handleReflowParagraph}
onStreamDocumentChanged={adoptNewDocument}
onPlaceStamp={handlePlaceStamp}
onPlaceSignature={handlePlaceSignature}
onDecorateText={handleDecorateText}
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#edeff2]">
<svg width="30" height="30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.4}>
<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.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p className="text-[14px] font-semibold text-[#18212e]">No document open</p>
<p className="text-[12px]">Open a PDF to start editing.</p>
<label className="mt-2 inline-flex h-10 cursor-pointer items-center gap-2 rounded-[8px] bg-[#2563eb] px-5 text-[13.5px] font-semibold text-white shadow-sm transition-colors hover:bg-[#1d4ed8]">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round"><path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" /></svg>
Open PDF
<input type="file" accept=".pdf" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = ''; }} />
</label>
</div>
)}
</div>
</div>
{isInspectorOpen && (
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
documents={documents}
selectedDocumentId={selectedDocId}
onSelectDocument={openDocument}
documentId={activeDoc?.id || ''}
totalPages={activeDoc?.totalPages || 0}
currentPage={currentPage}
sizeBytes={activeDoc?.sizeBytes}
onNavigateToPage={(i) => viewerRef.current?.scrollToPage(i)}
onDeletePage={handleDeletePage}
onReorderPage={handleReorderPage}
annotations={annotations}
onNavigateAnnotation={navigateToAnnotation}
onDeleteAnnotation={handleDeleteAnnotation}
onUpdateAnnotation={handleUpdateAnnotation}
outline={outline}
onNavigateOutline={(p) => viewerRef.current?.scrollToPage(p)}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
searchCaseSensitive={searchCaseSensitive}
onSearchCaseSensitiveChange={setSearchCaseSensitive}
searchWholeWords={searchWholeWords}
onSearchWholeWordsChange={setSearchWholeWords}
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
onSelectSearchMatch={selectSearchMatch}
metadata={metadata}
permissions={permissions ?? undefined}
fonts={fonts}
/>
)}
</div>
<SignatureModal
open={signatureModalOpen}
onClose={() => setSignatureModalOpen(false)}
onConfirm={(url, aspect) => {
setPendingSignature({ url, aspect });
setSignatureModalOpen(false);
setActiveTool('signature');
toast('Signature ready — click on the page to place it', 'info');
}}
/>
<AboutModal
open={aboutModalOpen}
onClose={() => setAboutModalOpen(false)}
/>
<CustomConfirmationModal state={confirmState} onClose={() => setConfirmState(null)} />
<PasswordModal
state={passwordPrompt}
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
onClose={() => setPasswordPrompt(null)}
/>
<ToastViewport />
</div>
);
}
export default App;