Basic tool creation

This commit is contained in:
Connor Yoh 2025-08-15 13:53:04 +01:00
parent 129e4d00e9
commit 85b612bf22
7 changed files with 496 additions and 1 deletions

View File

@ -1737,5 +1737,48 @@
"text": "To make these permissions unchangeable, use the Add Password tool to set an owner password." "text": "To make these permissions unchangeable, use the Add Password tool to set an owner password."
} }
} }
},
"watermark": {
"completed": "Watermark added",
"submit": "Add Watermark",
"filenamePrefix": "watermarked",
"error": {
"failed": "An error occurred while adding watermark to the PDF."
},
"watermarkType": {
"text": "Text",
"image": "Image"
},
"settings": {
"type": "Watermark Type",
"text": {
"label": "Watermark Text",
"placeholder": "Enter watermark text"
},
"image": {
"label": "Watermark Image",
"choose": "Choose Image",
"selected": "Selected: {{filename}}"
},
"fontSize": "Font Size",
"position": "Position",
"rotation": "Rotation (degrees)",
"opacity": "Opacity (%)",
"spacing": {
"width": "Width Spacing",
"height": "Height Spacing"
}
},
"positions": {
"topLeft": "Top Left",
"topCenter": "Top Center",
"topRight": "Top Right",
"centerLeft": "Center Left",
"center": "Center",
"centerRight": "Center Right",
"bottomLeft": "Bottom Left",
"bottomCenter": "Bottom Center",
"bottomRight": "Bottom Right"
}
} }
} }

View File

@ -0,0 +1,174 @@
import React, { useRef } from "react";
import { Button, Stack, Text, NumberInput, Select, TextInput, FileButton } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface AddWatermarkParameters {
watermarkType: 'text' | 'image';
watermarkText: string;
watermarkImage?: File;
fontSize: number;
rotation: number;
opacity: number;
widthSpacer: number;
heightSpacer: number;
position: string;
overrideX?: number;
overrideY?: number;
}
interface AddWatermarkSettingsProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
disabled?: boolean;
}
const AddWatermarkSettings = ({ parameters, onParameterChange, disabled = false }: AddWatermarkSettingsProps) => {
const { t } = useTranslation();
const resetRef = useRef<() => void>(null);
const positionOptions = [
{ value: 'topLeft', label: 'Top Left' },
{ value: 'topCenter', label: 'Top Center' },
{ value: 'topRight', label: 'Top Right' },
{ value: 'centerLeft', label: 'Center Left' },
{ value: 'center', label: 'Center' },
{ value: 'centerRight', label: 'Center Right' },
{ value: 'bottomLeft', label: 'Bottom Left' },
{ value: 'bottomCenter', label: 'Bottom Center' },
{ value: 'bottomRight', label: 'Bottom Right' }
];
return (
<Stack gap="md">
{/* Watermark Type Selection */}
<Stack gap="sm">
<Text size="sm" fw={500}>Watermark Type</Text>
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={parameters.watermarkType === 'text' ? 'filled' : 'outline'}
color={parameters.watermarkType === 'text' ? 'blue' : 'gray'}
onClick={() => onParameterChange('watermarkType', 'text')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
Text
</div>
</Button>
<Button
variant={parameters.watermarkType === 'image' ? 'filled' : 'outline'}
color={parameters.watermarkType === 'image' ? 'blue' : 'gray'}
onClick={() => onParameterChange('watermarkType', 'image')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
Image
</div>
</Button>
</div>
</Stack>
{/* Text Watermark Settings */}
{parameters.watermarkType === 'text' && (
<Stack gap="sm">
<Text size="sm" fw={500}>Watermark Text</Text>
<TextInput
placeholder="Enter watermark text"
value={parameters.watermarkText}
onChange={(e) => onParameterChange('watermarkText', e.target.value)}
disabled={disabled}
/>
<Text size="sm" fw={500}>Font Size</Text>
<NumberInput
value={parameters.fontSize}
onChange={(value) => onParameterChange('fontSize', value || 12)}
min={8}
max={72}
disabled={disabled}
/>
</Stack>
)}
{/* Image Watermark Settings */}
{parameters.watermarkType === 'image' && (
<Stack gap="sm">
<Text size="sm" fw={500}>Watermark Image</Text>
<FileButton
resetRef={resetRef}
onChange={(file) => onParameterChange('watermarkImage', file)}
accept="image/*"
disabled={disabled}
>
{(props) => (
<Button {...props} variant="outline" fullWidth>
{parameters.watermarkImage ? parameters.watermarkImage.name : 'Choose Image'}
</Button>
)}
</FileButton>
{parameters.watermarkImage && (
<Text size="xs" c="dimmed">
Selected: {parameters.watermarkImage.name}
</Text>
)}
</Stack>
)}
{/* Position Settings */}
<Stack gap="sm">
<Text size="sm" fw={500}>Position</Text>
<Select
value={parameters.position}
onChange={(value) => value && onParameterChange('position', value)}
data={positionOptions}
disabled={disabled}
/>
</Stack>
{/* Appearance Settings */}
<Stack gap="sm">
<Text size="sm" fw={500}>Rotation (degrees)</Text>
<NumberInput
value={parameters.rotation}
onChange={(value) => onParameterChange('rotation', value || 0)}
min={-360}
max={360}
disabled={disabled}
/>
<Text size="sm" fw={500}>Opacity (%)</Text>
<NumberInput
value={parameters.opacity}
onChange={(value) => onParameterChange('opacity', value || 50)}
min={0}
max={100}
disabled={disabled}
/>
</Stack>
{/* Spacing Settings */}
<Stack gap="sm">
<Text size="sm" fw={500}>Width Spacing</Text>
<NumberInput
value={parameters.widthSpacer}
onChange={(value) => onParameterChange('widthSpacer', value || 50)}
min={0}
max={200}
disabled={disabled}
/>
<Text size="sm" fw={500}>Height Spacing</Text>
<NumberInput
value={parameters.heightSpacer}
onChange={(value) => onParameterChange('heightSpacer', value || 50)}
min={0}
max={200}
disabled={disabled}
/>
</Stack>
</Stack>
);
};
export default AddWatermarkSettings;

View File

@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddWatermarkParameters } from './useAddWatermarkParameters';
const buildFormData = (parameters: AddWatermarkParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
if (parameters.watermarkType === 'text') {
formData.append("watermarkText", parameters.watermarkText);
} else if (parameters.watermarkImage) {
formData.append("watermarkImage", parameters.watermarkImage);
}
formData.append("fontSize", parameters.fontSize.toString());
formData.append("rotation", parameters.rotation.toString());
formData.append("opacity", (parameters.opacity / 100).toString()); // Convert percentage to decimal
formData.append("widthSpacer", parameters.widthSpacer.toString());
formData.append("heightSpacer", parameters.heightSpacer.toString());
formData.append("position", parameters.position);
if (parameters.overrideX !== undefined) {
formData.append("overrideX", parameters.overrideX.toString());
}
if (parameters.overrideY !== undefined) {
formData.append("overrideY", parameters.overrideY.toString());
}
return formData;
};
export const useAddWatermarkOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddWatermarkParameters>({
operationType: 'watermark',
endpoint: '/api/v1/security/add-watermark',
buildFormData,
filePrefix: t('watermark.filenamePrefix', 'watermarked') + '_',
multiFileEndpoint: false, // Individual API calls per file
getErrorMessage: createStandardErrorHandler(t('watermark.error.failed', 'An error occurred while adding watermark to the PDF.'))
});
};

View File

@ -0,0 +1,56 @@
import { useState, useCallback } from 'react';
export interface AddWatermarkParameters {
watermarkType: 'text' | 'image';
watermarkText: string;
watermarkImage?: File;
fontSize: number;
rotation: number;
opacity: number;
widthSpacer: number;
heightSpacer: number;
position: string;
overrideX?: number;
overrideY?: number;
}
const defaultParameters: AddWatermarkParameters = {
watermarkType: 'text',
watermarkText: '',
fontSize: 12,
rotation: 0,
opacity: 50,
widthSpacer: 50,
heightSpacer: 50,
position: 'center'
};
export const useAddWatermarkParameters = () => {
const [parameters, setParameters] = useState<AddWatermarkParameters>(defaultParameters);
const updateParameter = useCallback(<K extends keyof AddWatermarkParameters>(
key: K,
value: AddWatermarkParameters[K]
) => {
setParameters(prev => ({ ...prev, [key]: value }));
}, []);
const resetParameters = useCallback(() => {
setParameters(defaultParameters);
}, []);
const validateParameters = useCallback((): boolean => {
if (parameters.watermarkType === 'text') {
return parameters.watermarkText.trim().length > 0;
} else {
return parameters.watermarkImage !== undefined;
}
}, [parameters]);
return {
parameters,
updateParameter,
resetParameters,
validateParameters
};
};

View File

@ -6,6 +6,7 @@ import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import ApiIcon from "@mui/icons-material/Api"; import ApiIcon from "@mui/icons-material/Api";
import CleaningServicesIcon from "@mui/icons-material/CleaningServices"; import CleaningServicesIcon from "@mui/icons-material/CleaningServices";
import LockIcon from "@mui/icons-material/Lock"; import LockIcon from "@mui/icons-material/Lock";
import BrandingWatermarkIcon from "@mui/icons-material/BrandingWatermark";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool"; import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
@ -104,6 +105,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
description: "Change document restrictions and permissions", description: "Change document restrictions and permissions",
endpoints: ["add-password"] endpoints: ["add-password"]
}, },
watermark: {
id: "watermark",
icon: <BrandingWatermarkIcon />,
component: React.lazy(() => import("../tools/AddWatermark")),
maxFiles: -1,
category: "security",
description: "Add text or image watermarks to PDF files",
endpoints: ["add-watermark"]
},
}; };

View File

@ -0,0 +1,167 @@
import React, { useEffect, useMemo } from "react";
import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import AddWatermarkSettings from "../components/tools/addWatermark/AddWatermarkSettings";
import { useAddWatermarkParameters } from "../hooks/tools/addWatermark/useAddWatermarkParameters";
import { useAddWatermarkOperation } from "../hooks/tools/addWatermark/useAddWatermarkOperation";
import { BaseToolProps } from "../types/tool";
const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection();
const watermarkParams = useAddWatermarkParameters();
const watermarkOperation = useAddWatermarkOperation();
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-watermark");
useEffect(() => {
watermarkOperation.resetResults();
onPreviewFile?.(null);
}, [watermarkParams.parameters, selectedFiles]);
const handleAddWatermark = async () => {
try {
await watermarkOperation.executeOperation(
watermarkParams.parameters,
selectedFiles
);
if (watermarkOperation.files && onComplete) {
onComplete(watermarkOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : 'Add watermark operation failed');
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'watermark');
setCurrentMode('viewer');
};
const handleSettingsReset = () => {
watermarkOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode('watermark');
};
const hasFiles = selectedFiles.length > 0;
const hasResults = watermarkOperation.files.length > 0 || watermarkOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
const previewResults = useMemo(() =>
watermarkOperation.files?.map((file, index) => ({
file,
thumbnail: watermarkOperation.thumbnails[index]
})) || [],
[watermarkOperation.files, watermarkOperation.thumbnails]
);
return (
<ToolStepContainer>
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title="Files"
isVisible={true}
isCollapsed={filesCollapsed}
isCompleted={filesCollapsed}
completedMessage={hasFiles ?
selectedFiles.length === 1
? `Selected: ${selectedFiles[0].name}`
: `Selected: ${selectedFiles.length} files`
: undefined}
>
<FileStatusIndicator
selectedFiles={selectedFiles}
placeholder="Select a PDF file in the main view to get started"
/>
</ToolStep>
{/* Settings Step */}
<ToolStep
title="Settings"
isVisible={hasFiles}
isCollapsed={settingsCollapsed}
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? "Watermark added" : undefined}
>
<Stack gap="sm">
<AddWatermarkSettings
parameters={watermarkParams.parameters}
onParameterChange={watermarkParams.updateParameter}
disabled={endpointLoading}
/>
<OperationButton
onClick={handleAddWatermark}
isLoading={watermarkOperation.isLoading}
disabled={!watermarkParams.validateParameters() || !hasFiles || !endpointEnabled}
loadingText={t("loading")}
submitText="Add Watermark and Review"
/>
</Stack>
</ToolStep>
{/* Results Step */}
<ToolStep
title="Results"
isVisible={hasResults}
>
<Stack gap="sm">
{watermarkOperation.status && (
<Text size="sm" c="dimmed">{watermarkOperation.status}</Text>
)}
<ErrorNotification
error={watermarkOperation.errorMessage}
onClose={watermarkOperation.clearError}
/>
{watermarkOperation.downloadUrl && (
<Button
component="a"
href={watermarkOperation.downloadUrl}
download={watermarkOperation.downloadFilename}
leftSection={<DownloadIcon />}
color="green"
fullWidth
mb="md"
>
{t("download", "Download")}
</Button>
)}
<ResultsPreview
files={previewResults}
onFileClick={handleThumbnailClick}
isGeneratingThumbnails={watermarkOperation.isGeneratingThumbnails}
title="Watermark Results"
/>
</Stack>
</ToolStep>
</Stack>
</ToolStepContainer>
);
}
export default AddWatermark;

View File

@ -16,7 +16,8 @@ export type ModeType =
| 'convert' | 'convert'
| 'sanitize' | 'sanitize'
| 'addPassword' | 'addPassword'
| 'changePermissions'; | 'changePermissions'
| 'watermark';
export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor'; export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor';