diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index ce5f7c0ab..8dafadb2a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1998,7 +1998,34 @@ }, "clear": "Clear", "add": "Add", - "saved": "Saved Signatures", + "saved": { + "heading": "Saved signatures", + "description": "Reuse saved signatures at any time.", + "emptyTitle": "No saved signatures yet", + "emptyDescription": "Draw, upload, or type a signature above, then use \"Save to library\" to keep up to {{max}} favourites ready to use.", + "type": { + "canvas": "Drawing", + "image": "Upload", + "text": "Text" + }, + "limitTitle": "Limit reached", + "limitDescription": "Remove a saved signature before adding new ones (max {{max}}).", + "carouselPosition": "{{current}} of {{total}}", + "prev": "Previous", + "next": "Next", + "delete": "Remove", + "label": "Label", + "defaultLabel": "Signature", + "defaultCanvasLabel": "Drawing signature", + "defaultImageLabel": "Uploaded signature", + "defaultTextLabel": "Typed signature", + "saveButton": "Save signature", + "saveUnavailable": "Create a signature first to save it.", + "noChanges": "Current signature is already saved.", + "status": { + "saved": "Saved" + } + }, "save": "Save Signature", "applySignatures": "Apply Signatures", "personalSigs": "Personal Signatures", @@ -2027,7 +2054,8 @@ "draw": "Draw", "canvas": "Canvas", "image": "Image", - "text": "Text" + "text": "Text", + "saved": "Saved" }, "image": { "label": "Upload signature image", @@ -2038,6 +2066,7 @@ "title": "How to add signature", "canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.", "image": "After uploading your signature image above, click anywhere on the PDF to place it.", + "saved": "Select a saved signature above, then click anywhere on the PDF to place it.", "text": "After entering your name above, click anywhere on the PDF to place your signature.", "paused": "Placement paused", "resumeHint": "Resume placement to click and add your signature.", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 10e923c90..70695091f 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -2256,7 +2256,34 @@ }, "clear": "Clear", "add": "Add", - "saved": "Saved Signatures", + "saved": { + "heading": "Saved signatures", + "description": "Reuse saved signatures at any time.", + "emptyTitle": "No saved signatures yet", + "emptyDescription": "Draw, upload, or type a signature above, then use \"Save to library\" to keep up to {{max}} favourites ready to use.", + "type": { + "canvas": "Drawing", + "image": "Upload", + "text": "Text" + }, + "limitTitle": "Limit reached", + "limitDescription": "Remove a saved signature before adding new ones (max {{max}}).", + "carouselPosition": "{{current}} of {{total}}", + "prev": "Previous", + "next": "Next", + "delete": "Remove", + "label": "Label", + "defaultLabel": "Signature", + "defaultCanvasLabel": "Drawing signature", + "defaultImageLabel": "Uploaded signature", + "defaultTextLabel": "Typed signature", + "saveButton": "Save signature", + "saveUnavailable": "Create a signature first to save it.", + "noChanges": "Current signature is already saved.", + "status": { + "saved": "Saved" + } + }, "save": "Save Signature", "applySignatures": "Apply Signatures", "personalSigs": "Personal Signatures", @@ -2291,6 +2318,7 @@ "title": "How to add signature", "canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.", "image": "After uploading your signature image above, click anywhere on the PDF to place it.", + "saved": "Select a saved signature above, then click anywhere on the PDF to place it.", "text": "After entering your name above, click anywhere on the PDF to place your signature." }, "mode": { diff --git a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index 52908edbc..67476db68 100644 --- a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -205,10 +205,12 @@ export const DrawingCanvas: React.FC = ({ if (!initialSignatureData) { ctx.clearRect(0, 0, canvas.width, canvas.height); + setSavedSignatureData(null); return; } renderPreview(initialSignatureData); + setSavedSignatureData(initialSignatureData); }, [initialSignatureData]); return ( diff --git a/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx b/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx new file mode 100644 index 000000000..dbdab551b --- /dev/null +++ b/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx @@ -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 = { + canvas: 'indigo', + image: 'teal', + text: 'grape', +}; + +export const SavedSignaturesSection = ({ + signatures, + disabled = false, + isAtCapacity, + onUseSignature, + onDeleteSignature, + onRenameSignature, +}: SavedSignaturesSectionProps) => { + const { t } = useTranslation(); + const [labelDrafts, setLabelDrafts] = useState>({}); + const [activeIndex, setActiveIndex] = useState(0); + const activeSignature = signatures[activeIndex]; + const activeSignatureRef = useRef(activeSignature ?? null); + const appliedSignatureIdRef = useRef(null); + const onUseSignatureRef = useRef(onUseSignature); + + useEffect(() => { + onUseSignatureRef.current = onUseSignature; + }, [onUseSignature]); + + useEffect(() => { + activeSignatureRef.current = activeSignature ?? null; + }, [activeSignature]); + + useEffect(() => { + setLabelDrafts(prev => { + const nextDrafts: Record = {}; + 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 ( + + + {signature.signerName} + + + ); + } + + return ( + + + + ); + }; + + const emptyState = ( + + + {t('sign.saved.emptyTitle', 'No saved signatures yet')} + + {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 } + )} + + + + ); + + 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, signature: SavedSignature) => { + const { value } = event.currentTarget; + setLabelDrafts(prev => ({ ...prev, [signature.id]: value })); + }; + + const handleLabelKeyDown = (event: React.KeyboardEvent, 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 ( + + + + + {t('sign.saved.heading', 'Saved signatures')} + + + {t('sign.saved.description', 'Reuse saved signatures at any time.')} + + + + + {isAtCapacity && ( + + + {t('sign.saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', { + max: MAX_SAVED_SIGNATURES, + })} + + + )} + + {signatures.length === 0 ? ( + emptyState + ) : ( + + + + {t('sign.saved.carouselPosition', '{{current}} of {{total}}', { + current: activeIndex + 1, + total: signatures.length, + })} + + + handleNavigate('prev')} + disabled={disabled || activeIndex === 0} + > + + + handleNavigate('next')} + disabled={disabled || activeIndex >= signatures.length - 1} + > + + + + + + {activeSignature && ( + + + + + {typeLabel(activeSignature.type)} + + + onDeleteSignature(activeSignature)} + disabled={disabled} + > + + + + + + {renderPreview(activeSignature)} + + handleLabelChange(event, activeSignature)} + onBlur={() => handleLabelBlur(activeSignature)} + onKeyDown={event => handleLabelKeyDown(event, activeSignature)} + disabled={disabled} + /> + + + + )} + + )} + + ); +}; + +export default SavedSignaturesSection; diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index 6c2599d0c..a0a1bd8db 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useTranslation } from "react-i18next"; import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } from '@mantine/core'; import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; @@ -14,6 +14,8 @@ import { ImageUploader } from "@app/components/annotation/shared/ImageUploader"; import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont"; import { ColorPicker } from "@app/components/annotation/shared/ColorPicker"; import { LocalIcon } from "@app/components/shared/LocalIcon"; +import { useSavedSignatures, SavedSignature, SavedSignaturePayload, SavedSignatureType, MAX_SAVED_SIGNATURES, AddSignatureResult } from '@app/hooks/tools/sign/useSavedSignatures'; +import { SavedSignaturesSection } from '@app/components/tools/sign/SavedSignaturesSection'; type SignatureDrafts = { canvas?: string; @@ -39,6 +41,8 @@ interface SignSettingsProps { onSave?: () => void; } +type SignatureSource = 'canvas' | 'image' | 'text' | 'saved'; + const SignSettings = ({ parameters, onParameterChange, @@ -70,6 +74,244 @@ const SignSettings = ({ const lastSyncedTextDraft = useRef(null); const lastAppliedPlacementKey = useRef(null); const previousFileIndexRef = useRef(activeFileIndex); + const { + savedSignatures, + isAtCapacity: isSavedSignatureLimitReached, + addSignature, + removeSignature, + updateSignatureLabel, + byTypeCounts, + } = useSavedSignatures(); + const [signatureSource, setSignatureSource] = useState(parameters.signatureType); + const [lastSavedSignatureKeys, setLastSavedSignatureKeys] = useState>({ + 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 = ( + + ); + + if (tooltipMessage) { + return ( + + {button} + + ); + } + + return button; + }; + + useEffect(() => { + if (signatureSource === 'saved') { + return; + } + if (signatureSource !== parameters.signatureType) { + setSignatureSource(parameters.signatureType); + } + }, [parameters.signatureType, signatureSource]); useEffect(() => { if (!disabled) { @@ -77,6 +319,19 @@ const SignSettings = ({ } }, [selectedColor, penSize, disabled, onUpdateDrawSettings]); + const handleSignatureSourceChange = useCallback( + (value: SignatureSource) => { + setSignatureSource(value); + if (value === 'saved') { + return; + } + if (parameters.signatureType !== value) { + onParameterChange('signatureType', value); + } + }, + [onParameterChange, parameters.signatureType] + ); + useEffect(() => { if (signaturesApplied) { setPlacementManuallyPaused(false); @@ -168,32 +423,8 @@ const SignSettings = ({ if (!isCurrentTypeReady) { return null; } - - switch (parameters.signatureType) { - case 'canvas': - return canvasSignatureData ?? null; - case 'image': - return imageSignatureData ?? null; - case 'text': - return JSON.stringify({ - signerName: (parameters.signerName ?? '').trim(), - fontSize: parameters.fontSize ?? 16, - fontFamily: parameters.fontFamily ?? 'Helvetica', - textColor: parameters.textColor ?? '#000000', - }); - default: - return null; - } - }, [ - isCurrentTypeReady, - parameters.signatureType, - canvasSignatureData, - imageSignatureData, - parameters.signerName, - parameters.fontSize, - parameters.fontFamily, - parameters.textColor, - ]); + return signatureKeysByType[parameters.signatureType] ?? null; + }, [isCurrentTypeReady, parameters.signatureType, signatureKeysByType]); const shouldEnablePlacement = useMemo(() => { if (disabled) return false; @@ -424,57 +655,100 @@ const SignSettings = ({ }, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]); const renderSignatureBuilder = () => { - if (parameters.signatureType === 'canvas') { + if (signatureSource === 'saved') { return ( - setIsColorPickerOpen(true)} - onPenSizeChange={setPenSize} - onPenSizeInputChange={setPenSizeInput} - onSignatureDataChange={handleCanvasSignatureChange} - onDrawingComplete={() => { - onActivateSignaturePlacement?.(); - }} + ); } - if (parameters.signatureType === 'image') { + if (signatureSource === 'canvas') { return ( - + + setIsColorPickerOpen(true)} + onPenSizeChange={setPenSize} + onPenSizeInputChange={setPenSizeInput} + onSignatureDataChange={handleCanvasSignatureChange} + onDrawingComplete={() => { + onActivateSignaturePlacement?.(); + }} + disabled={disabled} + initialSignatureData={canvasSignatureData} + /> + + {renderSaveButton('canvas', hasCanvasSignature, handleSaveCanvasSignature)} + + + ); + } + + if (signatureSource === 'image') { + return ( + + + + {renderSaveButton('image', hasImageSignature, handleSaveImageSignature)} + + ); } return ( - onParameterChange('signerName', text)} - fontSize={parameters.fontSize || 16} - onFontSizeChange={(size) => onParameterChange('fontSize', size)} - fontFamily={parameters.fontFamily || 'Helvetica'} - onFontFamilyChange={(family) => onParameterChange('fontFamily', family)} - textColor={parameters.textColor || '#000000'} - onTextColorChange={(color) => onParameterChange('textColor', color)} - disabled={disabled} - /> + + onParameterChange('signerName', text)} + fontSize={parameters.fontSize || 16} + onFontSizeChange={(size) => onParameterChange('fontSize', size)} + fontFamily={parameters.fontFamily || 'Helvetica'} + onFontFamilyChange={(family) => onParameterChange('fontFamily', family)} + textColor={parameters.textColor || '#000000'} + onTextColorChange={(color) => onParameterChange('textColor', color)} + disabled={disabled} + /> + + {renderSaveButton('text', hasTextSignature, handleSaveTextSignature)} + + ); }; const placementInstructions = () => { + if (signatureSource === 'saved') { + return t( + 'sign.instructions.saved', + 'Select a saved signature above, then click anywhere on the PDF to place it.' + ); + } if (parameters.signatureType === 'canvas') { - return t('sign.instructions.canvas', 'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.'); + return t( + 'sign.instructions.canvas', + 'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.' + ); } if (parameters.signatureType === 'image') { - return t('sign.instructions.image', 'After uploading your signature image, click anywhere on the PDF to place it.'); + return t( + 'sign.instructions.image', + 'After uploading your signature image, click anywhere on the PDF to place it.' + ); } - return t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.'); + return t( + 'sign.instructions.text', + 'After entering your name above, click anywhere on the PDF to place your signature.' + ); }; const placementAlert = isCurrentTypeReady @@ -580,13 +854,14 @@ const SignSettings = ({ {t('sign.step.createDesc', 'Choose how you want to create the signature')} onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')} + onChange={(value) => handleSignatureSourceChange(value as SignatureSource)} data={[ { label: t('sign.type.canvas', 'Draw'), value: 'canvas' }, { label: t('sign.type.image', 'Upload'), value: 'image' }, { label: t('sign.type.text', 'Type'), value: 'text' }, + { label: t('sign.type.saved', 'Saved'), value: 'saved' }, ]} /> {renderSignatureBuilder()} diff --git a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx index 107967ef9..a2e36d4e2 100644 --- a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx +++ b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx @@ -43,23 +43,32 @@ export function PdfViewerToolbar({ // Register for immediate scroll updates and sync with actual scroll state useEffect(() => { - registerImmediateScrollUpdate((currentPage, _totalPages) => { + const unregister = registerImmediateScrollUpdate((currentPage, _totalPages) => { setPageInput(currentPage); }); setPageInput(scrollState.currentPage); - }, [registerImmediateScrollUpdate]); + return () => { + unregister?.(); + }; + }, [registerImmediateScrollUpdate, scrollState.currentPage]); // Register for immediate zoom updates and sync with actual zoom state useEffect(() => { - registerImmediateZoomUpdate(setDisplayZoomPercent); + const unregister = registerImmediateZoomUpdate(setDisplayZoomPercent); setDisplayZoomPercent(zoomState.zoomPercent || 140); - }, [zoomState.zoomPercent, registerImmediateZoomUpdate]); + return () => { + unregister?.(); + }; + }, [registerImmediateZoomUpdate, zoomState.zoomPercent]); useEffect(() => { - registerImmediateSpreadUpdate((_mode, isDual) => { + const unregister = registerImmediateSpreadUpdate((_mode, isDual) => { setIsDualPageActive(isDual); }); setIsDualPageActive(spreadState.isDualPage); + return () => { + unregister?.(); + }; }, [registerImmediateSpreadUpdate, spreadState.isDualPage]); const handleZoomOut = () => { diff --git a/frontend/src/core/components/viewer/SignatureAPIBridge.tsx b/frontend/src/core/components/viewer/SignatureAPIBridge.tsx index c914c50a2..200d5e43f 100644 --- a/frontend/src/core/components/viewer/SignatureAPIBridge.tsx +++ b/frontend/src/core/components/viewer/SignatureAPIBridge.tsx @@ -1,4 +1,4 @@ -import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef } from 'react'; +import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef, useState } from 'react'; import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models'; import { useSignature } from '@app/contexts/SignatureContext'; @@ -123,10 +123,20 @@ const createTextStampImage = ( export const SignatureAPIBridge = forwardRef(function SignatureAPIBridge(_, ref) { const { provides: annotationApi } = useAnnotationCapability(); const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature(); - const { getZoomState } = useViewer(); - const currentZoom = getZoomState()?.currentZoom ?? 1; + const { getZoomState, registerImmediateZoomUpdate } = useViewer(); + const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1); const lastStampImageRef = useRef(null); + useEffect(() => { + setCurrentZoom(getZoomState()?.currentZoom ?? 1); + const unregister = registerImmediateZoomUpdate(percent => { + setCurrentZoom(Math.max(percent / 100, 0.01)); + }); + return () => { + unregister?.(); + }; + }, [getZoomState, registerImmediateZoomUpdate]); + const cssToPdfSize = useCallback( (size: { width: number; height: number }) => { const zoom = currentZoom || 1; diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 2ffe2210d..fc0f1ab70 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -39,14 +39,23 @@ import { import { SpreadMode } from '@embedpdf/plugin-spread/react'; function useImmediateNotifier() { - const callbackRef = useRef<((...args: Args) => void) | null>(null); + const callbacksRef = useRef(new Set<(...args: Args) => void>()); const register = useCallback((callback: (...args: Args) => void) => { - callbackRef.current = callback; + callbacksRef.current.add(callback); + return () => { + callbacksRef.current.delete(callback); + }; }, []); const trigger = useCallback((...args: Args) => { - callbackRef.current?.(...args); + callbacksRef.current.forEach(cb => { + try { + cb(...args); + } catch (error) { + console.error('Immediate callback error:', error); + } + }); }, []); return { register, trigger }; @@ -91,9 +100,9 @@ interface ViewerContextType { getExportState: () => ExportState; // Immediate update callbacks - registerImmediateZoomUpdate: (callback: (percent: number) => void) => void; - registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void; - registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void; + registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void; + registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => () => void; + registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => () => void; // Internal - for bridges to trigger immediate updates triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void; diff --git a/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts b/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts new file mode 100644 index 000000000..665f40814 --- /dev/null +++ b/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts @@ -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(() => 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>( + (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;