mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-26 17:52:59 +02:00
add page numbers
This commit is contained in:
parent
21b1428ab5
commit
77d8a1c59a
@ -10,16 +10,34 @@
|
|||||||
"selectText": {
|
"selectText": {
|
||||||
"1": "Select PDF file:",
|
"1": "Select PDF file:",
|
||||||
"2": "Margin Size",
|
"2": "Margin Size",
|
||||||
"3": "Position",
|
"3": "Position Selection",
|
||||||
"4": "Starting Number",
|
"4": "Starting Number",
|
||||||
"5": "Pages to Number",
|
"5": "Pages to Number",
|
||||||
"6": "Custom Text"
|
"6": "Custom Text Format"
|
||||||
},
|
},
|
||||||
"customTextDesc": "Custom Text",
|
"customTextDesc": "Custom Text",
|
||||||
"numberPagesDesc": "Which pages to number, default 'all', also accepts 1-5 or 2,5,9 etc",
|
"numberPagesDesc": "e.g., 1,3,5-8 or leave blank for all pages",
|
||||||
"customNumberDesc": "Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}",
|
"customNumberDesc": "e.g., \"Page {n}\" or leave blank for just numbers",
|
||||||
"submit": "Add Page Numbers"
|
"submit": "Add Page Numbers",
|
||||||
|
"configuration": "Configuration",
|
||||||
|
"customize": "Customize Appearance",
|
||||||
|
"pagesAndStarting": "Pages & Starting Number",
|
||||||
|
"positionAndPages": "Position & Pages",
|
||||||
|
"error": {
|
||||||
|
"failed": "Add page numbers operation failed"
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"title": "Page Number Results"
|
||||||
|
},
|
||||||
|
"preview": "Position Selection",
|
||||||
|
"previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics."
|
||||||
},
|
},
|
||||||
|
"pageSelectionPrompt": "Specify which pages to add numbers to. Examples: \"1,3,5\" for specific pages, \"1-5\" for ranges, \"2n\" for even pages, or leave blank for all pages.",
|
||||||
|
"startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.",
|
||||||
|
"marginTooltip": "Distance between the page number and the edge of the page.",
|
||||||
|
"fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.",
|
||||||
|
"fontTypeTooltip": "Font family for the page numbers. Choose based on your document style.",
|
||||||
|
"customTextTooltip": "Optional custom format for page numbers. Use {n} as placeholder for the number. Example: \"Page {n}\" will show \"Page 1\", \"Page 2\", etc.",
|
||||||
"pdfPrompt": "Select PDF(s)",
|
"pdfPrompt": "Select PDF(s)",
|
||||||
"multiPdfPrompt": "Select PDFs (2+)",
|
"multiPdfPrompt": "Select PDFs (2+)",
|
||||||
"multiPdfDropPrompt": "Select (or drag & drop) all PDFs you require",
|
"multiPdfDropPrompt": "Select (or drag & drop) all PDFs you require",
|
||||||
|
@ -0,0 +1,101 @@
|
|||||||
|
/* PageNumberPreview.module.css - EXACT copy from StampPreview */
|
||||||
|
|
||||||
|
/* 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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick grid overlay styles - EXACT copy from stamp */
|
||||||
|
.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;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
font-size: 20px;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 600;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position numbers at edges within each tile with extra top/bottom spacing */
|
||||||
|
.gridTile:nth-child(1) { align-items: flex-start; justify-content: flex-start; padding-top: 4px; } /* top-left */
|
||||||
|
.gridTile:nth-child(2) { align-items: flex-start; justify-content: center; padding-top: 4px; } /* top-center */
|
||||||
|
.gridTile:nth-child(3) { align-items: flex-start; justify-content: flex-end; padding-top: 4px; } /* top-right */
|
||||||
|
.gridTile:nth-child(4) { align-items: center; justify-content: flex-start; } /* middle-left */
|
||||||
|
.gridTile:nth-child(5) { align-items: center; justify-content: center; } /* center */
|
||||||
|
.gridTile:nth-child(6) { align-items: center; justify-content: flex-end; } /* middle-right */
|
||||||
|
.gridTile:nth-child(7) { align-items: flex-end; justify-content: flex-start; padding-bottom: 4px; } /* bottom-left */
|
||||||
|
.gridTile:nth-child(8) { align-items: flex-end; justify-content: center; padding-bottom: 4px; } /* bottom-center */
|
||||||
|
.gridTile:nth-child(9) { align-items: flex-end; justify-content: flex-end; padding-bottom: 4px; } /* bottom-right */
|
||||||
|
|
||||||
|
/* Base padding for all tiles */
|
||||||
|
.gridTile {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridTileSelected,
|
||||||
|
.gridTileHovered {
|
||||||
|
border: 2px solid var(--mantine-primary-color-filled, #3b82f6);
|
||||||
|
background-color: rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
@ -0,0 +1,248 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AddPageNumbersParameters } from './useAddPageNumbersParameters';
|
||||||
|
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
|
||||||
|
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
|
||||||
|
import styles from './PageNumberPreview.module.css';
|
||||||
|
|
||||||
|
// Simple utilities for page numbers (adapted from stamp)
|
||||||
|
const A4_ASPECT_RATIO = 0.707;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePreviewText = (parameters: AddPageNumbersParameters): string => {
|
||||||
|
const pageNum = parameters.startingNumber;
|
||||||
|
if (parameters.customText.trim()) {
|
||||||
|
return parameters.customText.replace(/\{n\}/g, pageNum.toString());
|
||||||
|
}
|
||||||
|
return pageNum.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectOverallBackgroundColor = async (thumbnailSrc: string | null): Promise<'light' | 'dark'> => {
|
||||||
|
if (!thumbnailSrc) {
|
||||||
|
return 'light'; // Default to light background if no thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
resolve('light');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
// Sample the entire image at reduced resolution for performance
|
||||||
|
const sampleWidth = Math.min(100, img.width);
|
||||||
|
const sampleHeight = Math.min(100, img.height);
|
||||||
|
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||||
|
const data = imageData.data;
|
||||||
|
|
||||||
|
let totalBrightness = 0;
|
||||||
|
let pixelCount = 0;
|
||||||
|
|
||||||
|
// Sample every nth pixel for performance
|
||||||
|
const step = Math.max(1, Math.floor((img.width * img.height) / (sampleWidth * sampleHeight)));
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += 4 * step) {
|
||||||
|
const r = data[i];
|
||||||
|
const g = data[i + 1];
|
||||||
|
const b = data[i + 2];
|
||||||
|
|
||||||
|
// Calculate perceived brightness using luminance formula
|
||||||
|
const brightness = (0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
|
totalBrightness += brightness;
|
||||||
|
pixelCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const averageBrightness = totalBrightness / pixelCount;
|
||||||
|
|
||||||
|
// Threshold: 128 is middle gray
|
||||||
|
resolve(averageBrightness > 128 ? 'light' : 'dark');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error detecting background color:', error);
|
||||||
|
resolve('light'); // Default fallback
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => resolve('light');
|
||||||
|
img.src = thumbnailSrc;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
parameters: AddPageNumbersParameters;
|
||||||
|
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
|
||||||
|
file?: File | null;
|
||||||
|
showQuickGrid?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PageNumberPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
||||||
|
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);
|
||||||
|
const [textColor, setTextColor] = useState<string>('#fff');
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if (!cancelled) setPageSize(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
// Load first-page thumbnail for background preview
|
||||||
|
useEffect(() => {
|
||||||
|
let isActive = true;
|
||||||
|
const loadThumb = async () => {
|
||||||
|
if (!file || file.type !== 'application/pdf') {
|
||||||
|
setPageThumbnail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pagesToNumber || '1'));
|
||||||
|
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.pagesToNumber, requestThumbnail]);
|
||||||
|
|
||||||
|
// Detect text color based on overall PDF background
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pageThumbnail) {
|
||||||
|
setTextColor('#fff'); // Default to white for no thumbnail
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectColor = async () => {
|
||||||
|
const backgroundType = await detectOverallBackgroundColor(pageThumbnail);
|
||||||
|
setTextColor(backgroundType === 'light' ? '#000' : '#fff');
|
||||||
|
};
|
||||||
|
|
||||||
|
detectColor();
|
||||||
|
}, [pageThumbnail]);
|
||||||
|
|
||||||
|
const containerStyle = useMemo(() => ({
|
||||||
|
position: 'relative' as const,
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`,
|
||||||
|
backgroundColor: pageThumbnail ? 'transparent' : 'rgba(255,255,255,0.03)',
|
||||||
|
border: '1px solid var(--border-default, #333)',
|
||||||
|
overflow: 'hidden' as const
|
||||||
|
}), [pageSize, pageThumbnail]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.previewHeader}>
|
||||||
|
<div className={styles.divider} />
|
||||||
|
<div className={styles.previewLabel}>{t('addPageNumbers.preview', 'Preview Page Numbers')}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`${styles.container} ${styles.containerBorder} ${pageThumbnail ? styles.containerWithThumbnail : styles.containerWithoutThumbnail}`}
|
||||||
|
style={containerStyle}
|
||||||
|
>
|
||||||
|
{pageThumbnail && (
|
||||||
|
<img
|
||||||
|
src={pageThumbnail}
|
||||||
|
alt="page preview"
|
||||||
|
className={styles.pageThumbnail}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick position overlay grid - EXACT copy from stamp */}
|
||||||
|
{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;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.gridTile} ${selected || hoverTile === idx ? styles.gridTileSelected : ''} ${hoverTile === idx ? styles.gridTileHovered : ''}`}
|
||||||
|
onClick={() => onParameterChange('position', idx as any)}
|
||||||
|
onMouseEnter={() => setHoverTile(idx)}
|
||||||
|
onMouseLeave={() => setHoverTile(null)}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
textShadow: textColor === '#fff'
|
||||||
|
? '1px 1px 2px rgba(0, 0, 0, 0.8)'
|
||||||
|
: '1px 1px 2px rgba(255, 255, 255, 0.8)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{idx}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.previewDisclaimer}>
|
||||||
|
{t('addPageNumbers.previewDisclaimer', 'Preview is approximate. Final output may vary due to PDF font metrics.')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ToolType, useToolOperation } from '../../../hooks/tools/shared/useToolOperation';
|
||||||
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
|
import { AddPageNumbersParameters, defaultParameters } from './useAddPageNumbersParameters';
|
||||||
|
|
||||||
|
export const buildAddPageNumbersFormData = (parameters: AddPageNumbersParameters, file: File): FormData => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('fileInput', file);
|
||||||
|
formData.append('customMargin', parameters.customMargin);
|
||||||
|
formData.append('position', String(parameters.position));
|
||||||
|
formData.append('fontSize', String(parameters.fontSize));
|
||||||
|
formData.append('fontType', parameters.fontType);
|
||||||
|
formData.append('startingNumber', String(parameters.startingNumber));
|
||||||
|
formData.append('pagesToNumber', parameters.pagesToNumber);
|
||||||
|
formData.append('customText', parameters.customText);
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addPageNumbersOperationConfig = {
|
||||||
|
toolType: ToolType.singleFile,
|
||||||
|
buildFormData: buildAddPageNumbersFormData,
|
||||||
|
operationType: 'addPageNumbers',
|
||||||
|
endpoint: '/api/v1/misc/add-page-numbers',
|
||||||
|
defaultParameters,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const useAddPageNumbersOperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useToolOperation<AddPageNumbersParameters>({
|
||||||
|
...addPageNumbersOperationConfig,
|
||||||
|
getErrorMessage: createStandardErrorHandler(
|
||||||
|
t('addPageNumbers.error.failed', 'An error occurred while adding page numbers to the PDF.')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,34 @@
|
|||||||
|
import { BaseParameters } from '../../../types/parameters';
|
||||||
|
import { useBaseParameters, type BaseParametersHook } from '../../../hooks/tools/shared/useBaseParameters';
|
||||||
|
|
||||||
|
export interface AddPageNumbersParameters extends BaseParameters {
|
||||||
|
customMargin: 'small' | 'medium' | 'large' | 'x-large';
|
||||||
|
position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||||
|
fontSize: number;
|
||||||
|
fontType: 'Times' | 'Helvetica' | 'Courier';
|
||||||
|
startingNumber: number;
|
||||||
|
pagesToNumber: string;
|
||||||
|
customText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultParameters: AddPageNumbersParameters = {
|
||||||
|
customMargin: 'medium',
|
||||||
|
position: 8, // Default to bottom center like the original HTML
|
||||||
|
fontSize: 12,
|
||||||
|
fontType: 'Times',
|
||||||
|
startingNumber: 1,
|
||||||
|
pagesToNumber: '',
|
||||||
|
customText: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddPageNumbersParametersHook = BaseParametersHook<AddPageNumbersParameters>;
|
||||||
|
|
||||||
|
export const useAddPageNumbersParameters = (): AddPageNumbersParametersHook => {
|
||||||
|
return useBaseParameters<AddPageNumbersParameters>({
|
||||||
|
defaultParameters,
|
||||||
|
endpointName: 'add-page-numbers',
|
||||||
|
validateFn: (params): boolean => {
|
||||||
|
return params.fontSize > 0 && params.startingNumber > 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
@ -74,6 +74,8 @@ import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/u
|
|||||||
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||||
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||||
import CropSettings from "../components/tools/crop/CropSettings";
|
import CropSettings from "../components/tools/crop/CropSettings";
|
||||||
|
import AddPageNumbers from "../tools/AddPageNumbers";
|
||||||
|
import { addPageNumbersOperationConfig } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
|
||||||
|
|
||||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||||
|
|
||||||
@ -407,11 +409,13 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
addPageNumbers: {
|
addPageNumbers: {
|
||||||
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.addPageNumbers.title", "Add Page Numbers"),
|
name: t("home.addPageNumbers.title", "Add Page Numbers"),
|
||||||
component: null,
|
component: AddPageNumbers,
|
||||||
|
|
||||||
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
|
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
|
||||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||||
|
maxFiles: -1,
|
||||||
|
endpoints: ["add-page-numbers"],
|
||||||
|
operationConfig: addPageNumbersOperationConfig,
|
||||||
synonyms: getSynonyms(t, "addPageNumbers")
|
synonyms: getSynonyms(t, "addPageNumbers")
|
||||||
},
|
},
|
||||||
pageLayout: {
|
pageLayout: {
|
||||||
|
203
frontend/src/tools/AddPageNumbers.tsx
Normal file
203
frontend/src/tools/AddPageNumbers.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect } 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 { useAddPageNumbersParameters } from "../components/tools/addPageNumbers/useAddPageNumbersParameters";
|
||||||
|
import { useAddPageNumbersOperation } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
|
||||||
|
import { Select, Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core";
|
||||||
|
import { Tooltip } from "../components/shared/Tooltip";
|
||||||
|
import PageNumberPreview from "../components/tools/addPageNumbers/PageNumberPreview";
|
||||||
|
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
|
||||||
|
|
||||||
|
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
|
const params = useAddPageNumbersParameters();
|
||||||
|
const operation = useAddPageNumbersOperation();
|
||||||
|
|
||||||
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-page-numbers");
|
||||||
|
|
||||||
|
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("addPageNumbers.error.failed", "Add page numbers operation failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||||
|
|
||||||
|
enum AddPageNumbersStep {
|
||||||
|
NONE = 'none',
|
||||||
|
POSITION_AND_PAGES = 'position_and_pages',
|
||||||
|
CUSTOMIZE = 'customize'
|
||||||
|
}
|
||||||
|
|
||||||
|
const accordion = useAccordionSteps<AddPageNumbersStep>({
|
||||||
|
noneValue: AddPageNumbersStep.NONE,
|
||||||
|
initialStep: AddPageNumbersStep.POSITION_AND_PAGES,
|
||||||
|
stateConditions: {
|
||||||
|
hasFiles,
|
||||||
|
hasResults
|
||||||
|
},
|
||||||
|
afterResults: () => {
|
||||||
|
operation.resetResults();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSteps = () => {
|
||||||
|
const steps: any[] = [];
|
||||||
|
|
||||||
|
// Step 1: Position Selection & Pages/Starting Number
|
||||||
|
steps.push({
|
||||||
|
title: t("addPageNumbers.positionAndPages", "Position & Pages"),
|
||||||
|
isCollapsed: accordion.getCollapsedState(AddPageNumbersStep.POSITION_AND_PAGES),
|
||||||
|
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.POSITION_AND_PAGES),
|
||||||
|
isVisible: hasFiles || hasResults,
|
||||||
|
content: (
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* Position Selection */}
|
||||||
|
<Stack gap="md">
|
||||||
|
<PageNumberPreview
|
||||||
|
parameters={params.parameters}
|
||||||
|
onParameterChange={params.updateParameter}
|
||||||
|
file={selectedFiles[0] || null}
|
||||||
|
showQuickGrid={true}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Pages & Starting Number Section */}
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
|
||||||
|
|
||||||
|
<Tooltip content={t('pageSelectionPrompt', 'Specify which pages to add numbers to. Examples: "1,3,5" for specific pages, "1-5" for ranges, "2n" for even pages, or leave blank for all pages.')}>
|
||||||
|
<TextInput
|
||||||
|
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
|
||||||
|
value={params.parameters.pagesToNumber}
|
||||||
|
onChange={(e) => params.updateParameter('pagesToNumber', e.currentTarget.value)}
|
||||||
|
placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={t('startingNumberTooltip', 'The first number to display. Subsequent pages will increment from this number.')}>
|
||||||
|
<NumberInput
|
||||||
|
label={t('addPageNumbers.selectText.4', 'Starting Number')}
|
||||||
|
value={params.parameters.startingNumber}
|
||||||
|
onChange={(v) => params.updateParameter('startingNumber', typeof v === 'number' ? v : 1)}
|
||||||
|
min={1}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Customize Appearance
|
||||||
|
steps.push({
|
||||||
|
title: t("addPageNumbers.customize", "Customize Appearance"),
|
||||||
|
isCollapsed: accordion.getCollapsedState(AddPageNumbersStep.CUSTOMIZE),
|
||||||
|
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.CUSTOMIZE),
|
||||||
|
isVisible: hasFiles || hasResults,
|
||||||
|
content: (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Tooltip content={t('marginTooltip', 'Distance between the page number and the edge of the page.')}>
|
||||||
|
<Select
|
||||||
|
label={t('addPageNumbers.selectText.2', 'Margin')}
|
||||||
|
value={params.parameters.customMargin}
|
||||||
|
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
|
||||||
|
data={[
|
||||||
|
{ value: 'small', label: t('sizes.small', 'Small') },
|
||||||
|
{ value: 'medium', label: t('sizes.medium', 'Medium') },
|
||||||
|
{ value: 'large', label: t('sizes.large', 'Large') },
|
||||||
|
{ value: 'x-large', label: t('sizes.x-large', 'Extra Large') },
|
||||||
|
]}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={t('fontSizeTooltip', 'Size of the page number text in points. Larger numbers create bigger text.')}>
|
||||||
|
<NumberInput
|
||||||
|
label={t('addPageNumbers.fontSize', 'Font Size')}
|
||||||
|
value={params.parameters.fontSize}
|
||||||
|
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 12)}
|
||||||
|
min={1}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={t('fontTypeTooltip', 'Font family for the page numbers. Choose based on your document style.')}>
|
||||||
|
<Select
|
||||||
|
label={t('addPageNumbers.fontName', 'Font Type')}
|
||||||
|
value={params.parameters.fontType}
|
||||||
|
onChange={(v) => params.updateParameter('fontType', (v as any) || 'Times')}
|
||||||
|
data={[
|
||||||
|
{ value: 'Times', label: 'Times Roman' },
|
||||||
|
{ value: 'Helvetica', label: 'Helvetica' },
|
||||||
|
{ value: 'Courier', label: 'Courier New' },
|
||||||
|
]}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={t('customTextTooltip', 'Optional custom format for page numbers. Use {n} as placeholder for the number. Example: "Page {n}" will show "Page 1", "Page 2", etc.')}>
|
||||||
|
<TextInput
|
||||||
|
label={t('addPageNumbers.selectText.6', 'Custom Text Format')}
|
||||||
|
value={params.parameters.customText}
|
||||||
|
onChange={(e) => params.updateParameter('customText', e.currentTarget.value)}
|
||||||
|
placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
};
|
||||||
|
|
||||||
|
return createToolFlow({
|
||||||
|
files: {
|
||||||
|
selectedFiles,
|
||||||
|
isCollapsed: hasResults,
|
||||||
|
},
|
||||||
|
steps: getSteps(),
|
||||||
|
executeButton: {
|
||||||
|
text: t('addPageNumbers.submit', 'Add Page Numbers'),
|
||||||
|
isVisible: !hasResults,
|
||||||
|
loadingText: t('loading'),
|
||||||
|
onClick: handleExecute,
|
||||||
|
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
isVisible: hasResults,
|
||||||
|
operation: operation,
|
||||||
|
title: t('addPageNumbers.results.title', 'Page Number Results'),
|
||||||
|
onFileClick: (file) => onPreviewFile?.(file),
|
||||||
|
onUndo: async () => {
|
||||||
|
await operation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
AddPageNumbers.tool = () => useAddPageNumbersOperation;
|
||||||
|
|
||||||
|
export default AddPageNumbers as ToolComponent;
|
Loading…
Reference in New Issue
Block a user