From 78964f1e4eddc967ba23bcc9ad64b72d4367cbfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Thu, 27 Aug 2026 22:50:49 +0200 Subject: [PATCH] refactor(viewer): optimize document lifecycle hooks, spread readiness, and async redaction commits --- .../public/locales/en-US/translation.toml | 4 + .../viewer/ActiveDocumentContext.tsx | 79 --- .../AnnotationSelectionMenu.stories.tsx | 5 +- .../viewer/DocumentReadyWrapper.tsx | 50 +- .../core/components/viewer/EmbedPdfViewer.tsx | 8 +- .../core/components/viewer/LocalEmbedPDF.tsx | 628 ++++++++++-------- .../components/viewer/RedactionAPIBridge.tsx | 9 +- .../RedactionPendingTracker.stories.tsx | 5 +- .../viewer/RedactionSelectionMenu.stories.tsx | 5 +- .../core/components/viewer/ZoomAPIBridge.tsx | 74 ++- .../viewer/hooks/useDocumentReady.ts | 71 +- .../components/viewer/useActiveDocumentId.ts | 17 +- .../src/core/contexts/RedactionContext.tsx | 8 +- frontend/editor/src/core/utils/viewerZoom.ts | 8 +- 14 files changed, 456 insertions(+), 515 deletions(-) delete mode 100644 frontend/editor/src/core/components/viewer/ActiveDocumentContext.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 8271d66dfc..15526399c3 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -11716,8 +11716,12 @@ disableColorFilter = "Disable Color Filter" dualPageView = "Dual Page View" enableDarkFilter = "Enable Dark Filter" enableSepiaFilter = "Enable Sepia Filter" +engineLoadError = "Failed to initialize PDF viewer engine" +engineSlowWarning = "PDF engine initialization is taking longer than expected. Please check your browser WebAssembly and Worker settings, or reload the page." firstPage = "First Page" lastPage = "Last Page" +loadingDocument = "Loading document..." +loadingEngine = "Loading PDF Engine..." moreOptions = "More" nextPage = "Next Page" onlyPdfSupported = "This file format is not supported for preview." diff --git a/frontend/editor/src/core/components/viewer/ActiveDocumentContext.tsx b/frontend/editor/src/core/components/viewer/ActiveDocumentContext.tsx deleted file mode 100644 index 07c035593c..0000000000 --- a/frontend/editor/src/core/components/viewer/ActiveDocumentContext.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { - createContext, - useContext, - useMemo, - useState, - useEffect, - useRef, -} from "react"; -import { useDocumentManagerPlugin } from "@embedpdf/plugin-document-manager/react"; - -interface ActiveDocumentContextType { - documentId: string | null; -} - -const ActiveDocumentContext = createContext({ - documentId: null, -}); - -export function ActiveDocumentProvider({ - children, -}: { - children: React.ReactNode; -}) { - const { plugin, isLoading } = useDocumentManagerPlugin(); - const [documentId, setDocumentId] = useState(null); - const unsubscribeRef = useRef<(() => void) | null>(null); - const documentIdRef = useRef(null); - - useEffect(() => { - if (isLoading || !plugin) return; - - const docManagerApi = plugin.provides?.(); - if (!docManagerApi) return; - - // Get initial active document (synchronously if available) - const activeDoc = docManagerApi.getActiveDocument?.(); - if (activeDoc?.id && activeDoc.id !== documentIdRef.current) { - documentIdRef.current = activeDoc.id; - setDocumentId(activeDoc.id); - } - - // Subscribe to document changes (only if not already subscribed) - if (!unsubscribeRef.current && docManagerApi.onDocumentOpened) { - unsubscribeRef.current = docManagerApi.onDocumentOpened((event: any) => { - const docId = event?.documentId || event?.id || event?.document?.id; - if (docId && docId !== documentIdRef.current) { - documentIdRef.current = docId; - setDocumentId(docId); - } - }); - } - - // Note: We don't unsubscribe on effect cleanup to avoid re-subscribing on every render - // Cleanup happens only on unmount - }, [plugin, isLoading]); - - // Cleanup on unmount only - useEffect(() => { - return () => { - if (unsubscribeRef.current) { - unsubscribeRef.current(); - unsubscribeRef.current = null; - } - }; - }, []); - - const value = useMemo(() => ({ documentId }), [documentId]); - - return ( - - {children} - - ); -} - -export function useActiveDocument(): string | null { - const context = useContext(ActiveDocumentContext); - return context.documentId; -} diff --git a/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx index bd9d86f0c6..47c45f43a0 100644 --- a/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx +++ b/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx @@ -1,10 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { AnnotationSelectionMenu } from "@app/components/viewer/AnnotationSelectionMenu"; -// AnnotationSelectionMenu reads the active document from ActiveDocumentContext, which -// defaults to `null` outside of a live EmbedPDF document-manager session (not something the -// shared preview can stub). With no active document it short-circuits and renders nothing, -// so this story only exercises that no-active-document mount path without throwing. +// AnnotationSelectionMenu reads the active document, which defaults to null outside a live EmbedPDF session. const meta = { title: "Viewer/AnnotationSelectionMenu", component: AnnotationSelectionMenu, diff --git a/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.tsx b/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.tsx index 520417afd0..1696b50d52 100644 --- a/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.tsx +++ b/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from "react"; -import { useDocumentManagerPlugin } from "@embedpdf/plugin-document-manager/react"; +import React from "react"; +import { useActiveDocument } from "@embedpdf/plugin-document-manager/react"; interface DocumentReadyWrapperProps { children: (documentId: string) => React.ReactNode; @@ -10,47 +10,13 @@ export function DocumentReadyWrapper({ children, fallback = null, }: DocumentReadyWrapperProps) { - const { plugin, isLoading, ready } = useDocumentManagerPlugin(); - const [activeDocumentId, setActiveDocumentId] = useState(null); - - useEffect(() => { - if (isLoading || !plugin) return; - - const checkActiveDocument = async () => { - await ready; - const docManagerApi = plugin.provides?.(); - if (docManagerApi) { - const activeDoc = docManagerApi.getActiveDocument?.(); - if (activeDoc?.id) { - setActiveDocumentId(activeDoc.id); - return; - } - } - }; - - checkActiveDocument(); - - // Subscribe to document changes - const docManagerApi = plugin.provides?.(); - if (docManagerApi?.onDocumentOpened) { - const unsubscribe = docManagerApi.onDocumentOpened((event: any) => { - const docId = event?.documentId || event?.id || event?.document?.id; - if (docId) { - setActiveDocumentId(docId); - } - }); - - return () => { - if (typeof unsubscribe === "function") { - unsubscribe(); - } - }; - } - }, [plugin, isLoading, ready]); - - if (!activeDocumentId) { + const { activeDocumentId, activeDocument } = useActiveDocument(); + if ( + !activeDocumentId || + activeDocument?.status !== "loaded" || + !activeDocument?.document + ) { return <>{fallback}; } - return <>{children(activeDocumentId)}; } diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index c0f9d4b4c4..d112e3bc60 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -620,9 +620,7 @@ const EmbedPdfViewerContent = ({ 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)); + await redactionTrackerRef.current?.commitAllPending(); } // Step 1: Export PDF with annotations using EmbedPDF @@ -752,7 +750,6 @@ const EmbedPdfViewerContent = ({ 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); @@ -890,6 +887,9 @@ const EmbedPdfViewerContent = ({ pendingRotationRestoreRef.current = currentRotation; rotationRestoreAttemptsRef.current = 0; + const newFileId = stubs[0]?.id; + if (newFileId) setActiveFileId(newFileId); + // Consume only the current file (replace in context) await actions.consumeFiles([currentFileId], stirlingFiles, stubs); diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx index 07fe867ed2..406fd8470c 100644 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx @@ -60,8 +60,7 @@ import { } from "@embedpdf/plugin-redaction/react"; import { CustomSearchLayer } from "@app/components/viewer/CustomSearchLayer"; import { ZoomAPIBridge } from "@app/components/viewer/ZoomAPIBridge"; -import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback"; -import { Center, Stack, Text } from "@mantine/core"; +import { Center, Loader, Stack, Text } from "@mantine/core"; import { ScrollAPIBridge } from "@app/components/viewer/ScrollAPIBridge"; import { SelectionAPIBridge } from "@app/components/viewer/SelectionAPIBridge"; import { PanAPIBridge } from "@app/components/viewer/PanAPIBridge"; @@ -98,7 +97,6 @@ import { import { RedactionAPIBridge } from "@app/components/viewer/RedactionAPIBridge"; import { DocumentPermissionsAPIBridge } from "@app/components/viewer/DocumentPermissionsAPIBridge"; import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrapper"; -import { ActiveDocumentProvider } from "@app/components/viewer/ActiveDocumentContext"; import { pdfiumWasmUrl } from "@app/services/wasmPrecompiler"; import { FormFieldOverlay } from "@app/tools/formFill/FormFieldOverlay"; import { FormCreationInteractionLock } from "@app/tools/formFill/FormCreationInteractionLock"; @@ -235,7 +233,7 @@ export function LocalEmbedPDF({ }: LocalEmbedPDFProps) { const { t } = useTranslation(); const { config } = useAppConfig(); - const [pdfUrl, setPdfUrl] = useState(null); + const [pdfUrl, setPdfUrl] = useState(() => url ?? null); const [, setAnnotations] = useState< Array<{ id: string; pageIndex: number; rect: Rect }> >([]); @@ -310,16 +308,59 @@ export function LocalEmbedPDF({ const fileStableKey = fileId ?? (file ? `${(file as File).name}-${file.size}` : null); useEffect(() => { + if (url) { + setPdfUrl(url); + return; + } if (file) { const objectUrl = URL.createObjectURL(file); setPdfUrl(objectUrl); return () => URL.revokeObjectURL(objectUrl); - } else if (url) { - setPdfUrl(url); } // When file is present, use the stable key to avoid blob URL churn from FileContext // re-renders. When only url is provided, depend on url directly so changes are picked up. - }, [file ? fileStableKey : url]); + }, [url, file ? fileStableKey : null]); + + const [pdfBuffer, setPdfBuffer] = useState(null); + + // Read file/url directly into an ArrayBuffer on the main thread so EmbedPDF's worker + // receives the document data via buffer rather than failing to fetch partitioned blob URLs. + useEffect(() => { + let cancelled = false; + if (file && typeof (file as Blob).arrayBuffer === "function") { + (file as Blob) + .arrayBuffer() + .then((buf) => { + if (!cancelled) setPdfBuffer(buf); + }) + .catch((err) => { + console.error( + "[LocalEmbedPDF] Failed to read file arrayBuffer:", + err, + ); + }); + return () => { + cancelled = true; + }; + } + if (url) { + fetch(url) + .then((r) => r.arrayBuffer()) + .then((buf) => { + if (!cancelled) setPdfBuffer(buf); + }) + .catch((err) => { + console.error( + "[LocalEmbedPDF] Failed to fetch url arrayBuffer:", + err, + ); + }); + return () => { + cancelled = true; + }; + } + setPdfBuffer(null); + }, [file ? fileStableKey : null, url]); // Keyed by fileStableKey to avoid recomputing on every FileContext re-render. const exportFileName = useMemo(() => { @@ -331,7 +372,11 @@ export function LocalEmbedPDF({ // Create plugins configuration const plugins = useMemo(() => { - if (!pdfUrl) return []; + // When a File object is the source, we MUST wait for the buffer, the + // worker cannot fetch partitioned blob: URLs. pdfUrl is still created + // (for thumbnails etc.) but plugins must not start until the buffer lands. + if (file && !pdfBuffer) return []; + if (!pdfBuffer && !pdfUrl) return []; // Calculate 3.5rem in pixels dynamically based on root font size const rootFontSize = parseFloat( @@ -341,16 +386,29 @@ export function LocalEmbedPDF({ return [ createPluginRegistration(DocumentManagerPluginPackage, { - initialDocuments: [ - { - url: pdfUrl, - name: exportFileName, - }, - ], + initialDocuments: pdfBuffer + ? [ + { + buffer: pdfBuffer, + name: exportFileName, + }, + ] + : pdfUrl + ? [ + { + url: pdfUrl, + name: exportFileName, + }, + ] + : [], }), createPluginRegistration(ViewportPluginPackage, { viewportGap, }), + // Register spread plugin before scroll and zoom plugins that depend on it + createPluginRegistration(SpreadPluginPackage, { + defaultSpreadMode: SpreadMode.None, + }), createPluginRegistration(ScrollPluginPackage), createPluginRegistration(RenderPluginPackage, { withForms: !enableFormFill, @@ -395,7 +453,7 @@ export function LocalEmbedPDF({ // Register zoom plugin with configuration createPluginRegistration(ZoomPluginPackage, { - defaultZoomLevel: ZoomMode.FitWidth, // Start with FitWidth, will be adjusted in ZoomAPIBridge + defaultZoomLevel: ZoomMode.FitWidth, minZoom: 0.2, maxZoom: 5.0, }), @@ -407,11 +465,6 @@ export function LocalEmbedPDF({ extraRings: 1, }), - // Register spread plugin for dual page layout - createPluginRegistration(SpreadPluginPackage, { - defaultSpreadMode: SpreadMode.None, // Start with single page view - }), - // Register search plugin for text search createPluginRegistration(SearchPluginPackage), @@ -432,17 +485,28 @@ export function LocalEmbedPDF({ defaultFileName: exportFileName, }), - // Register print plugin for printing PDFs createPluginRegistration(PrintPluginPackage), ]; - }, [pdfUrl, enableAnnotations, exportFileName]); + }, [!!file, pdfBuffer, pdfUrl, enableAnnotations, exportFileName]); - // Initialize the engine with the React hook - use local WASM for offline support const { engine, isLoading, error } = usePdfiumEngine({ wasmUrl: pdfiumWasmUrl, }); - // Early return if no file or URL provided + const [engineTimeout, setEngineTimeout] = useState(false); + useEffect(() => { + if (!isLoading) { + setEngineTimeout(false); + return; + } + const timer = setTimeout(() => { + if (isLoading) { + setEngineTimeout(true); + } + }, 15000); + return () => clearTimeout(timer); + }, [isLoading]); + if (!file && !url) { return (
@@ -483,21 +547,47 @@ export function LocalEmbedPDF({ ); } - if (isLoading || !engine || !pdfUrl) { - return ; + const hasInput = Boolean(file || url); + const isInputReady = Boolean(pdfBuffer || (!file && pdfUrl)); + + if (isLoading || !engine || (hasInput && !isInputReady)) { + return ( +
+ + + + {t("viewer.loadingEngine", "Loading PDF Engine...")} + + {engineTimeout && ( + + {t( + "viewer.engineSlowWarning", + "PDF engine initialization is taking longer than expected. Please check your browser WebAssembly and Worker settings, or reload the page.", + )} + + )} + +
+ ); } if (error) { return (
-
- - Error loading PDF engine: {error.message} +
⚠️
+ + {t( + "viewer.engineLoadError", + "Failed to initialize PDF viewer engine", + )} + + + {error.message}
@@ -1008,264 +1098,254 @@ export function LocalEmbedPDF({ } }} > - - - - - - - - - - - {(enableAnnotations || - enableRedaction || - isManualRedactionMode) && ( - - )} - {/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */} - {(enableRedaction || isManualRedactionMode) && ( - - )} - {/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */} - {(enableAnnotations || - enableRedaction || - isManualRedactionMode) && ( - - )} - {(enableRedaction || isManualRedactionMode) && ( - - )} - {enableAnnotations && ( - - )} + + + + + + + + + + {(enableAnnotations || enableRedaction || isManualRedactionMode) && ( + + )} + {/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */} + {(enableRedaction || isManualRedactionMode) && } + {/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */} + {(enableAnnotations || enableRedaction || isManualRedactionMode) && ( + + )} + {(enableRedaction || isManualRedactionMode) && ( + + )} + {enableAnnotations && } - - - - - - - -
- } - > - {(documentId) => ( - <> - - + + + + + + + + + {t("viewer.loadingDocument", "Loading document...")} + + + + } + > + {(documentId) => ( + <> + + + - { - return ( - ( + + + - - -
- -
+ /> + - + -
- ( - - )} - /> -
- - - {/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */} - {enableFormFill && file && ( - +
+ ( + )} + /> +
+ - {/* FormFieldOverlay for interactive form filling */} - {enableFormFill && ( - + {/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */} + {enableFormFill && file && ( + + )} + + {/* FormFieldOverlay for interactive form filling */} + {enableFormFill && ( + + )} + + {/* Create-mode: drag to place new fields */} + {enableFormFill && formEditingActive && ( + + )} + + {/* Modify-mode: select / move / resize existing fields */} + {enableFormFill && formEditingActive && ( + + )} + + {/* SignatureFieldOverlay — bitmaps of digital-signature appearances */} + {file && ( + + )} + + {/* AnnotationLayer for annotation editing and annotation-based redactions */} + {(enableAnnotations || enableRedaction) && ( + ( + )} + style={ + !showBakedAnnotations + ? { + opacity: 0, + pointerEvents: "none", + } + : undefined + } + /> + )} - {/* Create-mode: drag to place new fields */} - {enableFormFill && formEditingActive && ( - + {enableRedaction && ( + ( + )} + /> + )} - {/* Modify-mode: select / move / resize existing fields */} - {enableFormFill && formEditingActive && ( - - )} + {/* LinkLayer – uses EmbedPDF annotation state for link rendering */} + - {/* SignatureFieldOverlay — bitmaps of digital-signature appearances */} - {file && ( - - )} - - {/* AnnotationLayer for annotation editing and annotation-based redactions */} - {(enableAnnotations || enableRedaction) && ( - ( - - )} - style={ - !showBakedAnnotations - ? { - opacity: 0, - pointerEvents: "none", - } - : undefined - } - /> - )} - - {enableRedaction && ( - ( - - )} - /> - )} - - {/* LinkLayer – uses EmbedPDF annotation state for link rendering */} - - - {/* Signature preview overlay (opt-in; off by default) */} - {signatureOverlayEnabled && ( - - )} -
-
-
- ); - }} - /> -
-
- {enableAnnotations && ( - - - - )} - - )} -
- + {/* Signature preview overlay (opt-in; off by default) */} + {signatureOverlayEnabled && ( + + )} + + + + )} + /> +
+
+ {enableAnnotations && ( + + + + )} + + )} + diff --git a/frontend/editor/src/core/components/viewer/RedactionAPIBridge.tsx b/frontend/editor/src/core/components/viewer/RedactionAPIBridge.tsx index c860e074d0..2defd7f334 100644 --- a/frontend/editor/src/core/components/viewer/RedactionAPIBridge.tsx +++ b/frontend/editor/src/core/components/viewer/RedactionAPIBridge.tsx @@ -87,10 +87,11 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) { redactionProvides?.endRedact(); }, // Common methods - commitAllPending: () => { - redactionProvides?.commitAllPending(); - // Don't set redactionsApplied here - it should only be set after the file is saved - // The save operation in applyChanges will handle setting/clearing this flag + commitAllPending: async () => { + const task = redactionProvides?.commitAllPending(); + if (task && typeof task.toPromise === "function") { + await task.toPromise(); + } }, getActiveType: () => state?.activeType ?? null, getPendingCount: () => state?.pendingCount ?? 0, diff --git a/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx b/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx index 40062505f8..4639d0eeb1 100644 --- a/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx +++ b/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx @@ -1,10 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { RedactionPendingTracker } from "@app/components/viewer/RedactionPendingTracker"; -// RedactionPendingTracker reads the active document from ActiveDocumentContext, which -// defaults to `null` outside of a live EmbedPDF document-manager session (not something the -// shared preview can stub). With no active document it short-circuits and renders nothing, -// so this story only exercises that no-active-document mount path without throwing. +// RedactionPendingTracker reads the active document, which defaults to null outside a live EmbedPDF session. const meta = { title: "Viewer/RedactionPendingTracker", component: RedactionPendingTracker, diff --git a/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx index af01665228..5fb6f2284d 100644 --- a/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx +++ b/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx @@ -1,10 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { RedactionSelectionMenu } from "@app/components/viewer/RedactionSelectionMenu"; -// RedactionSelectionMenu renders only when there's an active document ID -// (ActiveDocumentContext) and a selected redaction annotation from the live -// EmbedPDF redaction plugin. Neither exists in Storybook, so the component's -// own guard clause renders nothing here - that's its real empty state. +// RedactionSelectionMenu renders only when there is an active document ID and a selected redaction annotation. const meta = { title: "Viewer/RedactionSelectionMenu", component: RedactionSelectionMenu, diff --git a/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx b/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx index 0cb11837cc..0af07b31fd 100644 --- a/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx +++ b/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useZoom, ZoomMode } from "@embedpdf/plugin-zoom/react"; import { useSpread, SpreadMode } from "@embedpdf/plugin-spread/react"; +import { useScroll } from "@embedpdf/plugin-scroll/react"; import { useViewer } from "@app/contexts/ViewerContext"; import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId"; import { useAllFiles } from "@app/contexts/FileContext"; @@ -34,7 +35,9 @@ export function ZoomAPIBridge() { function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { const { provides: zoom, state: zoomState } = useZoom(documentId); - const { spreadMode } = useSpread(documentId); + const { provides: spread, spreadMode } = useSpread(documentId); + const { state: scrollState } = useScroll(documentId); + const totalPages = scrollState?.totalPages ?? 0; const { registerBridge, triggerImmediateZoomUpdate } = useViewer(); const { fileStubs } = useAllFiles(); @@ -45,10 +48,35 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { const zoomRef = useRef(zoom); const [autoZoomTick, setAutoZoomTick] = useState(0); - // Keep zoom ref updated + const spreadRef = useRef(spread); useEffect(() => { - zoomRef.current = zoom; - }, [zoom]); + spreadRef.current = spread; + }, [spread]); + + const [spreadReadyTick, setSpreadReadyTick] = useState(0); + + const checkSpreadReady = useCallback(() => { + if (!spreadRef.current) { + return false; + } + try { + const pages = spreadRef.current.getSpreadPages(); + return Array.isArray(pages) && pages.length > 0; + } catch { + return false; + } + }, []); + + useEffect(() => { + if (checkSpreadReady()) return; + const interval = setInterval(() => { + if (checkSpreadReady()) { + setSpreadReadyTick((t) => t + 1); + clearInterval(interval); + } + }, 50); + return () => clearInterval(interval); + }, [checkSpreadReady, documentId, totalPages]); const scheduleAutoZoom = useCallback(() => { hasSetInitialZoom.current = false; @@ -57,14 +85,18 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { }, []); const requestFitWidth = useCallback(() => { - if (zoomRef.current) { - zoomRef.current.requestZoom(ZoomMode.FitWidth, { vx: 0.5, vy: 0 }); + if (zoomRef.current && checkSpreadReady()) { + try { + zoomRef.current.requestZoom(ZoomMode.FitWidth, { vx: 0.5, vy: 0 }); + } catch (error) { + console.warn("[ZoomAPIBridge] Failed to request fit width:", error); + } } - }, []); + }, [checkSpreadReady]); const stubs = fileStubs; const firstFileStub = stubs[0]; - const firstFileId = firstFileStub?.id; + const firstFileRootId = firstFileStub?.originalFileId || firstFileStub?.id; // Extract primitive values from zoomState for dependency arrays const zoomLevel = zoomState?.zoomLevel; @@ -74,18 +106,19 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub); useEffect(() => { - if (!firstFileId) { + if (!firstFileRootId) { hasSetInitialZoom.current = false; lastFileId.current = undefined; lastAppliedZoom.current = null; return; } - if (firstFileId !== lastFileId.current) { - lastFileId.current = firstFileId; + // Only reset zoom when opening a genuinely different document, not on version increments + if (firstFileRootId !== lastFileId.current) { + lastFileId.current = firstFileRootId; scheduleAutoZoom(); } - }, [firstFileId, scheduleAutoZoom]); + }, [firstFileRootId, scheduleAutoZoom]); useEffect(() => { const currentSpreadMode = spreadMode ?? SpreadMode.None; @@ -105,6 +138,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { }, [spreadMode, zoomLevel, scheduleAutoZoom, requestFitWidth]); const isManagedZoom = + checkSpreadReady() && !!zoom && (zoomLevel === ZoomMode.FitWidth || zoomLevel === ZoomMode.Automatic || @@ -121,7 +155,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { return; } - if (!firstFileId) { + if (!firstFileRootId || !checkSpreadReady()) { return; } @@ -130,9 +164,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { } if (zoomLevel !== ZoomMode.FitWidth) { - if (zoomLevel === ZoomMode.Automatic) { - requestFitWidth(); - } + requestFitWidth(); return; } @@ -145,7 +177,11 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { level: number | ZoomMode, effectiveZoom: number, ) => { - zoom.requestZoom(level, { vx: 0.5, vy: 0 }); + try { + zoom.requestZoom(level, { vx: 0.5, vy: 0 }); + } catch (error) { + console.warn("[ZoomAPIBridge] Failed to request zoom:", error); + } lastAppliedZoom.current = effectiveZoom; triggerImmediateZoomUpdate(Math.round(effectiveZoom * 100)); hasSetInitialZoom.current = true; @@ -217,7 +253,9 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { zoom, zoomLevel, currentZoomLevel, - firstFileId, + firstFileRootId, + checkSpreadReady, + spreadReadyTick, metadataAspectRatio, requestFitWidth, autoZoomTick, diff --git a/frontend/editor/src/core/components/viewer/hooks/useDocumentReady.ts b/frontend/editor/src/core/components/viewer/hooks/useDocumentReady.ts index b79400bc72..67ccbeae58 100644 --- a/frontend/editor/src/core/components/viewer/hooks/useDocumentReady.ts +++ b/frontend/editor/src/core/components/viewer/hooks/useDocumentReady.ts @@ -1,70 +1 @@ -import { useState, useEffect } from "react"; -import { useDocumentManagerCapability } from "@embedpdf/plugin-document-manager/react"; - -/** - * useDocumentReady - Custom hook to track whether a PDF document is fully loaded - * and ready for interaction. - * - * Subscribes to both onDocumentOpened (sets true) and onDocumentClosed (resets - * to false) so the flag correctly tracks the document lifecycle across - * open → close → reopen transitions. - * - * The initial check is synchronous (getActiveDocument is sync) — no debounce - * needed. - */ -export function useDocumentReady() { - const { provides: documentManagerCapability } = - useDocumentManagerCapability(); - const [documentReady, setDocumentReady] = useState(false); - - useEffect(() => { - if (!documentManagerCapability) { - setDocumentReady(false); - return; - } - - let mounted = true; - - const unsubOpen = documentManagerCapability.onDocumentOpened?.( - (event: { documentId?: string; id?: string }) => { - if (mounted && (event?.documentId || event?.id)) { - setDocumentReady(true); - } - }, - ); - - const unsubClose = documentManagerCapability.onDocumentClosed?.(() => { - if (!mounted) return; - - try { - const remaining = documentManagerCapability.getActiveDocument?.(); - if (!remaining?.id && mounted) { - setDocumentReady(false); - } - } catch { - if (mounted) setDocumentReady(false); - } - }); - - try { - const activeDoc = documentManagerCapability.getActiveDocument?.(); - if (mounted) { - setDocumentReady(!!activeDoc?.id); - } - } catch { - if (mounted) setDocumentReady(false); - } - - return () => { - mounted = false; - if (typeof unsubOpen === "function") { - unsubOpen(); - } - if (typeof unsubClose === "function") { - unsubClose(); - } - }; - }, [documentManagerCapability]); - - return documentReady; -} +export { useDocumentReady } from "@app/components/viewer/useActiveDocumentId"; diff --git a/frontend/editor/src/core/components/viewer/useActiveDocumentId.ts b/frontend/editor/src/core/components/viewer/useActiveDocumentId.ts index f889e6f8e3..b6d2f9670c 100644 --- a/frontend/editor/src/core/components/viewer/useActiveDocumentId.ts +++ b/frontend/editor/src/core/components/viewer/useActiveDocumentId.ts @@ -1,9 +1,14 @@ -import { useActiveDocument } from "@app/components/viewer/ActiveDocumentContext"; +import { useActiveDocument } from "@embedpdf/plugin-document-manager/react"; -/** - * Hook to get the currently active document ID. - * Uses a shared context to avoid multiple subscriptions. - */ export function useActiveDocumentId(): string | null { - return useActiveDocument(); + return useActiveDocument().activeDocumentId; +} + +export function useDocumentReady(): boolean { + const { activeDocumentId, activeDocument } = useActiveDocument(); + return Boolean( + activeDocumentId && + activeDocument?.status === "loaded" && + activeDocument?.document, + ); } diff --git a/frontend/editor/src/core/contexts/RedactionContext.tsx b/frontend/editor/src/core/contexts/RedactionContext.tsx index 0391642cda..8afda027c5 100644 --- a/frontend/editor/src/core/contexts/RedactionContext.tsx +++ b/frontend/editor/src/core/contexts/RedactionContext.tsx @@ -20,7 +20,7 @@ export interface RedactionAPI { isRedactActive: () => boolean; endRedact: () => void; // Common methods - commitAllPending: () => void; + commitAllPending: () => Promise; getActiveType: () => RedactionMode | null; getPendingCount: () => number; } @@ -64,7 +64,7 @@ interface RedactionActions { // Unified redaction actions (v2.5.0) activateRedact: () => void; deactivateRedact: () => void; - commitAllPending: () => void; + commitAllPending: () => Promise; // Unified manual redaction action activateManualRedact: () => void; // Legacy UI actions (for backwards compatibility with UI) @@ -196,9 +196,9 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ } }, []); - const commitAllPending = useCallback(() => { + const commitAllPending = useCallback(async () => { if (redactionApiRef.current) { - redactionApiRef.current.commitAllPending(); + await redactionApiRef.current.commitAllPending(); // Mark redactions as applied (but not yet saved) so the Save Changes button stays enabled // The button will only be disabled after the file is successfully saved setRedactionsApplied(true); diff --git a/frontend/editor/src/core/utils/viewerZoom.ts b/frontend/editor/src/core/utils/viewerZoom.ts index 6e9f8f3e0c..afda86cbf6 100644 --- a/frontend/editor/src/core/utils/viewerZoom.ts +++ b/frontend/editor/src/core/utils/viewerZoom.ts @@ -160,8 +160,12 @@ export function useFitWidthResize({ } timeoutId = window.setTimeout(() => { - requestFitWidthRef.current?.(); - onDebouncedResizeRef.current?.(); + try { + requestFitWidthRef.current?.(); + onDebouncedResizeRef.current?.(); + } catch { + // Ignore resize calculations if document is transitioning or not yet initialized + } }, debounceMs); };