From 458bb641b5d6a565bbeebd0f27a88577332fa8c2 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:48:39 +0100 Subject: [PATCH] Feature/adjust colors contrast tool (#4544) # Description of Changes - Addition of the "Adjust Colors/Contrast" tool --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../public/locales/en-GB/translation.json | 7 +- .../public/locales/en-US/translation.json | 7 +- .../sliderWithInput/SliderWithInput.tsx | 44 +++++++ .../AdjustContrastBasicSettings.tsx | 25 ++++ .../AdjustContrastColorSettings.tsx | 25 ++++ .../adjustContrast/AdjustContrastPreview.tsx | 93 ++++++++++++++ .../AdjustContrastSingleStepSettings.tsx | 31 +++++ .../components/tools/adjustContrast/utils.ts | 79 ++++++++++++ .../tools/shared/createToolFlow.tsx | 6 + .../src/data/useTranslatedToolRegistry.tsx | 8 +- .../useAdjustContrastOperation.ts | 94 ++++++++++++++ .../useAdjustContrastParameters.ts | 30 +++++ frontend/src/tools/AdjustContrast.tsx | 121 ++++++++++++++++++ 13 files changed, 562 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/shared/sliderWithInput/SliderWithInput.tsx create mode 100644 frontend/src/components/tools/adjustContrast/AdjustContrastBasicSettings.tsx create mode 100644 frontend/src/components/tools/adjustContrast/AdjustContrastColorSettings.tsx create mode 100644 frontend/src/components/tools/adjustContrast/AdjustContrastPreview.tsx create mode 100644 frontend/src/components/tools/adjustContrast/AdjustContrastSingleStepSettings.tsx create mode 100644 frontend/src/components/tools/adjustContrast/utils.ts create mode 100644 frontend/src/hooks/tools/adjustContrast/useAdjustContrastOperation.ts create mode 100644 frontend/src/hooks/tools/adjustContrast/useAdjustContrastParameters.ts create mode 100644 frontend/src/tools/AdjustContrast.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index b1646e76f..2772ad15e 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -547,7 +547,7 @@ "adjustContrast": { "tags": "contrast,brightness,saturation", "title": "Adjust Colours/Contrast", - "desc": "Adjust Contrast, Saturation and Brightness of a PDF" + "desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF" }, "crop": { "tags": "trim,cut,resize", @@ -2791,8 +2791,9 @@ "submit": "Sanitize PDF" }, "adjustContrast": { - "title": "Adjust Contrast", - "header": "Adjust Contrast", + "title": "Adjust Colors/Contrast", + "header": "Adjust Colors/Contrast", + "basic": "Basic Adjustments", "contrast": "Contrast:", "brightness": "Brightness:", "saturation": "Saturation:", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index d20cca6ae..b4ac8302d 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -562,7 +562,7 @@ "adjustContrast": { "tags": "contrast,brightness,saturation", "title": "Adjust Colors/Contrast", - "desc": "Adjust Contrast, Saturation and Brightness of a PDF" + "desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF" }, "crop": { "tags": "trim,cut,resize", @@ -1712,8 +1712,9 @@ "submit": "Sanitize PDF" }, "adjustContrast": { - "title": "Adjust Contrast", - "header": "Adjust Contrast", + "title": "Adjust Colors/Contrast", + "header": "Adjust Colors/Contrast", + "basic": "Basic Adjustments", "contrast": "Contrast:", "brightness": "Brightness:", "saturation": "Saturation:", diff --git a/frontend/src/components/shared/sliderWithInput/SliderWithInput.tsx b/frontend/src/components/shared/sliderWithInput/SliderWithInput.tsx new file mode 100644 index 000000000..e8e504ded --- /dev/null +++ b/frontend/src/components/shared/sliderWithInput/SliderWithInput.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Slider, Text, Group, NumberInput } from '@mantine/core'; + +interface Props { + label: string; + value: number; + onChange: (value: number) => void; + disabled?: boolean; + min?: number; + max?: number; + step?: number; +} + +export default function SliderWithInput({ + label, + value, + onChange, + disabled, + min = 0, + max = 200, + step = 1, +}: Props) { + return ( +
+ {label}: {Math.round(value)}% + +
+ +
+ onChange(Number(v) || 0)} + min={min} + max={max} + step={step} + disabled={disabled} + style={{ width: 90 }} + /> +
+
+ ); +} + + diff --git a/frontend/src/components/tools/adjustContrast/AdjustContrastBasicSettings.tsx b/frontend/src/components/tools/adjustContrast/AdjustContrastBasicSettings.tsx new file mode 100644 index 000000000..6993186bb --- /dev/null +++ b/frontend/src/components/tools/adjustContrast/AdjustContrastBasicSettings.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters'; +import SliderWithInput from '../../shared/sliderWithInput/SliderWithInput'; + +interface Props { + parameters: AdjustContrastParameters; + onParameterChange: (key: K, value: AdjustContrastParameters[K]) => void; + disabled?: boolean; +} + +export default function AdjustContrastBasicSettings({ parameters, onParameterChange, disabled }: Props) { + const { t } = useTranslation(); + + return ( + + onParameterChange('contrast', v as any)} disabled={disabled} /> + onParameterChange('brightness', v as any)} disabled={disabled} /> + onParameterChange('saturation', v as any)} disabled={disabled} /> + + ); +} + + diff --git a/frontend/src/components/tools/adjustContrast/AdjustContrastColorSettings.tsx b/frontend/src/components/tools/adjustContrast/AdjustContrastColorSettings.tsx new file mode 100644 index 000000000..0f2722873 --- /dev/null +++ b/frontend/src/components/tools/adjustContrast/AdjustContrastColorSettings.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters'; +import SliderWithInput from '../../shared/sliderWithInput/SliderWithInput'; + +interface Props { + parameters: AdjustContrastParameters; + onParameterChange: (key: K, value: AdjustContrastParameters[K]) => void; + disabled?: boolean; +} + +export default function AdjustContrastColorSettings({ parameters, onParameterChange, disabled }: Props) { + const { t } = useTranslation(); + + return ( + + onParameterChange('red', v as any)} disabled={disabled} /> + onParameterChange('green', v as any)} disabled={disabled} /> + onParameterChange('blue', v as any)} disabled={disabled} /> + + ); +} + + diff --git a/frontend/src/components/tools/adjustContrast/AdjustContrastPreview.tsx b/frontend/src/components/tools/adjustContrast/AdjustContrastPreview.tsx new file mode 100644 index 000000000..a50fb98ca --- /dev/null +++ b/frontend/src/components/tools/adjustContrast/AdjustContrastPreview.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters'; +import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration'; +import ObscuredOverlay from '../../shared/ObscuredOverlay'; +import { Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { applyAdjustmentsToCanvas } from './utils'; + +interface Props { + file: File | null; + parameters: AdjustContrastParameters; +} + +export default function AdjustContrastPreview({ file, parameters }: Props) { + const { t } = useTranslation(); + const containerRef = useRef(null); + const canvasRef = useRef(null); + const [thumb, setThumb] = useState(null); + const { requestThumbnail } = useThumbnailGeneration(); + + useEffect(() => { + let active = true; + const load = async () => { + if (!file || file.type !== 'application/pdf') { setThumb(null); return; } + const id = `${file.name}:${file.size}:${file.lastModified}:page:1`; + const tUrl = await requestThumbnail(id, file, 1); + if (active) setThumb(tUrl || null); + }; + load(); + return () => { active = false; }; + }, [file, requestThumbnail]); + + useEffect(() => { + const revoked: string | null = null; + const render = async () => { + if (!thumb || !canvasRef.current) return; + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.src = thumb; + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(); + }); + + // Draw thumbnail to a source canvas + const src = document.createElement('canvas'); + src.width = img.naturalWidth; + src.height = img.naturalHeight; + const sctx = src.getContext('2d'); + if (!sctx) return; + sctx.drawImage(img, 0, 0); + + // Apply accurate pixel adjustments + const adjusted = applyAdjustmentsToCanvas(src, parameters); + + // Draw adjusted onto display canvas + const display = canvasRef.current; + display.width = adjusted.width; + display.height = adjusted.height; + const dctx = display.getContext('2d'); + if (!dctx) return; + dctx.clearRect(0, 0, display.width, display.height); + dctx.drawImage(adjusted, 0, 0); + }; + render(); + return () => { + if (revoked) URL.revokeObjectURL(revoked); + }; + }, [thumb, parameters]); + + return ( +
+
+
+
{t('common.preview', 'Preview')}
+
+
+ {t('adjustContrast.noPreview', 'Select a PDF to preview')}} + borderRadius={6} + > +
+ {thumb && ( + + )} +
+
+
+ ); +} + + diff --git a/frontend/src/components/tools/adjustContrast/AdjustContrastSingleStepSettings.tsx b/frontend/src/components/tools/adjustContrast/AdjustContrastSingleStepSettings.tsx new file mode 100644 index 000000000..ce19b8012 --- /dev/null +++ b/frontend/src/components/tools/adjustContrast/AdjustContrastSingleStepSettings.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Stack } from '@mantine/core'; +import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters'; +import AdjustContrastBasicSettings from './AdjustContrastBasicSettings'; +import AdjustContrastColorSettings from './AdjustContrastColorSettings'; + +interface Props { + parameters: AdjustContrastParameters; + onParameterChange: (key: K, value: AdjustContrastParameters[K]) => void; + disabled?: boolean; +} + +// Single-step settings used by Automate to configure Adjust Contrast in one panel +export default function AdjustContrastSingleStepSettings({ parameters, onParameterChange, disabled }: Props) { + return ( + + + + + ); +} + + diff --git a/frontend/src/components/tools/adjustContrast/utils.ts b/frontend/src/components/tools/adjustContrast/utils.ts new file mode 100644 index 000000000..16dfc199f --- /dev/null +++ b/frontend/src/components/tools/adjustContrast/utils.ts @@ -0,0 +1,79 @@ +import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters'; + +export function applyAdjustmentsToCanvas(src: HTMLCanvasElement, params: AdjustContrastParameters): HTMLCanvasElement { + const out = document.createElement('canvas'); + out.width = src.width; + out.height = src.height; + const ctx = out.getContext('2d'); + if (!ctx) return src; + ctx.drawImage(src, 0, 0); + + const imageData = ctx.getImageData(0, 0, out.width, out.height); + const data = imageData.data; + + const contrast = params.contrast / 100; // 0..2 + const brightness = params.brightness / 100; // 0..2 + const saturation = params.saturation / 100; // 0..2 + const redMul = params.red / 100; // 0..2 + const greenMul = params.green / 100; // 0..2 + const blueMul = params.blue / 100; // 0..2 + + const clamp = (v: number) => Math.min(255, Math.max(0, v)); + + for (let i = 0; i < data.length; i += 4) { + let r = data[i] * redMul; + let g = data[i + 1] * greenMul; + let b = data[i + 2] * blueMul; + + // Contrast (centered at 128) + r = clamp((r - 128) * contrast + 128); + g = clamp((g - 128) * contrast + 128); + b = clamp((b - 128) * contrast + 128); + + // Brightness + r = clamp(r * brightness); + g = clamp(g * brightness); + b = clamp(b * brightness); + + // Saturation via HSL + const rn = r / 255, gn = g / 255, bn = b / 255; + const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn); + let h = 0, s = 0; + const l = (max + min) / 2; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break; + case gn: h = (bn - rn) / d + 2; break; + default: h = (rn - gn) / d + 4; break; + } + h /= 6; + } + s = Math.min(1, Math.max(0, s * saturation)); + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + let r2: number, g2: number, b2: number; + if (s === 0) { r2 = g2 = b2 = l; } + else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r2 = hue2rgb(p, q, h + 1/3); + g2 = hue2rgb(p, q, h); + b2 = hue2rgb(p, q, h - 1/3); + } + data[i] = clamp(Math.round(r2 * 255)); + data[i + 1] = clamp(Math.round(g2 * 255)); + data[i + 2] = clamp(Math.round(b2 * 255)); + } + + ctx.putImageData(imageData, 0, 0); + return out; +} + + diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index 4724648c8..ffa4b0db6 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -54,6 +54,8 @@ export interface ToolFlowConfig { title?: TitleConfig; files: FilesStepConfig; steps: MiddleStepConfig[]; + // Optional preview content rendered between steps and the execute button + preview?: React.ReactNode; executeButton?: ExecuteButtonConfig; review: ReviewStepConfig; forceStepNumbers?: boolean; @@ -90,6 +92,10 @@ export function createToolFlow(config: ToolFlowConfig) { }, stepConfig.content) )} + {/* Preview (outside steps, above execute button). + Hide when review is visible or when no files are selected. */} + {!config.review.isVisible && (config.files.selectedFiles?.length ?? 0) > 0 && config.preview} + {/* Execute Button */} {config.executeButton && config.executeButton.isVisible !== false && ( , name: t("home.adjustContrast.title", "Adjust Colors/Contrast"), - component: null, + component: AdjustContrast, description: t("home.adjustContrast.desc", "Adjust colors and contrast of PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + operationConfig: adjustContrastOperationConfig, + automationSettings: AdjustContrastSingleStepSettings, synonyms: getSynonyms(t, "adjustContrast"), - automationSettings: null, }, repair: { icon: , diff --git a/frontend/src/hooks/tools/adjustContrast/useAdjustContrastOperation.ts b/frontend/src/hooks/tools/adjustContrast/useAdjustContrastOperation.ts new file mode 100644 index 000000000..c52ed9864 --- /dev/null +++ b/frontend/src/hooks/tools/adjustContrast/useAdjustContrastOperation.ts @@ -0,0 +1,94 @@ +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { AdjustContrastParameters, defaultParameters } from './useAdjustContrastParameters'; +import { PDFDocument as PDFLibDocument } from 'pdf-lib'; +import { applyAdjustmentsToCanvas } from '../../../components/tools/adjustContrast/utils'; +import { pdfWorkerManager } from '../../../services/pdfWorkerManager'; +import { createFileFromApiResponse } from '../../../utils/fileResponseUtils'; + +async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number): Promise { + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale }); + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas 2D context unavailable'); + await page.render({ canvasContext: ctx, viewport }).promise; + return canvas; +} + +// adjustment logic moved to shared util + +// Render, adjust, and assemble all pages of a single PDF into a new PDF +async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParameters): Promise { + const arrayBuffer = await file.arrayBuffer(); + const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {}); + const pageCount = pdf.numPages; + + const newDoc = await PDFLibDocument.create(); + + for (let p = 1; p <= pageCount; p++) { + const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2); + const adjusted = applyAdjustmentsToCanvas(srcCanvas, params); + const pngUrl = adjusted.toDataURL('image/png'); + const res = await fetch(pngUrl); + const pngBytes = new Uint8Array(await res.arrayBuffer()); + const embedded = await newDoc.embedPng(pngBytes); + const { width, height } = embedded.scale(1); + const page = newDoc.addPage([width, height]); + page.drawImage(embedded, { x: 0, y: 0, width, height }); + } + + const pdfBytes = await newDoc.save(); + const out = createFileFromApiResponse(pdfBytes, { 'content-type': 'application/pdf' }, file.name); + pdfWorkerManager.destroyDocument(pdf); + return out; +} + +async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise { + // Limit concurrency to avoid exhausting memory/CPU while still getting speedups + // Heuristic: use up to 4 workers on capable machines, otherwise 2-3 + let CONCURRENCY_LIMIT = 2; + if (typeof navigator !== 'undefined' && typeof navigator.hardwareConcurrency === 'number') { + if (navigator.hardwareConcurrency >= 8) CONCURRENCY_LIMIT = 4; + else if (navigator.hardwareConcurrency >= 4) CONCURRENCY_LIMIT = 3; + } + CONCURRENCY_LIMIT = Math.min(CONCURRENCY_LIMIT, files.length); + + const mapWithConcurrency = async (items: T[], limit: number, worker: (item: T, index: number) => Promise): Promise => { + const results: R[] = new Array(items.length); + let nextIndex = 0; + + const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => { + let current = nextIndex++; + while (current < items.length) { + results[current] = await worker(items[current], current); + current = nextIndex++; + } + }); + + await Promise.all(workers); + return results; + }; + + return mapWithConcurrency(files, CONCURRENCY_LIMIT, (file) => buildAdjustedPdfForFile(file, params)); +} + +export const adjustContrastOperationConfig = { + toolType: ToolType.custom, + customProcessor: processPdfClientSide, + operationType: 'adjustContrast', + defaultParameters, + // Single-step settings component for Automate + settingsComponentPath: 'components/tools/adjustContrast/AdjustContrastSingleStepSettings', +} as const; + +export const useAdjustContrastOperation = () => { + const { t } = useTranslation(); + return useToolOperation({ + ...adjustContrastOperationConfig, + getErrorMessage: () => t('adjustContrast.error.failed', 'Failed to adjust colors/contrast') + }); +}; + diff --git a/frontend/src/hooks/tools/adjustContrast/useAdjustContrastParameters.ts b/frontend/src/hooks/tools/adjustContrast/useAdjustContrastParameters.ts new file mode 100644 index 000000000..14dd543d8 --- /dev/null +++ b/frontend/src/hooks/tools/adjustContrast/useAdjustContrastParameters.ts @@ -0,0 +1,30 @@ +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface AdjustContrastParameters { + contrast: number; // 0-200 (%), 100 = neutral + brightness: number; // 0-200 (%), 100 = neutral + saturation: number; // 0-200 (%), 100 = neutral + red: number; // 0-200 (%), 100 = neutral + green: number; // 0-200 (%), 100 = neutral + blue: number; // 0-200 (%), 100 = neutral +} + +export const defaultParameters: AdjustContrastParameters = { + contrast: 100, + brightness: 100, + saturation: 100, + red: 100, + green: 100, + blue: 100, +}; + +export type AdjustContrastParametersHook = BaseParametersHook; + +export const useAdjustContrastParameters = (): AdjustContrastParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: '', + }); +}; + + diff --git a/frontend/src/tools/AdjustContrast.tsx b/frontend/src/tools/AdjustContrast.tsx new file mode 100644 index 000000000..0de528a40 --- /dev/null +++ b/frontend/src/tools/AdjustContrast.tsx @@ -0,0 +1,121 @@ +import { useTranslation } from 'react-i18next'; +import React, { useEffect, useMemo, useState } from 'react'; +import { createToolFlow } from '../components/tools/shared/createToolFlow'; +import { BaseToolProps, ToolComponent } from '../types/tool'; +import { useBaseTool } from '../hooks/tools/shared/useBaseTool'; +import { useAdjustContrastParameters } from '../hooks/tools/adjustContrast/useAdjustContrastParameters'; +import { useAdjustContrastOperation } from '../hooks/tools/adjustContrast/useAdjustContrastOperation'; +import AdjustContrastBasicSettings from '../components/tools/adjustContrast/AdjustContrastBasicSettings'; +import AdjustContrastColorSettings from '../components/tools/adjustContrast/AdjustContrastColorSettings'; +import AdjustContrastPreview from '../components/tools/adjustContrast/AdjustContrastPreview'; +import { useAccordionSteps } from '../hooks/tools/shared/useAccordionSteps'; +import NavigationArrows from '../components/shared/filePreview/NavigationArrows'; + +const AdjustContrast = (props: BaseToolProps) => { + const { t } = useTranslation(); + + const base = useBaseTool( + 'adjustContrast', + useAdjustContrastParameters, + useAdjustContrastOperation, + props + ); + + enum Step { NONE='none', BASIC='basic', COLORS='colors' } + const accordion = useAccordionSteps({ + noneValue: Step.NONE, + initialStep: Step.BASIC, + stateConditions: { hasFiles: base.hasFiles, hasResults: base.hasResults }, + afterResults: base.handleSettingsReset + }); + + // Track which selected file is being previewed. Clamp when selection changes. + const [previewIndex, setPreviewIndex] = useState(0); + const totalSelected = base.selectedFiles.length; + + useEffect(() => { + if (previewIndex >= totalSelected) { + setPreviewIndex(Math.max(0, totalSelected - 1)); + } + }, [totalSelected, previewIndex]); + + const currentFile = useMemo(() => { + return totalSelected > 0 ? base.selectedFiles[previewIndex] : null; + }, [base.selectedFiles, previewIndex, totalSelected]); + + const handlePrev = () => setPreviewIndex((i) => Math.max(0, i - 1)); + const handleNext = () => setPreviewIndex((i) => Math.min(totalSelected - 1, i + 1)); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: t('adjustContrast.basic', 'Basic Adjustments'), + isCollapsed: accordion.getCollapsedState(Step.BASIC), + onCollapsedClick: () => accordion.handleStepToggle(Step.BASIC), + content: ( + + ), + }, + { + title: t('adjustContrast.adjustColors', 'Adjust Colors'), + isCollapsed: accordion.getCollapsedState(Step.COLORS), + onCollapsedClick: () => accordion.handleStepToggle(Step.COLORS), + content: ( + + ), + }, + ], + preview: ( +
+ +
+ +
+
+ {totalSelected > 1 && ( +
+ {`${previewIndex + 1} of ${totalSelected}`} +
+ )} +
+ ), + executeButton: { + text: t('adjustContrast.confirm', 'Confirm'), + isVisible: !base.hasResults, + loadingText: t('loading'), + onClick: base.handleExecute, + disabled: !base.hasFiles, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t('adjustContrast.results.title', 'Adjusted PDF'), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + forceStepNumbers: true, + }); +}; + +export default AdjustContrast as ToolComponent; + +