mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-26 17:52:59 +02:00
Addition of the Add Stamp to PDF tool (#4440)
This commit is contained in:
parent
46a4a978fc
commit
13eff6b333
@ -237,34 +237,54 @@ public class StampController {
|
|||||||
|
|
||||||
PDRectangle pageSize = page.getMediaBox();
|
PDRectangle pageSize = page.getMediaBox();
|
||||||
float x, y;
|
float x, y;
|
||||||
|
|
||||||
if (overrideX >= 0 && overrideY >= 0) {
|
|
||||||
// Use override values if provided
|
|
||||||
x = overrideX;
|
|
||||||
y = overrideY;
|
|
||||||
} else {
|
|
||||||
x = calculatePositionX(pageSize, position, fontSize, font, fontSize, stampText, margin);
|
|
||||||
y =
|
|
||||||
calculatePositionY(
|
|
||||||
pageSize, position, calculateTextCapHeight(font, fontSize), margin);
|
|
||||||
}
|
|
||||||
// Split the stampText into multiple lines
|
// Split the stampText into multiple lines
|
||||||
String[] lines = stampText.split("\\\\n");
|
String[] lines = stampText.split("\\r?\\n|\\\\n");
|
||||||
|
|
||||||
// Calculate dynamic line height based on font ascent and descent
|
// Calculate dynamic line height based on font ascent and descent
|
||||||
float ascent = font.getFontDescriptor().getAscent();
|
float ascent = font.getFontDescriptor().getAscent();
|
||||||
float descent = font.getFontDescriptor().getDescent();
|
float descent = font.getFontDescriptor().getDescent();
|
||||||
float lineHeight = ((ascent - descent) / 1000) * fontSize;
|
float lineHeight = ((ascent - descent) / 1000) * fontSize;
|
||||||
|
|
||||||
|
// Compute a single pivot for the entire text block to avoid line-by-line wobble
|
||||||
|
float capHeight = calculateTextCapHeight(font, fontSize);
|
||||||
|
float blockHeight = Math.max(lineHeight, lineHeight * Math.max(1, lines.length));
|
||||||
|
float maxWidth = 0f;
|
||||||
|
for (String ln : lines) {
|
||||||
|
maxWidth = Math.max(maxWidth, calculateTextWidth(ln, font, fontSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrideX >= 0 && overrideY >= 0) {
|
||||||
|
// Use override values if provided
|
||||||
|
x = overrideX;
|
||||||
|
y = overrideY;
|
||||||
|
} else {
|
||||||
|
// Base positioning on the true multi-line block size
|
||||||
|
x = calculatePositionX(pageSize, position, maxWidth, null, 0, null, margin);
|
||||||
|
y = calculatePositionY(pageSize, position, blockHeight, margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After anchoring the block, draw from the top line downward
|
||||||
|
float adjustedX = x;
|
||||||
|
float adjustedY = y;
|
||||||
|
float pivotX = adjustedX + maxWidth / 2f;
|
||||||
|
float pivotY = adjustedY + blockHeight / 2f;
|
||||||
|
|
||||||
|
// Apply rotation about the block center at the graphics state level
|
||||||
|
contentStream.saveGraphicsState();
|
||||||
|
contentStream.transform(Matrix.getTranslateInstance(pivotX, pivotY));
|
||||||
|
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
|
||||||
|
contentStream.transform(Matrix.getTranslateInstance(-pivotX, -pivotY));
|
||||||
|
|
||||||
contentStream.beginText();
|
contentStream.beginText();
|
||||||
for (int i = 0; i < lines.length; i++) {
|
for (int i = 0; i < lines.length; i++) {
|
||||||
String line = lines[i];
|
String line = lines[i];
|
||||||
// Set the text matrix for each line with rotation
|
// Start from top line: yTop = adjustedY + blockHeight - capHeight
|
||||||
contentStream.setTextMatrix(
|
float yLine = adjustedY + blockHeight - capHeight - (i * lineHeight);
|
||||||
Matrix.getRotateInstance(Math.toRadians(rotation), x, y - (i * lineHeight)));
|
contentStream.setTextMatrix(Matrix.getTranslateInstance(adjustedX, yLine));
|
||||||
contentStream.showText(line);
|
contentStream.showText(line);
|
||||||
}
|
}
|
||||||
contentStream.endText();
|
contentStream.endText();
|
||||||
|
contentStream.restoreGraphicsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addImageStamp(
|
private void addImageStamp(
|
||||||
@ -308,9 +328,17 @@ public class StampController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
contentStream.saveGraphicsState();
|
contentStream.saveGraphicsState();
|
||||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
// Rotate and scale about the center of the image
|
||||||
|
float centerX = x + (desiredPhysicalWidth / 2f);
|
||||||
|
float centerY = y + (desiredPhysicalHeight / 2f);
|
||||||
|
contentStream.transform(Matrix.getTranslateInstance(centerX, centerY));
|
||||||
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
|
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
|
||||||
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
|
contentStream.drawImage(
|
||||||
|
xobject,
|
||||||
|
-desiredPhysicalWidth / 2f,
|
||||||
|
-desiredPhysicalHeight / 2f,
|
||||||
|
desiredPhysicalWidth,
|
||||||
|
desiredPhysicalHeight);
|
||||||
contentStream.restoreGraphicsState();
|
contentStream.restoreGraphicsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2382,6 +2382,7 @@
|
|||||||
"tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize,Customise",
|
"tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize,Customise",
|
||||||
"header": "Stamp PDF",
|
"header": "Stamp PDF",
|
||||||
"title": "Stamp PDF",
|
"title": "Stamp PDF",
|
||||||
|
"stampSetup": "Stamp Setup",
|
||||||
"stampType": "Stamp Type",
|
"stampType": "Stamp Type",
|
||||||
"stampText": "Stamp Text",
|
"stampText": "Stamp Text",
|
||||||
"stampImage": "Stamp Image",
|
"stampImage": "Stamp Image",
|
||||||
@ -2394,7 +2395,8 @@
|
|||||||
"overrideY": "Override Y Coordinate",
|
"overrideY": "Override Y Coordinate",
|
||||||
"customMargin": "Custom Margin",
|
"customMargin": "Custom Margin",
|
||||||
"customColor": "Custom Text Colour",
|
"customColor": "Custom Text Colour",
|
||||||
"submit": "Submit"
|
"submit": "Submit",
|
||||||
|
"noStampSelected": "No stamp selected. Return to Step 1."
|
||||||
},
|
},
|
||||||
"removeImagePdf": {
|
"removeImagePdf": {
|
||||||
"tags": "Remove Image,Page operations,Back end,server side"
|
"tags": "Remove Image,Page operations,Back end,server side"
|
||||||
|
@ -1470,6 +1470,7 @@
|
|||||||
"tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize",
|
"tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize",
|
||||||
"header": "Stamp PDF",
|
"header": "Stamp PDF",
|
||||||
"title": "Stamp PDF",
|
"title": "Stamp PDF",
|
||||||
|
"stampSetup": "Stamp Setup",
|
||||||
"stampType": "Stamp Type",
|
"stampType": "Stamp Type",
|
||||||
"stampText": "Stamp Text",
|
"stampText": "Stamp Text",
|
||||||
"stampImage": "Stamp Image",
|
"stampImage": "Stamp Image",
|
||||||
@ -1482,7 +1483,8 @@
|
|||||||
"overrideY": "Override Y Coordinate",
|
"overrideY": "Override Y Coordinate",
|
||||||
"customMargin": "Custom Margin",
|
"customMargin": "Custom Margin",
|
||||||
"customColor": "Custom Text Color",
|
"customColor": "Custom Text Color",
|
||||||
"submit": "Submit"
|
"submit": "Submit",
|
||||||
|
"noStampSelected": "No stamp selected. Return to Step 1."
|
||||||
},
|
},
|
||||||
"removeImagePdf": {
|
"removeImagePdf": {
|
||||||
"tags": "Remove Image,Page operations,Back end,server side"
|
"tags": "Remove Image,Page operations,Back end,server side"
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import FitText from "./FitText";
|
||||||
|
|
||||||
export interface ButtonOption<T> {
|
export interface ButtonOption<T> {
|
||||||
value: T;
|
value: T;
|
||||||
@ -13,6 +14,8 @@ interface ButtonSelectorProps<T> {
|
|||||||
label?: string;
|
label?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
|
buttonClassName?: string;
|
||||||
|
textClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ButtonSelector = <T extends string | number>({
|
const ButtonSelector = <T extends string | number>({
|
||||||
@ -22,6 +25,8 @@ const ButtonSelector = <T extends string | number>({
|
|||||||
label = undefined,
|
label = undefined,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
fullWidth = true,
|
fullWidth = true,
|
||||||
|
buttonClassName,
|
||||||
|
textClassName,
|
||||||
}: ButtonSelectorProps<T>) => {
|
}: ButtonSelectorProps<T>) => {
|
||||||
return (
|
return (
|
||||||
<Stack gap='var(--mantine-spacing-sm)'>
|
<Stack gap='var(--mantine-spacing-sm)'>
|
||||||
@ -41,6 +46,7 @@ const ButtonSelector = <T extends string | number>({
|
|||||||
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
||||||
onClick={() => onChange(option.value)}
|
onClick={() => onChange(option.value)}
|
||||||
disabled={disabled || option.disabled}
|
disabled={disabled || option.disabled}
|
||||||
|
className={buttonClassName}
|
||||||
style={{
|
style={{
|
||||||
flex: fullWidth ? 1 : undefined,
|
flex: fullWidth ? 1 : undefined,
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
@ -51,7 +57,13 @@ const ButtonSelector = <T extends string | number>({
|
|||||||
paddingBottom: '0.5rem'
|
paddingBottom: '0.5rem'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.label}
|
<FitText
|
||||||
|
text={option.label}
|
||||||
|
lines={1}
|
||||||
|
minimumFontScale={0.5}
|
||||||
|
fontSize={10}
|
||||||
|
className={textClassName}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
|
50
frontend/src/components/shared/ObscuredOverlay.tsx
Normal file
50
frontend/src/components/shared/ObscuredOverlay.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styles from './ObscuredOverlay/ObscuredOverlay.module.css';
|
||||||
|
|
||||||
|
type ObscuredOverlayProps = {
|
||||||
|
obscured: boolean;
|
||||||
|
overlayMessage?: React.ReactNode;
|
||||||
|
buttonText?: string;
|
||||||
|
onButtonClick?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
// Optional border radius for the overlay container. If undefined, no radius is applied.
|
||||||
|
borderRadius?: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ObscuredOverlay({
|
||||||
|
obscured,
|
||||||
|
overlayMessage,
|
||||||
|
buttonText,
|
||||||
|
onButtonClick,
|
||||||
|
children,
|
||||||
|
borderRadius,
|
||||||
|
}: ObscuredOverlayProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{children}
|
||||||
|
{obscured && (
|
||||||
|
<div
|
||||||
|
className={styles.overlay}
|
||||||
|
style={{
|
||||||
|
...(borderRadius !== undefined ? { borderRadius } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.overlayContent}>
|
||||||
|
{overlayMessage && (
|
||||||
|
<div className={styles.overlayMessage}>
|
||||||
|
{overlayMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{buttonText && onButtonClick && (
|
||||||
|
<button type="button" onClick={onButtonClick} className={styles.overlayButton}>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(16, 18, 27, 0.55);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayMessage {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayButton {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
195
frontend/src/components/tools/addStamp/StampPreview.module.css
Normal file
195
frontend/src/components/tools/addStamp/StampPreview.module.css
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
/* 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: 2px;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 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.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
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;
|
||||||
|
}
|
343
frontend/src/components/tools/addStamp/StampPreview.tsx
Normal file
343
frontend/src/components/tools/addStamp/StampPreview.tsx
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
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, getAlphabetPreviewScale } from './StampPreviewUtils';
|
||||||
|
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}:${file.size}:${file.lastModified}: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]);
|
||||||
|
|
||||||
|
// Keep center fixed when scaling via slider (or any fontSize changes)
|
||||||
|
const prevDimsRef = useRef<{ fontSize: number; widthPx: number; heightPx: number; leftPx: number; bottomPx: number } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const itemStyle = style.item as any;
|
||||||
|
if (!itemStyle || containerSize.width <= 0 || containerSize.height <= 0) return;
|
||||||
|
|
||||||
|
const parse = (v: any) => parseFloat(String(v).replace('px', '')) || 0;
|
||||||
|
const leftPx = parse(itemStyle.left);
|
||||||
|
const bottomPx = parse(itemStyle.bottom);
|
||||||
|
const widthPx = parse(itemStyle.width);
|
||||||
|
const heightPx = parse(itemStyle.height);
|
||||||
|
|
||||||
|
const prev = prevDimsRef.current;
|
||||||
|
const hasOverrides = parameters.overrideX >= 0 && parameters.overrideY >= 0;
|
||||||
|
const canAdjust = hasOverrides && !showQuickGrid;
|
||||||
|
if (
|
||||||
|
prev &&
|
||||||
|
canAdjust &&
|
||||||
|
parameters.fontSize !== prev.fontSize &&
|
||||||
|
prev.widthPx > 0 &&
|
||||||
|
prev.heightPx > 0 &&
|
||||||
|
widthPx > 0 &&
|
||||||
|
heightPx > 0
|
||||||
|
) {
|
||||||
|
const centerX = prev.leftPx + prev.widthPx / 2;
|
||||||
|
const centerY = prev.bottomPx + prev.heightPx / 2;
|
||||||
|
const newLeftPx = centerX - widthPx / 2;
|
||||||
|
const newBottomPx = centerY - heightPx / 2;
|
||||||
|
|
||||||
|
const widthPts = pageSize?.widthPts ?? 595.28;
|
||||||
|
const heightPts = pageSize?.heightPts ?? 841.89;
|
||||||
|
const scaleX = containerSize.width / widthPts;
|
||||||
|
const scaleY = containerSize.height / heightPts;
|
||||||
|
const newLeftPts = Math.max(0, Math.min(containerSize.width, newLeftPx)) / scaleX;
|
||||||
|
const newBottomPts = Math.max(0, Math.min(containerSize.height, newBottomPx)) / scaleY;
|
||||||
|
onParameterChange('overrideX', newLeftPts as any);
|
||||||
|
onParameterChange('overrideY', newBottomPts as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevDimsRef.current = { fontSize: parameters.fontSize, widthPx, heightPx, leftPx, bottomPx };
|
||||||
|
}, [parameters.fontSize, style.item, containerSize, pageSize, showQuickGrid, parameters.overrideX, parameters.overrideY, onParameterChange]);
|
||||||
|
|
||||||
|
// 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 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) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={styles.textLine}
|
||||||
|
style={{
|
||||||
|
fontFamily: getFontFamily(parameters.alphabet),
|
||||||
|
fontSize: `${Math.max(1, (parameters.fontSize * getAlphabetPreviewScale(parameters.alphabet)) / 2)}px`,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line || '\u00A0'}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
235
frontend/src/components/tools/addStamp/StampPreviewUtils.ts
Normal file
235
frontend/src/components/tools/addStamp/StampPreviewUtils.ts
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
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 };
|
||||||
|
|
||||||
|
// Unified per-alphabet preview adjustments
|
||||||
|
export type Alphabet = 'roman' | 'arabic' | 'japanese' | 'korean' | 'chinese' | 'thai';
|
||||||
|
export type AlphabetTweaks = { scale: number; rowOffsetRem: [number, number, number]; lineHeight: number; capHeightRatio: number; defaultFontSize: number };
|
||||||
|
export const ALPHABET_PREVIEW_TWEAKS: Record<Alphabet, AlphabetTweaks> = {
|
||||||
|
// [top, middle, bottom] row offsets in rem
|
||||||
|
roman: { scale: 1.0/1.18, rowOffsetRem: [0, 1, 2.2], lineHeight: 1.28, capHeightRatio: 0.70, defaultFontSize: 80 },
|
||||||
|
arabic: { scale: 1.2, rowOffsetRem: [0, 1.5, 2.5], lineHeight: 1, capHeightRatio: 0.68, defaultFontSize: 80 },
|
||||||
|
japanese: { scale: 1/1.2, rowOffsetRem: [-0.1, 1, 2], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 80 },
|
||||||
|
korean: { scale: 1.0/1.05, rowOffsetRem: [-0.2, 0.5, 1.4], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 80 },
|
||||||
|
chinese: { scale: 1/1.2, rowOffsetRem: [0, 2, 2.8], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 30 }, // temporary default font size so that it fits on the PDF
|
||||||
|
thai: { scale: 1/1.2, rowOffsetRem: [-1, 0, .8], lineHeight: 1, capHeightRatio: 0.66, defaultFontSize: 80 },
|
||||||
|
};
|
||||||
|
export const getAlphabetPreviewScale = (alphabet: string): number => (ALPHABET_PREVIEW_TWEAKS as any)[alphabet]?.scale ?? 1.0;
|
||||||
|
|
||||||
|
export const getDefaultFontSizeForAlphabet = (alphabet: string): number => {
|
||||||
|
return (ALPHABET_PREVIEW_TWEAKS as any)[alphabet]?.defaultFontSize ?? 80;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 * getAlphabetPreviewScale(parameters.alphabet);
|
||||||
|
let widthPtsContent = heightPtsContent;
|
||||||
|
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 * ((ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.capHeightRatio ?? 0.70)
|
||||||
|
: 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();
|
||||||
|
let xPx = xPts * scaleX;
|
||||||
|
let yPx = yPts * scaleY;
|
||||||
|
if (parameters.stampType === 'text') {
|
||||||
|
try {
|
||||||
|
const rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
|
||||||
|
const rowIndex = Math.floor((position - 1) / 3); // 0 top, 1 middle, 2 bottom
|
||||||
|
const offsets = (ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.rowOffsetRem ?? [0, 0, 0];
|
||||||
|
const offsetRem = offsets[rowIndex] ?? 0;
|
||||||
|
yPx += offsetRem * rootFontSizePx;
|
||||||
|
} catch (e) {
|
||||||
|
// no-op
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const widthPx = widthPtsContent * scaleX;
|
||||||
|
const heightPx = heightPtsContent * scaleY;
|
||||||
|
|
||||||
|
xPx = Math.max(0, Math.min(xPx, pageWidthPx - widthPx));
|
||||||
|
yPx = Math.max(0, Math.min(yPx, pageHeightPx - heightPx));
|
||||||
|
|
||||||
|
const opacity = Math.max(0, Math.min(1, parameters.opacity / 100));
|
||||||
|
const displayOpacity = opacity;
|
||||||
|
|
||||||
|
let alignItems: 'flex-start' | 'center' | 'flex-end' = 'flex-start';
|
||||||
|
if (parameters.stampType === 'text') {
|
||||||
|
const colIndex = position % 3; // 1: left, 2: center, 0: right
|
||||||
|
switch (colIndex) {
|
||||||
|
case 2: // center column
|
||||||
|
alignItems = 'center';
|
||||||
|
break;
|
||||||
|
case 0: // right column
|
||||||
|
alignItems = 'flex-end';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
alignItems = 'flex-start';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'center center',
|
||||||
|
color: parameters.customColor,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
lineHeight: (ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.lineHeight ?? 1,
|
||||||
|
alignItems,
|
||||||
|
cursor: showQuickGrid ? 'default' : 'move',
|
||||||
|
pointerEvents: showQuickGrid ? 'none' : 'auto',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
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', parameters.customMargin || 'medium');
|
||||||
|
formData.append('position', String(parameters.position));
|
||||||
|
const effectiveFontSize = 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: 'addStamp',
|
||||||
|
endpoint: '/api/v1/misc/add-stamp',
|
||||||
|
defaultParameters,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const useAddStampOperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useToolOperation<AddStampParameters>({
|
||||||
|
...addStampOperationConfig,
|
||||||
|
getErrorMessage: createStandardErrorHandler(
|
||||||
|
t('AddStampRequest.error.failed', 'An error occurred while adding stamp to the PDF.')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
|||||||
|
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;
|
||||||
|
_activePill: 'fontSize' | 'rotation' | 'opacity';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultParameters: AddStampParameters = {
|
||||||
|
stampType: 'text',
|
||||||
|
stampText: '',
|
||||||
|
alphabet: 'roman',
|
||||||
|
fontSize: 80,
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 50,
|
||||||
|
position: 5,
|
||||||
|
overrideX: -1,
|
||||||
|
overrideY: -1,
|
||||||
|
customMargin: 'medium',
|
||||||
|
customColor: '#d3d3d3',
|
||||||
|
pageNumbers: '1',
|
||||||
|
_activePill: 'fontSize',
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -15,6 +15,8 @@ export interface ReviewToolStepProps<TParams = unknown> {
|
|||||||
title?: string;
|
title?: string;
|
||||||
onFileClick?: (file: File) => void;
|
onFileClick?: (file: File) => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
onCollapsedClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewStepContent<TParams = unknown>({
|
function ReviewStepContent<TParams = unknown>({
|
||||||
@ -111,6 +113,8 @@ export function createReviewToolStep<TParams = unknown>(
|
|||||||
t("review", "Review"),
|
t("review", "Review"),
|
||||||
{
|
{
|
||||||
isVisible: props.isVisible,
|
isVisible: props.isVisible,
|
||||||
|
isCollapsed: props.isCollapsed,
|
||||||
|
onCollapsedClick: props.onCollapsedClick,
|
||||||
_excludeFromCount: true,
|
_excludeFromCount: true,
|
||||||
_noPadding: true,
|
_noPadding: true,
|
||||||
},
|
},
|
||||||
|
@ -113,4 +113,4 @@ export function createToolFlow(config: ToolFlowConfig) {
|
|||||||
</ToolStepProvider>
|
</ToolStepProvider>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -13,6 +13,7 @@ import RemovePages from "../tools/RemovePages";
|
|||||||
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";
|
||||||
@ -32,6 +33,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";
|
||||||
@ -213,10 +215,13 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
addStamp: {
|
addStamp: {
|
||||||
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.addStamp.title", "Add Stamp to PDF"),
|
name: t("home.addStamp.title", "Add Stamp to PDF"),
|
||||||
component: null,
|
component: AddStamp,
|
||||||
description: t("home.addStamp.desc", "Add text or add image stamps at set locations"),
|
description: t("home.addStamp.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" />,
|
||||||
|
@ -191,6 +191,8 @@
|
|||||||
--checkbox-checked-bg: #3FAFFF;
|
--checkbox-checked-bg: #3FAFFF;
|
||||||
--checkbox-tick: #FFFFFF;
|
--checkbox-tick: #FFFFFF;
|
||||||
|
|
||||||
|
--information-text-bg: #eaeaea;
|
||||||
|
--information-text-color: #5e5e5e;
|
||||||
/* Bulk selection panel specific colors (light mode) */
|
/* Bulk selection panel specific colors (light mode) */
|
||||||
--bulk-panel-bg: #ffffff; /* white background for parent container */
|
--bulk-panel-bg: #ffffff; /* white background for parent container */
|
||||||
--bulk-card-bg: #ffffff; /* white background for cards */
|
--bulk-card-bg: #ffffff; /* white background for cards */
|
||||||
@ -351,6 +353,9 @@
|
|||||||
/* Tool panel search bar background colors (dark mode) */
|
/* Tool panel search bar background colors (dark mode) */
|
||||||
--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;
|
||||||
|
|
||||||
/* Bulk selection panel specific colors (dark mode) */
|
/* Bulk selection panel specific colors (dark mode) */
|
||||||
--bulk-panel-bg: var(--bg-raised); /* dark background for parent container */
|
--bulk-panel-bg: var(--bg-raised); /* dark background for parent container */
|
||||||
|
413
frontend/src/tools/AddStamp.tsx
Normal file
413
frontend/src/tools/AddStamp.tsx
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
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, Divider } 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 ButtonSelector from "../components/shared/ButtonSelector";
|
||||||
|
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
|
||||||
|
import ObscuredOverlay from "../components/shared/ObscuredOverlay";
|
||||||
|
import { getDefaultFontSizeForAlphabet } from "../components/tools/addStamp/StampPreviewUtils";
|
||||||
|
|
||||||
|
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
enum AddStampStep {
|
||||||
|
NONE = 'none',
|
||||||
|
STAMP_SETUP = 'stampSetup',
|
||||||
|
POSITION_FORMATTING = 'positionFormatting'
|
||||||
|
}
|
||||||
|
|
||||||
|
const accordion = useAccordionSteps<AddStampStep>({
|
||||||
|
noneValue: AddStampStep.NONE,
|
||||||
|
initialStep: AddStampStep.STAMP_SETUP,
|
||||||
|
stateConditions: {
|
||||||
|
hasFiles,
|
||||||
|
hasResults
|
||||||
|
},
|
||||||
|
afterResults: () => {
|
||||||
|
operation.resetResults();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSteps = () => {
|
||||||
|
const steps: any[] = [];
|
||||||
|
|
||||||
|
// Step 1: Stamp Setup
|
||||||
|
steps.push({
|
||||||
|
title: t("AddStampRequest.stampSetup", "Stamp Setup"),
|
||||||
|
isCollapsed: accordion.getCollapsedState(AddStampStep.STAMP_SETUP),
|
||||||
|
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.STAMP_SETUP),
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<Divider/>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
|
||||||
|
<ButtonSelector
|
||||||
|
value={params.parameters.stampType}
|
||||||
|
onChange={(v: 'text' | 'image') => params.updateParameter('stampType', v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'text', label: t('watermark.type.1', 'Text') },
|
||||||
|
{ value: 'image', label: t('watermark.type.2', 'Image') },
|
||||||
|
]}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
buttonClassName={styles.modeToggleButton}
|
||||||
|
textClassName={styles.modeToggleButtonText}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t('AddStampRequest.alphabet', 'Alphabet')}
|
||||||
|
value={params.parameters.alphabet}
|
||||||
|
onChange={(v) => {
|
||||||
|
const nextAlphabet = (v as any) || 'roman';
|
||||||
|
params.updateParameter('alphabet', nextAlphabet);
|
||||||
|
const nextDefault = getDefaultFontSizeForAlphabet(nextAlphabet);
|
||||||
|
params.updateParameter('fontSize', nextDefault);
|
||||||
|
}}
|
||||||
|
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 && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(params.parameters.stampImage)}
|
||||||
|
alt="Selected stamp image"
|
||||||
|
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{params.parameters.stampImage.name}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Formatting & Position
|
||||||
|
steps.push({
|
||||||
|
title: t("AddStampRequest.positionAndFormatting", "Position & Formatting"),
|
||||||
|
isCollapsed: accordion.getCollapsedState(AddStampStep.POSITION_FORMATTING),
|
||||||
|
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.POSITION_FORMATTING),
|
||||||
|
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' && (
|
||||||
|
<ButtonSelector
|
||||||
|
value={quickPositionModeSelected ? 'quick' : 'custom'}
|
||||||
|
onChange={(v: 'quick' | 'custom') => {
|
||||||
|
const isQuick = v === 'quick';
|
||||||
|
setQuickPositionModeSelected(isQuick);
|
||||||
|
setCustomPositionModeSelected(!isQuick);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'quick', label: t('quickPosition', 'Quick Position') },
|
||||||
|
{ value: 'custom', label: t('customPosition', 'Custom Position') },
|
||||||
|
]}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
buttonClassName={styles.modeToggleButton}
|
||||||
|
textClassName={styles.modeToggleButtonText}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{params.parameters.stampType === 'image' && customPositionModeSelected && (
|
||||||
|
<div className={styles.informationContainer}>
|
||||||
|
<Text className={styles.informationText}>{t('AddStampRequest.customPosition', 'Drag the stamp to the desired location in the preview window.')}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{params.parameters.stampType === 'image' && !customPositionModeSelected && (
|
||||||
|
<div className={styles.informationContainer}>
|
||||||
|
<Text className={styles.informationText}>{t('AddStampRequest.quickPosition', 'Select a position on the page to place the stamp.')}</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._activePill === 'rotation' ? 'filled' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => params.updateParameter('_activePill', 'rotation')}
|
||||||
|
>
|
||||||
|
<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._activePill === 'opacity' ? 'filled' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => params.updateParameter('_activePill', 'opacity')}
|
||||||
|
>
|
||||||
|
<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._activePill === 'fontSize' ? 'filled' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => params.updateParameter('_activePill', 'fontSize')}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="zoom-in-map-rounded" width="1.1rem" height="1.1rem" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Single slider bound to selected pill */}
|
||||||
|
{params.parameters._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._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._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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Margin selection appears when using quick grid (and for text stamps) */}
|
||||||
|
{(params.parameters.stampType === 'text' || (params.parameters.stampType === 'image' && quickPositionModeSelected)) && (
|
||||||
|
<Select
|
||||||
|
label={t('AddStampRequest.margin', 'Margin')}
|
||||||
|
value={params.parameters.customMargin}
|
||||||
|
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
|
||||||
|
data={[
|
||||||
|
{ value: 'small', label: t('margin.small', 'Small') },
|
||||||
|
{ value: 'medium', label: t('margin.medium', 'Medium') },
|
||||||
|
{ value: 'large', label: t('margin.large', 'Large') },
|
||||||
|
{ value: 'x-large', label: t('margin.xLarge', 'Extra Large') },
|
||||||
|
]}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Unified preview wrapped with obscured overlay if no stamp selected in step 4 */}
|
||||||
|
<ObscuredOverlay
|
||||||
|
obscured={
|
||||||
|
accordion.currentStep === AddStampStep.POSITION_FORMATTING &&
|
||||||
|
((params.parameters.stampType === 'text' && params.parameters.stampText.trim().length === 0) ||
|
||||||
|
(params.parameters.stampType === 'image' && !params.parameters.stampImage))
|
||||||
|
}
|
||||||
|
overlayMessage={
|
||||||
|
<Text size="sm" c="white" fw={600}>
|
||||||
|
{t('AddStampRequest.noStampSelected', 'No stamp selected. Return to Step 1.')}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StampPreview
|
||||||
|
parameters={params.parameters}
|
||||||
|
onParameterChange={params.updateParameter}
|
||||||
|
file={selectedFiles[0] || null}
|
||||||
|
showQuickGrid={params.parameters.stampType === 'text' ? true : quickPositionModeSelected}
|
||||||
|
/>
|
||||||
|
</ObscuredOverlay>
|
||||||
|
</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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
AddStamp.tool = () => useAddStampOperation;
|
||||||
|
|
||||||
|
export default AddStamp as ToolComponent;
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user