681 lines
28 KiB
TypeScript
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;
|