mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Saved signatures
This commit is contained in:
parent
4a5f7e9c49
commit
c8bf43ea6b
@ -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.",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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;
|
||||
@ -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,8 +655,22 @@ const SignSettings = ({
|
||||
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
|
||||
|
||||
const renderSignatureBuilder = () => {
|
||||
if (parameters.signatureType === 'canvas') {
|
||||
if (signatureSource === 'saved') {
|
||||
return (
|
||||
<SavedSignaturesSection
|
||||
signatures={savedSignatures}
|
||||
disabled={disabled}
|
||||
isAtCapacity={isSavedSignatureLimitReached}
|
||||
onUseSignature={handleUseSavedSignature}
|
||||
onDeleteSignature={handleDeleteSavedSignature}
|
||||
onRenameSignature={handleRenameSavedSignature}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (signatureSource === 'canvas') {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
penSize={penSize}
|
||||
@ -440,19 +685,29 @@ const SignSettings = ({
|
||||
disabled={disabled}
|
||||
initialSignatureData={canvasSignatureData}
|
||||
/>
|
||||
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
|
||||
{renderSaveButton('canvas', hasCanvasSignature, handleSaveCanvasSignature)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters.signatureType === 'image') {
|
||||
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 (
|
||||
<Stack gap="xs">
|
||||
<TextInputWithFont
|
||||
text={parameters.signerName || ''}
|
||||
onTextChange={(text) => onParameterChange('signerName', text)}
|
||||
@ -464,17 +719,36 @@ const SignSettings = ({
|
||||
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()}
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
217
frontend/src/core/hooks/tools/sign/useSavedSignatures.ts
Normal file
217
frontend/src/core/hooks/tools/sign/useSavedSignatures.ts
Normal 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>;
|
||||
Loading…
Reference in New Issue
Block a user