Feature/v2/add text (#4951)

Refactor sign to separate out add text and add image functions. 
Implement add text as standalone tool
This commit is contained in:
Reece Browne
2025-11-24 13:37:35 +00:00
committed by GitHub
parent e8e98128d2
commit 30bcc38c04
15 changed files with 780 additions and 326 deletions
@@ -206,7 +206,6 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
if (!initialSignatureData) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
setSavedSignatureData(null);
return;
}
@@ -15,6 +15,7 @@ interface TextInputWithFontProps {
disabled?: boolean;
label?: string;
placeholder?: string;
onAnyChange?: () => void;
}
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
@@ -28,7 +29,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
onTextColorChange,
disabled = false,
label,
placeholder
placeholder,
onAnyChange
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
@@ -67,7 +69,10 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
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)}
onChange={(e) => {
onTextChange(e.target.value);
onAnyChange?.();
}}
disabled={disabled}
required
/>
@@ -76,7 +81,10 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
<Select
label={t('sign.text.fontLabel', 'Font')}
value={fontFamily}
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
onChange={(value) => {
onFontFamilyChange(value || 'Helvetica');
onAnyChange?.();
}}
data={fontOptions}
disabled={disabled}
searchable
@@ -110,6 +118,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 200) {
onFontSizeChange(size);
onAnyChange?.();
}
fontSizeCombobox.openDropdown();
@@ -157,6 +166,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
// Update color if valid hex
if (isValidHexColor(value)) {
onTextColorChange(value);
onAnyChange?.();
}
}}
onBlur={() => {
@@ -190,7 +200,10 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={textColor}
onColorChange={onTextColorChange}
onColorChange={(color) => {
onTextColorChange(color);
onAnyChange?.();
}}
/>
)}
</Stack>
@@ -11,6 +11,7 @@ interface SavedSignaturesSectionProps {
onUseSignature: (signature: SavedSignature) => void;
onDeleteSignature: (signature: SavedSignature) => void;
onRenameSignature: (id: string, label: string) => void;
translationScope?: string;
}
const typeBadgeColor: Record<SavedSignatureType, string> = {
@@ -26,11 +27,18 @@ export const SavedSignaturesSection = ({
onUseSignature,
onDeleteSignature,
onRenameSignature,
translationScope = 'sign',
}: SavedSignaturesSectionProps) => {
const { t } = useTranslation();
const translate = useCallback(
(key: string, defaultValue: string, options?: Record<string, unknown>) =>
t(`${translationScope}.${key}`, { defaultValue, ...options }),
[t, translationScope]
);
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
const [activeIndex, setActiveIndex] = useState(0);
const activeSignature = signatures[activeIndex];
const activeSignatureRef = useRef<SavedSignature | null>(activeSignature ?? null);
const appliedSignatureIdRef = useRef<string | null>(null);
const onUseSignatureRef = useRef(onUseSignature);
@@ -38,6 +46,10 @@ export const SavedSignaturesSection = ({
onUseSignatureRef.current = onUseSignature;
}, [onUseSignature]);
useEffect(() => {
activeSignatureRef.current = activeSignature ?? null;
}, [activeSignature]);
useEffect(() => {
setLabelDrafts(prev => {
const nextDrafts: Record<string, string> = {};
@@ -132,10 +144,10 @@ export const SavedSignaturesSection = ({
const emptyState = (
<Card withBorder>
<Stack gap="xs">
<Text fw={500}>{t('sign.saved.emptyTitle', 'No saved signatures yet')}</Text>
<Text fw={500}>{translate('saved.emptyTitle', 'No saved signatures yet')}</Text>
<Text size="sm" c="dimmed">
{t(
'sign.saved.emptyDescription',
{translate(
'saved.emptyDescription',
'Draw, upload, or type a signature above, then use "Save to library" to keep up to {{max}} favourites ready to use.',
{ max: MAX_SAVED_SIGNATURES }
)}
@@ -147,11 +159,11 @@ export const SavedSignaturesSection = ({
const typeLabel = (type: SavedSignatureType) => {
switch (type) {
case 'canvas':
return t('sign.saved.type.canvas', 'Drawing');
return translate('saved.type.canvas', 'Drawing');
case 'image':
return t('sign.saved.type.image', 'Upload');
return translate('saved.type.image', 'Upload');
case 'text':
return t('sign.saved.type.text', 'Text');
return translate('saved.type.text', 'Text');
default:
return type;
}
@@ -182,36 +194,37 @@ export const SavedSignaturesSection = ({
};
useEffect(() => {
if (!activeSignature || disabled) {
const signature = activeSignatureRef.current;
if (!signature || disabled) {
appliedSignatureIdRef.current = null;
return;
}
if (appliedSignatureIdRef.current === activeSignature.id) {
if (appliedSignatureIdRef.current === signature.id) {
return;
}
appliedSignatureIdRef.current = activeSignature.id;
onUseSignatureRef.current(activeSignature);
}, [activeSignature, disabled]);
appliedSignatureIdRef.current = signature.id;
onUseSignatureRef.current(signature);
}, [activeSignature?.id, disabled]);
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Stack gap={0}>
<Text fw={600} size="md">
{t('sign.saved.heading', 'Saved signatures')}
{translate('saved.heading', 'Saved signatures')}
</Text>
<Text size="sm" c="dimmed">
{t('sign.saved.description', 'Reuse saved signatures at any time.')}
{translate('saved.description', 'Reuse saved signatures at any time.')}
</Text>
</Stack>
</Group>
{isAtCapacity && (
<Alert color="yellow" title={t('sign.saved.limitTitle', 'Limit reached')}>
<Alert color="yellow" title={translate('saved.limitTitle', 'Limit reached')}>
<Text size="sm">
{t('sign.saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
{translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
max: MAX_SAVED_SIGNATURES,
})}
</Text>
@@ -224,7 +237,7 @@ export const SavedSignaturesSection = ({
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{t('sign.saved.carouselPosition', '{{current}} of {{total}}', {
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeIndex + 1,
total: signatures.length,
})}
@@ -232,7 +245,7 @@ export const SavedSignaturesSection = ({
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={t('sign.saved.prev', 'Previous')}
aria-label={translate('saved.prev', 'Previous')}
onClick={() => handleNavigate('prev')}
disabled={disabled || activeIndex === 0}
>
@@ -240,7 +253,7 @@ export const SavedSignaturesSection = ({
</ActionIcon>
<ActionIcon
variant="light"
aria-label={t('sign.saved.next', 'Next')}
aria-label={translate('saved.next', 'Next')}
onClick={() => handleNavigate('next')}
disabled={disabled || activeIndex >= signatures.length - 1}
>
@@ -256,11 +269,11 @@ export const SavedSignaturesSection = ({
<Badge color={typeBadgeColor[activeSignature.type]} variant="light">
{typeLabel(activeSignature.type)}
</Badge>
<Tooltip label={t('sign.saved.delete', 'Remove')}>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t('sign.saved.delete', 'Remove')}
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeSignature)}
disabled={disabled}
>
@@ -272,7 +285,7 @@ export const SavedSignaturesSection = ({
{renderPreview(activeSignature)}
<TextInput
label={t('sign.saved.label', 'Label')}
label={translate('saved.label', 'Label')}
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
onChange={event => handleLabelChange(event, activeSignature)}
onBlur={() => handleLabelBlur(activeSignature)}
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from "react-i18next";
import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } from '@mantine/core';
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
@@ -39,9 +39,14 @@ interface SignSettingsProps {
onUndo?: () => void;
onRedo?: () => void;
onSave?: () => void;
translationScope?: string;
allowedSignatureSources?: SignatureSource[];
defaultSignatureSource?: SignatureSource;
}
type SignatureSource = 'canvas' | 'image' | 'text' | 'saved';
export type SignatureSource = 'canvas' | 'image' | 'text' | 'saved';
const DEFAULT_SIGNATURE_SOURCES: SignatureSource[] = ['canvas', 'image', 'text', 'saved'];
const SignSettings = ({
parameters,
@@ -52,13 +57,26 @@ const SignSettings = ({
onUpdateDrawSettings,
onUndo,
onRedo,
onSave
onSave,
translationScope = 'sign',
allowedSignatureSources = DEFAULT_SIGNATURE_SOURCES,
defaultSignatureSource,
}: SignSettingsProps) => {
const { t } = useTranslation();
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
const { activeFileIndex } = useViewer();
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const historyApiInstance = historyApiRef.current;
const translate = useCallback(
(key: string, defaultValue: string, options?: Record<string, unknown>) =>
t(`${translationScope}.${key}`, { defaultValue, ...options }),
[t, translationScope]
);
const effectiveDefaultSource =
(defaultSignatureSource && allowedSignatureSources.includes(defaultSignatureSource)
? defaultSignatureSource
: allowedSignatureSources[0]) ?? 'text';
const canUseSavedLibrary = allowedSignatureSources.includes('saved');
// State for drawing
const [selectedColor, setSelectedColor] = useState('#000000');
@@ -82,7 +100,13 @@ const SignSettings = ({
updateSignatureLabel,
byTypeCounts,
} = useSavedSignatures();
const [signatureSource, setSignatureSource] = useState<SignatureSource>(parameters.signatureType);
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
const paramSource = parameters.signatureType as SignatureSource;
if (allowedSignatureSources.includes(paramSource)) {
return paramSource;
}
return effectiveDefaultSource;
});
const [lastSavedSignatureKeys, setLastSavedSignatureKeys] = useState<Record<SavedSignatureType, string | null>>({
canvas: null,
image: null,
@@ -103,17 +127,17 @@ const SignSettings = ({
const getDefaultSavedLabel = useCallback(
(type: SavedSignatureType) => {
const nextIndex = (byTypeCounts[type] ?? 0) + 1;
let baseLabel = t('sign.saved.defaultLabel', 'Signature');
let baseLabel = translate('saved.defaultLabel', 'Signature');
if (type === 'canvas') {
baseLabel = t('sign.saved.defaultCanvasLabel', 'Drawing signature');
baseLabel = translate('saved.defaultCanvasLabel', 'Drawing signature');
} else if (type === 'image') {
baseLabel = t('sign.saved.defaultImageLabel', 'Uploaded signature');
baseLabel = translate('saved.defaultImageLabel', 'Uploaded signature');
} else if (type === 'text') {
baseLabel = t('sign.saved.defaultTextLabel', 'Typed signature');
baseLabel = translate('saved.defaultTextLabel', 'Typed signature');
}
return `${baseLabel} ${nextIndex}`;
},
[byTypeCounts, t]
[byTypeCounts, t, translate]
);
const signatureKeysByType = useMemo(() => {
@@ -263,7 +287,10 @@ const SignSettings = ({
);
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
const label = t('sign.saved.saveButton', 'Save signature');
if (!canUseSavedLibrary) {
return null;
}
const label = translate('saved.saveButton', 'Save signature');
const currentKey = signatureKeysByType[type];
const lastSavedKey = lastSavedSignatureKeys[type];
const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey);
@@ -271,11 +298,11 @@ const SignSettings = ({
let tooltipMessage: string | undefined;
if (!isReady) {
tooltipMessage = t('sign.saved.saveUnavailable', 'Create a signature first to save it.');
tooltipMessage = translate('saved.saveUnavailable', 'Create a signature first to save it.');
} else if (isSaved) {
tooltipMessage = t('sign.saved.noChanges', 'Current signature is already saved.');
tooltipMessage = translate('saved.noChanges', 'Current signature is already saved.');
} else if (isSavedSignatureLimitReached) {
tooltipMessage = t('sign.saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
tooltipMessage = translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
max: MAX_SAVED_SIGNATURES,
});
}
@@ -289,7 +316,7 @@ const SignSettings = ({
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
leftSection={<LocalIcon icon="material-symbols:save-rounded" width={16} height={16} />}
>
{isSaved ? t('sign.saved.status.saved', 'Saved') : label}
{isSaved ? translate('saved.status.saved', 'Saved') : label}
</Button>
);
@@ -304,14 +331,33 @@ const SignSettings = ({
return button;
};
const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
const button = renderSaveButton(type, isReady, onClick);
if (!button) {
return null;
}
return (
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{button}
</Box>
);
};
useEffect(() => {
if (signatureSource === 'saved' && !canUseSavedLibrary) {
setSignatureSource(effectiveDefaultSource);
return;
}
if (signatureSource === 'saved') {
return;
}
if (signatureSource !== parameters.signatureType) {
setSignatureSource(parameters.signatureType);
const nextSource = allowedSignatureSources.includes(parameters.signatureType as SignatureSource)
? (parameters.signatureType as SignatureSource)
: effectiveDefaultSource;
if (signatureSource !== nextSource) {
setSignatureSource(nextSource);
}
}, [parameters.signatureType, signatureSource]);
}, [parameters.signatureType, signatureSource, allowedSignatureSources, effectiveDefaultSource, canUseSavedLibrary]);
useEffect(() => {
if (!disabled) {
@@ -376,26 +422,47 @@ const SignSettings = ({
reader.readAsDataURL(file);
});
// Reset pause state and directly activate placement
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;
setImageSignatureData(result);
// Directly activate placement on image upload
if (typeof window !== 'undefined') {
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
} else {
onActivateSignaturePlacement?.();
}
} catch (error) {
console.error('Error reading file:', error);
}
} else if (!file) {
setImageSignatureData(undefined);
onDeactivateSignature?.();
setImageSignatureData(undefined);
onDeactivateSignature?.();
}
};
// Handle signature data changes
const handleCanvasSignatureChange = (data: string | null) => {
const handleCanvasSignatureChange = useCallback((data: string | null) => {
const nextValue = data ?? undefined;
setCanvasSignatureData(prev => {
if (prev === nextValue) {
return prev;
setCanvasSignatureData(prevData => {
// Reset pause state and trigger placement for signature changes
// (onDrawingComplete handles initial activation)
if (prevData && prevData !== nextValue && nextValue) {
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;
// Directly activate placement on signature change
if (typeof window !== 'undefined') {
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
} else {
onActivateSignaturePlacement?.();
}
}
return nextValue;
});
};
}, [onActivateSignaturePlacement]);
const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]);
const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]);
@@ -588,9 +655,7 @@ const SignSettings = ({
const timer = window.setTimeout(() => {
onActivateSignaturePlacement?.();
}, PLACEMENT_ACTIVATION_DELAY);
return () => {
window.clearTimeout(timer);
};
return () => window.clearTimeout(timer);
}
onActivateSignaturePlacement?.();
@@ -601,6 +666,7 @@ const SignSettings = ({
isPlacementManuallyPaused,
onActivateSignaturePlacement,
onDeactivateSignature,
placementSignatureKey,
]);
useEffect(() => {
@@ -627,9 +693,7 @@ const SignSettings = ({
if (typeof window !== 'undefined') {
const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY);
return () => {
window.clearTimeout(timer);
};
return () => window.clearTimeout(timer);
}
trigger();
@@ -652,16 +716,29 @@ const SignSettings = ({
const timer = window.setTimeout(() => {
onActivateSignaturePlacement?.();
}, FILE_SWITCH_ACTIVATION_DELAY);
return () => {
window.clearTimeout(timer);
};
return () => window.clearTimeout(timer);
}
onActivateSignaturePlacement?.();
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
const sourceLabels: Record<SignatureSource, string> = {
canvas: translate('type.canvas', 'Draw'),
image: translate('type.image', 'Upload'),
text: translate('type.text', 'Type'),
saved: translate('type.saved', 'Saved'),
};
const sourceOptions = allowedSignatureSources.map(source => ({
label: sourceLabels[source],
value: source,
}));
const renderSignatureBuilder = () => {
if (signatureSource === 'saved') {
if (!canUseSavedLibrary) {
return null;
}
return (
<SavedSignaturesSection
signatures={savedSignatures}
@@ -670,6 +747,7 @@ const SignSettings = ({
onUseSignature={handleUseSavedSignature}
onDeleteSignature={handleDeleteSavedSignature}
onRenameSignature={handleRenameSavedSignature}
translationScope={translationScope}
/>
);
}
@@ -691,9 +769,7 @@ const SignSettings = ({
disabled={disabled}
initialSignatureData={canvasSignatureData}
/>
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{renderSaveButton('canvas', hasCanvasSignature, handleSaveCanvasSignature)}
</Box>
{renderSaveButtonRow('canvas', hasCanvasSignature, handleSaveCanvasSignature)}
</Stack>
);
}
@@ -705,9 +781,7 @@ const SignSettings = ({
onImageChange={handleImageChange}
disabled={disabled}
/>
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{renderSaveButton('image', hasImageSignature, handleSaveImageSignature)}
</Box>
{renderSaveButtonRow('image', hasImageSignature, handleSaveImageSignature)}
</Stack>
);
}
@@ -724,35 +798,43 @@ const SignSettings = ({
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
onAnyChange={() => {
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;
// Directly activate placement on text changes
if (typeof window !== 'undefined') {
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
} else {
onActivateSignaturePlacement?.();
}
}}
/>
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{renderSaveButton('text', hasTextSignature, handleSaveTextSignature)}
</Box>
{renderSaveButtonRow('text', hasTextSignature, handleSaveTextSignature)}
</Stack>
);
};
const placementInstructions = () => {
if (signatureSource === 'saved') {
return t(
'sign.instructions.saved',
return translate(
'instructions.saved',
'Select a saved signature above, then click anywhere on the PDF to place it.'
);
}
if (parameters.signatureType === 'canvas') {
return t(
'sign.instructions.canvas',
return translate(
'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',
return translate(
'instructions.image',
'After uploading your signature image, click anywhere on the PDF to place it.'
);
}
return t(
'sign.instructions.text',
return translate(
'instructions.text',
'After entering your name above, click anywhere on the PDF to place your signature.'
);
};
@@ -761,16 +843,16 @@ const SignSettings = ({
? {
color: isPlacementMode ? 'blue' : 'teal',
title: isPlacementMode
? t('sign.instructions.title', 'How to add your signature')
: t('sign.instructions.paused', 'Placement paused'),
? translate('instructions.title', 'How to add your signature')
: translate('instructions.paused', 'Placement paused'),
message: isPlacementMode
? placementInstructions()
: t('sign.instructions.resumeHint', 'Resume placement to click and add your signature.'),
: translate('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.'),
title: translate('instructions.title', 'How to add your signature'),
message: translate('instructions.noSignature', 'Create a signature above to enable placement tools.'),
};
const handlePausePlacement = () => {
@@ -806,11 +888,11 @@ const SignSettings = ({
onActivateSignaturePlacement || onDeactivateSignature
? isPlacementMode
? (
<Tooltip label={t('sign.mode.pause', 'Pause placement')}>
<Tooltip label={translate('mode.pause', 'Pause placement')}>
<ActionIcon
variant="default"
size="lg"
aria-label={t('sign.mode.pause', 'Pause placement')}
aria-label={translate('mode.pause', 'Pause placement')}
onClick={handlePausePlacement}
disabled={disabled || !onDeactivateSignature}
style={{
@@ -823,17 +905,17 @@ const SignSettings = ({
>
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{t('sign.mode.pause', 'Pause placement')}
{translate('mode.pause', 'Pause placement')}
</Text>
</ActionIcon>
</Tooltip>
)
: (
<Tooltip label={t('sign.mode.resume', 'Resume placement')}>
<Tooltip label={translate('mode.resume', 'Resume placement')}>
<ActionIcon
variant="default"
size="lg"
aria-label={t('sign.mode.resume', 'Resume placement')}
aria-label={translate('mode.resume', 'Resume placement')}
onClick={handleResumePlacement}
disabled={disabled || !isCurrentTypeReady || !onActivateSignaturePlacement}
style={{
@@ -846,7 +928,7 @@ const SignSettings = ({
>
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{t('sign.mode.resume', 'Resume placement')}
{translate('mode.resume', 'Resume placement')}
</Text>
</ActionIcon>
</Tooltip>
@@ -857,19 +939,16 @@ const SignSettings = ({
<Stack>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t('sign.step.createDesc', 'Choose how you want to create the signature')}
{translate('step.createDesc', 'Choose how you want to create the signature')}
</Text>
<SegmentedControl
value={signatureSource}
fullWidth
onChange={(value) => handleSignatureSourceChange(value as SignatureSource)}
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' },
{ label: t('sign.type.saved', 'Saved'), value: 'saved' },
]}
/>
{sourceOptions.length > 1 && (
<SegmentedControl
value={signatureSource}
fullWidth
onChange={(value) => handleSignatureSourceChange(value as SignatureSource)}
data={sourceOptions}
/>
)}
{renderSignatureBuilder()}
</Stack>
@@ -877,10 +956,10 @@ const SignSettings = ({
<Stack gap="sm">
<Text fw={600} size="md">
{t('sign.step.place', 'Place & save')}
{translate('step.place', 'Place & save')}
</Text>
<Text size="sm" c="dimmed">
{t('sign.step.placeDesc', 'Position the signature on your PDF')}
{translate('step.placeDesc', 'Position the signature on your PDF')}
</Text>
<Group gap="xs" wrap="nowrap" align="center">
@@ -911,7 +990,7 @@ const SignSettings = ({
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')}
title={translate('canvas.colorPickerTitle', 'Choose stroke colour')}
/>
{onSave && (
@@ -921,7 +1000,7 @@ const SignSettings = ({
variant="filled"
fullWidth
>
{t('sign.applySignatures', 'Apply Signatures')}
{translate('applySignatures', 'Apply Signatures')}
</Button>
)}
@@ -15,7 +15,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { isStirlingFile } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay';
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
import { useWheelZoom } from '@app/hooks/useWheelZoom';
export interface EmbedPdfViewerProps {
@@ -82,7 +82,8 @@ const EmbedPdfViewerContent = ({
// Check if we're in signature mode OR viewer annotation mode
const { selectedTool } = useNavigationState();
const isSignatureMode = selectedTool === 'sign';
// Tools that use the stamp/signature placement system with hover preview
const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText';
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
@@ -328,7 +329,7 @@ const EmbedPdfViewerContent = ({
// Future: Handle signature completion
}}
/>
<SignaturePlacementOverlay
<StampPlacementOverlay
containerRef={pdfContainerRef}
isActive={isPlacementOverlayActive}
signatureConfig={signatureConfig}
@@ -14,14 +14,6 @@ const MIN_SIGNATURE_DIMENSION = 12;
// This provides a good balance between visual fidelity and performance/memory usage.
const TEXT_OVERSAMPLE_FACTOR = 2;
type TextStampImageResult = {
dataUrl: string;
pixelWidth: number;
pixelHeight: number;
displayWidth: number;
displayHeight: number;
};
const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new Set()): string | undefined => {
if (!value || depth > 6) return undefined;
@@ -56,7 +48,7 @@ const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new S
const createTextStampImage = (
config: SignParameters,
displaySize?: { width: number; height: number } | null
): TextStampImageResult | null => {
): { dataUrl: string; pixelWidth: number; pixelHeight: number; displayWidth: number; displayHeight: number } | null => {
const text = (config.signerName ?? '').trim();
if (!text) {
return null;
@@ -207,7 +199,6 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
}
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
// Enable keyboard deletion of selected annotations
useEffect(() => {
// Always enable delete key when we have annotation API and are in sign mode
@@ -436,6 +427,60 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
};
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
useEffect(() => {
if (!annotationApi?.onAnnotationEvent) {
return;
}
const unsubscribe = annotationApi.onAnnotationEvent(event => {
if (event.type !== 'create' && event.type !== 'update') {
return;
}
const annotation: any = event.annotation;
const annotationId: string | undefined = annotation?.id;
if (!annotationId) {
return;
}
const directData =
extractDataUrl(annotation.imageSrc) ||
extractDataUrl(annotation.imageData) ||
extractDataUrl(annotation.appearance) ||
extractDataUrl(annotation.stampData) ||
extractDataUrl(annotation.contents) ||
extractDataUrl(annotation.data) ||
extractDataUrl(annotation.customData) ||
extractDataUrl(annotation.asset);
const dataToStore = directData || lastStampImageRef.current;
if (dataToStore) {
storeImageData(annotationId, dataToStore);
}
});
return () => {
unsubscribe?.();
};
}, [annotationApi, storeImageData]);
useEffect(() => {
if (!isPlacementMode) {
return;
}
let cancelled = false;
configureStampDefaults().catch((error) => {
if (!cancelled) {
console.error('Error updating signature defaults:', error);
}
});
return () => {
cancelled = true;
};
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
return null; // This is a bridge component with no UI
});
@@ -0,0 +1,171 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box } from '@mantine/core';
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePreview';
import { useSignature } from '@app/contexts/SignatureContext';
import {
MAX_PREVIEW_WIDTH_RATIO,
MAX_PREVIEW_HEIGHT_RATIO,
MAX_PREVIEW_WIDTH_REM,
MAX_PREVIEW_HEIGHT_REM,
MIN_SIGNATURE_DIMENSION_REM,
OVERLAY_EDGE_PADDING_REM,
} from '@app/constants/signConstants';
// Convert rem to pixels using browser's base font size (typically 16px)
const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
interface StampPlacementOverlayProps {
containerRef: React.RefObject<HTMLElement | null>;
isActive: boolean;
signatureConfig: SignParameters | null;
}
export const StampPlacementOverlay: React.FC<StampPlacementOverlayProps> = ({
containerRef,
isActive,
signatureConfig,
}) => {
const [preview, setPreview] = useState<SignaturePreview | null>(null);
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
const { setPlacementPreviewSize } = useSignature();
useEffect(() => {
let cancelled = false;
const buildPreview = async () => {
try {
const value = await buildSignaturePreview(signatureConfig ?? null);
if (!cancelled) {
setPreview(value);
}
} catch (error) {
console.error('Failed to build signature preview:', error);
if (!cancelled) {
setPreview(null);
}
}
};
buildPreview();
return () => {
cancelled = true;
};
}, [signatureConfig]);
useEffect(() => {
const element = containerRef.current;
if (!isActive || !element) {
setCursor(null);
return;
}
const handleMove = (event: MouseEvent) => {
const rect = element.getBoundingClientRect();
setCursor({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
};
const handleLeave = () => setCursor(null);
element.addEventListener('mousemove', handleMove);
element.addEventListener('mouseleave', handleLeave);
return () => {
element.removeEventListener('mousemove', handleMove);
element.removeEventListener('mouseleave', handleLeave);
};
}, [containerRef, isActive]);
const scaledSize = useMemo(() => {
if (!preview || !containerRef.current) {
return null;
}
const container = containerRef.current;
const containerWidth = container.clientWidth || 1;
const containerHeight = container.clientHeight || 1;
const maxWidth = Math.min(containerWidth * MAX_PREVIEW_WIDTH_RATIO, remToPx(MAX_PREVIEW_WIDTH_REM));
const maxHeight = Math.min(containerHeight * MAX_PREVIEW_HEIGHT_RATIO, remToPx(MAX_PREVIEW_HEIGHT_REM));
const scale = Math.min(
1,
maxWidth / Math.max(preview.width, 1),
maxHeight / Math.max(preview.height, 1)
);
return {
width: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.width * scale),
height: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.height * scale),
};
}, [preview, containerRef]);
useEffect(() => {
if (!isActive || !scaledSize) {
setPlacementPreviewSize(null);
} else {
setPlacementPreviewSize(scaledSize);
}
}, [isActive, scaledSize, setPlacementPreviewSize]);
useEffect(() => {
return () => {
setPlacementPreviewSize(null);
};
}, [setPlacementPreviewSize]);
const display = useMemo(() => {
if (!preview || !scaledSize || !cursor || !containerRef.current) {
return null;
}
const container = containerRef.current;
const containerWidth = container.clientWidth || 1;
const containerHeight = container.clientHeight || 1;
const width = scaledSize.width;
const height = scaledSize.height;
const edgePadding = remToPx(OVERLAY_EDGE_PADDING_REM);
const clampedLeft = Math.max(edgePadding, Math.min(cursor.x - width / 2, containerWidth - width - edgePadding));
const clampedTop = Math.max(edgePadding, Math.min(cursor.y - height / 2, containerHeight - height - edgePadding));
return {
left: clampedLeft,
top: clampedTop,
width,
height,
dataUrl: preview.dataUrl,
};
}, [preview, scaledSize, cursor, containerRef]);
if (!isActive || !display || !preview) {
return null;
}
return (
<Box
style={{
position: 'absolute',
pointerEvents: 'none',
left: `${display.left}px`,
top: `${display.top}px`,
width: `${display.width}px`,
height: `${display.height}px`,
backgroundImage: `url(${display.dataUrl})`,
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
boxShadow: '0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)',
borderRadius: '4px',
transition: 'transform 70ms ease-out',
transform: 'translateZ(0)',
opacity: 0.6,
}}
/>
);
};