diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f73143e83..b0e6fdae3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -54,6 +54,7 @@ "react-dom": "^19.1.1", "react-i18next": "^15.7.3", "react-router-dom": "^7.9.1", + "signature_pad": "^5.0.4", "tailwindcss": "^4.1.13", "web-vitals": "^5.1.0" }, @@ -9918,6 +9919,12 @@ "dev": true, "license": "ISC" }, + "node_modules/signature_pad": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.1.1.tgz", + "integrity": "sha512-BT5JJygS5BS0oV+tffPRorIud6q17bM7v/1LdQwd0o6mTqGoI25yY1NjSL99OqkekWltS4uon6p52Y8j1Zqu7g==", + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 319653af1..77ce581b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,6 +50,7 @@ "react-dom": "^19.1.1", "react-i18next": "^15.7.3", "react-router-dom": "^7.9.1", + "signature_pad": "^5.0.4", "tailwindcss": "^4.1.13", "web-vitals": "^5.1.0" }, diff --git a/frontend/src/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/components/annotation/shared/DrawingCanvas.tsx index 5cb6bc45a..87362f74d 100644 --- a/frontend/src/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/components/annotation/shared/DrawingCanvas.tsx @@ -1,7 +1,8 @@ -import React, { useRef, useState, useCallback } from 'react'; -import { Paper, Group, Button, Modal, Stack, Text } from '@mantine/core'; +import React, { useRef, useState } from 'react'; +import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core'; import { ColorSwatchButton } from './ColorPicker'; import PenSizeSelector from '../../tools/sign/PenSizeSelector'; +import SignaturePad from 'signature_pad'; interface DrawingCanvasProps { selectedColor: string; @@ -11,7 +12,7 @@ interface DrawingCanvasProps { onPenSizeChange: (size: number) => void; onPenSizeInputChange: (input: string) => void; onSignatureDataChange: (data: string | null) => void; - onDrawingComplete?: () => void; // Called when user finishes drawing + onDrawingComplete?: () => void; disabled?: boolean; width?: number; height?: number; @@ -32,146 +33,144 @@ export const DrawingCanvas: React.FC = ({ disabled = false, width = 400, height = 150, - modalWidth = 800, - modalHeight = 400, - additionalButtons }) => { - const canvasRef = useRef(null); - const hiddenCanvasRef = useRef(null); // Hidden canvas that persists - const visibleModalCanvasRef = useRef(null); // Visible canvas in modal + const previewCanvasRef = useRef(null); + const modalCanvasRef = useRef(null); + const padRef = useRef(null); + const [modalOpen, setModalOpen] = useState(false); + const [colorPickerOpen, setColorPickerOpen] = useState(false); - const [isModalDrawing, setIsModalDrawing] = useState(false); - const [isModalOpen, setIsModalOpen] = useState(false); + const initPad = (canvas: HTMLCanvasElement) => { + if (!padRef.current) { + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; - // Modal canvas drawing functions - draw to BOTH canvases - const startModalDrawing = useCallback((e: React.MouseEvent) => { - if (!visibleModalCanvasRef.current || !hiddenCanvasRef.current) return; - - setIsModalDrawing(true); - const rect = visibleModalCanvasRef.current.getBoundingClientRect(); - const scaleX = visibleModalCanvasRef.current.width / rect.width; - const scaleY = visibleModalCanvasRef.current.height / rect.height; - const x = (e.clientX - rect.left) * scaleX; - const y = (e.clientY - rect.top) * scaleY; - - // Draw on both canvases - const visibleCtx = visibleModalCanvasRef.current.getContext('2d'); - const hiddenCtx = hiddenCanvasRef.current.getContext('2d'); - - [visibleCtx, hiddenCtx].forEach(ctx => { - if (ctx) { - ctx.strokeStyle = selectedColor; - ctx.lineWidth = penSize; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.beginPath(); - ctx.moveTo(x, y); - } - }); - }, [selectedColor, penSize]); - - const drawModal = useCallback((e: React.MouseEvent) => { - if (!isModalDrawing || !visibleModalCanvasRef.current || !hiddenCanvasRef.current) return; - - const rect = visibleModalCanvasRef.current.getBoundingClientRect(); - const scaleX = visibleModalCanvasRef.current.width / rect.width; - const scaleY = visibleModalCanvasRef.current.height / rect.height; - const x = (e.clientX - rect.left) * scaleX; - const y = (e.clientY - rect.top) * scaleY; - - // Draw on both canvases - const visibleCtx = visibleModalCanvasRef.current.getContext('2d'); - const hiddenCtx = hiddenCanvasRef.current.getContext('2d'); - - [visibleCtx, hiddenCtx].forEach(ctx => { - if (ctx) { - ctx.lineTo(x, y); - ctx.stroke(); - } - }); - }, [isModalDrawing]); - - const stopModalDrawing = useCallback(() => { - if (!isModalDrawing) return; - setIsModalDrawing(false); - }, [isModalDrawing]); - - // Clear canvas function - const clearModalCanvas = useCallback(() => { - // Clear hidden canvas - if (hiddenCanvasRef.current) { - const ctx = hiddenCanvasRef.current.getContext('2d'); - if (ctx) { - ctx.clearRect(0, 0, hiddenCanvasRef.current.width, hiddenCanvasRef.current.height); - } + padRef.current = new SignaturePad(canvas, { + penColor: selectedColor, + minWidth: penSize * 0.5, + maxWidth: penSize * 2.5, + throttle: 10, + minDistance: 5, + velocityFilterWeight: 0.7, + }); } + }; - // Clear visible modal canvas - if (visibleModalCanvasRef.current) { - const ctx = visibleModalCanvasRef.current.getContext('2d'); - if (ctx) { - ctx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height); - } + const openModal = () => { + // Clear pad ref so it reinitializes + if (padRef.current) { + padRef.current.off(); + padRef.current = null; } + setModalOpen(true); + }; - // Clear small preview canvas - if (canvasRef.current) { - const ctx = canvasRef.current.getContext('2d'); - if (ctx) { - ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); - } - } + const trimCanvas = (canvas: HTMLCanvasElement): string => { + const ctx = canvas.getContext('2d'); + if (!ctx) return canvas.toDataURL('image/png'); - onSignatureDataChange(null); - }, [onSignatureDataChange]); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const pixels = imageData.data; - const closeModalAndSave = useCallback(() => { - if (!hiddenCanvasRef.current) { - setIsModalOpen(false); - return; - } + let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0; - // Get data from the hidden canvas (which persists) - const dataURL = hiddenCanvasRef.current.toDataURL('image/png'); - - // Update signature data immediately - onSignatureDataChange(dataURL); - - // Copy to small canvas for display - if (canvasRef.current) { - const ctx = canvasRef.current.getContext('2d'); - if (ctx) { - const img = new Image(); - img.onload = () => { - ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height); - ctx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height); - }; - img.src = dataURL; - } - } - - // Close modal (hidden canvas stays mounted) - setIsModalOpen(false); - - // Trigger drawing complete callback to activate placement mode - if (onDrawingComplete) { - onDrawingComplete(); - } - }, [onSignatureDataChange, onDrawingComplete]); - - const openModal = useCallback(() => { - setIsModalOpen(true); - // Copy hidden canvas content to visible modal canvas after modal opens - setTimeout(() => { - if (hiddenCanvasRef.current && visibleModalCanvasRef.current) { - const visibleCtx = visibleModalCanvasRef.current.getContext('2d'); - if (visibleCtx) { - visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height); - visibleCtx.drawImage(hiddenCanvasRef.current, 0, 0); + // Find bounds of non-transparent pixels + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < canvas.width; x++) { + const alpha = pixels[(y * canvas.width + x) * 4 + 3]; + if (alpha > 0) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; } } - }, 50); - }, []); + } + + const trimWidth = maxX - minX + 1; + const trimHeight = maxY - minY + 1; + + // Create trimmed canvas + const trimmedCanvas = document.createElement('canvas'); + trimmedCanvas.width = trimWidth; + trimmedCanvas.height = trimHeight; + const trimmedCtx = trimmedCanvas.getContext('2d'); + if (trimmedCtx) { + trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight); + } + + return trimmedCanvas.toDataURL('image/png'); + }; + + const closeModal = () => { + if (padRef.current && !padRef.current.isEmpty()) { + const canvas = modalCanvasRef.current; + if (canvas) { + const trimmedPng = trimCanvas(canvas); + onSignatureDataChange(trimmedPng); + + // Update preview canvas with proper aspect ratio + const img = new Image(); + img.onload = () => { + if (previewCanvasRef.current) { + const ctx = previewCanvasRef.current.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height); + + // Calculate scaling to fit within preview canvas while maintaining aspect ratio + const scale = Math.min( + previewCanvasRef.current.width / img.width, + previewCanvasRef.current.height / img.height + ); + const scaledWidth = img.width * scale; + const scaledHeight = img.height * scale; + const x = (previewCanvasRef.current.width - scaledWidth) / 2; + const y = (previewCanvasRef.current.height - scaledHeight) / 2; + + ctx.drawImage(img, x, y, scaledWidth, scaledHeight); + } + } + }; + img.src = trimmedPng; + + if (onDrawingComplete) { + onDrawingComplete(); + } + } + } + if (padRef.current) { + padRef.current.off(); + padRef.current = null; + } + setModalOpen(false); + }; + + const clear = () => { + if (padRef.current) { + padRef.current.clear(); + } + if (previewCanvasRef.current) { + const ctx = previewCanvasRef.current.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height); + } + } + onSignatureDataChange(null); + }; + + const updatePenColor = (color: string) => { + if (padRef.current) { + padRef.current.penColor = color; + } + }; + + const updatePenSize = (size: number) => { + if (padRef.current) { + padRef.current.minWidth = size * 0.8; + padRef.current.maxWidth = size * 1.2; + } + }; return ( <> @@ -179,13 +178,13 @@ export const DrawingCanvas: React.FC = ({ Draw your signature = ({ - {/* Hidden canvas that persists - always mounted */} - - - {/* Modal for drawing signature */} - + - {/* Color and Pen Size picker */} - +
Color - + + +
+ setColorPickerOpen(!colorPickerOpen)} + /> +
+
+ + { + onColorSwatchClick(); + updatePenColor(color); + }} + swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']} + /> + +
Pen Size { + onPenSizeChange(size); + updatePenSize(size); + }} onInputChange={onPenSizeInputChange} placeholder="Size" size="compact-sm" style={{ width: '60px' }} />
- +
{ + modalCanvasRef.current = el; + if (el) initPad(el); + }} style={{ border: '1px solid #ccc', borderRadius: '4px', - cursor: 'crosshair', - backgroundColor: '#ffffff', + display: 'block', + touchAction: 'none', + backgroundColor: 'white', width: '100%', - maxWidth: `${modalWidth}px`, - height: 'auto', + maxWidth: '800px', + height: '400px', + cursor: 'crosshair', }} - onMouseDown={startModalDrawing} - onMouseMove={drawModal} - onMouseUp={stopModalDrawing} - onMouseLeave={stopModalDrawing} /> - - - - +
); }; -export default DrawingCanvas; \ No newline at end of file +export default DrawingCanvas; diff --git a/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx index 92b778e44..42dbbb5a8 100644 --- a/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -25,7 +25,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati const viewerContext = React.useContext(ViewerContext); // Signature context for accessing drawing API - const { signatureApiRef } = useSignature(); + const { signatureApiRef, isPlacementMode } = useSignature(); // File state for save functionality const { state, selectors } = useFileState(); @@ -50,7 +50,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati onClick={() => { viewerContext?.toggleAnnotationsVisibility(); }} - disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode} + disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode} > { const { t } = useTranslation(); + const { isPlacementMode } = useSignature(); // State for drawing const [selectedColor, setSelectedColor] = useState('#000000'); const [penSize, setPenSize] = useState(2); const [penSizeInput, setPenSizeInput] = useState('2'); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + const [interactionMode, setInteractionMode] = useState<'move' | 'place'>('move'); // State for different signature types const [canvasSignatureData, setCanvasSignatureData] = useState(null); @@ -100,12 +103,14 @@ const SignSettings = ({ useEffect(() => { if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') { if (onActivateSignaturePlacement) { + setInteractionMode('place'); setTimeout(() => { onActivateSignaturePlacement(); }, 100); } } else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) { if (onDeactivateSignature) { + setInteractionMode('move'); onDeactivateSignature(); } } @@ -130,6 +135,7 @@ const SignSettings = ({ // Handle image signature activation - activate when image data syncs with parameters useEffect(() => { if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) { + setInteractionMode('place'); setTimeout(() => { onActivateSignaturePlacement(); }, 100); @@ -139,6 +145,7 @@ const SignSettings = ({ // Handle canvas signature activation - activate when canvas data syncs with parameters useEffect(() => { if (parameters.signatureType === 'canvas' && canvasSignatureData && parameters.signatureData === canvasSignatureData && onActivateSignaturePlacement) { + setInteractionMode('place'); setTimeout(() => { onActivateSignaturePlacement(); }, 100); @@ -235,10 +242,34 @@ const SignSettings = ({ )} + {/* Interaction Mode Toggle */} + {(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== '')) && ( + { + setInteractionMode(value as 'move' | 'place'); + if (value === 'place') { + if (onActivateSignaturePlacement) { + onActivateSignaturePlacement(); + } + } else { + if (onDeactivateSignature) { + onDeactivateSignature(); + } + } + }} + data={[ + { label: 'Move Signature', value: 'move' }, + { label: 'Place Signature', value: 'place' } + ]} + fullWidth + /> + )} + {/* Instructions for placing signature */} - {parameters.signatureType === 'canvas' && 'After drawing your signature in the canvas above, click "Update and Place" then click anywhere on the PDF to place it.'} + {parameters.signatureType === 'canvas' && 'After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.'} {parameters.signatureType === 'image' && 'After uploading your signature image above, click anywhere on the PDF to place it.'} {parameters.signatureType === 'text' && 'After entering your name above, click anywhere on the PDF to place your signature.'} diff --git a/frontend/src/tools/Sign.tsx b/frontend/src/tools/Sign.tsx index 807dd732d..87f784d6f 100644 --- a/frontend/src/tools/Sign.tsx +++ b/frontend/src/tools/Sign.tsx @@ -45,14 +45,18 @@ const Sign = (props: BaseToolProps) => { props ); - // Open viewer when files are selected + const hasOpenedViewer = useRef(false); + + // Open viewer when files are selected (only once) useEffect(() => { - if (base.selectedFiles.length > 0) { + if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) { setWorkbench('viewer'); + hasOpenedViewer.current = true; } }, [base.selectedFiles.length, setWorkbench]); + // Sync signature configuration with context useEffect(() => { setSignatureConfig(base.params.parameters); @@ -123,26 +127,28 @@ const Sign = (props: BaseToolProps) => { const getSteps = () => { const steps = []; - // Step 1: Signature Configuration - Always visible - steps.push({ - title: t('sign.steps.configure', 'Configure Signature'), - isCollapsed: false, - onCollapsedClick: undefined, - content: ( - - ), - }); + // Step 1: Signature Configuration - Only visible when file is loaded + if (base.selectedFiles.length > 0) { + steps.push({ + title: t('sign.steps.configure', 'Configure Signature'), + isCollapsed: false, + onCollapsedClick: undefined, + content: ( + + ), + }); + } return steps; }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..9ae7c67c8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Stirling-PDF", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}