Saved signatures

This commit is contained in:
Reece 2025-11-14 12:59:10 +00:00
parent 4a5f7e9c49
commit c8bf43ea6b
9 changed files with 955 additions and 78 deletions

View File

@ -1998,7 +1998,34 @@
},
"clear": "Clear",
"add": "Add",
"saved": "Saved Signatures",
"saved": {
"heading": "Saved signatures",
"description": "Reuse saved signatures at any time.",
"emptyTitle": "No saved signatures yet",
"emptyDescription": "Draw, upload, or type a signature above, then use \"Save to library\" to keep up to {{max}} favourites ready to use.",
"type": {
"canvas": "Drawing",
"image": "Upload",
"text": "Text"
},
"limitTitle": "Limit reached",
"limitDescription": "Remove a saved signature before adding new ones (max {{max}}).",
"carouselPosition": "{{current}} of {{total}}",
"prev": "Previous",
"next": "Next",
"delete": "Remove",
"label": "Label",
"defaultLabel": "Signature",
"defaultCanvasLabel": "Drawing signature",
"defaultImageLabel": "Uploaded signature",
"defaultTextLabel": "Typed signature",
"saveButton": "Save signature",
"saveUnavailable": "Create a signature first to save it.",
"noChanges": "Current signature is already saved.",
"status": {
"saved": "Saved"
}
},
"save": "Save Signature",
"applySignatures": "Apply Signatures",
"personalSigs": "Personal Signatures",
@ -2027,7 +2054,8 @@
"draw": "Draw",
"canvas": "Canvas",
"image": "Image",
"text": "Text"
"text": "Text",
"saved": "Saved"
},
"image": {
"label": "Upload signature image",
@ -2038,6 +2066,7 @@
"title": "How to add signature",
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
"saved": "Select a saved signature above, then click anywhere on the PDF to place it.",
"text": "After entering your name above, click anywhere on the PDF to place your signature.",
"paused": "Placement paused",
"resumeHint": "Resume placement to click and add your signature.",

View File

@ -2256,7 +2256,34 @@
},
"clear": "Clear",
"add": "Add",
"saved": "Saved Signatures",
"saved": {
"heading": "Saved signatures",
"description": "Reuse saved signatures at any time.",
"emptyTitle": "No saved signatures yet",
"emptyDescription": "Draw, upload, or type a signature above, then use \"Save to library\" to keep up to {{max}} favourites ready to use.",
"type": {
"canvas": "Drawing",
"image": "Upload",
"text": "Text"
},
"limitTitle": "Limit reached",
"limitDescription": "Remove a saved signature before adding new ones (max {{max}}).",
"carouselPosition": "{{current}} of {{total}}",
"prev": "Previous",
"next": "Next",
"delete": "Remove",
"label": "Label",
"defaultLabel": "Signature",
"defaultCanvasLabel": "Drawing signature",
"defaultImageLabel": "Uploaded signature",
"defaultTextLabel": "Typed signature",
"saveButton": "Save signature",
"saveUnavailable": "Create a signature first to save it.",
"noChanges": "Current signature is already saved.",
"status": {
"saved": "Saved"
}
},
"save": "Save Signature",
"applySignatures": "Apply Signatures",
"personalSigs": "Personal Signatures",
@ -2291,6 +2318,7 @@
"title": "How to add signature",
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
"saved": "Select a saved signature above, then click anywhere on the PDF to place it.",
"text": "After entering your name above, click anywhere on the PDF to place your signature."
},
"mode": {

View File

@ -205,10 +205,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
if (!initialSignatureData) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
setSavedSignatureData(null);
return;
}
renderPreview(initialSignatureData);
setSavedSignatureData(initialSignatureData);
}, [initialSignatureData]);
return (

View File

@ -0,0 +1,298 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActionIcon, Alert, Badge, Box, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core';
import { LocalIcon } from '@app/components/shared/LocalIcon';
import { MAX_SAVED_SIGNATURES, SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
interface SavedSignaturesSectionProps {
signatures: SavedSignature[];
disabled?: boolean;
isAtCapacity: boolean;
onUseSignature: (signature: SavedSignature) => void;
onDeleteSignature: (signature: SavedSignature) => void;
onRenameSignature: (id: string, label: string) => void;
}
const typeBadgeColor: Record<SavedSignatureType, string> = {
canvas: 'indigo',
image: 'teal',
text: 'grape',
};
export const SavedSignaturesSection = ({
signatures,
disabled = false,
isAtCapacity,
onUseSignature,
onDeleteSignature,
onRenameSignature,
}: SavedSignaturesSectionProps) => {
const { t } = useTranslation();
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);
useEffect(() => {
onUseSignatureRef.current = onUseSignature;
}, [onUseSignature]);
useEffect(() => {
activeSignatureRef.current = activeSignature ?? null;
}, [activeSignature]);
useEffect(() => {
setLabelDrafts(prev => {
const nextDrafts: Record<string, string> = {};
signatures.forEach(sig => {
nextDrafts[sig.id] = prev[sig.id] ?? sig.label ?? '';
});
return nextDrafts;
});
}, [signatures]);
useEffect(() => {
if (signatures.length === 0) {
setActiveIndex(0);
return;
}
setActiveIndex(prev => Math.min(prev, Math.max(signatures.length - 1, 0)));
}, [signatures.length]);
const handleNavigate = useCallback(
(direction: 'prev' | 'next') => {
setActiveIndex(prev => {
if (direction === 'prev') {
return Math.max(0, prev - 1);
}
return Math.min(signatures.length - 1, prev + 1);
});
},
[signatures.length]
);
const renderPreview = (signature: SavedSignature) => {
if (signature.type === 'text') {
return (
<Box
component="div"
style={{
fontFamily: signature.fontFamily,
fontSize: `${signature.fontSize}px`,
color: signature.textColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '120px',
borderRadius: '0.5rem',
backgroundColor: '#ffffff',
padding: '0.5rem',
textAlign: 'center',
overflow: 'hidden',
}}
>
<Text
size="lg"
style={{
fontFamily: signature.fontFamily,
color: signature.textColor,
whiteSpace: 'nowrap',
}}
>
{signature.signerName}
</Text>
</Box>
);
}
return (
<Box
component="div"
style={{
backgroundColor: '#ffffff',
borderRadius: '0.5rem',
height: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0.5rem',
}}
>
<Box
component="img"
src={signature.dataUrl}
alt={signature.label}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
</Box>
);
};
const emptyState = (
<Card withBorder>
<Stack gap="xs">
<Text fw={500}>{t('sign.saved.emptyTitle', 'No saved signatures yet')}</Text>
<Text size="sm" c="dimmed">
{t(
'sign.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 }
)}
</Text>
</Stack>
</Card>
);
const typeLabel = (type: SavedSignatureType) => {
switch (type) {
case 'canvas':
return t('sign.saved.type.canvas', 'Drawing');
case 'image':
return t('sign.saved.type.image', 'Upload');
case 'text':
return t('sign.saved.type.text', 'Text');
default:
return type;
}
};
const handleLabelBlur = (signature: SavedSignature) => {
const nextValue = labelDrafts[signature.id]?.trim() ?? '';
if (!nextValue || nextValue === signature.label) {
setLabelDrafts(prev => ({ ...prev, [signature.id]: signature.label }));
return;
}
onRenameSignature(signature.id, nextValue);
};
const handleLabelChange = (event: React.ChangeEvent<HTMLInputElement>, signature: SavedSignature) => {
const { value } = event.currentTarget;
setLabelDrafts(prev => ({ ...prev, [signature.id]: value }));
};
const handleLabelKeyDown = (event: React.KeyboardEvent<HTMLInputElement>, signature: SavedSignature) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
if (event.key === 'Escape') {
setLabelDrafts(prev => ({ ...prev, [signature.id]: signature.label }));
event.currentTarget.blur();
}
};
useEffect(() => {
const signature = activeSignatureRef.current;
if (!signature || disabled) {
appliedSignatureIdRef.current = null;
return;
}
if (appliedSignatureIdRef.current === signature.id) {
return;
}
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')}
</Text>
<Text size="sm" c="dimmed">
{t('sign.saved.description', 'Reuse saved signatures at any time.')}
</Text>
</Stack>
</Group>
{isAtCapacity && (
<Alert color="yellow" title={t('sign.saved.limitTitle', 'Limit reached')}>
<Text size="sm">
{t('sign.saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
max: MAX_SAVED_SIGNATURES,
})}
</Text>
</Alert>
)}
{signatures.length === 0 ? (
emptyState
) : (
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{t('sign.saved.carouselPosition', '{{current}} of {{total}}', {
current: activeIndex + 1,
total: signatures.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={t('sign.saved.prev', 'Previous')}
onClick={() => handleNavigate('prev')}
disabled={disabled || activeIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={t('sign.saved.next', 'Next')}
onClick={() => handleNavigate('next')}
disabled={disabled || activeIndex >= signatures.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
{activeSignature && (
<Card withBorder padding="sm" key={activeSignature.id}>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activeSignature.type]} variant="light">
{typeLabel(activeSignature.type)}
</Badge>
<Tooltip label={t('sign.saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t('sign.saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
{renderPreview(activeSignature)}
<TextInput
label={t('sign.saved.label', 'Label')}
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
onChange={event => handleLabelChange(event, activeSignature)}
onBlur={() => handleLabelBlur(activeSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeSignature)}
disabled={disabled}
/>
</Stack>
</Card>
)}
</Stack>
)}
</Stack>
);
};
export default SavedSignaturesSection;

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef } 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";
@ -14,6 +14,8 @@ import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import { useSavedSignatures, SavedSignature, SavedSignaturePayload, SavedSignatureType, MAX_SAVED_SIGNATURES, AddSignatureResult } from '@app/hooks/tools/sign/useSavedSignatures';
import { SavedSignaturesSection } from '@app/components/tools/sign/SavedSignaturesSection';
type SignatureDrafts = {
canvas?: string;
@ -39,6 +41,8 @@ interface SignSettingsProps {
onSave?: () => void;
}
type SignatureSource = 'canvas' | 'image' | 'text' | 'saved';
const SignSettings = ({
parameters,
onParameterChange,
@ -70,6 +74,244 @@ const SignSettings = ({
const lastSyncedTextDraft = useRef<SignatureDrafts['text'] | null>(null);
const lastAppliedPlacementKey = useRef<string | null>(null);
const previousFileIndexRef = useRef(activeFileIndex);
const {
savedSignatures,
isAtCapacity: isSavedSignatureLimitReached,
addSignature,
removeSignature,
updateSignatureLabel,
byTypeCounts,
} = useSavedSignatures();
const [signatureSource, setSignatureSource] = useState<SignatureSource>(parameters.signatureType);
const [lastSavedSignatureKeys, setLastSavedSignatureKeys] = useState<Record<SavedSignatureType, string | null>>({
canvas: null,
image: null,
text: null,
});
const buildTextSignatureKey = useCallback(
(signerName: string, fontSize: number, fontFamily: string, textColor: string) =>
JSON.stringify({
signerName: signerName.trim(),
fontSize,
fontFamily,
textColor,
}),
[]
);
const getDefaultSavedLabel = useCallback(
(type: SavedSignatureType) => {
const nextIndex = (byTypeCounts[type] ?? 0) + 1;
let baseLabel = t('sign.saved.defaultLabel', 'Signature');
if (type === 'canvas') {
baseLabel = t('sign.saved.defaultCanvasLabel', 'Drawing signature');
} else if (type === 'image') {
baseLabel = t('sign.saved.defaultImageLabel', 'Uploaded signature');
} else if (type === 'text') {
baseLabel = t('sign.saved.defaultTextLabel', 'Typed signature');
}
return `${baseLabel} ${nextIndex}`;
},
[byTypeCounts, t]
);
const signatureKeysByType = useMemo(() => {
const canvasKey = canvasSignatureData ?? null;
const imageKey = imageSignatureData ?? null;
const textKey = buildTextSignatureKey(
parameters.signerName ?? '',
parameters.fontSize ?? 16,
parameters.fontFamily ?? 'Helvetica',
parameters.textColor ?? '#000000'
);
return {
canvas: canvasKey,
image: imageKey,
text: textKey,
};
}, [canvasSignatureData, imageSignatureData, buildTextSignatureKey, parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
const saveSignatureToLibrary = useCallback(
(payload: SavedSignaturePayload, type: SavedSignatureType): AddSignatureResult => {
if (isSavedSignatureLimitReached) {
return { success: false, reason: 'limit' };
}
return addSignature(payload, getDefaultSavedLabel(type));
},
[addSignature, getDefaultSavedLabel, isSavedSignatureLimitReached]
);
const setLastSavedKeyForType = useCallback(
(type: SavedSignatureType, explicitKey?: string | null) => {
setLastSavedSignatureKeys(prev => ({
...prev,
[type]: explicitKey !== undefined ? explicitKey : signatureKeysByType[type] ?? null,
}));
},
[signatureKeysByType]
);
const handleSaveCanvasSignature = useCallback(() => {
if (!canvasSignatureData) {
return;
}
const result = saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas');
if (result.success) {
setLastSavedKeyForType('canvas');
}
}, [canvasSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
const handleSaveImageSignature = useCallback(() => {
if (!imageSignatureData) {
return;
}
const result = saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image');
if (result.success) {
setLastSavedKeyForType('image');
}
}, [imageSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
const handleSaveTextSignature = useCallback(() => {
const signerName = (parameters.signerName ?? '').trim();
if (!signerName) {
return;
}
const result = saveSignatureToLibrary(
{
type: 'text',
signerName,
fontFamily: parameters.fontFamily ?? 'Helvetica',
fontSize: parameters.fontSize ?? 16,
textColor: parameters.textColor ?? '#000000',
},
'text'
);
if (result.success) {
setLastSavedKeyForType('text');
}
}, [
parameters.fontFamily,
parameters.fontSize,
parameters.signerName,
parameters.textColor,
saveSignatureToLibrary,
setLastSavedKeyForType,
]);
const handleUseSavedSignature = useCallback(
(signature: SavedSignature) => {
setPlacementManuallyPaused(false);
if (signature.type === 'canvas') {
if (parameters.signatureType !== 'canvas') {
onParameterChange('signatureType', 'canvas');
}
setCanvasSignatureData(signature.dataUrl);
} else if (signature.type === 'image') {
if (parameters.signatureType !== 'image') {
onParameterChange('signatureType', 'image');
}
setImageSignatureData(signature.dataUrl);
} else if (signature.type === 'text') {
if (parameters.signatureType !== 'text') {
onParameterChange('signatureType', 'text');
}
onParameterChange('signerName', signature.signerName);
onParameterChange('fontFamily', signature.fontFamily);
onParameterChange('fontSize', signature.fontSize);
onParameterChange('textColor', signature.textColor);
}
const savedKey =
signature.type === 'text'
? buildTextSignatureKey(signature.signerName, signature.fontSize, signature.fontFamily, signature.textColor)
: signature.dataUrl;
setLastSavedKeyForType(signature.type, savedKey);
const activate = () => onActivateSignaturePlacement?.();
if (typeof window !== 'undefined') {
window.setTimeout(activate, PLACEMENT_ACTIVATION_DELAY);
} else {
activate();
}
},
[
buildTextSignatureKey,
onActivateSignaturePlacement,
onParameterChange,
parameters.signatureType,
setCanvasSignatureData,
setImageSignatureData,
setPlacementManuallyPaused,
setLastSavedKeyForType,
]
);
const handleDeleteSavedSignature = useCallback(
(signature: SavedSignature) => {
removeSignature(signature.id);
},
[removeSignature]
);
const handleRenameSavedSignature = useCallback(
(id: string, label: string) => {
updateSignatureLabel(id, label);
},
[updateSignatureLabel]
);
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
const label = t('sign.saved.saveButton', 'Save signature');
const currentKey = signatureKeysByType[type];
const lastSavedKey = lastSavedSignatureKeys[type];
const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey);
const isSaved = isReady && !hasChanges;
let tooltipMessage: string | undefined;
if (!isReady) {
tooltipMessage = t('sign.saved.saveUnavailable', 'Create a signature first to save it.');
} else if (isSaved) {
tooltipMessage = t('sign.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}}).', {
max: MAX_SAVED_SIGNATURES,
});
}
const button = (
<Button
size="xs"
variant="outline"
color={isSaved ? 'green' : undefined}
onClick={onClick}
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
leftSection={<LocalIcon icon="material-symbols:save-rounded" width={16} height={16} />}
>
{isSaved ? t('sign.saved.status.saved', 'Saved') : label}
</Button>
);
if (tooltipMessage) {
return (
<Tooltip label={tooltipMessage}>
<span style={{ display: 'inline-block' }}>{button}</span>
</Tooltip>
);
}
return button;
};
useEffect(() => {
if (signatureSource === 'saved') {
return;
}
if (signatureSource !== parameters.signatureType) {
setSignatureSource(parameters.signatureType);
}
}, [parameters.signatureType, signatureSource]);
useEffect(() => {
if (!disabled) {
@ -77,6 +319,19 @@ const SignSettings = ({
}
}, [selectedColor, penSize, disabled, onUpdateDrawSettings]);
const handleSignatureSourceChange = useCallback(
(value: SignatureSource) => {
setSignatureSource(value);
if (value === 'saved') {
return;
}
if (parameters.signatureType !== value) {
onParameterChange('signatureType', value);
}
},
[onParameterChange, parameters.signatureType]
);
useEffect(() => {
if (signaturesApplied) {
setPlacementManuallyPaused(false);
@ -168,32 +423,8 @@ const SignSettings = ({
if (!isCurrentTypeReady) {
return null;
}
switch (parameters.signatureType) {
case 'canvas':
return canvasSignatureData ?? null;
case 'image':
return imageSignatureData ?? null;
case 'text':
return JSON.stringify({
signerName: (parameters.signerName ?? '').trim(),
fontSize: parameters.fontSize ?? 16,
fontFamily: parameters.fontFamily ?? 'Helvetica',
textColor: parameters.textColor ?? '#000000',
});
default:
return null;
}
}, [
isCurrentTypeReady,
parameters.signatureType,
canvasSignatureData,
imageSignatureData,
parameters.signerName,
parameters.fontSize,
parameters.fontFamily,
parameters.textColor,
]);
return signatureKeysByType[parameters.signatureType] ?? null;
}, [isCurrentTypeReady, parameters.signatureType, signatureKeysByType]);
const shouldEnablePlacement = useMemo(() => {
if (disabled) return false;
@ -424,57 +655,100 @@ const SignSettings = ({
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
const renderSignatureBuilder = () => {
if (parameters.signatureType === 'canvas') {
if (signatureSource === 'saved') {
return (
<DrawingCanvas
selectedColor={selectedColor}
penSize={penSize}
penSizeInput={penSizeInput}
onColorSwatchClick={() => setIsColorPickerOpen(true)}
onPenSizeChange={setPenSize}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={handleCanvasSignatureChange}
onDrawingComplete={() => {
onActivateSignaturePlacement?.();
}}
<SavedSignaturesSection
signatures={savedSignatures}
disabled={disabled}
initialSignatureData={canvasSignatureData}
isAtCapacity={isSavedSignatureLimitReached}
onUseSignature={handleUseSavedSignature}
onDeleteSignature={handleDeleteSavedSignature}
onRenameSignature={handleRenameSavedSignature}
/>
);
}
if (parameters.signatureType === 'image') {
if (signatureSource === 'canvas') {
return (
<ImageUploader
onImageChange={handleImageChange}
disabled={disabled}
/>
<Stack gap="xs">
<DrawingCanvas
selectedColor={selectedColor}
penSize={penSize}
penSizeInput={penSizeInput}
onColorSwatchClick={() => setIsColorPickerOpen(true)}
onPenSizeChange={setPenSize}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={handleCanvasSignatureChange}
onDrawingComplete={() => {
onActivateSignaturePlacement?.();
}}
disabled={disabled}
initialSignatureData={canvasSignatureData}
/>
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{renderSaveButton('canvas', hasCanvasSignature, handleSaveCanvasSignature)}
</Box>
</Stack>
);
}
if (signatureSource === 'image') {
return (
<Stack gap="xs">
<ImageUploader
onImageChange={handleImageChange}
disabled={disabled}
/>
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{renderSaveButton('image', hasImageSignature, handleSaveImageSignature)}
</Box>
</Stack>
);
}
return (
<TextInputWithFont
text={parameters.signerName || ''}
onTextChange={(text) => onParameterChange('signerName', text)}
fontSize={parameters.fontSize || 16}
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
fontFamily={parameters.fontFamily || 'Helvetica'}
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
/>
<Stack gap="xs">
<TextInputWithFont
text={parameters.signerName || ''}
onTextChange={(text) => onParameterChange('signerName', text)}
fontSize={parameters.fontSize || 16}
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
fontFamily={parameters.fontFamily || 'Helvetica'}
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
/>
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{renderSaveButton('text', hasTextSignature, handleSaveTextSignature)}
</Box>
</Stack>
);
};
const placementInstructions = () => {
if (signatureSource === 'saved') {
return t(
'sign.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', 'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.');
return t(
'sign.instructions.canvas',
'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.'
);
}
if (parameters.signatureType === 'image') {
return t('sign.instructions.image', 'After uploading your signature image, click anywhere on the PDF to place it.');
return t(
'sign.instructions.image',
'After uploading your signature image, click anywhere on the PDF to place it.'
);
}
return t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.');
return t(
'sign.instructions.text',
'After entering your name above, click anywhere on the PDF to place your signature.'
);
};
const placementAlert = isCurrentTypeReady
@ -580,13 +854,14 @@ const SignSettings = ({
{t('sign.step.createDesc', 'Choose how you want to create the signature')}
</Text>
<SegmentedControl
value={parameters.signatureType}
value={signatureSource}
fullWidth
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
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' },
]}
/>
{renderSignatureBuilder()}

View File

@ -43,23 +43,32 @@ export function PdfViewerToolbar({
// Register for immediate scroll updates and sync with actual scroll state
useEffect(() => {
registerImmediateScrollUpdate((currentPage, _totalPages) => {
const unregister = registerImmediateScrollUpdate((currentPage, _totalPages) => {
setPageInput(currentPage);
});
setPageInput(scrollState.currentPage);
}, [registerImmediateScrollUpdate]);
return () => {
unregister?.();
};
}, [registerImmediateScrollUpdate, scrollState.currentPage]);
// Register for immediate zoom updates and sync with actual zoom state
useEffect(() => {
registerImmediateZoomUpdate(setDisplayZoomPercent);
const unregister = registerImmediateZoomUpdate(setDisplayZoomPercent);
setDisplayZoomPercent(zoomState.zoomPercent || 140);
}, [zoomState.zoomPercent, registerImmediateZoomUpdate]);
return () => {
unregister?.();
};
}, [registerImmediateZoomUpdate, zoomState.zoomPercent]);
useEffect(() => {
registerImmediateSpreadUpdate((_mode, isDual) => {
const unregister = registerImmediateSpreadUpdate((_mode, isDual) => {
setIsDualPageActive(isDual);
});
setIsDualPageActive(spreadState.isDualPage);
return () => {
unregister?.();
};
}, [registerImmediateSpreadUpdate, spreadState.isDualPage]);
const handleZoomOut = () => {

View File

@ -1,4 +1,4 @@
import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef } from 'react';
import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef, useState } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import { useSignature } from '@app/contexts/SignatureContext';
@ -123,10 +123,20 @@ const createTextStampImage = (
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
const { getZoomState } = useViewer();
const currentZoom = getZoomState()?.currentZoom ?? 1;
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1);
const lastStampImageRef = useRef<string | null>(null);
useEffect(() => {
setCurrentZoom(getZoomState()?.currentZoom ?? 1);
const unregister = registerImmediateZoomUpdate(percent => {
setCurrentZoom(Math.max(percent / 100, 0.01));
});
return () => {
unregister?.();
};
}, [getZoomState, registerImmediateZoomUpdate]);
const cssToPdfSize = useCallback(
(size: { width: number; height: number }) => {
const zoom = currentZoom || 1;

View File

@ -39,14 +39,23 @@ import {
import { SpreadMode } from '@embedpdf/plugin-spread/react';
function useImmediateNotifier<Args extends unknown[]>() {
const callbackRef = useRef<((...args: Args) => void) | null>(null);
const callbacksRef = useRef(new Set<(...args: Args) => void>());
const register = useCallback((callback: (...args: Args) => void) => {
callbackRef.current = callback;
callbacksRef.current.add(callback);
return () => {
callbacksRef.current.delete(callback);
};
}, []);
const trigger = useCallback((...args: Args) => {
callbackRef.current?.(...args);
callbacksRef.current.forEach(cb => {
try {
cb(...args);
} catch (error) {
console.error('Immediate callback error:', error);
}
});
}, []);
return { register, trigger };
@ -91,9 +100,9 @@ interface ViewerContextType {
getExportState: () => ExportState;
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void;
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => () => void;
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => () => void;
// Internal - for bridges to trigger immediate updates
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;

View File

@ -0,0 +1,217 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
const STORAGE_KEY = 'stirling:saved-signatures:v1';
export const MAX_SAVED_SIGNATURES = 10;
export type SavedSignatureType = 'canvas' | 'image' | 'text';
export type SavedSignaturePayload =
| {
type: 'canvas';
dataUrl: string;
}
| {
type: 'image';
dataUrl: string;
}
| {
type: 'text';
signerName: string;
fontFamily: string;
fontSize: number;
textColor: string;
};
export type SavedSignature = SavedSignaturePayload & {
id: string;
label: string;
createdAt: number;
updatedAt: number;
};
export type AddSignatureResult =
| { success: true; signature: SavedSignature }
| { success: false; reason: 'limit' | 'invalid' };
const isSupportedEnvironment = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
const safeParse = (raw: string | null): SavedSignature[] => {
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((entry: any): entry is SavedSignature => {
if (!entry || typeof entry !== 'object') {
return false;
}
if (typeof entry.id !== 'string' || typeof entry.label !== 'string') {
return false;
}
if (typeof entry.type !== 'string') {
return false;
}
if (entry.type === 'text') {
return (
typeof entry.signerName === 'string' &&
typeof entry.fontFamily === 'string' &&
typeof entry.fontSize === 'number' &&
typeof entry.textColor === 'string'
);
}
return typeof entry.dataUrl === 'string';
});
} catch {
return [];
}
};
const readFromStorage = (): SavedSignature[] => {
if (!isSupportedEnvironment()) {
return [];
}
try {
return safeParse(window.localStorage.getItem(STORAGE_KEY));
} catch {
return [];
}
};
const writeToStorage = (entries: SavedSignature[]) => {
if (!isSupportedEnvironment()) {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
// Swallow storage errors silently; we still keep state in memory.
}
};
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 = () => {
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage());
useEffect(() => {
if (!isSupportedEnvironment()) {
return;
}
const syncFromStorage = () => {
setSavedSignatures(readFromStorage());
};
window.addEventListener('storage', syncFromStorage);
return () => window.removeEventListener('storage', syncFromStorage);
}, []);
useEffect(() => {
writeToStorage(savedSignatures);
}, [savedSignatures]);
const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES;
const addSignature = useCallback(
(payload: SavedSignaturePayload, label?: string): AddSignatureResult => {
if (
(payload.type === 'text' && !payload.signerName.trim()) ||
((payload.type === 'canvas' || payload.type === 'image') && !payload.dataUrl)
) {
return { success: false, reason: 'invalid' };
}
let createdSignature: SavedSignature | null = null;
setSavedSignatures(prev => {
if (prev.length >= MAX_SAVED_SIGNATURES) {
return prev;
}
const timestamp = Date.now();
const nextEntry: SavedSignature = {
...payload,
id: generateId(),
label: (label || 'Signature').trim() || 'Signature',
createdAt: timestamp,
updatedAt: timestamp,
};
createdSignature = nextEntry;
return [nextEntry, ...prev];
});
return createdSignature
? { success: true, signature: createdSignature }
: { success: false, reason: 'limit' };
},
[]
);
const removeSignature = useCallback((id: string) => {
setSavedSignatures(prev => prev.filter(entry => entry.id !== id));
}, []);
const updateSignatureLabel = useCallback((id: string, nextLabel: string) => {
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
: entry
)
);
}, []);
const replaceSignature = useCallback((id: string, payload: SavedSignaturePayload) => {
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? {
...entry,
...payload,
updatedAt: Date.now(),
}
: entry
)
);
}, []);
const clearSignatures = useCallback(() => {
setSavedSignatures([]);
}, []);
const byTypeCounts = useMemo(() => {
return savedSignatures.reduce<Record<SavedSignatureType, number>>(
(acc, entry) => {
acc[entry.type] += 1;
return acc;
},
{ canvas: 0, image: 0, text: 0 }
);
}, [savedSignatures]);
return {
savedSignatures,
isAtCapacity,
addSignature,
removeSignature,
updateSignatureLabel,
replaceSignature,
clearSignatures,
byTypeCounts,
};
};
export type UseSavedSignaturesReturn = ReturnType<typeof useSavedSignatures>;