mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 02:54:06 +02:00
please merge #5919, alternatively, just push this and delete that PR because this is a continuation of that. This PR bumps the embed PDF version to 2.8.0 and also adds comments functionaliy --------- Co-authored-by: ConnorYoh <[email protected]> Co-authored-by: Anthony Stirling <[email protected]>
487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
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<unknown> = 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<string, unknown>)) {
|
|
const result = extractDataUrl((value as Record<string, unknown>)[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<SignatureAPI, SignatureAPIBridgeProps>(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<string | null>(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<any[]> => {
|
|
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';
|