Save signatures to server (#5080)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
Reece Browne
2025-11-29 19:29:06 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent fde449e738
commit 651f17f1c6
19 changed files with 1674 additions and 425 deletions
@@ -1,13 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, 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';
import type { StorageType } from '@app/services/signatureStorageService';
interface SavedSignaturesSectionProps {
signatures: SavedSignature[];
disabled?: boolean;
isAtCapacity: boolean;
storageType?: StorageType | null;
onUseSignature: (signature: SavedSignature) => void;
onDeleteSignature: (signature: SavedSignature) => void;
onRenameSignature: (id: string, label: string) => void;
@@ -24,6 +26,7 @@ export const SavedSignaturesSection = ({
signatures,
disabled = false,
isAtCapacity,
storageType: _storageType,
onUseSignature,
onDeleteSignature,
onRenameSignature,
@@ -36,20 +39,30 @@ export const SavedSignaturesSection = ({
[t, translationScope]
);
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
const [activeIndex, setActiveIndex] = useState(0);
const activeSignature = signatures[activeIndex];
const activeSignatureRef = useRef<SavedSignature | null>(activeSignature ?? null);
const appliedSignatureIdRef = useRef<string | null>(null);
// Group signatures by scope
const groupedSignatures = useMemo(() => {
const personal = signatures.filter(sig => sig.scope === 'personal');
const shared = signatures.filter(sig => sig.scope === 'shared');
const localStorage = signatures.filter(sig => sig.scope === 'localStorage');
return { personal, shared, localStorage };
}, [signatures]);
// Separate carousel state for each category
const [activePersonalIndex, setActivePersonalIndex] = useState(0);
const [activeSharedIndex, setActiveSharedIndex] = useState(0);
const [activeLocalStorageIndex, setActiveLocalStorageIndex] = useState(0);
const activePersonalSignature = groupedSignatures.personal[activePersonalIndex];
const activeSharedSignature = groupedSignatures.shared[activeSharedIndex];
const activeLocalStorageSignature = groupedSignatures.localStorage[activeLocalStorageIndex];
const onUseSignatureRef = useRef(onUseSignature);
useEffect(() => {
onUseSignatureRef.current = onUseSignature;
}, [onUseSignature]);
useEffect(() => {
activeSignatureRef.current = activeSignature ?? null;
}, [activeSignature]);
useEffect(() => {
setLabelDrafts(prev => {
const nextDrafts: Record<string, string> = {};
@@ -60,25 +73,18 @@ export const SavedSignaturesSection = ({
});
}, [signatures]);
// Reset carousel indices when categories change
useEffect(() => {
if (signatures.length === 0) {
setActiveIndex(0);
return;
}
setActiveIndex(prev => Math.min(prev, Math.max(signatures.length - 1, 0)));
}, [signatures.length]);
setActivePersonalIndex(prev => Math.min(prev, Math.max(groupedSignatures.personal.length - 1, 0)));
}, [groupedSignatures.personal.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]
);
useEffect(() => {
setActiveSharedIndex(prev => Math.min(prev, Math.max(groupedSignatures.shared.length - 1, 0)));
}, [groupedSignatures.shared.length]);
useEffect(() => {
setActiveLocalStorageIndex(prev => Math.min(prev, Math.max(groupedSignatures.localStorage.length - 1, 0)));
}, [groupedSignatures.localStorage.length]);
const renderPreview = (signature: SavedSignature) => {
if (signature.type === 'text') {
@@ -193,21 +199,6 @@ export const SavedSignaturesSection = ({
}
};
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">
@@ -234,67 +225,253 @@ export const SavedSignaturesSection = ({
{signatures.length === 0 ? (
emptyState
) : (
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeIndex + 1,
total: signatures.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('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={translate('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>
<Stack gap="md">
{/* Personal Signatures */}
{groupedSignatures.personal.length > 0 && activePersonalSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="material-symbols:person-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate('saved.personalHeading', 'Personal Signatures')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{translate('saved.personalDescription', 'Only you can see these signatures.')}
</Text>
{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={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activePersonalIndex + 1,
total: groupedSignatures.personal.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => setActivePersonalIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activePersonalIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))}
disabled={disabled || activePersonalIndex >= groupedSignatures.personal.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
{renderPreview(activeSignature)}
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activePersonalSignature.type]} variant="light">
{typeLabel(activePersonalSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
onClick={() => onUseSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{renderPreview(activePersonalSignature)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activePersonalSignature.id] ?? activePersonalSignature.label}
onChange={event => handleLabelChange(event, activePersonalSignature)}
onBlur={() => handleLabelBlur(activePersonalSignature)}
onKeyDown={event => handleLabelKeyDown(event, activePersonalSignature)}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
onChange={event => handleLabelChange(event, activeSignature)}
onBlur={() => handleLabelBlur(activeSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeSignature)}
disabled={disabled}
/>
{/* Shared Signatures */}
{groupedSignatures.shared.length > 0 && activeSharedSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="material-symbols:groups-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate('saved.sharedHeading', 'Shared Signatures')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{translate('saved.sharedDescription', 'All users can see and use these signatures.')}
</Text>
</Stack>
</Card>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeSharedIndex + 1,
total: groupedSignatures.shared.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => setActiveSharedIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activeSharedIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))}
disabled={disabled || activeSharedIndex >= groupedSignatures.shared.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activeSharedSignature.type]} variant="light">
{typeLabel(activeSharedSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
onClick={() => onUseSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{renderPreview(activeSharedSignature)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activeSharedSignature.id] ?? activeSharedSignature.label}
onChange={event => handleLabelChange(event, activeSharedSignature)}
onBlur={() => handleLabelBlur(activeSharedSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeSharedSignature)}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
)}
{/* Browser Storage (localStorage) - Temporary */}
{groupedSignatures.localStorage.length > 0 && activeLocalStorageSignature && (
<Stack gap="xs">
<Alert color="blue" title={translate('saved.tempStorageTitle', 'Temporary browser storage')}>
<Text size="xs">
{translate(
'saved.tempStorageDescription',
'Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers.'
)}
</Text>
</Alert>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeLocalStorageIndex + 1,
total: groupedSignatures.localStorage.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activeLocalStorageIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))}
disabled={disabled || activeLocalStorageIndex >= groupedSignatures.localStorage.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activeLocalStorageSignature.type]} variant="light">
{typeLabel(activeLocalStorageSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
onClick={() => onUseSignature(activeLocalStorageSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeLocalStorageSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{renderPreview(activeLocalStorageSignature)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activeLocalStorageSignature.id] ?? activeLocalStorageSignature.label}
onChange={event => handleLabelChange(event, activeLocalStorageSignature)}
onBlur={() => handleLabelBlur(activeLocalStorageSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeLocalStorageSignature)}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
)}
</Stack>
)}
@@ -16,6 +16,7 @@ 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';
import { buildSignaturePreview } from '@app/utils/signaturePreview';
type SignatureDrafts = {
canvas?: string;
@@ -99,6 +100,7 @@ const SignSettings = ({
removeSignature,
updateSignatureLabel,
byTypeCounts,
storageType,
} = useSavedSignatures();
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
const paramSource = parameters.signatureType as SignatureSource;
@@ -157,11 +159,11 @@ const SignSettings = ({
}, [canvasSignatureData, imageSignatureData, buildTextSignatureKey, parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
const saveSignatureToLibrary = useCallback(
(payload: SavedSignaturePayload, type: SavedSignatureType): AddSignatureResult => {
async (payload: SavedSignaturePayload, type: SavedSignatureType, scope: 'personal' | 'shared'): Promise<AddSignatureResult> => {
if (isSavedSignatureLimitReached) {
return { success: false, reason: 'limit' };
}
return addSignature(payload, getDefaultSavedLabel(type));
return await addSignature(payload, getDefaultSavedLabel(type), scope);
},
[addSignature, getDefaultSavedLabel, isSavedSignatureLimitReached]
);
@@ -176,40 +178,57 @@ const SignSettings = ({
[signatureKeysByType]
);
const handleSaveCanvasSignature = useCallback(() => {
const handleSaveCanvasSignature = useCallback(async (scope: 'personal' | 'shared') => {
if (!canvasSignatureData) {
return;
}
const result = saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas');
const result = await saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas', scope);
if (result.success) {
setLastSavedKeyForType('canvas');
}
}, [canvasSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
const handleSaveImageSignature = useCallback(() => {
const handleSaveImageSignature = useCallback(async (scope: 'personal' | 'shared') => {
if (!imageSignatureData) {
return;
}
const result = saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image');
const result = await saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image', scope);
if (result.success) {
setLastSavedKeyForType('image');
}
}, [imageSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
const handleSaveTextSignature = useCallback(() => {
const handleSaveTextSignature = useCallback(async (scope: 'personal' | 'shared') => {
const signerName = (parameters.signerName ?? '').trim();
if (!signerName) {
return;
}
const result = saveSignatureToLibrary(
// Generate image from text signature
const preview = await buildSignaturePreview({
signatureType: 'text',
signerName,
fontFamily: parameters.fontFamily ?? 'Helvetica',
fontSize: parameters.fontSize ?? 16,
textColor: parameters.textColor ?? '#000000',
});
if (!preview?.dataUrl) {
console.error('Failed to generate text signature preview');
return;
}
const result = await saveSignatureToLibrary(
{
type: 'text',
dataUrl: preview.dataUrl,
signerName,
fontFamily: parameters.fontFamily ?? 'Helvetica',
fontSize: parameters.fontSize ?? 16,
textColor: parameters.textColor ?? '#000000',
},
'text'
'text',
scope
);
if (result.success) {
setLastSavedKeyForType('text');
@@ -286,16 +305,21 @@ const SignSettings = ({
[updateSignatureLabel]
);
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void, scope: 'personal' | 'shared', icon: string, label: string) => {
if (!canUseSavedLibrary) {
return null;
}
const label = translate('saved.saveButton', 'Save signature');
const currentKey = signatureKeysByType[type];
const lastSavedKey = lastSavedSignatureKeys[type];
const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey);
const isSaved = isReady && !hasChanges;
// Only show backend storage buttons when backend is available
const showButton = storageType === 'backend' || storageType === null;
if (!showButton) {
return null;
}
let tooltipMessage: string | undefined;
if (!isReady) {
tooltipMessage = translate('saved.saveUnavailable', 'Create a signature first to save it.');
@@ -312,11 +336,11 @@ const SignSettings = ({
size="xs"
variant="outline"
color={isSaved ? 'green' : undefined}
onClick={onClick}
onClick={() => onClick(scope)}
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
leftSection={<LocalIcon icon="material-symbols:save-rounded" width={16} height={16} />}
leftSection={<LocalIcon icon={icon} width={16} height={16} />}
>
{isSaved ? translate('saved.status.saved', 'Saved') : label}
{label}
</Button>
);
@@ -331,15 +355,38 @@ const SignSettings = ({
return button;
};
const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
const button = renderSaveButton(type, isReady, onClick);
if (!button) {
const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void) => {
if (!canUseSavedLibrary) {
return null;
}
const personalButton = renderSaveButton(
type,
isReady,
onClick,
'personal',
'material-symbols:person-rounded',
translate('saved.savePersonal', 'Save Personal')
);
const sharedButton = renderSaveButton(
type,
isReady,
onClick,
'shared',
'material-symbols:groups-rounded',
translate('saved.saveShared', 'Save Shared')
);
if (!personalButton && !sharedButton) {
return null;
}
return (
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{button}
</Box>
<Group gap="xs" style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{personalButton}
{sharedButton}
</Group>
);
};
@@ -744,6 +791,7 @@ const SignSettings = ({
signatures={savedSignatures}
disabled={disabled}
isAtCapacity={isSavedSignatureLimitReached}
storageType={storageType}
onUseSignature={handleUseSavedSignature}
onDeleteSignature={handleDeleteSavedSignature}
onRenameSignature={handleRenameSavedSignature}