mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Merge 71dcbf9c12 into 5c9e590856
This commit is contained in:
commit
5b4faceaf1
@ -1978,9 +1978,23 @@
|
|||||||
"title": "Draw your signature",
|
"title": "Draw your signature",
|
||||||
"clear": "Clear"
|
"clear": "Clear"
|
||||||
},
|
},
|
||||||
|
"canvas": {
|
||||||
|
"heading": "Draw your signature",
|
||||||
|
"clickToOpen": "Click to open the drawing canvas",
|
||||||
|
"modalTitle": "Draw your signature",
|
||||||
|
"colorLabel": "Colour",
|
||||||
|
"penSizeLabel": "Pen size",
|
||||||
|
"penSizePlaceholder": "Size",
|
||||||
|
"clear": "Clear canvas",
|
||||||
|
"colorPickerTitle": "Choose stroke colour"
|
||||||
|
},
|
||||||
"text": {
|
"text": {
|
||||||
"name": "Signer Name",
|
"name": "Signer Name",
|
||||||
"placeholder": "Enter your full name"
|
"placeholder": "Enter your full name",
|
||||||
|
"fontLabel": "Font",
|
||||||
|
"fontSizeLabel": "Font size",
|
||||||
|
"fontSizePlaceholder": "Type or select font size (8-200)",
|
||||||
|
"colorLabel": "Text colour"
|
||||||
},
|
},
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
@ -2003,6 +2017,11 @@
|
|||||||
"steps": {
|
"steps": {
|
||||||
"configure": "Configure Signature"
|
"configure": "Configure Signature"
|
||||||
},
|
},
|
||||||
|
"step": {
|
||||||
|
"createDesc": "Choose how you want to create the signature",
|
||||||
|
"place": "Place & save",
|
||||||
|
"placeDesc": "Position the signature on your PDF"
|
||||||
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"title": "Signature Type",
|
"title": "Signature Type",
|
||||||
"draw": "Draw",
|
"draw": "Draw",
|
||||||
@ -2019,11 +2038,16 @@
|
|||||||
"title": "How to add signature",
|
"title": "How to add signature",
|
||||||
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
|
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
|
||||||
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
|
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
|
||||||
"text": "After entering your name above, click anywhere on the PDF to place your signature."
|
"text": "After entering your name above, click anywhere on the PDF to place your signature.",
|
||||||
|
"paused": "Placement paused",
|
||||||
|
"resumeHint": "Resume placement to click and add your signature.",
|
||||||
|
"noSignature": "Create a signature above to enable placement tools."
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"move": "Move Signature",
|
"move": "Move Signature",
|
||||||
"place": "Place Signature"
|
"place": "Place Signature",
|
||||||
|
"pause": "Pause placement",
|
||||||
|
"resume": "Resume placement"
|
||||||
},
|
},
|
||||||
"updateAndPlace": "Update and Place",
|
"updateAndPlace": "Update and Place",
|
||||||
"activate": "Activate Signature Placement",
|
"activate": "Activate Signature Placement",
|
||||||
@ -4622,6 +4646,9 @@
|
|||||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"colorPicker": {
|
||||||
|
"title": "Choose colour"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
@ -4637,7 +4664,8 @@
|
|||||||
"used": "used",
|
"used": "used",
|
||||||
"available": "available",
|
"available": "available",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"preview": "Preview"
|
"preview": "Preview",
|
||||||
|
"done": "Done"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"overview": {
|
"overview": {
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Stack, Alert, Text } from '@mantine/core';
|
import { Stack, Alert, Text } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
|
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
|
||||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||||
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
|
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
|
||||||
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
|
|
||||||
export interface AnnotationToolConfig {
|
export interface AnnotationToolConfig {
|
||||||
enableDrawing?: boolean;
|
enableDrawing?: boolean;
|
||||||
@ -32,10 +33,34 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
|||||||
undo,
|
undo,
|
||||||
redo
|
redo
|
||||||
} = usePDFAnnotation();
|
} = usePDFAnnotation();
|
||||||
|
const { historyApiRef } = useSignature();
|
||||||
|
|
||||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||||
|
const historyApiInstance = historyApiRef.current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!historyApiInstance) {
|
||||||
|
setHistoryAvailability({ canUndo: false, canRedo: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAvailability = () => {
|
||||||
|
setHistoryAvailability({
|
||||||
|
canUndo: historyApiInstance.canUndo?.() ?? false,
|
||||||
|
canRedo: historyApiInstance.canRedo?.() ?? false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = historyApiInstance.subscribe?.(updateAvailability);
|
||||||
|
updateAvailability();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [historyApiInstance]);
|
||||||
|
|
||||||
const handleSignatureDataChange = (data: string | null) => {
|
const handleSignatureDataChange = (data: string | null) => {
|
||||||
setSignatureData(data);
|
setSignatureData(data);
|
||||||
@ -54,6 +79,8 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
|||||||
<DrawingControls
|
<DrawingControls
|
||||||
onUndo={undo}
|
onUndo={undo}
|
||||||
onRedo={redo}
|
onRedo={redo}
|
||||||
|
canUndo={historyAvailability.canUndo}
|
||||||
|
canRedo={historyAvailability.canRedo}
|
||||||
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
||||||
hasSignatureData={!!signatureData}
|
hasSignatureData={!!signatureData}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -14,13 +15,16 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
selectedColor,
|
selectedColor,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
title = "Choose Color"
|
title
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={isOpen}
|
opened={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={title}
|
title={resolvedTitle}
|
||||||
size="sm"
|
size="sm"
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
@ -36,7 +40,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
Done
|
{t('common.done', 'Done')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
|
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
||||||
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
|
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
|
||||||
import SignaturePad from 'signature_pad';
|
import SignaturePad from 'signature_pad';
|
||||||
@ -20,6 +21,7 @@ interface DrawingCanvasProps {
|
|||||||
modalWidth?: number;
|
modalWidth?: number;
|
||||||
modalHeight?: number;
|
modalHeight?: number;
|
||||||
additionalButtons?: React.ReactNode;
|
additionalButtons?: React.ReactNode;
|
||||||
|
initialSignatureData?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||||
@ -34,12 +36,14 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
width = 400,
|
width = 400,
|
||||||
height = 150,
|
height = 150,
|
||||||
|
initialSignatureData,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const padRef = useRef<SignaturePad | null>(null);
|
const padRef = useRef<SignaturePad | null>(null);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(null);
|
||||||
|
|
||||||
const initPad = (canvas: HTMLCanvasElement) => {
|
const initPad = (canvas: HTMLCanvasElement) => {
|
||||||
if (!padRef.current) {
|
if (!padRef.current) {
|
||||||
@ -55,6 +59,18 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
minDistance: 5,
|
minDistance: 5,
|
||||||
velocityFilterWeight: 0.7,
|
velocityFilterWeight: 0.7,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restore saved signature data if it exists
|
||||||
|
if (savedSignatureData) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = savedSignatureData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -104,36 +120,35 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
return trimmedCanvas.toDataURL('image/png');
|
return trimmedCanvas.toDataURL('image/png');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderPreview = (dataUrl: string) => {
|
||||||
|
const canvas = previewCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
|
||||||
|
const scaledWidth = img.width * scale;
|
||||||
|
const scaledHeight = img.height * scale;
|
||||||
|
const x = (canvas.width - scaledWidth) / 2;
|
||||||
|
const y = (canvas.height - scaledHeight) / 2;
|
||||||
|
|
||||||
|
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||||
|
};
|
||||||
|
img.src = dataUrl;
|
||||||
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
if (padRef.current && !padRef.current.isEmpty()) {
|
if (padRef.current && !padRef.current.isEmpty()) {
|
||||||
const canvas = modalCanvasRef.current;
|
const canvas = modalCanvasRef.current;
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const trimmedPng = trimCanvas(canvas);
|
const trimmedPng = trimCanvas(canvas);
|
||||||
|
const untrimmedPng = canvas.toDataURL('image/png');
|
||||||
|
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
|
||||||
onSignatureDataChange(trimmedPng);
|
onSignatureDataChange(trimmedPng);
|
||||||
|
renderPreview(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) {
|
if (onDrawingComplete) {
|
||||||
onDrawingComplete();
|
onDrawingComplete();
|
||||||
@ -157,6 +172,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setSavedSignatureData(null); // Clear saved signature
|
||||||
onSignatureDataChange(null);
|
onSignatureDataChange(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -173,12 +189,34 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updatePenColor(selectedColor);
|
||||||
|
}, [selectedColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updatePenSize(penSize);
|
||||||
|
}, [penSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = previewCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
if (!initialSignatureData) {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPreview(initialSignatureData);
|
||||||
|
}, [initialSignatureData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Paper withBorder p="md">
|
<Paper withBorder p="md">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={500}>Draw your signature</Text>
|
|
||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
|
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
|
||||||
<canvas
|
<canvas
|
||||||
ref={previewCanvasRef}
|
ref={previewCanvasRef}
|
||||||
width={width}
|
width={width}
|
||||||
@ -194,46 +232,27 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
/>
|
/>
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
Click to open drawing canvas
|
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
|
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" centered>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
|
<Group gap="lg" align="flex-end" wrap="wrap">
|
||||||
<div>
|
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||||
<Text size="sm" fw={500} mb="xs">Color</Text>
|
<Text size="sm" fw={500}>
|
||||||
<Popover
|
{t('sign.canvas.colorLabel', 'Colour')}
|
||||||
opened={colorPickerOpen}
|
</Text>
|
||||||
onChange={setColorPickerOpen}
|
|
||||||
position="bottom-start"
|
|
||||||
withArrow
|
|
||||||
withinPortal={false}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<div>
|
|
||||||
<ColorSwatchButton
|
<ColorSwatchButton
|
||||||
color={selectedColor}
|
color={selectedColor}
|
||||||
onClick={() => setColorPickerOpen(!colorPickerOpen)}
|
onClick={onColorSwatchClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Stack>
|
||||||
</Popover.Target>
|
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||||
<Popover.Dropdown>
|
<Text size="sm" fw={500}>
|
||||||
<MantineColorPicker
|
{t('sign.canvas.penSizeLabel', 'Pen size')}
|
||||||
format="hex"
|
</Text>
|
||||||
value={selectedColor}
|
|
||||||
onChange={(color) => {
|
|
||||||
onColorSwatchClick();
|
|
||||||
updatePenColor(color);
|
|
||||||
}}
|
|
||||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
|
||||||
/>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
|
||||||
<PenSizeSelector
|
<PenSizeSelector
|
||||||
value={penSize}
|
value={penSize}
|
||||||
inputValue={penSizeInput}
|
inputValue={penSizeInput}
|
||||||
@ -242,12 +261,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
updatePenSize(size);
|
updatePenSize(size);
|
||||||
}}
|
}}
|
||||||
onInputChange={onPenSizeInputChange}
|
onInputChange={onPenSizeInputChange}
|
||||||
placeholder="Size"
|
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
style={{ width: '60px' }}
|
style={{ width: '80px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</Group>
|
||||||
|
|
||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
<canvas
|
<canvas
|
||||||
@ -263,7 +282,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '800px',
|
maxWidth: '800px',
|
||||||
height: '400px',
|
height: '25rem',
|
||||||
cursor: 'crosshair',
|
cursor: 'crosshair',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -271,10 +290,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<Button variant="subtle" color="red" onClick={clear}>
|
<Button variant="subtle" color="red" onClick={clear}>
|
||||||
Clear Canvas
|
{t('sign.canvas.clear', 'Clear canvas')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={closeModal}>
|
<Button onClick={closeModal}>
|
||||||
Done
|
{t('common.done', 'Done')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Group, Button } from '@mantine/core';
|
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||||
|
|
||||||
interface DrawingControlsProps {
|
interface DrawingControlsProps {
|
||||||
onUndo?: () => void;
|
onUndo?: () => void;
|
||||||
@ -8,8 +9,11 @@ interface DrawingControlsProps {
|
|||||||
onPlaceSignature?: () => void;
|
onPlaceSignature?: () => void;
|
||||||
hasSignatureData?: boolean;
|
hasSignatureData?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
canUndo?: boolean;
|
||||||
|
canRedo?: boolean;
|
||||||
showPlaceButton?: boolean;
|
showPlaceButton?: boolean;
|
||||||
placeButtonText?: string;
|
placeButtonText?: string;
|
||||||
|
additionalControls?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||||
@ -18,30 +22,48 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
|||||||
onPlaceSignature,
|
onPlaceSignature,
|
||||||
hasSignatureData = false,
|
hasSignatureData = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
canUndo = true,
|
||||||
|
canRedo = true,
|
||||||
showPlaceButton = true,
|
showPlaceButton = true,
|
||||||
placeButtonText = "Update and Place"
|
placeButtonText = "Update and Place",
|
||||||
|
additionalControls,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const undoDisabled = disabled || !canUndo;
|
||||||
|
const redoDisabled = disabled || !canRedo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap="sm">
|
<Group gap="xs" wrap="nowrap" align="center">
|
||||||
{/* Undo/Redo Controls */}
|
{onUndo && (
|
||||||
<Button
|
<Tooltip label={t('sign.undo', 'Undo')}>
|
||||||
variant="outline"
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t('sign.undo', 'Undo')}
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={disabled}
|
disabled={undoDisabled}
|
||||||
flex={1}
|
color={undoDisabled ? 'gray' : 'blue'}
|
||||||
>
|
>
|
||||||
{t('sign.undo', 'Undo')}
|
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
<Button
|
</Tooltip>
|
||||||
variant="outline"
|
)}
|
||||||
|
{onRedo && (
|
||||||
|
<Tooltip label={t('sign.redo', 'Redo')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t('sign.redo', 'Redo')}
|
||||||
onClick={onRedo}
|
onClick={onRedo}
|
||||||
disabled={disabled}
|
disabled={redoDisabled}
|
||||||
flex={1}
|
color={redoDisabled ? 'gray' : 'blue'}
|
||||||
>
|
>
|
||||||
{t('sign.redo', 'Redo')}
|
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{additionalControls}
|
||||||
|
|
||||||
{/* Place Signature Button */}
|
{/* Place Signature Button */}
|
||||||
{showPlaceButton && onPlaceSignature && (
|
{showPlaceButton && onPlaceSignature && (
|
||||||
@ -50,7 +72,7 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
|||||||
color="blue"
|
color="blue"
|
||||||
onClick={onPlaceSignature}
|
onClick={onPlaceSignature}
|
||||||
disabled={disabled || !hasSignatureData}
|
disabled={disabled || !hasSignatureData}
|
||||||
flex={1}
|
ml="auto"
|
||||||
>
|
>
|
||||||
{placeButtonText}
|
{placeButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -34,12 +34,18 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||||
const fontSizeCombobox = useCombobox();
|
const fontSizeCombobox = useCombobox();
|
||||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
|
const [colorInput, setColorInput] = useState(textColor);
|
||||||
|
|
||||||
// Sync font size input with prop changes
|
// Sync font size input with prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFontSizeInput(fontSize.toString());
|
setFontSizeInput(fontSize.toString());
|
||||||
}, [fontSize]);
|
}, [fontSize]);
|
||||||
|
|
||||||
|
// Sync color input with prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
setColorInput(textColor);
|
||||||
|
}, [textColor]);
|
||||||
|
|
||||||
const fontOptions = [
|
const fontOptions = [
|
||||||
{ value: 'Helvetica', label: 'Helvetica' },
|
{ value: 'Helvetica', label: 'Helvetica' },
|
||||||
{ value: 'Times-Roman', label: 'Times' },
|
{ value: 'Times-Roman', label: 'Times' },
|
||||||
@ -50,10 +56,15 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
|
|
||||||
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
|
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
|
||||||
|
|
||||||
|
// Validate hex color
|
||||||
|
const isValidHexColor = (color: string): boolean => {
|
||||||
|
return /^#[0-9A-Fa-f]{6}$/.test(color);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<TextInput
|
<TextInput
|
||||||
label={label || t('sign.text.name', 'Signer Name')}
|
label={label || t('sign.text.name', 'Signer name')}
|
||||||
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => onTextChange(e.target.value)}
|
onChange={(e) => onTextChange(e.target.value)}
|
||||||
@ -63,7 +74,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
|
|
||||||
{/* Font Selection */}
|
{/* Font Selection */}
|
||||||
<Select
|
<Select
|
||||||
label="Font"
|
label={t('sign.text.fontLabel', 'Font')}
|
||||||
value={fontFamily}
|
value={fontFamily}
|
||||||
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
||||||
data={fontOptions}
|
data={fontOptions}
|
||||||
@ -88,8 +99,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
>
|
>
|
||||||
<Combobox.Target>
|
<Combobox.Target>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Font Size"
|
label={t('sign.text.fontSizeLabel', 'Font size')}
|
||||||
placeholder="Type or select font size (8-200)"
|
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
|
||||||
value={fontSizeInput}
|
value={fontSizeInput}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = event.currentTarget.value;
|
const value = event.currentTarget.value;
|
||||||
@ -135,14 +146,29 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
{onTextColorChange && (
|
{onTextColorChange && (
|
||||||
<Box>
|
<Box>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Text Color"
|
label={t('sign.text.colorLabel', 'Text colour')}
|
||||||
value={textColor}
|
value={colorInput}
|
||||||
readOnly
|
placeholder="#000000"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
onChange={(e) => {
|
||||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
const value = e.currentTarget.value;
|
||||||
|
setColorInput(value);
|
||||||
|
|
||||||
|
// Update color if valid hex
|
||||||
|
if (isValidHexColor(value)) {
|
||||||
|
onTextColorChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Revert to valid color on blur if invalid
|
||||||
|
if (!isValidHexColor(colorInput)) {
|
||||||
|
setColorInput(textColor);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
rightSection={
|
rightSection={
|
||||||
<Box
|
<Box
|
||||||
|
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||||
style={{
|
style={{
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Stack, Button, Text, Alert, Tabs, SegmentedControl } from '@mantine/core';
|
import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } from '@mantine/core';
|
||||||
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
|
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
|
||||||
import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection";
|
import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection";
|
||||||
import { useSignature } from "@app/contexts/SignatureContext";
|
import { useSignature } from "@app/contexts/SignatureContext";
|
||||||
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
|
import { PLACEMENT_ACTIVATION_DELAY, FILE_SWITCH_ACTIVATION_DELAY } from '@app/constants/signConstants';
|
||||||
|
|
||||||
// Import the new reusable components
|
// Import the new reusable components
|
||||||
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
|
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
|
||||||
@ -11,6 +13,18 @@ import { DrawingControls } from "@app/components/annotation/shared/DrawingContro
|
|||||||
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
||||||
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
|
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
|
||||||
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
||||||
|
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||||
|
|
||||||
|
type SignatureDrafts = {
|
||||||
|
canvas?: string;
|
||||||
|
image?: string;
|
||||||
|
text?: {
|
||||||
|
signerName: string;
|
||||||
|
fontSize: number;
|
||||||
|
fontFamily: string;
|
||||||
|
textColor: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface SignSettingsProps {
|
interface SignSettingsProps {
|
||||||
parameters: SignParameters;
|
parameters: SignParameters;
|
||||||
@ -31,23 +45,64 @@ const SignSettings = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
onActivateSignaturePlacement,
|
onActivateSignaturePlacement,
|
||||||
onDeactivateSignature,
|
onDeactivateSignature,
|
||||||
|
onUpdateDrawSettings,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onSave
|
onSave
|
||||||
}: SignSettingsProps) => {
|
}: SignSettingsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isPlacementMode } = useSignature();
|
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
|
||||||
|
const { activeFileIndex } = useViewer();
|
||||||
|
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||||
|
const historyApiInstance = historyApiRef.current;
|
||||||
|
|
||||||
// State for drawing
|
// State for drawing
|
||||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||||
const [penSize, setPenSize] = useState(2);
|
const [penSize, setPenSize] = useState(2);
|
||||||
const [penSizeInput, setPenSizeInput] = useState('2');
|
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
const [interactionMode, setInteractionMode] = useState<'move' | 'place'>('move');
|
const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false);
|
||||||
|
|
||||||
// State for different signature types
|
// State for different signature types
|
||||||
const [canvasSignatureData, setCanvasSignatureData] = useState<string | null>(null);
|
const [canvasSignatureData, setCanvasSignatureData] = useState<string | undefined>();
|
||||||
const [imageSignatureData, setImageSignatureData] = useState<string | null>(null);
|
const [imageSignatureData, setImageSignatureData] = useState<string | undefined>();
|
||||||
|
const [signatureDrafts, setSignatureDrafts] = useState<SignatureDrafts>({});
|
||||||
|
const lastSyncedTextDraft = useRef<SignatureDrafts['text'] | null>(null);
|
||||||
|
const lastAppliedPlacementKey = useRef<string | null>(null);
|
||||||
|
const previousFileIndexRef = useRef(activeFileIndex);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disabled) {
|
||||||
|
onUpdateDrawSettings?.(selectedColor, penSize);
|
||||||
|
}
|
||||||
|
}, [selectedColor, penSize, disabled, onUpdateDrawSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (signaturesApplied) {
|
||||||
|
setPlacementManuallyPaused(false);
|
||||||
|
}
|
||||||
|
}, [signaturesApplied]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!historyApiInstance) {
|
||||||
|
setHistoryAvailability({ canUndo: false, canRedo: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAvailability = () => {
|
||||||
|
setHistoryAvailability({
|
||||||
|
canUndo: historyApiInstance.canUndo?.() ?? false,
|
||||||
|
canRedo: historyApiInstance.canRedo?.() ?? false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = historyApiInstance.subscribe?.(updateAvailability);
|
||||||
|
updateAvailability();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [historyApiInstance]);
|
||||||
|
|
||||||
// Handle image upload
|
// Handle image upload
|
||||||
const handleImageChange = async (file: File | null) => {
|
const handleImageChange = async (file: File | null) => {
|
||||||
@ -66,64 +121,209 @@ const SignSettings = ({
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear any existing canvas signatures when uploading image
|
|
||||||
setCanvasSignatureData(null);
|
|
||||||
setImageSignatureData(result);
|
setImageSignatureData(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading file:', error);
|
console.error('Error reading file:', error);
|
||||||
}
|
}
|
||||||
} else if (!file) {
|
} else if (!file) {
|
||||||
setImageSignatureData(null);
|
setImageSignatureData(undefined);
|
||||||
if (onDeactivateSignature) {
|
onDeactivateSignature?.();
|
||||||
onDeactivateSignature();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle signature data changes
|
// Handle signature data changes
|
||||||
const handleCanvasSignatureChange = (data: string | null) => {
|
const handleCanvasSignatureChange = (data: string | null) => {
|
||||||
|
const nextValue = data ?? undefined;
|
||||||
setCanvasSignatureData(prev => {
|
setCanvasSignatureData(prev => {
|
||||||
if (prev === data) return prev; // Prevent unnecessary updates
|
if (prev === nextValue) {
|
||||||
return data;
|
return prev;
|
||||||
});
|
|
||||||
if (data) {
|
|
||||||
// Clear image data when canvas is used
|
|
||||||
setImageSignatureData(null);
|
|
||||||
}
|
}
|
||||||
|
return nextValue;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle signature mode deactivation when switching types
|
const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]);
|
||||||
useEffect(() => {
|
const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]);
|
||||||
if (parameters.signatureType !== 'text' && onDeactivateSignature) {
|
const hasTextSignature = useMemo(
|
||||||
onDeactivateSignature();
|
() => Boolean(parameters.signerName && parameters.signerName.trim() !== ''),
|
||||||
}
|
[parameters.signerName]
|
||||||
}, [parameters.signatureType]);
|
);
|
||||||
|
|
||||||
// Handle text signature activation (including fontSize and fontFamily changes)
|
const hasAnySignature = hasCanvasSignature || hasImageSignature || hasTextSignature;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [parameters.signatureType, parameters.signerName, parameters.fontSize, parameters.fontFamily, onActivateSignaturePlacement, onDeactivateSignature]);
|
|
||||||
|
|
||||||
// Reset to move mode when placement mode is deactivated
|
const isCurrentTypeReady = useMemo(() => {
|
||||||
useEffect(() => {
|
switch (parameters.signatureType) {
|
||||||
if (!isPlacementMode && interactionMode === 'place') {
|
case 'canvas':
|
||||||
setInteractionMode('move');
|
return hasCanvasSignature;
|
||||||
|
case 'image':
|
||||||
|
return hasImageSignature;
|
||||||
|
case 'text':
|
||||||
|
return hasTextSignature;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, [isPlacementMode, interactionMode]);
|
}, [parameters.signatureType, hasCanvasSignature, hasImageSignature, hasTextSignature]);
|
||||||
|
|
||||||
|
const placementSignatureKey = useMemo(() => {
|
||||||
|
if (!isCurrentTypeReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (parameters.signatureType) {
|
||||||
|
case 'canvas':
|
||||||
|
return canvasSignatureData ?? null;
|
||||||
|
case 'image':
|
||||||
|
return imageSignatureData ?? null;
|
||||||
|
case 'text':
|
||||||
|
return JSON.stringify({
|
||||||
|
signerName: (parameters.signerName ?? '').trim(),
|
||||||
|
fontSize: parameters.fontSize ?? 16,
|
||||||
|
fontFamily: parameters.fontFamily ?? 'Helvetica',
|
||||||
|
textColor: parameters.textColor ?? '#000000',
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isCurrentTypeReady,
|
||||||
|
parameters.signatureType,
|
||||||
|
canvasSignatureData,
|
||||||
|
imageSignatureData,
|
||||||
|
parameters.signerName,
|
||||||
|
parameters.fontSize,
|
||||||
|
parameters.fontFamily,
|
||||||
|
parameters.textColor,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shouldEnablePlacement = useMemo(() => {
|
||||||
|
if (disabled) return false;
|
||||||
|
return isCurrentTypeReady;
|
||||||
|
}, [disabled, isCurrentTypeReady]);
|
||||||
|
|
||||||
|
const shouldAutoActivate = shouldEnablePlacement && !isPlacementManuallyPaused && !signaturesApplied;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSignatureDrafts(prev => {
|
||||||
|
if (canvasSignatureData) {
|
||||||
|
if (prev.canvas === canvasSignatureData) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { ...prev, canvas: canvasSignatureData };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev.canvas !== undefined) {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.canvas;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [canvasSignatureData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSignatureDrafts(prev => {
|
||||||
|
if (imageSignatureData) {
|
||||||
|
if (prev.image === imageSignatureData) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { ...prev, image: imageSignatureData };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev.image !== undefined) {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.image;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [imageSignatureData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextDraft = {
|
||||||
|
signerName: parameters.signerName || '',
|
||||||
|
fontSize: parameters.fontSize || 16,
|
||||||
|
fontFamily: parameters.fontFamily || 'Helvetica',
|
||||||
|
textColor: parameters.textColor || '#000000',
|
||||||
|
};
|
||||||
|
|
||||||
|
setSignatureDrafts(prev => {
|
||||||
|
const prevDraft = prev.text;
|
||||||
|
if (
|
||||||
|
prevDraft &&
|
||||||
|
prevDraft.signerName === nextDraft.signerName &&
|
||||||
|
prevDraft.fontSize === nextDraft.fontSize &&
|
||||||
|
prevDraft.fontFamily === nextDraft.fontFamily &&
|
||||||
|
prevDraft.textColor === nextDraft.textColor
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, text: nextDraft };
|
||||||
|
});
|
||||||
|
}, [parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parameters.signatureType === 'text') {
|
||||||
|
const draft = signatureDrafts.text;
|
||||||
|
if (!draft) {
|
||||||
|
lastSyncedTextDraft.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSignerName = parameters.signerName ?? '';
|
||||||
|
const currentFontSize = parameters.fontSize ?? 16;
|
||||||
|
const currentFontFamily = parameters.fontFamily ?? 'Helvetica';
|
||||||
|
const currentTextColor = parameters.textColor ?? '#000000';
|
||||||
|
|
||||||
|
const isSynced =
|
||||||
|
draft.signerName === currentSignerName &&
|
||||||
|
draft.fontSize === currentFontSize &&
|
||||||
|
draft.fontFamily === currentFontFamily &&
|
||||||
|
draft.textColor === currentTextColor;
|
||||||
|
|
||||||
|
if (isSynced) {
|
||||||
|
lastSyncedTextDraft.current = draft;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSynced = lastSyncedTextDraft.current;
|
||||||
|
const alreadyAttempted =
|
||||||
|
lastSynced &&
|
||||||
|
lastSynced.signerName === draft.signerName &&
|
||||||
|
lastSynced.fontSize === draft.fontSize &&
|
||||||
|
lastSynced.fontFamily === draft.fontFamily &&
|
||||||
|
lastSynced.textColor === draft.textColor;
|
||||||
|
|
||||||
|
if (!alreadyAttempted) {
|
||||||
|
lastSyncedTextDraft.current = draft;
|
||||||
|
if (draft.signerName !== currentSignerName) {
|
||||||
|
onParameterChange('signerName', draft.signerName);
|
||||||
|
}
|
||||||
|
if (draft.fontSize !== currentFontSize) {
|
||||||
|
onParameterChange('fontSize', draft.fontSize);
|
||||||
|
}
|
||||||
|
if (draft.fontFamily !== currentFontFamily) {
|
||||||
|
onParameterChange('fontFamily', draft.fontFamily);
|
||||||
|
}
|
||||||
|
if (draft.textColor !== currentTextColor) {
|
||||||
|
onParameterChange('textColor', draft.textColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastSyncedTextDraft.current = null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
parameters.signatureType,
|
||||||
|
parameters.signerName,
|
||||||
|
parameters.fontSize,
|
||||||
|
parameters.fontFamily,
|
||||||
|
parameters.textColor,
|
||||||
|
signatureDrafts.text,
|
||||||
|
onParameterChange,
|
||||||
|
]);
|
||||||
|
|
||||||
// Handle signature data updates
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let newSignatureData: string | undefined = undefined;
|
let newSignatureData: string | undefined = undefined;
|
||||||
|
|
||||||
@ -133,71 +333,99 @@ const SignSettings = ({
|
|||||||
newSignatureData = canvasSignatureData;
|
newSignatureData = canvasSignatureData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update if the signature data has actually changed
|
|
||||||
if (parameters.signatureData !== newSignatureData) {
|
if (parameters.signatureData !== newSignatureData) {
|
||||||
onParameterChange('signatureData', newSignatureData);
|
onParameterChange('signatureData', newSignatureData);
|
||||||
}
|
}
|
||||||
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData]);
|
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData, onParameterChange]);
|
||||||
|
|
||||||
// Handle image signature activation - activate when image data syncs with parameters
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) {
|
if (!shouldEnablePlacement) {
|
||||||
setInteractionMode('place');
|
if (isPlacementMode) {
|
||||||
setTimeout(() => {
|
onDeactivateSignature?.();
|
||||||
onActivateSignaturePlacement();
|
}
|
||||||
}, 100);
|
if (isPlacementManuallyPaused) {
|
||||||
|
setPlacementManuallyPaused(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [parameters.signatureType, parameters.signatureData, imageSignatureData]);
|
|
||||||
|
|
||||||
// Handle canvas signature activation - activate when canvas data syncs with parameters
|
if (!shouldAutoActivate || isPlacementMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
onActivateSignaturePlacement?.();
|
||||||
|
}, PLACEMENT_ACTIVATION_DELAY);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivateSignaturePlacement?.();
|
||||||
|
}, [
|
||||||
|
shouldEnablePlacement,
|
||||||
|
shouldAutoActivate,
|
||||||
|
isPlacementMode,
|
||||||
|
isPlacementManuallyPaused,
|
||||||
|
onActivateSignaturePlacement,
|
||||||
|
onDeactivateSignature,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (parameters.signatureType === 'canvas' && canvasSignatureData && parameters.signatureData === canvasSignatureData && onActivateSignaturePlacement) {
|
if (!shouldAutoActivate || !placementSignatureKey) {
|
||||||
setInteractionMode('place');
|
if (!shouldEnablePlacement || !shouldAutoActivate) {
|
||||||
setTimeout(() => {
|
lastAppliedPlacementKey.current = null;
|
||||||
onActivateSignaturePlacement();
|
}
|
||||||
}, 100);
|
return;
|
||||||
}
|
}
|
||||||
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData]);
|
|
||||||
|
|
||||||
// Draw settings are no longer needed since draw mode is removed
|
if (!isPlacementMode) {
|
||||||
|
lastAppliedPlacementKey.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastAppliedPlacementKey.current === placementSignatureKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = () => {
|
||||||
|
onActivateSignaturePlacement?.();
|
||||||
|
lastAppliedPlacementKey.current = placementSignatureKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger();
|
||||||
|
}, [placementSignatureKey, shouldAutoActivate, shouldEnablePlacement, isPlacementMode, onActivateSignaturePlacement]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeFileIndex === previousFileIndexRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousFileIndexRef.current = activeFileIndex;
|
||||||
|
|
||||||
|
if (!shouldEnablePlacement || signaturesApplied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlacementManuallyPaused(false);
|
||||||
|
lastAppliedPlacementKey.current = null;
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
onActivateSignaturePlacement?.();
|
||||||
|
}, FILE_SWITCH_ACTIVATION_DELAY);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivateSignaturePlacement?.();
|
||||||
|
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
|
||||||
|
|
||||||
|
const renderSignatureBuilder = () => {
|
||||||
|
if (parameters.signatureType === 'canvas') {
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
|
||||||
{/* Signature Type Selection */}
|
|
||||||
<Tabs
|
|
||||||
value={parameters.signatureType}
|
|
||||||
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
|
|
||||||
>
|
|
||||||
<Tabs.List grow>
|
|
||||||
<Tabs.Tab value="canvas" style={{ fontSize: '0.8rem' }}>
|
|
||||||
{t('sign.type.canvas', 'Canvas')}
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="image" style={{ fontSize: '0.8rem' }}>
|
|
||||||
{t('sign.type.image', 'Image')}
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="text" style={{ fontSize: '0.8rem' }}>
|
|
||||||
{t('sign.type.text', 'Text')}
|
|
||||||
</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Drawing Controls */}
|
|
||||||
<DrawingControls
|
|
||||||
onUndo={onUndo}
|
|
||||||
onRedo={onRedo}
|
|
||||||
onPlaceSignature={() => {
|
|
||||||
if (onActivateSignaturePlacement) {
|
|
||||||
onActivateSignaturePlacement();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))}
|
|
||||||
disabled={disabled}
|
|
||||||
showPlaceButton={false}
|
|
||||||
placeButtonText={t('sign.updateAndPlace', 'Update and Place')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Signature Creation based on type */}
|
|
||||||
{parameters.signatureType === 'canvas' && (
|
|
||||||
<DrawingCanvas
|
<DrawingCanvas
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
penSize={penSize}
|
penSize={penSize}
|
||||||
@ -207,36 +435,24 @@ const SignSettings = ({
|
|||||||
onPenSizeInputChange={setPenSizeInput}
|
onPenSizeInputChange={setPenSizeInput}
|
||||||
onSignatureDataChange={handleCanvasSignatureChange}
|
onSignatureDataChange={handleCanvasSignatureChange}
|
||||||
onDrawingComplete={() => {
|
onDrawingComplete={() => {
|
||||||
if (onActivateSignaturePlacement) {
|
onActivateSignaturePlacement?.();
|
||||||
onActivateSignaturePlacement();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
additionalButtons={
|
initialSignatureData={canvasSignatureData}
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (onActivateSignaturePlacement) {
|
|
||||||
onActivateSignaturePlacement();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color="blue"
|
|
||||||
variant="filled"
|
|
||||||
disabled={disabled || !canvasSignatureData}
|
|
||||||
>
|
|
||||||
{t('sign.updateAndPlace', 'Update and Place')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{parameters.signatureType === 'image' && (
|
if (parameters.signatureType === 'image') {
|
||||||
|
return (
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
onImageChange={handleImageChange}
|
onImageChange={handleImageChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{parameters.signatureType === 'text' && (
|
return (
|
||||||
<TextInputWithFont
|
<TextInputWithFont
|
||||||
text={parameters.signerName || ''}
|
text={parameters.signerName || ''}
|
||||||
onTextChange={(text) => onParameterChange('signerName', text)}
|
onTextChange={(text) => onParameterChange('signerName', text)}
|
||||||
@ -248,51 +464,175 @@ const SignSettings = ({
|
|||||||
onTextColorChange={(color) => onParameterChange('textColor', color)}
|
onTextColorChange={(color) => onParameterChange('textColor', color)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const placementInstructions = () => {
|
||||||
|
if (parameters.signatureType === 'canvas') {
|
||||||
|
return t('sign.instructions.canvas', 'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.');
|
||||||
|
}
|
||||||
|
if (parameters.signatureType === 'image') {
|
||||||
|
return t('sign.instructions.image', 'After uploading your signature image, click anywhere on the PDF to place it.');
|
||||||
|
}
|
||||||
|
return t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.');
|
||||||
|
};
|
||||||
|
|
||||||
{/* Interaction Mode Toggle */}
|
const placementAlert = isCurrentTypeReady
|
||||||
{(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== '')) && (
|
? {
|
||||||
<SegmentedControl
|
color: isPlacementMode ? 'blue' : 'teal',
|
||||||
value={interactionMode}
|
title: isPlacementMode
|
||||||
onChange={(value) => {
|
? t('sign.instructions.title', 'How to add your signature')
|
||||||
setInteractionMode(value as 'move' | 'place');
|
: t('sign.instructions.paused', 'Placement paused'),
|
||||||
if (value === 'place') {
|
message: isPlacementMode
|
||||||
if (onActivateSignaturePlacement) {
|
? placementInstructions()
|
||||||
onActivateSignaturePlacement();
|
: t('sign.instructions.resumeHint', 'Resume placement to click and add your signature.'),
|
||||||
}
|
}
|
||||||
} else {
|
: {
|
||||||
if (onDeactivateSignature) {
|
color: 'yellow',
|
||||||
onDeactivateSignature();
|
title: t('sign.instructions.title', 'How to add your signature'),
|
||||||
|
message: t('sign.instructions.noSignature', 'Create a signature above to enable placement tools.'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePausePlacement = () => {
|
||||||
|
setPlacementManuallyPaused(true);
|
||||||
|
onDeactivateSignature?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResumePlacement = () => {
|
||||||
|
setPlacementManuallyPaused(false);
|
||||||
|
onActivateSignaturePlacement?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Escape key to toggle pause/resume
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCurrentTypeReady) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isPlacementMode) {
|
||||||
|
handlePausePlacement();
|
||||||
|
} else if (isPlacementManuallyPaused) {
|
||||||
|
handleResumePlacement();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isCurrentTypeReady, isPlacementMode, isPlacementManuallyPaused]);
|
||||||
|
|
||||||
|
const placementToggleControl =
|
||||||
|
onActivateSignaturePlacement || onDeactivateSignature
|
||||||
|
? isPlacementMode
|
||||||
|
? (
|
||||||
|
<Tooltip label={t('sign.mode.pause', 'Pause placement')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t('sign.mode.pause', 'Pause placement')}
|
||||||
|
onClick={handlePausePlacement}
|
||||||
|
disabled={disabled || !onDeactivateSignature}
|
||||||
|
style={{
|
||||||
|
width: 'auto',
|
||||||
|
paddingInline: '0.75rem',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
}}
|
}}
|
||||||
data={[
|
>
|
||||||
{ label: t('sign.mode.move', 'Move Signature'), value: 'move' },
|
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
|
||||||
{ label: t('sign.mode.place', 'Place Signature'), value: 'place' }
|
<Text component="span" size="sm" fw={500}>
|
||||||
]}
|
{t('sign.mode.pause', 'Pause placement')}
|
||||||
fullWidth
|
</Text>
|
||||||
/>
|
</ActionIcon>
|
||||||
)}
|
</Tooltip>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Tooltip label={t('sign.mode.resume', 'Resume placement')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t('sign.mode.resume', 'Resume placement')}
|
||||||
|
onClick={handleResumePlacement}
|
||||||
|
disabled={disabled || !isCurrentTypeReady || !onActivateSignaturePlacement}
|
||||||
|
style={{
|
||||||
|
width: 'auto',
|
||||||
|
paddingInline: '0.75rem',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
|
||||||
|
<Text component="span" size="sm" fw={500}>
|
||||||
|
{t('sign.mode.resume', 'Resume placement')}
|
||||||
|
</Text>
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
{/* Instructions for placing signature */}
|
return (
|
||||||
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
<Stack>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('sign.step.createDesc', 'Choose how you want to create the signature')}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
value={parameters.signatureType}
|
||||||
|
fullWidth
|
||||||
|
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
|
||||||
|
data={[
|
||||||
|
{ label: t('sign.type.canvas', 'Draw'), value: 'canvas' },
|
||||||
|
{ label: t('sign.type.image', 'Upload'), value: 'image' },
|
||||||
|
{ label: t('sign.type.text', 'Type'), value: 'text' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{renderSignatureBuilder()}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text fw={600} size="md">
|
||||||
|
{t('sign.step.place', 'Place & save')}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('sign.step.placeDesc', 'Position the signature on your PDF')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="nowrap" align="center">
|
||||||
|
<DrawingControls
|
||||||
|
onUndo={onUndo}
|
||||||
|
onRedo={onRedo}
|
||||||
|
canUndo={historyAvailability.canUndo}
|
||||||
|
canRedo={historyAvailability.canRedo}
|
||||||
|
hasSignatureData={hasAnySignature}
|
||||||
|
disabled={disabled}
|
||||||
|
showPlaceButton={false}
|
||||||
|
/>
|
||||||
|
<Box style={{ marginLeft: 'auto' }}>
|
||||||
|
{placementToggleControl}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Alert color={placementAlert.color} title={placementAlert.title}>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{parameters.signatureType === 'canvas' && t('sign.instructions.canvas', 'After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.')}
|
{placementAlert.message}
|
||||||
{parameters.signatureType === 'image' && t('sign.instructions.image', 'After uploading your signature image above, click anywhere on the PDF to place it.')}
|
|
||||||
{parameters.signatureType === 'text' && t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.')}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{/* Color Picker Modal */}
|
</Stack>
|
||||||
|
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
isOpen={isColorPickerOpen}
|
isOpen={isColorPickerOpen}
|
||||||
onClose={() => setIsColorPickerOpen(false)}
|
onClose={() => setIsColorPickerOpen(false)}
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
onColorChange={setSelectedColor}
|
onColorChange={setSelectedColor}
|
||||||
|
title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Apply Signatures Button */}
|
|
||||||
{onSave && (
|
{onSave && (
|
||||||
<Button
|
<Button
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
@ -304,7 +644,6 @@ const SignSettings = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Suggested Tools Section */}
|
|
||||||
<SuggestedToolsSection />
|
<SuggestedToolsSection />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
|||||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||||
import { isStirlingFile } from '@app/types/fileContext';
|
import { isStirlingFile } from '@app/types/fileContext';
|
||||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||||
|
import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay';
|
||||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||||
|
|
||||||
export interface EmbedPdfViewerProps {
|
export interface EmbedPdfViewerProps {
|
||||||
@ -34,6 +35,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
setActiveFileIndex: externalSetActiveFileIndex,
|
setActiveFileIndex: externalSetActiveFileIndex,
|
||||||
}: EmbedPdfViewerProps) => {
|
}: EmbedPdfViewerProps) => {
|
||||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||||
|
|
||||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
||||||
@ -53,7 +55,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
}, [rotationState.rotation]);
|
}, [rotationState.rotation]);
|
||||||
|
|
||||||
// Get signature context
|
// Get signature context
|
||||||
const { signatureApiRef, historyApiRef } = useSignature();
|
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
||||||
|
|
||||||
// Get current file from FileContext
|
// Get current file from FileContext
|
||||||
const { selectors, state } = useFileState();
|
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
|
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||||
|
const isPlacementOverlayActive = Boolean(
|
||||||
|
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
|
||||||
|
);
|
||||||
|
|
||||||
// Track which file tab is active
|
// Track which file tab is active
|
||||||
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
||||||
@ -247,7 +252,9 @@ const EmbedPdfViewerContent = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* EmbedPDF Viewer */}
|
{/* EmbedPDF Viewer */}
|
||||||
<Box style={{
|
<Box
|
||||||
|
ref={pdfContainerRef}
|
||||||
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -268,6 +275,11 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Future: Handle signature completion
|
// Future: Handle signature completion
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<SignaturePlacementOverlay
|
||||||
|
containerRef={pdfContainerRef}
|
||||||
|
isActive={isPlacementOverlayActive}
|
||||||
|
signatureConfig={signatureConfig}
|
||||||
|
/>
|
||||||
</Box>
|
</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 { useHistoryCapability } from '@embedpdf/plugin-history/react';
|
||||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||||
import { useSignature } from '@app/contexts/SignatureContext';
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
@ -9,6 +9,7 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
|||||||
const { provides: historyApi } = useHistoryCapability();
|
const { provides: historyApi } = useHistoryCapability();
|
||||||
const { provides: annotationApi } = useAnnotationCapability();
|
const { provides: annotationApi } = useAnnotationCapability();
|
||||||
const { getImageData, storeImageData } = useSignature();
|
const { getImageData, storeImageData } = useSignature();
|
||||||
|
const restoringIds = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// Monitor annotation events to detect when annotations are restored
|
// Monitor annotation events to detect when annotations are restored
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -20,11 +21,52 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
|||||||
// Store image data for all STAMP annotations immediately when created or modified
|
// Store image data for all STAMP annotations immediately when created or modified
|
||||||
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
|
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
|
||||||
const storedImageData = getImageData(annotation.id);
|
const storedImageData = getImageData(annotation.id);
|
||||||
if (!storedImageData || storedImageData !== annotation.imageSrc) {
|
if (!storedImageData) {
|
||||||
storeImageData(annotation.id, annotation.imageSrc);
|
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
|
// Handle annotation restoration after undo operations
|
||||||
if (event.type === 'create' && event.committed) {
|
if (event.type === 'create' && event.committed) {
|
||||||
// Check if this is a STAMP annotation (signature) that might need image data restoration
|
// 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: () => {
|
canRedo: () => {
|
||||||
return historyApi ? historyApi.canRedo() : false;
|
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]);
|
}), [historyApi]);
|
||||||
|
|
||||||
return null; // This is a bridge component with no UI
|
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 { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||||
import { useSignature } from '@app/contexts/SignatureContext';
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
|
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) {
|
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
||||||
const { provides: annotationApi } = useAnnotationCapability();
|
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
|
// Enable keyboard deletion of selected annotations
|
||||||
@ -108,58 +289,9 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
|||||||
activateSignaturePlacementMode: () => {
|
activateSignaturePlacementMode: () => {
|
||||||
if (!annotationApi || !signatureConfig) return;
|
if (!annotationApi || !signatureConfig) return;
|
||||||
|
|
||||||
try {
|
configureStampDefaults().catch((error) => {
|
||||||
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) {
|
|
||||||
console.error('Error activating signature tool:', error);
|
console.error('Error activating signature tool:', error);
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateDrawSettings: (color: string, size: number) => {
|
updateDrawSettings: (color: string, size: number) => {
|
||||||
@ -230,7 +362,61 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
|||||||
return [];
|
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
|
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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -21,4 +21,5 @@ export interface HistoryAPI {
|
|||||||
redo: () => void;
|
redo: () => void;
|
||||||
canUndo: () => boolean;
|
canUndo: () => boolean;
|
||||||
canRedo: () => boolean;
|
canRedo: () => boolean;
|
||||||
|
subscribe?: (listener: () => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
15
frontend/src/core/constants/signConstants.ts
Normal file
15
frontend/src/core/constants/signConstants.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Timeout delays (ms) to allow PDF viewer to complete rendering before activating placement mode
|
||||||
|
export const PLACEMENT_ACTIVATION_DELAY = 60; // Standard delay for signature changes
|
||||||
|
export const FILE_SWITCH_ACTIVATION_DELAY = 80; // Slightly longer delay when switching files
|
||||||
|
|
||||||
|
// Signature preview sizing
|
||||||
|
export const MAX_PREVIEW_WIDTH_RATIO = 0.35; // Max preview width as percentage of container
|
||||||
|
export const MAX_PREVIEW_HEIGHT_RATIO = 0.35; // Max preview height as percentage of container
|
||||||
|
export const MAX_PREVIEW_WIDTH_REM = 15; // Absolute max width in rem
|
||||||
|
export const MAX_PREVIEW_HEIGHT_REM = 10; // Absolute max height in rem
|
||||||
|
export const MIN_SIGNATURE_DIMENSION_REM = 0.75; // Min dimension for visibility
|
||||||
|
export const OVERLAY_EDGE_PADDING_REM = 0.25; // Padding from container edges
|
||||||
|
|
||||||
|
// Text signature padding (relative to font size)
|
||||||
|
export const HORIZONTAL_PADDING_RATIO = 0.8;
|
||||||
|
export const VERTICAL_PADDING_RATIO = 0.6;
|
||||||
@ -10,6 +10,8 @@ interface SignatureState {
|
|||||||
isPlacementMode: boolean;
|
isPlacementMode: boolean;
|
||||||
// Whether signatures have been applied (allows export)
|
// Whether signatures have been applied (allows export)
|
||||||
signaturesApplied: boolean;
|
signaturesApplied: boolean;
|
||||||
|
// Size (in screen units) we want newly placed signatures to use
|
||||||
|
placementPreviewSize: { width: number; height: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature actions interface
|
// Signature actions interface
|
||||||
@ -26,6 +28,7 @@ interface SignatureActions {
|
|||||||
storeImageData: (id: string, data: string) => void;
|
storeImageData: (id: string, data: string) => void;
|
||||||
getImageData: (id: string) => string | undefined;
|
getImageData: (id: string) => string | undefined;
|
||||||
setSignaturesApplied: (applied: boolean) => void;
|
setSignaturesApplied: (applied: boolean) => void;
|
||||||
|
setPlacementPreviewSize: (size: { width: number; height: number } | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combined context interface
|
// Combined context interface
|
||||||
@ -42,6 +45,7 @@ const initialState: SignatureState = {
|
|||||||
signatureConfig: null,
|
signatureConfig: null,
|
||||||
isPlacementMode: false,
|
isPlacementMode: false,
|
||||||
signaturesApplied: true, // Start as true (no signatures placed yet)
|
signaturesApplied: true, // Start as true (no signatures placed yet)
|
||||||
|
placementPreviewSize: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Provider component
|
// Provider component
|
||||||
@ -131,6 +135,27 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setPlacementPreviewSize = useCallback((size: { width: number; height: number } | null) => {
|
||||||
|
setState(prev => {
|
||||||
|
const prevSize = prev.placementPreviewSize;
|
||||||
|
const same =
|
||||||
|
(prevSize === null && size === null) ||
|
||||||
|
(prevSize !== null &&
|
||||||
|
size !== null &&
|
||||||
|
Math.abs(prevSize.width - size.width) < 0.5 &&
|
||||||
|
Math.abs(prevSize.height - size.height) < 0.5);
|
||||||
|
|
||||||
|
if (same) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
placementPreviewSize: size,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// No auto-activation - all modes use manual buttons
|
// No auto-activation - all modes use manual buttons
|
||||||
|
|
||||||
const contextValue: SignatureContextValue = {
|
const contextValue: SignatureContextValue = {
|
||||||
@ -149,6 +174,7 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||||||
storeImageData,
|
storeImageData,
|
||||||
getImageData,
|
getImageData,
|
||||||
setSignaturesApplied,
|
setSignaturesApplied,
|
||||||
|
setPlacementPreviewSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const buildSignFormData = (params: SignParameters, file: File): FormData
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add signature type
|
// Add signature type
|
||||||
formData.append('signatureType', params.signatureType || 'draw');
|
formData.append('signatureType', params.signatureType || 'canvas');
|
||||||
|
|
||||||
// Add other parameters
|
// Add other parameters
|
||||||
if (params.reason) {
|
if (params.reason) {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export interface SignaturePosition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SignParameters {
|
export interface SignParameters {
|
||||||
signatureType: 'image' | 'text' | 'draw' | 'canvas';
|
signatureType: 'image' | 'text' | 'canvas';
|
||||||
signatureData?: string; // Base64 encoded image or text content
|
signatureData?: string; // Base64 encoded image or text content
|
||||||
signaturePosition?: SignaturePosition;
|
signaturePosition?: SignaturePosition;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
|||||||
@ -87,7 +87,7 @@ export function useTooltipPosition({
|
|||||||
if (sidebarTooltip) {
|
if (sidebarTooltip) {
|
||||||
// Require sidebar refs and state for proper positioning
|
// Require sidebar refs and state for proper positioning
|
||||||
if (!sidebarRefs || !sidebarState) {
|
if (!sidebarRefs || !sidebarState) {
|
||||||
console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props');
|
console.warn('Sidebar tooltip requires sidebarRefs and sidebarState props');
|
||||||
setPositionReady(false);
|
setPositionReady(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -97,7 +97,7 @@ export function useTooltipPosition({
|
|||||||
|
|
||||||
// Only show tooltip if we have the tool panel active
|
// Only show tooltip if we have the tool panel active
|
||||||
if (!sidebarInfo.isToolPanelActive) {
|
if (!sidebarInfo.isToolPanelActive) {
|
||||||
console.log('🚫 Not showing tooltip - tool panel not active');
|
console.log('Not showing tooltip - tool panel not active');
|
||||||
setPositionReady(false);
|
setPositionReady(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -115,6 +115,29 @@ const Sign = (props: BaseToolProps) => {
|
|||||||
// Deactivate signature placement mode after everything completes
|
// Deactivate signature placement mode after everything completes
|
||||||
handleDeactivateSignature();
|
handleDeactivateSignature();
|
||||||
|
|
||||||
|
const hasSignatureReady = (() => {
|
||||||
|
const params = base.params.parameters;
|
||||||
|
switch (params.signatureType) {
|
||||||
|
case 'canvas':
|
||||||
|
case 'image':
|
||||||
|
return Boolean(params.signatureData);
|
||||||
|
case 'text':
|
||||||
|
return Boolean(params.signerName && params.signerName.trim() !== '');
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (hasSignatureReady) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
handleActivateSignaturePlacement();
|
||||||
|
}, 150);
|
||||||
|
} else {
|
||||||
|
handleActivateSignaturePlacement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// File has been consumed - viewer should reload automatically via key prop
|
// File has been consumed - viewer should reload automatically via key prop
|
||||||
} else {
|
} else {
|
||||||
console.error('Signature flattening failed');
|
console.error('Signature flattening failed');
|
||||||
@ -122,7 +145,7 @@ const Sign = (props: BaseToolProps) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving signed document:', error);
|
console.error('Error saving signed document:', error);
|
||||||
}
|
}
|
||||||
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
|
}, [exportActions, base.selectedFiles, base.params.parameters, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, handleActivateSignaturePlacement, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
|
||||||
|
|
||||||
const getSteps = () => {
|
const getSteps = () => {
|
||||||
const steps = [];
|
const steps = [];
|
||||||
|
|||||||
84
frontend/src/core/utils/signaturePreview.ts
Normal file
84
frontend/src/core/utils/signaturePreview.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||||
|
import { HORIZONTAL_PADDING_RATIO, VERTICAL_PADDING_RATIO } from '@app/constants/signConstants';
|
||||||
|
|
||||||
|
export interface SignaturePreview {
|
||||||
|
dataUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadImage = (src: string): Promise<HTMLImageElement> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildSignaturePreview = async (config: SignParameters | null): Promise<SignaturePreview | null> => {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.signatureType === 'text') {
|
||||||
|
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.round(fontSize * HORIZONTAL_PADDING_RATIO);
|
||||||
|
const paddingY = Math.round(fontSize * VERTICAL_PADDING_RATIO);
|
||||||
|
|
||||||
|
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 width = Math.max(1, textWidth + paddingX * 2);
|
||||||
|
const height = Math.max(1, Math.ceil(fontSize + paddingY * 2));
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(text, paddingX, height / 2);
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL('image/png');
|
||||||
|
return { dataUrl, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = config.signatureData;
|
||||||
|
if (!dataUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await loadImage(dataUrl);
|
||||||
|
return {
|
||||||
|
dataUrl,
|
||||||
|
width: image.naturalWidth || image.width,
|
||||||
|
height: image.naturalHeight || image.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user