import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { Box, Center, Text, ActionIcon, Button, Stack } from "@mantine/core"; import CloseIcon from "@mui/icons-material/Close"; import LockIcon from "@mui/icons-material/Lock"; import { useFileState, useFileActions } from "@app/contexts/FileContext"; import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar"; import { ThumbnailSidebar } from "@app/components/viewer/ThumbnailSidebar"; import { BookmarkSidebar } from "@app/components/viewer/BookmarkSidebar"; import { AttachmentSidebar } from "@app/components/viewer/AttachmentSidebar"; import { LayerSidebar } from "@app/components/viewer/LayerSidebar"; import { useNavigationGuard, useNavigationState, } from "@app/contexts/NavigationContext"; import { useSignature } from "@app/contexts/SignatureContext"; import { useRedaction } from "@app/contexts/RedactionContext"; import type { RedactionPendingTrackerAPI } from "@app/components/viewer/RedactionPendingTracker"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext"; import { useViewerWorkbenchBarButtons } from "@app/components/viewer/useViewerWorkbenchBarButtons"; import { StampPlacementOverlay } from "@app/components/viewer/StampPlacementOverlay"; import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale, } from "@app/components/viewer/RulerOverlay"; import type { PDFDict, PDFNumber } from "@cantoo/pdf-lib"; import { useWheelZoom } from "@app/hooks/useWheelZoom"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import { FormSaveBar } from "@app/tools/formFill/FormSaveBar"; import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand"; // ─── Measure dictionary extraction ──────────────────────────────────────────── async function extractPageMeasureScales( file: Blob, ): Promise { try { const { PDFDocument, PDFDict, PDFName, PDFArray, PDFNumber, PDFString, PDFHexString, } = await import("@cantoo/pdf-lib"); const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { ignoreEncryption: true, }); // Parse a Measure dict into a MeasureScale, or return null if malformed. const parseScale = (measureObj: unknown) => { if (!(measureObj instanceof PDFDict)) return null; const rObj = measureObj.lookup(PDFName.of("R")); const ratioLabel = rObj instanceof PDFString || rObj instanceof PDFHexString ? rObj.decodeText() : ""; // D = distance array, X = x-axis fallback let fmtArray = measureObj.lookup(PDFName.of("D")); if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.lookup(PDFName.of("X")); if (!(fmtArray instanceof PDFArray)) return null; const firstFmt = fmtArray.lookup(0); if (!(firstFmt instanceof PDFDict)) return null; const cObj = firstFmt.lookup(PDFName.of("C")); const uObj = firstFmt.lookup(PDFName.of("U")); if (!(cObj instanceof PDFNumber) || cObj.asNumber() <= 0) return null; const unit = uObj instanceof PDFString || uObj instanceof PDFHexString ? uObj.decodeText() : "units"; return { factor: cObj.asNumber(), unit, ratioLabel }; }; const result: PageMeasureScales = new Map(); for (let i = 0; i < pdfDoc.getPageCount(); i++) { const page = pdfDoc.getPage(i); const pageHeight = page.getHeight(); const pageNode = page.node as unknown as PDFDict; const viewports: ViewportScale[] = []; // Spec-conformant: /VP array — each viewport can have its own scale and BBox const vpObj = pageNode.lookup(PDFName.of("VP")); if (vpObj instanceof PDFArray) { for (let j = 0; j < vpObj.size(); j++) { const vpEntry = vpObj.lookup(j); if (!(vpEntry instanceof PDFDict)) continue; const scale = parseScale(vpEntry.lookup(PDFName.of("Measure"))); if (!scale) continue; let bbox: ViewportScale["bbox"] = null; const bboxObj = vpEntry.lookup(PDFName.of("BBox")); if (bboxObj instanceof PDFArray && bboxObj.size() >= 4) { bbox = [ (bboxObj.lookup(0) as PDFNumber).asNumber(), (bboxObj.lookup(1) as PDFNumber).asNumber(), (bboxObj.lookup(2) as PDFNumber).asNumber(), (bboxObj.lookup(3) as PDFNumber).asNumber(), ]; } viewports.push({ bbox, scale }); } } // Fallback: /Measure directly on page (non-conforming but seen in the wild) if (viewports.length === 0) { const scale = parseScale(pageNode.lookup(PDFName.of("Measure"))); if (scale) viewports.push({ bbox: null, scale }); } if (viewports.length > 0) result.set(i, { viewports, pageHeight } satisfies PageScaleInfo); } return result.size > 0 ? result : null; } catch { return null; } } // ────────────────────────────────────────────────────────────────────────────── export interface EmbedPdfViewerProps { sidebarsVisible: boolean; setSidebarsVisible: (v: boolean) => void; onClose?: () => void; previewFile?: File | null; } const EmbedPdfViewerContent = ({ sidebarsVisible: _sidebarsVisible, setSidebarsVisible: _setSidebarsVisible, onClose, previewFile, }: EmbedPdfViewerProps) => { const { t } = useTranslation(); const viewerRef = React.useRef(null); const pdfContainerRef = useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); const { isThumbnailSidebarVisible, toggleThumbnailSidebar, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isLayerSidebarVisible, setHasLayers, isCommentsSidebarVisible, isSearchInterfaceVisible, searchInterfaceActions, zoomActions, scrollActions, panActions: _panActions, rotationActions, getScrollState, getRotationState, setAnnotationMode, isAnnotationsVisible, exportActions, printActions, setApplyChanges, applyChanges: viewerApplyChanges, pdfRenderMode, cyclePdfRenderMode, setActiveFileIndex: _setActiveFileIndex, activeFileId, setActiveFileId, } = useViewer(); const scrollState = getScrollState(); const rotationState = getRotationState(); // Track initial rotation to detect changes const initialRotationRef = useRef(null); useEffect(() => { if ( initialRotationRef.current === null && rotationState.rotation !== undefined ) { initialRotationRef.current = rotationState.rotation; } }, [rotationState.rotation]); // Get signature and annotation contexts const { signatureApiRef, annotationApiRef, historyApiRef, signatureConfig, isPlacementMode, } = useSignature(); // Track whether there are unsaved annotation changes in this viewer session. // This is our source of truth for navigation guards; it is set when the // annotation history changes, and cleared after we successfully apply changes. const hasAnnotationChangesRef = useRef(false); // Scroll position preservation system // We continuously track the last known good scroll position, so we always have it available const lastKnownScrollPageRef = useRef(1); const pendingScrollRestoreRef = useRef(null); const scrollRestoreAttemptsRef = useRef(0); // Rotation preservation system // Similar to scroll preservation - track rotation across file reloads const pendingRotationRestoreRef = useRef(null); const rotationRestoreAttemptsRef = useRef(0); const formApplyInProgressRef = useRef(false); // Get redaction context const { redactionsApplied, setRedactionsApplied } = useRedaction(); // Ref for redaction pending tracker API const redactionTrackerRef = useRef(null); // Get current file from FileContext const { selectors } = useFileState(); const { actions } = useFileActions(); const activeFiles = selectors.getFiles(); const activeFilesRef = useRef(activeFiles); activeFilesRef.current = activeFiles; const activeFileIds = activeFiles.map((f) => f.fileId); // Navigation guard for unsaved changes const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers, } = useNavigationGuard(); const { selectedTool } = useNavigationState(); // Form fill context const { fetchFields: fetchFormFields, setProviderMode } = useFormFill(); const isInAnnotationTool = selectedTool === "sign" || selectedTool === "addText" || selectedTool === "addImage" || selectedTool === "annotate"; const isSignatureMode = isInAnnotationTool; const isManualRedactMode = selectedTool === "redact"; // Enable annotations when annotation tool is selected OR when annotations are visible // (so users can interact with existing comment annotations in reader/viewer mode) const shouldEnableAnnotations = selectedTool === "annotate" || isSignatureMode || isAnnotationsVisible; // Enable redaction only when redaction tool is selected const shouldEnableRedaction = selectedTool === "redact"; // FormFill tool mode — uses PDFBox backend for full-fidelity form handling const isFormFillToolActive = (selectedTool as string) === "formFill"; // Form overlays are shown in BOTH modes: // - Normal viewer: form overlays visible (PDFium WASM, frontend-only) // - formFill tool: form overlays visible (PDFBox, backend) const shouldEnableFormFill = true; // Switch the provider when the tool mode changes useEffect(() => { setProviderMode(isFormFillToolActive ? "pdfbox" : "pdflib"); }, [isFormFillToolActive, setProviderMode]); // Track previous annotation/redaction state to detect tool switches const prevEnableAnnotationsRef = useRef(shouldEnableAnnotations); const prevEnableRedactionRef = useRef(shouldEnableRedaction); // Track scroll position whenever scrollState changes from the context // This ensures we always have the most up-to-date position useEffect(() => { if (scrollState.currentPage > 0) { lastKnownScrollPageRef.current = scrollState.currentPage; } }, [scrollState.currentPage]); // Preserve scroll position when switching between annotation and redaction tools // Using useLayoutEffect to capture synchronously before DOM updates useLayoutEffect(() => { const annotationsChanged = prevEnableAnnotationsRef.current !== shouldEnableAnnotations; const redactionChanged = prevEnableRedactionRef.current !== shouldEnableRedaction; if (annotationsChanged || redactionChanged) { // Read scroll state directly AND use the tracked value - take whichever is valid const currentScrollState = getScrollState(); const pageFromState = currentScrollState.currentPage; const pageFromRef = lastKnownScrollPageRef.current; // Use the current state if valid, otherwise fall back to tracked ref const pageToRestore = pageFromState > 0 ? pageFromState : pageFromRef; if (pageToRestore > 0) { pendingScrollRestoreRef.current = pageToRestore; scrollRestoreAttemptsRef.current = 0; } prevEnableAnnotationsRef.current = shouldEnableAnnotations; prevEnableRedactionRef.current = shouldEnableRedaction; } }, [shouldEnableAnnotations, shouldEnableRedaction, getScrollState]); // Keep annotation mode enabled when entering placement tools without overriding manual toggles useEffect(() => { if (isInAnnotationTool) { setAnnotationMode(true); } }, [isInAnnotationTool, setAnnotationMode]); const isPlacementOverlayActive = Boolean( isInAnnotationTool && isPlacementMode && signatureConfig, ); // Determine which file to display — use activeFileId (stable) not activeFileIndex (shifts on removal) const currentFile = React.useMemo(() => { if (previewFile) { return previewFile; } else if (activeFiles.length > 0) { const byId = activeFileId ? activeFiles.find( (f) => isStirlingFile(f) && f.fileId === activeFileId, ) : null; return byId || activeFiles[0]; } return null; }, [previewFile, activeFiles, activeFileId]); // Stable id — avoids blob URL churn when FileContext recreates file objects each render. const currentFileStableId = currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null; const fileWithUrl = useFileWithUrl(currentFile, currentFileStableId); // Determine the effective file to display const effectiveFile = React.useMemo(() => { if (previewFile) { // In preview mode, show the preview file if (previewFile.size === 0) { return null; } return { file: previewFile, url: null }; } else { return fileWithUrl; } }, [previewFile, fileWithUrl]); // Check if the current file is encrypted (gate the viewer to prevent PDFium crash) const isCurrentFileEncrypted = React.useMemo(() => { if (!currentFile || !isStirlingFile(currentFile)) return false; const stub = selectors.getStirlingFileStub(currentFile.fileId); return stub?.processedFile?.isEncrypted === true; }, [currentFile, selectors]); const bookmarkCacheKey = React.useMemo(() => { if (currentFile && isStirlingFile(currentFile)) { return currentFile.fileId; } if (previewFile) { const uniquePreviewId = `${previewFile.name}-${previewFile.size}-${previewFile.lastModified ?? "na"}`; return `preview-${uniquePreviewId}`; } if (effectiveFile?.url) { return effectiveFile.url; } if (effectiveFile?.file instanceof File) { const fileObj = effectiveFile.file; return `file-${fileObj.name}-${fileObj.size}-${fileObj.lastModified ?? "na"}`; } return undefined; }, [currentFile, effectiveFile, previewFile]); // Generate cache keys for all active files to enable preloading const allBookmarkCacheKeys = React.useMemo(() => { if (previewFile) { return [bookmarkCacheKey].filter(Boolean) as string[]; } return activeFiles .map((file) => { if (isStirlingFile(file)) { return file.fileId; } return undefined; }) .filter(Boolean) as string[]; }, [activeFiles, previewFile, bookmarkCacheKey]); useWheelZoom({ ref: viewerRef, onZoomIn: zoomActions.zoomIn, onZoomOut: zoomActions.zoomOut, }); const viewerKeyCommand = useViewerKeyCommand(); // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const mod = event.ctrlKey || event.metaKey; // Ctrl+P (print) must be intercepted unconditionally // whenever the viewer is mounted, even before the user has hovered over it. // Ctrl+R (rotate) is intercepted only on desktop (Tauri), while on web it still falls through to browser refresh. // Without this, the browser falls through to its native "print HTML page" // or "reload page" behaviour. if (mod) { const target = event.target as Element; const isInTextInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || (target as HTMLElement).isContentEditable; if (!isInTextInput) { const wasOverridden = viewerKeyCommand(event); if (!wasOverridden) { switch (event.key) { case "p": case "P": event.preventDefault(); printActions.print(); return; } } } } // All remaining shortcuts require the viewer to be hovered so they // don't conflict with the rest of the UI when the viewer is mounted // but not the active focus target. if (!isViewerHovered) return; // Modifier key shortcuts (Ctrl/Cmd + key) if (mod) { switch (event.key) { case "=": case "+": event.preventDefault(); zoomActions.zoomIn(); return; case "-": case "_": event.preventDefault(); zoomActions.zoomOut(); return; case "0": // Ctrl+0: Reset zoom to fit width event.preventDefault(); zoomActions.requestZoom("fit-width"); return; case "a": case "A": // Ctrl+A: Prevent browser from selecting all UI text event.preventDefault(); return; case "f": case "F": event.preventDefault(); if (isSearchInterfaceVisible) { window.dispatchEvent(new CustomEvent("refocus-search-input")); } else { searchInterfaceActions.open(); } return; case "s": case "S": // Ctrl+S: Save/apply changes if (!event.shiftKey) { event.preventDefault(); if (viewerApplyChanges) { viewerApplyChanges(); } } return; case "z": case "Z": // Ctrl+Z: Undo; Ctrl+Shift+Z: Redo event.preventDefault(); if (event.shiftKey) { historyApiRef.current?.redo?.(); } else { historyApiRef.current?.undo?.(); } return; case "y": case "Y": // Ctrl+Y: Redo event.preventDefault(); historyApiRef.current?.redo?.(); return; } return; } // Non-modifier shortcuts switch (event.key) { case "Home": event.preventDefault(); scrollActions.scrollToFirstPage(); return; case "End": event.preventDefault(); scrollActions.scrollToLastPage(); return; case "PageUp": event.preventDefault(); scrollActions.scrollToPreviousPage(); return; case "PageDown": event.preventDefault(); scrollActions.scrollToNextPage(); return; case "Escape": if (isSearchInterfaceVisible) { event.preventDefault(); searchInterfaceActions.close(); } return; } }; document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); }; }, [ isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions, scrollActions, printActions, exportActions, rotationActions, historyApiRef, viewerApplyChanges, cyclePdfRenderMode, viewerKeyCommand, ]); // Watch the annotation history API to detect when the document becomes "dirty". // We treat any change that makes the history undoable as unsaved changes until // the user explicitly applies them via applyChanges. useEffect(() => { const historyApi = historyApiRef.current; if (!historyApi || !historyApi.subscribe) { return; } const updateHasChanges = () => { const canUndo = historyApi.canUndo?.() ?? false; if (!hasAnnotationChangesRef.current && canUndo) { hasAnnotationChangesRef.current = true; setHasUnsavedChanges(true); } }; const unsubscribe = historyApi.subscribe(updateHasChanges); return () => { if (typeof unsubscribe === "function") { unsubscribe(); } }; }, [historyApiRef.current, setHasUnsavedChanges]); // Register checker for unsaved changes (annotations only for now) useEffect(() => { if (previewFile) { return; } const checkForChanges = () => { // Check for annotation history changes (using ref that's updated by useEffect) const hasAnnotationChanges = hasAnnotationChangesRef.current; // Check for pending redactions const hasPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0; // Always consider applied redactions as unsaved until export const hasAppliedRedactions = redactionsApplied; return ( hasAnnotationChanges || hasPendingRedactions || hasAppliedRedactions ); }; registerUnsavedChangesChecker(checkForChanges); return () => { unregisterUnsavedChangesChecker(); }; }, [ historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, isManualRedactMode, redactionsApplied, ]); // Save changes - save annotations and redactions to file (overwrites active file) const applyChanges = useCallback(async () => { if (!currentFile || activeFileIds.length === 0) return; try { console.log( "[Viewer] Applying changes - exporting PDF with annotations/redactions", ); // Use the continuously tracked scroll position - more reliable than reading at this moment const pageToRestore = lastKnownScrollPageRef.current; // Save the current rotation to restore after reload const currentRotation = rotationState.rotation ?? 0; // Step 0: Commit any pending redactions before export const hadPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0; // Mark redactions as applied BEFORE committing, so the button stays enabled during the save process // This ensures the button doesn't become disabled when pendingCount becomes 0 if (hadPendingRedactions || redactionsApplied) { setRedactionsApplied(true); } if (hadPendingRedactions) { console.log("[Viewer] Committing pending redactions before export"); redactionTrackerRef.current?.commitAllPending(); // Give a small delay for the commit to process await new Promise((resolve) => setTimeout(resolve, 100)); } // Step 1: Export PDF with annotations using EmbedPDF const arrayBuffer = await exportActions.saveAsCopy(); if (!arrayBuffer) { throw new Error("Failed to export PDF"); } // Step 2: Convert ArrayBuffer to File const blob = new Blob([arrayBuffer], { type: "application/pdf" }); const filename = currentFile.name || "document.pdf"; const file = new File([blob], filename, { type: "application/pdf" }); // Step 3: Create StirlingFiles and stubs for version history // Only consume the current file, not all active files const currentFileId = currentFileStableId; if (!currentFileId) throw new Error("Current file ID not found"); const parentStub = selectors.getStirlingFileStub(currentFileId); if (!parentStub) throw new Error("Parent stub not found"); const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( [file], parentStub, selectedTool ?? "multiTool", ); // Store the page to restore after file replacement triggers re-render pendingScrollRestoreRef.current = pageToRestore; scrollRestoreAttemptsRef.current = 0; // Store the rotation to restore after file replacement pendingRotationRestoreRef.current = currentRotation; rotationRestoreAttemptsRef.current = 0; // Track the new file ID so the viewer follows it after the list reorders const newFileId = stubs[0]?.id; if (newFileId) setActiveFileId(newFileId); // Step 4: Consume only the current file (replace in context) await actions.consumeFiles([currentFileId], stirlingFiles, stubs); // Mark annotations as saved so navigation away from the viewer is allowed. hasAnnotationChangesRef.current = false; setHasUnsavedChanges(false); setRedactionsApplied(false); } catch (error) { console.error("Apply changes failed:", error); } }, [ currentFile, activeFiles, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied, rotationState.rotation, ]); // Apply form fill changes - reload the filled PDF into the viewer const handleFormApply = useCallback( async (filledBlob: Blob) => { if (formApplyInProgressRef.current) return; if (!currentFile || activeFileIds.length === 0) return; formApplyInProgressRef.current = true; try { console.log( "[Viewer] Applying form fill changes - reloading filled PDF", ); // Use the continuously tracked scroll position const pageToRestore = lastKnownScrollPageRef.current; // Save the current rotation to restore after reload const currentRotation = rotationState.rotation ?? 0; // Convert Blob to File const filename = currentFile.name || "document.pdf"; const file = new File([filledBlob], filename, { type: "application/pdf", }); // Get current file info for creating the updated version const currentFileId = currentFileStableId; if (!currentFileId) throw new Error("Current file ID not found"); const parentStub = selectors.getStirlingFileStub(currentFileId); if (!parentStub) throw new Error("Parent stub not found"); // Create StirlingFiles and stubs for version history const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( [file], parentStub, selectedTool ?? "multiTool", ); // Store the page to restore after file replacement pendingScrollRestoreRef.current = pageToRestore; scrollRestoreAttemptsRef.current = 0; // Store the rotation to restore after file replacement pendingRotationRestoreRef.current = currentRotation; rotationRestoreAttemptsRef.current = 0; // Track the new file ID so the viewer follows it after the list reorders const newFileId = stubs[0]?.id; if (newFileId) setActiveFileId(newFileId); // Replace the current file in context await actions.consumeFiles([currentFileId], stirlingFiles, stubs); console.log("[Viewer] Form fill changes applied successfully"); } catch (error) { console.error("[Viewer] Apply form changes failed:", error); } finally { formApplyInProgressRef.current = false; } }, [ currentFile, activeFiles, actions, selectors, activeFileIds.length, rotationState.rotation, ], ); useEffect(() => { const handler = (e: Event) => { const blob = (e as CustomEvent).detail?.blob; if (blob) { handleFormApply(blob); } }; window.addEventListener("formfill:apply", handler); return () => window.removeEventListener("formfill:apply", handler); }, [handleFormApply]); // Apply layer visibility changes - reload the modified PDF into the viewer const layerApplyInProgressRef = useRef(false); const handleLayerApply = useCallback( async (modifiedBlob: Blob) => { if (layerApplyInProgressRef.current) return; if (!currentFile || activeFileIds.length === 0) return; layerApplyInProgressRef.current = true; try { const pageToRestore = lastKnownScrollPageRef.current; const currentRotation = rotationState.rotation ?? 0; const filename = currentFile.name || "document.pdf"; const file = new File([modifiedBlob], filename, { type: "application/pdf", }); const currentFileId = currentFileStableId; if (!currentFileId) throw new Error("Current file ID not found"); const parentStub = selectors.getStirlingFileStub(currentFileId); if (!parentStub) throw new Error("Parent stub not found"); const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( [file], parentStub, selectedTool ?? "multiTool", ); pendingScrollRestoreRef.current = pageToRestore; scrollRestoreAttemptsRef.current = 0; pendingRotationRestoreRef.current = currentRotation; rotationRestoreAttemptsRef.current = 0; const newFileId = stubs[0]?.id; if (newFileId) setActiveFileId(newFileId); await actions.consumeFiles([currentFileId], stirlingFiles, stubs); } catch (error) { console.error("[Viewer] Apply layer changes failed:", error); } finally { layerApplyInProgressRef.current = false; } }, [ currentFile, activeFiles, actions, selectors, activeFileIds.length, rotationState.rotation, ], ); // Discard pending redactions but save already-applied ones // This is called when user clicks "Discard & Leave" - we want to: // 1. NOT commit pending redaction marks (they get discarded) // 2. Save the PDF with already-applied redactions (if any) const discardAndSaveApplied = useCallback(async () => { // Only save if there are applied redactions to preserve if (!redactionsApplied || !currentFile || activeFileIds.length === 0) { return; } try { console.log( "[Viewer] Discarding pending marks but saving applied redactions", ); // Save current view state to restore after file replacement const pageToRestore = lastKnownScrollPageRef.current; const currentRotation = rotationState.rotation ?? 0; // Export PDF WITHOUT committing pending marks - this saves only applied redactions const arrayBuffer = await exportActions.saveAsCopy(); if (!arrayBuffer) { throw new Error("Failed to export PDF"); } // Convert ArrayBuffer to File const blob = new Blob([arrayBuffer], { type: "application/pdf" }); const filename = currentFile.name || "document.pdf"; const file = new File([blob], filename, { type: "application/pdf" }); // Create StirlingFiles and stubs for version history const currentFileId = currentFileStableId; if (!currentFileId) throw new Error("Current file ID not found"); const parentStub = selectors.getStirlingFileStub(currentFileId); if (!parentStub) throw new Error("Parent stub not found"); const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( [file], parentStub, selectedTool ?? "multiTool", ); // Store view state to restore after file replacement pendingScrollRestoreRef.current = pageToRestore; scrollRestoreAttemptsRef.current = 0; pendingRotationRestoreRef.current = currentRotation; rotationRestoreAttemptsRef.current = 0; // Consume only the current file (replace in context) await actions.consumeFiles([currentFileId], stirlingFiles, stubs); // Clear flags hasAnnotationChangesRef.current = false; setRedactionsApplied(false); console.log("[Viewer] Applied redactions saved, pending marks discarded"); } catch (error) { console.error("Failed to save applied redactions:", error); } }, [ redactionsApplied, currentFile, activeFiles, activeFileIds.length, exportActions, actions, selectors, setRedactionsApplied, rotationState.rotation, ]); // Register navigation warning handlers so the global modal can call our save/discard logic useEffect(() => { if (previewFile) return; registerNavigationWarningHandlers({ onApplyAndContinue: async () => { await applyChanges(); }, onDiscardAndContinue: async () => { await discardAndSaveApplied(); const historyApi = historyApiRef.current; if (historyApi?.canUndo) { while (historyApi.canUndo()) { historyApi.undo?.(); } } hasAnnotationChangesRef.current = false; }, }); return () => unregisterNavigationWarningHandlers(); }, [ previewFile, applyChanges, discardAndSaveApplied, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers, ]); // Restore scroll position after file replacement or tool switch // Uses polling with retries to ensure the scroll succeeds useEffect(() => { if (pendingScrollRestoreRef.current === null) return; const pageToRestore = pendingScrollRestoreRef.current; const maxAttempts = 10; const attemptInterval = 100; // ms between attempts const attemptScroll = () => { const currentState = getScrollState(); const targetPage = Math.min(pageToRestore, currentState.totalPages); // Only attempt if we have valid state (totalPages > 0 means PDF is loaded) if (currentState.totalPages > 0 && targetPage > 0) { scrollActions.scrollToPage(targetPage, "instant"); // Check if scroll succeeded after a brief delay setTimeout(() => { const afterState = getScrollState(); if ( afterState.currentPage === targetPage || scrollRestoreAttemptsRef.current >= maxAttempts ) { // Success or max attempts reached - clear pending pendingScrollRestoreRef.current = null; scrollRestoreAttemptsRef.current = 0; } else { // Scroll might not have worked, retry scrollRestoreAttemptsRef.current++; if (scrollRestoreAttemptsRef.current < maxAttempts) { setTimeout(attemptScroll, attemptInterval); } else { // Give up after max attempts pendingScrollRestoreRef.current = null; scrollRestoreAttemptsRef.current = 0; } } }, 50); } else if (scrollRestoreAttemptsRef.current < maxAttempts) { // PDF not ready yet, retry scrollRestoreAttemptsRef.current++; setTimeout(attemptScroll, attemptInterval); } else { // Give up after max attempts pendingScrollRestoreRef.current = null; scrollRestoreAttemptsRef.current = 0; } }; // Start attempting after initial delay const timer = setTimeout(attemptScroll, 150); return () => clearTimeout(timer); }, [scrollState.totalPages, scrollActions, getScrollState]); // Restore rotation after file replacement or tool switch // Uses polling with retries to ensure the rotation succeeds useEffect(() => { if (pendingRotationRestoreRef.current === null) return; const rotationToRestore = pendingRotationRestoreRef.current; const maxAttempts = 10; const attemptInterval = 100; // ms between attempts const attemptRotation = () => { const currentState = getScrollState(); // Only attempt if PDF is loaded (totalPages > 0) if (currentState.totalPages > 0) { rotationActions.setRotation(rotationToRestore); // Check if rotation succeeded after a brief delay setTimeout(() => { const currentRotation = rotationActions.getRotation(); if ( currentRotation === rotationToRestore || rotationRestoreAttemptsRef.current >= maxAttempts ) { // Success or max attempts reached - clear pending pendingRotationRestoreRef.current = null; rotationRestoreAttemptsRef.current = 0; } else { // Rotation might not have worked, retry rotationRestoreAttemptsRef.current++; if (rotationRestoreAttemptsRef.current < maxAttempts) { setTimeout(attemptRotation, attemptInterval); } else { // Give up after max attempts pendingRotationRestoreRef.current = null; rotationRestoreAttemptsRef.current = 0; } } }, 50); } else if (rotationRestoreAttemptsRef.current < maxAttempts) { // PDF not ready yet, retry rotationRestoreAttemptsRef.current++; setTimeout(attemptRotation, attemptInterval); } else { // Give up after max attempts pendingRotationRestoreRef.current = null; rotationRestoreAttemptsRef.current = 0; } }; // Start attempting after initial delay const timer = setTimeout(attemptRotation, 150); return () => clearTimeout(timer); }, [scrollState.totalPages, rotationActions, getScrollState]); // Register applyChanges with ViewerContext so tools can access it directly useEffect(() => { setApplyChanges(applyChanges); return () => { setApplyChanges(null); }; }, [applyChanges, setApplyChanges]); // Ruler / measurement tool state const [isRulerActive, setIsRulerActive] = useState(false); const [pageMeasureScales, setPageMeasureScales] = useState(null); useEffect(() => { const file = effectiveFile?.file; if (!file) { setPageMeasureScales(null); return; } let cancelled = false; extractPageMeasureScales(file).then((scales) => { if (!cancelled) setPageMeasureScales(scales); }); return () => { cancelled = true; }; }, [effectiveFile]); // Register workbench bar buttons for the viewer useViewerWorkbenchBarButtons(isRulerActive, setIsRulerActive); // Auto-fetch form fields when a PDF is loaded in the viewer. // In normal viewer mode, this uses PDFium WASM (frontend-only). // In formFill tool mode, this uses PDFBox (backend). const formFillFileIdRef = useRef(null); const formFillProviderRef = useRef(isFormFillToolActive); // Generate a unique identifier for the current file to detect file changes const currentFileId = React.useMemo(() => { return getFormFillFileId(currentFile); }, [currentFile]); useEffect(() => { const fileChanged = currentFileId !== formFillFileIdRef.current; const providerChanged = formFillProviderRef.current !== isFormFillToolActive; formFillProviderRef.current = isFormFillToolActive; if (fileChanged) { console.log( "[FormFill] File changed. Old:", formFillFileIdRef.current, "New:", currentFileId, ); formFillFileIdRef.current = currentFileId; // NOTE: Don't call resetFormFill() here — fetchFormFields() handles // clearing old state internally. Calling reset() before fetch() would // double-increment fetchVersionRef, causing version mismatches when // the effect re-fires before the async fetch completes. } if ( currentFile && !isCurrentFileEncrypted && (fileChanged || providerChanged) ) { console.log("[FormFill] Fetching form fields for:", currentFileId); fetchFormFields(currentFile, currentFileId ?? undefined); } }, [ isFormFillToolActive, currentFile, currentFileId, fetchFormFields, isCurrentFileEncrypted, ]); const sidebarWidthRem = 15; const commentsSidebarWidthRem = 18; const totalRightMargin = (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0) + (isAttachmentSidebarVisible ? sidebarWidthRem : 0) + (isLayerSidebarVisible ? sidebarWidthRem : 0) + (isCommentsSidebarVisible ? commentsSidebarWidthRem : 0); return ( setIsViewerHovered(true)} onMouseLeave={() => setIsViewerHovered(false)} style={{ position: "relative", height: "100%", display: "flex", flexDirection: "column", overflow: "hidden", contain: "layout style paint", }} > {/* Close Button - Only show in preview mode */} {onClose && previewFile && ( )} {!effectiveFile ? (
Error: No file provided to viewer
) : isCurrentFileEncrypted ? (
{t( "encryptedPdfUnlock.viewerLocked", "This PDF is password-protected", )}
) : ( <> {/* EmbedPDF Viewer */} } annotationApiRef={annotationApiRef as React.RefObject} historyApiRef={historyApiRef as React.RefObject} redactionTrackerRef={ redactionTrackerRef as React.RefObject } fileId={currentFileId} isCommentsSidebarVisible={isCommentsSidebarVisible} commentsSidebarRightOffset={`${(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0) + (isAttachmentSidebarVisible ? sidebarWidthRem : 0) + (isLayerSidebarVisible ? sidebarWidthRem : 0)}rem`} onSignatureAdded={() => { // Handle signature added - for debugging, enable console logs as needed // Future: Handle signature completion }} /> {/* Floating save bar for form-filled PDFs (like Chrome/Firefox PDF viewers) */} )} {/* Bottom Toolbar Overlay */} {effectiveFile && (
)} {/* Thumbnail Sidebar */}
); }; const EmbedPdfViewer = (props: EmbedPdfViewerProps) => { return ; }; export default EmbedPdfViewer;