mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Feature/v2/add text (#4951)
Refactor sign to separate out add text and add image functions. Implement add text as standalone tool
This commit is contained in:
@@ -855,6 +855,11 @@
|
|||||||
"overlay-pdfs": {
|
"overlay-pdfs": {
|
||||||
"desc": "Overlay one PDF on top of another",
|
"desc": "Overlay one PDF on top of another",
|
||||||
"title": "Overlay PDFs"
|
"title": "Overlay PDFs"
|
||||||
|
},
|
||||||
|
"addText": {
|
||||||
|
"tags": "text,annotation,label",
|
||||||
|
"title": "Add Text",
|
||||||
|
"desc": "Add custom text anywhere in your PDF"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
@@ -2152,8 +2157,8 @@
|
|||||||
"colorPickerTitle": "Choose stroke colour"
|
"colorPickerTitle": "Choose stroke colour"
|
||||||
},
|
},
|
||||||
"text": {
|
"text": {
|
||||||
"name": "Signer Name",
|
"name": "Text",
|
||||||
"placeholder": "Enter your full name",
|
"placeholder": "Enter text",
|
||||||
"fontLabel": "Font",
|
"fontLabel": "Font",
|
||||||
"fontSizeLabel": "Font size",
|
"fontSizeLabel": "Font size",
|
||||||
"fontSizePlaceholder": "Type or select font size (8-200)",
|
"fontSizePlaceholder": "Type or select font size (8-200)",
|
||||||
@@ -2421,8 +2426,12 @@
|
|||||||
"selection": {
|
"selection": {
|
||||||
"originalEditedTitle": "Select Original and Edited PDFs"
|
"originalEditedTitle": "Select Original and Edited PDFs"
|
||||||
},
|
},
|
||||||
"original": { "label": "Original PDF" },
|
"original": {
|
||||||
"edited": { "label": "Edited PDF" },
|
"label": "Original PDF"
|
||||||
|
},
|
||||||
|
"edited": {
|
||||||
|
"label": "Edited PDF"
|
||||||
|
},
|
||||||
"swap": {
|
"swap": {
|
||||||
"confirmTitle": "Re-run comparison?",
|
"confirmTitle": "Re-run comparison?",
|
||||||
"confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?",
|
"confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?",
|
||||||
@@ -5751,5 +5760,45 @@
|
|||||||
"pleaseLoginAgain": "Please login again.",
|
"pleaseLoginAgain": "Please login again.",
|
||||||
"accessDenied": "Access Denied",
|
"accessDenied": "Access Denied",
|
||||||
"insufficientPermissions": "You do not have permission to perform this action."
|
"insufficientPermissions": "You do not have permission to perform this action."
|
||||||
|
},
|
||||||
|
"addText": {
|
||||||
|
"title": "Add Text",
|
||||||
|
"header": "Add text to PDFs",
|
||||||
|
"text": {
|
||||||
|
"name": "Text content",
|
||||||
|
"placeholder": "Enter the text you want to add",
|
||||||
|
"fontLabel": "Font",
|
||||||
|
"fontSizeLabel": "Font size",
|
||||||
|
"fontSizePlaceholder": "Type or select font size (8-200)",
|
||||||
|
"colorLabel": "Text colour"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"configure": "Configure Text"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"createDesc": "Enter the text you want to add",
|
||||||
|
"place": "Place text",
|
||||||
|
"placeDesc": "Click on the PDF to add your text"
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"title": "How to add text",
|
||||||
|
"text": "After entering your text above, click anywhere on the PDF to place it.",
|
||||||
|
"paused": "Placement paused",
|
||||||
|
"resumeHint": "Resume placement to click and add your text.",
|
||||||
|
"noSignature": "Enter text above to enable placement."
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"move": "Move Text",
|
||||||
|
"place": "Place Text",
|
||||||
|
"pause": "Pause placement",
|
||||||
|
"resume": "Resume placement"
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"title": "Add Text Results"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"failed": "An error occurred while adding text to the PDF."
|
||||||
|
},
|
||||||
|
"tags": "text,annotation,label"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,6 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
if (!initialSignatureData) {
|
if (!initialSignatureData) {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
setSavedSignatureData(null);
|
setSavedSignatureData(null);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface TextInputWithFontProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
onAnyChange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||||
@@ -28,7 +29,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
onTextColorChange,
|
onTextColorChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
label,
|
label,
|
||||||
placeholder
|
placeholder,
|
||||||
|
onAnyChange
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||||
@@ -67,7 +69,10 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
label={label || t('sign.text.name', 'Signer name')}
|
label={label || t('sign.text.name', 'Signer name')}
|
||||||
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => onTextChange(e.target.value)}
|
onChange={(e) => {
|
||||||
|
onTextChange(e.target.value);
|
||||||
|
onAnyChange?.();
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -76,7 +81,10 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
<Select
|
<Select
|
||||||
label={t('sign.text.fontLabel', 'Font')}
|
label={t('sign.text.fontLabel', 'Font')}
|
||||||
value={fontFamily}
|
value={fontFamily}
|
||||||
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
onChange={(value) => {
|
||||||
|
onFontFamilyChange(value || 'Helvetica');
|
||||||
|
onAnyChange?.();
|
||||||
|
}}
|
||||||
data={fontOptions}
|
data={fontOptions}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
searchable
|
searchable
|
||||||
@@ -110,6 +118,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
const size = parseInt(value);
|
const size = parseInt(value);
|
||||||
if (!isNaN(size) && size >= 8 && size <= 200) {
|
if (!isNaN(size) && size >= 8 && size <= 200) {
|
||||||
onFontSizeChange(size);
|
onFontSizeChange(size);
|
||||||
|
onAnyChange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
fontSizeCombobox.openDropdown();
|
fontSizeCombobox.openDropdown();
|
||||||
@@ -157,6 +166,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
// Update color if valid hex
|
// Update color if valid hex
|
||||||
if (isValidHexColor(value)) {
|
if (isValidHexColor(value)) {
|
||||||
onTextColorChange(value);
|
onTextColorChange(value);
|
||||||
|
onAnyChange?.();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
@@ -190,7 +200,10 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
isOpen={isColorPickerOpen}
|
isOpen={isColorPickerOpen}
|
||||||
onClose={() => setIsColorPickerOpen(false)}
|
onClose={() => setIsColorPickerOpen(false)}
|
||||||
selectedColor={textColor}
|
selectedColor={textColor}
|
||||||
onColorChange={onTextColorChange}
|
onColorChange={(color) => {
|
||||||
|
onTextColorChange(color);
|
||||||
|
onAnyChange?.();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface SavedSignaturesSectionProps {
|
|||||||
onUseSignature: (signature: SavedSignature) => void;
|
onUseSignature: (signature: SavedSignature) => void;
|
||||||
onDeleteSignature: (signature: SavedSignature) => void;
|
onDeleteSignature: (signature: SavedSignature) => void;
|
||||||
onRenameSignature: (id: string, label: string) => void;
|
onRenameSignature: (id: string, label: string) => void;
|
||||||
|
translationScope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeBadgeColor: Record<SavedSignatureType, string> = {
|
const typeBadgeColor: Record<SavedSignatureType, string> = {
|
||||||
@@ -26,11 +27,18 @@ export const SavedSignaturesSection = ({
|
|||||||
onUseSignature,
|
onUseSignature,
|
||||||
onDeleteSignature,
|
onDeleteSignature,
|
||||||
onRenameSignature,
|
onRenameSignature,
|
||||||
|
translationScope = 'sign',
|
||||||
}: SavedSignaturesSectionProps) => {
|
}: SavedSignaturesSectionProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const translate = useCallback(
|
||||||
|
(key: string, defaultValue: string, options?: Record<string, unknown>) =>
|
||||||
|
t(`${translationScope}.${key}`, { defaultValue, ...options }),
|
||||||
|
[t, translationScope]
|
||||||
|
);
|
||||||
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
|
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const activeSignature = signatures[activeIndex];
|
const activeSignature = signatures[activeIndex];
|
||||||
|
const activeSignatureRef = useRef<SavedSignature | null>(activeSignature ?? null);
|
||||||
const appliedSignatureIdRef = useRef<string | null>(null);
|
const appliedSignatureIdRef = useRef<string | null>(null);
|
||||||
const onUseSignatureRef = useRef(onUseSignature);
|
const onUseSignatureRef = useRef(onUseSignature);
|
||||||
|
|
||||||
@@ -38,6 +46,10 @@ export const SavedSignaturesSection = ({
|
|||||||
onUseSignatureRef.current = onUseSignature;
|
onUseSignatureRef.current = onUseSignature;
|
||||||
}, [onUseSignature]);
|
}, [onUseSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeSignatureRef.current = activeSignature ?? null;
|
||||||
|
}, [activeSignature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLabelDrafts(prev => {
|
setLabelDrafts(prev => {
|
||||||
const nextDrafts: Record<string, string> = {};
|
const nextDrafts: Record<string, string> = {};
|
||||||
@@ -132,10 +144,10 @@ export const SavedSignaturesSection = ({
|
|||||||
const emptyState = (
|
const emptyState = (
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text fw={500}>{t('sign.saved.emptyTitle', 'No saved signatures yet')}</Text>
|
<Text fw={500}>{translate('saved.emptyTitle', 'No saved signatures yet')}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t(
|
{translate(
|
||||||
'sign.saved.emptyDescription',
|
'saved.emptyDescription',
|
||||||
'Draw, upload, or type a signature above, then use "Save to library" to keep up to {{max}} favourites ready to use.',
|
'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: MAX_SAVED_SIGNATURES }
|
||||||
)}
|
)}
|
||||||
@@ -147,11 +159,11 @@ export const SavedSignaturesSection = ({
|
|||||||
const typeLabel = (type: SavedSignatureType) => {
|
const typeLabel = (type: SavedSignatureType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'canvas':
|
case 'canvas':
|
||||||
return t('sign.saved.type.canvas', 'Drawing');
|
return translate('saved.type.canvas', 'Drawing');
|
||||||
case 'image':
|
case 'image':
|
||||||
return t('sign.saved.type.image', 'Upload');
|
return translate('saved.type.image', 'Upload');
|
||||||
case 'text':
|
case 'text':
|
||||||
return t('sign.saved.type.text', 'Text');
|
return translate('saved.type.text', 'Text');
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
@@ -182,36 +194,37 @@ export const SavedSignaturesSection = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeSignature || disabled) {
|
const signature = activeSignatureRef.current;
|
||||||
|
if (!signature || disabled) {
|
||||||
appliedSignatureIdRef.current = null;
|
appliedSignatureIdRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appliedSignatureIdRef.current === activeSignature.id) {
|
if (appliedSignatureIdRef.current === signature.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
appliedSignatureIdRef.current = activeSignature.id;
|
appliedSignatureIdRef.current = signature.id;
|
||||||
onUseSignatureRef.current(activeSignature);
|
onUseSignatureRef.current(signature);
|
||||||
}, [activeSignature, disabled]);
|
}, [activeSignature?.id, disabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" align="flex-start">
|
<Group justify="space-between" align="flex-start">
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text fw={600} size="md">
|
<Text fw={600} size="md">
|
||||||
{t('sign.saved.heading', 'Saved signatures')}
|
{translate('saved.heading', 'Saved signatures')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('sign.saved.description', 'Reuse saved signatures at any time.')}
|
{translate('saved.description', 'Reuse saved signatures at any time.')}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{isAtCapacity && (
|
{isAtCapacity && (
|
||||||
<Alert color="yellow" title={t('sign.saved.limitTitle', 'Limit reached')}>
|
<Alert color="yellow" title={translate('saved.limitTitle', 'Limit reached')}>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{t('sign.saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
{translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
||||||
max: MAX_SAVED_SIGNATURES,
|
max: MAX_SAVED_SIGNATURES,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -224,7 +237,7 @@ export const SavedSignaturesSection = ({
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('sign.saved.carouselPosition', '{{current}} of {{total}}', {
|
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
|
||||||
current: activeIndex + 1,
|
current: activeIndex + 1,
|
||||||
total: signatures.length,
|
total: signatures.length,
|
||||||
})}
|
})}
|
||||||
@@ -232,7 +245,7 @@ export const SavedSignaturesSection = ({
|
|||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
aria-label={t('sign.saved.prev', 'Previous')}
|
aria-label={translate('saved.prev', 'Previous')}
|
||||||
onClick={() => handleNavigate('prev')}
|
onClick={() => handleNavigate('prev')}
|
||||||
disabled={disabled || activeIndex === 0}
|
disabled={disabled || activeIndex === 0}
|
||||||
>
|
>
|
||||||
@@ -240,7 +253,7 @@ export const SavedSignaturesSection = ({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
aria-label={t('sign.saved.next', 'Next')}
|
aria-label={translate('saved.next', 'Next')}
|
||||||
onClick={() => handleNavigate('next')}
|
onClick={() => handleNavigate('next')}
|
||||||
disabled={disabled || activeIndex >= signatures.length - 1}
|
disabled={disabled || activeIndex >= signatures.length - 1}
|
||||||
>
|
>
|
||||||
@@ -256,11 +269,11 @@ export const SavedSignaturesSection = ({
|
|||||||
<Badge color={typeBadgeColor[activeSignature.type]} variant="light">
|
<Badge color={typeBadgeColor[activeSignature.type]} variant="light">
|
||||||
{typeLabel(activeSignature.type)}
|
{typeLabel(activeSignature.type)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Tooltip label={t('sign.saved.delete', 'Remove')}>
|
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
aria-label={t('sign.saved.delete', 'Remove')}
|
aria-label={translate('saved.delete', 'Remove')}
|
||||||
onClick={() => onDeleteSignature(activeSignature)}
|
onClick={() => onDeleteSignature(activeSignature)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
@@ -272,7 +285,7 @@ export const SavedSignaturesSection = ({
|
|||||||
{renderPreview(activeSignature)}
|
{renderPreview(activeSignature)}
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('sign.saved.label', 'Label')}
|
label={translate('saved.label', 'Label')}
|
||||||
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
|
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
|
||||||
onChange={event => handleLabelChange(event, activeSignature)}
|
onChange={event => handleLabelChange(event, activeSignature)}
|
||||||
onBlur={() => handleLabelBlur(activeSignature)}
|
onBlur={() => handleLabelBlur(activeSignature)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } from '@mantine/core';
|
import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } from '@mantine/core';
|
||||||
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
|
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
|
||||||
@@ -39,9 +39,14 @@ interface SignSettingsProps {
|
|||||||
onUndo?: () => void;
|
onUndo?: () => void;
|
||||||
onRedo?: () => void;
|
onRedo?: () => void;
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
|
translationScope?: string;
|
||||||
|
allowedSignatureSources?: SignatureSource[];
|
||||||
|
defaultSignatureSource?: SignatureSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignatureSource = 'canvas' | 'image' | 'text' | 'saved';
|
export type SignatureSource = 'canvas' | 'image' | 'text' | 'saved';
|
||||||
|
|
||||||
|
const DEFAULT_SIGNATURE_SOURCES: SignatureSource[] = ['canvas', 'image', 'text', 'saved'];
|
||||||
|
|
||||||
const SignSettings = ({
|
const SignSettings = ({
|
||||||
parameters,
|
parameters,
|
||||||
@@ -52,13 +57,26 @@ const SignSettings = ({
|
|||||||
onUpdateDrawSettings,
|
onUpdateDrawSettings,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onSave
|
onSave,
|
||||||
|
translationScope = 'sign',
|
||||||
|
allowedSignatureSources = DEFAULT_SIGNATURE_SOURCES,
|
||||||
|
defaultSignatureSource,
|
||||||
}: SignSettingsProps) => {
|
}: SignSettingsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
|
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
|
||||||
const { activeFileIndex } = useViewer();
|
const { activeFileIndex } = useViewer();
|
||||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||||
const historyApiInstance = historyApiRef.current;
|
const historyApiInstance = historyApiRef.current;
|
||||||
|
const translate = useCallback(
|
||||||
|
(key: string, defaultValue: string, options?: Record<string, unknown>) =>
|
||||||
|
t(`${translationScope}.${key}`, { defaultValue, ...options }),
|
||||||
|
[t, translationScope]
|
||||||
|
);
|
||||||
|
const effectiveDefaultSource =
|
||||||
|
(defaultSignatureSource && allowedSignatureSources.includes(defaultSignatureSource)
|
||||||
|
? defaultSignatureSource
|
||||||
|
: allowedSignatureSources[0]) ?? 'text';
|
||||||
|
const canUseSavedLibrary = allowedSignatureSources.includes('saved');
|
||||||
|
|
||||||
// State for drawing
|
// State for drawing
|
||||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||||
@@ -82,7 +100,13 @@ const SignSettings = ({
|
|||||||
updateSignatureLabel,
|
updateSignatureLabel,
|
||||||
byTypeCounts,
|
byTypeCounts,
|
||||||
} = useSavedSignatures();
|
} = useSavedSignatures();
|
||||||
const [signatureSource, setSignatureSource] = useState<SignatureSource>(parameters.signatureType);
|
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
|
||||||
|
const paramSource = parameters.signatureType as SignatureSource;
|
||||||
|
if (allowedSignatureSources.includes(paramSource)) {
|
||||||
|
return paramSource;
|
||||||
|
}
|
||||||
|
return effectiveDefaultSource;
|
||||||
|
});
|
||||||
const [lastSavedSignatureKeys, setLastSavedSignatureKeys] = useState<Record<SavedSignatureType, string | null>>({
|
const [lastSavedSignatureKeys, setLastSavedSignatureKeys] = useState<Record<SavedSignatureType, string | null>>({
|
||||||
canvas: null,
|
canvas: null,
|
||||||
image: null,
|
image: null,
|
||||||
@@ -103,17 +127,17 @@ const SignSettings = ({
|
|||||||
const getDefaultSavedLabel = useCallback(
|
const getDefaultSavedLabel = useCallback(
|
||||||
(type: SavedSignatureType) => {
|
(type: SavedSignatureType) => {
|
||||||
const nextIndex = (byTypeCounts[type] ?? 0) + 1;
|
const nextIndex = (byTypeCounts[type] ?? 0) + 1;
|
||||||
let baseLabel = t('sign.saved.defaultLabel', 'Signature');
|
let baseLabel = translate('saved.defaultLabel', 'Signature');
|
||||||
if (type === 'canvas') {
|
if (type === 'canvas') {
|
||||||
baseLabel = t('sign.saved.defaultCanvasLabel', 'Drawing signature');
|
baseLabel = translate('saved.defaultCanvasLabel', 'Drawing signature');
|
||||||
} else if (type === 'image') {
|
} else if (type === 'image') {
|
||||||
baseLabel = t('sign.saved.defaultImageLabel', 'Uploaded signature');
|
baseLabel = translate('saved.defaultImageLabel', 'Uploaded signature');
|
||||||
} else if (type === 'text') {
|
} else if (type === 'text') {
|
||||||
baseLabel = t('sign.saved.defaultTextLabel', 'Typed signature');
|
baseLabel = translate('saved.defaultTextLabel', 'Typed signature');
|
||||||
}
|
}
|
||||||
return `${baseLabel} ${nextIndex}`;
|
return `${baseLabel} ${nextIndex}`;
|
||||||
},
|
},
|
||||||
[byTypeCounts, t]
|
[byTypeCounts, t, translate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const signatureKeysByType = useMemo(() => {
|
const signatureKeysByType = useMemo(() => {
|
||||||
@@ -263,7 +287,10 @@ const SignSettings = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
|
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
|
||||||
const label = t('sign.saved.saveButton', 'Save signature');
|
if (!canUseSavedLibrary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = translate('saved.saveButton', 'Save signature');
|
||||||
const currentKey = signatureKeysByType[type];
|
const currentKey = signatureKeysByType[type];
|
||||||
const lastSavedKey = lastSavedSignatureKeys[type];
|
const lastSavedKey = lastSavedSignatureKeys[type];
|
||||||
const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey);
|
const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey);
|
||||||
@@ -271,11 +298,11 @@ const SignSettings = ({
|
|||||||
|
|
||||||
let tooltipMessage: string | undefined;
|
let tooltipMessage: string | undefined;
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
tooltipMessage = t('sign.saved.saveUnavailable', 'Create a signature first to save it.');
|
tooltipMessage = translate('saved.saveUnavailable', 'Create a signature first to save it.');
|
||||||
} else if (isSaved) {
|
} else if (isSaved) {
|
||||||
tooltipMessage = t('sign.saved.noChanges', 'Current signature is already saved.');
|
tooltipMessage = translate('saved.noChanges', 'Current signature is already saved.');
|
||||||
} else if (isSavedSignatureLimitReached) {
|
} else if (isSavedSignatureLimitReached) {
|
||||||
tooltipMessage = t('sign.saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
tooltipMessage = translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
||||||
max: MAX_SAVED_SIGNATURES,
|
max: MAX_SAVED_SIGNATURES,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -289,7 +316,7 @@ const SignSettings = ({
|
|||||||
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
|
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
|
||||||
leftSection={<LocalIcon icon="material-symbols:save-rounded" width={16} height={16} />}
|
leftSection={<LocalIcon icon="material-symbols:save-rounded" width={16} height={16} />}
|
||||||
>
|
>
|
||||||
{isSaved ? t('sign.saved.status.saved', 'Saved') : label}
|
{isSaved ? translate('saved.status.saved', 'Saved') : label}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -304,14 +331,33 @@ const SignSettings = ({
|
|||||||
return button;
|
return button;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
|
||||||
|
const button = renderSaveButton(type, isReady, onClick);
|
||||||
|
if (!button) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
|
||||||
|
{button}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (signatureSource === 'saved' && !canUseSavedLibrary) {
|
||||||
|
setSignatureSource(effectiveDefaultSource);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (signatureSource === 'saved') {
|
if (signatureSource === 'saved') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (signatureSource !== parameters.signatureType) {
|
const nextSource = allowedSignatureSources.includes(parameters.signatureType as SignatureSource)
|
||||||
setSignatureSource(parameters.signatureType);
|
? (parameters.signatureType as SignatureSource)
|
||||||
|
: effectiveDefaultSource;
|
||||||
|
if (signatureSource !== nextSource) {
|
||||||
|
setSignatureSource(nextSource);
|
||||||
}
|
}
|
||||||
}, [parameters.signatureType, signatureSource]);
|
}, [parameters.signatureType, signatureSource, allowedSignatureSources, effectiveDefaultSource, canUseSavedLibrary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
@@ -376,26 +422,47 @@ const SignSettings = ({
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset pause state and directly activate placement
|
||||||
|
setPlacementManuallyPaused(false);
|
||||||
|
lastAppliedPlacementKey.current = null;
|
||||||
setImageSignatureData(result);
|
setImageSignatureData(result);
|
||||||
|
|
||||||
|
// Directly activate placement on image upload
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
|
||||||
|
} else {
|
||||||
|
onActivateSignaturePlacement?.();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading file:', error);
|
console.error('Error reading file:', error);
|
||||||
}
|
}
|
||||||
} else if (!file) {
|
} else if (!file) {
|
||||||
setImageSignatureData(undefined);
|
setImageSignatureData(undefined);
|
||||||
onDeactivateSignature?.();
|
onDeactivateSignature?.();
|
||||||
|
setImageSignatureData(undefined);
|
||||||
|
onDeactivateSignature?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle signature data changes
|
// Handle signature data changes
|
||||||
const handleCanvasSignatureChange = (data: string | null) => {
|
const handleCanvasSignatureChange = useCallback((data: string | null) => {
|
||||||
const nextValue = data ?? undefined;
|
const nextValue = data ?? undefined;
|
||||||
setCanvasSignatureData(prev => {
|
setCanvasSignatureData(prevData => {
|
||||||
if (prev === nextValue) {
|
// Reset pause state and trigger placement for signature changes
|
||||||
return prev;
|
// (onDrawingComplete handles initial activation)
|
||||||
|
if (prevData && prevData !== nextValue && nextValue) {
|
||||||
|
setPlacementManuallyPaused(false);
|
||||||
|
lastAppliedPlacementKey.current = null;
|
||||||
|
// Directly activate placement on signature change
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
|
||||||
|
} else {
|
||||||
|
onActivateSignaturePlacement?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nextValue;
|
return nextValue;
|
||||||
});
|
});
|
||||||
};
|
}, [onActivateSignaturePlacement]);
|
||||||
|
|
||||||
const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]);
|
const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]);
|
||||||
const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]);
|
const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]);
|
||||||
@@ -588,9 +655,7 @@ const SignSettings = ({
|
|||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
onActivateSignaturePlacement?.();
|
onActivateSignaturePlacement?.();
|
||||||
}, PLACEMENT_ACTIVATION_DELAY);
|
}, PLACEMENT_ACTIVATION_DELAY);
|
||||||
return () => {
|
return () => window.clearTimeout(timer);
|
||||||
window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onActivateSignaturePlacement?.();
|
onActivateSignaturePlacement?.();
|
||||||
@@ -601,6 +666,7 @@ const SignSettings = ({
|
|||||||
isPlacementManuallyPaused,
|
isPlacementManuallyPaused,
|
||||||
onActivateSignaturePlacement,
|
onActivateSignaturePlacement,
|
||||||
onDeactivateSignature,
|
onDeactivateSignature,
|
||||||
|
placementSignatureKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -627,9 +693,7 @@ const SignSettings = ({
|
|||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY);
|
const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY);
|
||||||
return () => {
|
return () => window.clearTimeout(timer);
|
||||||
window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trigger();
|
trigger();
|
||||||
@@ -652,16 +716,29 @@ const SignSettings = ({
|
|||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
onActivateSignaturePlacement?.();
|
onActivateSignaturePlacement?.();
|
||||||
}, FILE_SWITCH_ACTIVATION_DELAY);
|
}, FILE_SWITCH_ACTIVATION_DELAY);
|
||||||
return () => {
|
return () => window.clearTimeout(timer);
|
||||||
window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onActivateSignaturePlacement?.();
|
onActivateSignaturePlacement?.();
|
||||||
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
|
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
|
||||||
|
|
||||||
|
const sourceLabels: Record<SignatureSource, string> = {
|
||||||
|
canvas: translate('type.canvas', 'Draw'),
|
||||||
|
image: translate('type.image', 'Upload'),
|
||||||
|
text: translate('type.text', 'Type'),
|
||||||
|
saved: translate('type.saved', 'Saved'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceOptions = allowedSignatureSources.map(source => ({
|
||||||
|
label: sourceLabels[source],
|
||||||
|
value: source,
|
||||||
|
}));
|
||||||
|
|
||||||
const renderSignatureBuilder = () => {
|
const renderSignatureBuilder = () => {
|
||||||
if (signatureSource === 'saved') {
|
if (signatureSource === 'saved') {
|
||||||
|
if (!canUseSavedLibrary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<SavedSignaturesSection
|
<SavedSignaturesSection
|
||||||
signatures={savedSignatures}
|
signatures={savedSignatures}
|
||||||
@@ -670,6 +747,7 @@ const SignSettings = ({
|
|||||||
onUseSignature={handleUseSavedSignature}
|
onUseSignature={handleUseSavedSignature}
|
||||||
onDeleteSignature={handleDeleteSavedSignature}
|
onDeleteSignature={handleDeleteSavedSignature}
|
||||||
onRenameSignature={handleRenameSavedSignature}
|
onRenameSignature={handleRenameSavedSignature}
|
||||||
|
translationScope={translationScope}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -691,9 +769,7 @@ const SignSettings = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
initialSignatureData={canvasSignatureData}
|
initialSignatureData={canvasSignatureData}
|
||||||
/>
|
/>
|
||||||
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
|
{renderSaveButtonRow('canvas', hasCanvasSignature, handleSaveCanvasSignature)}
|
||||||
{renderSaveButton('canvas', hasCanvasSignature, handleSaveCanvasSignature)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -705,9 +781,7 @@ const SignSettings = ({
|
|||||||
onImageChange={handleImageChange}
|
onImageChange={handleImageChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
|
{renderSaveButtonRow('image', hasImageSignature, handleSaveImageSignature)}
|
||||||
{renderSaveButton('image', hasImageSignature, handleSaveImageSignature)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -724,35 +798,43 @@ const SignSettings = ({
|
|||||||
textColor={parameters.textColor || '#000000'}
|
textColor={parameters.textColor || '#000000'}
|
||||||
onTextColorChange={(color) => onParameterChange('textColor', color)}
|
onTextColorChange={(color) => onParameterChange('textColor', color)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onAnyChange={() => {
|
||||||
|
setPlacementManuallyPaused(false);
|
||||||
|
lastAppliedPlacementKey.current = null;
|
||||||
|
// Directly activate placement on text changes
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
|
||||||
|
} else {
|
||||||
|
onActivateSignaturePlacement?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
|
{renderSaveButtonRow('text', hasTextSignature, handleSaveTextSignature)}
|
||||||
{renderSaveButton('text', hasTextSignature, handleSaveTextSignature)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const placementInstructions = () => {
|
const placementInstructions = () => {
|
||||||
if (signatureSource === 'saved') {
|
if (signatureSource === 'saved') {
|
||||||
return t(
|
return translate(
|
||||||
'sign.instructions.saved',
|
'instructions.saved',
|
||||||
'Select a saved signature above, then click anywhere on the PDF to place it.'
|
'Select a saved signature above, then click anywhere on the PDF to place it.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (parameters.signatureType === 'canvas') {
|
if (parameters.signatureType === 'canvas') {
|
||||||
return t(
|
return translate(
|
||||||
'sign.instructions.canvas',
|
'instructions.canvas',
|
||||||
'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.'
|
'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (parameters.signatureType === 'image') {
|
if (parameters.signatureType === 'image') {
|
||||||
return t(
|
return translate(
|
||||||
'sign.instructions.image',
|
'instructions.image',
|
||||||
'After uploading your signature image, click anywhere on the PDF to place it.'
|
'After uploading your signature image, click anywhere on the PDF to place it.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return t(
|
return translate(
|
||||||
'sign.instructions.text',
|
'instructions.text',
|
||||||
'After entering your name above, click anywhere on the PDF to place your signature.'
|
'After entering your name above, click anywhere on the PDF to place your signature.'
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -761,16 +843,16 @@ const SignSettings = ({
|
|||||||
? {
|
? {
|
||||||
color: isPlacementMode ? 'blue' : 'teal',
|
color: isPlacementMode ? 'blue' : 'teal',
|
||||||
title: isPlacementMode
|
title: isPlacementMode
|
||||||
? t('sign.instructions.title', 'How to add your signature')
|
? translate('instructions.title', 'How to add your signature')
|
||||||
: t('sign.instructions.paused', 'Placement paused'),
|
: translate('instructions.paused', 'Placement paused'),
|
||||||
message: isPlacementMode
|
message: isPlacementMode
|
||||||
? placementInstructions()
|
? placementInstructions()
|
||||||
: t('sign.instructions.resumeHint', 'Resume placement to click and add your signature.'),
|
: translate('instructions.resumeHint', 'Resume placement to click and add your signature.'),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
color: 'yellow',
|
color: 'yellow',
|
||||||
title: t('sign.instructions.title', 'How to add your signature'),
|
title: translate('instructions.title', 'How to add your signature'),
|
||||||
message: t('sign.instructions.noSignature', 'Create a signature above to enable placement tools.'),
|
message: translate('instructions.noSignature', 'Create a signature above to enable placement tools.'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePausePlacement = () => {
|
const handlePausePlacement = () => {
|
||||||
@@ -806,11 +888,11 @@ const SignSettings = ({
|
|||||||
onActivateSignaturePlacement || onDeactivateSignature
|
onActivateSignaturePlacement || onDeactivateSignature
|
||||||
? isPlacementMode
|
? isPlacementMode
|
||||||
? (
|
? (
|
||||||
<Tooltip label={t('sign.mode.pause', 'Pause placement')}>
|
<Tooltip label={translate('mode.pause', 'Pause placement')}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t('sign.mode.pause', 'Pause placement')}
|
aria-label={translate('mode.pause', 'Pause placement')}
|
||||||
onClick={handlePausePlacement}
|
onClick={handlePausePlacement}
|
||||||
disabled={disabled || !onDeactivateSignature}
|
disabled={disabled || !onDeactivateSignature}
|
||||||
style={{
|
style={{
|
||||||
@@ -823,17 +905,17 @@ const SignSettings = ({
|
|||||||
>
|
>
|
||||||
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
|
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
|
||||||
<Text component="span" size="sm" fw={500}>
|
<Text component="span" size="sm" fw={500}>
|
||||||
{t('sign.mode.pause', 'Pause placement')}
|
{translate('mode.pause', 'Pause placement')}
|
||||||
</Text>
|
</Text>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<Tooltip label={t('sign.mode.resume', 'Resume placement')}>
|
<Tooltip label={translate('mode.resume', 'Resume placement')}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t('sign.mode.resume', 'Resume placement')}
|
aria-label={translate('mode.resume', 'Resume placement')}
|
||||||
onClick={handleResumePlacement}
|
onClick={handleResumePlacement}
|
||||||
disabled={disabled || !isCurrentTypeReady || !onActivateSignaturePlacement}
|
disabled={disabled || !isCurrentTypeReady || !onActivateSignaturePlacement}
|
||||||
style={{
|
style={{
|
||||||
@@ -846,7 +928,7 @@ const SignSettings = ({
|
|||||||
>
|
>
|
||||||
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
|
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
|
||||||
<Text component="span" size="sm" fw={500}>
|
<Text component="span" size="sm" fw={500}>
|
||||||
{t('sign.mode.resume', 'Resume placement')}
|
{translate('mode.resume', 'Resume placement')}
|
||||||
</Text>
|
</Text>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -857,19 +939,16 @@ const SignSettings = ({
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('sign.step.createDesc', 'Choose how you want to create the signature')}
|
{translate('step.createDesc', 'Choose how you want to create the signature')}
|
||||||
</Text>
|
</Text>
|
||||||
<SegmentedControl
|
{sourceOptions.length > 1 && (
|
||||||
value={signatureSource}
|
<SegmentedControl
|
||||||
fullWidth
|
value={signatureSource}
|
||||||
onChange={(value) => handleSignatureSourceChange(value as SignatureSource)}
|
fullWidth
|
||||||
data={[
|
onChange={(value) => handleSignatureSourceChange(value as SignatureSource)}
|
||||||
{ label: t('sign.type.canvas', 'Draw'), value: 'canvas' },
|
data={sourceOptions}
|
||||||
{ 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()}
|
{renderSignatureBuilder()}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -877,10 +956,10 @@ const SignSettings = ({
|
|||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={600} size="md">
|
<Text fw={600} size="md">
|
||||||
{t('sign.step.place', 'Place & save')}
|
{translate('step.place', 'Place & save')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('sign.step.placeDesc', 'Position the signature on your PDF')}
|
{translate('step.placeDesc', 'Position the signature on your PDF')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Group gap="xs" wrap="nowrap" align="center">
|
<Group gap="xs" wrap="nowrap" align="center">
|
||||||
@@ -911,7 +990,7 @@ const SignSettings = ({
|
|||||||
onClose={() => setIsColorPickerOpen(false)}
|
onClose={() => setIsColorPickerOpen(false)}
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
onColorChange={setSelectedColor}
|
onColorChange={setSelectedColor}
|
||||||
title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')}
|
title={translate('canvas.colorPickerTitle', 'Choose stroke colour')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{onSave && (
|
{onSave && (
|
||||||
@@ -921,7 +1000,7 @@ const SignSettings = ({
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
{t('sign.applySignatures', 'Apply Signatures')}
|
{translate('applySignatures', 'Apply Signatures')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
|||||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||||
import { isStirlingFile } from '@app/types/fileContext';
|
import { isStirlingFile } from '@app/types/fileContext';
|
||||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||||
import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay';
|
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
||||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||||
|
|
||||||
export interface EmbedPdfViewerProps {
|
export interface EmbedPdfViewerProps {
|
||||||
@@ -82,7 +82,8 @@ const EmbedPdfViewerContent = ({
|
|||||||
|
|
||||||
// Check if we're in signature mode OR viewer annotation mode
|
// Check if we're in signature mode OR viewer annotation mode
|
||||||
const { selectedTool } = useNavigationState();
|
const { selectedTool } = useNavigationState();
|
||||||
const isSignatureMode = selectedTool === 'sign';
|
// Tools that use the stamp/signature placement system with hover preview
|
||||||
|
const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText';
|
||||||
|
|
||||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||||
@@ -328,7 +329,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Future: Handle signature completion
|
// Future: Handle signature completion
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SignaturePlacementOverlay
|
<StampPlacementOverlay
|
||||||
containerRef={pdfContainerRef}
|
containerRef={pdfContainerRef}
|
||||||
isActive={isPlacementOverlayActive}
|
isActive={isPlacementOverlayActive}
|
||||||
signatureConfig={signatureConfig}
|
signatureConfig={signatureConfig}
|
||||||
|
|||||||
@@ -14,14 +14,6 @@ const MIN_SIGNATURE_DIMENSION = 12;
|
|||||||
// This provides a good balance between visual fidelity and performance/memory usage.
|
// This provides a good balance between visual fidelity and performance/memory usage.
|
||||||
const TEXT_OVERSAMPLE_FACTOR = 2;
|
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 => {
|
const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new Set()): string | undefined => {
|
||||||
if (!value || depth > 6) return undefined;
|
if (!value || depth > 6) return undefined;
|
||||||
|
|
||||||
@@ -56,7 +48,7 @@ const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new S
|
|||||||
const createTextStampImage = (
|
const createTextStampImage = (
|
||||||
config: SignParameters,
|
config: SignParameters,
|
||||||
displaySize?: { width: number; height: number } | null
|
displaySize?: { width: number; height: number } | null
|
||||||
): TextStampImageResult | null => {
|
): { dataUrl: string; pixelWidth: number; pixelHeight: number; displayWidth: number; displayHeight: number } | null => {
|
||||||
const text = (config.signerName ?? '').trim();
|
const text = (config.signerName ?? '').trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return null;
|
return null;
|
||||||
@@ -207,7 +199,6 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
|||||||
}
|
}
|
||||||
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
||||||
|
|
||||||
|
|
||||||
// Enable keyboard deletion of selected annotations
|
// Enable keyboard deletion of selected annotations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Always enable delete key when we have annotation API and are in sign mode
|
// Always enable delete key when we have annotation API and are in sign mode
|
||||||
@@ -436,6 +427,60 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
|||||||
};
|
};
|
||||||
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
|
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!annotationApi?.onAnnotationEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = annotationApi.onAnnotationEvent(event => {
|
||||||
|
if (event.type !== 'create' && event.type !== 'update') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotation: any = event.annotation;
|
||||||
|
const annotationId: string | undefined = annotation?.id;
|
||||||
|
if (!annotationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directData =
|
||||||
|
extractDataUrl(annotation.imageSrc) ||
|
||||||
|
extractDataUrl(annotation.imageData) ||
|
||||||
|
extractDataUrl(annotation.appearance) ||
|
||||||
|
extractDataUrl(annotation.stampData) ||
|
||||||
|
extractDataUrl(annotation.contents) ||
|
||||||
|
extractDataUrl(annotation.data) ||
|
||||||
|
extractDataUrl(annotation.customData) ||
|
||||||
|
extractDataUrl(annotation.asset);
|
||||||
|
|
||||||
|
const dataToStore = directData || lastStampImageRef.current;
|
||||||
|
if (dataToStore) {
|
||||||
|
storeImageData(annotationId, dataToStore);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [annotationApi, storeImageData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlacementMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
configureStampDefaults().catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('Error updating signature defaults:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
|
||||||
|
|
||||||
|
|
||||||
return null; // This is a bridge component with no UI
|
return null; // This is a bridge component with no UI
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||||
|
import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePreview';
|
||||||
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
|
import {
|
||||||
|
MAX_PREVIEW_WIDTH_RATIO,
|
||||||
|
MAX_PREVIEW_HEIGHT_RATIO,
|
||||||
|
MAX_PREVIEW_WIDTH_REM,
|
||||||
|
MAX_PREVIEW_HEIGHT_REM,
|
||||||
|
MIN_SIGNATURE_DIMENSION_REM,
|
||||||
|
OVERLAY_EDGE_PADDING_REM,
|
||||||
|
} from '@app/constants/signConstants';
|
||||||
|
|
||||||
|
// Convert rem to pixels using browser's base font size (typically 16px)
|
||||||
|
const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
|
|
||||||
|
interface StampPlacementOverlayProps {
|
||||||
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
|
isActive: boolean;
|
||||||
|
signatureConfig: SignParameters | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StampPlacementOverlay: React.FC<StampPlacementOverlayProps> = ({
|
||||||
|
containerRef,
|
||||||
|
isActive,
|
||||||
|
signatureConfig,
|
||||||
|
}) => {
|
||||||
|
const [preview, setPreview] = useState<SignaturePreview | null>(null);
|
||||||
|
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const { setPlacementPreviewSize } = useSignature();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const buildPreview = async () => {
|
||||||
|
try {
|
||||||
|
const value = await buildSignaturePreview(signatureConfig ?? null);
|
||||||
|
if (!cancelled) {
|
||||||
|
setPreview(value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to build signature preview:', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setPreview(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
buildPreview();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [signatureConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = containerRef.current;
|
||||||
|
if (!isActive || !element) {
|
||||||
|
setCursor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMove = (event: MouseEvent) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
setCursor({
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeave = () => setCursor(null);
|
||||||
|
|
||||||
|
element.addEventListener('mousemove', handleMove);
|
||||||
|
element.addEventListener('mouseleave', handleLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('mousemove', handleMove);
|
||||||
|
element.removeEventListener('mouseleave', handleLeave);
|
||||||
|
};
|
||||||
|
}, [containerRef, isActive]);
|
||||||
|
|
||||||
|
const scaledSize = useMemo(() => {
|
||||||
|
if (!preview || !containerRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const containerWidth = container.clientWidth || 1;
|
||||||
|
const containerHeight = container.clientHeight || 1;
|
||||||
|
|
||||||
|
const maxWidth = Math.min(containerWidth * MAX_PREVIEW_WIDTH_RATIO, remToPx(MAX_PREVIEW_WIDTH_REM));
|
||||||
|
const maxHeight = Math.min(containerHeight * MAX_PREVIEW_HEIGHT_RATIO, remToPx(MAX_PREVIEW_HEIGHT_REM));
|
||||||
|
|
||||||
|
const scale = Math.min(
|
||||||
|
1,
|
||||||
|
maxWidth / Math.max(preview.width, 1),
|
||||||
|
maxHeight / Math.max(preview.height, 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.width * scale),
|
||||||
|
height: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.height * scale),
|
||||||
|
};
|
||||||
|
}, [preview, containerRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive || !scaledSize) {
|
||||||
|
setPlacementPreviewSize(null);
|
||||||
|
} else {
|
||||||
|
setPlacementPreviewSize(scaledSize);
|
||||||
|
}
|
||||||
|
}, [isActive, scaledSize, setPlacementPreviewSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setPlacementPreviewSize(null);
|
||||||
|
};
|
||||||
|
}, [setPlacementPreviewSize]);
|
||||||
|
|
||||||
|
const display = useMemo(() => {
|
||||||
|
if (!preview || !scaledSize || !cursor || !containerRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const containerWidth = container.clientWidth || 1;
|
||||||
|
const containerHeight = container.clientHeight || 1;
|
||||||
|
|
||||||
|
const width = scaledSize.width;
|
||||||
|
const height = scaledSize.height;
|
||||||
|
const edgePadding = remToPx(OVERLAY_EDGE_PADDING_REM);
|
||||||
|
|
||||||
|
const clampedLeft = Math.max(edgePadding, Math.min(cursor.x - width / 2, containerWidth - width - edgePadding));
|
||||||
|
const clampedTop = Math.max(edgePadding, Math.min(cursor.y - height / 2, containerHeight - height - edgePadding));
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: clampedLeft,
|
||||||
|
top: clampedTop,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
dataUrl: preview.dataUrl,
|
||||||
|
};
|
||||||
|
}, [preview, scaledSize, cursor, containerRef]);
|
||||||
|
|
||||||
|
if (!isActive || !display || !preview) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
left: `${display.left}px`,
|
||||||
|
top: `${display.top}px`,
|
||||||
|
width: `${display.width}px`,
|
||||||
|
height: `${display.height}px`,
|
||||||
|
backgroundImage: `url(${display.dataUrl})`,
|
||||||
|
backgroundSize: '100% 100%',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
boxShadow: '0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'transform 70ms ease-out',
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -46,6 +46,7 @@ import Rotate from "@app/tools/Rotate";
|
|||||||
import ChangeMetadata from "@app/tools/ChangeMetadata";
|
import ChangeMetadata from "@app/tools/ChangeMetadata";
|
||||||
import Crop from "@app/tools/Crop";
|
import Crop from "@app/tools/Crop";
|
||||||
import Sign from "@app/tools/Sign";
|
import Sign from "@app/tools/Sign";
|
||||||
|
import AddText from "@app/tools/AddText";
|
||||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||||
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
|
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
|
||||||
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
|
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
|
||||||
@@ -200,6 +201,18 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
|||||||
synonyms: getSynonyms(t, "sign"),
|
synonyms: getSynonyms(t, "sign"),
|
||||||
supportsAutomate: false, //TODO make support Sign
|
supportsAutomate: false, //TODO make support Sign
|
||||||
},
|
},
|
||||||
|
addText: {
|
||||||
|
icon: <LocalIcon icon="material-symbols:text-fields-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
|
name: t('home.addText.title', 'Add Text'),
|
||||||
|
component: AddText,
|
||||||
|
description: t('home.addText.desc', 'Add custom text anywhere in your PDF'),
|
||||||
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
|
subcategoryId: SubcategoryId.GENERAL,
|
||||||
|
operationConfig: signOperationConfig,
|
||||||
|
automationSettings: null,
|
||||||
|
synonyms: getSynonyms(t, 'addText'),
|
||||||
|
supportsAutomate: false,
|
||||||
|
},
|
||||||
|
|
||||||
// Document Security
|
// Document Security
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,12 @@ const writeToStorage = (entries: SavedSignature[]) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateId = () => crypto.randomUUID();
|
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 = () => {
|
export const useSavedSignatures = () => {
|
||||||
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage());
|
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage());
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createStampTool } from '@app/tools/stamp/createStampTool';
|
||||||
|
|
||||||
|
// AddText is text-only annotation (no drawing, no images, no save-to-library)
|
||||||
|
const AddText = createStampTool({
|
||||||
|
toolId: 'addText',
|
||||||
|
translationScope: 'addText',
|
||||||
|
allowedSignatureSources: ['text'],
|
||||||
|
defaultSignatureSource: 'text',
|
||||||
|
defaultSignatureType: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AddText;
|
||||||
@@ -1,208 +1,12 @@
|
|||||||
import { useEffect, useCallback, useRef } from "react";
|
import { createStampTool } from '@app/tools/stamp/createStampTool';
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
|
||||||
import { useSignParameters } from "@app/hooks/tools/sign/useSignParameters";
|
|
||||||
import { useSignOperation } from "@app/hooks/tools/sign/useSignOperation";
|
|
||||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
|
||||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
|
||||||
import SignSettings from "@app/components/tools/sign/SignSettings";
|
|
||||||
import { useNavigation } from "@app/contexts/NavigationContext";
|
|
||||||
import { useSignature } from "@app/contexts/SignatureContext";
|
|
||||||
import { useFileContext } from "@app/contexts/FileContext";
|
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
|
||||||
import { flattenSignatures } from "@app/utils/signatureFlattening";
|
|
||||||
|
|
||||||
const Sign = (props: BaseToolProps) => {
|
const Sign = createStampTool({
|
||||||
const { t } = useTranslation();
|
toolId: 'sign',
|
||||||
const { setWorkbench } = useNavigation();
|
translationScope: 'sign',
|
||||||
const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, signatureApiRef, getImageData, setSignaturesApplied } = useSignature();
|
allowedSignatureSources: ['canvas', 'image', 'text', 'saved'],
|
||||||
const { consumeFiles, selectors } = useFileContext();
|
defaultSignatureSource: 'canvas',
|
||||||
const { exportActions, getScrollState, activeFileIndex, setActiveFileIndex } = useViewer();
|
defaultSignatureType: 'canvas',
|
||||||
const { setHasUnsavedChanges, unregisterUnsavedChangesChecker } = useNavigation();
|
enableApplyAction: true,
|
||||||
|
|
||||||
// Track which signature mode was active for reactivation after save
|
|
||||||
const activeModeRef = useRef<'draw' | 'placement' | null>(null);
|
|
||||||
|
|
||||||
// Single handler that activates placement mode
|
|
||||||
const handleSignaturePlacement = useCallback(() => {
|
|
||||||
activateSignaturePlacementMode();
|
|
||||||
}, [activateSignaturePlacementMode]);
|
|
||||||
|
|
||||||
// Memoized callbacks for SignSettings to prevent infinite loops
|
|
||||||
const handleActivateDrawMode = useCallback(() => {
|
|
||||||
activeModeRef.current = 'draw';
|
|
||||||
activateDrawMode();
|
|
||||||
}, [activateDrawMode]);
|
|
||||||
|
|
||||||
const handleActivateSignaturePlacement = useCallback(() => {
|
|
||||||
activeModeRef.current = 'placement';
|
|
||||||
handleSignaturePlacement();
|
|
||||||
}, [handleSignaturePlacement]);
|
|
||||||
|
|
||||||
const handleDeactivateSignature = useCallback(() => {
|
|
||||||
activeModeRef.current = null;
|
|
||||||
deactivateDrawMode();
|
|
||||||
}, [deactivateDrawMode]);
|
|
||||||
|
|
||||||
const base = useBaseTool(
|
|
||||||
'sign',
|
|
||||||
useSignParameters,
|
|
||||||
useSignOperation,
|
|
||||||
props
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasOpenedViewer = useRef(false);
|
|
||||||
|
|
||||||
// Open viewer when files are selected (only once)
|
|
||||||
useEffect(() => {
|
|
||||||
if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) {
|
|
||||||
setWorkbench('viewer');
|
|
||||||
hasOpenedViewer.current = true;
|
|
||||||
}
|
|
||||||
}, [base.selectedFiles.length, setWorkbench]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Sync signature configuration with context
|
|
||||||
useEffect(() => {
|
|
||||||
setSignatureConfig(base.params.parameters);
|
|
||||||
}, [base.params.parameters, setSignatureConfig]);
|
|
||||||
|
|
||||||
// Save signed files to the system - apply signatures using EmbedPDF and replace original
|
|
||||||
const handleSaveToSystem = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
// Unregister unsaved changes checker to prevent warning during apply
|
|
||||||
unregisterUnsavedChangesChecker();
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
|
|
||||||
// Get the original file from FileContext using activeFileIndex
|
|
||||||
// The viewer displays files from FileContext, not from base.selectedFiles
|
|
||||||
const allFiles = selectors.getFiles();
|
|
||||||
const fileIndex = activeFileIndex < allFiles.length ? activeFileIndex : 0;
|
|
||||||
const originalFile = allFiles[fileIndex];
|
|
||||||
|
|
||||||
if (!originalFile) {
|
|
||||||
console.error('No file available to replace');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the signature flattening utility
|
|
||||||
const flattenResult = await flattenSignatures({
|
|
||||||
signatureApiRef,
|
|
||||||
getImageData,
|
|
||||||
exportActions,
|
|
||||||
selectors,
|
|
||||||
originalFile,
|
|
||||||
getScrollState,
|
|
||||||
activeFileIndex
|
|
||||||
});
|
|
||||||
|
|
||||||
if (flattenResult) {
|
|
||||||
// Now consume the files - this triggers the viewer reload
|
|
||||||
await consumeFiles(
|
|
||||||
flattenResult.inputFileIds,
|
|
||||||
[flattenResult.outputStirlingFile],
|
|
||||||
[flattenResult.outputStub]
|
|
||||||
);
|
|
||||||
|
|
||||||
// According to FileReducer.processFileSwap, new files are inserted at the beginning
|
|
||||||
// So the new file will be at index 0
|
|
||||||
setActiveFileIndex(0);
|
|
||||||
|
|
||||||
// Mark signatures as applied
|
|
||||||
setSignaturesApplied(true);
|
|
||||||
|
|
||||||
// Deactivate signature placement mode after everything completes
|
|
||||||
handleDeactivateSignature();
|
|
||||||
|
|
||||||
const hasSignatureReady = (() => {
|
|
||||||
const params = base.params.parameters;
|
|
||||||
switch (params.signatureType) {
|
|
||||||
case 'canvas':
|
|
||||||
case 'image':
|
|
||||||
return Boolean(params.signatureData);
|
|
||||||
case 'text':
|
|
||||||
return Boolean(params.signerName && params.signerName.trim() !== '');
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (hasSignatureReady) {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
// TODO: Ideally, we should trigger handleActivateSignaturePlacement when the viewer is ready.
|
|
||||||
// However, due to current architectural constraints, we use a 150ms delay to allow the viewer to reload.
|
|
||||||
// This value was empirically determined to be sufficient for most environments, but should be revisited.
|
|
||||||
window.setTimeout(() => {
|
|
||||||
handleActivateSignaturePlacement();
|
|
||||||
}, 150);
|
|
||||||
} else {
|
|
||||||
handleActivateSignaturePlacement();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// File has been consumed - viewer should reload automatically via key prop
|
|
||||||
} else {
|
|
||||||
console.error('Signature flattening failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving signed document:', error);
|
|
||||||
}
|
|
||||||
}, [exportActions, base.selectedFiles, base.params.parameters, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, handleActivateSignaturePlacement, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
|
|
||||||
|
|
||||||
const getSteps = () => {
|
|
||||||
const steps = [];
|
|
||||||
|
|
||||||
// Step 1: Signature Configuration - Only visible when file is loaded
|
|
||||||
if (base.selectedFiles.length > 0) {
|
|
||||||
steps.push({
|
|
||||||
title: t('sign.steps.configure', 'Configure Signature'),
|
|
||||||
isCollapsed: false,
|
|
||||||
onCollapsedClick: undefined,
|
|
||||||
content: (
|
|
||||||
<SignSettings
|
|
||||||
parameters={base.params.parameters}
|
|
||||||
onParameterChange={base.params.updateParameter}
|
|
||||||
disabled={base.endpointLoading}
|
|
||||||
onActivateDrawMode={handleActivateDrawMode}
|
|
||||||
onActivateSignaturePlacement={handleActivateSignaturePlacement}
|
|
||||||
onDeactivateSignature={handleDeactivateSignature}
|
|
||||||
onUpdateDrawSettings={updateDrawSettings}
|
|
||||||
onUndo={undo}
|
|
||||||
onRedo={redo}
|
|
||||||
onSave={handleSaveToSystem}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return steps;
|
|
||||||
};
|
|
||||||
|
|
||||||
return createToolFlow({
|
|
||||||
files: {
|
|
||||||
selectedFiles: base.selectedFiles,
|
|
||||||
isCollapsed: base.operation.files.length > 0,
|
|
||||||
},
|
|
||||||
steps: getSteps(),
|
|
||||||
review: {
|
|
||||||
isVisible: false, // Hide review section - save moved to configure section
|
|
||||||
operation: base.operation,
|
|
||||||
title: t('sign.results.title', 'Signature Results'),
|
|
||||||
onFileClick: base.handleThumbnailClick,
|
|
||||||
onUndo: () => {},
|
|
||||||
},
|
|
||||||
forceStepNumbers: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add the required static methods for automation
|
|
||||||
Sign.tool = () => useSignOperation;
|
|
||||||
Sign.getDefaultParameters = () => ({
|
|
||||||
signatureType: 'canvas',
|
|
||||||
reason: 'Document signing',
|
|
||||||
location: 'Digital',
|
|
||||||
signerName: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Sign as ToolComponent;
|
export default Sign;
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
||||||
|
import SignSettings, { SignatureSource } from '@app/components/tools/sign/SignSettings';
|
||||||
|
import { DEFAULT_PARAMETERS, useSignParameters, SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||||
|
import { useSignOperation } from '@app/hooks/tools/sign/useSignOperation';
|
||||||
|
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
|
||||||
|
import { BaseToolProps, ToolComponent } from '@app/types/tool';
|
||||||
|
import { ToolId } from '@app/types/toolId';
|
||||||
|
import { useNavigation } from '@app/contexts/NavigationContext';
|
||||||
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
|
import { useFileContext } from '@app/contexts/FileContext';
|
||||||
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
|
import { flattenSignatures } from '@app/utils/signatureFlattening';
|
||||||
|
|
||||||
|
export type StampToolConfig = {
|
||||||
|
toolId: ToolId;
|
||||||
|
translationScope?: string;
|
||||||
|
allowedSignatureSources?: SignatureSource[];
|
||||||
|
defaultSignatureSource?: SignatureSource;
|
||||||
|
defaultSignatureType?: SignParameters['signatureType'];
|
||||||
|
enableApplyAction?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAMP_TOOL_DEFAULT_SOURCES: SignatureSource[] = ['canvas', 'image', 'text', 'saved'];
|
||||||
|
|
||||||
|
export const createStampTool = (config: StampToolConfig) => {
|
||||||
|
const {
|
||||||
|
toolId,
|
||||||
|
translationScope = toolId,
|
||||||
|
allowedSignatureSources = STAMP_TOOL_DEFAULT_SOURCES,
|
||||||
|
defaultSignatureSource,
|
||||||
|
defaultSignatureType,
|
||||||
|
enableApplyAction = false,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const StampTool = (props: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const translateTool = useCallback(
|
||||||
|
(key: string, defaultValue: string) => t(`${translationScope}.${key}`, defaultValue),
|
||||||
|
[t, translationScope]
|
||||||
|
);
|
||||||
|
const { setWorkbench, setHasUnsavedChanges, unregisterUnsavedChangesChecker } = useNavigation();
|
||||||
|
const {
|
||||||
|
setSignatureConfig,
|
||||||
|
activateDrawMode,
|
||||||
|
activateSignaturePlacementMode,
|
||||||
|
deactivateDrawMode,
|
||||||
|
updateDrawSettings,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
signatureApiRef,
|
||||||
|
getImageData,
|
||||||
|
setSignaturesApplied,
|
||||||
|
} = useSignature();
|
||||||
|
const { consumeFiles, selectors } = useFileContext();
|
||||||
|
const { exportActions, getScrollState, activeFileIndex, setActiveFileIndex } = useViewer();
|
||||||
|
const base = useBaseTool(
|
||||||
|
toolId,
|
||||||
|
useSignParameters,
|
||||||
|
useSignOperation,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowedSignatureTypes = allowedSignatureSources.filter(
|
||||||
|
(source): source is SignParameters['signatureType'] => source !== 'saved'
|
||||||
|
);
|
||||||
|
const enforcedSignatureType =
|
||||||
|
defaultSignatureType ?? allowedSignatureTypes[0] ?? DEFAULT_PARAMETERS.signatureType;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowedSignatureTypes.includes(base.params.parameters.signatureType)) {
|
||||||
|
base.params.updateParameter('signatureType', enforcedSignatureType);
|
||||||
|
}
|
||||||
|
}, [allowedSignatureTypes, base.params.parameters.signatureType, base.params.updateParameter, enforcedSignatureType]);
|
||||||
|
|
||||||
|
const hasOpenedViewer = useRef(false);
|
||||||
|
const activeModeRef = useRef<'draw' | 'placement' | null>(null);
|
||||||
|
|
||||||
|
const handleSignaturePlacement = useCallback(() => {
|
||||||
|
activateSignaturePlacementMode();
|
||||||
|
}, [activateSignaturePlacementMode]);
|
||||||
|
|
||||||
|
const handleActivateDrawMode = useCallback(() => {
|
||||||
|
activeModeRef.current = 'draw';
|
||||||
|
activateDrawMode();
|
||||||
|
}, [activateDrawMode]);
|
||||||
|
|
||||||
|
const handleActivateSignaturePlacement = useCallback(() => {
|
||||||
|
activeModeRef.current = 'placement';
|
||||||
|
handleSignaturePlacement();
|
||||||
|
}, [handleSignaturePlacement]);
|
||||||
|
|
||||||
|
const handleDeactivateSignature = useCallback(() => {
|
||||||
|
activeModeRef.current = null;
|
||||||
|
deactivateDrawMode();
|
||||||
|
}, [deactivateDrawMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) {
|
||||||
|
setWorkbench('viewer');
|
||||||
|
hasOpenedViewer.current = true;
|
||||||
|
}
|
||||||
|
}, [base.selectedFiles.length, setWorkbench]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSignatureConfig(base.params.parameters);
|
||||||
|
}, [base.params.parameters, setSignatureConfig]);
|
||||||
|
|
||||||
|
const handleSaveToSystem = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
unregisterUnsavedChangesChecker();
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
|
||||||
|
const allFiles = selectors.getFiles();
|
||||||
|
const fileIndex = activeFileIndex < allFiles.length ? activeFileIndex : 0;
|
||||||
|
const originalFile = allFiles[fileIndex];
|
||||||
|
|
||||||
|
if (!originalFile) {
|
||||||
|
console.error('No file available to replace');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flattenResult = await flattenSignatures({
|
||||||
|
signatureApiRef,
|
||||||
|
getImageData,
|
||||||
|
exportActions,
|
||||||
|
selectors,
|
||||||
|
originalFile,
|
||||||
|
getScrollState,
|
||||||
|
activeFileIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (flattenResult) {
|
||||||
|
await consumeFiles(
|
||||||
|
flattenResult.inputFileIds,
|
||||||
|
[flattenResult.outputStirlingFile],
|
||||||
|
[flattenResult.outputStub]
|
||||||
|
);
|
||||||
|
|
||||||
|
setActiveFileIndex(0);
|
||||||
|
setSignaturesApplied(true);
|
||||||
|
handleDeactivateSignature();
|
||||||
|
|
||||||
|
const hasSignatureReady = (() => {
|
||||||
|
const params = base.params.parameters;
|
||||||
|
switch (params.signatureType) {
|
||||||
|
case 'canvas':
|
||||||
|
case 'image':
|
||||||
|
return Boolean(params.signatureData);
|
||||||
|
case 'text':
|
||||||
|
return Boolean(params.signerName && params.signerName.trim() !== '');
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (hasSignatureReady) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
handleActivateSignaturePlacement();
|
||||||
|
}, 150);
|
||||||
|
} else {
|
||||||
|
handleActivateSignaturePlacement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Signature flattening failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving signed document:', error);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
exportActions,
|
||||||
|
base.selectedFiles,
|
||||||
|
base.params.parameters,
|
||||||
|
selectors,
|
||||||
|
consumeFiles,
|
||||||
|
signatureApiRef,
|
||||||
|
getImageData,
|
||||||
|
setWorkbench,
|
||||||
|
activateDrawMode,
|
||||||
|
setSignaturesApplied,
|
||||||
|
getScrollState,
|
||||||
|
handleDeactivateSignature,
|
||||||
|
handleActivateSignaturePlacement,
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
unregisterUnsavedChangesChecker,
|
||||||
|
activeFileIndex,
|
||||||
|
setActiveFileIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getSteps = () => {
|
||||||
|
const steps = [];
|
||||||
|
|
||||||
|
if (base.selectedFiles.length > 0) {
|
||||||
|
steps.push({
|
||||||
|
title: translateTool('steps.configure', 'Configure Stamp'),
|
||||||
|
isCollapsed: false,
|
||||||
|
onCollapsedClick: undefined,
|
||||||
|
content: (
|
||||||
|
<SignSettings
|
||||||
|
parameters={base.params.parameters}
|
||||||
|
onParameterChange={base.params.updateParameter}
|
||||||
|
disabled={base.endpointLoading}
|
||||||
|
onActivateDrawMode={handleActivateDrawMode}
|
||||||
|
onActivateSignaturePlacement={handleActivateSignaturePlacement}
|
||||||
|
onDeactivateSignature={handleDeactivateSignature}
|
||||||
|
onUpdateDrawSettings={updateDrawSettings}
|
||||||
|
onUndo={undo}
|
||||||
|
onRedo={redo}
|
||||||
|
onSave={enableApplyAction ? handleSaveToSystem : undefined}
|
||||||
|
translationScope={translationScope}
|
||||||
|
allowedSignatureSources={allowedSignatureSources}
|
||||||
|
defaultSignatureSource={defaultSignatureSource}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
};
|
||||||
|
|
||||||
|
return createToolFlow({
|
||||||
|
files: {
|
||||||
|
selectedFiles: base.selectedFiles,
|
||||||
|
isCollapsed: base.operation.files.length > 0,
|
||||||
|
},
|
||||||
|
steps: getSteps(),
|
||||||
|
review: {
|
||||||
|
isVisible: false,
|
||||||
|
operation: base.operation,
|
||||||
|
title: translateTool('results.title', 'Stamp Results'),
|
||||||
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: () => {},
|
||||||
|
},
|
||||||
|
forceStepNumbers: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const StampToolComponent = StampTool as ToolComponent;
|
||||||
|
StampToolComponent.tool = () => useSignOperation;
|
||||||
|
StampToolComponent.getDefaultParameters = () => ({
|
||||||
|
...DEFAULT_PARAMETERS,
|
||||||
|
signatureType: config.defaultSignatureType ?? DEFAULT_PARAMETERS.signatureType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return StampToolComponent;
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ export type ToolKind = 'regular' | 'super' | 'link';
|
|||||||
export const CORE_REGULAR_TOOL_IDS = [
|
export const CORE_REGULAR_TOOL_IDS = [
|
||||||
'certSign',
|
'certSign',
|
||||||
'sign',
|
'sign',
|
||||||
|
'addText',
|
||||||
'addPassword',
|
'addPassword',
|
||||||
'removePassword',
|
'removePassword',
|
||||||
'removePages',
|
'removePages',
|
||||||
@@ -113,4 +114,3 @@ type Disjoint<A, B> = [A & B] extends [never] ? true : false;
|
|||||||
type _Check1 = Assert<Disjoint<RegularToolId, SuperToolId>>;
|
type _Check1 = Assert<Disjoint<RegularToolId, SuperToolId>>;
|
||||||
type _Check2 = Assert<Disjoint<RegularToolId, LinkToolId>>;
|
type _Check2 = Assert<Disjoint<RegularToolId, LinkToolId>>;
|
||||||
type _Check3 = Assert<Disjoint<SuperToolId, LinkToolId>>;
|
type _Check3 = Assert<Disjoint<SuperToolId, LinkToolId>>;
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
|||||||
'/read': 'read',
|
'/read': 'read',
|
||||||
'/automate': 'automate',
|
'/automate': 'automate',
|
||||||
'/sign': 'sign',
|
'/sign': 'sign',
|
||||||
|
'/add-text': 'addText',
|
||||||
|
|
||||||
// Developer tools
|
// Developer tools
|
||||||
'/dev-api': 'devApi',
|
'/dev-api': 'devApi',
|
||||||
|
|||||||
Reference in New Issue
Block a user