Addition of the Add Stamp to PDF tool

This commit is contained in:
EthanHealy01 2025-09-11 19:27:09 +01:00
parent f3fd85d777
commit 60a433610e
8 changed files with 1245 additions and 2 deletions

View File

@ -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;
}

View File

@ -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: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
file?: File | null;
showQuickGrid?: boolean;
};
export default function StampPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) {
const containerRef = useRef<HTMLDivElement>(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<string | null>(null);
const { requestThumbnail } = useThumbnailGeneration();
const [hoverTile, setHoverTile] = useState<number | null>(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 (
<div>
<div className={styles.previewHeader}>
<div className={styles.divider} />
<div className={styles.previewLabel}>Preview Stamp</div>
</div>
<div
ref={containerRef}
className={`${styles.container} ${styles.containerBorder} ${pageThumbnail ? styles.containerWithThumbnail : styles.containerWithoutThumbnail}`}
style={style.container as React.CSSProperties}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{pageThumbnail && (
<img
src={pageThumbnail}
alt="page preview"
className={styles.pageThumbnail}
draggable={false}
/>
)}
{parameters.stampType === 'text' && (
<div
className={`${styles.stampItem} ${styles.stampItemGridMode}`}
style={style.item as React.CSSProperties}
>
{(parameters.stampText || '').split('\n').map((line, idx) => (
<FitText
key={idx}
text={line || '\u00A0'}
lines={1}
minimumFontScale={0.5}
fontSize={parameters.fontSize}
className={styles.textLine}
style={{
fontFamily: getFontFamily(parameters.alphabet),
}}
as="span"
/>
))}
{itemHandles}
</div>
)}
{parameters.stampType === 'image' && imageMeta && (
<div
className={`${styles.stampItem} ${showQuickGrid ? styles.stampItemGridMode : styles.stampItemDraggable}`}
style={style.item as React.CSSProperties}
onPointerDown={(e) => handlePointerDown(e, 'move')}
>
<img
src={imageMeta.url}
alt="stamp preview"
className={styles.stampImage}
/>
{itemHandles}
</div>
)}
{/* Quick position overlay grid */}
{showQuickGrid && (
<div className={styles.quickGrid}>
{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 (
<button
key={idx}
type="button"
className={`${styles.gridTile} ${selected || hoverTile === idx ? styles.gridTileSelected : ''} ${hoverTile === idx ? styles.gridTileHovered : ''}`}
onClick={() => {
// Clear overrides to use grid positioning and set position
onParameterChange('overrideX', -1 as any);
onParameterChange('overrideY', -1 as any);
onParameterChange('position', idx as any);
}}
onMouseEnter={() => setHoverTile(idx)}
onMouseLeave={() => setHoverTile(null)}
>
{idx}
</button>
);
})}
</div>
)}
</div>
<div className={styles.previewDisclaimer}>
Preview is approximate. Final output may vary due to PDF font metrics.
</div>
</div>
);
}

View File

@ -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<AddStampParameters['customMargin'], number> = {
'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',
}
};
}

View File

@ -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<AddStampParameters>({
...addStampOperationConfig,
filePrefix: t('stamp.filenamePrefix', 'stamped') + '_',
getErrorMessage: createStandardErrorHandler(
t('AddStampRequest.error.failed', 'An error occurred while adding stamp to the PDF.')
),
});
};

View File

@ -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<AddStampParameters>;
export const useAddStampParameters = (): AddStampParametersHook => {
return useBaseParameters<AddStampParameters>({
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;
},
});
};

View File

@ -11,6 +11,7 @@ import ChangePermissions from "../tools/ChangePermissions";
import RemovePassword from "../tools/RemovePassword"; import RemovePassword from "../tools/RemovePassword";
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy"; import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
import AddWatermark from "../tools/AddWatermark"; import AddWatermark from "../tools/AddWatermark";
import AddStamp from "../tools/AddStamp";
import Merge from '../tools/Merge'; import Merge from '../tools/Merge';
import Repair from "../tools/Repair"; import Repair from "../tools/Repair";
import AutoRename from "../tools/AutoRename"; import AutoRename from "../tools/AutoRename";
@ -25,6 +26,7 @@ import { removePasswordOperationConfig } from "../hooks/tools/removePassword/use
import { sanitizeOperationConfig } from "../hooks/tools/sanitize/useSanitizeOperation"; import { sanitizeOperationConfig } from "../hooks/tools/sanitize/useSanitizeOperation";
import { repairOperationConfig } from "../hooks/tools/repair/useRepairOperation"; import { repairOperationConfig } from "../hooks/tools/repair/useRepairOperation";
import { addWatermarkOperationConfig } from "../hooks/tools/addWatermark/useAddWatermarkOperation"; import { addWatermarkOperationConfig } from "../hooks/tools/addWatermark/useAddWatermarkOperation";
import { addStampOperationConfig } from "../components/tools/addStamp/useAddStampOperation";
import { unlockPdfFormsOperationConfig } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation"; import { unlockPdfFormsOperationConfig } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
import { singleLargePageOperationConfig } from "../hooks/tools/singleLargePage/useSingleLargePageOperation"; import { singleLargePageOperationConfig } from "../hooks/tools/singleLargePage/useSingleLargePageOperation";
import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation"; import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
@ -189,10 +191,13 @@ export function useFlatToolRegistry(): ToolRegistry {
"add-stamp": { "add-stamp": {
icon: <LocalIcon icon="approval-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="approval-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.AddStampRequest.title", "Add Stamp to PDF"), 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"), description: t("home.AddStampRequest.desc", "Add text or add image stamps at set locations"),
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY, subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["add-stamp"],
operationConfig: addStampOperationConfig,
}, },
sanitize: { sanitize: {
icon: <LocalIcon icon="cleaning-services-outline-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="cleaning-services-outline-rounded" width="1.5rem" height="1.5rem" />,

View File

@ -178,6 +178,9 @@
--checkbox-border: #2F83BF; --checkbox-border: #2F83BF;
--checkbox-checked-bg: #3FAFFF; --checkbox-checked-bg: #3FAFFF;
--checkbox-tick: #FFFFFF; --checkbox-tick: #FFFFFF;
--information-text-bg: #eaeaea;
--information-text-color: #5e5e5e;
} }
[data-mantine-color-scheme="dark"] { [data-mantine-color-scheme="dark"] {
@ -322,6 +325,8 @@
--tool-panel-search-bg: #1F2329; --tool-panel-search-bg: #1F2329;
--tool-panel-search-border-bottom: #4B525A; --tool-panel-search-border-bottom: #4B525A;
--information-text-bg: #292e34;
--information-text-color: #ececec;
} }
/* Dropzone drop state styling */ /* Dropzone drop state styling */

View File

@ -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: (
<Stack gap="md">
<TextInput
label={t('pageSelectionPrompt', 'Page Selection (e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)')}
value={params.parameters.pageNumbers}
onChange={(e) => params.updateParameter('pageNumbers', e.currentTarget.value)}
disabled={endpointLoading}
/>
</Stack>
),
});
// 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: (
<Stack gap="md" justify="space-between" flex={1}>
<div>
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
<Group className={styles.modeToggleGroup} grow>
<Button
variant={params.parameters.stampType === 'text' ? 'filled' : 'outline'}
className={styles.modeToggleButton}
onClick={() => params.updateParameter('stampType', 'text')}
disabled={endpointLoading}
>
{t('watermark.type.1', 'Text')}
</Button>
<Button
variant={params.parameters.stampType === 'image' ? 'filled' : 'outline'}
className={styles.modeToggleButton}
onClick={() => params.updateParameter('stampType', 'image')}
disabled={endpointLoading}
>
{t('watermark.type.2', 'Image')}
</Button>
</Group>
</div>
{params.parameters.stampType === 'text' && (
<>
<Textarea
label={t('AddStampRequest.stampText', 'Stamp Text')}
value={params.parameters.stampText}
onChange={(e) => params.updateParameter('stampText', e.currentTarget.value)}
autosize
minRows={2}
disabled={endpointLoading}
/>
<Group justify="flex-start">
<Button
size="xs"
onClick={() => {
if ((params.parameters.stampText || '').trim().length === 0) return;
setTextConfirmed(true);
setCollapsedType(true);
setCollapsedPageSelection(true);
setCollapsedFormatting(false);
}}
disabled={(params.parameters.stampText || '').trim().length === 0}
>
{textConfirmed ? t('confirmed', 'Confirmed') : t('confirm', 'Confirm')}
</Button>
</Group>
<Select
label={t('AddStampRequest.alphabet', 'Alphabet')}
value={params.parameters.alphabet}
onChange={(v) => params.updateParameter('alphabet', (v as any) || 'roman')}
data={[
{ value: 'roman', label: 'Roman' },
{ value: 'arabic', label: 'العربية' },
{ value: 'japanese', label: '日本語' },
{ value: 'korean', label: '한국어' },
{ value: 'chinese', label: '简体中文' },
{ value: 'thai', label: 'ไทย' },
]}
disabled={endpointLoading}
/>
</>
)}
{params.parameters.stampType === 'image' && (
<Stack gap="xs">
<input
type="file"
accept=".png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.webp"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) params.updateParameter('stampImage', file);
}}
disabled={endpointLoading}
style={{ display: 'none' }}
id="stamp-image-input"
/>
<Button
size="xs"
component="label"
htmlFor="stamp-image-input"
disabled={endpointLoading}
>
{t('chooseFile', 'Choose File')}
</Button>
{params.parameters.stampImage && (
<Text size="xs" c="dimmed">
{params.parameters.stampImage.name}
</Text>
)}
</Stack>
)}
</Stack>
),
});
// Step 3: Formatting & Position
steps.push({
title: t("AddStampRequest.positionAndFormatting", "Position & Formatting"),
isCollapsed: hasResults ? true : collapsedFormatting,
onCollapsedClick: hasResults ? () => operation.resetResults() : () => {
// Prevent collapsing until text confirmed
if (params.parameters.stampType === 'text' && !textConfirmed) return;
setCollapsedFormatting(!collapsedFormatting);
if (collapsedFormatting) setCollapsedType(true);
},
isVisible: hasFiles || hasResults,
content: (
<Stack gap="md" justify="space-between">
{/* Mode toggle: Quick grid vs Custom drag - only show for image stamps */}
{params.parameters.stampType === 'image' && (
<Group className={styles.modeToggleGroup} grow>
<Button
variant={quickPositionModeSelected ? 'filled' : 'outline'}
className={styles.modeToggleButton}
onClick={() => {
setQuickPositionModeSelected(true);
setCustomPositionModeSelected(false);
}}
>
<FitText
text={t('quickPosition', 'Quick Position')}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={styles.modeToggleButtonText}
/>
</Button>
<Button
variant={customPositionModeSelected ? 'filled' : 'outline'}
className={styles.modeToggleButton}
onClick={() => {
setQuickPositionModeSelected(false);
setCustomPositionModeSelected(true);
}}
>
<FitText
text={t('customPosition', 'Custom Position')}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={styles.modeToggleButtonText}
/>
</Button>
</Group>
)}
{params.parameters.stampType === 'image' && customPositionModeSelected && (
<div className={styles.informationContainer}>
<Text className={styles.informationText}>{t('customPosition', 'Drag the stamp to the desired location in the preview window.')}</Text>
</div>
)}
{/* Icon pill buttons row */}
<div className="flex justify-between gap-[0.5rem]">
<Tooltip content={t('AddStampRequest.rotation', 'Rotation')} position="top">
<Button
variant={(params.parameters as any)._activePill === 'rotation' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => params.updateParameter('_activePill' as any, 'rotation' as any)}
>
<LocalIcon icon="rotate-right-rounded" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
<Tooltip content={t('AddStampRequest.opacity', 'Opacity')} position="top">
<Button
variant={(params.parameters as any)._activePill === 'opacity' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => params.updateParameter('_activePill' as any, 'opacity' as any)}
>
<LocalIcon icon="opacity" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
<Tooltip content={params.parameters.stampType === 'image' ? t('AddStampRequest.imageSize', 'Image Size') : t('AddStampRequest.fontSize', 'Font Size')} position="top">
<Button
variant={(params.parameters as any)._activePill === 'fontSize' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => params.updateParameter('_activePill' as any, 'fontSize' as any)}
>
<LocalIcon icon="zoom-in-map-rounded" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
</div>
{/* Single slider bound to selected pill */}
{(params.parameters as any)._activePill === 'fontSize' && (
<Stack gap="xs">
<Text className={styles.labelText}>
{params.parameters.stampType === 'image'
? t('AddStampRequest.imageSize', 'Image Size')
: t('AddStampRequest.fontSize', 'Font Size')
}
</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={params.parameters.fontSize}
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 1)}
min={1}
max={400}
step={1}
size="sm"
className={styles.numberInput}
disabled={endpointLoading}
/>
<Slider
value={params.parameters.fontSize}
onChange={(v) => params.updateParameter('fontSize', v as number)}
min={1}
max={400}
step={1}
className={styles.slider}
/>
</Group>
</Stack>
)}
{(params.parameters as any)._activePill === 'rotation' && (
<Stack gap="xs">
<Text className={styles.labelText}>{t('AddStampRequest.rotation', 'Rotation')}</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={params.parameters.rotation}
onChange={(v) => params.updateParameter('rotation', typeof v === 'number' ? v : 0)}
min={-180}
max={180}
step={1}
size="sm"
className={styles.numberInput}
hideControls
disabled={endpointLoading}
/>
<Slider
value={params.parameters.rotation}
onChange={(v) => params.updateParameter('rotation', v as number)}
min={-180}
max={180}
step={1}
className={styles.sliderWide}
/>
</Group>
</Stack>
)}
{(params.parameters as any)._activePill === 'opacity' && (
<Stack gap="xs">
<Text className={styles.labelText}>{t('AddStampRequest.opacity', 'Opacity')}</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={params.parameters.opacity}
onChange={(v) => params.updateParameter('opacity', typeof v === 'number' ? v : 0)}
min={0}
max={100}
step={1}
size="sm"
className={styles.numberInput}
disabled={endpointLoading}
/>
<Slider
value={params.parameters.opacity}
onChange={(v) => params.updateParameter('opacity', v as number)}
min={0}
max={100}
step={1}
className={styles.slider}
/>
</Group>
</Stack>
)}
{params.parameters.stampType !== 'image' && (
<ColorInput
label={t('AddStampRequest.customColor', 'Custom Text Color')}
value={params.parameters.customColor}
onChange={(value) => params.updateParameter('customColor', value)}
format="hex"
disabled={endpointLoading}
/>
)}
{/* Unified preview; when in quick mode, overlay grid inside preview */}
<StampPreview
parameters={params.parameters}
onParameterChange={params.updateParameter}
file={selectedFiles[0] || null}
showQuickGrid={params.parameters.stampType === 'text' ? true : quickPositionModeSelected}
/>
</Stack>
),
});
return steps;
};
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasResults,
},
steps: getSteps(),
executeButton: {
text: t('AddStampRequest.submit', 'Add Stamp'),
isVisible: !hasResults,
loadingText: t('loading'),
onClick: handleExecute,
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: operation,
title: t('AddStampRequest.results.title', 'Stamp Results'),
onFileClick: (file) => onPreviewFile?.(file),
onUndo: async () => {
await operation.undoOperation();
onPreviewFile?.(null);
},
},
forceStepNumbers: true,
});
};
AddStamp.tool = () => useAddStampOperation;
export default AddStamp as ToolComponent;