Feature/v2/watermark (#4215)

Add watermark feature

Auto scroll on review

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
ConnorYoh 2025-08-19 10:31:44 +01:00 committed by GitHub
parent acbebd67a3
commit c1b7911518
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1410 additions and 78 deletions

View File

@ -44,6 +44,7 @@
"editYourNewFiles": "Edit your new file(s)", "editYourNewFiles": "Edit your new file(s)",
"close": "Close", "close": "Close",
"fileSelected": "Selected: {{filename}}", "fileSelected": "Selected: {{filename}}",
"chooseFile": "Choose File",
"filesSelected": "{{count}} files selected", "filesSelected": "{{count}} files selected",
"files": { "files": {
"title": "Files", "title": "Files",
@ -777,26 +778,180 @@
"submit": "Add image" "submit": "Add image"
}, },
"watermark": { "watermark": {
"tags": "Text,repeating,label,own,copyright,trademark,img,jpg,picture,photo",
"title": "Add Watermark", "title": "Add Watermark",
"header": "Add Watermark", "desc": "Add text or image watermarks to PDF files",
"customColor": "Custom Text Colour", "completed": "Watermark added",
"selectText": {
"1": "Select PDF to add watermark to:",
"2": "Watermark Text:",
"3": "Font Size:",
"4": "Rotation (0-360):",
"5": "Width Spacer (Space between each watermark horizontally):",
"6": "Height Spacer (Space between each watermark vertically):",
"7": "Opacity (0% - 100%):",
"8": "Watermark Type:",
"9": "Watermark Image:",
"10": "Convert PDF to PDF-Image"
},
"submit": "Add Watermark", "submit": "Add Watermark",
"type": { "filenamePrefix": "watermarked",
"1": "Text", "error": {
"2": "Image" "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",
"size": "Size",
"alphabet": "Font/Language",
"color": "Watermark Colour",
"rotation": "Rotation (degrees)",
"opacity": "Opacity (%)",
"spacing": {
"horizontal": "Horizontal Spacing",
"vertical": "Vertical Spacing"
},
"convertToImage": "Flatten PDF pages to images"
},
"alphabet": {
"roman": "Roman/Latin",
"arabic": "Arabic",
"japanese": "Japanese",
"korean": "Korean",
"chinese": "Chinese",
"thai": "Thai"
},
"steps": {
"type": "Watermark Type",
"wording": "Wording",
"textStyle": "Style",
"formatting": "Formatting",
"file": "Watermark File"
},
"results": {
"title": "Watermark Results"
},
"tooltip": {
"language": {
"title": "Language Support",
"text": "Choose the appropriate language setting to ensure proper font rendering for your text."
},
"appearance": {
"title": "Appearance Settings",
"text": "Control how your watermark looks and blends with the document.",
"bullet1": "Rotation: -360° to 360° for angled watermarks",
"bullet2": "Opacity: 0-100% for transparency control",
"bullet3": "Lower opacity creates subtle watermarks"
},
"spacing": {
"title": "Spacing Control",
"text": "Adjust the spacing between repeated watermarks across the page.",
"bullet1": "Width spacing: Horizontal distance between watermarks",
"bullet2": "Height spacing: Vertical distance between watermarks",
"bullet3": "Higher values create more spread out patterns"
},
"type": {
"header": {
"title": "Watermark Type Selection"
},
"description": {
"title": "Choose Your Watermark",
"text": "Select between text or image watermarks based on your needs."
},
"text": {
"title": "Text Watermarks",
"text": "Perfect for adding copyright notices, company names, or confidentiality labels. Supports multiple languages and custom colours.",
"bullet1": "Customisable fonts and languages",
"bullet2": "Adjustable colours and transparency",
"bullet3": "Ideal for legal or branding text"
},
"image": {
"title": "Image Watermarks",
"text": "Use logos, stamps, or any image as a watermark. Great for branding and visual identification.",
"bullet1": "Upload any image format",
"bullet2": "Maintains image quality",
"bullet3": "Perfect for logos and stamps"
}
},
"wording": {
"header": {
"title": "Text Content"
},
"text": {
"title": "Watermark Text",
"text": "Enter the text that will appear as your watermark across the document.",
"bullet1": "Keep it concise for better readability",
"bullet2": "Common examples: 'CONFIDENTIAL', 'DRAFT', company name",
"bullet3": "Emoji characters are not supported and will be filtered out"
}
},
"textStyle": {
"header": {
"title": "Text Style"
},
"color": {
"title": "Colour Selection",
"text": "Choose a colour that provides good contrast with your document content.",
"bullet1": "Light grey (#d3d3d3) for subtle watermarks",
"bullet2": "Black or dark colours for high contrast",
"bullet3": "Custom colours for branding purposes"
},
"language": {
"title": "Language Support",
"text": "Choose the appropriate language setting to ensure proper font rendering."
}
},
"file": {
"header": {
"title": "Image Upload"
},
"upload": {
"title": "Image Selection",
"text": "Upload an image file to use as your watermark.",
"bullet1": "Supports common formats: PNG, JPG, GIF, BMP",
"bullet2": "PNG with transparency works best",
"bullet3": "Higher resolution images maintain quality better"
},
"recommendations": {
"title": "Best Practices",
"text": "Tips for optimal image watermark results.",
"bullet1": "Use logos or stamps with transparent backgrounds",
"bullet2": "Simple designs work better than complex images",
"bullet3": "Consider the final document size when choosing resolution"
}
},
"formatting": {
"header": {
"title": "Formatting & Layout"
},
"size": {
"title": "Size Control",
"text": "Adjust the size of your watermark (text or image).",
"bullet1": "Larger sizes create more prominent watermarks"
},
"appearance": {
"title": "Appearance Settings",
"text": "Control how your watermark looks and blends with the document.",
"bullet1": "Rotation: -360° to 360° for angled watermarks",
"bullet2": "Opacity: 0-100% for transparency control",
"bullet3": "Lower opacity creates subtle watermarks"
},
"spacing": {
"title": "Spacing Control",
"text": "Adjust the spacing between repeated watermarks across the page.",
"bullet1": "Horizontal spacing: Distance between watermarks left to right",
"bullet2": "Vertical spacing: Distance between watermarks top to bottom",
"bullet3": "Higher values create more spread out patterns"
},
"security": {
"title": "Security Option",
"text": "Convert the final PDF to an image-based format for enhanced security.",
"bullet1": "Prevents text selection and copying",
"bullet2": "Makes watermarks harder to remove",
"bullet3": "Results in larger file sizes",
"bullet4": "Best for sensitive or copyrighted content"
}
}
} }
}, },
"permissions": { "permissions": {

View File

@ -43,6 +43,7 @@
"download": "Download", "download": "Download",
"editYourNewFiles": "Edit your new file(s)", "editYourNewFiles": "Edit your new file(s)",
"close": "Close", "close": "Close",
"chooseFile": "Choose File",
"fileSelected": "Selected: {{filename}}", "fileSelected": "Selected: {{filename}}",
"filesSelected": "{{count}} files selected", "filesSelected": "{{count}} files selected",
"files": { "files": {
@ -717,29 +718,6 @@
"upload": "Add image", "upload": "Add image",
"submit": "Add image" "submit": "Add image"
}, },
"watermark": {
"tags": "Text,repeating,label,own,copyright,trademark,img,jpg,picture,photo",
"title": "Add Watermark",
"header": "Add Watermark",
"customColor": "Custom Text Color",
"selectText": {
"1": "Select PDF to add watermark to:",
"2": "Watermark Text:",
"3": "Font Size:",
"4": "Rotation (0-360):",
"5": "Width Spacer (Space between each watermark horizontally):",
"6": "Height Spacer (Space between each watermark vertically):",
"7": "Opacity (0% - 100%):",
"8": "Watermark Type:",
"9": "Watermark Image:",
"10": "Convert PDF to PDF-Image"
},
"submit": "Add Watermark",
"type": {
"1": "Text",
"2": "Image"
}
},
"permissions": { "permissions": {
"tags": "read,write,edit,print", "tags": "read,write,edit,print",
"title": "Change Permissions", "title": "Change Permissions",
@ -1742,6 +1720,245 @@
} }
} }
}, },
"watermark": {
"tags": "Text,repeating,label,own,copyright,trademark,img,jpg,picture,photo",
"title": "Add Watermark",
"desc": "Add text or image watermarks to PDF files",
"header": "Add 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",
"alphabet": "Font/Language",
"color": "Watermark Color",
"rotation": "Rotation (degrees)",
"opacity": "Opacity (%)",
"spacing": {
"horizontal": "Horizontal Spacing",
"vertical": "Vertical Spacing"
},
"convertToImage": "Flatten PDF pages to images"
},
"alphabet": {
"roman": "Roman/Latin",
"arabic": "Arabic",
"japanese": "Japanese",
"korean": "Korean",
"chinese": "Chinese",
"thai": "Thai"
},
"steps": {
"type": "Watermark Type",
"wording": "Wording",
"textStyle": "Style",
"file": "Watermark File",
"formatting": "Formatting"
},
"results": {
"title": "Watermark Results"
},
"tooltip": {
"language": {
"title": "Language Support",
"text": "Choose the appropriate language setting to ensure proper font rendering for your text."
},
"appearance": {
"title": "Appearance Settings",
"text": "Control how your watermark looks and blends with the document.",
"bullet1": "Rotation: -360° to 360° for angled watermarks",
"bullet2": "Opacity: 0-100% for transparency control",
"bullet3": "Lower opacity creates subtle watermarks"
},
"spacing": {
"title": "Spacing Control",
"text": "Adjust the spacing between repeated watermarks across the page.",
"bullet1": "Width spacing: Horizontal distance between watermarks",
"bullet2": "Height spacing: Vertical distance between watermarks",
"bullet3": "Higher values create more spread out patterns"
},
"type": {
"header": {
"title": "Watermark Type Selection"
},
"description": {
"title": "Choose Your Watermark",
"text": "Select between text or image watermarks based on your needs."
},
"text": {
"title": "Text Watermarks",
"text": "Perfect for adding copyright notices, company names, or confidentiality labels. Supports multiple languages and custom colors.",
"bullet1": "Customizable fonts and languages",
"bullet2": "Adjustable colors and transparency",
"bullet3": "Ideal for legal or branding text"
},
"image": {
"title": "Image Watermarks",
"text": "Use logos, stamps, or any image as a watermark. Great for branding and visual identification.",
"bullet1": "Upload any image format",
"bullet2": "Maintains image quality",
"bullet3": "Perfect for logos and stamps"
}
},
"content": {
"header": {
"title": "Content Configuration"
},
"text": {
"title": "Text Settings",
"text": "Configure your text watermark appearance and language support.",
"bullet1": "Enter your watermark text",
"bullet2": "Adjust font size (8-72pt)",
"bullet3": "Select language/script support",
"bullet4": "Choose custom colors"
},
"language": {
"title": "Language Support",
"text": "Choose the appropriate language setting to ensure proper font rendering for your text.",
"bullet1": "Roman/Latin for Western languages",
"bullet2": "Arabic for Arabic script",
"bullet3": "Japanese, Korean, Chinese for Asian languages",
"bullet4": "Thai for Thai script"
}
},
"style": {
"header": {
"title": "Style & Positioning"
},
"appearance": {
"title": "Appearance Settings",
"text": "Control how your watermark looks and blends with the document.",
"bullet1": "Rotation: -360° to 360° for angled watermarks",
"bullet2": "Opacity: 0-100% for transparency control",
"bullet3": "Lower opacity creates subtle watermarks"
},
"spacing": {
"title": "Spacing Control",
"text": "Adjust the spacing between repeated watermarks across the page.",
"bullet1": "Width spacing: Horizontal distance between watermarks",
"bullet2": "Height spacing: Vertical distance between watermarks",
"bullet3": "Higher values create more spread out patterns"
}
},
"wording": {
"header": {
"title": "Text Content"
},
"text": {
"title": "Watermark Text",
"text": "Enter the text that will appear as your watermark across the document.",
"bullet1": "Keep it concise for better readability",
"bullet2": "Common examples: 'CONFIDENTIAL', 'DRAFT', company name",
"bullet3": "Emoji characters are not supported and will be filtered out"
}
},
"textStyle": {
"header": {
"title": "Text Style"
},
"language": {
"title": "Language Support",
"text": "Choose the appropriate language setting to ensure proper font rendering.",
"bullet1": "Roman/Latin for Western languages",
"bullet2": "Arabic for Arabic script",
"bullet3": "Japanese, Korean, Chinese for Asian languages",
"bullet4": "Thai for Thai script"
},
"color": {
"title": "Color Selection",
"text": "Choose a color that provides good contrast with your document content.",
"bullet1": "Light gray (#d3d3d3) for subtle watermarks",
"bullet2": "Black or dark colors for high contrast",
"bullet3": "Custom colors for branding purposes"
}
},
"file": {
"header": {
"title": "Image Upload"
},
"upload": {
"title": "Image Selection",
"text": "Upload an image file to use as your watermark.",
"bullet1": "Supports common formats: PNG, JPG, GIF, BMP",
"bullet2": "PNG with transparency works best",
"bullet3": "Higher resolution images maintain quality better"
},
"recommendations": {
"title": "Best Practices",
"text": "Tips for optimal image watermark results.",
"bullet1": "Use logos or stamps with transparent backgrounds",
"bullet2": "Simple designs work better than complex images",
"bullet3": "Consider the final document size when choosing resolution"
}
},
"formatting": {
"header": {
"title": "Formatting & Layout"
},
"size": {
"title": "Size Control",
"text": "Adjust the size of your watermark (text or image).",
"bullet1": "Larger sizes create more prominent watermarks"
},
"appearance": {
"title": "Appearance Settings",
"text": "Control how your watermark looks and blends with the document.",
"bullet1": "Rotation: -360° to 360° for angled watermarks",
"bullet2": "Opacity: 0-100% for transparency control",
"bullet3": "Lower opacity creates subtle watermarks"
},
"spacing": {
"title": "Spacing Control",
"text": "Adjust the spacing between repeated watermarks across the page.",
"bullet1": "Horizontal spacing: Distance between watermarks left to right",
"bullet2": "Vertical spacing: Distance between watermarks top to bottom",
"bullet3": "Higher values create more spread out patterns"
},
"security": {
"title": "Security Option",
"text": "Flatten PDF pages to images for enhanced security.",
"bullet1": "Prevents text selection and copying",
"bullet2": "Makes watermarks harder to remove",
"bullet3": "Results in larger file sizes",
"bullet4": "Best for sensitive or copyrighted content"
}
},
"advanced": {
"header": {
"title": "Advanced Options"
},
"conversion": {
"title": "PDF to Image Conversion",
"text": "Convert the final PDF to an image-based format for enhanced security.",
"bullet1": "Prevents text selection and copying",
"bullet2": "Makes watermarks harder to remove",
"bullet3": "Results in larger file sizes",
"bullet4": "Best for sensitive or copyrighted content"
},
"security": {
"title": "Security Considerations",
"text": "Image-based PDFs provide additional protection against unauthorized editing and content extraction."
}
}
}
},
"removePassword": { "removePassword": {
"title": "Remove Password", "title": "Remove Password",
"desc": "Remove password protection from your PDF document.", "desc": "Remove password protection from your PDF document.",

View File

@ -0,0 +1,45 @@
import React, { useRef } from "react";
import { FileButton, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface FileUploadButtonProps {
file?: File;
onChange: (file: File | null) => void;
accept?: string;
disabled?: boolean;
placeholder?: string;
variant?: "outline" | "filled" | "light" | "default" | "subtle" | "gradient";
fullWidth?: boolean;
}
const FileUploadButton = ({
file,
onChange,
accept = "*/*",
disabled = false,
placeholder,
variant = "outline",
fullWidth = true
}: FileUploadButtonProps) => {
const { t } = useTranslation();
const resetRef = useRef<() => void>(null);
const defaultPlaceholder = t('chooseFile', 'Choose File');
return (
<FileButton
resetRef={resetRef}
onChange={onChange}
accept={accept}
disabled={disabled}
>
{(props) => (
<Button {...props} variant={variant} fullWidth={fullWidth}>
{file ? file.name : (placeholder || defaultPlaceholder)}
</Button>
)}
</FileButton>
);
};
export default FileUploadButton;

View File

@ -2,7 +2,8 @@ import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils'; import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
import { useTooltipPosition } from '../../hooks/useTooltipPosition'; import { useTooltipPosition } from '../../hooks/useTooltipPosition';
import { TooltipContent, TooltipTip } from './tooltip/TooltipContent'; import { TooltipTip } from '../../types/tips';
import { TooltipContent } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext'; import { useSidebarContext } from '../../contexts/SidebarContext';
import styles from './tooltip/Tooltip.module.css' import styles from './tooltip/Tooltip.module.css'

View File

@ -1,12 +1,6 @@
import React from 'react'; import React from 'react';
import styles from './Tooltip.module.css'; import styles from './Tooltip.module.css';
import { TooltipTip } from '../../../types/tips';
export interface TooltipTip {
title?: string;
description?: string;
bullets?: string[];
body?: React.ReactNode;
}
interface TooltipContentProps { interface TooltipContentProps {
content?: React.ReactNode; content?: React.ReactNode;

View File

@ -0,0 +1,83 @@
import React from "react";
import { Stack, Checkbox, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
import NumberInputWithUnit from "../shared/NumberInputWithUnit";
interface WatermarkFormattingProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
disabled?: boolean;
}
const WatermarkFormatting = ({ parameters, onParameterChange, disabled = false }: WatermarkFormattingProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
{/* Size - single row */}
<NumberInputWithUnit
label={t('watermark.settings.size', 'Size')}
value={parameters.fontSize}
onChange={(value) => onParameterChange('fontSize', typeof value === 'number' ? value : 12)}
unit={parameters.watermarkType === 'text' ? 'pt' : 'px'}
min={1}
disabled={disabled}
/>
{/* Position & Appearance - 2 per row */}
<Group grow align="flex-start">
<NumberInputWithUnit
label={t('watermark.settings.rotation', 'Rotation')}
value={parameters.rotation}
onChange={(value) => onParameterChange('rotation', typeof value === 'number' ? value : 0)}
unit="°"
min={-360}
max={360}
disabled={disabled}
/>
<NumberInputWithUnit
label={t('watermark.settings.opacity', 'Opacity')}
value={parameters.opacity}
onChange={(value) => onParameterChange('opacity', typeof value === 'number' ? value : 50)}
unit="%"
min={0}
max={100}
disabled={disabled}
/>
</Group>
{/* Spacing - 2 per row */}
<Group grow align="flex-start">
<NumberInputWithUnit
label={t('watermark.settings.spacing.horizontal', 'Horizontal Spacing')}
value={parameters.widthSpacer}
onChange={(value) => onParameterChange('widthSpacer', typeof value === 'number' ? value : 50)}
unit="px"
min={0}
max={200}
disabled={disabled}
/>
<NumberInputWithUnit
label={t('watermark.settings.spacing.vertical', 'Vertical Spacing')}
value={parameters.heightSpacer}
onChange={(value) => onParameterChange('heightSpacer', typeof value === 'number' ? value : 50)}
unit="px"
min={0}
max={200}
disabled={disabled}
/>
</Group>
{/* Advanced Options */}
<Checkbox
label={t('watermark.settings.convertToImage', 'Flatten PDF pages to images')}
checked={parameters.convertPDFToImage}
onChange={(event) => onParameterChange('convertPDFToImage', event.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
};
export default WatermarkFormatting;

View File

@ -0,0 +1,29 @@
import React from "react";
import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
import FileUploadButton from "../../shared/FileUploadButton";
interface WatermarkImageFileProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
disabled?: boolean;
}
const WatermarkImageFile = ({ parameters, onParameterChange, disabled = false }: WatermarkImageFileProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
<FileUploadButton
file={parameters.watermarkImage}
onChange={(file) => onParameterChange('watermarkImage', file)}
accept="image/*"
disabled={disabled}
placeholder={t('watermark.settings.image.choose', 'Choose Image')}
/>
</Stack>
);
};
export default WatermarkImageFile;

View File

@ -0,0 +1,63 @@
import React from "react";
import { Stack, Text, NumberInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
interface WatermarkStyleSettingsProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
disabled?: boolean;
}
const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = false }: WatermarkStyleSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
{/* Appearance Settings */}
<Stack gap="sm">
<Text size="sm" fw={500}>{t('watermark.settings.rotation', 'Rotation (degrees)')}</Text>
<NumberInput
value={parameters.rotation}
onChange={(value) => onParameterChange('rotation', value || 0)}
min={-360}
max={360}
disabled={disabled}
/>
<Text size="sm" fw={500}>{t('watermark.settings.opacity', '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}>{t('watermark.settings.spacing.width', 'Width Spacing')}</Text>
<NumberInput
value={parameters.widthSpacer}
onChange={(value) => onParameterChange('widthSpacer', value || 50)}
min={0}
max={200}
disabled={disabled}
/>
<Text size="sm" fw={500}>{t('watermark.settings.spacing.height', 'Height Spacing')}</Text>
<NumberInput
value={parameters.heightSpacer}
onChange={(value) => onParameterChange('heightSpacer', value || 50)}
min={0}
max={200}
disabled={disabled}
/>
</Stack>
</Stack>
);
};
export default WatermarkStyleSettings;

View File

@ -0,0 +1,46 @@
import React from "react";
import { Stack, Text, Select, ColorInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
import { alphabetOptions } from "../../../constants/addWatermarkConstants";
interface WatermarkTextStyleProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
disabled?: boolean;
}
const WatermarkTextStyle = ({ parameters, onParameterChange, disabled = false }: WatermarkTextStyleProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
<Stack gap="xs">
<Text size="xs" fw={500}>
{t("watermark.settings.color", "Colour")}
</Text>
<ColorInput
value={parameters.customColor}
onChange={(value) => onParameterChange("customColor", value)}
disabled={disabled}
format="hex"
/>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={500}>
{t("watermark.settings.alphabet", "Alphabet")}
</Text>
<Select
value={parameters.alphabet}
onChange={(value) => value && onParameterChange("alphabet", value)}
data={alphabetOptions}
disabled={disabled}
/>
</Stack>
</Stack>
);
};
export default WatermarkTextStyle;

View File

@ -0,0 +1,44 @@
import React from "react";
import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface WatermarkTypeSettingsProps {
watermarkType?: 'text' | 'image';
onWatermarkTypeChange: (type: 'text' | 'image') => void;
disabled?: boolean;
}
const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled = false }: WatermarkTypeSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={watermarkType === 'text' ? 'filled' : 'outline'}
color={watermarkType === 'text' ? 'blue' : 'gray'}
onClick={() => onWatermarkTypeChange('text')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('watermark.watermarkType.text', 'Text')}
</div>
</Button>
<Button
variant={watermarkType === 'image' ? 'filled' : 'outline'}
color={watermarkType === 'image' ? 'blue' : 'gray'}
onClick={() => onWatermarkTypeChange('image')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('watermark.watermarkType.image', 'Image')}
</div>
</Button>
</div>
</Stack>
);
};
export default WatermarkTypeSettings;

View File

@ -0,0 +1,34 @@
import React from "react";
import { Stack, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
import { removeEmojis } from "../../../utils/textUtils";
interface WatermarkWordingProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
disabled?: boolean;
}
const WatermarkWording = ({ parameters, onParameterChange, disabled = false }: WatermarkWordingProps) => {
const { t } = useTranslation();
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const filteredValue = removeEmojis(value);
onParameterChange('watermarkText', filteredValue);
};
return (
<Stack gap="sm">
<TextInput
placeholder={t('watermark.settings.text.placeholder', 'Enter watermark text')}
value={parameters.watermarkText}
onChange={handleTextChange}
disabled={disabled}
/>
</Stack>
);
};
export default WatermarkWording;

View File

@ -0,0 +1,57 @@
import React, { useState, useEffect } from "react";
import { Stack, Text, NumberInput } from "@mantine/core";
interface NumberInputWithUnitProps {
label: string;
value: number;
onChange: (value: number | string) => void;
unit: string;
min?: number;
max?: number;
disabled?: boolean;
}
const NumberInputWithUnit = ({
label,
value,
onChange,
unit,
min,
max,
disabled = false
}: NumberInputWithUnitProps) => {
const [localValue, setLocalValue] = useState<number | string>(value);
// Sync local value when external value changes
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleBlur = () => {
onChange(localValue);
};
return (
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="xs" fw={500} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{label}
</Text>
<NumberInput
value={localValue}
onChange={setLocalValue}
onBlur={handleBlur}
min={min}
max={max}
disabled={disabled}
rightSection={
<Text size="sm" c="dimmed" pr="sm">
{unit}
</Text>
}
rightSectionWidth={unit.length * 8 + 20} // Dynamic width based on unit length
/>
</Stack>
);
};
export default NumberInputWithUnit;

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useRef } from 'react';
import { Button, Stack, Text } from '@mantine/core'; import { Button, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import DownloadIcon from '@mui/icons-material/Download'; import DownloadIcon from '@mui/icons-material/Download';
@ -14,24 +14,32 @@ export interface ReviewToolStepProps<TParams = unknown> {
onFileClick?: (file: File) => void; onFileClick?: (file: File) => void;
} }
export function createReviewToolStep<TParams = unknown>( function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { operation: ToolOperationHook<TParams>; onFileClick?: (file: File) => void }) {
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
props: ReviewToolStepProps<TParams>
): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { operation } = props; const stepRef = useRef<HTMLDivElement>(null);
const previewFiles = operation.files?.map((file, index) => ({ const previewFiles = operation.files?.map((file, index) => ({
file, file,
thumbnail: operation.thumbnails[index] thumbnail: operation.thumbnails[index]
})) || []; })) || [];
return createStep(t("review", "Review"), { // Auto-scroll to bottom when content appears
isVisible: props.isVisible, useEffect(() => {
_excludeFromCount: true, if (stepRef.current && (previewFiles.length > 0 || operation.downloadUrl || operation.errorMessage)) {
_noPadding: true const scrollableContainer = stepRef.current.closest('[style*="overflow: auto"]') as HTMLElement;
}, ( if (scrollableContainer) {
<Stack gap="sm" > setTimeout(() => {
scrollableContainer.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: 'smooth'
});
}, 100); // Small delay to ensure content is rendered
}
}
}, [previewFiles.length, operation.downloadUrl, operation.errorMessage]);
return (
<Stack gap="sm" ref={stepRef}>
<ErrorNotification <ErrorNotification
error={operation.errorMessage} error={operation.errorMessage}
onClose={operation.clearError} onClose={operation.clearError}
@ -40,7 +48,7 @@ export function createReviewToolStep<TParams = unknown>(
{previewFiles.length > 0 && ( {previewFiles.length > 0 && (
<ResultsPreview <ResultsPreview
files={previewFiles} files={previewFiles}
onFileClick={props.onFileClick} onFileClick={onFileClick}
isGeneratingThumbnails={operation.isGeneratingThumbnails} isGeneratingThumbnails={operation.isGeneratingThumbnails}
/> />
)} )}
@ -61,5 +69,23 @@ export function createReviewToolStep<TParams = unknown>(
<SuggestedToolsSection /> <SuggestedToolsSection />
</Stack> </Stack>
);
}
export function createReviewToolStep<TParams = unknown>(
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
props: ReviewToolStepProps<TParams>
): React.ReactElement {
const { t } = useTranslation();
return createStep(t("review", "Review"), {
isVisible: props.isVisible,
_excludeFromCount: true,
_noPadding: true
}, (
<ReviewStepContent
operation={props.operation}
onFileClick={props.onFileClick}
/>
)); ));
} }

View File

@ -3,12 +3,13 @@ import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { TooltipTip } from '../../shared/tooltip/TooltipContent'; import { TooltipTip } from '../../../types/tips';
import { createFilesToolStep, FilesToolStepProps } from './FilesToolStep'; import { createFilesToolStep, FilesToolStepProps } from './FilesToolStep';
import { createReviewToolStep, ReviewToolStepProps } from './ReviewToolStep'; import { createReviewToolStep, ReviewToolStepProps } from './ReviewToolStep';
interface ToolStepContextType { interface ToolStepContextType {
visibleStepCount: number; visibleStepCount: number;
forceStepNumbers?: boolean;
} }
const ToolStepContext = createContext<ToolStepContextType | null>(null); const ToolStepContext = createContext<ToolStepContextType | null>(null);
@ -82,10 +83,11 @@ const ToolStep = ({
const parent = useContext(ToolStepContext); const parent = useContext(ToolStepContext);
// Auto-detect if we should show numbers based on sibling count // Auto-detect if we should show numbers based on sibling count or force option
const shouldShowNumber = useMemo(() => { const shouldShowNumber = useMemo(() => {
if (showNumber !== undefined) return showNumber; if (showNumber !== undefined) return showNumber; // Individual step override
return parent ? parent.visibleStepCount >= 3 : false; if (parent?.forceStepNumbers) return true; // Flow-level force
return parent ? parent.visibleStepCount >= 3 : false; // Auto-detect
}, [showNumber, parent]); }, [showNumber, parent]);
const stepNumber = _stepNumber; const stepNumber = _stepNumber;
@ -196,7 +198,7 @@ export function createToolSteps() {
} }
// Context provider wrapper for tools using the factory // Context provider wrapper for tools using the factory
export function ToolStepProvider({ children }: { children: React.ReactNode }) { export function ToolStepProvider({ children, forceStepNumbers }: { children: React.ReactNode; forceStepNumbers?: boolean }) {
// Count visible steps from children that are ToolStep elements // Count visible steps from children that are ToolStep elements
const visibleStepCount = useMemo(() => { const visibleStepCount = useMemo(() => {
let count = 0; let count = 0;
@ -212,8 +214,9 @@ export function ToolStepProvider({ children }: { children: React.ReactNode }) {
}, [children]); }, [children]);
const contextValue = useMemo(() => ({ const contextValue = useMemo(() => ({
visibleStepCount visibleStepCount,
}), [visibleStepCount]); forceStepNumbers
}), [visibleStepCount, forceStepNumbers]);
return ( return (
<ToolStepContext.Provider value={contextValue}> <ToolStepContext.Provider value={contextValue}>

View File

@ -49,6 +49,7 @@ export interface ToolFlowConfig {
steps: MiddleStepConfig[]; steps: MiddleStepConfig[];
executeButton?: ExecuteButtonConfig; executeButton?: ExecuteButtonConfig;
review: ReviewStepConfig; review: ReviewStepConfig;
forceStepNumbers?: boolean;
} }
/** /**
@ -60,7 +61,7 @@ export function createToolFlow(config: ToolFlowConfig) {
return ( return (
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}> <Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
<ToolStepProvider> <ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
{/* Files Step */} {/* Files Step */}
{steps.createFilesStep({ {steps.createFilesStep({
selectedFiles: config.files.selectedFiles, selectedFiles: config.files.selectedFiles,

View File

@ -0,0 +1,176 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent, TooltipTip } from '../../types/tips';
// Shared tooltip content to reduce duplication
const useSharedWatermarkContent = () => {
const { t } = useTranslation();
const languageSupportTip: TooltipTip = {
title: t("watermark.tooltip.language.title", "Language Support"),
description: t("watermark.tooltip.language.text", "Choose the appropriate language setting to ensure proper font rendering for your text.")
};
const appearanceTip: TooltipTip = {
title: t("watermark.tooltip.appearance.title", "Appearance Settings"),
description: t("watermark.tooltip.appearance.text", "Control how your watermark looks and blends with the document."),
bullets: [
t("watermark.tooltip.appearance.bullet1", "Rotation: -360° to 360° for angled watermarks"),
t("watermark.tooltip.appearance.bullet2", "Opacity: 0-100% for transparency control"),
t("watermark.tooltip.appearance.bullet3", "Lower opacity creates subtle watermarks")
]
};
const spacingTip: TooltipTip = {
title: t("watermark.tooltip.spacing.title", "Spacing Control"),
description: t("watermark.tooltip.spacing.text", "Adjust the spacing between repeated watermarks across the page."),
bullets: [
t("watermark.tooltip.spacing.bullet1", "Width spacing: Horizontal distance between watermarks"),
t("watermark.tooltip.spacing.bullet2", "Height spacing: Vertical distance between watermarks"),
t("watermark.tooltip.spacing.bullet3", "Higher values create more spread out patterns")
]
};
return { languageSupportTip, appearanceTip, spacingTip };
};
export const useWatermarkTypeTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("watermark.tooltip.type.header.title", "Watermark Type Selection")
},
tips: [
{
title: t("watermark.tooltip.type.description.title", "Choose Your Watermark"),
description: t("watermark.tooltip.type.description.text", "Select between text or image watermarks based on your needs.")
},
{
title: t("watermark.tooltip.type.text.title", "Text Watermarks"),
description: t("watermark.tooltip.type.text.text", "Perfect for adding copyright notices, company names, or confidentiality labels. Supports multiple languages and custom colors."),
bullets: [
t("watermark.tooltip.type.text.bullet1", "Customizable fonts and languages"),
t("watermark.tooltip.type.text.bullet2", "Adjustable colors and transparency"),
t("watermark.tooltip.type.text.bullet3", "Ideal for legal or branding text")
]
},
{
title: t("watermark.tooltip.type.image.title", "Image Watermarks"),
description: t("watermark.tooltip.type.image.text", "Use logos, stamps, or any image as a watermark. Great for branding and visual identification."),
bullets: [
t("watermark.tooltip.type.image.bullet1", "Upload any image format"),
t("watermark.tooltip.type.image.bullet2", "Maintains image quality"),
t("watermark.tooltip.type.image.bullet3", "Perfect for logos and stamps")
]
}
]
};
};
export const useWatermarkWordingTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("watermark.tooltip.wording.header.title", "Text Content")
},
tips: [
{
title: t("watermark.tooltip.wording.text.title", "Watermark Text"),
description: t("watermark.tooltip.wording.text.text", "Enter the text that will appear as your watermark across the document."),
bullets: [
t("watermark.tooltip.wording.text.bullet1", "Keep it concise for better readability"),
t("watermark.tooltip.wording.text.bullet2", "Common examples: 'CONFIDENTIAL', 'DRAFT', company name"),
t("watermark.tooltip.wording.text.bullet3", "Emoji characters are not supported and will be filtered out")
]
}
]
};
};
export const useWatermarkTextStyleTips = (): TooltipContent => {
const { t } = useTranslation();
const { languageSupportTip } = useSharedWatermarkContent();
return {
header: {
title: t("watermark.tooltip.textStyle.header.title", "Text Style")
},
tips: [
{
title: t("watermark.tooltip.textStyle.color.title", "Color Selection"),
description: t("watermark.tooltip.textStyle.color.text", "Choose a color that provides good contrast with your document content."),
bullets: [
t("watermark.tooltip.textStyle.color.bullet1", "Light gray (#d3d3d3) for subtle watermarks"),
t("watermark.tooltip.textStyle.color.bullet2", "Black or dark colors for high contrast"),
t("watermark.tooltip.textStyle.color.bullet3", "Custom colors for branding purposes")
]
},
languageSupportTip
]
};
};
export const useWatermarkFileTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("watermark.tooltip.file.header.title", "Image Upload")
},
tips: [
{
title: t("watermark.tooltip.file.upload.title", "Image Selection"),
description: t("watermark.tooltip.file.upload.text", "Upload an image file to use as your watermark."),
bullets: [
t("watermark.tooltip.file.upload.bullet1", "Supports common formats: PNG, JPG, GIF, BMP"),
t("watermark.tooltip.file.upload.bullet2", "PNG with transparency works best"),
t("watermark.tooltip.file.upload.bullet3", "Higher resolution images maintain quality better")
]
},
{
title: t("watermark.tooltip.file.recommendations.title", "Best Practices"),
description: t("watermark.tooltip.file.recommendations.text", "Tips for optimal image watermark results."),
bullets: [
t("watermark.tooltip.file.recommendations.bullet1", "Use logos or stamps with transparent backgrounds"),
t("watermark.tooltip.file.recommendations.bullet2", "Simple designs work better than complex images"),
t("watermark.tooltip.file.recommendations.bullet3", "Consider the final document size when choosing resolution")
]
}
]
};
};
export const useWatermarkFormattingTips = (): TooltipContent => {
const { t } = useTranslation();
const { appearanceTip, spacingTip } = useSharedWatermarkContent();
return {
header: {
title: t("watermark.tooltip.formatting.header.title", "Formatting & Layout")
},
tips: [
{
title: t("watermark.tooltip.formatting.size.title", "Size Control"),
description: t("watermark.tooltip.formatting.size.text", "Adjust the size of your watermark (text or image)."),
bullets: [
t("watermark.tooltip.formatting.size.bullet1", "Larger sizes create more prominent watermarks")
]
},
appearanceTip,
spacingTip,
{
title: t("watermark.tooltip.formatting.security.title", "Security Option"),
description: t("watermark.tooltip.formatting.security.text", "Convert the final PDF to an image-based format for enhanced security."),
bullets: [
t("watermark.tooltip.formatting.security.bullet1", "Prevents text selection and copying"),
t("watermark.tooltip.formatting.security.bullet2", "Makes watermarks harder to remove"),
t("watermark.tooltip.formatting.security.bullet3", "Results in larger file sizes"),
t("watermark.tooltip.formatting.security.bullet4", "Best for sensitive or copyrighted content")
]
}
]
};
};

View File

@ -0,0 +1,28 @@
import { AddWatermarkParameters } from "../hooks/tools/addWatermark/useAddWatermarkParameters";
export interface AlphabetOption {
value: string;
label: string;
}
export const alphabetOptions: AlphabetOption[] = [
{ value: "roman", label: "Roman" },
{ value: "arabic", label: "العربية" },
{ value: "japanese", label: "日本語" },
{ value: "korean", label: "한국어" },
{ value: "chinese", label: "简体中文" },
{ value: "thai", label: "ไทย" },
];
export const defaultWatermarkParameters: AddWatermarkParameters = {
watermarkType: undefined,
watermarkText: '',
fontSize: 12,
rotation: 0,
opacity: 50,
widthSpacer: 50,
heightSpacer: 50,
alphabet: 'roman',
customColor: '#d3d3d3',
convertPDFToImage: false
};

View File

@ -0,0 +1,46 @@
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);
// Required: watermarkType as string
formData.append("watermarkType", parameters.watermarkType || "text");
// Add watermark content based on type
if (parameters.watermarkType === 'text') {
formData.append("watermarkText", parameters.watermarkText);
} else if (parameters.watermarkType === 'image' && parameters.watermarkImage) {
formData.append("watermarkImage", parameters.watermarkImage);
}
// Required parameters with correct formatting
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());
// Backend-expected parameters from user input
formData.append("alphabet", parameters.alphabet);
formData.append("customColor", parameters.customColor);
formData.append("convertPDFToImage", parameters.convertPDFToImage.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,50 @@
import { useState, useCallback } from 'react';
import { defaultWatermarkParameters } from '../../../constants/addWatermarkConstants';
export interface AddWatermarkParameters {
watermarkType?: 'text' | 'image';
watermarkText: string;
watermarkImage?: File;
fontSize: number; // Used for both text size and image size
rotation: number;
opacity: number;
widthSpacer: number;
heightSpacer: number;
alphabet: string;
customColor: string;
convertPDFToImage: boolean;
}
export const useAddWatermarkParameters = () => {
const [parameters, setParameters] = useState<AddWatermarkParameters>(defaultWatermarkParameters);
const updateParameter = useCallback(<K extends keyof AddWatermarkParameters>(
key: K,
value: AddWatermarkParameters[K]
) => {
setParameters(prev => ({ ...prev, [key]: value }));
}, []);
const resetParameters = useCallback(() => {
setParameters(defaultWatermarkParameters);
}, []);
const validateParameters = useCallback((): boolean => {
if (!parameters.watermarkType) {
return false;
}
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 LockOpenIcon from "@mui/icons-material/LockOpen"; import LockOpenIcon from "@mui/icons-material/LockOpen";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { Tool, ToolDefinition, ToolRegistry } from "../types/tool"; import { Tool, ToolDefinition, ToolRegistry } from "../types/tool";
@ -105,6 +106,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"]
},
removePassword: { removePassword: {
id: "removePassword", id: "removePassword",
icon: <LockOpenIcon />, icon: <LockOpenIcon />,

View File

@ -0,0 +1,212 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import WatermarkTypeSettings from "../components/tools/addWatermark/WatermarkTypeSettings";
import WatermarkWording from "../components/tools/addWatermark/WatermarkWording";
import WatermarkTextStyle from "../components/tools/addWatermark/WatermarkTextStyle";
import WatermarkImageFile from "../components/tools/addWatermark/WatermarkImageFile";
import WatermarkFormatting from "../components/tools/addWatermark/WatermarkFormatting";
import { useAddWatermarkParameters } from "../hooks/tools/addWatermark/useAddWatermarkParameters";
import { useAddWatermarkOperation } from "../hooks/tools/addWatermark/useAddWatermarkOperation";
import {
useWatermarkTypeTips,
useWatermarkWordingTips,
useWatermarkTextStyleTips,
useWatermarkFileTips,
useWatermarkFormattingTips,
} from "../components/tooltips/useWatermarkTips";
import { BaseToolProps } from "../types/tool";
const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection();
const [collapsedType, setCollapsedType] = useState(false);
const [collapsedStyle, setCollapsedStyle] = useState(true);
const [collapsedFormatting, setCollapsedFormatting] = useState(true);
const watermarkParams = useAddWatermarkParameters();
const watermarkOperation = useAddWatermarkOperation();
const watermarkTypeTips = useWatermarkTypeTips();
const watermarkWordingTips = useWatermarkWordingTips();
const watermarkTextStyleTips = useWatermarkTextStyleTips();
const watermarkFileTips = useWatermarkFileTips();
const watermarkFormattingTips = useWatermarkFormattingTips();
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-watermark");
useEffect(() => {
watermarkOperation.resetResults();
onPreviewFile?.(null);
}, [watermarkParams.parameters]);
// Auto-collapse type step after selection
useEffect(() => {
if (watermarkParams.parameters.watermarkType && !collapsedType) {
setCollapsedType(true);
}
}, [watermarkParams.parameters.watermarkType]);
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 : t("watermark.error.failed", "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;
// Dynamic step structure based on watermark type
const getSteps = () => {
const steps = [];
steps.push({
title: t("watermark.steps.type", "Watermark Type"),
isCollapsed: hasResults ? true : collapsedType,
isVisible: hasFiles || hasResults,
onCollapsedClick: hasResults ? handleSettingsReset : () => setCollapsedType(!collapsedType),
tooltip: watermarkTypeTips,
content: (
<WatermarkTypeSettings
watermarkType={watermarkParams.parameters.watermarkType}
onWatermarkTypeChange={(type) => watermarkParams.updateParameter("watermarkType", type)}
disabled={endpointLoading}
/>
),
});
if (hasFiles || hasResults) {
// Text watermark path
if (watermarkParams.parameters.watermarkType === "text") {
// Step 2: Wording
steps.push({
title: t("watermark.steps.wording", "Wording"),
isCollapsed: hasResults,
tooltip: watermarkWordingTips,
content: (
<WatermarkWording
parameters={watermarkParams.parameters}
onParameterChange={watermarkParams.updateParameter}
disabled={endpointLoading}
/>
),
});
// Step 3: Style
steps.push({
title: t("watermark.steps.textStyle", "Style"),
isCollapsed: hasResults ? true : collapsedStyle,
onCollapsedClick: hasResults ? handleSettingsReset : () => setCollapsedStyle(!collapsedStyle),
tooltip: watermarkTextStyleTips,
content: (
<WatermarkTextStyle
parameters={watermarkParams.parameters}
onParameterChange={watermarkParams.updateParameter}
disabled={endpointLoading}
/>
),
});
// Step 4: Formatting
steps.push({
title: t("watermark.steps.formatting", "Formatting"),
isCollapsed: hasResults ? true : collapsedFormatting,
onCollapsedClick: hasResults ? handleSettingsReset : () => setCollapsedFormatting(!collapsedFormatting),
tooltip: watermarkFormattingTips,
content: (
<WatermarkFormatting
parameters={watermarkParams.parameters}
onParameterChange={watermarkParams.updateParameter}
disabled={endpointLoading}
/>
),
});
}
// Image watermark path
if (watermarkParams.parameters.watermarkType === "image") {
// Step 2: Watermark File
steps.push({
title: t("watermark.steps.file", "Watermark File"),
isCollapsed: hasResults,
tooltip: watermarkFileTips,
content: (
<WatermarkImageFile
parameters={watermarkParams.parameters}
onParameterChange={watermarkParams.updateParameter}
disabled={endpointLoading}
/>
),
});
// Step 3: Formatting
steps.push({
title: t("watermark.steps.formatting", "Formatting"),
isCollapsed: hasResults ? true : collapsedFormatting,
onCollapsedClick: hasResults ? handleSettingsReset : () => setCollapsedFormatting(!collapsedFormatting),
tooltip: watermarkFormattingTips,
content: (
<WatermarkFormatting
parameters={watermarkParams.parameters}
onParameterChange={watermarkParams.updateParameter}
disabled={endpointLoading}
/>
),
});
}
}
return steps;
};
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasFiles || hasResults,
},
steps: getSteps(),
executeButton: {
text: t("watermark.submit", "Add Watermark"),
isVisible: !hasResults,
loadingText: t("loading"),
onClick: handleAddWatermark,
disabled: !watermarkParams.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: watermarkOperation,
title: t("watermark.results.title", "Watermark Results"),
onFileClick: handleThumbnailClick,
},
forceStepNumbers: true,
});
};
export default AddWatermark;

View File

@ -17,6 +17,7 @@ export type ModeType =
| 'sanitize' | 'sanitize'
| 'addPassword' | 'addPassword'
| 'changePermissions' | 'changePermissions'
| 'watermark'
| 'removePassword'; | 'removePassword';
export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor'; export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor';
@ -108,12 +109,12 @@ export interface FileContextActions {
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void; removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
replaceFile: (oldFileId: string, newFile: File) => Promise<void>; replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
clearAllFiles: () => void; clearAllFiles: () => void;
// File pinning // File pinning
pinFile: (file: File) => void; pinFile: (file: File) => void;
unpinFile: (file: File) => void; unpinFile: (file: File) => void;
isFilePinned: (file: File) => boolean; isFilePinned: (file: File) => boolean;
// File consumption (replace unpinned files with outputs) // File consumption (replace unpinned files with outputs)
consumeFiles: (inputFiles: File[], outputFiles: File[]) => Promise<void>; consumeFiles: (inputFiles: File[], outputFiles: File[]) => Promise<void>;

View File

@ -1,13 +1,15 @@
export interface TooltipTip {
title?: string;
description?: string;
bullets?: string[];
body?: React.ReactNode;
}
export interface TooltipContent { export interface TooltipContent {
header?: { header?: {
title: string; title: string;
logo?: string | React.ReactNode; logo?: string | React.ReactNode;
}; };
tips?: Array<{ tips?: TooltipTip[];
title?: string;
description?: string;
bullets?: string[];
body?: React.ReactNode;
}>;
content?: React.ReactNode; content?: React.ReactNode;
} }

View File

@ -0,0 +1,9 @@
/**
* Filters out emoji characters from a text string
* @param text - The input text string
* @returns The filtered text without emoji characters
*/
export const removeEmojis = (text: string): string => {
// Filter out emoji characters (Unicode ranges for emojis)
return text.replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '');
};