Bug/v2/signature fixes (#5104)

Co-authored-by: Dario Ghunney Ware <[email protected]>
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Reece Browne
2025-12-02 22:48:29 +00:00
committed by GitHub
co-authored by Dario Ghunney Ware James Brunton
parent f3cc30d0c2
commit f2f4bd5230
9 changed files with 280 additions and 74 deletions
@@ -2,14 +2,16 @@ 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 { SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
import type { StorageType } from '@app/services/signatureStorageService';
interface SavedSignaturesSectionProps {
signatures: SavedSignature[];
disabled?: boolean;
isAtCapacity: boolean;
maxLimit: number;
storageType?: StorageType | null;
isAdmin?: boolean;
onUseSignature: (signature: SavedSignature) => void;
onDeleteSignature: (signature: SavedSignature) => void;
onRenameSignature: (id: string, label: string) => void;
@@ -26,7 +28,9 @@ export const SavedSignaturesSection = ({
signatures,
disabled = false,
isAtCapacity,
maxLimit,
storageType: _storageType,
isAdmin = false,
onUseSignature,
onDeleteSignature,
onRenameSignature,
@@ -155,7 +159,7 @@ export const SavedSignaturesSection = ({
{translate(
'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 }
{ max: maxLimit }
)}
</Text>
</Stack>
@@ -216,7 +220,7 @@ export const SavedSignaturesSection = ({
<Alert color="yellow" title={translate('saved.limitTitle', 'Limit reached')}>
<Text size="sm">
{translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
max: MAX_SAVED_SIGNATURES,
max: maxLimit,
})}
</Text>
</Alert>
@@ -365,17 +369,19 @@ export const SavedSignaturesSection = ({
>
<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>
{isAdmin && (
<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)}
@@ -14,7 +14,7 @@ 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 { useSavedSignatures, SavedSignature, SavedSignaturePayload, SavedSignatureType, AddSignatureResult } from '@app/hooks/tools/sign/useSavedSignatures';
import { SavedSignaturesSection } from '@app/components/tools/sign/SavedSignaturesSection';
import { buildSignaturePreview } from '@app/utils/signaturePreview';
@@ -96,11 +96,13 @@ const SignSettings = ({
const {
savedSignatures,
isAtCapacity: isSavedSignatureLimitReached,
maxLimit,
addSignature,
removeSignature,
updateSignatureLabel,
byTypeCounts,
storageType,
isAdmin,
} = useSavedSignatures();
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
const paramSource = parameters.signatureType as SignatureSource;
@@ -246,16 +248,19 @@ const SignSettings = ({
(signature: SavedSignature) => {
setPlacementManuallyPaused(false);
// Use the data URL directly (already converted to base64 when loaded)
const dataUrlToUse = signature.dataUrl;
if (signature.type === 'canvas') {
if (parameters.signatureType !== 'canvas') {
onParameterChange('signatureType', 'canvas');
}
setCanvasSignatureData(signature.dataUrl);
setCanvasSignatureData(dataUrlToUse);
} else if (signature.type === 'image') {
if (parameters.signatureType !== 'image') {
onParameterChange('signatureType', 'image');
}
setImageSignatureData(signature.dataUrl);
setImageSignatureData(dataUrlToUse);
} else if (signature.type === 'text') {
if (parameters.signatureType !== 'text') {
onParameterChange('signatureType', 'text');
@@ -269,7 +274,7 @@ const SignSettings = ({
const savedKey =
signature.type === 'text'
? buildTextSignatureKey(signature.signerName, signature.fontSize, signature.fontFamily, signature.textColor)
: signature.dataUrl;
: dataUrlToUse;
setLastSavedKeyForType(signature.type, savedKey);
const activate = () => onActivateSignaturePlacement?.();
@@ -326,8 +331,8 @@ const SignSettings = ({
} else if (isSaved) {
tooltipMessage = translate('saved.noChanges', 'Current signature is already saved.');
} else if (isSavedSignatureLimitReached) {
tooltipMessage = translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
max: MAX_SAVED_SIGNATURES,
tooltipMessage = translate('saved.limitDescription', 'You have reached the maximum limit of {{max}} saved signatures. Remove a saved signature before adding new ones.', {
max: maxLimit,
});
}
@@ -791,7 +796,9 @@ const SignSettings = ({
signatures={savedSignatures}
disabled={disabled}
isAtCapacity={isSavedSignatureLimitReached}
maxLimit={maxLimit}
storageType={storageType}
isAdmin={isAdmin}
onUseSignature={handleUseSavedSignature}
onDeleteSignature={handleDeleteSavedSignature}
onRenameSignature={handleRenameSavedSignature}
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { signatureStorageService, type StorageType } from '@app/services/signatureStorageService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
export const MAX_SAVED_SIGNATURES = 10;
export const MAX_SAVED_SIGNATURES_BACKEND = 20; // Backend limit per user
export const MAX_SAVED_SIGNATURES_LOCALSTORAGE = 10; // LocalStorage limit
export type SavedSignatureType = 'canvas' | 'image' | 'text';
export type SignatureScope = 'personal' | 'shared' | 'localStorage';
@@ -49,6 +51,8 @@ export const useSavedSignatures = () => {
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
const [storageType, setStorageType] = useState<StorageType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { config } = useAppConfig();
const isAdmin = config?.isAdmin ?? false;
// Load signatures and detect storage type on mount
useEffect(() => {
@@ -97,7 +101,9 @@ export const useSavedSignatures = () => {
return () => window.removeEventListener('storage', syncFromStorage);
}, [storageType]);
const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES;
// Different limits for backend vs localStorage
const maxLimit = storageType === 'backend' ? MAX_SAVED_SIGNATURES_BACKEND : MAX_SAVED_SIGNATURES_LOCALSTORAGE;
const isAtCapacity = savedSignatures.length >= maxLimit;
const addSignature = useCallback(
async (payload: SavedSignaturePayload, label?: string, scope?: SignatureScope): Promise<AddSignatureResult> => {
@@ -108,7 +114,7 @@ export const useSavedSignatures = () => {
return { success: false, reason: 'invalid' };
}
if (savedSignatures.length >= MAX_SAVED_SIGNATURES) {
if (isAtCapacity) {
return { success: false, reason: 'limit' };
}
@@ -146,17 +152,24 @@ export const useSavedSignatures = () => {
const updateSignatureLabel = useCallback(async (id: string, nextLabel: string) => {
try {
await signatureStorageService.updateSignatureLabel(id, nextLabel);
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
: entry
)
);
// Reload signatures to get updated data from backend
if (storageType === 'backend') {
const signatures = await signatureStorageService.loadSignatures();
setSavedSignatures(signatures);
} else {
// For localStorage, update in place
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
: entry
)
);
}
} catch (error) {
console.error('[useSavedSignatures] Failed to update signature label:', error);
}
}, []);
}, [storageType]);
const replaceSignature = useCallback(
async (id: string, payload: SavedSignaturePayload) => {
@@ -201,6 +214,7 @@ export const useSavedSignatures = () => {
return {
savedSignatures,
isAtCapacity,
maxLimit,
addSignature,
removeSignature,
updateSignatureLabel,
@@ -209,6 +223,7 @@ export const useSavedSignatures = () => {
byTypeCounts,
storageType,
isLoading,
isAdmin,
};
};
@@ -14,7 +14,6 @@ interface SignatureStorageCapabilities {
class SignatureStorageService {
private capabilities: SignatureStorageCapabilities | null = null;
private detectionPromise: Promise<SignatureStorageCapabilities> | null = null;
private blobUrls: Set<string> = new Set();
/**
* Detect if backend supports signature storage API
@@ -83,9 +82,6 @@ class SignatureStorageService {
* Load all signatures
*/
async loadSignatures(): Promise<SavedSignature[]> {
// Clean up old blob URLs before loading new ones
this.cleanup();
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend) {
@@ -130,9 +126,7 @@ class SignatureStorageService {
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend) {
// Backend only stores images - labels not supported for backend signatures
console.log('[SignatureStorage] Label updates not supported for backend signatures');
return;
await this._updateLabelInBackend(id, label);
} else {
this._updateLabelInLocalStorage(id, label);
}
@@ -144,7 +138,7 @@ class SignatureStorageService {
const response = await apiClient.get<SavedSignature[]>('/api/v1/proprietary/signatures');
const signatures = response.data;
// Fetch image data for each signature and convert to blob URLs
// Fetch image data for each signature and convert to data URLs
const signaturePromises = signatures.map(async (sig) => {
if (sig.dataUrl && sig.dataUrl.startsWith('/api/v1/general/signatures/')) {
try {
@@ -153,14 +147,20 @@ class SignatureStorageService {
responseType: 'arraybuffer',
});
// Convert to blob URL
// Convert to data URL (base64) for both display and use
const blob = new Blob([imageResponse.data], {
type: imageResponse.headers['content-type'] || 'image/png',
});
const blobUrl = URL.createObjectURL(blob);
this.blobUrls.add(blobUrl);
return { ...sig, dataUrl: blobUrl };
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
// Use data URL for everything - more reliable than blob URLs
return { ...sig, dataUrl };
} catch (error) {
console.error(`[SignatureStorage] Failed to load image for ${sig.id}:`, error);
return sig; // Return original if image fetch fails
@@ -184,6 +184,10 @@ class SignatureStorageService {
await apiClient.delete(`/api/v1/proprietary/signatures/${id}`);
}
private async _updateLabelInBackend(id: string, label: string): Promise<void> {
await apiClient.post(`/api/v1/proprietary/signatures/${id}/label`, { label });
}
// LocalStorage methods
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
@@ -267,16 +271,6 @@ class SignatureStorageService {
return { migrated, failed };
}
/**
* Clean up blob URLs to prevent memory leaks
*/
cleanup(): void {
this.blobUrls.forEach(url => {
URL.revokeObjectURL(url);
});
this.blobUrls.clear();
}
}
export const signatureStorageService = new SignatureStorageService();