1603 lines
64 KiB
TypeScript
1603 lines
64 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 { RedactPagesModal } from './components/RedactPagesModal';
|
|
import { AboutModal } from './components/AboutModal';
|
|
import { VersionHistoryModal } from './components/VersionHistoryModal';
|
|
import { ExportPDFModal } from './components/ExportPDFModal';
|
|
import { MergePDFModal } from './components/MergePDFModal';
|
|
import { WatermarkModal, type WatermarkConfig } from './components/WatermarkModal';
|
|
import { triggerPDFDownload } from './lib/pdfExport';
|
|
|
|
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
|
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
|
import { UnsavedChangesModal } from './components/UnsavedChangesModal';
|
|
import type { UnsavedChangesModalState } from './components/UnsavedChangesModal';
|
|
import { PDFViewer } from './viewer/PDFViewer';
|
|
import { CreatePDFModal } from './features/document-creator/components/CreatePDFModal';
|
|
import type { PageLayout } from './features/document-creator/model/PaginationEngine';
|
|
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 { ProtectModal } from './components/ProtectModal';
|
|
import type { ProtectModalState } from './components/ProtectModal';
|
|
import { UnlockModal } from './components/UnlockModal';
|
|
import type { UnlockModalState } from './components/UnlockModal';
|
|
import { CompareModal } from './components/CompareModal';
|
|
import { CompareWorkspace } from './components/CompareWorkspace';
|
|
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions, CompareResponse, DocumentSummary } from './lib/gatewayService';
|
|
import { viewportRectToPdf } from './lib/coordinateMapping';
|
|
import type { Rect } from './lib/coordinateMapping';
|
|
|
|
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS, STAMP_PRESETS } from './lib/tools';
|
|
import type { ToolId, ToolSettings, StampPreset } 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) => { };
|
|
const disabledTools = new Set<ToolId>();
|
|
disabledTools.add('ocr');
|
|
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 [createPdfModalOpen, setCreatePdfModalOpen] = useState(false);
|
|
const [createPdfKey, setCreatePdfKey] = useState(0);
|
|
const [unsavedModalState, setUnsavedModalState] = useState<UnsavedChangesModalState | null>(null);
|
|
const [creatorActions, setCreatorActions] = useState<{
|
|
canUndo: boolean;
|
|
canRedo: boolean;
|
|
undo: () => void;
|
|
redo: () => void;
|
|
generate: () => void;
|
|
print: () => void;
|
|
generateBlob?: () => Promise<Blob>;
|
|
updateRunFormatting?: (updates: Partial<import('./features/document-creator/types/documentModel').TextRun>) => void;
|
|
getActiveRun?: () => import('./features/document-creator/types/documentModel').TextRun | null;
|
|
addParagraph?: (afterBlockId?: string) => void;
|
|
insertStamp?: (stampType?: string) => void;
|
|
insertComment?: (commentText?: string) => void;
|
|
deletePage?: (pageIndex: number) => void;
|
|
reorderPage?: (fromIndex: number, toIndex: number) => void;
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (activeTool === 'create_pdf') {
|
|
setCreatePdfModalOpen(true);
|
|
} else if (activeTool === 'merge_pdf') {
|
|
setMergeModalOpen(true);
|
|
setActiveTool('select');
|
|
}
|
|
}, [activeTool]);
|
|
|
|
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
|
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
|
|
const [fonts, setFonts] = useState<FontInfo[]>([]);
|
|
const [outline, setOutline] = useState<OutlineItem[]>([]);
|
|
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
|
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 [importError, setImportError] = useState<string | null>(null);
|
|
|
|
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
|
|
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
|
|
const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false);
|
|
const [aboutModalOpen, setAboutModalOpen] = useState(false);
|
|
const [versionHistoryModalOpen, setVersionHistoryModalOpen] = useState(false);
|
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
|
const [watermarkModalOpen, setWatermarkModalOpen] = useState(false);
|
|
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
|
|
const [protectModalState, setProtectModalState] = useState<ProtectModalState | null>(null);
|
|
const [unlockModalState, setUnlockModalState] = useState<UnlockModalState | null>(null);
|
|
const [compareModalOpen, setCompareModalOpen] = useState(false);
|
|
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
|
const [compareResult, setCompareResult] = useState<CompareResponse | null>(null);
|
|
const [compareDocA, setCompareDocA] = useState<DocumentSummary | null>(null);
|
|
const [compareDocB, setCompareDocB] = useState<DocumentSummary | null>(null);
|
|
|
|
|
|
const [isInspectorExpanded, setIsInspectorExpanded] = useState(false);
|
|
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
|
|
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
|
|
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 [isOCRLoading, setIsOCRLoading] = useState(false);
|
|
const [creatorPageCount, setCreatorPageCount] = useState<number>(1);
|
|
const [creatorPages, setCreatorPages] = useState<PageLayout[]>([]);
|
|
const [watermarkPreview, setWatermarkPreview] = useState<WatermarkConfig | null>(null);
|
|
|
|
const handleCreatorPageCountChange = useCallback((count: number, pages?: PageLayout[]) => {
|
|
setCreatorPageCount(count);
|
|
if (pages) setCreatorPages(pages);
|
|
}, []);
|
|
|
|
const handleRunOCR = async () => {
|
|
if (!activeDoc) return;
|
|
setIsOCRLoading(true);
|
|
try {
|
|
await gatewayService.performPageOCR(activeDoc.id, currentPage);
|
|
viewerRef.current?.refreshPageLayout(currentPage);
|
|
} catch (err: any) {
|
|
alert(`OCR processing failed: ${err.message || err}`);
|
|
} finally {
|
|
setIsOCRLoading(false);
|
|
}
|
|
};
|
|
|
|
const forceOpenDocument = useCallback((id: string) => {
|
|
localStorage.removeItem('active_mode');
|
|
setCreatePdfModalOpen(false);
|
|
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
|
|
setHist({ stack: [id], index: 0 });
|
|
}, []);
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const isRemote = urlParams.has('stream_url') && urlParams.has('upload_url') && urlParams.has('token');
|
|
const urlToken = urlParams.get('token') || undefined;
|
|
|
|
const hasUnsavedChanges = useCallback(() => {
|
|
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
|
return Boolean(creatorActions?.canUndo);
|
|
}
|
|
return hist.stack.length > 1;
|
|
}, [activeTool, createPdfModalOpen, creatorActions?.canUndo, hist.stack.length]);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen;
|
|
if (isCreatorActive) {
|
|
if (creatorActions?.generate) {
|
|
setIsSaving(true);
|
|
try {
|
|
await creatorActions.generate();
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (!activeDoc) return;
|
|
setIsSaving(true);
|
|
try {
|
|
if (isRemote) {
|
|
await gatewayService.exportRemoteDocument(selectedDocId, urlToken);
|
|
const parentOrigin = urlParams.get('parent_origin') || import.meta.env.VITE_PARENT_ORIGIN || '*';
|
|
window.parent.postMessage({ type: 'REMOTE_SAVE_COMPLETE' }, parentOrigin);
|
|
} else {
|
|
await new Promise(resolve => setTimeout(resolve, 600));
|
|
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
|
}
|
|
} catch (e) {
|
|
console.error('Save failed', e);
|
|
alert('Save failed: ' + String(e));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [activeDoc, activeTool, createPdfModalOpen, creatorActions, isRemote, selectedDocId, urlToken]);
|
|
|
|
const executeStartNewBlankPDF = useCallback(() => {
|
|
localStorage.setItem('active_mode', 'create_pdf');
|
|
setCreatePdfModalOpen(true);
|
|
setActiveTool('create_pdf');
|
|
setCreatePdfKey((k) => k + 1);
|
|
}, []);
|
|
|
|
const startNewBlankPDF = useCallback(() => {
|
|
if (hasUnsavedChanges()) {
|
|
setUnsavedModalState({
|
|
title: 'Save Unsaved Document?',
|
|
message: 'You have unsaved changes in your document. Would you like to save your document before creating a new blank document?',
|
|
onSaveAndContinue: async () => {
|
|
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
|
if (creatorActions?.generate) {
|
|
await creatorActions.generate();
|
|
}
|
|
} else {
|
|
await handleSave();
|
|
}
|
|
executeStartNewBlankPDF();
|
|
},
|
|
onDiscardAndContinue: () => {
|
|
executeStartNewBlankPDF();
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
executeStartNewBlankPDF();
|
|
}, [hasUnsavedChanges, activeTool, createPdfModalOpen, creatorActions, handleSave, executeStartNewBlankPDF]);
|
|
|
|
const openDocument = useCallback((id: string, bypassCheck = false) => {
|
|
if (!bypassCheck && selectedDocId && selectedDocId !== id && hasUnsavedChanges()) {
|
|
setUnsavedModalState({
|
|
title: 'Save Unsaved Document?',
|
|
message: 'You have unsaved changes in your current document. Would you like to save before opening another document?',
|
|
onSaveAndContinue: async () => {
|
|
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
|
if (creatorActions?.generate) {
|
|
await creatorActions.generate();
|
|
}
|
|
} else {
|
|
await handleSave();
|
|
}
|
|
forceOpenDocument(id);
|
|
},
|
|
onDiscardAndContinue: () => {
|
|
forceOpenDocument(id);
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
forceOpenDocument(id);
|
|
}, [selectedDocId, hasUnsavedChanges, activeTool, createPdfModalOpen, creatorActions, handleSave, forceOpenDocument]);
|
|
|
|
const executeUpload = async (file: File, password = '') => {
|
|
try {
|
|
setIsLoading(true);
|
|
const newDoc = await gatewayService.uploadDocument(file, password);
|
|
setDocuments((prev) => [newDoc, ...prev]);
|
|
setCreatePdfModalOpen(false);
|
|
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
|
|
forceOpenDocument(newDoc.id);
|
|
|
|
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);
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUpload = async (file: File, password = '', bypassCheck = false) => {
|
|
if (!bypassCheck && hasUnsavedChanges()) {
|
|
setUnsavedModalState({
|
|
title: 'Save Unsaved Document?',
|
|
message: `You have unsaved changes in your document. Would you like to save before opening "${file.name}"?`,
|
|
onSaveAndContinue: async () => {
|
|
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
|
if (creatorActions?.generate) {
|
|
await creatorActions.generate();
|
|
}
|
|
} else {
|
|
await handleSave();
|
|
}
|
|
await executeUpload(file, password);
|
|
},
|
|
onDiscardAndContinue: async () => {
|
|
await executeUpload(file, password);
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
await executeUpload(file, password);
|
|
};
|
|
|
|
const handleMergeCompleted = (docInfo: DocumentInfo) => {
|
|
setDocuments((prev) => [...prev, docInfo]);
|
|
openDocument(docInfo.id);
|
|
};
|
|
|
|
const handleOpenCompareModal = () => {
|
|
if (!activeDoc) return;
|
|
setCompareDocA({
|
|
id: activeDoc.id,
|
|
filename: activeDoc.filename,
|
|
totalPages: activeDoc.totalPages,
|
|
sizeBytes: activeDoc.sizeBytes,
|
|
pageWidth: activeDoc.pageWidth || 612,
|
|
pageHeight: activeDoc.pageHeight || 792,
|
|
});
|
|
setCompareModalOpen(true);
|
|
};
|
|
|
|
const handleUploadCompareFile = async (file: File): Promise<DocumentSummary> => {
|
|
const doc = await gatewayService.uploadDocument(file);
|
|
setDocuments((prev) => [...prev, doc]);
|
|
return {
|
|
id: doc.id,
|
|
filename: doc.filename,
|
|
totalPages: doc.totalPages,
|
|
sizeBytes: doc.sizeBytes,
|
|
pageWidth: doc.pageWidth || 612,
|
|
pageHeight: doc.pageHeight || 792,
|
|
};
|
|
};
|
|
|
|
const handleStartCompare = async (docIdA: string, docIdB: string) => {
|
|
const res = await gatewayService.compareDocuments(docIdA, docIdB, true, 144);
|
|
setCompareResult(res);
|
|
const foundB = documents.find((d) => d.id === docIdB);
|
|
if (foundB) {
|
|
setCompareDocB({
|
|
id: foundB.id,
|
|
filename: foundB.filename,
|
|
totalPages: foundB.totalPages,
|
|
sizeBytes: foundB.sizeBytes,
|
|
pageWidth: foundB.pageWidth || 612,
|
|
pageHeight: foundB.pageHeight || 792,
|
|
});
|
|
} else {
|
|
setCompareDocB({
|
|
id: docIdB,
|
|
filename: 'Compared Document.pdf',
|
|
totalPages: res.summary.pagesB,
|
|
sizeBytes: 0,
|
|
pageWidth: 612,
|
|
pageHeight: 792,
|
|
});
|
|
}
|
|
};
|
|
|
|
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) }));
|
|
|
|
};
|
|
const redo = () => {
|
|
if (!canRedo) return;
|
|
preservePageRef.current = true;
|
|
setHist((h) => ({ ...h, index: Math.min(h.stack.length - 1, h.index + 1) }));
|
|
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (hist.stack.length > 0) {
|
|
localStorage.setItem('pdf_hist', JSON.stringify(hist));
|
|
}
|
|
}, [hist]);
|
|
|
|
useEffect(() => {
|
|
gatewayService.getHealth()
|
|
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
|
|
.catch(() => setBackendHealthy(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const params = new URLSearchParams(window.location.search);
|
|
const streamUrl = params.get('stream_url');
|
|
const uploadUrl = params.get('upload_url');
|
|
const token = params.get('token');
|
|
const resourceId = params.get('resource_id');
|
|
|
|
if (streamUrl && uploadUrl && token) {
|
|
try {
|
|
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || '';
|
|
const res = await fetch(`${gatewayUrl}/documents/import-remote`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
stream_url: streamUrl,
|
|
upload_url: uploadUrl,
|
|
auth_token: token,
|
|
resource_id: resourceId || undefined,
|
|
}),
|
|
});
|
|
if (res.ok) {
|
|
const docInfo = await res.json();
|
|
setDocuments([docInfo]);
|
|
openDocument(docInfo.id);
|
|
return;
|
|
}
|
|
console.error('Remote import failed:', res.status, await res.text().catch(() => ''));
|
|
setImportError('Failed to open the remote document.');
|
|
} catch (err) {
|
|
console.error('Remote import failed:', err);
|
|
setImportError('Failed to open the remote document.');
|
|
}
|
|
}
|
|
|
|
const docs = await gatewayService.listDocuments();
|
|
setDocuments(docs);
|
|
|
|
const savedMode = localStorage.getItem('active_mode');
|
|
if (savedMode === 'create_pdf') {
|
|
setCreatePdfModalOpen(true);
|
|
setActiveTool('create_pdf');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (docs.length > 0) {
|
|
// Attempt to load from localStorage, otherwise fallback to the most recent document
|
|
const savedHist = localStorage.getItem('pdf_hist');
|
|
if (savedHist) {
|
|
try {
|
|
const parsedHist = JSON.parse(savedHist);
|
|
if (parsedHist.stack && parsedHist.stack.length > 0 && docs.some((d: any) => d.id === parsedHist.stack[parsedHist.index])) {
|
|
setHist(parsedHist);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to parse history', e);
|
|
}
|
|
}
|
|
// Default to the most recent document (first in list)
|
|
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,
|
|
quadPoints: a.quad_points || a.quadPoints,
|
|
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);
|
|
}
|
|
} catch (e) {
|
|
console.error('Edit failed', e);
|
|
} 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 quadPointsBackend = 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, x2: lx + lw, y2: ly, x3: lx, y3: ly + lh, x4: lx + lw, y4: ly + lh };
|
|
});
|
|
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
const qPointsFront = lines.map((line) => {
|
|
const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom;
|
|
if (lx < minX) minX = lx;
|
|
if (ly < minY) minY = ly;
|
|
if (lx + lw > maxX) maxX = lx + lw;
|
|
if (ly + lh > maxY) maxY = ly + lh;
|
|
return [{ x: lx, y: ly }, { x: lx + lw, y: ly }, { x: lx, y: ly + lh }, { x: lx + lw, y: ly + lh }];
|
|
});
|
|
|
|
const newAnno = {
|
|
id: rid('locdec'),
|
|
type,
|
|
pageIndex,
|
|
bbox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
|
quadPoints: qPointsFront,
|
|
color,
|
|
author: 'Current User',
|
|
} as Annotation;
|
|
setAnnotations(prev => [...prev, newAnno]);
|
|
|
|
applyOps([{ id: rid('decor'), type, pageIndex, data: { quadPoints: quadPointsBackend, 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 padding = 12; // visual padding
|
|
const dateWidth = 16 * (fontSize * 0.5) * 0.7; // date string approx length
|
|
const labelWidth = activeStamp.label.length * fontSize * 0.7;
|
|
const contentWidth = Math.max(labelWidth, dateWidth);
|
|
const width = Math.max(80, contentWidth) + (padding * 2);
|
|
const height = fontSize * 1.5 + (padding * 2);
|
|
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
|
|
applyOps([{
|
|
id: rid('stamp'), type: 'stamp', pageIndex,
|
|
data: {
|
|
x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height,
|
|
text: activeStamp.label,
|
|
textColor: activeStamp.textColor,
|
|
backgroundColor: activeStamp.backgroundColor,
|
|
borderColor: activeStamp.borderColor,
|
|
fontSize,
|
|
includeDate: true // We can toggle this later, true by default for now
|
|
},
|
|
}], `Stamp “${activeStamp.label}” placed`);
|
|
};
|
|
|
|
const handleRedactPages = (pagesString: string) => {
|
|
if (!activeDoc) return;
|
|
const ranges = pagesString.split(',').map(s => s.trim());
|
|
const pagesToRedact = new Set<number>();
|
|
for (const r of ranges) {
|
|
if (r.includes('-')) {
|
|
const parts = r.split('-');
|
|
if (parts.length === 2) {
|
|
const start = parseInt(parts[0], 10);
|
|
const end = parseInt(parts[1], 10);
|
|
if (!isNaN(start) && !isNaN(end)) {
|
|
for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
|
|
if (i >= 1 && i <= activeDoc.totalPages) pagesToRedact.add(i - 1);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
const val = parseInt(r, 10);
|
|
if (!isNaN(val) && val >= 1 && val <= activeDoc.totalPages) {
|
|
pagesToRedact.add(val - 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
const newRedactions: { id: string, pageIndex: number, bounds: Rect }[] = [];
|
|
pagesToRedact.forEach(pageIndex => {
|
|
const pInfo = activeDoc.pages?.[pageIndex];
|
|
if (!pInfo) return;
|
|
newRedactions.push({
|
|
id: rid('redmark'),
|
|
pageIndex,
|
|
bounds: {
|
|
x: 0,
|
|
y: 0,
|
|
width: pInfo.width,
|
|
height: pInfo.height
|
|
}
|
|
});
|
|
});
|
|
|
|
if (newRedactions.length > 0) {
|
|
setPendingRedactions(p => [...p, ...newRedactions]);
|
|
} else {
|
|
}
|
|
setRedactPagesModalOpen(false);
|
|
};
|
|
|
|
const handlePlaceSignature = (pageIndex: number, pdfRect: { x: number; y: number; width: number; height: number }, _rotation: number) => {
|
|
if (!pendingSignature) return;
|
|
if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; }
|
|
applyOps([{
|
|
id: rid('sig'), type: 'image_overlay', pageIndex,
|
|
data: { x: pdfRect.x, y: pdfRect.y, width: pdfRect.width, height: pdfRect.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) { 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 [pendingRedactions, setPendingRedactions] = useState<{ id: string, pageIndex: number, bounds: Rect }[]>([]);
|
|
|
|
const handleMarkRedaction = (pageIndex: number, bounds: Rect) => {
|
|
setPendingRedactions(prev => [...prev, { id: rid('redmark'), pageIndex, bounds }]);
|
|
if (activeTool !== 'redact') {
|
|
setActiveTool('redact');
|
|
}
|
|
};
|
|
|
|
const handleApplyRedactions = () => {
|
|
if (!activeDoc) return;
|
|
if (!can('canModify')) { denyToast('Redaction'); return; }
|
|
if (pendingRedactions.length === 0) return;
|
|
|
|
setConfirmState({
|
|
title: 'Apply Redactions?',
|
|
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone.',
|
|
confirmLabel: 'Apply', danger: true,
|
|
onConfirm: () => {
|
|
const ops = pendingRedactions.map(mark => {
|
|
const pdf = viewportRectToPdf(mark.bounds, zoom, pageHeightPts(mark.pageIndex));
|
|
return {
|
|
id: mark.id, type: 'redaction' as const, pageIndex: mark.pageIndex,
|
|
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#000000' },
|
|
};
|
|
});
|
|
applyOps(ops, 'Redactions applied');
|
|
setPendingRedactions([]);
|
|
setActiveTool('select');
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleApplyWatermark = async (config: WatermarkConfig, targetPageIndices: number[]) => {
|
|
const buildOps = (docWidthFallback = 612, docHeightFallback = 792): EditOperation[] => {
|
|
const isImage = config.type === 'image' && config.imageDataUrl;
|
|
return targetPageIndices.map((p) => {
|
|
if (isImage) {
|
|
const pageW = activeDoc?.pages?.[p]?.width ?? activeDoc?.pageWidth ?? docWidthFallback;
|
|
const pageH = activeDoc?.pages?.[p]?.height ?? activeDoc?.pageHeight ?? docHeightFallback;
|
|
const imgNativeW = config.imageWidth || 150;
|
|
const imgNativeH = config.imageHeight || 150;
|
|
const scaleFactor = config.scale || 0.5;
|
|
|
|
let targetW = imgNativeW * scaleFactor;
|
|
let targetH = imgNativeH * scaleFactor;
|
|
|
|
if (targetW > pageW * 0.85) {
|
|
const r = (pageW * 0.85) / targetW;
|
|
targetW *= r;
|
|
targetH *= r;
|
|
}
|
|
if (targetH > pageH * 0.85) {
|
|
const r = (pageH * 0.85) / targetH;
|
|
targetW *= r;
|
|
targetH *= r;
|
|
}
|
|
|
|
const margin = 36.0;
|
|
let cx = pageW / 2.0;
|
|
let cy = pageH / 2.0;
|
|
|
|
if (config.position === 'top_left') { cx = margin + targetW / 2.0; cy = pageH - margin - targetH / 2.0; }
|
|
else if (config.position === 'top_center') { cx = pageW / 2.0; cy = pageH - margin - targetH / 2.0; }
|
|
else if (config.position === 'top_right') { cx = pageW - margin - targetW / 2.0; cy = pageH - margin - targetH / 2.0; }
|
|
else if (config.position === 'center_left') { cx = margin + targetW / 2.0; cy = pageH / 2.0; }
|
|
else if (config.position === 'center_right') { cx = pageW - margin - targetW / 2.0; cy = pageH / 2.0; }
|
|
else if (config.position === 'bottom_left') { cx = margin + targetW / 2.0; cy = margin + targetH / 2.0; }
|
|
else if (config.position === 'bottom_center') { cx = pageW / 2.0; cy = margin + targetH / 2.0; }
|
|
else if (config.position === 'bottom_right') { cx = pageW - margin - targetW / 2.0; cy = margin + targetH / 2.0; }
|
|
|
|
const x = cx - targetW / 2.0;
|
|
const y = cy - targetH / 2.0;
|
|
|
|
return {
|
|
id: rid('img_wm'),
|
|
type: 'image_overlay' as const,
|
|
pageIndex: p,
|
|
data: {
|
|
imageData: config.imageDataUrl!,
|
|
x,
|
|
y,
|
|
width: targetW,
|
|
height: targetH,
|
|
opacity: config.opacity / 100.0,
|
|
rotation: config.rotation,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: rid('watermark'),
|
|
type: 'add_watermark' as const,
|
|
pageIndex: p,
|
|
data: {
|
|
text: config.text,
|
|
fontFamily: config.fontFamily,
|
|
fontSize: config.fontSize,
|
|
fontWeight: config.fontWeight,
|
|
color: config.color,
|
|
opacity: config.opacity / 100.0,
|
|
rotation: config.rotation,
|
|
position: config.position,
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen;
|
|
if (isCreatorActive) {
|
|
if (!creatorActions?.generateBlob) {
|
|
alert('Document Creator is initializing.');
|
|
return;
|
|
}
|
|
try {
|
|
setIsSaving(true);
|
|
const pdfBlob = await creatorActions.generateBlob();
|
|
const file = new File([pdfBlob], 'New Blank Document.pdf', { type: 'application/pdf' });
|
|
const newDoc = await gatewayService.uploadDocument(file);
|
|
setDocuments((prev) => [newDoc, ...prev]);
|
|
setCreatePdfModalOpen(false);
|
|
setActiveTool('select');
|
|
openDocument(newDoc.id);
|
|
|
|
const ops = buildOps(newDoc.pageWidth || 612, newDoc.pageHeight || 792);
|
|
|
|
const result = await gatewayService.applyEdits(newDoc.id, ops);
|
|
if (result.success) {
|
|
adoptNewDocument(result.newDocumentId);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to apply watermark in creator mode', e);
|
|
alert('Failed to apply watermark to new blank document.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
setWatermarkPreview(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!selectedDocId) return;
|
|
if (!can('canAnnotate')) { denyToast('Watermark'); return; }
|
|
|
|
const ops = buildOps();
|
|
|
|
try {
|
|
await applyOps(ops, 'Watermark applied');
|
|
} finally {
|
|
setWatermarkPreview(null);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleProtectSubmit = async (payload: {
|
|
userPassword: string;
|
|
ownerPassword?: string;
|
|
confirmPassword: string;
|
|
permissions?: any;
|
|
}) => {
|
|
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen || selectedDocId === 'new-blank-creator' || !selectedDocId;
|
|
try {
|
|
setIsSaving(true);
|
|
let targetDocId = selectedDocId;
|
|
|
|
if (isCreatorActive || !targetDocId) {
|
|
if (!creatorActions?.generateBlob) {
|
|
throw new Error('Document creator is not ready.');
|
|
}
|
|
const pdfBlob = await creatorActions.generateBlob();
|
|
const file = new File([pdfBlob], 'New Blank Document.pdf', { type: 'application/pdf' });
|
|
const newDoc = await gatewayService.uploadDocument(file);
|
|
setDocuments((prev) => [newDoc, ...prev]);
|
|
setCreatePdfModalOpen(false);
|
|
setActiveTool('select');
|
|
targetDocId = newDoc.id;
|
|
openDocument(newDoc.id);
|
|
}
|
|
|
|
if (!targetDocId) {
|
|
throw new Error('No document available to protect');
|
|
}
|
|
|
|
const updatedDoc = await gatewayService.protectDocument(targetDocId, {
|
|
...payload,
|
|
permissions: payload.permissions || {},
|
|
});
|
|
setDocuments((prev) => prev.map((d) => (d.id === targetDocId ? updatedDoc : d)));
|
|
setActiveDoc(updatedDoc);
|
|
setProtectModalState(null);
|
|
setIsInspectorOpen(true);
|
|
setInspectorTab('properties');
|
|
setExportModalOpen(true);
|
|
} catch (e: any) {
|
|
console.error('Protection failed', e);
|
|
throw e;
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleUnlockSubmit = async () => {
|
|
if (!selectedDocId) return;
|
|
try {
|
|
setIsSaving(true);
|
|
const updatedDoc = await gatewayService.unlockDocument(selectedDocId);
|
|
setDocuments((prev) => prev.map((d) => (d.id === selectedDocId ? updatedDoc : d)));
|
|
setActiveDoc(updatedDoc);
|
|
setUnlockModalState(null);
|
|
setIsInspectorOpen(true);
|
|
setInspectorTab('properties');
|
|
setExportModalOpen(true);
|
|
} catch (e: any) {
|
|
console.error('Unlock failed', e);
|
|
throw e;
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleToolChange = async (tool: ToolId) => {
|
|
if (tool !== 'watermark') {
|
|
setWatermarkPreview(null);
|
|
}
|
|
if (tool === 'watermark') {
|
|
setActiveTool('watermark');
|
|
setInspectorTab('watermark');
|
|
setIsInspectorOpen(true);
|
|
setIsInspectorExpanded(true);
|
|
return;
|
|
}
|
|
if (tool === 'create_pdf') {
|
|
startNewBlankPDF();
|
|
return;
|
|
}
|
|
|
|
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen;
|
|
if (isCreatorActive) {
|
|
const activeRun = creatorActions?.getActiveRun?.();
|
|
|
|
if (tool === 'underline' || tool === 'squiggly') {
|
|
creatorActions?.updateRunFormatting?.({ underline: !activeRun?.underline });
|
|
return;
|
|
}
|
|
if (tool === 'strikeout') {
|
|
creatorActions?.updateRunFormatting?.({ strikethrough: !activeRun?.strikethrough });
|
|
return;
|
|
}
|
|
if (tool === 'highlight') {
|
|
const nextColor = activeRun?.highlightColor ? undefined : '#fef08a';
|
|
creatorActions?.updateRunFormatting?.({ highlightColor: nextColor });
|
|
return;
|
|
}
|
|
if (tool === 'textbox') {
|
|
creatorActions?.addParagraph?.();
|
|
return;
|
|
}
|
|
if (tool === 'stamp') {
|
|
setActiveTool('stamp');
|
|
return;
|
|
}
|
|
if (tool === 'comment') {
|
|
creatorActions?.insertComment?.();
|
|
return;
|
|
}
|
|
if (tool === 'draw') {
|
|
setActiveTool('draw');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setActiveTool(tool);
|
|
};
|
|
|
|
const handleExport = () => {
|
|
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen;
|
|
if (!isCreatorActive) {
|
|
if (!activeDoc) return;
|
|
if (!can('canCopy')) { denyToast('Exporting'); return; }
|
|
}
|
|
setExportModalOpen(true);
|
|
};
|
|
|
|
const handleConfirmExport = async (targetFilename: string) => {
|
|
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen;
|
|
console.log(`[EXPORT_PDF] Export requested. Document ID: ${isCreatorActive ? 'new-blank-creator' : selectedDocId}`);
|
|
console.log(`[EXPORT_PDF] Filename: ${targetFilename}`);
|
|
|
|
let pdfBlob: Blob;
|
|
if (isCreatorActive) {
|
|
if (creatorActions?.generateBlob) {
|
|
console.log('[EXPORT_PDF] Generating PDF blob from document creator');
|
|
pdfBlob = await creatorActions.generateBlob();
|
|
} else {
|
|
throw new Error('Document creator generator is not ready.');
|
|
}
|
|
} else {
|
|
if (!selectedDocId) {
|
|
throw new Error('No active document available to export.');
|
|
}
|
|
console.log(`[EXPORT_PDF] Fetching export blob from backend for ID: ${selectedDocId}`);
|
|
pdfBlob = await gatewayService.exportDocumentBlob(selectedDocId);
|
|
}
|
|
|
|
console.log(`[EXPORT_PDF] PDF size: ${pdfBlob.size} bytes`);
|
|
await triggerPDFDownload(pdfBlob, targetFilename);
|
|
};
|
|
|
|
const handlePrint = async () => {
|
|
if (!activeDoc) return;
|
|
if (!can('canPrint')) { denyToast('Printing'); return; }
|
|
try {
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const toggleInspector = () => {
|
|
setIsInspectorOpen((prev) => !prev);
|
|
};
|
|
|
|
const fitWidth = () => {
|
|
const w = activeDoc?.pageWidth || 612;
|
|
const inspectorWidth = isInspectorOpen ? 360 : 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={(activeTool === 'create_pdf' || createPdfModalOpen) ? 'New Blank Document.pdf' : activeDoc?.filename}
|
|
backendHealthy={backendHealthy}
|
|
engineReady={engineReady}
|
|
canUndo={activeTool === 'create_pdf' || createPdfModalOpen ? (creatorActions?.canUndo ?? false) : canUndo}
|
|
canRedo={activeTool === 'create_pdf' || createPdfModalOpen ? (creatorActions?.canRedo ?? false) : canRedo}
|
|
onUndo={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.undo()) : undo}
|
|
onRedo={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.redo()) : redo}
|
|
isSaving={isSaving}
|
|
isDirtySaved={(activeTool === 'create_pdf' || createPdfModalOpen) ? (creatorActions?.canUndo ?? false) : hist.stack.length > 1}
|
|
onRotate={handleRotate}
|
|
onExport={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.generate()) : handleExport}
|
|
onPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? (() => creatorActions?.print?.()) : handlePrint}
|
|
onProtect={() => {
|
|
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen;
|
|
if (isCreatorActive) {
|
|
setProtectModalState({ documentId: 'new-blank-creator', filename: 'New Blank Document.pdf' });
|
|
} else if (selectedDocId) {
|
|
setProtectModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' });
|
|
}
|
|
}}
|
|
onUnlock={() => selectedDocId && setUnlockModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' })}
|
|
onCompare={handleOpenCompareModal}
|
|
onMergePDF={() => setMergeModalOpen(true)}
|
|
isEncrypted={activeDoc?.permissions?.isEncrypted ?? false}
|
|
canPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canPrint')}
|
|
canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')}
|
|
canAssemble={can('canAssemble')}
|
|
onUpload={handleUpload}
|
|
onNewBlankPDF={startNewBlankPDF}
|
|
onShowVersionHistory={() => setVersionHistoryModalOpen(true)}
|
|
isInspectorOpen={isInspectorOpen}
|
|
onToggleInspector={toggleInspector}
|
|
zoom={zoom}
|
|
onZoomChange={setZoom}
|
|
onFitWidth={fitWidth}
|
|
currentPage={currentPage}
|
|
totalPages={activeDoc?.totalPages || 1}
|
|
onGoToPage={(p) => viewerRef.current?.scrollToPage(p)}
|
|
onSave={handleSave}
|
|
/>
|
|
|
|
<div className="flex min-h-0 flex-1">
|
|
<ToolRail
|
|
activeTool={activeTool}
|
|
onToolChange={handleToolChange}
|
|
hasSignature={!!pendingSignature}
|
|
onOpenSignature={() => setSignatureModalOpen(true)}
|
|
onOpenAbout={() => setAboutModalOpen(true)}
|
|
disabledTools={disabledTools}
|
|
onRunOCR={handleRunOCR}
|
|
isOCRLoading={isOCRLoading}
|
|
/>
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
{activeTool !== 'create_pdf' && !createPdfModalOpen && (
|
|
<Toolbar
|
|
activeTool={activeTool}
|
|
settings={toolSettings}
|
|
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
|
|
onOpenSignature={() => setSignatureModalOpen(true)}
|
|
hasSignature={!!pendingSignature}
|
|
activeStamp={activeStamp}
|
|
onSelectStamp={setActiveStamp}
|
|
redactionMode={redactionMode}
|
|
onRedactionModeChange={setRedactionMode}
|
|
pendingRedactionCount={pendingRedactions.length}
|
|
onApplyRedactions={handleApplyRedactions}
|
|
onClearRedactions={() => setPendingRedactions([])}
|
|
onRedactPages={() => setRedactPagesModalOpen(true)}
|
|
onOpenWatermark={() => {
|
|
setInspectorTab('watermark');
|
|
setIsInspectorOpen(true);
|
|
setIsInspectorExpanded(true);
|
|
}}
|
|
selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)}
|
|
onUpdateAnnotation={(patch) => {
|
|
const a = annotations.find(x => x.id === selectedAnnotationId);
|
|
if (a) handleUpdateAnnotation({ ...a, ...patch });
|
|
}}
|
|
onDeleteAnnotation={() => {
|
|
const a = annotations.find(x => x.id === selectedAnnotationId);
|
|
if (a) {
|
|
handleDeleteAnnotation(a);
|
|
setSelectedAnnotationId(null);
|
|
}
|
|
}}
|
|
onDeselectAnnotation={() => setSelectedAnnotationId(null)}
|
|
/>
|
|
)}
|
|
|
|
<div className="relative min-h-0 flex-1">
|
|
{activeTool === 'create_pdf' || createPdfModalOpen ? (
|
|
<CreatePDFModal
|
|
key={createPdfKey}
|
|
isOpen={true}
|
|
activeTool={activeTool}
|
|
drawColor={toolSettings.inkColor}
|
|
drawWidth={toolSettings.inkThickness}
|
|
activeStamp={activeStamp?.label || 'APPROVED'}
|
|
onUpdateDrawColor={(color) => setToolSettings((s) => ({ ...s, inkColor: color }))}
|
|
onUpdateDrawWidth={(width) => setToolSettings((s) => ({ ...s, inkThickness: width }))}
|
|
onSelectStamp={(label) => {
|
|
const preset = STAMP_PRESETS.find((s) => s.label === label);
|
|
if (preset) setActiveStamp(preset);
|
|
}}
|
|
onClose={() => {
|
|
if (hasUnsavedChanges()) {
|
|
setUnsavedModalState({
|
|
title: 'Save Unsaved Document?',
|
|
message: 'You have unsaved changes in your document. Would you like to save before closing?',
|
|
onSaveAndContinue: async () => {
|
|
if (creatorActions?.generate) {
|
|
await creatorActions.generate();
|
|
}
|
|
localStorage.removeItem('active_mode');
|
|
setCreatePdfModalOpen(false);
|
|
if (activeTool === 'create_pdf') setActiveTool('select');
|
|
},
|
|
onDiscardAndContinue: () => {
|
|
localStorage.removeItem('active_mode');
|
|
setCreatePdfModalOpen(false);
|
|
if (activeTool === 'create_pdf') setActiveTool('select');
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
localStorage.removeItem('active_mode');
|
|
setCreatePdfModalOpen(false);
|
|
if (activeTool === 'create_pdf') setActiveTool('select');
|
|
}}
|
|
onCreatePDF={async (file) => {
|
|
await handleUpload(file, '', true);
|
|
setCreatePdfModalOpen(false);
|
|
setActiveTool('select');
|
|
}}
|
|
onPageCountChange={handleCreatorPageCountChange}
|
|
onRegisterActions={setCreatorActions}
|
|
/>
|
|
) : 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}
|
|
redactionMode={redactionMode}
|
|
hasSignature={!!pendingSignature}
|
|
signatureImageUrl={pendingSignature?.url}
|
|
signatureAspect={pendingSignature?.aspect}
|
|
activeStamp={activeStamp}
|
|
annotations={annotations}
|
|
canCopy={can('canCopy')}
|
|
watermarkPreview={watermarkPreview}
|
|
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={(anno) => {
|
|
if (activeTool === 'select') {
|
|
setSelectedAnnotationId(anno.id);
|
|
} else {
|
|
setInspectorTab('notes');
|
|
if (!isInspectorOpen) setIsInspectorOpen(true);
|
|
}
|
|
}}
|
|
onPageVisible={setCurrentPage}
|
|
onMarkRedaction={handleMarkRedaction}
|
|
pendingRedactions={pendingRedactions}
|
|
onRemoveRedaction={(id) => setPendingRedactions(p => p.filter(x => x.id !== id))}
|
|
onPlaceText={handlePlaceText}
|
|
onEditText={handleEditText}
|
|
onReflowParagraph={handleReflowParagraph}
|
|
onStreamDocumentChanged={adoptNewDocument}
|
|
onPlaceStamp={handlePlaceStamp}
|
|
onPlaceSignature={handlePlaceSignature}
|
|
onDecorateText={handleDecorateText}
|
|
onEditBlock={async (pageIndex, payload) => {
|
|
if (!selectedDocId) return;
|
|
try {
|
|
if (payload.type === 'text' && payload.text !== undefined) {
|
|
const updatedLayout = await gatewayService.updateBlock(
|
|
selectedDocId,
|
|
pageIndex,
|
|
payload.blockId,
|
|
{ text: payload.text }
|
|
);
|
|
viewerRef.current?.updatePageLayout(pageIndex, updatedLayout);
|
|
} else if (payload.type === 'bounds' && payload.bounds) {
|
|
const updatedLayout = await gatewayService.updateBlock(
|
|
selectedDocId,
|
|
pageIndex,
|
|
payload.blockId,
|
|
{ bounds: payload.bounds }
|
|
);
|
|
viewerRef.current?.updatePageLayout(pageIndex, updatedLayout);
|
|
} else if (payload.type === 'rotate' && payload.rotation !== undefined) {
|
|
const updatedLayout = await gatewayService.updateBlock(
|
|
selectedDocId,
|
|
pageIndex,
|
|
payload.blockId,
|
|
{ rotation: payload.rotation }
|
|
);
|
|
viewerRef.current?.updatePageLayout(pageIndex, updatedLayout);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to commit block edit to backend:', err);
|
|
throw err;
|
|
}
|
|
}}
|
|
/>
|
|
) : importError ? (
|
|
<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-red-50 text-red-500">
|
|
<svg width="30" height="30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-[14px] font-semibold text-[#18212e]">Import Error</p>
|
|
<p className="text-[13px]">{importError}</p>
|
|
</div>
|
|
) : (
|
|
<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 or Image 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 / Image
|
|
<input type="file" accept=".pdf,image/*,.png,.jpg,.jpeg,.webp,.bmp,.tiff" 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={(t) => {
|
|
if (t !== 'watermark') setWatermarkPreview(null);
|
|
setInspectorTab(t);
|
|
}}
|
|
isExpanded={isInspectorExpanded}
|
|
onExpandedChange={setIsInspectorExpanded}
|
|
documents={documents}
|
|
selectedDocumentId={selectedDocId}
|
|
onSelectDocument={openDocument}
|
|
documentId={(activeTool === 'create_pdf' || createPdfModalOpen) ? '' : (activeDoc?.id || '')}
|
|
totalPages={(activeTool === 'create_pdf' || createPdfModalOpen) ? creatorPageCount : (activeDoc?.totalPages || 0)}
|
|
creatorPages={creatorPages}
|
|
currentPage={currentPage}
|
|
sizeBytes={activeDoc?.sizeBytes}
|
|
onNavigateToPage={(i) => {
|
|
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
|
const cards = document.querySelectorAll('.bg-white.shadow-xl');
|
|
cards[i]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
} else {
|
|
viewerRef.current?.scrollToPage(i);
|
|
}
|
|
}}
|
|
onDeletePage={(i) => {
|
|
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
|
creatorActions?.deletePage?.(i);
|
|
} else {
|
|
handleDeletePage(i);
|
|
}
|
|
}}
|
|
onReorderPage={(fromIdx, toIdx) => {
|
|
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
|
creatorActions?.reorderPage?.(fromIdx, toIdx);
|
|
} else {
|
|
handleReorderPage(fromIdx, toIdx);
|
|
}
|
|
}}
|
|
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}
|
|
history={hist}
|
|
onRestoreHistory={(docId) => {
|
|
const index = hist.stack.findIndex(id => id === docId);
|
|
if (index !== -1) {
|
|
setHist(h => ({ ...h, index }));
|
|
preservePageRef.current = true;
|
|
}
|
|
}}
|
|
onApplyWatermark={handleApplyWatermark}
|
|
onPreviewChange={setWatermarkPreview}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<SignatureModal
|
|
open={signatureModalOpen}
|
|
onClose={() => setSignatureModalOpen(false)}
|
|
onConfirm={(url, aspect) => {
|
|
setPendingSignature({ url, aspect });
|
|
setSignatureModalOpen(false);
|
|
setActiveTool('signature');
|
|
}}
|
|
/>
|
|
|
|
<RedactPagesModal
|
|
open={redactPagesModalOpen}
|
|
onClose={() => setRedactPagesModalOpen(false)}
|
|
onConfirm={handleRedactPages}
|
|
/>
|
|
|
|
<AboutModal
|
|
open={aboutModalOpen}
|
|
onClose={() => setAboutModalOpen(false)}
|
|
/>
|
|
|
|
<VersionHistoryModal
|
|
open={versionHistoryModalOpen}
|
|
onClose={() => setVersionHistoryModalOpen(false)}
|
|
history={hist}
|
|
onRestore={(id) => openDocument(id)}
|
|
/>
|
|
|
|
<ExportPDFModal
|
|
open={exportModalOpen}
|
|
onClose={() => setExportModalOpen(false)}
|
|
documentName={(activeTool === 'create_pdf' || createPdfModalOpen) ? 'New Blank Document.pdf' : activeDoc?.filename}
|
|
onConfirmExport={handleConfirmExport}
|
|
/>
|
|
|
|
<WatermarkModal
|
|
open={watermarkModalOpen}
|
|
onClose={() => setWatermarkModalOpen(false)}
|
|
totalPages={(activeTool === 'create_pdf' || createPdfModalOpen) ? creatorPageCount : (activeDoc?.totalPages || 1)}
|
|
currentPage={currentPage}
|
|
onApplyWatermark={handleApplyWatermark}
|
|
/>
|
|
|
|
<CustomConfirmationModal state={confirmState} onClose={() => setConfirmState(null)} />
|
|
|
|
<UnsavedChangesModal state={unsavedModalState} onClose={() => setUnsavedModalState(null)} />
|
|
|
|
<PasswordModal
|
|
state={passwordPrompt}
|
|
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
|
|
onClose={() => setPasswordPrompt(null)}
|
|
/>
|
|
|
|
<ProtectModal
|
|
state={protectModalState}
|
|
onSubmit={handleProtectSubmit}
|
|
onClose={() => setProtectModalState(null)}
|
|
/>
|
|
|
|
<UnlockModal
|
|
state={unlockModalState}
|
|
onConfirm={handleUnlockSubmit}
|
|
onClose={() => setUnlockModalState(null)}
|
|
/>
|
|
|
|
<CompareModal
|
|
isOpen={compareModalOpen}
|
|
onClose={() => setCompareModalOpen(false)}
|
|
currentDoc={compareDocA}
|
|
availableDocs={documents.map((d) => ({
|
|
id: d.id,
|
|
filename: d.filename,
|
|
totalPages: d.totalPages,
|
|
sizeBytes: d.sizeBytes,
|
|
}))}
|
|
onUploadFile={handleUploadCompareFile}
|
|
onStartCompare={handleStartCompare}
|
|
/>
|
|
|
|
{compareResult && compareDocA && compareDocB && (
|
|
<CompareWorkspace
|
|
compareResult={compareResult}
|
|
docA={compareDocA}
|
|
docB={compareDocB}
|
|
onExit={() => {
|
|
setCompareResult(null);
|
|
setCompareDocA(null);
|
|
setCompareDocB(null);
|
|
}}
|
|
renderPageUrl={(docId, pageIdx, dpi) => gatewayService.getPageRenderUrl(docId, pageIdx, dpi)}
|
|
/>
|
|
)}
|
|
|
|
<MergePDFModal
|
|
isOpen={mergeModalOpen}
|
|
onClose={() => setMergeModalOpen(false)}
|
|
onMergeComplete={handleMergeCompleted}
|
|
apiBaseUrl={import.meta.env.VITE_GATEWAY_URL || 'http://localhost:8000'}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|