import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef, useState } from 'react'; import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models'; import { useSignature } from '@app/contexts/SignatureContext'; import type { SignatureAPI, AnnotationRect } 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. const MIN_SIGNATURE_DIMENSION = 12; // Use 2x oversampling to improve text rendering quality (anti-aliasing) when generating signature images. // This provides a good balance between visual fidelity and performance/memory usage. const TEXT_OVERSAMPLE_FACTOR = 2; const extractDataUrl = (value: unknown, depth = 0, visited: Set = new Set()): string | undefined => { if (!value || depth > 6) return undefined; // Prevent circular references if (typeof value === 'object' && visited.has(value)) { return undefined; } if (typeof value === 'string') { return value.startsWith('data:image') ? value : undefined; } if (typeof value === 'object') { visited.add(value); if (Array.isArray(value)) { for (const entry of value) { const result = extractDataUrl(entry, depth + 1, visited); if (result) return result; } } else { for (const key of Object.keys(value as Record)) { const result = extractDataUrl((value as Record)[key], depth + 1, visited); if (result) return result; } } } return undefined; }; const createTextStampImage = ( config: SignParameters, displaySize?: { width: number; height: number } | null ): { dataUrl: string; pixelWidth: number; pixelHeight: number; displayWidth: number; displayHeight: number } | null => { const text = (config.signerName ?? '').trim(); if (!text) { return null; } const fontSize = config.fontSize ?? 16; const fontFamily = config.fontFamily ?? 'Helvetica'; const textColor = config.textColor ?? '#000000'; const paddingX = Math.max(4, Math.round(fontSize * 0.8)); const paddingY = Math.max(4, Math.round(fontSize * 0.6)); const measureCanvas = document.createElement('canvas'); const measureCtx = measureCanvas.getContext('2d'); if (!measureCtx) { return null; } measureCtx.font = `${fontSize}px ${fontFamily}`; const metrics = measureCtx.measureText(text); const textWidth = Math.ceil(metrics.width); const naturalWidth = Math.max(MIN_SIGNATURE_DIMENSION, textWidth + paddingX * 2); const naturalHeight = Math.max(MIN_SIGNATURE_DIMENSION, Math.ceil(fontSize + paddingY * 2)); const scale = displaySize && naturalWidth > 0 && naturalHeight > 0 ? Math.min(displaySize.width / naturalWidth, displaySize.height / naturalHeight) : 1; const displayWidth = Math.max(MIN_SIGNATURE_DIMENSION, naturalWidth * scale); const displayHeight = Math.max(MIN_SIGNATURE_DIMENSION, naturalHeight * scale); const canvasWidth = Math.max( MIN_SIGNATURE_DIMENSION, Math.round(displayWidth * TEXT_OVERSAMPLE_FACTOR) ); const canvasHeight = Math.max( MIN_SIGNATURE_DIMENSION, Math.round(displayHeight * TEXT_OVERSAMPLE_FACTOR) ); const canvas = document.createElement('canvas'); canvas.width = canvasWidth; canvas.height = canvasHeight; const ctx = canvas.getContext('2d'); if (!ctx) { return null; } const effectiveScale = scale * TEXT_OVERSAMPLE_FACTOR; ctx.scale(effectiveScale, effectiveScale); ctx.fillStyle = textColor; ctx.font = `${fontSize}px ${fontFamily}`; ctx.textAlign = config.textAlign || 'left'; ctx.textBaseline = 'middle'; const horizontalPadding = paddingX; const verticalCenter = naturalHeight / 2; let xPosition = horizontalPadding; if (config.textAlign === 'center') { xPosition = naturalWidth / 2; } else if (config.textAlign === 'right') { xPosition = naturalWidth - horizontalPadding; } ctx.fillText(text, xPosition, verticalCenter); return { dataUrl: canvas.toDataURL('image/png'), pixelWidth: canvasWidth, pixelHeight: canvasHeight, displayWidth, displayHeight, }; }; interface SignatureAPIBridgeProps { /** When true (sign tool context), the general ink/pen annotation tool is blocked. */ isSignMode?: boolean; } export const SignatureAPIBridge = forwardRef(function SignatureAPIBridge({ isSignMode = false }, ref) { 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(null); useEffect(() => { setCurrentZoom(getZoomState()?.currentZoom ?? 1); const unregister = registerImmediateZoomUpdate(percent => { setCurrentZoom(Math.max(percent / 100, 0.01)); }); return () => { unregister?.(); }; }, [getZoomState, registerImmediateZoomUpdate]); // When entering sign mode, deactivate any active annotation tool immediately. // Only signature-specific tools (signatureInk, stamp) should be usable. useEffect(() => { if (isSignMode && annotationApi && documentReady) { annotationApi.setActiveTool(null); } }, [isSignMode, annotationApi, documentReady]); const cssToPdfSize = useCallback( (size: { width: number; height: number }) => { const zoom = currentZoom || 1; const factor = 1 / zoom; return { width: size.width * factor, height: size.height * factor, }; }, [currentZoom] ); const applyStampDefaults = useCallback( (imageSrc: string, subject: string, size?: { width: number; height: number }) => { if (!annotationApi) return; annotationApi.setActiveTool(null); annotationApi.setActiveTool('stamp'); const stampTool = annotationApi.getActiveTool(); if (stampTool && stampTool.id === 'stamp') { annotationApi.setToolDefaults('stamp', { imageSrc, subject, ...(size ? { imageSize: { width: size.width, height: size.height } } : {}), }); } }, [annotationApi] ); const configureStampDefaults = useCallback(async () => { if (!annotationApi || !signatureConfig) { return; } try { if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) { const textStamp = createTextStampImage(signatureConfig, placementPreviewSize); if (textStamp) { const displaySize = placementPreviewSize ?? { width: textStamp.displayWidth, height: textStamp.displayHeight, }; const pdfSize = cssToPdfSize(displaySize); lastStampImageRef.current = textStamp.dataUrl; applyStampDefaults(textStamp.dataUrl, `Text Signature - ${signatureConfig.signerName}`, pdfSize); } return; } if (signatureConfig.signatureData) { const pdfSize = placementPreviewSize ? cssToPdfSize(placementPreviewSize) : undefined; lastStampImageRef.current = signatureConfig.signatureData; applyStampDefaults(signatureConfig.signatureData, `Digital Signature - ${signatureConfig.reason || 'Document signing'}`, pdfSize); return; } } catch (error) { console.error('Error preparing signature defaults:', error); } }, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]); // 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) || !documentReady) return; const handleKeyDown = (event: KeyboardEvent) => { // Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox) const target = event.target as HTMLElement | null; const tag = target?.tagName?.toLowerCase(); const editable = target?.getAttribute?.('contenteditable'); if (tag === 'input' || tag === 'textarea' || editable === 'true') { return; } if (event.key === 'Delete' || event.key === 'Backspace') { const selectedAnnotation = annotationApi.getSelectedAnnotation?.(); if (selectedAnnotation) { const annotation = selectedAnnotation as any; const pageIndex = annotation.object?.pageIndex || 0; const id = annotation.object?.id; // For STAMP annotations, ensure image data is preserved before deletion if (annotation.object?.type === 13 && id) { // Get current annotation data to ensure we have latest image data stored const pageAnnotationsTask = annotationApi.getPageAnnotations?.({ pageIndex }); if (pageAnnotationsTask) { pageAnnotationsTask.toPromise().then((pageAnnotations: any) => { const currentAnn = pageAnnotations?.find((ann: any) => ann.id === id); if (currentAnn && currentAnn.imageSrc) { // Ensure the image data is stored in our persistent store storeImageData(id, currentAnn.imageSrc); } }).catch(console.error); } } // Use EmbedPDF's native deletion which should integrate with history if ((annotationApi as any).deleteSelected) { (annotationApi as any).deleteSelected(); } else { // Fallback to direct deletion - less ideal for history if (id) { annotationApi.deleteAnnotation(pageIndex, id); } } } } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [annotationApi, storeImageData, isPlacementMode]); useImperativeHandle(ref, () => ({ addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => { if (!annotationApi) return; // Create image stamp annotation with proper image data const annotationId = uuidV4(); // Store image data in our persistent store storeImageData(annotationId, signatureData); annotationApi.createAnnotation(pageIndex, { type: PdfAnnotationSubtype.STAMP, rect: { origin: { x, y }, size: { width, height } }, author: 'Digital Signature', subject: 'Digital Signature', pageIndex: pageIndex, id: annotationId, created: new Date(), // Store image data in multiple places to ensure history captures it imageSrc: signatureData, contents: signatureData, // Some annotation systems use contents data: signatureData, // Try data field imageData: signatureData, // Try imageData field appearance: signatureData // Try appearance field }); }, activateDrawMode: () => { if (!annotationApi) return; // Use signatureInk (not ink) so the annotation pen tool stays unaffected annotationApi.setActiveTool('signatureInk'); const activeTool = annotationApi.getActiveTool(); if (activeTool && activeTool.id === 'signatureInk') { annotationApi.setToolDefaults('signatureInk', { color: '#000000', thickness: 2, lineWidth: 2, strokeWidth: 2, width: 2 }); } }, activateSignaturePlacementMode: () => { if (!annotationApi || !signatureConfig) return; configureStampDefaults().catch((error) => { console.error('Error activating signature tool:', error); }); }, updateDrawSettings: (color: string, size: number) => { if (!annotationApi) return; // Always update ink tool defaults - use multiple property names for compatibility annotationApi.setToolDefaults('ink', { color: color, thickness: size, lineWidth: size, strokeWidth: size, width: size }); // Only reactivate ink if it was already the active tool — never auto-activate it. // This prevents the ink (pen annotation) tool from being activated when // updateDrawSettings is called on sign-tool mount. const currentTool = annotationApi.getActiveTool(); if (currentTool?.id === 'ink') { annotationApi.setActiveTool(null); setTimeout(() => { annotationApi.setActiveTool('ink'); }, 50); } }, activateDeleteMode: () => { if (!annotationApi) return; // Activate selection tool to allow selecting and deleting annotations // Users can click annotations to select them, then press Delete key or right-click to delete annotationApi.setActiveTool('select'); }, deleteAnnotation: (annotationId: string, pageIndex: number) => { if (!annotationApi) return; // Before deleting, try to preserve image data for potential undo const pageAnnotationsTask = annotationApi.getPageAnnotations?.({ pageIndex }); if (pageAnnotationsTask) { pageAnnotationsTask.toPromise().then((pageAnnotations: any) => { const annotation = pageAnnotations?.find((ann: any) => ann.id === annotationId); if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.imageSrc) { // Store image data before deletion storeImageData(annotationId, annotation.imageSrc); } }).catch(console.error); } // Delete specific annotation by ID annotationApi.deleteAnnotation(pageIndex, annotationId); }, deactivateTools: () => { if (!annotationApi) return; annotationApi.setActiveTool(null); }, getPageAnnotations: async (pageIndex: number): Promise => { if (!annotationApi || !annotationApi.getPageAnnotations) { console.warn('getPageAnnotations not available'); return []; } try { const pageAnnotationsTask = annotationApi.getPageAnnotations({ pageIndex }); if (pageAnnotationsTask && pageAnnotationsTask.toPromise) { const annotations = await pageAnnotationsTask.toPromise(); return annotations || []; } return []; } catch (error) { console.error(`Error getting annotations for page ${pageIndex}:`, error); return []; } }, moveAnnotation: (pageIndex: number, annotationId: string, newRect: AnnotationRect) => { if (!annotationApi) return; // v2.7.0: move signature stamp to newRect without regenerating the AP stream, // preserving the original appearance (image data stays intact). (annotationApi as any).moveAnnotation?.(pageIndex, annotationId, newRect); }, }), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]); useEffect(() => { if (!annotationApi?.onAnnotationEvent || !documentReady) { 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, documentReady]); useEffect(() => { if (!isPlacementMode || !documentReady) { return; } let cancelled = false; configureStampDefaults().catch((error) => { if (!cancelled) { console.error('Error updating signature defaults:', error); } }); return () => { cancelled = true; }; }, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig, documentReady]); return null; // This is a bridge component with no UI }); SignatureAPIBridge.displayName = 'SignatureAPIBridge';