mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-01 20:10:35 +01:00
parent
0f73a1cf13
commit
dd1c653301
@ -2154,7 +2154,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",
|
||||
@ -2183,7 +2210,8 @@
|
||||
"draw": "Draw",
|
||||
"canvas": "Canvas",
|
||||
"image": "Image",
|
||||
"text": "Text"
|
||||
"text": "Text",
|
||||
"saved": "Saved"
|
||||
},
|
||||
"image": {
|
||||
"label": "Upload signature image",
|
||||
@ -2194,6 +2222,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.",
|
||||
|
||||
@ -205,10 +205,13 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
|
||||
if (!initialSignatureData) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
setSavedSignatureData(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
renderPreview(initialSignatureData);
|
||||
setSavedSignatureData(initialSignatureData);
|
||||
}, [initialSignatureData]);
|
||||
|
||||
return (
|
||||
@ -281,7 +284,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
touchAction: 'none',
|
||||
backgroundColor: 'white',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
maxWidth: '50rem',
|
||||
height: '25rem',
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
|
||||
@ -0,0 +1,292 @@
|
||||
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 appliedSignatureIdRef = useRef<string | null>(null);
|
||||
const onUseSignatureRef = useRef(onUseSignature);
|
||||
|
||||
useEffect(() => {
|
||||
onUseSignatureRef.current = onUseSignature;
|
||||
}, [onUseSignature]);
|
||||
|
||||
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(() => {
|
||||
if (!activeSignature || disabled) {
|
||||
appliedSignatureIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (appliedSignatureIdRef.current === activeSignature.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedSignatureIdRef.current = activeSignature.id;
|
||||
onUseSignatureRef.current(activeSignature);
|
||||
}, [activeSignature, 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 { 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<SignatureDrafts['text'] | null>(null);
|
||||
const lastAppliedPlacementKey = useRef<string | null>(null);
|
||||
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(() => {
|
||||
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;
|
||||
@ -357,7 +588,9 @@ const SignSettings = ({
|
||||
const timer = window.setTimeout(() => {
|
||||
onActivateSignaturePlacement?.();
|
||||
}, PLACEMENT_ACTIVATION_DELAY);
|
||||
return () => window.clearTimeout(timer);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
|
||||
onActivateSignaturePlacement?.();
|
||||
@ -394,7 +627,9 @@ const SignSettings = ({
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY);
|
||||
return () => window.clearTimeout(timer);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
|
||||
trigger();
|
||||
@ -417,64 +652,109 @@ const SignSettings = ({
|
||||
const timer = window.setTimeout(() => {
|
||||
onActivateSignaturePlacement?.();
|
||||
}, FILE_SWITCH_ACTIVATION_DELAY);
|
||||
return () => window.clearTimeout(timer);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
|
||||
onActivateSignaturePlacement?.();
|
||||
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
|
||||
|
||||
const renderSignatureBuilder = () => {
|
||||
if (parameters.signatureType === 'canvas') {
|
||||
if (signatureSource === 'saved') {
|
||||
return (
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
penSize={penSize}
|
||||
penSizeInput={penSizeInput}
|
||||
onColorSwatchClick={() => setIsColorPickerOpen(true)}
|
||||
onPenSizeChange={setPenSize}
|
||||
onPenSizeInputChange={setPenSizeInput}
|
||||
onSignatureDataChange={handleCanvasSignatureChange}
|
||||
onDrawingComplete={() => {
|
||||
onActivateSignaturePlacement?.();
|
||||
}}
|
||||
<SavedSignaturesSection
|
||||
signatures={savedSignatures}
|
||||
disabled={disabled}
|
||||
initialSignatureData={canvasSignatureData}
|
||||
isAtCapacity={isSavedSignatureLimitReached}
|
||||
onUseSignature={handleUseSavedSignature}
|
||||
onDeleteSignature={handleDeleteSavedSignature}
|
||||
onRenameSignature={handleRenameSavedSignature}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters.signatureType === 'image') {
|
||||
if (signatureSource === 'canvas') {
|
||||
return (
|
||||
<ImageUploader
|
||||
onImageChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Stack gap="xs">
|
||||
<DrawingCanvas
|
||||
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 (
|
||||
<TextInputWithFont
|
||||
text={parameters.signerName || ''}
|
||||
onTextChange={(text) => 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}
|
||||
/>
|
||||
<Stack gap="xs">
|
||||
<TextInputWithFont
|
||||
text={parameters.signerName || ''}
|
||||
onTextChange={(text) => 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}
|
||||
/>
|
||||
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
|
||||
{renderSaveButton('text', hasTextSignature, handleSaveTextSignature)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
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 +860,14 @@ const SignSettings = ({
|
||||
{t('sign.step.createDesc', 'Choose how you want to create the signature')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={parameters.signatureType}
|
||||
value={signatureSource}
|
||||
fullWidth
|
||||
onChange={(value) => 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()}
|
||||
|
||||
@ -2,8 +2,9 @@ import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
|
||||
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { uuidV4 } from '@embedpdf/models';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
import type { HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import { ANNOTATION_RECREATION_DELAY_MS, ANNOTATION_VERIFICATION_DELAY_MS } from '@app/constants/app';
|
||||
|
||||
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
|
||||
const { provides: historyApi } = useHistoryCapability();
|
||||
@ -19,14 +20,14 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
const annotation = event.annotation;
|
||||
|
||||
// Store image data for all STAMP annotations immediately when created or modified
|
||||
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id && annotation.imageSrc) {
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
if (!storedImageData) {
|
||||
storeImageData(annotation.id, annotation.imageSrc);
|
||||
}
|
||||
}
|
||||
|
||||
if (annotation && annotation.type === 13 && annotation.id) {
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id) {
|
||||
// Prevent infinite loops when we recreate annotations
|
||||
if (restoringIds.current.has(annotation.id)) {
|
||||
restoringIds.current.delete(annotation.id);
|
||||
@ -59,7 +60,7 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
data: storedImageData,
|
||||
appearance: storedImageData,
|
||||
});
|
||||
}, 50);
|
||||
}, ANNOTATION_RECREATION_DELAY_MS);
|
||||
} catch (restoreError) {
|
||||
console.error('HistoryAPI: Failed to restore cropped signature:', restoreError);
|
||||
}
|
||||
@ -70,7 +71,7 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
// Handle annotation restoration after undo operations
|
||||
if (event.type === 'create' && event.committed) {
|
||||
// Check if this is a STAMP annotation (signature) that might need image data restoration
|
||||
if (annotation && annotation.type === 13 && annotation.id) {
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id) {
|
||||
getImageData(annotation.id);
|
||||
|
||||
// Delay the check to allow the annotation to be fully created
|
||||
@ -103,12 +104,12 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
// Small delay to ensure deletion completes
|
||||
setTimeout(() => {
|
||||
annotationApi.createAnnotation(event.pageIndex, restoredData);
|
||||
}, 50);
|
||||
}, ANNOTATION_RECREATION_DELAY_MS);
|
||||
} catch (error) {
|
||||
console.error('HistoryAPI: Failed to restore annotation:', error);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}, ANNOTATION_VERIFICATION_DELAY_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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';
|
||||
@ -14,6 +14,14 @@ const MIN_SIGNATURE_DIMENSION = 12;
|
||||
// This provides a good balance between visual fidelity and performance/memory usage.
|
||||
const TEXT_OVERSAMPLE_FACTOR = 2;
|
||||
|
||||
type TextStampImageResult = {
|
||||
dataUrl: string;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
};
|
||||
|
||||
const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new Set()): string | undefined => {
|
||||
if (!value || depth > 6) return undefined;
|
||||
|
||||
@ -48,7 +56,7 @@ const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new S
|
||||
const createTextStampImage = (
|
||||
config: SignParameters,
|
||||
displaySize?: { width: number; height: number } | null
|
||||
): { dataUrl: string; pixelWidth: number; pixelHeight: number; displayWidth: number; displayHeight: number } | null => {
|
||||
): TextStampImageResult | null => {
|
||||
const text = (config.signerName ?? '').trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
@ -123,10 +131,20 @@ const createTextStampImage = (
|
||||
export const SignatureAPIBridge = forwardRef<SignatureAPI>(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<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(
|
||||
(size: { width: number; height: number }) => {
|
||||
const zoom = currentZoom || 1;
|
||||
@ -328,7 +346,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
if (pageAnnotationsTask) {
|
||||
pageAnnotationsTask.toPromise().then((pageAnnotations: any) => {
|
||||
const annotation = pageAnnotations?.find((ann: any) => ann.id === annotationId);
|
||||
if (annotation && annotation.type === 13 && annotation.imageSrc) {
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.imageSrc) {
|
||||
// Store image data before deletion
|
||||
storeImageData(annotationId, annotation.imageSrc);
|
||||
}
|
||||
|
||||
@ -3,6 +3,11 @@
|
||||
// When no subpath, use empty string instead of '.' to avoid relative path issues
|
||||
export const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '').replace(/^\.$/, '');
|
||||
|
||||
// EmbedPDF needs time to remove annotations internally before a recreation runs.
|
||||
// Without the buffer we occasionally end up with duplicate annotations or stale image data.
|
||||
export const ANNOTATION_RECREATION_DELAY_MS = 50;
|
||||
export const ANNOTATION_VERIFICATION_DELAY_MS = 100;
|
||||
|
||||
/** For in-app navigations when you must touch window.location (rare). */
|
||||
export const withBasePath = (path: string): string => {
|
||||
const clean = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
@ -39,14 +39,23 @@ import {
|
||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
|
||||
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) => {
|
||||
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;
|
||||
|
||||
212
frontend/src/core/hooks/tools/sign/useSavedSignatures.ts
Normal file
212
frontend/src/core/hooks/tools/sign/useSavedSignatures.ts
Normal file
@ -0,0 +1,212 @@
|
||||
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 = () => crypto.randomUUID();
|
||||
|
||||
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>;
|
||||
@ -1,4 +1,5 @@
|
||||
import { PDFDocument, rgb } from 'pdf-lib';
|
||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
|
||||
import { createProcessedFile, createChildStub } from '@app/contexts/file/fileActions';
|
||||
import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '@app/types/fileContext';
|
||||
@ -228,7 +229,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
size: 12,
|
||||
color: rgb(0, 0, 0)
|
||||
});
|
||||
} else if (annotation.type === 14 || annotation.type === 15) {
|
||||
} else if (annotation.type === PdfAnnotationSubtype.INK || annotation.type === PdfAnnotationSubtype.LINE) {
|
||||
// Handle ink annotations (drawn signatures)
|
||||
page.drawRectangle({
|
||||
x: pdfX,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user