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
+54 -5
View File
@@ -855,6 +855,11 @@
"overlay-pdfs": { "overlay-pdfs": {
"desc": "Overlay one PDF on top of another", "desc": "Overlay one PDF on top of another",
"title": "Overlay PDFs" "title": "Overlay PDFs"
},
"addText": {
"tags": "text,annotation,label",
"title": "Add Text",
"desc": "Add custom text anywhere in your PDF"
} }
}, },
"landing": { "landing": {
@@ -2152,8 +2157,8 @@
"colorPickerTitle": "Choose stroke colour" "colorPickerTitle": "Choose stroke colour"
}, },
"text": { "text": {
"name": "Signer Name", "name": "Text",
"placeholder": "Enter your full name", "placeholder": "Enter text",
"fontLabel": "Font", "fontLabel": "Font",
"fontSizeLabel": "Font size", "fontSizeLabel": "Font size",
"fontSizePlaceholder": "Type or select font size (8-200)", "fontSizePlaceholder": "Type or select font size (8-200)",
@@ -2421,8 +2426,12 @@
"selection": { "selection": {
"originalEditedTitle": "Select Original and Edited PDFs" "originalEditedTitle": "Select Original and Edited PDFs"
}, },
"original": { "label": "Original PDF" }, "original": {
"edited": { "label": "Edited PDF" }, "label": "Original PDF"
},
"edited": {
"label": "Edited PDF"
},
"swap": { "swap": {
"confirmTitle": "Re-run comparison?", "confirmTitle": "Re-run comparison?",
"confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?", "confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?",
@@ -5751,5 +5760,45 @@
"pleaseLoginAgain": "Please login again.", "pleaseLoginAgain": "Please login again.",
"accessDenied": "Access Denied", "accessDenied": "Access Denied",
"insufficientPermissions": "You do not have permission to perform this action." "insufficientPermissions": "You do not have permission to perform this action."
},
"addText": {
"title": "Add Text",
"header": "Add text to PDFs",
"text": {
"name": "Text content",
"placeholder": "Enter the text you want to add",
"fontLabel": "Font",
"fontSizeLabel": "Font size",
"fontSizePlaceholder": "Type or select font size (8-200)",
"colorLabel": "Text colour"
},
"steps": {
"configure": "Configure Text"
},
"step": {
"createDesc": "Enter the text you want to add",
"place": "Place text",
"placeDesc": "Click on the PDF to add your text"
},
"instructions": {
"title": "How to add text",
"text": "After entering your text above, click anywhere on the PDF to place it.",
"paused": "Placement paused",
"resumeHint": "Resume placement to click and add your text.",
"noSignature": "Enter text above to enable placement."
},
"mode": {
"move": "Move Text",
"place": "Place Text",
"pause": "Pause placement",
"resume": "Resume placement"
},
"results": {
"title": "Add Text Results"
},
"error": {
"failed": "An error occurred while adding text to the PDF."
},
"tags": "text,annotation,label"
} }
} }
@@ -206,7 +206,6 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
if (!initialSignatureData) { if (!initialSignatureData) {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
setSavedSignatureData(null); setSavedSignatureData(null);
return; return;
} }
@@ -15,6 +15,7 @@ interface TextInputWithFontProps {
disabled?: boolean; disabled?: boolean;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
onAnyChange?: () => void;
} }
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
@@ -28,7 +29,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
onTextColorChange, onTextColorChange,
disabled = false, disabled = false,
label, label,
placeholder placeholder,
onAnyChange
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString()); const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
@@ -67,7 +69,10 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
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);
onAnyChange?.();
}}
disabled={disabled} disabled={disabled}
required required
/> />
@@ -76,7 +81,10 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
<Select <Select
label={t('sign.text.fontLabel', 'Font')} label={t('sign.text.fontLabel', 'Font')}
value={fontFamily} value={fontFamily}
onChange={(value) => onFontFamilyChange(value || 'Helvetica')} onChange={(value) => {
onFontFamilyChange(value || 'Helvetica');
onAnyChange?.();
}}
data={fontOptions} data={fontOptions}
disabled={disabled} disabled={disabled}
searchable searchable
@@ -110,6 +118,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
const size = parseInt(value); const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 200) { if (!isNaN(size) && size >= 8 && size <= 200) {
onFontSizeChange(size); onFontSizeChange(size);
onAnyChange?.();
} }
fontSizeCombobox.openDropdown(); fontSizeCombobox.openDropdown();
@@ -157,6 +166,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
// Update color if valid hex // Update color if valid hex
if (isValidHexColor(value)) { if (isValidHexColor(value)) {
onTextColorChange(value); onTextColorChange(value);
onAnyChange?.();
} }
}} }}
onBlur={() => { onBlur={() => {
@@ -190,7 +200,10 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
isOpen={isColorPickerOpen} isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)} onClose={() => setIsColorPickerOpen(false)}
selectedColor={textColor} selectedColor={textColor}
onColorChange={onTextColorChange} onColorChange={(color) => {
onTextColorChange(color);
onAnyChange?.();
}}
/> />
)} )}
</Stack> </Stack>
@@ -11,6 +11,7 @@ interface SavedSignaturesSectionProps {
onUseSignature: (signature: SavedSignature) => void; onUseSignature: (signature: SavedSignature) => void;
onDeleteSignature: (signature: SavedSignature) => void; onDeleteSignature: (signature: SavedSignature) => void;
onRenameSignature: (id: string, label: string) => void; onRenameSignature: (id: string, label: string) => void;
translationScope?: string;
} }
const typeBadgeColor: Record<SavedSignatureType, string> = { const typeBadgeColor: Record<SavedSignatureType, string> = {
@@ -26,11 +27,18 @@ export const SavedSignaturesSection = ({
onUseSignature, onUseSignature,
onDeleteSignature, onDeleteSignature,
onRenameSignature, onRenameSignature,
translationScope = 'sign',
}: SavedSignaturesSectionProps) => { }: SavedSignaturesSectionProps) => {
const { t } = useTranslation(); 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 [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const activeSignature = signatures[activeIndex]; const activeSignature = signatures[activeIndex];
const activeSignatureRef = useRef<SavedSignature | null>(activeSignature ?? null);
const appliedSignatureIdRef = useRef<string | null>(null); const appliedSignatureIdRef = useRef<string | null>(null);
const onUseSignatureRef = useRef(onUseSignature); const onUseSignatureRef = useRef(onUseSignature);
@@ -38,6 +46,10 @@ export const SavedSignaturesSection = ({
onUseSignatureRef.current = onUseSignature; onUseSignatureRef.current = onUseSignature;
}, [onUseSignature]); }, [onUseSignature]);
useEffect(() => {
activeSignatureRef.current = activeSignature ?? null;
}, [activeSignature]);
useEffect(() => { useEffect(() => {
setLabelDrafts(prev => { setLabelDrafts(prev => {
const nextDrafts: Record<string, string> = {}; const nextDrafts: Record<string, string> = {};
@@ -132,10 +144,10 @@ export const SavedSignaturesSection = ({
const emptyState = ( const emptyState = (
<Card withBorder> <Card withBorder>
<Stack gap="xs"> <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"> <Text size="sm" c="dimmed">
{t( {translate(
'sign.saved.emptyDescription', 'saved.emptyDescription',
'Draw, upload, or type a signature above, then use "Save to library" to keep up to {{max}} favourites ready to use.', '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 } { max: MAX_SAVED_SIGNATURES }
)} )}
@@ -147,11 +159,11 @@ export const SavedSignaturesSection = ({
const typeLabel = (type: SavedSignatureType) => { const typeLabel = (type: SavedSignatureType) => {
switch (type) { switch (type) {
case 'canvas': case 'canvas':
return t('sign.saved.type.canvas', 'Drawing'); return translate('saved.type.canvas', 'Drawing');
case 'image': case 'image':
return t('sign.saved.type.image', 'Upload'); return translate('saved.type.image', 'Upload');
case 'text': case 'text':
return t('sign.saved.type.text', 'Text'); return translate('saved.type.text', 'Text');
default: default:
return type; return type;
} }
@@ -182,36 +194,37 @@ export const SavedSignaturesSection = ({
}; };
useEffect(() => { useEffect(() => {
if (!activeSignature || disabled) { const signature = activeSignatureRef.current;
if (!signature || disabled) {
appliedSignatureIdRef.current = null; appliedSignatureIdRef.current = null;
return; return;
} }
if (appliedSignatureIdRef.current === activeSignature.id) { if (appliedSignatureIdRef.current === signature.id) {
return; return;
} }
appliedSignatureIdRef.current = activeSignature.id; appliedSignatureIdRef.current = signature.id;
onUseSignatureRef.current(activeSignature); onUseSignatureRef.current(signature);
}, [activeSignature, disabled]); }, [activeSignature?.id, disabled]);
return ( return (
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between" align="flex-start"> <Group justify="space-between" align="flex-start">
<Stack gap={0}> <Stack gap={0}>
<Text fw={600} size="md"> <Text fw={600} size="md">
{t('sign.saved.heading', 'Saved signatures')} {translate('saved.heading', 'Saved signatures')}
</Text> </Text>
<Text size="sm" c="dimmed"> <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> </Text>
</Stack> </Stack>
</Group> </Group>
{isAtCapacity && ( {isAtCapacity && (
<Alert color="yellow" title={t('sign.saved.limitTitle', 'Limit reached')}> <Alert color="yellow" title={translate('saved.limitTitle', 'Limit reached')}>
<Text size="sm"> <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, max: MAX_SAVED_SIGNATURES,
})} })}
</Text> </Text>
@@ -224,7 +237,7 @@ export const SavedSignaturesSection = ({
<Stack gap="xs"> <Stack gap="xs">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t('sign.saved.carouselPosition', '{{current}} of {{total}}', { {translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeIndex + 1, current: activeIndex + 1,
total: signatures.length, total: signatures.length,
})} })}
@@ -232,7 +245,7 @@ export const SavedSignaturesSection = ({
<Group gap={4}> <Group gap={4}>
<ActionIcon <ActionIcon
variant="light" variant="light"
aria-label={t('sign.saved.prev', 'Previous')} aria-label={translate('saved.prev', 'Previous')}
onClick={() => handleNavigate('prev')} onClick={() => handleNavigate('prev')}
disabled={disabled || activeIndex === 0} disabled={disabled || activeIndex === 0}
> >
@@ -240,7 +253,7 @@ export const SavedSignaturesSection = ({
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
variant="light" variant="light"
aria-label={t('sign.saved.next', 'Next')} aria-label={translate('saved.next', 'Next')}
onClick={() => handleNavigate('next')} onClick={() => handleNavigate('next')}
disabled={disabled || activeIndex >= signatures.length - 1} disabled={disabled || activeIndex >= signatures.length - 1}
> >
@@ -256,11 +269,11 @@ export const SavedSignaturesSection = ({
<Badge color={typeBadgeColor[activeSignature.type]} variant="light"> <Badge color={typeBadgeColor[activeSignature.type]} variant="light">
{typeLabel(activeSignature.type)} {typeLabel(activeSignature.type)}
</Badge> </Badge>
<Tooltip label={t('sign.saved.delete', 'Remove')}> <Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="red" color="red"
aria-label={t('sign.saved.delete', 'Remove')} aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeSignature)} onClick={() => onDeleteSignature(activeSignature)}
disabled={disabled} disabled={disabled}
> >
@@ -272,7 +285,7 @@ export const SavedSignaturesSection = ({
{renderPreview(activeSignature)} {renderPreview(activeSignature)}
<TextInput <TextInput
label={t('sign.saved.label', 'Label')} label={translate('saved.label', 'Label')}
value={labelDrafts[activeSignature.id] ?? activeSignature.label} value={labelDrafts[activeSignature.id] ?? activeSignature.label}
onChange={event => handleLabelChange(event, activeSignature)} onChange={event => handleLabelChange(event, activeSignature)}
onBlur={() => handleLabelBlur(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 { useTranslation } from "react-i18next";
import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } 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";
@@ -39,9 +39,14 @@ interface SignSettingsProps {
onUndo?: () => void; onUndo?: () => void;
onRedo?: () => void; onRedo?: () => void;
onSave?: () => 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 = ({ const SignSettings = ({
parameters, parameters,
@@ -52,13 +57,26 @@ const SignSettings = ({
onUpdateDrawSettings, onUpdateDrawSettings,
onUndo, onUndo,
onRedo, onRedo,
onSave onSave,
translationScope = 'sign',
allowedSignatureSources = DEFAULT_SIGNATURE_SOURCES,
defaultSignatureSource,
}: SignSettingsProps) => { }: SignSettingsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature(); const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
const { activeFileIndex } = useViewer(); const { activeFileIndex } = useViewer();
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false }); const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const historyApiInstance = historyApiRef.current; 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 // State for drawing
const [selectedColor, setSelectedColor] = useState('#000000'); const [selectedColor, setSelectedColor] = useState('#000000');
@@ -82,7 +100,13 @@ const SignSettings = ({
updateSignatureLabel, updateSignatureLabel,
byTypeCounts, byTypeCounts,
} = useSavedSignatures(); } = 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>>({ const [lastSavedSignatureKeys, setLastSavedSignatureKeys] = useState<Record<SavedSignatureType, string | null>>({
canvas: null, canvas: null,
image: null, image: null,
@@ -103,17 +127,17 @@ const SignSettings = ({
const getDefaultSavedLabel = useCallback( const getDefaultSavedLabel = useCallback(
(type: SavedSignatureType) => { (type: SavedSignatureType) => {
const nextIndex = (byTypeCounts[type] ?? 0) + 1; const nextIndex = (byTypeCounts[type] ?? 0) + 1;
let baseLabel = t('sign.saved.defaultLabel', 'Signature'); let baseLabel = translate('saved.defaultLabel', 'Signature');
if (type === 'canvas') { if (type === 'canvas') {
baseLabel = t('sign.saved.defaultCanvasLabel', 'Drawing signature'); baseLabel = translate('saved.defaultCanvasLabel', 'Drawing signature');
} else if (type === 'image') { } else if (type === 'image') {
baseLabel = t('sign.saved.defaultImageLabel', 'Uploaded signature'); baseLabel = translate('saved.defaultImageLabel', 'Uploaded signature');
} else if (type === 'text') { } else if (type === 'text') {
baseLabel = t('sign.saved.defaultTextLabel', 'Typed signature'); baseLabel = translate('saved.defaultTextLabel', 'Typed signature');
} }
return `${baseLabel} ${nextIndex}`; return `${baseLabel} ${nextIndex}`;
}, },
[byTypeCounts, t] [byTypeCounts, t, translate]
); );
const signatureKeysByType = useMemo(() => { const signatureKeysByType = useMemo(() => {
@@ -263,7 +287,10 @@ const SignSettings = ({
); );
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => { 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 currentKey = signatureKeysByType[type];
const lastSavedKey = lastSavedSignatureKeys[type]; const lastSavedKey = lastSavedSignatureKeys[type];
const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey); const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey);
@@ -271,11 +298,11 @@ const SignSettings = ({
let tooltipMessage: string | undefined; let tooltipMessage: string | undefined;
if (!isReady) { 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) { } 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) { } 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, max: MAX_SAVED_SIGNATURES,
}); });
} }
@@ -289,7 +316,7 @@ const SignSettings = ({
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges} disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
leftSection={<LocalIcon icon="material-symbols:save-rounded" width={16} height={16} />} 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> </Button>
); );
@@ -304,14 +331,33 @@ const SignSettings = ({
return button; 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(() => { useEffect(() => {
if (signatureSource === 'saved' && !canUseSavedLibrary) {
setSignatureSource(effectiveDefaultSource);
return;
}
if (signatureSource === 'saved') { if (signatureSource === 'saved') {
return; return;
} }
if (signatureSource !== parameters.signatureType) { const nextSource = allowedSignatureSources.includes(parameters.signatureType as SignatureSource)
setSignatureSource(parameters.signatureType); ? (parameters.signatureType as SignatureSource)
: effectiveDefaultSource;
if (signatureSource !== nextSource) {
setSignatureSource(nextSource);
} }
}, [parameters.signatureType, signatureSource]); }, [parameters.signatureType, signatureSource, allowedSignatureSources, effectiveDefaultSource, canUseSavedLibrary]);
useEffect(() => { useEffect(() => {
if (!disabled) { if (!disabled) {
@@ -376,26 +422,47 @@ const SignSettings = ({
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
// Reset pause state and directly activate placement
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;
setImageSignatureData(result); setImageSignatureData(result);
// Directly activate placement on image upload
if (typeof window !== 'undefined') {
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
} else {
onActivateSignaturePlacement?.();
}
} catch (error) { } catch (error) {
console.error('Error reading file:', error); console.error('Error reading file:', error);
} }
} else if (!file) { } else if (!file) {
setImageSignatureData(undefined); setImageSignatureData(undefined);
onDeactivateSignature?.(); onDeactivateSignature?.();
setImageSignatureData(undefined);
onDeactivateSignature?.();
} }
}; };
// Handle signature data changes // Handle signature data changes
const handleCanvasSignatureChange = (data: string | null) => { const handleCanvasSignatureChange = useCallback((data: string | null) => {
const nextValue = data ?? undefined; const nextValue = data ?? undefined;
setCanvasSignatureData(prev => { setCanvasSignatureData(prevData => {
if (prev === nextValue) { // Reset pause state and trigger placement for signature changes
return prev; // (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; return nextValue;
}); });
}; }, [onActivateSignaturePlacement]);
const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]); const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]);
const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]); const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]);
@@ -588,9 +655,7 @@ const SignSettings = ({
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
onActivateSignaturePlacement?.(); onActivateSignaturePlacement?.();
}, PLACEMENT_ACTIVATION_DELAY); }, PLACEMENT_ACTIVATION_DELAY);
return () => { return () => window.clearTimeout(timer);
window.clearTimeout(timer);
};
} }
onActivateSignaturePlacement?.(); onActivateSignaturePlacement?.();
@@ -601,6 +666,7 @@ const SignSettings = ({
isPlacementManuallyPaused, isPlacementManuallyPaused,
onActivateSignaturePlacement, onActivateSignaturePlacement,
onDeactivateSignature, onDeactivateSignature,
placementSignatureKey,
]); ]);
useEffect(() => { useEffect(() => {
@@ -627,9 +693,7 @@ const SignSettings = ({
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY); const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY);
return () => { return () => window.clearTimeout(timer);
window.clearTimeout(timer);
};
} }
trigger(); trigger();
@@ -652,16 +716,29 @@ const SignSettings = ({
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
onActivateSignaturePlacement?.(); onActivateSignaturePlacement?.();
}, FILE_SWITCH_ACTIVATION_DELAY); }, FILE_SWITCH_ACTIVATION_DELAY);
return () => { return () => window.clearTimeout(timer);
window.clearTimeout(timer);
};
} }
onActivateSignaturePlacement?.(); onActivateSignaturePlacement?.();
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, 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 = () => { const renderSignatureBuilder = () => {
if (signatureSource === 'saved') { if (signatureSource === 'saved') {
if (!canUseSavedLibrary) {
return null;
}
return ( return (
<SavedSignaturesSection <SavedSignaturesSection
signatures={savedSignatures} signatures={savedSignatures}
@@ -670,6 +747,7 @@ const SignSettings = ({
onUseSignature={handleUseSavedSignature} onUseSignature={handleUseSavedSignature}
onDeleteSignature={handleDeleteSavedSignature} onDeleteSignature={handleDeleteSavedSignature}
onRenameSignature={handleRenameSavedSignature} onRenameSignature={handleRenameSavedSignature}
translationScope={translationScope}
/> />
); );
} }
@@ -691,9 +769,7 @@ const SignSettings = ({
disabled={disabled} disabled={disabled}
initialSignatureData={canvasSignatureData} initialSignatureData={canvasSignatureData}
/> />
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}> {renderSaveButtonRow('canvas', hasCanvasSignature, handleSaveCanvasSignature)}
{renderSaveButton('canvas', hasCanvasSignature, handleSaveCanvasSignature)}
</Box>
</Stack> </Stack>
); );
} }
@@ -705,9 +781,7 @@ const SignSettings = ({
onImageChange={handleImageChange} onImageChange={handleImageChange}
disabled={disabled} disabled={disabled}
/> />
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}> {renderSaveButtonRow('image', hasImageSignature, handleSaveImageSignature)}
{renderSaveButton('image', hasImageSignature, handleSaveImageSignature)}
</Box>
</Stack> </Stack>
); );
} }
@@ -724,35 +798,43 @@ const SignSettings = ({
textColor={parameters.textColor || '#000000'} textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)} onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled} 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' }}> {renderSaveButtonRow('text', hasTextSignature, handleSaveTextSignature)}
{renderSaveButton('text', hasTextSignature, handleSaveTextSignature)}
</Box>
</Stack> </Stack>
); );
}; };
const placementInstructions = () => { const placementInstructions = () => {
if (signatureSource === 'saved') { if (signatureSource === 'saved') {
return t( return translate(
'sign.instructions.saved', 'instructions.saved',
'Select a saved signature above, then click anywhere on the PDF to place it.' 'Select a saved signature above, then click anywhere on the PDF to place it.'
); );
} }
if (parameters.signatureType === 'canvas') { if (parameters.signatureType === 'canvas') {
return t( return translate(
'sign.instructions.canvas', 'instructions.canvas',
'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.' 'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.'
); );
} }
if (parameters.signatureType === 'image') { if (parameters.signatureType === 'image') {
return t( return translate(
'sign.instructions.image', 'instructions.image',
'After uploading your signature image, click anywhere on the PDF to place it.' 'After uploading your signature image, click anywhere on the PDF to place it.'
); );
} }
return t( return translate(
'sign.instructions.text', 'instructions.text',
'After entering your name above, click anywhere on the PDF to place your signature.' 'After entering your name above, click anywhere on the PDF to place your signature.'
); );
}; };
@@ -761,16 +843,16 @@ const SignSettings = ({
? { ? {
color: isPlacementMode ? 'blue' : 'teal', color: isPlacementMode ? 'blue' : 'teal',
title: isPlacementMode title: isPlacementMode
? t('sign.instructions.title', 'How to add your signature') ? translate('instructions.title', 'How to add your signature')
: t('sign.instructions.paused', 'Placement paused'), : translate('instructions.paused', 'Placement paused'),
message: isPlacementMode message: isPlacementMode
? placementInstructions() ? 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', color: 'yellow',
title: t('sign.instructions.title', 'How to add your signature'), title: translate('instructions.title', 'How to add your signature'),
message: t('sign.instructions.noSignature', 'Create a signature above to enable placement tools.'), message: translate('instructions.noSignature', 'Create a signature above to enable placement tools.'),
}; };
const handlePausePlacement = () => { const handlePausePlacement = () => {
@@ -806,11 +888,11 @@ const SignSettings = ({
onActivateSignaturePlacement || onDeactivateSignature onActivateSignaturePlacement || onDeactivateSignature
? isPlacementMode ? isPlacementMode
? ( ? (
<Tooltip label={t('sign.mode.pause', 'Pause placement')}> <Tooltip label={translate('mode.pause', 'Pause placement')}>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" size="lg"
aria-label={t('sign.mode.pause', 'Pause placement')} aria-label={translate('mode.pause', 'Pause placement')}
onClick={handlePausePlacement} onClick={handlePausePlacement}
disabled={disabled || !onDeactivateSignature} disabled={disabled || !onDeactivateSignature}
style={{ style={{
@@ -823,17 +905,17 @@ const SignSettings = ({
> >
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} /> <LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}> <Text component="span" size="sm" fw={500}>
{t('sign.mode.pause', 'Pause placement')} {translate('mode.pause', 'Pause placement')}
</Text> </Text>
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
) )
: ( : (
<Tooltip label={t('sign.mode.resume', 'Resume placement')}> <Tooltip label={translate('mode.resume', 'Resume placement')}>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" size="lg"
aria-label={t('sign.mode.resume', 'Resume placement')} aria-label={translate('mode.resume', 'Resume placement')}
onClick={handleResumePlacement} onClick={handleResumePlacement}
disabled={disabled || !isCurrentTypeReady || !onActivateSignaturePlacement} disabled={disabled || !isCurrentTypeReady || !onActivateSignaturePlacement}
style={{ style={{
@@ -846,7 +928,7 @@ const SignSettings = ({
> >
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} /> <LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}> <Text component="span" size="sm" fw={500}>
{t('sign.mode.resume', 'Resume placement')} {translate('mode.resume', 'Resume placement')}
</Text> </Text>
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -857,19 +939,16 @@ const SignSettings = ({
<Stack> <Stack>
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" c="dimmed"> <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> </Text>
<SegmentedControl {sourceOptions.length > 1 && (
value={signatureSource} <SegmentedControl
fullWidth value={signatureSource}
onChange={(value) => handleSignatureSourceChange(value as SignatureSource)} fullWidth
data={[ onChange={(value) => handleSignatureSourceChange(value as SignatureSource)}
{ label: t('sign.type.canvas', 'Draw'), value: 'canvas' }, data={sourceOptions}
{ label: t('sign.type.image', 'Upload'), value: 'image' }, />
{ label: t('sign.type.text', 'Type'), value: 'text' }, )}
{ label: t('sign.type.saved', 'Saved'), value: 'saved' },
]}
/>
{renderSignatureBuilder()} {renderSignatureBuilder()}
</Stack> </Stack>
@@ -877,10 +956,10 @@ const SignSettings = ({
<Stack gap="sm"> <Stack gap="sm">
<Text fw={600} size="md"> <Text fw={600} size="md">
{t('sign.step.place', 'Place & save')} {translate('step.place', 'Place & save')}
</Text> </Text>
<Text size="sm" c="dimmed"> <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> </Text>
<Group gap="xs" wrap="nowrap" align="center"> <Group gap="xs" wrap="nowrap" align="center">
@@ -911,7 +990,7 @@ const SignSettings = ({
onClose={() => setIsColorPickerOpen(false)} onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor} selectedColor={selectedColor}
onColorChange={setSelectedColor} onColorChange={setSelectedColor}
title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')} title={translate('canvas.colorPickerTitle', 'Choose stroke colour')}
/> />
{onSave && ( {onSave && (
@@ -921,7 +1000,7 @@ const SignSettings = ({
variant="filled" variant="filled"
fullWidth fullWidth
> >
{t('sign.applySignatures', 'Apply Signatures')} {translate('applySignatures', 'Apply Signatures')}
</Button> </Button>
)} )}
@@ -15,7 +15,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 { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
import { useWheelZoom } from '@app/hooks/useWheelZoom'; import { useWheelZoom } from '@app/hooks/useWheelZoom';
export interface EmbedPdfViewerProps { export interface EmbedPdfViewerProps {
@@ -82,7 +82,8 @@ const EmbedPdfViewerContent = ({
// Check if we're in signature mode OR viewer annotation mode // Check if we're in signature mode OR viewer annotation mode
const { selectedTool } = useNavigationState(); 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 // 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;
@@ -328,7 +329,7 @@ const EmbedPdfViewerContent = ({
// Future: Handle signature completion // Future: Handle signature completion
}} }}
/> />
<SignaturePlacementOverlay <StampPlacementOverlay
containerRef={pdfContainerRef} containerRef={pdfContainerRef}
isActive={isPlacementOverlayActive} isActive={isPlacementOverlayActive}
signatureConfig={signatureConfig} signatureConfig={signatureConfig}
@@ -14,14 +14,6 @@ const MIN_SIGNATURE_DIMENSION = 12;
// This provides a good balance between visual fidelity and performance/memory usage. // This provides a good balance between visual fidelity and performance/memory usage.
const TEXT_OVERSAMPLE_FACTOR = 2; 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 => { const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new Set()): string | undefined => {
if (!value || depth > 6) return undefined; if (!value || depth > 6) return undefined;
@@ -56,7 +48,7 @@ const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new S
const createTextStampImage = ( const createTextStampImage = (
config: SignParameters, config: SignParameters,
displaySize?: { width: number; height: number } | null displaySize?: { width: number; height: number } | null
): TextStampImageResult | null => { ): { dataUrl: string; pixelWidth: number; pixelHeight: number; displayWidth: number; displayHeight: number } | null => {
const text = (config.signerName ?? '').trim(); const text = (config.signerName ?? '').trim();
if (!text) { if (!text) {
return null; return null;
@@ -207,7 +199,6 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
} }
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]); }, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
// Enable keyboard deletion of selected annotations // Enable keyboard deletion of selected annotations
useEffect(() => { useEffect(() => {
// Always enable delete key when we have annotation API and are in sign mode // 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]); }, [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 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,
}}
/>
);
};
@@ -46,6 +46,7 @@ import Rotate from "@app/tools/Rotate";
import ChangeMetadata from "@app/tools/ChangeMetadata"; import ChangeMetadata from "@app/tools/ChangeMetadata";
import Crop from "@app/tools/Crop"; import Crop from "@app/tools/Crop";
import Sign from "@app/tools/Sign"; import Sign from "@app/tools/Sign";
import AddText from "@app/tools/AddText";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation"; import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation"; import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
@@ -200,6 +201,18 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "sign"), synonyms: getSynonyms(t, "sign"),
supportsAutomate: false, //TODO make support Sign supportsAutomate: false, //TODO make support Sign
}, },
addText: {
icon: <LocalIcon icon="material-symbols:text-fields-rounded" width="1.5rem" height="1.5rem" />,
name: t('home.addText.title', 'Add Text'),
component: AddText,
description: t('home.addText.desc', 'Add custom text anywhere in your PDF'),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
operationConfig: signOperationConfig,
automationSettings: null,
synonyms: getSynonyms(t, 'addText'),
supportsAutomate: false,
},
// Document Security // Document Security
@@ -97,7 +97,12 @@ const writeToStorage = (entries: SavedSignature[]) => {
} }
}; };
const generateId = () => crypto.randomUUID(); const generateId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `sig_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
};
export const useSavedSignatures = () => { export const useSavedSignatures = () => {
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage()); const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage());
+12
View File
@@ -0,0 +1,12 @@
import { createStampTool } from '@app/tools/stamp/createStampTool';
// AddText is text-only annotation (no drawing, no images, no save-to-library)
const AddText = createStampTool({
toolId: 'addText',
translationScope: 'addText',
allowedSignatureSources: ['text'],
defaultSignatureSource: 'text',
defaultSignatureType: 'text',
});
export default AddText;
+9 -205
View File
@@ -1,208 +1,12 @@
import { useEffect, useCallback, useRef } from "react"; import { createStampTool } from '@app/tools/stamp/createStampTool';
import { useTranslation } from "react-i18next";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import { useSignParameters } from "@app/hooks/tools/sign/useSignParameters";
import { useSignOperation } from "@app/hooks/tools/sign/useSignOperation";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
import SignSettings from "@app/components/tools/sign/SignSettings";
import { useNavigation } from "@app/contexts/NavigationContext";
import { useSignature } from "@app/contexts/SignatureContext";
import { useFileContext } from "@app/contexts/FileContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { flattenSignatures } from "@app/utils/signatureFlattening";
const Sign = (props: BaseToolProps) => { const Sign = createStampTool({
const { t } = useTranslation(); toolId: 'sign',
const { setWorkbench } = useNavigation(); translationScope: 'sign',
const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, signatureApiRef, getImageData, setSignaturesApplied } = useSignature(); allowedSignatureSources: ['canvas', 'image', 'text', 'saved'],
const { consumeFiles, selectors } = useFileContext(); defaultSignatureSource: 'canvas',
const { exportActions, getScrollState, activeFileIndex, setActiveFileIndex } = useViewer(); defaultSignatureType: 'canvas',
const { setHasUnsavedChanges, unregisterUnsavedChangesChecker } = useNavigation(); enableApplyAction: true,
// Track which signature mode was active for reactivation after save
const activeModeRef = useRef<'draw' | 'placement' | null>(null);
// Single handler that activates placement mode
const handleSignaturePlacement = useCallback(() => {
activateSignaturePlacementMode();
}, [activateSignaturePlacementMode]);
// Memoized callbacks for SignSettings to prevent infinite loops
const handleActivateDrawMode = useCallback(() => {
activeModeRef.current = 'draw';
activateDrawMode();
}, [activateDrawMode]);
const handleActivateSignaturePlacement = useCallback(() => {
activeModeRef.current = 'placement';
handleSignaturePlacement();
}, [handleSignaturePlacement]);
const handleDeactivateSignature = useCallback(() => {
activeModeRef.current = null;
deactivateDrawMode();
}, [deactivateDrawMode]);
const base = useBaseTool(
'sign',
useSignParameters,
useSignOperation,
props
);
const hasOpenedViewer = useRef(false);
// Open viewer when files are selected (only once)
useEffect(() => {
if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) {
setWorkbench('viewer');
hasOpenedViewer.current = true;
}
}, [base.selectedFiles.length, setWorkbench]);
// Sync signature configuration with context
useEffect(() => {
setSignatureConfig(base.params.parameters);
}, [base.params.parameters, setSignatureConfig]);
// Save signed files to the system - apply signatures using EmbedPDF and replace original
const handleSaveToSystem = useCallback(async () => {
try {
// Unregister unsaved changes checker to prevent warning during apply
unregisterUnsavedChangesChecker();
setHasUnsavedChanges(false);
// Get the original file from FileContext using activeFileIndex
// The viewer displays files from FileContext, not from base.selectedFiles
const allFiles = selectors.getFiles();
const fileIndex = activeFileIndex < allFiles.length ? activeFileIndex : 0;
const originalFile = allFiles[fileIndex];
if (!originalFile) {
console.error('No file available to replace');
return;
}
// Use the signature flattening utility
const flattenResult = await flattenSignatures({
signatureApiRef,
getImageData,
exportActions,
selectors,
originalFile,
getScrollState,
activeFileIndex
});
if (flattenResult) {
// Now consume the files - this triggers the viewer reload
await consumeFiles(
flattenResult.inputFileIds,
[flattenResult.outputStirlingFile],
[flattenResult.outputStub]
);
// According to FileReducer.processFileSwap, new files are inserted at the beginning
// So the new file will be at index 0
setActiveFileIndex(0);
// Mark signatures as applied
setSignaturesApplied(true);
// Deactivate signature placement mode after everything completes
handleDeactivateSignature();
const hasSignatureReady = (() => {
const params = base.params.parameters;
switch (params.signatureType) {
case 'canvas':
case 'image':
return Boolean(params.signatureData);
case 'text':
return Boolean(params.signerName && params.signerName.trim() !== '');
default:
return false;
}
})();
if (hasSignatureReady) {
if (typeof window !== 'undefined') {
// TODO: Ideally, we should trigger handleActivateSignaturePlacement when the viewer is ready.
// However, due to current architectural constraints, we use a 150ms delay to allow the viewer to reload.
// This value was empirically determined to be sufficient for most environments, but should be revisited.
window.setTimeout(() => {
handleActivateSignaturePlacement();
}, 150);
} else {
handleActivateSignaturePlacement();
}
}
// File has been consumed - viewer should reload automatically via key prop
} else {
console.error('Signature flattening failed');
}
} catch (error) {
console.error('Error saving signed document:', error);
}
}, [exportActions, base.selectedFiles, base.params.parameters, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, handleActivateSignaturePlacement, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
const getSteps = () => {
const steps = [];
// Step 1: Signature Configuration - Only visible when file is loaded
if (base.selectedFiles.length > 0) {
steps.push({
title: t('sign.steps.configure', 'Configure Signature'),
isCollapsed: false,
onCollapsedClick: undefined,
content: (
<SignSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
onActivateDrawMode={handleActivateDrawMode}
onActivateSignaturePlacement={handleActivateSignaturePlacement}
onDeactivateSignature={handleDeactivateSignature}
onUpdateDrawSettings={updateDrawSettings}
onUndo={undo}
onRedo={redo}
onSave={handleSaveToSystem}
/>
),
});
}
return steps;
};
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.operation.files.length > 0,
},
steps: getSteps(),
review: {
isVisible: false, // Hide review section - save moved to configure section
operation: base.operation,
title: t('sign.results.title', 'Signature Results'),
onFileClick: base.handleThumbnailClick,
onUndo: () => {},
},
forceStepNumbers: true,
});
};
// Add the required static methods for automation
Sign.tool = () => useSignOperation;
Sign.getDefaultParameters = () => ({
signatureType: 'canvas',
reason: 'Document signing',
location: 'Digital',
signerName: '',
}); });
export default Sign as ToolComponent; export default Sign;
@@ -0,0 +1,249 @@
import { useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
import SignSettings, { SignatureSource } from '@app/components/tools/sign/SignSettings';
import { DEFAULT_PARAMETERS, useSignParameters, SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { useSignOperation } from '@app/hooks/tools/sign/useSignOperation';
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
import { BaseToolProps, ToolComponent } from '@app/types/tool';
import { ToolId } from '@app/types/toolId';
import { useNavigation } from '@app/contexts/NavigationContext';
import { useSignature } from '@app/contexts/SignatureContext';
import { useFileContext } from '@app/contexts/FileContext';
import { useViewer } from '@app/contexts/ViewerContext';
import { flattenSignatures } from '@app/utils/signatureFlattening';
export type StampToolConfig = {
toolId: ToolId;
translationScope?: string;
allowedSignatureSources?: SignatureSource[];
defaultSignatureSource?: SignatureSource;
defaultSignatureType?: SignParameters['signatureType'];
enableApplyAction?: boolean;
};
const STAMP_TOOL_DEFAULT_SOURCES: SignatureSource[] = ['canvas', 'image', 'text', 'saved'];
export const createStampTool = (config: StampToolConfig) => {
const {
toolId,
translationScope = toolId,
allowedSignatureSources = STAMP_TOOL_DEFAULT_SOURCES,
defaultSignatureSource,
defaultSignatureType,
enableApplyAction = false,
} = config;
const StampTool = (props: BaseToolProps) => {
const { t } = useTranslation();
const translateTool = useCallback(
(key: string, defaultValue: string) => t(`${translationScope}.${key}`, defaultValue),
[t, translationScope]
);
const { setWorkbench, setHasUnsavedChanges, unregisterUnsavedChangesChecker } = useNavigation();
const {
setSignatureConfig,
activateDrawMode,
activateSignaturePlacementMode,
deactivateDrawMode,
updateDrawSettings,
undo,
redo,
signatureApiRef,
getImageData,
setSignaturesApplied,
} = useSignature();
const { consumeFiles, selectors } = useFileContext();
const { exportActions, getScrollState, activeFileIndex, setActiveFileIndex } = useViewer();
const base = useBaseTool(
toolId,
useSignParameters,
useSignOperation,
props
);
const allowedSignatureTypes = allowedSignatureSources.filter(
(source): source is SignParameters['signatureType'] => source !== 'saved'
);
const enforcedSignatureType =
defaultSignatureType ?? allowedSignatureTypes[0] ?? DEFAULT_PARAMETERS.signatureType;
useEffect(() => {
if (!allowedSignatureTypes.includes(base.params.parameters.signatureType)) {
base.params.updateParameter('signatureType', enforcedSignatureType);
}
}, [allowedSignatureTypes, base.params.parameters.signatureType, base.params.updateParameter, enforcedSignatureType]);
const hasOpenedViewer = useRef(false);
const activeModeRef = useRef<'draw' | 'placement' | null>(null);
const handleSignaturePlacement = useCallback(() => {
activateSignaturePlacementMode();
}, [activateSignaturePlacementMode]);
const handleActivateDrawMode = useCallback(() => {
activeModeRef.current = 'draw';
activateDrawMode();
}, [activateDrawMode]);
const handleActivateSignaturePlacement = useCallback(() => {
activeModeRef.current = 'placement';
handleSignaturePlacement();
}, [handleSignaturePlacement]);
const handleDeactivateSignature = useCallback(() => {
activeModeRef.current = null;
deactivateDrawMode();
}, [deactivateDrawMode]);
useEffect(() => {
if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) {
setWorkbench('viewer');
hasOpenedViewer.current = true;
}
}, [base.selectedFiles.length, setWorkbench]);
useEffect(() => {
setSignatureConfig(base.params.parameters);
}, [base.params.parameters, setSignatureConfig]);
const handleSaveToSystem = useCallback(async () => {
try {
unregisterUnsavedChangesChecker();
setHasUnsavedChanges(false);
const allFiles = selectors.getFiles();
const fileIndex = activeFileIndex < allFiles.length ? activeFileIndex : 0;
const originalFile = allFiles[fileIndex];
if (!originalFile) {
console.error('No file available to replace');
return;
}
const flattenResult = await flattenSignatures({
signatureApiRef,
getImageData,
exportActions,
selectors,
originalFile,
getScrollState,
activeFileIndex,
});
if (flattenResult) {
await consumeFiles(
flattenResult.inputFileIds,
[flattenResult.outputStirlingFile],
[flattenResult.outputStub]
);
setActiveFileIndex(0);
setSignaturesApplied(true);
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();
}
}
} else {
console.error('Signature flattening failed');
}
} catch (error) {
console.error('Error saving signed document:', error);
}
}, [
exportActions,
base.selectedFiles,
base.params.parameters,
selectors,
consumeFiles,
signatureApiRef,
getImageData,
setWorkbench,
activateDrawMode,
setSignaturesApplied,
getScrollState,
handleDeactivateSignature,
handleActivateSignaturePlacement,
setHasUnsavedChanges,
unregisterUnsavedChangesChecker,
activeFileIndex,
setActiveFileIndex,
]);
const getSteps = () => {
const steps = [];
if (base.selectedFiles.length > 0) {
steps.push({
title: translateTool('steps.configure', 'Configure Stamp'),
isCollapsed: false,
onCollapsedClick: undefined,
content: (
<SignSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
onActivateDrawMode={handleActivateDrawMode}
onActivateSignaturePlacement={handleActivateSignaturePlacement}
onDeactivateSignature={handleDeactivateSignature}
onUpdateDrawSettings={updateDrawSettings}
onUndo={undo}
onRedo={redo}
onSave={enableApplyAction ? handleSaveToSystem : undefined}
translationScope={translationScope}
allowedSignatureSources={allowedSignatureSources}
defaultSignatureSource={defaultSignatureSource}
/>
),
});
}
return steps;
};
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.operation.files.length > 0,
},
steps: getSteps(),
review: {
isVisible: false,
operation: base.operation,
title: translateTool('results.title', 'Stamp Results'),
onFileClick: base.handleThumbnailClick,
onUndo: () => {},
},
forceStepNumbers: true,
});
};
const StampToolComponent = StampTool as ToolComponent;
StampToolComponent.tool = () => useSignOperation;
StampToolComponent.getDefaultParameters = () => ({
...DEFAULT_PARAMETERS,
signatureType: config.defaultSignatureType ?? DEFAULT_PARAMETERS.signatureType,
});
return StampToolComponent;
};
+1 -1
View File
@@ -9,6 +9,7 @@ export type ToolKind = 'regular' | 'super' | 'link';
export const CORE_REGULAR_TOOL_IDS = [ export const CORE_REGULAR_TOOL_IDS = [
'certSign', 'certSign',
'sign', 'sign',
'addText',
'addPassword', 'addPassword',
'removePassword', 'removePassword',
'removePages', 'removePages',
@@ -113,4 +114,3 @@ type Disjoint<A, B> = [A & B] extends [never] ? true : false;
type _Check1 = Assert<Disjoint<RegularToolId, SuperToolId>>; type _Check1 = Assert<Disjoint<RegularToolId, SuperToolId>>;
type _Check2 = Assert<Disjoint<RegularToolId, LinkToolId>>; type _Check2 = Assert<Disjoint<RegularToolId, LinkToolId>>;
type _Check3 = Assert<Disjoint<SuperToolId, LinkToolId>>; type _Check3 = Assert<Disjoint<SuperToolId, LinkToolId>>;
+1
View File
@@ -96,6 +96,7 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/read': 'read', '/read': 'read',
'/automate': 'automate', '/automate': 'automate',
'/sign': 'sign', '/sign': 'sign',
'/add-text': 'addText',
// Developer tools // Developer tools
'/dev-api': 'devApi', '/dev-api': 'devApi',