mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Feature/v2/add text (#4951)
Refactor sign to separate out add text and add image functions. Implement add text as standalone tool
This commit is contained in:
@@ -15,7 +15,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 { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
@@ -82,7 +82,8 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
// Check if we're in signature mode OR viewer annotation mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignatureMode = selectedTool === 'sign';
|
||||
// Tools that use the stamp/signature placement system with hover preview
|
||||
const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText';
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
@@ -328,7 +329,7 @@ const EmbedPdfViewerContent = ({
|
||||
// Future: Handle signature completion
|
||||
}}
|
||||
/>
|
||||
<SignaturePlacementOverlay
|
||||
<StampPlacementOverlay
|
||||
containerRef={pdfContainerRef}
|
||||
isActive={isPlacementOverlayActive}
|
||||
signatureConfig={signatureConfig}
|
||||
|
||||
@@ -14,14 +14,6 @@ const MIN_SIGNATURE_DIMENSION = 12;
|
||||
// This provides a good balance between visual fidelity and performance/memory usage.
|
||||
const TEXT_OVERSAMPLE_FACTOR = 2;
|
||||
|
||||
type TextStampImageResult = {
|
||||
dataUrl: string;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
};
|
||||
|
||||
const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new Set()): string | undefined => {
|
||||
if (!value || depth > 6) return undefined;
|
||||
|
||||
@@ -56,7 +48,7 @@ const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new S
|
||||
const createTextStampImage = (
|
||||
config: SignParameters,
|
||||
displaySize?: { width: number; height: number } | null
|
||||
): TextStampImageResult | null => {
|
||||
): { dataUrl: string; pixelWidth: number; pixelHeight: number; displayWidth: number; displayHeight: number } | null => {
|
||||
const text = (config.signerName ?? '').trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
@@ -207,7 +199,6 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
}
|
||||
}, [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
|
||||
@@ -436,6 +427,60 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
};
|
||||
}, [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;
|
||||
}
|
||||
|
||||
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 StampPlacementOverlayProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
isActive: boolean;
|
||||
signatureConfig: SignParameters | null;
|
||||
}
|
||||
|
||||
export const StampPlacementOverlay: React.FC<StampPlacementOverlayProps> = ({
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user