mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
refactor(viewer): optimize document lifecycle hooks, spread readiness, and async redaction commits
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user