refactor(viewer): optimize document lifecycle hooks, spread readiness, and async redaction commits

This commit is contained in:
Balázs Szücs
2026-08-27 22:50:49 +02:00
parent be13028209
commit 78964f1e4e
14 changed files with 456 additions and 515 deletions
@@ -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."
@@ -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<ActiveDocumentContextType>({
documentId: null,
});
export function ActiveDocumentProvider({
children,
}: {
children: React.ReactNode;
}) {
const { plugin, isLoading } = useDocumentManagerPlugin();
const [documentId, setDocumentId] = useState<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const documentIdRef = useRef<string | null>(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 (
<ActiveDocumentContext.Provider value={value}>
{children}
</ActiveDocumentContext.Provider>
);
}
export function useActiveDocument(): string | null {
const context = useContext(ActiveDocumentContext);
return context.documentId;
}
@@ -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,
@@ -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<string | null>(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)}</>;
}
@@ -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);
@@ -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<string | null>(null);
const [pdfUrl, setPdfUrl] = useState<string | null>(() => 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<ArrayBuffer | null>(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 (
<Center h="100%" w="100%">
@@ -483,21 +547,47 @@ export function LocalEmbedPDF({
);
}
if (isLoading || !engine || !pdfUrl) {
return <ToolLoadingFallback toolName="PDF Engine" />;
const hasInput = Boolean(file || url);
const isInputReady = Boolean(pdfBuffer || (!file && pdfUrl));
if (isLoading || !engine || (hasInput && !isInputReady)) {
return (
<Center h="100%" w="100%">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text c="dimmed" size="sm">
{t("viewer.loadingEngine", "Loading PDF Engine...")}
</Text>
{engineTimeout && (
<Text
c="var(--color-red-dark)"
size="xs"
style={{ textAlign: "center", maxWidth: "360px" }}
>
{t(
"viewer.engineSlowWarning",
"PDF engine initialization is taking longer than expected. Please check your browser WebAssembly and Worker settings, or reload the page.",
)}
</Text>
)}
</Stack>
</Center>
);
}
if (error) {
return (
<Center h="100%" w="100%">
<Stack align="center" gap="md">
<div style={{ fontSize: "24px" }}></div>
<Text
c="var(--color-red-dark)"
size="sm"
style={{ textAlign: "center" }}
>
Error loading PDF engine: {error.message}
<div style={{ fontSize: "24px" }}></div>
<Text c="red" size="sm">
{t(
"viewer.engineLoadError",
"Failed to initialize PDF viewer engine",
)}
</Text>
<Text c="dimmed" size="xs">
{error.message}
</Text>
</Stack>
</Center>
@@ -1008,264 +1098,254 @@ export function LocalEmbedPDF({
}
}}
>
<ActiveDocumentProvider>
<ZoomAPIBridge />
<ScrollAPIBridge />
<SelectionAPIBridge />
<FormCreationInteractionLock />
<PanAPIBridge />
<SpreadAPIBridge />
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
{(enableAnnotations ||
enableRedaction ||
isManualRedactionMode) && (
<HistoryAPIBridge ref={historyApiRef} />
)}
{/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */}
{(enableRedaction || isManualRedactionMode) && (
<RedactionAPIBridge />
)}
{/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */}
{(enableAnnotations ||
enableRedaction ||
isManualRedactionMode) && (
<SignatureAPIBridge
ref={signatureApiRef}
isSignMode={isSignMode}
/>
)}
{(enableRedaction || isManualRedactionMode) && (
<RedactionPendingTracker ref={redactionTrackerRef} />
)}
{enableAnnotations && (
<AnnotationAPIBridge ref={annotationApiRef} />
)}
<ZoomAPIBridge />
<ScrollAPIBridge />
<SelectionAPIBridge />
<FormCreationInteractionLock />
<PanAPIBridge />
<SpreadAPIBridge />
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
{(enableAnnotations || enableRedaction || isManualRedactionMode) && (
<HistoryAPIBridge ref={historyApiRef} />
)}
{/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */}
{(enableRedaction || isManualRedactionMode) && <RedactionAPIBridge />}
{/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */}
{(enableAnnotations || enableRedaction || isManualRedactionMode) && (
<SignatureAPIBridge ref={signatureApiRef} isSignMode={isSignMode} />
)}
{(enableRedaction || isManualRedactionMode) && (
<RedactionPendingTracker ref={redactionTrackerRef} />
)}
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
<AttachmentAPIBridge />
<PrintAPIBridge file={file} url={pdfUrl} fileName={fileName} />
<DocumentPermissionsAPIBridge />
<DocumentReadyWrapper
fallback={
<Center style={{ height: "100%", width: "100%" }}>
<ToolLoadingFallback />
</Center>
}
>
{(documentId) => (
<>
<GlobalPointerProvider documentId={documentId}>
<Viewport
<ExportAPIBridge />
<BookmarkAPIBridge />
<AttachmentAPIBridge />
<PrintAPIBridge file={file} url={pdfUrl} fileName={fileName} />
<DocumentPermissionsAPIBridge />
<DocumentReadyWrapper
fallback={
<Center style={{ height: "100%", width: "100%" }}>
<Stack align="center" gap="md">
<Loader size="lg" />
<Text c="dimmed" size="sm">
{t("viewer.loadingDocument", "Loading document...")}
</Text>
</Stack>
</Center>
}
>
{(documentId) => (
<>
<GlobalPointerProvider documentId={documentId}>
<Viewport
documentId={documentId}
style={{
backgroundColor: "var(--c-bg)",
height: "100%",
width: "100%",
maxHeight: "100%",
maxWidth: "100%",
overflow: "auto",
position: "relative",
flex: 1,
minHeight: 0,
minWidth: 0,
contain: "strict",
}}
>
<Scroller
documentId={documentId}
style={{
backgroundColor: "var(--c-bg)",
height: "100%",
width: "100%",
maxHeight: "100%",
maxWidth: "100%",
overflow: "auto",
position: "relative",
flex: 1,
minHeight: 0,
minWidth: 0,
contain: "strict",
}}
>
<Scroller
documentId={documentId}
renderPage={({ width, height, pageIndex }) => {
return (
<Rotate
key={`${documentId}-${pageIndex}`}
renderPage={({ width, height, pageIndex }) => (
<Rotate
key={`${documentId}-${pageIndex}`}
documentId={documentId}
pageIndex={pageIndex}
>
<PagePointerProvider
documentId={documentId}
pageIndex={pageIndex}
>
<ViewerPageContainer
documentId={documentId}
pageIndex={pageIndex}
width={width}
height={height}
>
<PagePointerProvider
documentId={documentId}
pageIndex={pageIndex}
<div
style={{
position: "absolute",
inset: 0,
transition: "filter 0.25s ease",
filter:
pdfRenderMode === "dark"
? "invert(1) hue-rotate(180deg)"
: pdfRenderMode === "sepia"
? "sepia(0.7) brightness(0.85)"
: undefined,
}}
>
<ViewerPageContainer
<TilingLayer
documentId={documentId}
pageIndex={pageIndex}
width={width}
height={height}
>
<div
style={{
position: "absolute",
inset: 0,
transition: "filter 0.25s ease",
filter:
pdfRenderMode === "dark"
? "invert(1) hue-rotate(180deg)"
: pdfRenderMode === "sepia"
? "sepia(0.7) brightness(0.85)"
: undefined,
}}
>
<TilingLayer
documentId={documentId}
pageIndex={pageIndex}
/>
</div>
/>
</div>
<CustomSearchLayer
documentId={documentId}
pageIndex={pageIndex}
/>
<CustomSearchLayer
documentId={documentId}
pageIndex={pageIndex}
/>
<div
className="pdf-selection-layer"
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<SelectionLayer
documentId={documentId}
pageIndex={pageIndex}
background="var(--pdf-selection-bg)"
selectionMenu={(props) => (
<TextSelectionMenu {...props} />
)}
/>
</div>
<TextSelectionHandler
documentId={documentId}
pageIndex={pageIndex}
/>
{/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */}
{enableFormFill && file && (
<ButtonAppearanceOverlay
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
<div
className="pdf-selection-layer"
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<SelectionLayer
documentId={documentId}
pageIndex={pageIndex}
background="var(--pdf-selection-bg)"
selectionMenu={(props) => (
<TextSelectionMenu {...props} />
)}
/>
</div>
<TextSelectionHandler
documentId={documentId}
pageIndex={pageIndex}
/>
{/* FormFieldOverlay for interactive form filling */}
{enableFormFill && (
<FormFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
{/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */}
{enableFormFill && file && (
<ButtonAppearanceOverlay
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* FormFieldOverlay for interactive form filling */}
{enableFormFill && (
<FormFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Create-mode: drag to place new fields */}
{enableFormFill && formEditingActive && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Modify-mode: select / move / resize existing fields */}
{enableFormFill && formEditingActive && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
{(enableAnnotations || enableRedaction) && (
<AnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutline={{ color: "#007ACC" }}
selectionMenu={(props) => (
<AnnotationSelectionMenu {...props} />
)}
style={
!showBakedAnnotations
? {
opacity: 0,
pointerEvents: "none",
}
: undefined
}
/>
)}
{/* Create-mode: drag to place new fields */}
{enableFormFill && formEditingActive && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
{enableRedaction && (
<RedactionLayer
documentId={documentId}
pageIndex={pageIndex}
selectionMenu={(props) => (
<RedactionSelectionMenu {...props} />
)}
/>
)}
{/* Modify-mode: select / move / resize existing fields */}
{enableFormFill && formEditingActive && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* LinkLayer uses EmbedPDF annotation state for link rendering */}
<LinkLayer
documentId={documentId}
pageIndex={pageIndex}
/>
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
{(enableAnnotations || enableRedaction) && (
<AnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutline={{ color: "#007ACC" }}
selectionMenu={(props) => (
<AnnotationSelectionMenu {...props} />
)}
style={
!showBakedAnnotations
? {
opacity: 0,
pointerEvents: "none",
}
: undefined
}
/>
)}
{enableRedaction && (
<RedactionLayer
documentId={documentId}
pageIndex={pageIndex}
selectionMenu={(props) => (
<RedactionSelectionMenu {...props} />
)}
/>
)}
{/* LinkLayer uses EmbedPDF annotation state for link rendering */}
<LinkLayer
documentId={documentId}
pageIndex={pageIndex}
/>
{/* Signature preview overlay (opt-in; off by default) */}
{signatureOverlayEnabled && (
<SignaturePreviewLayer
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
previews={localSignaturePreviews}
readOnly={signaturePreviewsReadOnly}
placementMode={signaturePlacementMode}
placementData={signaturePlacementData}
placementType={signaturePlacementType}
onChange={handleSignaturePreviewsChange}
selectedId={selectedSignatureId}
onSelect={setSelectedSignatureId}
/>
)}
</ViewerPageContainer>
</PagePointerProvider>
</Rotate>
);
}}
/>
</Viewport>
</GlobalPointerProvider>
{enableAnnotations && (
<CommentAuthorProvider displayName={commentAuthorName}>
<CommentsSidebar
documentId={documentId}
visible={isCommentsSidebarVisible}
rightOffset={commentsSidebarRightOffset}
/>
</CommentAuthorProvider>
)}
</>
)}
</DocumentReadyWrapper>
</ActiveDocumentProvider>
{/* Signature preview overlay (opt-in; off by default) */}
{signatureOverlayEnabled && (
<SignaturePreviewLayer
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
previews={localSignaturePreviews}
readOnly={signaturePreviewsReadOnly}
placementMode={signaturePlacementMode}
placementData={signaturePlacementData}
placementType={signaturePlacementType}
onChange={handleSignaturePreviewsChange}
selectedId={selectedSignatureId}
onSelect={setSelectedSignatureId}
/>
)}
</ViewerPageContainer>
</PagePointerProvider>
</Rotate>
)}
/>
</Viewport>
</GlobalPointerProvider>
{enableAnnotations && (
<CommentAuthorProvider displayName={commentAuthorName}>
<CommentsSidebar
documentId={documentId}
visible={isCommentsSidebarVisible}
rightOffset={commentsSidebarRightOffset}
/>
</CommentAuthorProvider>
)}
</>
)}
</DocumentReadyWrapper>
</EmbedPDF>
</div>
</PrivateContent>
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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";
@@ -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,
);
}
@@ -20,7 +20,7 @@ export interface RedactionAPI {
isRedactActive: () => boolean;
endRedact: () => void;
// Common methods
commitAllPending: () => void;
commitAllPending: () => Promise<void>;
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<void>;
// 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);
+6 -2
View File
@@ -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);
};