mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Feature/sign placement UI (#4891)
Show signature preview on hover Place signature smaller (mkatch preview size) Retain signature in modal on close/open Clean up ui generally Small bug fixes
This commit is contained in:
@@ -14,6 +14,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay';
|
||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
@@ -34,6 +35,7 @@ const EmbedPdfViewerContent = ({
|
||||
setActiveFileIndex: externalSetActiveFileIndex,
|
||||
}: EmbedPdfViewerProps) => {
|
||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
||||
@@ -53,7 +55,7 @@ const EmbedPdfViewerContent = ({
|
||||
}, [rotationState.rotation]);
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors, state } = useFileState();
|
||||
@@ -71,6 +73,9 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
const isPlacementOverlayActive = Boolean(
|
||||
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
|
||||
);
|
||||
|
||||
// Track which file tab is active
|
||||
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
||||
@@ -247,15 +252,17 @@ const EmbedPdfViewerContent = ({
|
||||
) : (
|
||||
<>
|
||||
{/* EmbedPDF Viewer */}
|
||||
<Box style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<Box
|
||||
ref={pdfContainerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<LocalEmbedPDF
|
||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
||||
file={effectiveFile.file}
|
||||
@@ -268,6 +275,11 @@ const EmbedPdfViewerContent = ({
|
||||
// Future: Handle signature completion
|
||||
}}
|
||||
/>
|
||||
<SignaturePlacementOverlay
|
||||
containerRef={pdfContainerRef}
|
||||
isActive={isPlacementOverlayActive}
|
||||
signatureConfig={signatureConfig}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
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';
|
||||
@@ -9,6 +9,7 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
const { provides: historyApi } = useHistoryCapability();
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { getImageData, storeImageData } = useSignature();
|
||||
const restoringIds = useRef<Set<string>>(new Set());
|
||||
|
||||
// Monitor annotation events to detect when annotations are restored
|
||||
useEffect(() => {
|
||||
@@ -20,11 +21,52 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
// Store image data for all STAMP annotations immediately when created or modified
|
||||
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
if (!storedImageData || storedImageData !== annotation.imageSrc) {
|
||||
if (!storedImageData) {
|
||||
storeImageData(annotation.id, annotation.imageSrc);
|
||||
}
|
||||
}
|
||||
|
||||
if (annotation && annotation.type === 13 && annotation.id) {
|
||||
// Prevent infinite loops when we recreate annotations
|
||||
if (restoringIds.current.has(annotation.id)) {
|
||||
restoringIds.current.delete(annotation.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
// If EmbedPDF cropped the image (imageSrc changed), recreate annotation using stored data
|
||||
if (storedImageData && annotation.imageSrc && annotation.imageSrc !== storedImageData) {
|
||||
const newId = uuidV4();
|
||||
restoringIds.current.add(newId);
|
||||
storeImageData(newId, storedImageData);
|
||||
|
||||
const pageIndex = event.pageIndex ?? annotation.pageIndex ?? annotation.object?.pageIndex ?? 0;
|
||||
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
|
||||
|
||||
try {
|
||||
annotationApi.deleteAnnotation(pageIndex, annotation.id);
|
||||
setTimeout(() => {
|
||||
annotationApi.createAnnotation(pageIndex, {
|
||||
type: annotation.type,
|
||||
rect,
|
||||
author: annotation.author || 'Digital Signature',
|
||||
subject: annotation.subject || 'Digital Signature',
|
||||
pageIndex,
|
||||
id: newId,
|
||||
created: annotation.created || new Date(),
|
||||
imageSrc: storedImageData,
|
||||
contents: storedImageData,
|
||||
data: storedImageData,
|
||||
appearance: storedImageData,
|
||||
});
|
||||
}, 50);
|
||||
} catch (restoreError) {
|
||||
console.error('HistoryAPI: Failed to restore cropped signature:', restoreError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle annotation restoration after undo operations
|
||||
if (event.type === 'create' && event.committed) {
|
||||
// Check if this is a STAMP annotation (signature) that might need image data restoration
|
||||
@@ -102,6 +144,21 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
canRedo: () => {
|
||||
return historyApi ? historyApi.canRedo() : false;
|
||||
},
|
||||
|
||||
subscribe: (listener: () => void) => {
|
||||
if (!historyApi?.onHistoryChange) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const wrapped = () => listener();
|
||||
const unsubscribe = historyApi.onHistoryChange(wrapped);
|
||||
listener();
|
||||
|
||||
if (typeof unsubscribe === 'function') {
|
||||
return unsubscribe;
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
}), [historyApi]);
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
|
||||
@@ -1,12 +1,193 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
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';
|
||||
|
||||
// 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 = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const horizontalPadding = paddingX;
|
||||
const verticalCenter = naturalHeight / 2;
|
||||
ctx.fillText(text, horizontalPadding, verticalCenter);
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
pixelWidth: canvasWidth,
|
||||
pixelHeight: canvasHeight,
|
||||
displayWidth,
|
||||
displayHeight,
|
||||
};
|
||||
};
|
||||
|
||||
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
|
||||
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
|
||||
const { getZoomState } = useViewer();
|
||||
const currentZoom = getZoomState()?.currentZoom ?? 1;
|
||||
const lastStampImageRef = useRef<string | null>(null);
|
||||
|
||||
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
|
||||
@@ -108,58 +289,9 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
activateSignaturePlacementMode: () => {
|
||||
if (!annotationApi || !signatureConfig) return;
|
||||
|
||||
try {
|
||||
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||
// Skip native text tools - always use stamp for consistent sizing
|
||||
const activatedTool = null;
|
||||
|
||||
if (!activatedTool) {
|
||||
// Create text image as stamp with actual pixel size matching desired display size
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const baseFontSize = signatureConfig.fontSize || 16;
|
||||
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
|
||||
const textColor = signatureConfig.textColor || '#000000';
|
||||
|
||||
// Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly)
|
||||
canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6);
|
||||
canvas.height = baseFontSize + 20;
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${baseFontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
|
||||
const dataURL = canvas.toDataURL();
|
||||
|
||||
// Deactivate and reactivate to force refresh
|
||||
annotationApi.setActiveTool(null);
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const stampTool = annotationApi.getActiveTool();
|
||||
if (stampTool && stampTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: dataURL,
|
||||
subject: `Text Signature - ${signatureConfig.signerName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (signatureConfig.signatureData) {
|
||||
// Use stamp tool for image/canvas signatures
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const activeTool = annotationApi.getActiveTool();
|
||||
|
||||
if (activeTool && activeTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: signatureConfig.signatureData,
|
||||
subject: `Digital Signature - ${signatureConfig.reason || 'Document signing'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
configureStampDefaults().catch((error) => {
|
||||
console.error('Error activating signature tool:', error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateDrawSettings: (color: string, size: number) => {
|
||||
@@ -230,7 +362,61 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}), [annotationApi, signatureConfig]);
|
||||
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
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]);
|
||||
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePreview';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import {
|
||||
MAX_PREVIEW_WIDTH_RATIO,
|
||||
MAX_PREVIEW_HEIGHT_RATIO,
|
||||
MAX_PREVIEW_WIDTH_REM,
|
||||
MAX_PREVIEW_HEIGHT_REM,
|
||||
MIN_SIGNATURE_DIMENSION_REM,
|
||||
OVERLAY_EDGE_PADDING_REM,
|
||||
} from '@app/constants/signConstants';
|
||||
|
||||
// Convert rem to pixels using browser's base font size (typically 16px)
|
||||
const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
|
||||
interface SignaturePlacementOverlayProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
isActive: boolean;
|
||||
signatureConfig: SignParameters | null;
|
||||
}
|
||||
|
||||
export const SignaturePlacementOverlay: React.FC<SignaturePlacementOverlayProps> = ({
|
||||
containerRef,
|
||||
isActive,
|
||||
signatureConfig,
|
||||
}) => {
|
||||
const [preview, setPreview] = useState<SignaturePreview | null>(null);
|
||||
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
|
||||
const { setPlacementPreviewSize } = useSignature();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const buildPreview = async () => {
|
||||
try {
|
||||
const value = await buildSignaturePreview(signatureConfig ?? null);
|
||||
if (!cancelled) {
|
||||
setPreview(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to build signature preview:', error);
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
buildPreview();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [signatureConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!isActive || !element) {
|
||||
setCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
setCursor({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLeave = () => setCursor(null);
|
||||
|
||||
element.addEventListener('mousemove', handleMove);
|
||||
element.addEventListener('mouseleave', handleLeave);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('mousemove', handleMove);
|
||||
element.removeEventListener('mouseleave', handleLeave);
|
||||
};
|
||||
}, [containerRef, isActive]);
|
||||
|
||||
const scaledSize = useMemo(() => {
|
||||
if (!preview || !containerRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerWidth = container.clientWidth || 1;
|
||||
const containerHeight = container.clientHeight || 1;
|
||||
|
||||
const maxWidth = Math.min(containerWidth * MAX_PREVIEW_WIDTH_RATIO, remToPx(MAX_PREVIEW_WIDTH_REM));
|
||||
const maxHeight = Math.min(containerHeight * MAX_PREVIEW_HEIGHT_RATIO, remToPx(MAX_PREVIEW_HEIGHT_REM));
|
||||
|
||||
const scale = Math.min(
|
||||
1,
|
||||
maxWidth / Math.max(preview.width, 1),
|
||||
maxHeight / Math.max(preview.height, 1)
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.width * scale),
|
||||
height: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.height * scale),
|
||||
};
|
||||
}, [preview, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !scaledSize) {
|
||||
setPlacementPreviewSize(null);
|
||||
} else {
|
||||
setPlacementPreviewSize(scaledSize);
|
||||
}
|
||||
}, [isActive, scaledSize, setPlacementPreviewSize]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setPlacementPreviewSize(null);
|
||||
};
|
||||
}, [setPlacementPreviewSize]);
|
||||
|
||||
const display = useMemo(() => {
|
||||
if (!preview || !scaledSize || !cursor || !containerRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerWidth = container.clientWidth || 1;
|
||||
const containerHeight = container.clientHeight || 1;
|
||||
|
||||
const width = scaledSize.width;
|
||||
const height = scaledSize.height;
|
||||
const edgePadding = remToPx(OVERLAY_EDGE_PADDING_REM);
|
||||
|
||||
const clampedLeft = Math.max(edgePadding, Math.min(cursor.x - width / 2, containerWidth - width - edgePadding));
|
||||
const clampedTop = Math.max(edgePadding, Math.min(cursor.y - height / 2, containerHeight - height - edgePadding));
|
||||
|
||||
return {
|
||||
left: clampedLeft,
|
||||
top: clampedTop,
|
||||
width,
|
||||
height,
|
||||
dataUrl: preview.dataUrl,
|
||||
};
|
||||
}, [preview, scaledSize, cursor, containerRef]);
|
||||
|
||||
if (!isActive || !display || !preview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
left: `${display.left}px`,
|
||||
top: `${display.top}px`,
|
||||
width: `${display.width}px`,
|
||||
height: `${display.height}px`,
|
||||
backgroundImage: `url(${display.dataUrl})`,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
boxShadow: '0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)',
|
||||
borderRadius: '4px',
|
||||
transition: 'transform 70ms ease-out',
|
||||
transform: 'translateZ(0)',
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -214,4 +214,4 @@ export function ZoomAPIBridge() {
|
||||
}, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,5 @@ export interface HistoryAPI {
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
subscribe?: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user