mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Feature/v2/watermark (#4215)
Add watermark feature Auto scroll on review --------- Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user