mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
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:
co-authored by
Dario Ghunney Ware
James Brunton
parent
f3cc30d0c2
commit
f2f4bd5230
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user