Files
pdf/frontend/src/App.tsx
T
2026-08-22 15:06:15 +05:30

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;