feat(sign): merge cert-sign and wet-sign into a unified Sign tool
Combines the separate CertSign and Sign tools into a single "Sign" entry that mirrors the shared-signing participant view. Users pick wet sign, cert sign, or both from a segmented control at the top of the full-screen editor. New files: - CombinedSignEditor.tsx — full-screen editor with mode selector, SignControlsStrip for wet/both modes, LocalEmbedPDFWithAnnotations, and CertificateConfigModal (with optional appearance settings) - CombinedSign.tsx — tool entry point; registers editor as a custom workbench view (hideToolPanel: true) and opens it on demand - useCombinedSignOperation.ts — submitCertSign (cert-sign endpoint) and submitWetOnly (sequential add-signature calls) Modified files: - CertificateConfigModal — adds showAppearanceSettings prop that renders SignatureSettingsInput and passes appearance data to onSign - toolId.ts — removes certSign from CORE_REGULAR_TOOL_IDS - useTranslatedToolRegistry — removes certSign entry; sign entry now uses CombinedSign with endpoints ["sign", "cert-sign"] - urlMapping.ts — /cert-sign and /manage-signatures redirect to sign - SignPopout.tsx — quick-access cert-sign button now selects sign - creditCosts.ts — merges certSign credit cost into sign (LARGE) - translation.toml — adds sign.editor.* and sign.mode.* keys
This commit is contained in:
@@ -4157,8 +4157,8 @@ tags = "javascript,code,script,show javascript,show JS,find javascript,detect ja
|
||||
title = "Show Javascript"
|
||||
|
||||
[home.sign]
|
||||
desc = "Adds signature to PDF by drawing, text or image"
|
||||
tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting"
|
||||
desc = "Sign PDFs with a wet signature, digital certificate, or both"
|
||||
tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting,certificate sign,cert sign,wet sign"
|
||||
title = "Sign"
|
||||
|
||||
[home.timestampPdf]
|
||||
@@ -6916,11 +6916,24 @@ saved = "Select a saved signature above, then click anywhere on the PDF to place
|
||||
text = "After entering your name above, click anywhere on the PDF to place your signature."
|
||||
title = "How to add signature"
|
||||
|
||||
[sign.editor]
|
||||
back = "Back to file selection"
|
||||
hint = "Open the editor to place wet signatures, apply a digital certificate, or both."
|
||||
noSignatures = "Place at least one signature on the PDF first."
|
||||
open = "Open Sign Editor"
|
||||
openStep = "Open Sign Editor"
|
||||
signingFailed = "Signing failed. Please try again."
|
||||
signDocument = "Sign Document"
|
||||
title = "Sign Editor"
|
||||
|
||||
[sign.mode]
|
||||
both = "Both"
|
||||
cert = "Cert Sign"
|
||||
move = "Move Signature"
|
||||
pause = "Pause placement"
|
||||
place = "Place Signature"
|
||||
resume = "Resume placement"
|
||||
wet = "Wet Sign"
|
||||
|
||||
[sign.results]
|
||||
title = "Signature Results"
|
||||
|
||||
@@ -677,11 +677,11 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
|
||||
className="quick-access-popout__quick-sign-btn"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
handleToolSelect('certSign');
|
||||
handleToolSelect('sign');
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="workspace-premium-rounded" width="1rem" height="1rem" />
|
||||
{t('quickAccess.certSign', 'Certificate Sign')}
|
||||
<LocalIcon icon="signature-rounded" width="1rem" height="1rem" />
|
||||
{t('quickAccess.sign', 'Sign')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Modal, Stack, Group, Button, Text, Collapse, TextInput } from '@mantine/core';
|
||||
import { Modal, Stack, Group, Button, Text, Collapse, TextInput, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { CertificateSelector, CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector';
|
||||
import SignatureSettingsInput, { SignatureSettings } from '@app/components/tools/certSign/SignatureSettingsInput';
|
||||
|
||||
export interface CertificateSubmitData {
|
||||
certType: CertificateType;
|
||||
@@ -11,6 +12,11 @@ export interface CertificateSubmitData {
|
||||
certFile: File | null;
|
||||
jksFile: File | null;
|
||||
password: string;
|
||||
// Appearance (only present when showAppearanceSettings=true)
|
||||
showSignature?: boolean;
|
||||
pageNumber?: number;
|
||||
showLogo?: boolean;
|
||||
includeSummaryPage?: boolean;
|
||||
}
|
||||
|
||||
interface CertificateConfigModalProps {
|
||||
@@ -21,6 +27,8 @@ interface CertificateConfigModalProps {
|
||||
disabled?: boolean;
|
||||
defaultReason?: string;
|
||||
defaultLocation?: string;
|
||||
/** When true, renders SignatureSettingsInput so the user can configure cert appearance */
|
||||
showAppearanceSettings?: boolean;
|
||||
}
|
||||
|
||||
export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
||||
@@ -31,6 +39,7 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
||||
disabled = false,
|
||||
defaultReason = '',
|
||||
defaultLocation = '',
|
||||
showAppearanceSettings = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -48,6 +57,11 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
||||
const [reason, setReason] = useState(defaultReason);
|
||||
const [location, setLocation] = useState(defaultLocation);
|
||||
|
||||
// Appearance settings (only used when showAppearanceSettings=true)
|
||||
const [appearance, setAppearance] = useState<SignatureSettings>({
|
||||
showSignature: false,
|
||||
});
|
||||
|
||||
const isUploadValid = () => {
|
||||
if (certType !== 'UPLOAD') return true;
|
||||
switch (uploadFormat) {
|
||||
@@ -71,11 +85,22 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
||||
|
||||
setSigning(true);
|
||||
try {
|
||||
await onSign(
|
||||
{ certType, uploadFormat, p12File, privateKeyFile, certFile, jksFile, password },
|
||||
reason,
|
||||
location
|
||||
);
|
||||
const certData: CertificateSubmitData = {
|
||||
certType,
|
||||
uploadFormat,
|
||||
p12File,
|
||||
privateKeyFile,
|
||||
certFile,
|
||||
jksFile,
|
||||
password,
|
||||
...(showAppearanceSettings && {
|
||||
showSignature: appearance.showSignature,
|
||||
pageNumber: appearance.pageNumber,
|
||||
showLogo: appearance.showLogo,
|
||||
includeSummaryPage: appearance.includeSummaryPage,
|
||||
}),
|
||||
};
|
||||
await onSign(certData, reason, location);
|
||||
} catch (error) {
|
||||
console.error('Failed to sign document:', error);
|
||||
} finally {
|
||||
@@ -118,6 +143,19 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
||||
disabled={disabled || signing}
|
||||
/>
|
||||
|
||||
{/* Signature Appearance Settings (combined sign tool) */}
|
||||
{showAppearanceSettings && (
|
||||
<>
|
||||
<Divider />
|
||||
<SignatureSettingsInput
|
||||
value={appearance}
|
||||
onChange={setAppearance}
|
||||
disabled={disabled || signing}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Advanced Settings - Optional */}
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Paper, Group, Button, Text, SegmentedControl, Divider, CloseButton } from '@mantine/core';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||
import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import { Z_INDEX_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { LocalEmbedPDFWithAnnotations, AnnotationAPI, SignaturePreview } from '@app/components/viewer/LocalEmbedPDFWithAnnotations';
|
||||
import SignControlsStrip from '@app/components/tools/certSign/SignControlsStrip';
|
||||
import { CertificateConfigModal } from '@app/components/tools/certSign/modals/CertificateConfigModal';
|
||||
import type { CertificateSubmitData } from '@app/components/tools/certSign/modals/CertificateConfigModal';
|
||||
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { useCombinedSignOperation } from '@app/hooks/tools/combinedSign/useCombinedSignOperation';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { useIsPhone } from '@app/hooks/useIsMobile';
|
||||
|
||||
type SignMode = 'wet' | 'both' | 'cert';
|
||||
|
||||
export interface CombinedSignEditorData {
|
||||
file: File;
|
||||
onComplete: (blob: Blob, filename: string) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SIGNATURE_CONFIG: SignParameters = {
|
||||
signatureType: 'canvas',
|
||||
signerName: '',
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 16,
|
||||
textColor: '#000000',
|
||||
};
|
||||
|
||||
/**
|
||||
* Full-screen signing editor, mirroring the SignRequestWorkbenchView layout.
|
||||
*
|
||||
* Registered as a custom workbench view by CombinedSign.tsx. Receives its
|
||||
* runtime state through the `data` prop (set via setCustomWorkbenchViewData).
|
||||
*
|
||||
* Modes:
|
||||
* wet — visual/wet signatures only, submitted to add-signature endpoint
|
||||
* cert — digital certificate only, submitted to cert-sign endpoint
|
||||
* both — wet signatures + digital cert, submitted to cert-sign with wetSignaturesData
|
||||
*/
|
||||
const CombinedSignEditor = ({ data }: { data: CombinedSignEditorData | null }) => {
|
||||
const { t } = useTranslation();
|
||||
const isPhone = useIsPhone();
|
||||
const { submitCertSign, submitWetOnly, loading } = useCombinedSignOperation();
|
||||
|
||||
const annotationApiRef = useRef<AnnotationAPI | null>(null);
|
||||
|
||||
const [signMode, setSignMode] = useState<SignMode>('both');
|
||||
const [signatureConfig, setSignatureConfig] = useState<SignParameters>(DEFAULT_SIGNATURE_CONFIG);
|
||||
const [placementMode, setPlacementMode] = useState(true);
|
||||
const [previewCount, setPreviewCount] = useState(0);
|
||||
const [hasSelectedAnnotation, setHasSelectedAnnotation] = useState(false);
|
||||
const [certModalOpen, setCertModalOpen] = useState(false);
|
||||
|
||||
const showWetControls = signMode === 'wet' || signMode === 'both';
|
||||
|
||||
// Poll for selected annotation state (enables delete button)
|
||||
useEffect(() => {
|
||||
if (!showWetControls) {
|
||||
setHasSelectedAnnotation(false);
|
||||
return;
|
||||
}
|
||||
const check = () => {
|
||||
const has = (annotationApiRef.current as any)?.getHasSelectedAnnotation?.();
|
||||
setHasSelectedAnnotation(Boolean(has));
|
||||
};
|
||||
check();
|
||||
const id = setInterval(check, 350);
|
||||
return () => clearInterval(id);
|
||||
}, [showWetControls]);
|
||||
|
||||
// Clear placed signatures when switching to cert-only mode
|
||||
useEffect(() => {
|
||||
if (signMode === 'cert') {
|
||||
annotationApiRef.current?.clearPreviews();
|
||||
setPreviewCount(0);
|
||||
}
|
||||
}, [signMode]);
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
if (!data) return;
|
||||
|
||||
if (signMode === 'wet') {
|
||||
const previews = annotationApiRef.current?.getSignaturePreviews() ?? [];
|
||||
if (previews.length === 0) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('sign.editor.noSignatures', 'Place at least one signature on the PDF first.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
void handleWetSign(previews);
|
||||
} else {
|
||||
// cert or both — open the certificate modal
|
||||
if (signMode === 'both' && previewCount === 0) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('sign.editor.noSignatures', 'Place at least one signature on the PDF first.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setCertModalOpen(true);
|
||||
}
|
||||
}, [data, signMode, previewCount, t]);
|
||||
|
||||
const handleWetSign = async (previews: SignaturePreview[]) => {
|
||||
if (!data) return;
|
||||
try {
|
||||
const result = await submitWetOnly(data.file, previews);
|
||||
data.onComplete(result.blob, result.filename);
|
||||
} catch {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('sign.editor.signingFailed', 'Signing failed. Please try again.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCertSign = async (
|
||||
certData: CertificateSubmitData,
|
||||
reason?: string,
|
||||
location?: string,
|
||||
) => {
|
||||
if (!data) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', data.file);
|
||||
|
||||
if (certData.certType === 'UPLOAD') {
|
||||
formData.append('certType', certData.uploadFormat);
|
||||
if (certData.p12File) formData.append('p12File', certData.p12File);
|
||||
if (certData.privateKeyFile) formData.append('privateKeyFile', certData.privateKeyFile);
|
||||
if (certData.certFile) formData.append('certFile', certData.certFile);
|
||||
if (certData.jksFile) formData.append('jksFile', certData.jksFile);
|
||||
if (certData.password) formData.append('password', certData.password);
|
||||
} else {
|
||||
formData.append('certType', certData.certType);
|
||||
}
|
||||
|
||||
// Appearance settings (from extended CertificateConfigModal)
|
||||
if (certData.showSignature !== undefined) {
|
||||
formData.append('showSignature', certData.showSignature.toString());
|
||||
}
|
||||
if (certData.showSignature) {
|
||||
if (certData.pageNumber !== undefined) formData.append('pageNumber', certData.pageNumber.toString());
|
||||
if (certData.showLogo !== undefined) formData.append('showLogo', certData.showLogo.toString());
|
||||
}
|
||||
if (reason?.trim()) formData.append('reason', reason);
|
||||
if (location?.trim()) formData.append('location', location);
|
||||
|
||||
// Wet signatures (both mode only)
|
||||
if (signMode === 'both') {
|
||||
const previews = annotationApiRef.current?.getSignaturePreviews() ?? [];
|
||||
if (previews.length > 0) {
|
||||
const wetSignaturesJson = previews.map((p) => ({
|
||||
type: p.signatureType,
|
||||
data: p.signatureData,
|
||||
page: p.pageIndex,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
}));
|
||||
formData.append('wetSignaturesData', JSON.stringify(wetSignaturesJson));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await submitCertSign(data.file, formData);
|
||||
setCertModalOpen(false);
|
||||
data.onComplete(result.blob, result.filename);
|
||||
} catch {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('common.error'),
|
||||
body: t('sign.editor.signingFailed', 'Signing failed. Please try again.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
(annotationApiRef.current as any)?.deleteSelectedAnnotation?.();
|
||||
}, []);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
{/* ── Top bar ─────────────────────────────────────────────────────── */}
|
||||
<Paper
|
||||
p="sm"
|
||||
shadow="sm"
|
||||
style={{ flexShrink: 0, zIndex: Z_INDEX_FULLSCREEN_SURFACE, position: 'relative' }}
|
||||
>
|
||||
<Group justify="space-between" style={{ flexWrap: isPhone ? 'wrap' : 'nowrap' }}>
|
||||
<Group gap="md">
|
||||
<LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
truncate={isPhone ? 'end' : undefined}
|
||||
style={{ maxWidth: isPhone ? '140px' : undefined }}
|
||||
>
|
||||
{data.file.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" style={{ width: isPhone ? '100%' : undefined }} justify={isPhone ? 'flex-end' : undefined}>
|
||||
{/* Sign mode selector */}
|
||||
<SegmentedControl
|
||||
value={signMode}
|
||||
onChange={(v) => setSignMode(v as SignMode)}
|
||||
size="xs"
|
||||
radius="xl"
|
||||
data={[
|
||||
{ value: 'wet', label: t('sign.mode.wet', 'Wet Sign') },
|
||||
{ value: 'both', label: t('sign.mode.both', 'Both') },
|
||||
{ value: 'cert', label: t('sign.mode.cert', 'Cert Sign') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{!isPhone && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<Button.Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => annotationApiRef.current?.zoomOut()}
|
||||
title={t('viewer.zoomOut', 'Zoom out')}
|
||||
>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => annotationApiRef.current?.resetZoom()}
|
||||
title={t('viewer.resetZoom', 'Reset zoom')}
|
||||
>
|
||||
<ZoomOutMapIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => annotationApiRef.current?.zoomIn()}
|
||||
title={t('viewer.zoomIn', 'Zoom in')}
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cert-only mode: show sign button in the top bar (no strip below) */}
|
||||
{signMode === 'cert' && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<CheckIcon fontSize="small" />}
|
||||
onClick={handleComplete}
|
||||
loading={loading}
|
||||
>
|
||||
{t('sign.editor.signDocument', 'Sign Document')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider orientation="vertical" />
|
||||
<CloseButton
|
||||
size="md"
|
||||
onClick={data.onBack}
|
||||
title={t('sign.editor.back', 'Back to file selection')}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* ── Wet signature controls strip (wet / both modes) ─────────────── */}
|
||||
{showWetControls && (
|
||||
<SignControlsStrip
|
||||
visible
|
||||
placementMode={placementMode}
|
||||
onPlacementModeChange={setPlacementMode}
|
||||
onSignatureSelected={setSignatureConfig}
|
||||
onComplete={handleComplete}
|
||||
canComplete={previewCount > 0}
|
||||
signatureConfig={signatureConfig}
|
||||
hasSelectedAnnotation={hasSelectedAnnotation}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── PDF viewer ──────────────────────────────────────────────────── */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||
<LocalEmbedPDFWithAnnotations
|
||||
ref={annotationApiRef}
|
||||
file={data.file}
|
||||
onAnnotationChange={() => {}}
|
||||
placementMode={showWetControls ? placementMode : false}
|
||||
signatureData={showWetControls ? signatureConfig?.signatureData : undefined}
|
||||
signatureType={showWetControls ? signatureConfig?.signatureType : undefined}
|
||||
onPlaceSignature={() => {}}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Certificate modal (cert / both modes) ───────────────────────── */}
|
||||
{(signMode === 'cert' || signMode === 'both') && (
|
||||
<CertificateConfigModal
|
||||
opened={certModalOpen}
|
||||
onClose={() => setCertModalOpen(false)}
|
||||
onSign={handleCertSign}
|
||||
signatureCount={previewCount}
|
||||
disabled={loading}
|
||||
showAppearanceSettings
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CombinedSignEditor;
|
||||
@@ -42,7 +42,7 @@ import UnlockPdfForms from "@app/tools/UnlockPdfForms";
|
||||
import FormFill from "@app/tools/formFill/FormFill";
|
||||
import RemoveCertificateSign from "@app/tools/RemoveCertificateSign";
|
||||
import RemoveImage from "@app/tools/RemoveImage";
|
||||
import CertSign from "@app/tools/CertSign";
|
||||
import CombinedSign from "@app/tools/CombinedSign";
|
||||
import TimestampPdf from "@app/tools/TimestampPdf";
|
||||
import BookletImposition from "@app/tools/BookletImposition";
|
||||
import Flatten from "@app/tools/Flatten";
|
||||
@@ -50,7 +50,6 @@ import Rotate from "@app/tools/Rotate";
|
||||
import PdfTextEditor from "@app/tools/pdfTextEditor/PdfTextEditor";
|
||||
import ChangeMetadata from "@app/tools/ChangeMetadata";
|
||||
import Crop from "@app/tools/Crop";
|
||||
import Sign from "@app/tools/Sign";
|
||||
import AddText from "@app/tools/AddText";
|
||||
import AddImage from "@app/tools/AddImage";
|
||||
import Annotate from "@app/tools/Annotate";
|
||||
@@ -69,7 +68,6 @@ import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
|
||||
import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOperation";
|
||||
import { removeCertificateSignOperationConfig } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
||||
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
|
||||
import { timestampPdfOperationConfig } from "@app/hooks/tools/timestampPdf/useTimestampPdfOperation";
|
||||
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
|
||||
import { mergeOperationConfig } from '@app/hooks/tools/merge/useMergeOperation';
|
||||
@@ -113,7 +111,6 @@ import MergeSettings from '@app/components/tools/merge/MergeSettings';
|
||||
import AdjustPageScaleSettings from "@app/components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||
import ScannerImageSplitSettings from "@app/components/tools/scannerImageSplit/ScannerImageSplitSettings";
|
||||
import ChangeMetadataSingleStep from "@app/components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||
import SignSettings from "@app/components/tools/sign/SignSettings";
|
||||
import AddPageNumbers from "@app/tools/AddPageNumbers";
|
||||
import RemoveAnnotations from "@app/tools/RemoveAnnotations";
|
||||
import PageLayoutSettings from "@app/components/tools/pageLayout/PageLayoutSettings";
|
||||
@@ -123,7 +120,6 @@ import ExtractImagesSettings from "@app/components/tools/extractImages/ExtractIm
|
||||
import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings";
|
||||
import ReplaceColorSettings from "@app/components/tools/replaceColor/ReplaceColorSettings";
|
||||
import AddStampAutomationSettings from "@app/components/tools/addStamp/AddStampAutomationSettings";
|
||||
import CertSignAutomationSettings from "@app/components/tools/certSign/CertSignAutomationSettings";
|
||||
import CropAutomationSettings from "@app/components/tools/crop/CropAutomationSettings";
|
||||
import RotateAutomationSettings from "@app/components/tools/rotate/RotateAutomationSettings";
|
||||
import SplitAutomationSettings from "@app/components/tools/split/SplitAutomationSettings";
|
||||
@@ -202,19 +198,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "merge")
|
||||
},
|
||||
// Signing
|
||||
certSign: {
|
||||
icon: <LocalIcon icon="workspace-premium-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.certSign.title", "Certificate Sign"),
|
||||
component: CertSign,
|
||||
description: t("home.certSign.desc", "Sign PDF documents using digital certificates"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING,
|
||||
synonyms: getSynonyms(t, "certSign"),
|
||||
maxFiles: -1,
|
||||
endpoints: ["cert-sign"],
|
||||
operationConfig: certSignOperationConfig,
|
||||
automationSettings: CertSignAutomationSettings,
|
||||
},
|
||||
timestampPdf: {
|
||||
icon: <LocalIcon icon="schedule-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.timestampPdf.title", "Timestamp PDF"),
|
||||
@@ -231,15 +214,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
sign: {
|
||||
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.sign.title", "Sign"),
|
||||
component: Sign,
|
||||
description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"),
|
||||
component: CombinedSign,
|
||||
description: t("home.sign.desc", "Sign PDFs with a wet signature, digital certificate, or both"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING,
|
||||
endpoints: ["sign"],
|
||||
endpoints: ["sign", "cert-sign"],
|
||||
operationConfig: signOperationConfig,
|
||||
automationSettings: SignSettings, // TODO:: not all settings shown, suggested next tools shown
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "sign"),
|
||||
supportsAutomate: false, //TODO make support Sign
|
||||
supportsAutomate: false,
|
||||
},
|
||||
addText: {
|
||||
icon: <LocalIcon icon="text-fields-rounded" width="1.5rem" height="1.5rem" />,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { SignaturePreview } from '@app/components/viewer/LocalEmbedPDFWithAnnotations';
|
||||
|
||||
export interface CombinedSignResult {
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface UseCombinedSignOperationReturn {
|
||||
submitCertSign: (file: File, formData: FormData) => Promise<CombinedSignResult>;
|
||||
submitWetOnly: (file: File, previews: SignaturePreview[]) => Promise<CombinedSignResult>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation hook for the combined sign tool.
|
||||
*
|
||||
* - submitCertSign: posts a pre-built FormData to /api/v1/security/cert-sign and
|
||||
* returns the signed PDF blob.
|
||||
* - submitWetOnly: sequentially applies each wet-signature preview via
|
||||
* /api/v1/security/add-signature (one call per signature, chained).
|
||||
*
|
||||
* Coordinates from LocalEmbedPDFWithAnnotations are normalised 0-1 fractions;
|
||||
* both endpoints are expected to accept this format.
|
||||
*/
|
||||
export const useCombinedSignOperation = (): UseCombinedSignOperationReturn => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const extractFilename = (headers: any, fallback: string): string => {
|
||||
const disposition: string = headers['content-disposition'] ?? '';
|
||||
const match = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(disposition);
|
||||
if (match?.[1]) return match[1].replace(/['"]/g, '');
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const submitCertSign = useCallback(async (file: File, formData: FormData): Promise<CombinedSignResult> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/security/cert-sign', formData, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
return { blob, filename: extractFilename(response.headers, `signed_${file.name}`) };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submitWetOnly = useCallback(async (file: File, previews: SignaturePreview[]): Promise<CombinedSignResult> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let currentFile: File = file;
|
||||
for (const preview of previews) {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', currentFile);
|
||||
formData.append('signatureData', preview.signatureData);
|
||||
formData.append('signatureType', preview.signatureType);
|
||||
formData.append('x', preview.x.toString());
|
||||
formData.append('y', preview.y.toString());
|
||||
formData.append('width', preview.width.toString());
|
||||
formData.append('height', preview.height.toString());
|
||||
formData.append('page', preview.pageIndex.toString());
|
||||
|
||||
const response = await apiClient.post('/api/v1/security/add-signature', formData, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
currentFile = new File([blob], file.name, { type: 'application/pdf' });
|
||||
}
|
||||
return { blob: currentFile, filename: `signed_${file.name}` };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { submitCertSign, submitWetOnly, loading };
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Stack, Text } from '@mantine/core';
|
||||
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
|
||||
|
||||
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
||||
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
|
||||
import { BaseToolProps, ToolComponent } from '@app/types/tool';
|
||||
import { useSignParameters, DEFAULT_PARAMETERS } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { useSignOperation, signOperationConfig } from '@app/hooks/tools/sign/useSignOperation';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import CombinedSignEditor, { CombinedSignEditorData } from '@app/components/tools/combinedSign/CombinedSignEditor';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
|
||||
const EDITOR_VIEW_ID = 'combinedSignEditor';
|
||||
const EDITOR_WORKBENCH_ID = 'custom:combinedSignEditor' as const;
|
||||
|
||||
const CombinedSign = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const base = useBaseTool('sign', useSignParameters, useSignOperation, props);
|
||||
|
||||
const editorIcon = useMemo(
|
||||
() => <LocalIcon icon="signature-rounded" width="1rem" height="1rem" />,
|
||||
[],
|
||||
);
|
||||
|
||||
// Register the full-screen editor as a custom workbench view (once on mount)
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: EDITOR_VIEW_ID,
|
||||
workbenchId: EDITOR_WORKBENCH_ID,
|
||||
label: t('sign.editor.title', 'Sign Editor'),
|
||||
icon: editorIcon,
|
||||
component: CombinedSignEditor,
|
||||
hideToolPanel: true,
|
||||
});
|
||||
return () => {
|
||||
clearCustomWorkbenchViewData(EDITOR_VIEW_ID);
|
||||
unregisterCustomWorkbenchView(EDITOR_VIEW_ID);
|
||||
};
|
||||
}, [clearCustomWorkbenchViewData, editorIcon, registerCustomWorkbenchView, t, unregisterCustomWorkbenchView]);
|
||||
|
||||
const handleOpenEditor = useCallback(() => {
|
||||
if (base.selectedFiles.length === 0) return;
|
||||
|
||||
const file = base.selectedFiles[0]; // StirlingFile extends File — works directly
|
||||
|
||||
const editorData: CombinedSignEditorData = {
|
||||
file,
|
||||
onComplete: (blob, filename) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
navigationActions.setWorkbench('viewer');
|
||||
},
|
||||
onBack: () => {
|
||||
navigationActions.setWorkbench('viewer');
|
||||
},
|
||||
};
|
||||
|
||||
setCustomWorkbenchViewData(EDITOR_VIEW_ID, editorData);
|
||||
navigationActions.setWorkbench(EDITOR_WORKBENCH_ID);
|
||||
}, [base.selectedFiles, navigationActions, setCustomWorkbenchViewData]);
|
||||
|
||||
const isEditorOpen = navigationState.workbench === EDITOR_WORKBENCH_ID;
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: isEditorOpen,
|
||||
},
|
||||
steps: base.selectedFiles.length > 0
|
||||
? [
|
||||
{
|
||||
title: t('sign.editor.openStep', 'Open Sign Editor'),
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'sign.editor.hint',
|
||||
'Open the editor to place wet signatures, apply a digital certificate, or both.',
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<OpenInFullIcon fontSize="small" />}
|
||||
onClick={handleOpenEditor}
|
||||
disabled={base.endpointLoading || isEditorOpen}
|
||||
>
|
||||
{t('sign.editor.open', 'Open Sign Editor')}
|
||||
</Button>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
executeButton: {
|
||||
text: t('sign.editor.open', 'Open Sign Editor'),
|
||||
loadingText: t('loading', 'Loading...'),
|
||||
onClick: async () => handleOpenEditor(),
|
||||
isVisible: false,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: true,
|
||||
},
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: base.operation,
|
||||
title: '',
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const CombinedSignTool = CombinedSign as ToolComponent;
|
||||
CombinedSignTool.tool = () => useSignOperation;
|
||||
CombinedSignTool.getDefaultParameters = () => ({ ...DEFAULT_PARAMETERS });
|
||||
|
||||
export default CombinedSignTool;
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
export type ToolKind = 'regular' | 'super' | 'link';
|
||||
|
||||
export const CORE_REGULAR_TOOL_IDS = [
|
||||
'certSign',
|
||||
'sign',
|
||||
'addText',
|
||||
'addPassword',
|
||||
|
||||
@@ -38,8 +38,8 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
'/add-password': 'addPassword',
|
||||
'/remove-password': 'removePassword',
|
||||
'/change-permissions': 'changePermissions',
|
||||
'/cert-sign': 'certSign',
|
||||
'/manage-signatures': 'certSign',
|
||||
'/cert-sign': 'sign',
|
||||
'/manage-signatures': 'sign',
|
||||
'/remove-certificate-sign': 'removeCertSign',
|
||||
'/remove-cert-sign': 'removeCertSign',
|
||||
'/unlock-pdf-forms': 'unlockPDFForms',
|
||||
|
||||
@@ -37,7 +37,7 @@ export const TOOL_CREDIT_COSTS: Record<ToolId, number> = {
|
||||
reorganizePages: CREDIT_COSTS.SMALL,
|
||||
scalePages: CREDIT_COSTS.SMALL,
|
||||
editTableOfContents: CREDIT_COSTS.SMALL,
|
||||
sign: CREDIT_COSTS.SMALL,
|
||||
sign: CREDIT_COSTS.LARGE,
|
||||
removeAnnotations: CREDIT_COSTS.SMALL,
|
||||
removeImage: CREDIT_COSTS.SMALL,
|
||||
scannerImageSplit: CREDIT_COSTS.SMALL,
|
||||
@@ -76,7 +76,6 @@ export const TOOL_CREDIT_COSTS: Record<ToolId, number> = {
|
||||
compress: CREDIT_COSTS.LARGE,
|
||||
convert: CREDIT_COSTS.LARGE,
|
||||
ocr: CREDIT_COSTS.LARGE,
|
||||
certSign: CREDIT_COSTS.LARGE,
|
||||
timestampPdf: CREDIT_COSTS.LARGE,
|
||||
|
||||
// Extra large operations (10 credits)
|
||||
|
||||
Reference in New Issue
Block a user