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:
Connor Yoh
2026-03-27 15:47:44 +00:00
parent 9500acd69f
commit a651061554
10 changed files with 614 additions and 39 deletions
+15 -2
View File
@@ -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 };
};
+133
View File
@@ -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;
-1
View File
@@ -7,7 +7,6 @@ import {
export type ToolKind = 'regular' | 'super' | 'link';
export const CORE_REGULAR_TOOL_IDS = [
'certSign',
'sign',
'addText',
'addPassword',
+2 -2
View File
@@ -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)