mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
feat(frontend): Upgrade embedPDF to v2.6.0 and migrate to pdf-lib fork, fix attachment/bookmark panel (#5723)
# Description of Changes Upgrades embedPDF from v2.5.0 to v2.6.0 and migrates from unmaintained pdf-lib to @cantoo/pdf-lib fork. Adds defensive error handling for malformed PDFs and improves bridge lifecycle management. ### Changes **Dependencies** - Upgrade all @embedpdf/* packages from ^2.5.0 to ^2.6.0 - Replace pdf-lib with @cantoo/pdf-lib (maintained fork with better TypeScript support) **PDF Viewer Infrastructure (attachment/bookmark fix)** - Add useDocumentReady hook to track document lifecycle across bridges - Implement defensive bridge cleanup to prevent stale registrations - Fix race condition in document ready state detection by subscribing to events before checking state **Link Extraction (updated to cantoo/pdf-lib)** - Add graceful error handling for PDFs with invalid catalog structures - Extract enhanced link metadata (tooltips, colors, border styles, highlight modes) - Return empty results instead of throwing on malformed PDFs - Add validation for link creation (destination page bounds, rect dimensions, color values) **Signature Flattening (updated to cantoo/pdf-lib)** - Improve SVG embedding with three-tier fallback strategy (native vector, rasterized PNG, placeholder) - Add proper Unicode handling for PDF form tooltips via PDFString.decodeText() - Extract SVG utilities into cleaner strategy pattern **Form Field Processing (updated to cantoo/pdf-lib)** - Add support for display labels vs export values in dropdown/list fields per PDF spec 12.7.4.4 - Implement caching for expensive field property lookups - Add proper handling of malformed /Opt arrays <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs <[email protected]>
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
||||
AnnotationEvent,
|
||||
AnnotationPatch,
|
||||
} from '@app/components/viewer/viewerTypes';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
|
||||
|
||||
@@ -290,6 +291,7 @@ const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
|
||||
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
|
||||
// Use the provided annotation API just like SignatureAPIBridge/HistoryAPIBridge
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
const buildAnnotationDefaults = useCallback(
|
||||
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
|
||||
@@ -323,6 +325,7 @@ export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function Annotation
|
||||
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
configureAnnotationTool(toolId, options);
|
||||
},
|
||||
isReady: () => !!annotationApi && documentReady,
|
||||
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
const defaults = buildAnnotationDefaults(toolId, options);
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
|
||||
@@ -3,7 +3,11 @@ import { useAttachmentCapability } from '@embedpdf/plugin-attachment/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { AttachmentState, AttachmentAPIWrapper } from '@app/contexts/viewer/viewerBridges';
|
||||
import { PdfAttachmentObject } from '@embedpdf/models';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Connects the PDF attachment plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function AttachmentAPIBridge() {
|
||||
const { provides: attachmentCapability } = useAttachmentCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
@@ -12,10 +16,19 @@ export function AttachmentAPIBridge() {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
const fetchAttachments = useCallback(
|
||||
async () => {
|
||||
if (!attachmentCapability) return [];
|
||||
if (!attachmentCapability || !documentReady) {
|
||||
// Set error state instead of throwing for better user experience
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Document not ready or attachment capability not available',
|
||||
isLoading: false
|
||||
}));
|
||||
return [];
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
@@ -42,11 +55,12 @@ export function AttachmentAPIBridge() {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[attachmentCapability]
|
||||
[attachmentCapability, documentReady]
|
||||
);
|
||||
|
||||
const api = useMemo<AttachmentAPIWrapper | null>(() => {
|
||||
if (!attachmentCapability) return null;
|
||||
// Only provide API when both capability AND document are ready
|
||||
if (!attachmentCapability || !documentReady) return null;
|
||||
|
||||
return {
|
||||
getAttachments: fetchAttachments,
|
||||
@@ -84,15 +98,23 @@ export function AttachmentAPIBridge() {
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [attachmentCapability, fetchAttachments]);
|
||||
}, [attachmentCapability, documentReady, fetchAttachments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
if (!api) {
|
||||
// If API becomes null (e.g. document transitions), ensure we unregister stale bridge
|
||||
registerBridge('attachment', null);
|
||||
return;
|
||||
}
|
||||
|
||||
registerBridge('attachment', {
|
||||
state,
|
||||
api,
|
||||
});
|
||||
|
||||
return () => {
|
||||
registerBridge('attachment', null);
|
||||
};
|
||||
}, [api, state, registerBridge]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useBookmarkCapability, BookmarkCapability } from '@embedpdf/plugin-bookmark/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { BookmarkState, BookmarkAPIWrapper } from '@app/contexts/viewer/viewerBridges';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Connects the PDF bookmark plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function BookmarkAPIBridge() {
|
||||
const { provides: bookmarkCapability } = useBookmarkCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
@@ -11,9 +15,19 @@ export function BookmarkAPIBridge() {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
const fetchBookmarks = useCallback(
|
||||
async (capability: BookmarkCapability) => {
|
||||
if (!documentReady) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Document not ready or bookmark capability not available',
|
||||
isLoading: false,
|
||||
}));
|
||||
return [];
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
const task = capability.getBookmarks();
|
||||
@@ -34,11 +48,12 @@ export function BookmarkAPIBridge() {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[]
|
||||
[documentReady]
|
||||
);
|
||||
|
||||
const api = useMemo<BookmarkAPIWrapper | null>(() => {
|
||||
if (!bookmarkCapability) return null;
|
||||
// Only provide API when both capability AND document are ready
|
||||
if (!bookmarkCapability || !documentReady) return null;
|
||||
|
||||
return {
|
||||
fetchBookmarks: () => fetchBookmarks(bookmarkCapability),
|
||||
@@ -57,15 +72,22 @@ export function BookmarkAPIBridge() {
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [bookmarkCapability, fetchBookmarks]);
|
||||
}, [bookmarkCapability, documentReady, fetchBookmarks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
if (!api) {
|
||||
registerBridge('bookmark', null);
|
||||
return;
|
||||
}
|
||||
|
||||
registerBridge('bookmark', {
|
||||
state,
|
||||
api,
|
||||
});
|
||||
|
||||
return () => {
|
||||
registerBridge('bookmark', null);
|
||||
};
|
||||
}, [api, state, registerBridge]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
import {
|
||||
PdfPermissionFlag,
|
||||
DocumentPermissionsState,
|
||||
@@ -25,6 +26,7 @@ export function DocumentPermissionsAPIBridge({
|
||||
permissions = PdfPermissionFlag.AllowAll,
|
||||
}: DocumentPermissionsAPIBridgeProps) {
|
||||
const { registerBridge } = useViewer();
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
const state = useMemo<DocumentPermissionsState>(() => ({
|
||||
isEncrypted,
|
||||
@@ -42,7 +44,7 @@ export function DocumentPermissionsAPIBridge({
|
||||
|
||||
const api = useMemo<DocumentPermissionsAPIWrapper>(() => ({
|
||||
hasPermission: (flag: PdfPermissionFlag) => hasPermissionFlag(permissions, flag),
|
||||
hasAllPermissions: (flags: PdfPermissionFlag[]) =>
|
||||
hasAllPermissions: (flags: PdfPermissionFlag[]) =>
|
||||
flags.every(flag => hasPermissionFlag(permissions, flag)),
|
||||
getEffectivePermission: (flag: PdfPermissionFlag) => {
|
||||
if (isOwnerUnlocked) return true;
|
||||
@@ -51,11 +53,17 @@ export function DocumentPermissionsAPIBridge({
|
||||
}), [permissions, isOwnerUnlocked]);
|
||||
|
||||
useEffect(() => {
|
||||
registerBridge('permissions', {
|
||||
state,
|
||||
api,
|
||||
});
|
||||
}, [registerBridge, state, api]);
|
||||
if (documentReady) {
|
||||
registerBridge('permissions', {
|
||||
state,
|
||||
api,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
registerBridge('permissions', null);
|
||||
};
|
||||
}, [registerBridge, state, api, documentReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useExportCapability } from '@embedpdf/plugin-export/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and provides export functionality
|
||||
@@ -8,9 +9,10 @@ import { useViewer } from '@app/contexts/ViewerContext';
|
||||
export function ExportAPIBridge() {
|
||||
const { provides: exportApi } = useExportCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
useEffect(() => {
|
||||
if (exportApi) {
|
||||
if (exportApi && documentReady) {
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('export', {
|
||||
state: {
|
||||
@@ -19,7 +21,11 @@ export function ExportAPIBridge() {
|
||||
api: exportApi
|
||||
});
|
||||
}
|
||||
}, [exportApi, registerBridge]);
|
||||
|
||||
return () => {
|
||||
registerBridge('export', null);
|
||||
};
|
||||
}, [exportApi, documentReady, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,25 @@ import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
|
||||
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
import { uuidV4, PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import type { HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import { ANNOTATION_RECREATION_DELAY_MS, ANNOTATION_VERIFICATION_DELAY_MS } from '@app/constants/app';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Connects the PDF history (undo/redo) plugin to the shared ViewerContext.
|
||||
*/
|
||||
|
||||
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
|
||||
const { provides: historyApi } = useHistoryCapability();
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { getImageData, storeImageData } = useSignature();
|
||||
const documentReady = useDocumentReady();
|
||||
const restoringIds = useRef<Set<string>>(new Set());
|
||||
|
||||
// Monitor annotation events to detect when annotations are restored
|
||||
useEffect(() => {
|
||||
if (!annotationApi) return;
|
||||
if (!annotationApi || !documentReady) return;
|
||||
|
||||
const handleAnnotationEvent = (event: any) => {
|
||||
const annotation = event.annotation;
|
||||
@@ -146,6 +152,8 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
return historyApi ? historyApi.canRedo() : false;
|
||||
},
|
||||
|
||||
isReady: () => !!historyApi && documentReady,
|
||||
|
||||
purgeByMetadata: <T,>(predicate: (metadata: T | undefined) => boolean, topic?: string) => {
|
||||
if (historyApi?.purgeByMetadata) {
|
||||
return historyApi.purgeByMetadata(predicate, topic);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useDocumentState } from '@embedpdf/core/react';
|
||||
import { useScroll } from '@embedpdf/plugin-scroll/react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import { usePdfLibLinks, type PdfLibLink } from '@app/hooks/usePdfLibLinks';
|
||||
import { usePdfLibLinks } from '@app/hooks/usePdfLibLinks';
|
||||
import type { PdfLibLink } from '@app/utils/pdfLinkUtils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline SVG icons (thin-stroke, modern)
|
||||
|
||||
@@ -125,7 +125,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
}),
|
||||
createPluginRegistration(ScrollPluginPackage),
|
||||
createPluginRegistration(RenderPluginPackage, {
|
||||
withForms: !enableFormFill, // Disable native form rendering when our interactive overlay is active
|
||||
withForms: true,
|
||||
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
|
||||
}),
|
||||
|
||||
@@ -204,7 +204,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
// Register print plugin for printing PDFs
|
||||
createPluginRegistration(PrintPluginPackage),
|
||||
];
|
||||
}, [pdfUrl, enableAnnotations, enableFormFill, showBakedAnnotations, fileName, file, url]);
|
||||
}, [pdfUrl, enableAnnotations, showBakedAnnotations, fileName, file, url]);
|
||||
|
||||
// Initialize the engine with the React hook - use local WASM for offline support
|
||||
const { engine, isLoading, error } = usePdfiumEngine({
|
||||
|
||||
@@ -2,28 +2,33 @@ import { useEffect, useRef } from 'react';
|
||||
import { usePan } from '@embedpdf/plugin-pan/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Connects the PDF pan (hand tool) plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function PanAPIBridge() {
|
||||
const activeDocumentId = useActiveDocumentId();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID
|
||||
if (!activeDocumentId) {
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID and the document is ready
|
||||
if (!activeDocumentId || !documentReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return <PanAPIBridgeInner documentId={activeDocumentId} />;
|
||||
}
|
||||
|
||||
function PanAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
const { provides: pan, isPanning } = usePan(documentId);
|
||||
const { registerBridge, triggerImmediatePanUpdate } = useViewer();
|
||||
|
||||
|
||||
// Keep pan ref updated to avoid re-running effect when object reference changes
|
||||
const panRef = useRef(pan);
|
||||
useEffect(() => {
|
||||
panRef.current = pan;
|
||||
}, [pan]);
|
||||
|
||||
|
||||
// Track previous isPanning value to detect changes
|
||||
const prevIsPanningRef = useRef<boolean>(isPanning);
|
||||
|
||||
@@ -57,13 +62,16 @@ function PanAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger immediate pan update if the value changed
|
||||
|
||||
if (prevIsPanningRef.current !== isPanning) {
|
||||
prevIsPanningRef.current = isPanning;
|
||||
triggerImmediatePanUpdate(isPanning);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
registerBridge('pan', null);
|
||||
};
|
||||
}, [isPanning, registerBridge, triggerImmediatePanUpdate]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePrintCapability } from '@embedpdf/plugin-print/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and exposes print API to ViewerContext
|
||||
* Connects the PDF print plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function PrintAPIBridge() {
|
||||
const { provides: print } = usePrintCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
useEffect(() => {
|
||||
if (print) {
|
||||
if (print && documentReady) {
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('print', {
|
||||
state: {},
|
||||
@@ -19,7 +21,11 @@ export function PrintAPIBridge() {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [print, registerBridge]);
|
||||
|
||||
return () => {
|
||||
registerBridge('print', null);
|
||||
};
|
||||
}, [print, documentReady, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,17 +4,18 @@ import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import { useRedaction } from '@app/contexts/RedactionContext';
|
||||
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* RedactionAPIBridge - Uses embedPDF v2.5.0
|
||||
* Bridges between the EmbedPDF redaction plugin and the Stirling-PDF RedactionContext.
|
||||
* Uses the unified redaction mode (toggleRedact/enableRedact/endRedact).
|
||||
*/
|
||||
export function RedactionAPIBridge() {
|
||||
const activeDocumentId = useActiveDocumentId();
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID
|
||||
if (!activeDocumentId) {
|
||||
// Don't render the inner component until we have a valid document ID and document is ready
|
||||
if (!activeDocumentId || !documentReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,9 +70,7 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
}, [annotationProvides, manualRedactColor]);
|
||||
|
||||
// Expose the EmbedPDF API through our context's ref
|
||||
// Uses v2.5.0 unified redaction mode
|
||||
useImperativeHandle(redactionApiRef, () => ({
|
||||
// Unified redaction methods (v2.5.0)
|
||||
toggleRedact: () => {
|
||||
redactionProvides?.toggleRedact();
|
||||
},
|
||||
|
||||
@@ -2,15 +2,20 @@ import { useEffect, useRef } from 'react';
|
||||
import { useRotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Connects the PDF rotation plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function RotateAPIBridge() {
|
||||
const activeDocumentId = useActiveDocumentId();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID
|
||||
if (!activeDocumentId) {
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID and document is ready
|
||||
if (!activeDocumentId || !documentReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return <RotateAPIBridgeInner documentId={activeDocumentId} />;
|
||||
}
|
||||
|
||||
@@ -42,7 +47,11 @@ function RotateAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
registerBridge('rotation', null);
|
||||
};
|
||||
}, [rotation, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,20 @@ import { useEffect, useRef } from 'react';
|
||||
import { useScroll } from '@embedpdf/plugin-scroll/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Connects the PDF scroll plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function ScrollAPIBridge() {
|
||||
const activeDocumentId = useActiveDocumentId();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID
|
||||
if (!activeDocumentId) {
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID and document is ready
|
||||
if (!activeDocumentId || !documentReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return <ScrollAPIBridgeInner documentId={activeDocumentId} />;
|
||||
}
|
||||
|
||||
@@ -35,7 +40,7 @@ function ScrollAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
currentPage,
|
||||
totalPages,
|
||||
};
|
||||
|
||||
|
||||
// Trigger immediate update for responsive UI
|
||||
triggerImmediateScrollUpdate(newState.currentPage, newState.totalPages);
|
||||
|
||||
@@ -44,6 +49,10 @@ function ScrollAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
api: currentScroll
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
registerBridge('scroll', null);
|
||||
};
|
||||
}, [currentPage, totalPages, registerBridge, triggerImmediateScrollUpdate]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import { useSearch } from '@embedpdf/plugin-search/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
interface SearchResult {
|
||||
pageIndex: number;
|
||||
@@ -11,30 +12,35 @@ interface SearchResult {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchAPIBridge - Updated for embedPDF v2.6.0
|
||||
* Connects the PDF search plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function SearchAPIBridge() {
|
||||
const activeDocumentId = useActiveDocumentId();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID
|
||||
if (!activeDocumentId) {
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID and document is ready
|
||||
if (!activeDocumentId || !documentReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return <SearchAPIBridgeInner documentId={activeDocumentId} />;
|
||||
}
|
||||
|
||||
function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
const { provides: search } = useSearch(documentId);
|
||||
const { registerBridge, scrollActions } = useViewer();
|
||||
|
||||
|
||||
// Keep search ref updated to avoid re-running effects when object reference changes
|
||||
const searchRef = useRef(search);
|
||||
const isSearchingRef = useRef(false);
|
||||
const lastScrolledIndexRef = useRef<number | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current = search;
|
||||
}, [search]);
|
||||
|
||||
|
||||
const [localState, setLocalState] = useState({
|
||||
results: null as SearchResult[] | null,
|
||||
activeIndex: 0
|
||||
@@ -42,14 +48,14 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
|
||||
// Subscribe to search result changes from EmbedPDF
|
||||
const subscriptionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup previous subscription
|
||||
if (subscriptionRef.current) {
|
||||
subscriptionRef.current();
|
||||
subscriptionRef.current = null;
|
||||
}
|
||||
|
||||
|
||||
if (!search) return;
|
||||
|
||||
subscriptionRef.current = search.onSearchResultStateChange?.((state: any) => {
|
||||
@@ -87,11 +93,11 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
lastScrolledIndexRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Only scroll if the active index actually changed
|
||||
const activeResultIndex = localActiveIndex - 1; // Convert back to 0-based
|
||||
if (activeResultIndex >= 0 &&
|
||||
activeResultIndex < localResults.length &&
|
||||
if (activeResultIndex >= 0 &&
|
||||
activeResultIndex < localResults.length &&
|
||||
lastScrolledIndexRef.current !== activeResultIndex) {
|
||||
const activeResult = localResults[activeResultIndex];
|
||||
if (activeResult) {
|
||||
@@ -114,13 +120,13 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
if (isSearchingRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if (!currentSearch?.startSearch || !currentSearch?.searchAllPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
isSearchingRef.current = true;
|
||||
|
||||
|
||||
try {
|
||||
currentSearch.startSearch();
|
||||
const results = await currentSearch.searchAllPages(query);
|
||||
@@ -171,6 +177,10 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
registerBridge('search', null);
|
||||
};
|
||||
}, [localResults, localActiveIndex, registerBridge]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSelectionCapability } from '@embedpdf/plugin-selection/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Connects the PDF selection plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function SelectionAPIBridge() {
|
||||
const { provides: selection } = useSelectionCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
|
||||
const hasSelectionRef = useRef(false);
|
||||
const selectedTextRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection) return;
|
||||
if (!selection || !documentReady) return;
|
||||
|
||||
const buildApi = () => ({
|
||||
copyToClipboard: () => selection.copyToClipboard(),
|
||||
@@ -68,8 +73,9 @@ export function SelectionAPIBridge() {
|
||||
unsubCopy?.();
|
||||
document.removeEventListener('copy', handleCopy);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
registerBridge('selection', null);
|
||||
};
|
||||
}, [selection]);
|
||||
}, [selection, documentReady, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
|
||||
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Connects the PDF signature (stamp/ink) tools to the shared ViewerContext and SignatureContext.
|
||||
*/
|
||||
|
||||
// Minimum allowed width/height (in pixels) for a signature image or text stamp.
|
||||
// This prevents rendering issues and ensures signatures are always visible and usable.
|
||||
@@ -132,6 +137,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize, setSignaturesApplied } = useSignature();
|
||||
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
|
||||
const documentReady = useDocumentReady();
|
||||
const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1);
|
||||
const lastStampImageRef = useRef<string | null>(null);
|
||||
|
||||
@@ -211,7 +217,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
// Enable keyboard deletion of selected annotations
|
||||
useEffect(() => {
|
||||
// Always enable delete key when we have annotation API and are in sign mode
|
||||
if (!annotationApi || (isPlacementMode === undefined)) return;
|
||||
if (!annotationApi || (isPlacementMode === undefined) || !documentReady) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox)
|
||||
@@ -391,7 +397,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!annotationApi?.onAnnotationEvent) {
|
||||
if (!annotationApi?.onAnnotationEvent || !documentReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -430,10 +436,10 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [annotationApi, storeImageData, setSignaturesApplied]);
|
||||
}, [annotationApi, storeImageData, setSignaturesApplied, documentReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlacementMode) {
|
||||
if (!isPlacementMode || !documentReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -447,66 +453,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!annotationApi?.onAnnotationEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = annotationApi.onAnnotationEvent(event => {
|
||||
if (event.type !== 'create' && event.type !== 'update') {
|
||||
return;
|
||||
}
|
||||
|
||||
const annotation: any = event.annotation;
|
||||
const annotationId: string | undefined = annotation?.id;
|
||||
if (!annotationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark signatures as not applied when a new signature is placed
|
||||
if (event.type === 'create') {
|
||||
setSignaturesApplied(false);
|
||||
}
|
||||
|
||||
const directData =
|
||||
extractDataUrl(annotation.imageSrc) ||
|
||||
extractDataUrl(annotation.imageData) ||
|
||||
extractDataUrl(annotation.appearance) ||
|
||||
extractDataUrl(annotation.stampData) ||
|
||||
extractDataUrl(annotation.contents) ||
|
||||
extractDataUrl(annotation.data) ||
|
||||
extractDataUrl(annotation.customData) ||
|
||||
extractDataUrl(annotation.asset);
|
||||
|
||||
const dataToStore = directData || lastStampImageRef.current;
|
||||
if (dataToStore) {
|
||||
storeImageData(annotationId, dataToStore);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [annotationApi, storeImageData, setSignaturesApplied]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlacementMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
configureStampDefaults().catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Error updating signature defaults:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
|
||||
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig, documentReady]);
|
||||
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
|
||||
@@ -2,15 +2,21 @@ import { useEffect, useRef } from 'react';
|
||||
import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* SpreadAPIBridge - Updated for embedPDF v2.6.0
|
||||
* Connects the PDF spread mode (single/dual page) plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function SpreadAPIBridge() {
|
||||
const activeDocumentId = useActiveDocumentId();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID
|
||||
if (!activeDocumentId) {
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID and document is ready
|
||||
if (!activeDocumentId || !documentReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return <SpreadAPIBridgeInner documentId={activeDocumentId} />;
|
||||
}
|
||||
|
||||
@@ -52,6 +58,10 @@ function SpreadAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
});
|
||||
|
||||
triggerImmediateSpreadUpdate(spreadMode);
|
||||
|
||||
return () => {
|
||||
registerBridge('spread', null);
|
||||
};
|
||||
}, [spreadMode, registerBridge, triggerImmediateSpreadUpdate]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useThumbnailCapability } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* ThumbnailAPIBridge provides thumbnail generation functionality.
|
||||
* Exposes thumbnail API to UI components without managing state.
|
||||
* ThumbnailAPIBridge - Updated for embedPDF v2.6.0
|
||||
* Provides thumbnail generation functionality.
|
||||
*/
|
||||
export function ThumbnailAPIBridge() {
|
||||
const { provides: thumbnail } = useThumbnailCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
useEffect(() => {
|
||||
if (thumbnail) {
|
||||
if (thumbnail && documentReady) {
|
||||
registerBridge('thumbnail', {
|
||||
state: null, // No state - just provides API
|
||||
api: thumbnail
|
||||
});
|
||||
}
|
||||
}, [thumbnail]);
|
||||
|
||||
return () => {
|
||||
registerBridge('thumbnail', null);
|
||||
};
|
||||
}, [thumbnail, documentReady, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,15 +11,20 @@ import {
|
||||
useFitWidthResize,
|
||||
} from '@app/utils/viewerZoom';
|
||||
import { getFirstPageAspectRatioFromStub } from '@app/utils/pageMetadata';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
/**
|
||||
* Connects the PDF zoom plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function ZoomAPIBridge() {
|
||||
const activeDocumentId = useActiveDocumentId();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID
|
||||
if (!activeDocumentId) {
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
// Don't render the inner component until we have a valid document ID and document is ready
|
||||
if (!activeDocumentId || !documentReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return <ZoomAPIBridgeInner documentId={activeDocumentId} />;
|
||||
}
|
||||
|
||||
@@ -60,7 +65,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
// Extract primitive values from zoomState for dependency arrays
|
||||
const zoomLevel = zoomState?.zoomLevel;
|
||||
const currentZoomLevel = zoomState?.currentZoomLevel;
|
||||
|
||||
|
||||
// Extract metadata aspect ratio as a primitive to avoid object reference issues
|
||||
const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub);
|
||||
|
||||
@@ -200,14 +205,14 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
|
||||
// Subscribe to zoom changes - use ref to avoid re-subscribing when zoom reference changes
|
||||
const zoomSubscriptionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup previous subscription if any
|
||||
if (zoomSubscriptionRef.current) {
|
||||
zoomSubscriptionRef.current();
|
||||
zoomSubscriptionRef.current = null;
|
||||
}
|
||||
|
||||
|
||||
if (!zoom) {
|
||||
return;
|
||||
}
|
||||
@@ -255,4 +260,4 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
}, [zoomStateCurrentZoomLevel, registerBridge, triggerImmediateZoomUpdate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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: any) => {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user