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",
|
"clear": "Clear",
|
||||||
"add": "Add",
|
"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",
|
"save": "Save Signature",
|
||||||
"applySignatures": "Apply Signatures",
|
"applySignatures": "Apply Signatures",
|
||||||
"personalSigs": "Personal Signatures",
|
"personalSigs": "Personal Signatures",
|
||||||
@ -2027,7 +2054,8 @@
|
|||||||
"draw": "Draw",
|
"draw": "Draw",
|
||||||
"canvas": "Canvas",
|
"canvas": "Canvas",
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
"text": "Text"
|
"text": "Text",
|
||||||
|
"saved": "Saved"
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"label": "Upload signature image",
|
"label": "Upload signature image",
|
||||||
@ -2038,6 +2066,7 @@
|
|||||||
"title": "How to add signature",
|
"title": "How to add signature",
|
||||||
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
|
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
|
||||||
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
|
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
|
||||||
|
"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.",
|
"text": "After entering your name above, click anywhere on the PDF to place your signature.",
|
||||||
"paused": "Placement paused",
|
"paused": "Placement paused",
|
||||||
"resumeHint": "Resume placement to click and add your signature.",
|
"resumeHint": "Resume placement to click and add your signature.",
|
||||||
|
|||||||
@ -2256,7 +2256,34 @@
|
|||||||
},
|
},
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"add": "Add",
|
"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",
|
"save": "Save Signature",
|
||||||
"applySignatures": "Apply Signatures",
|
"applySignatures": "Apply Signatures",
|
||||||
"personalSigs": "Personal Signatures",
|
"personalSigs": "Personal Signatures",
|
||||||
@ -2291,6 +2318,7 @@
|
|||||||
"title": "How to add signature",
|
"title": "How to add signature",
|
||||||
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
|
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
|
||||||
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
|
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
|
||||||
|
"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."
|
"text": "After entering your name above, click anywhere on the PDF to place your signature."
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
|
|||||||
@ -205,10 +205,12 @@ 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);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPreview(initialSignatureData);
|
renderPreview(initialSignatureData);
|
||||||
|
setSavedSignatureData(initialSignatureData);
|
||||||
}, [initialSignatureData]);
|
}, [initialSignatureData]);
|
||||||
|
|
||||||
return (
|
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 { 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";
|
||||||
@ -14,6 +14,8 @@ import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
|||||||
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
|
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
|
||||||
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
||||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
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 = {
|
type SignatureDrafts = {
|
||||||
canvas?: string;
|
canvas?: string;
|
||||||
@ -39,6 +41,8 @@ interface SignSettingsProps {
|
|||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SignatureSource = 'canvas' | 'image' | 'text' | 'saved';
|
||||||
|
|
||||||
const SignSettings = ({
|
const SignSettings = ({
|
||||||
parameters,
|
parameters,
|
||||||
onParameterChange,
|
onParameterChange,
|
||||||
@ -70,6 +74,244 @@ const SignSettings = ({
|
|||||||
const lastSyncedTextDraft = useRef<SignatureDrafts['text'] | null>(null);
|
const lastSyncedTextDraft = useRef<SignatureDrafts['text'] | null>(null);
|
||||||
const lastAppliedPlacementKey = useRef<string | null>(null);
|
const lastAppliedPlacementKey = useRef<string | null>(null);
|
||||||
const previousFileIndexRef = useRef(activeFileIndex);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
@ -77,6 +319,19 @@ const SignSettings = ({
|
|||||||
}
|
}
|
||||||
}, [selectedColor, penSize, disabled, onUpdateDrawSettings]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (signaturesApplied) {
|
if (signaturesApplied) {
|
||||||
setPlacementManuallyPaused(false);
|
setPlacementManuallyPaused(false);
|
||||||
@ -168,32 +423,8 @@ const SignSettings = ({
|
|||||||
if (!isCurrentTypeReady) {
|
if (!isCurrentTypeReady) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return signatureKeysByType[parameters.signatureType] ?? null;
|
||||||
switch (parameters.signatureType) {
|
}, [isCurrentTypeReady, parameters.signatureType, signatureKeysByType]);
|
||||||
case 'canvas':
|
|
||||||
return canvasSignatureData ?? null;
|
|
||||||
case 'image':
|
|
||||||
return imageSignatureData ?? null;
|
|
||||||
case 'text':
|
|
||||||
return JSON.stringify({
|
|
||||||
signerName: (parameters.signerName ?? '').trim(),
|
|
||||||
fontSize: parameters.fontSize ?? 16,
|
|
||||||
fontFamily: parameters.fontFamily ?? 'Helvetica',
|
|
||||||
textColor: parameters.textColor ?? '#000000',
|
|
||||||
});
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isCurrentTypeReady,
|
|
||||||
parameters.signatureType,
|
|
||||||
canvasSignatureData,
|
|
||||||
imageSignatureData,
|
|
||||||
parameters.signerName,
|
|
||||||
parameters.fontSize,
|
|
||||||
parameters.fontFamily,
|
|
||||||
parameters.textColor,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const shouldEnablePlacement = useMemo(() => {
|
const shouldEnablePlacement = useMemo(() => {
|
||||||
if (disabled) return false;
|
if (disabled) return false;
|
||||||
@ -424,57 +655,100 @@ const SignSettings = ({
|
|||||||
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
|
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
|
||||||
|
|
||||||
const renderSignatureBuilder = () => {
|
const renderSignatureBuilder = () => {
|
||||||
if (parameters.signatureType === 'canvas') {
|
if (signatureSource === 'saved') {
|
||||||
return (
|
return (
|
||||||
<DrawingCanvas
|
<SavedSignaturesSection
|
||||||
selectedColor={selectedColor}
|
signatures={savedSignatures}
|
||||||
penSize={penSize}
|
|
||||||
penSizeInput={penSizeInput}
|
|
||||||
onColorSwatchClick={() => setIsColorPickerOpen(true)}
|
|
||||||
onPenSizeChange={setPenSize}
|
|
||||||
onPenSizeInputChange={setPenSizeInput}
|
|
||||||
onSignatureDataChange={handleCanvasSignatureChange}
|
|
||||||
onDrawingComplete={() => {
|
|
||||||
onActivateSignaturePlacement?.();
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
initialSignatureData={canvasSignatureData}
|
isAtCapacity={isSavedSignatureLimitReached}
|
||||||
|
onUseSignature={handleUseSavedSignature}
|
||||||
|
onDeleteSignature={handleDeleteSavedSignature}
|
||||||
|
onRenameSignature={handleRenameSavedSignature}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parameters.signatureType === 'image') {
|
if (signatureSource === 'canvas') {
|
||||||
return (
|
return (
|
||||||
<ImageUploader
|
<Stack gap="xs">
|
||||||
onImageChange={handleImageChange}
|
<DrawingCanvas
|
||||||
disabled={disabled}
|
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 (
|
return (
|
||||||
<TextInputWithFont
|
<Stack gap="xs">
|
||||||
text={parameters.signerName || ''}
|
<TextInputWithFont
|
||||||
onTextChange={(text) => onParameterChange('signerName', text)}
|
text={parameters.signerName || ''}
|
||||||
fontSize={parameters.fontSize || 16}
|
onTextChange={(text) => onParameterChange('signerName', text)}
|
||||||
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
|
fontSize={parameters.fontSize || 16}
|
||||||
fontFamily={parameters.fontFamily || 'Helvetica'}
|
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
|
||||||
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
|
fontFamily={parameters.fontFamily || 'Helvetica'}
|
||||||
textColor={parameters.textColor || '#000000'}
|
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
|
||||||
onTextColorChange={(color) => onParameterChange('textColor', color)}
|
textColor={parameters.textColor || '#000000'}
|
||||||
disabled={disabled}
|
onTextColorChange={(color) => onParameterChange('textColor', color)}
|
||||||
/>
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
|
||||||
|
{renderSaveButton('text', hasTextSignature, handleSaveTextSignature)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const placementInstructions = () => {
|
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') {
|
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') {
|
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
|
const placementAlert = isCurrentTypeReady
|
||||||
@ -580,13 +854,14 @@ const SignSettings = ({
|
|||||||
{t('sign.step.createDesc', 'Choose how you want to create the signature')}
|
{t('sign.step.createDesc', 'Choose how you want to create the signature')}
|
||||||
</Text>
|
</Text>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={parameters.signatureType}
|
value={signatureSource}
|
||||||
fullWidth
|
fullWidth
|
||||||
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
|
onChange={(value) => handleSignatureSourceChange(value as SignatureSource)}
|
||||||
data={[
|
data={[
|
||||||
{ label: t('sign.type.canvas', 'Draw'), value: 'canvas' },
|
{ label: t('sign.type.canvas', 'Draw'), value: 'canvas' },
|
||||||
{ label: t('sign.type.image', 'Upload'), value: 'image' },
|
{ label: t('sign.type.image', 'Upload'), value: 'image' },
|
||||||
{ label: t('sign.type.text', 'Type'), value: 'text' },
|
{ label: t('sign.type.text', 'Type'), value: 'text' },
|
||||||
|
{ label: t('sign.type.saved', 'Saved'), value: 'saved' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{renderSignatureBuilder()}
|
{renderSignatureBuilder()}
|
||||||
|
|||||||
@ -43,23 +43,32 @@ export function PdfViewerToolbar({
|
|||||||
|
|
||||||
// Register for immediate scroll updates and sync with actual scroll state
|
// Register for immediate scroll updates and sync with actual scroll state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerImmediateScrollUpdate((currentPage, _totalPages) => {
|
const unregister = registerImmediateScrollUpdate((currentPage, _totalPages) => {
|
||||||
setPageInput(currentPage);
|
setPageInput(currentPage);
|
||||||
});
|
});
|
||||||
setPageInput(scrollState.currentPage);
|
setPageInput(scrollState.currentPage);
|
||||||
}, [registerImmediateScrollUpdate]);
|
return () => {
|
||||||
|
unregister?.();
|
||||||
|
};
|
||||||
|
}, [registerImmediateScrollUpdate, scrollState.currentPage]);
|
||||||
|
|
||||||
// Register for immediate zoom updates and sync with actual zoom state
|
// Register for immediate zoom updates and sync with actual zoom state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerImmediateZoomUpdate(setDisplayZoomPercent);
|
const unregister = registerImmediateZoomUpdate(setDisplayZoomPercent);
|
||||||
setDisplayZoomPercent(zoomState.zoomPercent || 140);
|
setDisplayZoomPercent(zoomState.zoomPercent || 140);
|
||||||
}, [zoomState.zoomPercent, registerImmediateZoomUpdate]);
|
return () => {
|
||||||
|
unregister?.();
|
||||||
|
};
|
||||||
|
}, [registerImmediateZoomUpdate, zoomState.zoomPercent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerImmediateSpreadUpdate((_mode, isDual) => {
|
const unregister = registerImmediateSpreadUpdate((_mode, isDual) => {
|
||||||
setIsDualPageActive(isDual);
|
setIsDualPageActive(isDual);
|
||||||
});
|
});
|
||||||
setIsDualPageActive(spreadState.isDualPage);
|
setIsDualPageActive(spreadState.isDualPage);
|
||||||
|
return () => {
|
||||||
|
unregister?.();
|
||||||
|
};
|
||||||
}, [registerImmediateSpreadUpdate, spreadState.isDualPage]);
|
}, [registerImmediateSpreadUpdate, spreadState.isDualPage]);
|
||||||
|
|
||||||
const handleZoomOut = () => {
|
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 { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||||
import { useSignature } from '@app/contexts/SignatureContext';
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
@ -123,10 +123,20 @@ const createTextStampImage = (
|
|||||||
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
||||||
const { provides: annotationApi } = useAnnotationCapability();
|
const { provides: annotationApi } = useAnnotationCapability();
|
||||||
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
|
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
|
||||||
const { getZoomState } = useViewer();
|
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
|
||||||
const currentZoom = getZoomState()?.currentZoom ?? 1;
|
const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1);
|
||||||
const lastStampImageRef = useRef<string | null>(null);
|
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(
|
const cssToPdfSize = useCallback(
|
||||||
(size: { width: number; height: number }) => {
|
(size: { width: number; height: number }) => {
|
||||||
const zoom = currentZoom || 1;
|
const zoom = currentZoom || 1;
|
||||||
|
|||||||
@ -39,14 +39,23 @@ import {
|
|||||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||||
|
|
||||||
function useImmediateNotifier<Args extends unknown[]>() {
|
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) => {
|
const register = useCallback((callback: (...args: Args) => void) => {
|
||||||
callbackRef.current = callback;
|
callbacksRef.current.add(callback);
|
||||||
|
return () => {
|
||||||
|
callbacksRef.current.delete(callback);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const trigger = useCallback((...args: Args) => {
|
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 };
|
return { register, trigger };
|
||||||
@ -91,9 +100,9 @@ interface ViewerContextType {
|
|||||||
getExportState: () => ExportState;
|
getExportState: () => ExportState;
|
||||||
|
|
||||||
// Immediate update callbacks
|
// Immediate update callbacks
|
||||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
|
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
|
||||||
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
|
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => () => void;
|
||||||
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void;
|
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => () => void;
|
||||||
|
|
||||||
// Internal - for bridges to trigger immediate updates
|
// Internal - for bridges to trigger immediate updates
|
||||||
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
|
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