Feature/v2/watermark (#4215)

Add watermark feature

Auto scroll on review

---------

Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
ConnorYoh
2025-08-19 10:31:44 +01:00
committed by GitHub
co-authored by Connor Yoh
parent acbebd67a3
commit c1b7911518
24 changed files with 1410 additions and 78 deletions
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { Button, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DownloadIcon from '@mui/icons-material/Download';
@@ -14,24 +14,32 @@ export interface ReviewToolStepProps<TParams = unknown> {
onFileClick?: (file: File) => void;
}
export function createReviewToolStep<TParams = unknown>(
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
props: ReviewToolStepProps<TParams>
): React.ReactElement {
function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { operation: ToolOperationHook<TParams>; onFileClick?: (file: File) => void }) {
const { t } = useTranslation();
const { operation } = props;
const stepRef = useRef<HTMLDivElement>(null);
const previewFiles = operation.files?.map((file, index) => ({
file,
thumbnail: operation.thumbnails[index]
})) || [];
return createStep(t("review", "Review"), {
isVisible: props.isVisible,
_excludeFromCount: true,
_noPadding: true
}, (
<Stack gap="sm" >
// Auto-scroll to bottom when content appears
useEffect(() => {
if (stepRef.current && (previewFiles.length > 0 || operation.downloadUrl || operation.errorMessage)) {
const scrollableContainer = stepRef.current.closest('[style*="overflow: auto"]') as HTMLElement;
if (scrollableContainer) {
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
error={operation.errorMessage}
onClose={operation.clearError}
@@ -40,7 +48,7 @@ export function createReviewToolStep<TParams = unknown>(
{previewFiles.length > 0 && (
<ResultsPreview
files={previewFiles}
onFileClick={props.onFileClick}
onFileClick={onFileClick}
isGeneratingThumbnails={operation.isGeneratingThumbnails}
/>
)}
@@ -61,5 +69,23 @@ export function createReviewToolStep<TParams = unknown>(
<SuggestedToolsSection />
</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}
/>
));
}
@@ -3,12 +3,13 @@ import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { Tooltip } from '../../shared/Tooltip';
import { TooltipTip } from '../../shared/tooltip/TooltipContent';
import { TooltipTip } from '../../../types/tips';
import { createFilesToolStep, FilesToolStepProps } from './FilesToolStep';
import { createReviewToolStep, ReviewToolStepProps } from './ReviewToolStep';
interface ToolStepContextType {
visibleStepCount: number;
forceStepNumbers?: boolean;
}
const ToolStepContext = createContext<ToolStepContextType | null>(null);
@@ -82,10 +83,11 @@ const ToolStep = ({
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(() => {
if (showNumber !== undefined) return showNumber;
return parent ? parent.visibleStepCount >= 3 : false;
if (showNumber !== undefined) return showNumber; // Individual step override
if (parent?.forceStepNumbers) return true; // Flow-level force
return parent ? parent.visibleStepCount >= 3 : false; // Auto-detect
}, [showNumber, parent]);
const stepNumber = _stepNumber;
@@ -196,7 +198,7 @@ export function createToolSteps() {
}
// 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
const visibleStepCount = useMemo(() => {
let count = 0;
@@ -212,8 +214,9 @@ export function ToolStepProvider({ children }: { children: React.ReactNode }) {
}, [children]);
const contextValue = useMemo(() => ({
visibleStepCount
}), [visibleStepCount]);
visibleStepCount,
forceStepNumbers
}), [visibleStepCount, forceStepNumbers]);
return (
<ToolStepContext.Provider value={contextValue}>
@@ -49,6 +49,7 @@ export interface ToolFlowConfig {
steps: MiddleStepConfig[];
executeButton?: ExecuteButtonConfig;
review: ReviewStepConfig;
forceStepNumbers?: boolean;
}
/**
@@ -60,7 +61,7 @@ export function createToolFlow(config: ToolFlowConfig) {
return (
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
<ToolStepProvider>
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
{/* Files Step */}
{steps.createFilesStep({
selectedFiles: config.files.selectedFiles,