From 60a433610eb2840247169b8b3525fc807f52b8e8 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Thu, 11 Sep 2025 19:27:09 +0100 Subject: [PATCH] Addition of the Add Stamp to PDF tool --- .../tools/addStamp/StampPreview.module.css | 193 ++++++++ .../tools/addStamp/StampPreview.tsx | 304 +++++++++++++ .../tools/addStamp/StampPreviewUtils.ts | 205 +++++++++ .../tools/addStamp/useAddStampOperation.ts | 55 +++ .../tools/addStamp/useAddStampParameters.ts | 51 +++ .../src/data/useTranslatedToolRegistry.tsx | 7 +- frontend/src/styles/theme.css | 7 +- frontend/src/tools/AddStamp.tsx | 425 ++++++++++++++++++ 8 files changed, 1245 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/tools/addStamp/StampPreview.module.css create mode 100644 frontend/src/components/tools/addStamp/StampPreview.tsx create mode 100644 frontend/src/components/tools/addStamp/StampPreviewUtils.ts create mode 100644 frontend/src/components/tools/addStamp/useAddStampOperation.ts create mode 100644 frontend/src/components/tools/addStamp/useAddStampParameters.ts create mode 100644 frontend/src/tools/AddStamp.tsx diff --git a/frontend/src/components/tools/addStamp/StampPreview.module.css b/frontend/src/components/tools/addStamp/StampPreview.module.css new file mode 100644 index 000000000..f7e5c5c3c --- /dev/null +++ b/frontend/src/components/tools/addStamp/StampPreview.module.css @@ -0,0 +1,193 @@ +/* StampPreview.module.css */ + +/* Container styles */ +.container { + position: relative; + width: 100%; + overflow: hidden; +} + +.containerWithThumbnail { + background-color: transparent; +} + +.containerWithoutThumbnail { + background-color: rgba(255, 255, 255, 0.03); +} + +.containerBorder { + border: 1px solid var(--border-default, #333); +} + +/* Page thumbnail styles */ +.pageThumbnail { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + filter: grayscale(10%) contrast(95%) brightness(105%); +} + +/* Stamp item styles */ +.stampItem { + position: absolute; + display: flex; + flex-direction: column; + justify-content: flex-start; + line-height: 1; + transform-origin: left bottom; +} + +.stampItemDraggable { + cursor: move; + pointer-events: auto; +} + +.stampItemGridMode { + cursor: default; + pointer-events: none; +} + +/* Text stamp styles */ +.textLine { + white-space: pre; + display: block; + word-break: keep-all; + overflow: visible; +} + +/* Image stamp styles */ +.stampImage { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Quick grid overlay styles */ +.quickGrid { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 8px; + padding: 8px; + pointer-events: auto; +} + +.gridTile { + border: 1px dashed rgba(0, 0, 0, 0.15); + background-color: transparent; + color: transparent; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + user-select: none; +} + +.gridTileSelected, +.gridTileHovered { + border: 2px solid var(--mantine-primary-color-filled, #3b82f6); +} + +/* Preview header */ +.previewHeader { + margin-bottom: 12px; +} + +.divider { + height: 1px; + background-color: var(--border-default, #333); + margin-bottom: 8px; +} + +.previewLabel { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + text-align: center; +} + +/* Preview disclaimer */ +.previewDisclaimer { + margin-top: 8px; + opacity: 0.7; + font-size: 12px; +} + +/* AddStamp.tsx specific styles */ + +/* Information text container */ +.informationContainer { + background-color: var(--information-text-bg); + padding: 8px; + border-radius: 10px; + margin-top: 8px; + margin-bottom: 8px; + width: 100%; + align-items: center; + justify-content: center; + text-align: center; +} + +.informationText { + font-size: 0.875rem; + font-weight: 500; + color: var(--information-text-color); +} + +/* Mode toggle buttons */ +.modeToggleGroup { + gap: 0.25rem; + flex-grow: 1; +} + +.modeToggleButton { + border-radius: 0.125rem; + font-size: 0.75rem; + width: 100%; +} + +/* Icon pill buttons */ +.iconPillGroup { + gap: 0.25rem; + flex-grow: 1; +} + +.iconPillButton { + border-radius: 0.125rem; + font-size: 0.75rem; + width: 100%; +} + +/* Slider controls */ +.sliderGroup { + gap: 1rem; + align-items: center; +} + +.numberInput { + width: 80px; + font-size: 0.875rem; +} + +.slider { + flex: 1; +} + +.sliderWide { + flex: 1.2; +} + +/* Label text */ +.labelText { + font-size: 0.875rem; + font-weight: 500; +} diff --git a/frontend/src/components/tools/addStamp/StampPreview.tsx b/frontend/src/components/tools/addStamp/StampPreview.tsx new file mode 100644 index 000000000..4d311df96 --- /dev/null +++ b/frontend/src/components/tools/addStamp/StampPreview.tsx @@ -0,0 +1,304 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { AddStampParameters } from './useAddStampParameters'; +import { pdfWorkerManager } from '../../../services/pdfWorkerManager'; +import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration'; +import { A4_ASPECT_RATIO, getFirstSelectedPage, getFontFamily, computeStampPreviewStyle } from './StampPreviewUtils'; +import FitText from '../../shared/FitText'; +import styles from './StampPreview.module.css'; + +type Props = { + parameters: AddStampParameters; + onParameterChange: (key: K, value: AddStampParameters[K]) => void; + file?: File | null; + showQuickGrid?: boolean; +}; + +export default function StampPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) { + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); + const [imageMeta, setImageMeta] = useState<{ url: string; width: number; height: number } | null>(null); + const [pageSize, setPageSize] = useState<{ widthPts: number; heightPts: number } | null>(null); + const [pageThumbnail, setPageThumbnail] = useState(null); + const { requestThumbnail } = useThumbnailGeneration(); + const [hoverTile, setHoverTile] = useState(null); + + // Load image URL and meta for aspect ratio if an image is selected + useEffect(() => { + if (parameters.stampType === 'image' && parameters.stampImage) { + const url = URL.createObjectURL(parameters.stampImage); + const img = new Image(); + img.onload = () => { + setImageMeta({ url, width: img.width, height: img.height }); + }; + img.src = url; + return () => URL.revokeObjectURL(url); + } else { + setImageMeta(null); + } + }, [parameters.stampType, parameters.stampImage]); + + // Observe container size for responsive positioning + useEffect(() => { + const node = containerRef.current; + if (!node) return; + const resize = () => { + const aspect = pageSize ? (pageSize.widthPts / pageSize.heightPts) : A4_ASPECT_RATIO; + setContainerSize({ width: node.clientWidth, height: node.clientWidth / aspect }); + }; + resize(); + const ro = new ResizeObserver(resize); + ro.observe(node); + return () => ro.disconnect(); + }, [pageSize]); + + // Load first PDF page size in points for accurate scaling + useEffect(() => { + let cancelled = false; + const load = async () => { + if (!file || file.type !== 'application/pdf') { + setPageSize(null); + return; + } + try { + const buffer = await file.arrayBuffer(); + const pdf = await pdfWorkerManager.createDocument(buffer, { disableAutoFetch: true, disableStream: true }); + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale: 1 }); + if (!cancelled) { + setPageSize({ widthPts: viewport.width, heightPts: viewport.height }); + } + pdfWorkerManager.destroyDocument(pdf); + } catch { + // Fallback to A4 if we cannot read page + if (!cancelled) setPageSize(null); + } + }; + load(); + return () => { cancelled = true; }; + }, [file]); + + // Load first-page thumbnail for background preview so users see the content + useEffect(() => { + let isActive = true; + const loadThumb = async () => { + if (!file || file.type !== 'application/pdf') { + setPageThumbnail(null); + return; + } + try { + const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pageNumbers)); + const pageId = `${file.name}:page:${pageNumber}`; + const thumb = await requestThumbnail(pageId, file, pageNumber); + if (isActive) setPageThumbnail(thumb || null); + } catch { + if (isActive) setPageThumbnail(null); + } + }; + loadThumb(); + return () => { isActive = false; }; + }, [file, parameters.pageNumbers, requestThumbnail]); + + const style = useMemo(() => ( + computeStampPreviewStyle( + parameters, + imageMeta, + pageSize, + containerSize, + showQuickGrid, + hoverTile, + !!pageThumbnail + ) + ), [containerSize, parameters, imageMeta, pageSize, showQuickGrid, hoverTile, pageThumbnail]); + + // Drag/resize/rotate interactions + const draggingRef = useRef<{ type: 'move' | 'resize' | 'rotate'; startX: number; startY: number; initLeft: number; initBottom: number; initHeight: number; centerX: number; centerY: number } | null>(null); + + const ensureOverrides = () => { + const pageWidth = containerSize.width; + const pageHeight = containerSize.height; + if (pageWidth <= 0 || pageHeight <= 0) return; + + // Recompute current x,y from style (so that we start from visual position) + const itemStyle = style.item as any; + const leftPx = parseFloat(String(itemStyle.left).replace('px', '')) || 0; + const bottomPx = parseFloat(String(itemStyle.bottom).replace('px', '')) || 0; + const widthPts = pageSize?.widthPts ?? 595.28; + const heightPts = pageSize?.heightPts ?? 841.89; + const scaleX = containerSize.width / widthPts; + const scaleY = containerSize.height / heightPts; + if (parameters.overrideX < 0 || parameters.overrideY < 0) { + onParameterChange('overrideX', Math.max(0, Math.min(pageWidth, leftPx)) / scaleX as any); + onParameterChange('overrideY', Math.max(0, Math.min(pageHeight, bottomPx)) / scaleY as any); + } + }; + + const handlePointerDown = (e: React.PointerEvent, type: 'move' | 'resize' | 'rotate') => { + e.preventDefault(); + ensureOverrides(); + + const item = style.item as any; + const left = parseFloat(String(item.left).replace('px', '')) || 0; + const bottom = parseFloat(String(item.bottom).replace('px', '')) || 0; + const width = parseFloat(String(item.width).replace('px', '')) || parameters.fontSize; + const height = parseFloat(String(item.height).replace('px', '')) || parameters.fontSize; + + const rect = (e.currentTarget.parentElement as HTMLElement)?.getBoundingClientRect(); + const centerX = left + width / 2; + const centerY = bottom + height / 2; + + draggingRef.current = { + type, + startX: e.clientX - (rect?.left || 0), + startY: (rect ? rect.bottom - e.clientY : 0), // convert to bottom-based coords + initLeft: left, + initBottom: bottom, + initHeight: height, + centerX, + centerY, + }; + + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!draggingRef.current) return; + const node = containerRef.current; + if (!node) return; + const rect = node.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = rect.bottom - e.clientY; // bottom-based + + const drag = draggingRef.current; + + if (drag.type === 'move') { + const dx = x - drag.startX; + const dy = y - drag.startY; + const newLeftPx = Math.max(0, Math.min(containerSize.width, drag.initLeft + dx)); + const newBottomPx = Math.max(0, Math.min(containerSize.height, drag.initBottom + dy)); + const widthPts = pageSize?.widthPts ?? 595.28; + const heightPts = pageSize?.heightPts ?? 841.89; + const scaleX = containerSize.width / widthPts; + const scaleY = containerSize.height / heightPts; + const newLeftPts = newLeftPx / scaleX; + const newBottomPts = newBottomPx / scaleY; + onParameterChange('overrideX', newLeftPts as any); + onParameterChange('overrideY', newBottomPts as any); + } + + if (drag.type === 'resize') { + // Height is our canonical size (fontSize) + const widthPts = pageSize?.widthPts ?? 595.28; + const heightPts = pageSize?.heightPts ?? 841.89; + const scaleY = containerSize.height / heightPts; + const newHeightPx = Math.max(1, drag.initHeight + (y - drag.startY)); + const newHeightPts = newHeightPx / scaleY; + onParameterChange('fontSize', newHeightPts as any); + } + + if (drag.type === 'rotate') { + const angle = Math.atan2(y - drag.centerY, x - drag.centerX) * (180 / Math.PI); + onParameterChange('rotation', angle as any); + } + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!draggingRef.current) return; + draggingRef.current = null; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + }; + + const itemHandles = null; // Drag-only per request + + return ( +
+
+
+
Preview Stamp
+
+
+ {pageThumbnail && ( + page preview + )} + {parameters.stampType === 'text' && ( +
+ {(parameters.stampText || '').split('\n').map((line, idx) => ( + + ))} + {itemHandles} +
+ )} + {parameters.stampType === 'image' && imageMeta && ( +
handlePointerDown(e, 'move')} + > + stamp preview + {itemHandles} +
+ )} + + {/* Quick position overlay grid */} + {showQuickGrid && ( +
+ {Array.from({ length: 9 }).map((_, i) => { + const idx = (i + 1) as 1|2|3|4|5|6|7|8|9; + const selected = parameters.position === idx && (parameters.overrideX < 0 || parameters.overrideY < 0); + return ( + + ); + })} +
+ )} +
+
+ Preview is approximate. Final output may vary due to PDF font metrics. +
+
+ ); +} + + diff --git a/frontend/src/components/tools/addStamp/StampPreviewUtils.ts b/frontend/src/components/tools/addStamp/StampPreviewUtils.ts new file mode 100644 index 000000000..4fd2f82e0 --- /dev/null +++ b/frontend/src/components/tools/addStamp/StampPreviewUtils.ts @@ -0,0 +1,205 @@ +import type { AddStampParameters } from './useAddStampParameters'; + +export type ContainerSize = { width: number; height: number }; +export type PageSizePts = { widthPts: number; heightPts: number } | null; +export type ImageMeta = { url: string; width: number; height: number } | null; + +// Map UI margin option to backend margin factor +export const marginFactorMap: Record = { + 'small': 0.02, + 'medium': 0.035, + 'large': 0.05, + 'x-large': 0.075, +}; + +export const A4_ASPECT_RATIO = 0.707; // width/height used elsewhere in legacy UI + +// Get font family based on selected alphabet (matching backend logic) +export const getFontFamily = (alphabet: string): string => { + switch (alphabet) { + case 'arabic': + return 'Noto Sans Arabic, Arial Unicode MS, sans-serif'; + case 'japanese': + return 'Meiryo, Yu Gothic, Hiragino Sans, sans-serif'; + case 'korean': + return 'Malgun Gothic, Dotum, sans-serif'; + case 'chinese': + return 'SimSun, Microsoft YaHei, sans-serif'; + case 'thai': + return 'Noto Sans Thai, Tahoma, sans-serif'; + case 'roman': + default: + return 'Noto Sans, Arial, Helvetica, sans-serif'; + } +}; + +// Lightweight parser: returns first page number from CSV/range input, otherwise 1 +export const getFirstSelectedPage = (input: string): number => { + if (!input) return 1; + const parts = input.split(',').map(s => s.trim()).filter(Boolean); + for (const part of parts) { + if (/^\d+\s*-\s*\d+$/.test(part)) { + const low = parseInt(part.split('-')[0].trim(), 10); + if (Number.isFinite(low) && low > 0) return low; + } + const n = parseInt(part, 10); + if (Number.isFinite(n) && n > 0) return n; + } + return 1; +}; + +export type StampPreviewStyle = { container: any; item: any }; + +export function computeStampPreviewStyle( + parameters: AddStampParameters, + imageMeta: ImageMeta, + pageSize: PageSizePts, + containerSize: ContainerSize, + showQuickGrid: boolean | undefined, + hoverTile: number | null, + hasPageThumbnail: boolean +): StampPreviewStyle { + const pageWidthPx = containerSize.width; + const pageHeightPx = containerSize.height; + const widthPts = pageSize?.widthPts ?? 595.28; // A4 width at 72 DPI + const heightPts = pageSize?.heightPts ?? 841.89; // A4 height at 72 DPI + const scaleX = pageWidthPx / widthPts; + const scaleY = pageHeightPx / heightPts; + if (pageWidthPx <= 0 || pageHeightPx <= 0) return { item: {}, container: {} } as any; + + const marginPts = (widthPts + heightPts) / 2 * (marginFactorMap[parameters.customMargin] ?? 0.035); + + // Compute content dimensions + const heightPtsContent = parameters.fontSize; // UI size in points + let widthPtsContent = heightPtsContent; + + // Approximate PDF cap height ratio per alphabet to mirror backend's calculateTextCapHeight usage + const getCapHeightRatio = (alphabet: string): number => { + switch (alphabet) { + case 'roman': + return 0.70; // Noto Sans/Helvetica ~0.7 em + case 'arabic': + return 0.68; + case 'thai': + return 0.66; + case 'japanese': + case 'korean': + case 'chinese': + return 0.72; // CJK glyph boxes + default: + return 0.70; + } + }; + + if (parameters.stampType === 'image' && imageMeta) { + const aspect = imageMeta.width / imageMeta.height; + widthPtsContent = heightPtsContent * aspect; + } else if (parameters.stampType === 'text') { + // Use Canvas 2D to measure text width for better fidelity than DOM spans + const textLine = (parameters.stampText || '').split('\n')[0] ?? ''; + const fontPx = heightPtsContent * scaleY; // Convert point size to px using vertical scale + const fontFamily = getFontFamily(parameters.alphabet); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.font = `${fontPx}px ${fontFamily}`; + const metrics = ctx.measureText(textLine); + const measuredWidthPx = metrics.width; + // Convert measured px width back to PDF points using horizontal scale + widthPtsContent = measuredWidthPx / scaleX; + + // Empirical tweak to better match PDFBox string width for Roman fonts + // PDFBox often yields ~8-12% narrower widths than browser canvas for the same font family + let adjustmentFactor = 1.0; + switch (parameters.alphabet) { + case 'roman': + adjustmentFactor = 0.90; + break; + case 'arabic': + case 'thai': + adjustmentFactor = 0.92; + break; + case 'japanese': + case 'korean': + case 'chinese': + adjustmentFactor = 0.88; + break; + default: + adjustmentFactor = 0.93; + } + widthPtsContent *= adjustmentFactor; + } + } + + // Positioning helpers - mirror backend logic + const position = parameters.position; + const calcX = () => { + if (parameters.overrideX >= 0 && parameters.overrideY >= 0) return parameters.overrideX; + switch (position % 3) { + case 1: // Left + return marginPts; + case 2: // Center + return (widthPts - widthPtsContent) / 2; + case 0: // Right + return widthPts - widthPtsContent - marginPts; + default: + return 0; + } + }; + const calcY = () => { + if (parameters.overrideX >= 0 && parameters.overrideY >= 0) return parameters.overrideY; + // For text, backend positions using cap height, not full font size + const heightForY = parameters.stampType === 'text' + ? heightPtsContent * getCapHeightRatio(parameters.alphabet) + : heightPtsContent; + switch (Math.floor((position - 1) / 3)) { + case 0: // Top + return heightPts - heightForY - marginPts; + case 1: // Middle + return (heightPts - heightForY) / 2; + case 2: // Bottom + return marginPts; + default: + return 0; + } + }; + + const xPts = calcX(); + const yPts = calcY(); + const xPx = xPts * scaleX; + const yPx = yPts * scaleY; + const widthPx = widthPtsContent * scaleX; + const heightPx = heightPtsContent * scaleY; + + const opacity = Math.max(0, Math.min(1, parameters.opacity / 100)); + const displayOpacity = opacity; + + return { + container: { + position: 'relative', + width: '100%', + aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`, + backgroundColor: hasPageThumbnail ? 'transparent' : 'rgba(255,255,255,0.03)', + border: '1px solid var(--border-default, #333)', + overflow: 'hidden' + }, + item: { + position: 'absolute', + left: `${xPx}px`, + bottom: `${yPx}px`, + width: `${widthPx}px`, + height: `${heightPx}px`, + opacity: displayOpacity, + transform: `rotate(${-parameters.rotation}deg)`, + transformOrigin: 'left bottom', + color: parameters.customColor, + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + lineHeight: 1, + cursor: showQuickGrid ? 'default' : 'move', + pointerEvents: showQuickGrid ? 'none' : 'auto', + } + }; +} diff --git a/frontend/src/components/tools/addStamp/useAddStampOperation.ts b/frontend/src/components/tools/addStamp/useAddStampOperation.ts new file mode 100644 index 000000000..1ff6b62c6 --- /dev/null +++ b/frontend/src/components/tools/addStamp/useAddStampOperation.ts @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../../../hooks/tools/shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { AddStampParameters, defaultParameters } from './useAddStampParameters'; + +export const buildAddStampFormData = (parameters: AddStampParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + formData.append('pageNumbers', parameters.pageNumbers); + formData.append('customMargin', 'medium'); + formData.append('position', String(parameters.position)); + const effectiveFontSize = parameters.stampType === 'text' + ? parameters.fontSize * 2.2 // upscale to match the preview size + : parameters.fontSize; + formData.append('fontSize', String(effectiveFontSize)); + formData.append('rotation', String(parameters.rotation)); + formData.append('opacity', String(parameters.opacity / 100)); + formData.append('overrideX', String(parameters.overrideX)); + formData.append('overrideY', String(parameters.overrideY)); + formData.append('customColor', parameters.customColor.startsWith('#') ? parameters.customColor : `#${parameters.customColor}`); + formData.append('alphabet', parameters.alphabet); + + // Stamp type and payload + formData.append('stampType', parameters.stampType || 'text'); + if (parameters.stampType === 'text') { + formData.append('stampText', parameters.stampText); + } else if (parameters.stampType === 'image' && parameters.stampImage) { + formData.append('stampImage', parameters.stampImage); + } + + return formData; +}; + +export const addStampOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildAddStampFormData, + operationType: 'stamp', + endpoint: '/api/v1/misc/add-stamp', + filePrefix: 'stamped_', + defaultParameters, +} as const; + +export const useAddStampOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...addStampOperationConfig, + filePrefix: t('stamp.filenamePrefix', 'stamped') + '_', + getErrorMessage: createStandardErrorHandler( + t('AddStampRequest.error.failed', 'An error occurred while adding stamp to the PDF.') + ), + }); +}; + + diff --git a/frontend/src/components/tools/addStamp/useAddStampParameters.ts b/frontend/src/components/tools/addStamp/useAddStampParameters.ts new file mode 100644 index 000000000..c448b7012 --- /dev/null +++ b/frontend/src/components/tools/addStamp/useAddStampParameters.ts @@ -0,0 +1,51 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, type BaseParametersHook } from '../../../hooks/tools/shared/useBaseParameters'; + +export interface AddStampParameters extends BaseParameters { + stampType?: 'text' | 'image'; + stampText: string; + stampImage?: File; + alphabet: 'roman' | 'arabic' | 'japanese' | 'korean' | 'chinese' | 'thai'; + fontSize: number; + rotation: number; + opacity: number; + position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + overrideX: number; + overrideY: number; + customMargin: 'small' | 'medium' | 'large' | 'x-large'; + customColor: string; + pageNumbers: string; +} + +export const defaultParameters: AddStampParameters = { + stampType: 'text', + stampText: '', + alphabet: 'roman', + fontSize: 100, + rotation: 0, + opacity: 50, + position: 5, + overrideX: -1, + overrideY: -1, + customMargin: 'medium', + customColor: '#d3d3d3', + pageNumbers: '1', +}; + +export type AddStampParametersHook = BaseParametersHook; + +export const useAddStampParameters = (): AddStampParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'add-stamp', + validateFn: (params): boolean => { + if (!params.stampType) return false; + if (params.stampType === 'text') { + return params.stampText.trim().length > 0; + } + return params.stampImage !== undefined; + }, + }); +}; + + diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index c88d46fec..1c75cbf61 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -11,6 +11,7 @@ import ChangePermissions from "../tools/ChangePermissions"; import RemovePassword from "../tools/RemovePassword"; import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy"; import AddWatermark from "../tools/AddWatermark"; +import AddStamp from "../tools/AddStamp"; import Merge from '../tools/Merge'; import Repair from "../tools/Repair"; import AutoRename from "../tools/AutoRename"; @@ -25,6 +26,7 @@ import { removePasswordOperationConfig } from "../hooks/tools/removePassword/use import { sanitizeOperationConfig } from "../hooks/tools/sanitize/useSanitizeOperation"; import { repairOperationConfig } from "../hooks/tools/repair/useRepairOperation"; import { addWatermarkOperationConfig } from "../hooks/tools/addWatermark/useAddWatermarkOperation"; +import { addStampOperationConfig } from "../components/tools/addStamp/useAddStampOperation"; import { unlockPdfFormsOperationConfig } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation"; import { singleLargePageOperationConfig } from "../hooks/tools/singleLargePage/useSingleLargePageOperation"; import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation"; @@ -189,10 +191,13 @@ export function useFlatToolRegistry(): ToolRegistry { "add-stamp": { icon: , name: t("home.AddStampRequest.title", "Add Stamp to PDF"), - component: null, + component: AddStamp, description: t("home.AddStampRequest.desc", "Add text or add image stamps at set locations"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + maxFiles: -1, + endpoints: ["add-stamp"], + operationConfig: addStampOperationConfig, }, sanitize: { icon: , diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 6643ca580..52bc777c7 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -178,6 +178,9 @@ --checkbox-border: #2F83BF; --checkbox-checked-bg: #3FAFFF; --checkbox-tick: #FFFFFF; + + --information-text-bg: #eaeaea; + --information-text-color: #5e5e5e; } [data-mantine-color-scheme="dark"] { @@ -321,7 +324,9 @@ /* Tool panel search bar background colors (dark mode) */ --tool-panel-search-bg: #1F2329; --tool-panel-search-border-bottom: #4B525A; - + + --information-text-bg: #292e34; + --information-text-color: #ececec; } /* Dropzone drop state styling */ diff --git a/frontend/src/tools/AddStamp.tsx b/frontend/src/tools/AddStamp.tsx new file mode 100644 index 000000000..61645abc9 --- /dev/null +++ b/frontend/src/tools/AddStamp.tsx @@ -0,0 +1,425 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useFileSelection } from "../contexts/FileContext"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import { BaseToolProps, ToolComponent } from "../types/tool"; +import { useEndpointEnabled } from "../hooks/useEndpointConfig"; +import { useAddStampParameters } from "../components/tools/addStamp/useAddStampParameters"; +import { useAddStampOperation } from "../components/tools/addStamp/useAddStampOperation"; +import { Group, Select, Stack, Textarea, TextInput, ColorInput, Button, Slider, Text, NumberInput } from "@mantine/core"; +import StampPreview from "../components/tools/addStamp/StampPreview"; +import LocalIcon from "../components/shared/LocalIcon"; +import styles from "../components/tools/addStamp/StampPreview.module.css"; +import { Tooltip } from "../components/shared/Tooltip"; +import FitText from "../components/shared/FitText"; + +const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { selectedFiles } = useFileSelection(); + + const [collapsedType, setCollapsedType] = useState(false); + const [collapsedFormatting, setCollapsedFormatting] = useState(true); + const [collapsedPageSelection, setCollapsedPageSelection] = useState(false); + const [textConfirmed, setTextConfirmed] = useState(false); + const [quickPositionModeSelected, setQuickPositionModeSelected] = useState(false); + const [customPositionModeSelected, setCustomPositionModeSelected] = useState(true); + + const params = useAddStampParameters(); + const operation = useAddStampOperation(); + + const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-stamp"); + + useEffect(() => { + operation.resetResults(); + onPreviewFile?.(null); + }, [params.parameters]); + + // Auto-collapse steps 2 and 3, and auto-expand step 4 when an image is uploaded + useEffect(() => { + if (params.parameters.stampType === 'image' && params.parameters.stampImage) { + setCollapsedType(true); + setCollapsedPageSelection(true); + setCollapsedFormatting(false); // Auto-expand step 4 (Position & Formatting) + } + }, [params.parameters.stampType, params.parameters.stampImage]); + + // Reset text confirmation when inputs change + useEffect(() => { + if (params.parameters.stampType !== 'text') { + setTextConfirmed(false); + } else { + setTextConfirmed(false); + } + }, [params.parameters.stampType, params.parameters.stampText, params.parameters.alphabet]); + + // Do not auto-collapse when switching types to avoid hiding file input prematurely + + const handleExecute = async () => { + try { + await operation.executeOperation(params.parameters, selectedFiles); + if (operation.files && onComplete) { + onComplete(operation.files); + } + } catch (error: any) { + onError?.(error?.message || t("AddStampRequest.error.failed", "Add stamp operation failed")); + } + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = operation.files.length > 0 || operation.downloadUrl !== null; + + const getSteps = () => { + const steps: any[] = []; + + // Step 1: File settings (page selection) - auto-collapse when image is uploaded + steps.push({ + title: t("AddStampRequest.pageSelection", "Page Selection"), + isCollapsed: hasResults || collapsedPageSelection, + onCollapsedClick: hasResults ? () => operation.resetResults() : () => setCollapsedPageSelection(!collapsedPageSelection), + isVisible: hasFiles || hasResults, + content: ( + + params.updateParameter('pageNumbers', e.currentTarget.value)} + disabled={endpointLoading} + /> + + ), + }); + + // Step 2: Type & Content - auto-collapse when image is uploaded + steps.push({ + title: t("AddStampRequest.stampType", "Stamp Type"), + isCollapsed: hasResults ? true : collapsedType, + onCollapsedClick: hasResults ? () => operation.resetResults() : () => setCollapsedType(!collapsedType), + isVisible: hasFiles || hasResults, + content: ( + +
+ {t('AddStampRequest.stampType', 'Stamp Type')} + + + + +
+ + {params.parameters.stampType === 'text' && ( + <> +