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": {
|
||||
"1": "Select PDF file:",
|
||||
"2": "Margin Size",
|
||||
"3": "Position",
|
||||
"3": "Position Selection",
|
||||
"4": "Starting Number",
|
||||
"5": "Pages to Number",
|
||||
"6": "Custom Text"
|
||||
"6": "Custom Text Format"
|
||||
},
|
||||
"customTextDesc": "Custom Text",
|
||||
"numberPagesDesc": "Which pages to number, default 'all', also accepts 1-5 or 2,5,9 etc",
|
||||
"customNumberDesc": "Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}",
|
||||
"submit": "Add Page Numbers"
|
||||
"numberPagesDesc": "e.g., 1,3,5-8 or leave blank for all pages",
|
||||
"customNumberDesc": "e.g., \"Page {n}\" or leave blank for just 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)",
|
||||
"multiPdfPrompt": "Select PDFs (2+)",
|
||||
"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 ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||
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
|
||||
|
||||
@ -407,11 +409,13 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
addPageNumbers: {
|
||||
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
|
||||
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"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-page-numbers"],
|
||||
operationConfig: addPageNumbersOperationConfig,
|
||||
synonyms: getSynonyms(t, "addPageNumbers")
|
||||
},
|
||||
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