This commit is contained in:
Reece Browne 2025-11-14 14:52:42 +00:00 committed by GitHub
commit 5b4faceaf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1401 additions and 361 deletions

View File

@ -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": {

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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>
); );

View File

@ -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>
</> </>
)} )}

View File

@ -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

View File

@ -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

View File

@ -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,
}}
/>
);
};

View File

@ -21,4 +21,5 @@ export interface HistoryAPI {
redo: () => void; redo: () => void;
canUndo: () => boolean; canUndo: () => boolean;
canRedo: () => boolean; canRedo: () => boolean;
subscribe?: (listener: () => void) => () => void;
} }

View 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;

View File

@ -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 (

View File

@ -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) {

View File

@ -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;

View File

@ -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;
} }

View File

@ -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 = [];

View 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,
};
};