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:
Balázs Szücs
2026-02-14 20:55:27 +00:00
committed by GitHub
parent b8ce4e47c1
commit 0a1d2effdc
43 changed files with 1502 additions and 742 deletions
@@ -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;
}