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",
|
||||
"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": {
|
||||
"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",
|
||||
"add": "Add",
|
||||
@ -2003,6 +2017,11 @@
|
||||
"steps": {
|
||||
"configure": "Configure Signature"
|
||||
},
|
||||
"step": {
|
||||
"createDesc": "Choose how you want to create the signature",
|
||||
"place": "Place & save",
|
||||
"placeDesc": "Position the signature on your PDF"
|
||||
},
|
||||
"type": {
|
||||
"title": "Signature Type",
|
||||
"draw": "Draw",
|
||||
@ -2019,11 +2038,16 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"move": "Move Signature",
|
||||
"place": "Place Signature"
|
||||
"place": "Place Signature",
|
||||
"pause": "Pause placement",
|
||||
"resume": "Resume placement"
|
||||
},
|
||||
"updateAndPlace": "Update and Place",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"title": "Choose colour"
|
||||
},
|
||||
"common": {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
@ -4637,7 +4664,8 @@
|
||||
"used": "used",
|
||||
"available": "available",
|
||||
"cancel": "Cancel",
|
||||
"preview": "Preview"
|
||||
"preview": "Preview",
|
||||
"done": "Done"
|
||||
},
|
||||
"config": {
|
||||
"overview": {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Stack, Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
|
||||
export interface AnnotationToolConfig {
|
||||
enableDrawing?: boolean;
|
||||
@ -32,10 +33,34 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
undo,
|
||||
redo
|
||||
} = usePDFAnnotation();
|
||||
const { historyApiRef } = useSignature();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
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) => {
|
||||
setSignatureData(data);
|
||||
@ -54,6 +79,8 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
<DrawingControls
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
canUndo={historyAvailability.canUndo}
|
||||
canRedo={historyAvailability.canRedo}
|
||||
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
||||
hasSignatureData={!!signatureData}
|
||||
disabled={disabled}
|
||||
@ -86,4 +113,4 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ColorPickerProps {
|
||||
isOpen: boolean;
|
||||
@ -14,13 +15,16 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
onClose,
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
title = "Choose Color"
|
||||
title
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
title={resolvedTitle}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
@ -36,7 +40,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={onClose}>
|
||||
Done
|
||||
{t('common.done', 'Done')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@ -64,4 +68,4 @@ export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
||||
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
|
||||
import SignaturePad from 'signature_pad';
|
||||
@ -20,6 +21,7 @@ interface DrawingCanvasProps {
|
||||
modalWidth?: number;
|
||||
modalHeight?: number;
|
||||
additionalButtons?: React.ReactNode;
|
||||
initialSignatureData?: string;
|
||||
}
|
||||
|
||||
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
@ -34,12 +36,14 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
disabled = false,
|
||||
width = 400,
|
||||
height = 150,
|
||||
initialSignatureData,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const padRef = useRef<SignaturePad | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(null);
|
||||
|
||||
const initPad = (canvas: HTMLCanvasElement) => {
|
||||
if (!padRef.current) {
|
||||
@ -55,6 +59,18 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
minDistance: 5,
|
||||
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');
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
if (padRef.current && !padRef.current.isEmpty()) {
|
||||
const canvas = modalCanvasRef.current;
|
||||
if (canvas) {
|
||||
const trimmedPng = trimCanvas(canvas);
|
||||
const untrimmedPng = canvas.toDataURL('image/png');
|
||||
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
|
||||
onSignatureDataChange(trimmedPng);
|
||||
|
||||
// Update preview canvas with proper aspect ratio
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
|
||||
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
|
||||
const scale = Math.min(
|
||||
previewCanvasRef.current.width / img.width,
|
||||
previewCanvasRef.current.height / img.height
|
||||
);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
|
||||
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
img.src = trimmedPng;
|
||||
renderPreview(trimmedPng);
|
||||
|
||||
if (onDrawingComplete) {
|
||||
onDrawingComplete();
|
||||
@ -157,6 +172,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
setSavedSignatureData(null); // Clear saved signature
|
||||
onSignatureDataChange(null);
|
||||
};
|
||||
|
||||
@ -173,67 +189,70 @@ 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 (
|
||||
<>
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>Draw your signature</Text>
|
||||
<PrivateContent>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
</PrivateContent>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Click to open drawing canvas
|
||||
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</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">
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Color</Text>
|
||||
<Popover
|
||||
opened={colorPickerOpen}
|
||||
onChange={setColorPickerOpen}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={() => setColorPickerOpen(!colorPickerOpen)}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
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>
|
||||
<Group gap="lg" align="flex-end" wrap="wrap">
|
||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('sign.canvas.colorLabel', 'Colour')}
|
||||
</Text>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={onColorSwatchClick}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('sign.canvas.penSizeLabel', 'Pen size')}
|
||||
</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
inputValue={penSizeInput}
|
||||
@ -242,12 +261,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
updatePenSize(size);
|
||||
}}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
placeholder="Size"
|
||||
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
|
||||
size="compact-sm"
|
||||
style={{ width: '60px' }}
|
||||
style={{ width: '80px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<PrivateContent>
|
||||
<canvas
|
||||
@ -263,7 +282,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
backgroundColor: 'white',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
height: '400px',
|
||||
height: '25rem',
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
/>
|
||||
@ -271,10 +290,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button variant="subtle" color="red" onClick={clear}>
|
||||
Clear Canvas
|
||||
{t('sign.canvas.clear', 'Clear canvas')}
|
||||
</Button>
|
||||
<Button onClick={closeModal}>
|
||||
Done
|
||||
{t('common.done', 'Done')}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Group, Button } from '@mantine/core';
|
||||
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface DrawingControlsProps {
|
||||
onUndo?: () => void;
|
||||
@ -8,8 +9,11 @@ interface DrawingControlsProps {
|
||||
onPlaceSignature?: () => void;
|
||||
hasSignatureData?: boolean;
|
||||
disabled?: boolean;
|
||||
canUndo?: boolean;
|
||||
canRedo?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
additionalControls?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
@ -18,30 +22,48 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
onPlaceSignature,
|
||||
hasSignatureData = false,
|
||||
disabled = false,
|
||||
canUndo = true,
|
||||
canRedo = true,
|
||||
showPlaceButton = true,
|
||||
placeButtonText = "Update and Place"
|
||||
placeButtonText = "Update and Place",
|
||||
additionalControls,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const undoDisabled = disabled || !canUndo;
|
||||
const redoDisabled = disabled || !canRedo;
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
{/* Undo/Redo Controls */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUndo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.undo', 'Undo')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRedo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.redo', 'Redo')}
|
||||
</Button>
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
{onUndo && (
|
||||
<Tooltip label={t('sign.undo', 'Undo')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t('sign.undo', 'Undo')}
|
||||
onClick={onUndo}
|
||||
disabled={undoDisabled}
|
||||
color={undoDisabled ? 'gray' : 'blue'}
|
||||
>
|
||||
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onRedo && (
|
||||
<Tooltip label={t('sign.redo', 'Redo')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t('sign.redo', 'Redo')}
|
||||
onClick={onRedo}
|
||||
disabled={redoDisabled}
|
||||
color={redoDisabled ? 'gray' : 'blue'}
|
||||
>
|
||||
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{additionalControls}
|
||||
|
||||
{/* Place Signature Button */}
|
||||
{showPlaceButton && onPlaceSignature && (
|
||||
@ -50,11 +72,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
color="blue"
|
||||
onClick={onPlaceSignature}
|
||||
disabled={disabled || !hasSignatureData}
|
||||
flex={1}
|
||||
ml="auto"
|
||||
>
|
||||
{placeButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@ -34,12 +34,18 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
const fontSizeCombobox = useCombobox();
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [colorInput, setColorInput] = useState(textColor);
|
||||
|
||||
// Sync font size input with prop changes
|
||||
useEffect(() => {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
}, [fontSize]);
|
||||
|
||||
// Sync color input with prop changes
|
||||
useEffect(() => {
|
||||
setColorInput(textColor);
|
||||
}, [textColor]);
|
||||
|
||||
const fontOptions = [
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ 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'];
|
||||
|
||||
// Validate hex color
|
||||
const isValidHexColor = (color: string): boolean => {
|
||||
return /^#[0-9A-Fa-f]{6}$/.test(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<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')}
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
@ -63,7 +74,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
|
||||
{/* Font Selection */}
|
||||
<Select
|
||||
label="Font"
|
||||
label={t('sign.text.fontLabel', 'Font')}
|
||||
value={fontFamily}
|
||||
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
||||
data={fontOptions}
|
||||
@ -88,8 +99,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
>
|
||||
<Combobox.Target>
|
||||
<TextInput
|
||||
label="Font Size"
|
||||
placeholder="Type or select font size (8-200)"
|
||||
label={t('sign.text.fontSizeLabel', 'Font size')}
|
||||
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
|
||||
value={fontSizeInput}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
@ -135,14 +146,29 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
{onTextColorChange && (
|
||||
<Box>
|
||||
<TextInput
|
||||
label="Text Color"
|
||||
value={textColor}
|
||||
readOnly
|
||||
label={t('sign.text.colorLabel', 'Text colour')}
|
||||
value={colorInput}
|
||||
placeholder="#000000"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
onChange={(e) => {
|
||||
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={
|
||||
<Box
|
||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
@ -169,4 +195,4 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
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 { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection";
|
||||
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 { 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 { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
|
||||
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 {
|
||||
parameters: SignParameters;
|
||||
@ -31,23 +45,64 @@ const SignSettings = ({
|
||||
disabled = false,
|
||||
onActivateSignaturePlacement,
|
||||
onDeactivateSignature,
|
||||
onUpdateDrawSettings,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSave
|
||||
}: SignSettingsProps) => {
|
||||
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
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [interactionMode, setInteractionMode] = useState<'move' | 'place'>('move');
|
||||
const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false);
|
||||
|
||||
// State for different signature types
|
||||
const [canvasSignatureData, setCanvasSignatureData] = useState<string | null>(null);
|
||||
const [imageSignatureData, setImageSignatureData] = useState<string | null>(null);
|
||||
const [canvasSignatureData, setCanvasSignatureData] = useState<string | undefined>();
|
||||
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
|
||||
const handleImageChange = async (file: File | null) => {
|
||||
@ -66,64 +121,209 @@ const SignSettings = ({
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Clear any existing canvas signatures when uploading image
|
||||
setCanvasSignatureData(null);
|
||||
setImageSignatureData(result);
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
setImageSignatureData(null);
|
||||
if (onDeactivateSignature) {
|
||||
onDeactivateSignature();
|
||||
}
|
||||
setImageSignatureData(undefined);
|
||||
onDeactivateSignature?.();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle signature data changes
|
||||
const handleCanvasSignatureChange = (data: string | null) => {
|
||||
const nextValue = data ?? undefined;
|
||||
setCanvasSignatureData(prev => {
|
||||
if (prev === data) return prev; // Prevent unnecessary updates
|
||||
return data;
|
||||
if (prev === nextValue) {
|
||||
return prev;
|
||||
}
|
||||
return nextValue;
|
||||
});
|
||||
if (data) {
|
||||
// Clear image data when canvas is used
|
||||
setImageSignatureData(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle signature mode deactivation when switching types
|
||||
useEffect(() => {
|
||||
if (parameters.signatureType !== 'text' && onDeactivateSignature) {
|
||||
onDeactivateSignature();
|
||||
}
|
||||
}, [parameters.signatureType]);
|
||||
const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]);
|
||||
const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]);
|
||||
const hasTextSignature = useMemo(
|
||||
() => Boolean(parameters.signerName && parameters.signerName.trim() !== ''),
|
||||
[parameters.signerName]
|
||||
);
|
||||
|
||||
const hasAnySignature = hasCanvasSignature || hasImageSignature || hasTextSignature;
|
||||
|
||||
const isCurrentTypeReady = useMemo(() => {
|
||||
switch (parameters.signatureType) {
|
||||
case 'canvas':
|
||||
return hasCanvasSignature;
|
||||
case 'image':
|
||||
return hasImageSignature;
|
||||
case 'text':
|
||||
return hasTextSignature;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [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;
|
||||
|
||||
// Handle text signature activation (including fontSize and fontFamily changes)
|
||||
useEffect(() => {
|
||||
if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') {
|
||||
if (onActivateSignaturePlacement) {
|
||||
setInteractionMode('place');
|
||||
setTimeout(() => {
|
||||
onActivateSignaturePlacement();
|
||||
}, 100);
|
||||
setSignatureDrafts(prev => {
|
||||
if (canvasSignatureData) {
|
||||
if (prev.canvas === canvasSignatureData) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, canvas: canvasSignatureData };
|
||||
}
|
||||
} else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) {
|
||||
if (onDeactivateSignature) {
|
||||
setInteractionMode('move');
|
||||
onDeactivateSignature();
|
||||
|
||||
if (prev.canvas !== undefined) {
|
||||
const next = { ...prev };
|
||||
delete next.canvas;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
}, [parameters.signatureType, parameters.signerName, parameters.fontSize, parameters.fontFamily, onActivateSignaturePlacement, onDeactivateSignature]);
|
||||
|
||||
// Reset to move mode when placement mode is deactivated
|
||||
return prev;
|
||||
});
|
||||
}, [canvasSignatureData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlacementMode && interactionMode === 'place') {
|
||||
setInteractionMode('move');
|
||||
}
|
||||
}, [isPlacementMode, interactionMode]);
|
||||
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(() => {
|
||||
let newSignatureData: string | undefined = undefined;
|
||||
|
||||
@ -133,71 +333,99 @@ const SignSettings = ({
|
||||
newSignatureData = canvasSignatureData;
|
||||
}
|
||||
|
||||
// Only update if the signature data has actually changed
|
||||
if (parameters.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(() => {
|
||||
if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) {
|
||||
setInteractionMode('place');
|
||||
setTimeout(() => {
|
||||
onActivateSignaturePlacement();
|
||||
}, 100);
|
||||
if (!shouldEnablePlacement) {
|
||||
if (isPlacementMode) {
|
||||
onDeactivateSignature?.();
|
||||
}
|
||||
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(() => {
|
||||
if (parameters.signatureType === 'canvas' && canvasSignatureData && parameters.signatureData === canvasSignatureData && onActivateSignaturePlacement) {
|
||||
setInteractionMode('place');
|
||||
setTimeout(() => {
|
||||
onActivateSignaturePlacement();
|
||||
}, 100);
|
||||
if (!shouldAutoActivate || !placementSignatureKey) {
|
||||
if (!shouldEnablePlacement || !shouldAutoActivate) {
|
||||
lastAppliedPlacementKey.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData]);
|
||||
|
||||
// Draw settings are no longer needed since draw mode is removed
|
||||
if (!isPlacementMode) {
|
||||
lastAppliedPlacementKey.current = null;
|
||||
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>
|
||||
if (lastAppliedPlacementKey.current === placementSignatureKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
{/* 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')}
|
||||
/>
|
||||
const trigger = () => {
|
||||
onActivateSignaturePlacement?.();
|
||||
lastAppliedPlacementKey.current = placementSignatureKey;
|
||||
};
|
||||
|
||||
{/* Signature Creation based on type */}
|
||||
{parameters.signatureType === 'canvas' && (
|
||||
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 (
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
penSize={penSize}
|
||||
@ -207,92 +435,204 @@ const SignSettings = ({
|
||||
onPenSizeInputChange={setPenSizeInput}
|
||||
onSignatureDataChange={handleCanvasSignatureChange}
|
||||
onDrawingComplete={() => {
|
||||
if (onActivateSignaturePlacement) {
|
||||
onActivateSignaturePlacement();
|
||||
}
|
||||
onActivateSignaturePlacement?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
additionalButtons={
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (onActivateSignaturePlacement) {
|
||||
onActivateSignaturePlacement();
|
||||
}
|
||||
}}
|
||||
color="blue"
|
||||
variant="filled"
|
||||
disabled={disabled || !canvasSignatureData}
|
||||
>
|
||||
{t('sign.updateAndPlace', 'Update and Place')}
|
||||
</Button>
|
||||
}
|
||||
initialSignatureData={canvasSignatureData}
|
||||
/>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
{parameters.signatureType === 'image' && (
|
||||
if (parameters.signatureType === 'image') {
|
||||
return (
|
||||
<ImageUploader
|
||||
onImageChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
{parameters.signatureType === 'text' && (
|
||||
<TextInputWithFont
|
||||
text={parameters.signerName || ''}
|
||||
onTextChange={(text) => onParameterChange('signerName', text)}
|
||||
fontSize={parameters.fontSize || 16}
|
||||
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
|
||||
fontFamily={parameters.fontFamily || 'Helvetica'}
|
||||
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
|
||||
textColor={parameters.textColor || '#000000'}
|
||||
onTextColorChange={(color) => onParameterChange('textColor', color)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<TextInputWithFont
|
||||
text={parameters.signerName || ''}
|
||||
onTextChange={(text) => onParameterChange('signerName', text)}
|
||||
fontSize={parameters.fontSize || 16}
|
||||
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
|
||||
fontFamily={parameters.fontFamily || 'Helvetica'}
|
||||
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
|
||||
textColor={parameters.textColor || '#000000'}
|
||||
onTextColorChange={(color) => onParameterChange('textColor', color)}
|
||||
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 */}
|
||||
{(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== '')) && (
|
||||
<SegmentedControl
|
||||
value={interactionMode}
|
||||
onChange={(value) => {
|
||||
setInteractionMode(value as 'move' | 'place');
|
||||
if (value === 'place') {
|
||||
if (onActivateSignaturePlacement) {
|
||||
onActivateSignaturePlacement();
|
||||
}
|
||||
} else {
|
||||
if (onDeactivateSignature) {
|
||||
onDeactivateSignature();
|
||||
}
|
||||
}
|
||||
}}
|
||||
data={[
|
||||
{ label: t('sign.mode.move', 'Move Signature'), value: 'move' },
|
||||
{ label: t('sign.mode.place', 'Place Signature'), value: 'place' }
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
const placementAlert = isCurrentTypeReady
|
||||
? {
|
||||
color: isPlacementMode ? 'blue' : 'teal',
|
||||
title: isPlacementMode
|
||||
? t('sign.instructions.title', 'How to add your signature')
|
||||
: t('sign.instructions.paused', 'Placement paused'),
|
||||
message: isPlacementMode
|
||||
? placementInstructions()
|
||||
: t('sign.instructions.resumeHint', 'Resume placement to click and add your signature.'),
|
||||
}
|
||||
: {
|
||||
color: 'yellow',
|
||||
title: t('sign.instructions.title', 'How to add your signature'),
|
||||
message: t('sign.instructions.noSignature', 'Create a signature above to enable placement tools.'),
|
||||
};
|
||||
|
||||
{/* Instructions for placing signature */}
|
||||
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
||||
<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.')}
|
||||
{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.')}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
|
||||
<Text component="span" size="sm" fw={500}>
|
||||
{t('sign.mode.pause', 'Pause placement')}
|
||||
</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;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('sign.step.createDesc', 'Choose how you want to create the signature')}
|
||||
</Text>
|
||||
</Alert>
|
||||
<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">
|
||||
{placementAlert.message}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
</Stack>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={setSelectedColor}
|
||||
title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')}
|
||||
/>
|
||||
|
||||
{/* Apply Signatures Button */}
|
||||
{onSave && (
|
||||
<Button
|
||||
onClick={onSave}
|
||||
@ -304,7 +644,6 @@ const SignSettings = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Suggested Tools Section */}
|
||||
<SuggestedToolsSection />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@ -14,6 +14,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay';
|
||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
@ -34,6 +35,7 @@ const EmbedPdfViewerContent = ({
|
||||
setActiveFileIndex: externalSetActiveFileIndex,
|
||||
}: EmbedPdfViewerProps) => {
|
||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
||||
@ -53,7 +55,7 @@ const EmbedPdfViewerContent = ({
|
||||
}, [rotationState.rotation]);
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors, state } = useFileState();
|
||||
@ -71,6 +73,9 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
const isPlacementOverlayActive = Boolean(
|
||||
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
|
||||
);
|
||||
|
||||
// Track which file tab is active
|
||||
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
||||
@ -247,15 +252,17 @@ const EmbedPdfViewerContent = ({
|
||||
) : (
|
||||
<>
|
||||
{/* EmbedPDF Viewer */}
|
||||
<Box style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<Box
|
||||
ref={pdfContainerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<LocalEmbedPDF
|
||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
||||
file={effectiveFile.file}
|
||||
@ -268,6 +275,11 @@ const EmbedPdfViewerContent = ({
|
||||
// Future: Handle signature completion
|
||||
}}
|
||||
/>
|
||||
<SignaturePlacementOverlay
|
||||
containerRef={pdfContainerRef}
|
||||
isActive={isPlacementOverlayActive}
|
||||
signatureConfig={signatureConfig}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
|
||||
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
@ -9,6 +9,7 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
const { provides: historyApi } = useHistoryCapability();
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { getImageData, storeImageData } = useSignature();
|
||||
const restoringIds = useRef<Set<string>>(new Set());
|
||||
|
||||
// Monitor annotation events to detect when annotations are restored
|
||||
useEffect(() => {
|
||||
@ -20,11 +21,52 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
// Store image data for all STAMP annotations immediately when created or modified
|
||||
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
if (!storedImageData || storedImageData !== annotation.imageSrc) {
|
||||
if (!storedImageData) {
|
||||
storeImageData(annotation.id, annotation.imageSrc);
|
||||
}
|
||||
}
|
||||
|
||||
if (annotation && annotation.type === 13 && annotation.id) {
|
||||
// Prevent infinite loops when we recreate annotations
|
||||
if (restoringIds.current.has(annotation.id)) {
|
||||
restoringIds.current.delete(annotation.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
// If EmbedPDF cropped the image (imageSrc changed), recreate annotation using stored data
|
||||
if (storedImageData && annotation.imageSrc && annotation.imageSrc !== storedImageData) {
|
||||
const newId = uuidV4();
|
||||
restoringIds.current.add(newId);
|
||||
storeImageData(newId, storedImageData);
|
||||
|
||||
const pageIndex = event.pageIndex ?? annotation.pageIndex ?? annotation.object?.pageIndex ?? 0;
|
||||
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
|
||||
|
||||
try {
|
||||
annotationApi.deleteAnnotation(pageIndex, annotation.id);
|
||||
setTimeout(() => {
|
||||
annotationApi.createAnnotation(pageIndex, {
|
||||
type: annotation.type,
|
||||
rect,
|
||||
author: annotation.author || 'Digital Signature',
|
||||
subject: annotation.subject || 'Digital Signature',
|
||||
pageIndex,
|
||||
id: newId,
|
||||
created: annotation.created || new Date(),
|
||||
imageSrc: storedImageData,
|
||||
contents: storedImageData,
|
||||
data: storedImageData,
|
||||
appearance: storedImageData,
|
||||
});
|
||||
}, 50);
|
||||
} catch (restoreError) {
|
||||
console.error('HistoryAPI: Failed to restore cropped signature:', restoreError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle annotation restoration after undo operations
|
||||
if (event.type === 'create' && event.committed) {
|
||||
// Check if this is a STAMP annotation (signature) that might need image data restoration
|
||||
@ -102,6 +144,21 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
canRedo: () => {
|
||||
return historyApi ? historyApi.canRedo() : false;
|
||||
},
|
||||
|
||||
subscribe: (listener: () => void) => {
|
||||
if (!historyApi?.onHistoryChange) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const wrapped = () => listener();
|
||||
const unsubscribe = historyApi.onHistoryChange(wrapped);
|
||||
listener();
|
||||
|
||||
if (typeof unsubscribe === 'function') {
|
||||
return unsubscribe;
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
}), [historyApi]);
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
|
||||
@ -1,12 +1,193 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
|
||||
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
// Minimum allowed width/height (in pixels) for a signature image or text stamp.
|
||||
// This prevents rendering issues and ensures signatures are always visible and usable.
|
||||
const MIN_SIGNATURE_DIMENSION = 12;
|
||||
|
||||
// Use 2x oversampling to improve text rendering quality (anti-aliasing) when generating signature images.
|
||||
// This provides a good balance between visual fidelity and performance/memory usage.
|
||||
const TEXT_OVERSAMPLE_FACTOR = 2;
|
||||
|
||||
const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new Set()): string | undefined => {
|
||||
if (!value || depth > 6) return undefined;
|
||||
|
||||
// Prevent circular references
|
||||
if (typeof value === 'object' && visited.has(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.startsWith('data:image') ? value : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
visited.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const result = extractDataUrl(entry, depth + 1, visited);
|
||||
if (result) return result;
|
||||
}
|
||||
} else {
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
const result = extractDataUrl((value as Record<string, unknown>)[key], depth + 1, visited);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const createTextStampImage = (
|
||||
config: SignParameters,
|
||||
displaySize?: { width: number; height: number } | null
|
||||
): { dataUrl: string; pixelWidth: number; pixelHeight: number; displayWidth: number; displayHeight: number } | null => {
|
||||
const text = (config.signerName ?? '').trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fontSize = config.fontSize ?? 16;
|
||||
const fontFamily = config.fontFamily ?? 'Helvetica';
|
||||
const textColor = config.textColor ?? '#000000';
|
||||
|
||||
const paddingX = Math.max(4, Math.round(fontSize * 0.8));
|
||||
const paddingY = Math.max(4, Math.round(fontSize * 0.6));
|
||||
|
||||
const measureCanvas = document.createElement('canvas');
|
||||
const measureCtx = measureCanvas.getContext('2d');
|
||||
if (!measureCtx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
measureCtx.font = `${fontSize}px ${fontFamily}`;
|
||||
const metrics = measureCtx.measureText(text);
|
||||
const textWidth = Math.ceil(metrics.width);
|
||||
const naturalWidth = Math.max(MIN_SIGNATURE_DIMENSION, textWidth + paddingX * 2);
|
||||
const naturalHeight = Math.max(MIN_SIGNATURE_DIMENSION, Math.ceil(fontSize + paddingY * 2));
|
||||
|
||||
const scale =
|
||||
displaySize && naturalWidth > 0 && naturalHeight > 0
|
||||
? Math.min(displaySize.width / naturalWidth, displaySize.height / naturalHeight)
|
||||
: 1;
|
||||
|
||||
const displayWidth = Math.max(MIN_SIGNATURE_DIMENSION, naturalWidth * scale);
|
||||
const displayHeight = Math.max(MIN_SIGNATURE_DIMENSION, naturalHeight * scale);
|
||||
|
||||
const canvasWidth = Math.max(
|
||||
MIN_SIGNATURE_DIMENSION,
|
||||
Math.round(displayWidth * TEXT_OVERSAMPLE_FACTOR)
|
||||
);
|
||||
const canvasHeight = Math.max(
|
||||
MIN_SIGNATURE_DIMENSION,
|
||||
Math.round(displayHeight * TEXT_OVERSAMPLE_FACTOR)
|
||||
);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveScale = scale * TEXT_OVERSAMPLE_FACTOR;
|
||||
ctx.scale(effectiveScale, effectiveScale);
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const horizontalPadding = paddingX;
|
||||
const verticalCenter = naturalHeight / 2;
|
||||
ctx.fillText(text, horizontalPadding, verticalCenter);
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
pixelWidth: canvasWidth,
|
||||
pixelHeight: canvasHeight,
|
||||
displayWidth,
|
||||
displayHeight,
|
||||
};
|
||||
};
|
||||
|
||||
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
|
||||
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
|
||||
const { getZoomState } = useViewer();
|
||||
const currentZoom = getZoomState()?.currentZoom ?? 1;
|
||||
const lastStampImageRef = useRef<string | null>(null);
|
||||
|
||||
const cssToPdfSize = useCallback(
|
||||
(size: { width: number; height: number }) => {
|
||||
const zoom = currentZoom || 1;
|
||||
const factor = 1 / zoom;
|
||||
return {
|
||||
width: size.width * factor,
|
||||
height: size.height * factor,
|
||||
};
|
||||
},
|
||||
[currentZoom]
|
||||
);
|
||||
|
||||
const applyStampDefaults = useCallback(
|
||||
(imageSrc: string, subject: string, size?: { width: number; height: number }) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
annotationApi.setActiveTool(null);
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const stampTool = annotationApi.getActiveTool();
|
||||
if (stampTool && stampTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc,
|
||||
subject,
|
||||
...(size ? { imageSize: { width: size.width, height: size.height } } : {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
[annotationApi]
|
||||
);
|
||||
|
||||
const configureStampDefaults = useCallback(async () => {
|
||||
if (!annotationApi || !signatureConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||
const textStamp = createTextStampImage(signatureConfig, placementPreviewSize);
|
||||
if (textStamp) {
|
||||
const displaySize =
|
||||
placementPreviewSize ?? {
|
||||
width: textStamp.displayWidth,
|
||||
height: textStamp.displayHeight,
|
||||
};
|
||||
const pdfSize = cssToPdfSize(displaySize);
|
||||
lastStampImageRef.current = textStamp.dataUrl;
|
||||
applyStampDefaults(textStamp.dataUrl, `Text Signature - ${signatureConfig.signerName}`, pdfSize);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (signatureConfig.signatureData) {
|
||||
const pdfSize = placementPreviewSize ? cssToPdfSize(placementPreviewSize) : undefined;
|
||||
lastStampImageRef.current = signatureConfig.signatureData;
|
||||
applyStampDefaults(signatureConfig.signatureData, `Digital Signature - ${signatureConfig.reason || 'Document signing'}`, pdfSize);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error preparing signature defaults:', error);
|
||||
}
|
||||
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
||||
|
||||
|
||||
// Enable keyboard deletion of selected annotations
|
||||
@ -108,58 +289,9 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
activateSignaturePlacementMode: () => {
|
||||
if (!annotationApi || !signatureConfig) return;
|
||||
|
||||
try {
|
||||
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||
// Skip native text tools - always use stamp for consistent sizing
|
||||
const activatedTool = null;
|
||||
|
||||
if (!activatedTool) {
|
||||
// Create text image as stamp with actual pixel size matching desired display size
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const baseFontSize = signatureConfig.fontSize || 16;
|
||||
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
|
||||
const textColor = signatureConfig.textColor || '#000000';
|
||||
|
||||
// Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly)
|
||||
canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6);
|
||||
canvas.height = baseFontSize + 20;
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${baseFontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
|
||||
const dataURL = canvas.toDataURL();
|
||||
|
||||
// Deactivate and reactivate to force refresh
|
||||
annotationApi.setActiveTool(null);
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const stampTool = annotationApi.getActiveTool();
|
||||
if (stampTool && stampTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: dataURL,
|
||||
subject: `Text Signature - ${signatureConfig.signerName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (signatureConfig.signatureData) {
|
||||
// Use stamp tool for image/canvas signatures
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const activeTool = annotationApi.getActiveTool();
|
||||
|
||||
if (activeTool && activeTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: signatureConfig.signatureData,
|
||||
subject: `Digital Signature - ${signatureConfig.reason || 'Document signing'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
configureStampDefaults().catch((error) => {
|
||||
console.error('Error activating signature tool:', error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateDrawSettings: (color: string, size: number) => {
|
||||
@ -230,7 +362,61 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}), [annotationApi, signatureConfig]);
|
||||
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!annotationApi?.onAnnotationEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = annotationApi.onAnnotationEvent(event => {
|
||||
if (event.type !== 'create' && event.type !== 'update') {
|
||||
return;
|
||||
}
|
||||
|
||||
const annotation: any = event.annotation;
|
||||
const annotationId: string | undefined = annotation?.id;
|
||||
if (!annotationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directData =
|
||||
extractDataUrl(annotation.imageSrc) ||
|
||||
extractDataUrl(annotation.imageData) ||
|
||||
extractDataUrl(annotation.appearance) ||
|
||||
extractDataUrl(annotation.stampData) ||
|
||||
extractDataUrl(annotation.contents) ||
|
||||
extractDataUrl(annotation.data) ||
|
||||
extractDataUrl(annotation.customData) ||
|
||||
extractDataUrl(annotation.asset);
|
||||
|
||||
const dataToStore = directData || lastStampImageRef.current;
|
||||
if (dataToStore) {
|
||||
storeImageData(annotationId, dataToStore);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [annotationApi, storeImageData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlacementMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
configureStampDefaults().catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Error updating signature defaults:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
|
||||
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
|
||||
@ -0,0 +1,171 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePreview';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import {
|
||||
MAX_PREVIEW_WIDTH_RATIO,
|
||||
MAX_PREVIEW_HEIGHT_RATIO,
|
||||
MAX_PREVIEW_WIDTH_REM,
|
||||
MAX_PREVIEW_HEIGHT_REM,
|
||||
MIN_SIGNATURE_DIMENSION_REM,
|
||||
OVERLAY_EDGE_PADDING_REM,
|
||||
} from '@app/constants/signConstants';
|
||||
|
||||
// Convert rem to pixels using browser's base font size (typically 16px)
|
||||
const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
|
||||
interface SignaturePlacementOverlayProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
isActive: boolean;
|
||||
signatureConfig: SignParameters | null;
|
||||
}
|
||||
|
||||
export const SignaturePlacementOverlay: React.FC<SignaturePlacementOverlayProps> = ({
|
||||
containerRef,
|
||||
isActive,
|
||||
signatureConfig,
|
||||
}) => {
|
||||
const [preview, setPreview] = useState<SignaturePreview | null>(null);
|
||||
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
|
||||
const { setPlacementPreviewSize } = useSignature();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const buildPreview = async () => {
|
||||
try {
|
||||
const value = await buildSignaturePreview(signatureConfig ?? null);
|
||||
if (!cancelled) {
|
||||
setPreview(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to build signature preview:', error);
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
buildPreview();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [signatureConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!isActive || !element) {
|
||||
setCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
setCursor({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLeave = () => setCursor(null);
|
||||
|
||||
element.addEventListener('mousemove', handleMove);
|
||||
element.addEventListener('mouseleave', handleLeave);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('mousemove', handleMove);
|
||||
element.removeEventListener('mouseleave', handleLeave);
|
||||
};
|
||||
}, [containerRef, isActive]);
|
||||
|
||||
const scaledSize = useMemo(() => {
|
||||
if (!preview || !containerRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerWidth = container.clientWidth || 1;
|
||||
const containerHeight = container.clientHeight || 1;
|
||||
|
||||
const maxWidth = Math.min(containerWidth * MAX_PREVIEW_WIDTH_RATIO, remToPx(MAX_PREVIEW_WIDTH_REM));
|
||||
const maxHeight = Math.min(containerHeight * MAX_PREVIEW_HEIGHT_RATIO, remToPx(MAX_PREVIEW_HEIGHT_REM));
|
||||
|
||||
const scale = Math.min(
|
||||
1,
|
||||
maxWidth / Math.max(preview.width, 1),
|
||||
maxHeight / Math.max(preview.height, 1)
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.width * scale),
|
||||
height: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.height * scale),
|
||||
};
|
||||
}, [preview, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !scaledSize) {
|
||||
setPlacementPreviewSize(null);
|
||||
} else {
|
||||
setPlacementPreviewSize(scaledSize);
|
||||
}
|
||||
}, [isActive, scaledSize, setPlacementPreviewSize]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setPlacementPreviewSize(null);
|
||||
};
|
||||
}, [setPlacementPreviewSize]);
|
||||
|
||||
const display = useMemo(() => {
|
||||
if (!preview || !scaledSize || !cursor || !containerRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerWidth = container.clientWidth || 1;
|
||||
const containerHeight = container.clientHeight || 1;
|
||||
|
||||
const width = scaledSize.width;
|
||||
const height = scaledSize.height;
|
||||
const edgePadding = remToPx(OVERLAY_EDGE_PADDING_REM);
|
||||
|
||||
const clampedLeft = Math.max(edgePadding, Math.min(cursor.x - width / 2, containerWidth - width - edgePadding));
|
||||
const clampedTop = Math.max(edgePadding, Math.min(cursor.y - height / 2, containerHeight - height - edgePadding));
|
||||
|
||||
return {
|
||||
left: clampedLeft,
|
||||
top: clampedTop,
|
||||
width,
|
||||
height,
|
||||
dataUrl: preview.dataUrl,
|
||||
};
|
||||
}, [preview, scaledSize, cursor, containerRef]);
|
||||
|
||||
if (!isActive || !display || !preview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
left: `${display.left}px`,
|
||||
top: `${display.top}px`,
|
||||
width: `${display.width}px`,
|
||||
height: `${display.height}px`,
|
||||
backgroundImage: `url(${display.dataUrl})`,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
boxShadow: '0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)',
|
||||
borderRadius: '4px',
|
||||
transition: 'transform 70ms ease-out',
|
||||
transform: 'translateZ(0)',
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -243,4 +243,4 @@ export function ZoomAPIBridge() {
|
||||
}, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -21,4 +21,5 @@ export interface HistoryAPI {
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
subscribe?: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
// Whether signatures have been applied (allows export)
|
||||
signaturesApplied: boolean;
|
||||
// Size (in screen units) we want newly placed signatures to use
|
||||
placementPreviewSize: { width: number; height: number } | null;
|
||||
}
|
||||
|
||||
// Signature actions interface
|
||||
@ -26,6 +28,7 @@ interface SignatureActions {
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
setSignaturesApplied: (applied: boolean) => void;
|
||||
setPlacementPreviewSize: (size: { width: number; height: number } | null) => void;
|
||||
}
|
||||
|
||||
// Combined context interface
|
||||
@ -42,6 +45,7 @@ const initialState: SignatureState = {
|
||||
signatureConfig: null,
|
||||
isPlacementMode: false,
|
||||
signaturesApplied: true, // Start as true (no signatures placed yet)
|
||||
placementPreviewSize: null,
|
||||
};
|
||||
|
||||
// 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
|
||||
|
||||
const contextValue: SignatureContextValue = {
|
||||
@ -149,6 +174,7 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
storeImageData,
|
||||
getImageData,
|
||||
setSignaturesApplied,
|
||||
setPlacementPreviewSize,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -23,7 +23,7 @@ export const buildSignFormData = (params: SignParameters, file: File): FormData
|
||||
}
|
||||
|
||||
// Add signature type
|
||||
formData.append('signatureType', params.signatureType || 'draw');
|
||||
formData.append('signatureType', params.signatureType || 'canvas');
|
||||
|
||||
// Add other parameters
|
||||
if (params.reason) {
|
||||
@ -56,4 +56,4 @@ export const useSignOperation = (): ToolOperationHook<SignParameters> => {
|
||||
...signOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('sign.error.failed', 'An error occurred while signing the PDF.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@ -9,7 +9,7 @@ export interface SignaturePosition {
|
||||
}
|
||||
|
||||
export interface SignParameters {
|
||||
signatureType: 'image' | 'text' | 'draw' | 'canvas';
|
||||
signatureType: 'image' | 'text' | 'canvas';
|
||||
signatureData?: string; // Base64 encoded image or text content
|
||||
signaturePosition?: SignaturePosition;
|
||||
reason?: string;
|
||||
@ -60,4 +60,4 @@ export const useSignParameters = () => {
|
||||
endpointName: 'add-signature',
|
||||
validateFn: validateSignParameters,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@ -87,7 +87,7 @@ export function useTooltipPosition({
|
||||
if (sidebarTooltip) {
|
||||
// Require sidebar refs and state for proper positioning
|
||||
if (!sidebarRefs || !sidebarState) {
|
||||
console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props');
|
||||
console.warn('Sidebar tooltip requires sidebarRefs and sidebarState props');
|
||||
setPositionReady(false);
|
||||
return;
|
||||
}
|
||||
@ -97,7 +97,7 @@ export function useTooltipPosition({
|
||||
|
||||
// Only show tooltip if we have the tool panel active
|
||||
if (!sidebarInfo.isToolPanelActive) {
|
||||
console.log('🚫 Not showing tooltip - tool panel not active');
|
||||
console.log('Not showing tooltip - tool panel not active');
|
||||
setPositionReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -115,6 +115,29 @@ const Sign = (props: BaseToolProps) => {
|
||||
// Deactivate signature placement mode after everything completes
|
||||
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
|
||||
} else {
|
||||
console.error('Signature flattening failed');
|
||||
@ -122,7 +145,7 @@ const Sign = (props: BaseToolProps) => {
|
||||
} catch (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 steps = [];
|
||||
@ -179,4 +202,4 @@ Sign.getDefaultParameters = () => ({
|
||||
signerName: '',
|
||||
});
|
||||
|
||||
export default Sign as ToolComponent;
|
||||
export default Sign as ToolComponent;
|
||||
|
||||
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