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 1/4] 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; + + From 8aa6aff53acc86aae58d5da10b5bf396a253ab31 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:49:10 +0100 Subject: [PATCH 2/4] Config becomes account when enableLogin (#4585) {36B7A86A-4B9A-433F-9784-B9E923FF4872} Co-authored-by: Connor Yoh --- frontend/public/locales/en-GB/translation.json | 1 + frontend/src/components/shared/QuickAccessBar.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2772ad15e..71f0bb90b 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3127,6 +3127,7 @@ "automate": "Automate", "files": "Files", "activity": "Activity", + "account": "Account", "config": "Config", "allTools": "All Tools" }, diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index b7206fa93..af9c8acff 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -13,6 +13,7 @@ import './quickAccessBar/QuickAccessBar.css'; import AllToolsNavButton from './AllToolsNavButton'; import ActiveToolButton from "./quickAccessBar/ActiveToolButton"; import AppConfigModal from './AppConfigModal'; +import { useAppConfig } from '../../hooks/useAppConfig'; import { isNavButtonActive, getNavButtonStyle, @@ -25,6 +26,7 @@ const QuickAccessBar = forwardRef((_, ref) => { const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); const { getToolNavigation } = useSidebarNavigation(); + const { config } = useAppConfig(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); @@ -151,8 +153,8 @@ const QuickAccessBar = forwardRef((_, ref) => { //}, { id: 'config', - name: t("quickAccess.config", "Config"), - icon: , + name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"), + icon: config?.enableLogin ? : , size: 'lg', type: 'modal', onClick: () => { From f9ac1bd62e5dec32135150e51d4975552057c3cb Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 2 Oct 2025 20:14:35 +0100 Subject: [PATCH 3/4] Feature/v2/navigate save prompt (#4586) # Description of Changes --- ## 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. --- .../src/components/pageEditor/PageEditor.tsx | 108 ++++++++---------- .../components/pageEditor/PageThumbnail.tsx | 1 + .../pageEditor/commands/pageCommands.ts | 14 ++- .../shared/NavigationWarningModal.tsx | 56 +++++---- .../src/components/viewer/EmbedPdfViewer.tsx | 107 +++++++++++++++-- .../components/viewer/SignatureAPIBridge.tsx | 2 +- frontend/src/contexts/NavigationContext.tsx | 47 +++++++- frontend/src/contexts/file/fileActions.ts | 22 +++- .../services/documentManipulationService.ts | 55 ++++----- .../services/enhancedPDFProcessingService.ts | 28 +++-- frontend/src/services/fileStubHelpers.ts | 34 ++++++ frontend/src/services/pdfExportHelpers.ts | 46 ++++++++ frontend/src/services/pdfExportService.ts | 20 +--- .../services/thumbnailGenerationService.ts | 2 +- frontend/src/utils/thumbnailUtils.ts | 23 +++- 15 files changed, 396 insertions(+), 169 deletions(-) create mode 100644 frontend/src/services/fileStubHelpers.ts create mode 100644 frontend/src/services/pdfExportHelpers.ts diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index b24fb819a..ee77d142d 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -5,6 +5,8 @@ import { useNavigationGuard } from "../../contexts/NavigationContext"; import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor"; import { pdfExportService } from "../../services/pdfExportService"; import { documentManipulationService } from "../../services/documentManipulationService"; +import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers"; +import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers"; // Thumbnail generation is now handled by individual PageThumbnail components import './PageEditor.module.css'; import PageThumbnail from './PageThumbnail'; @@ -524,66 +526,38 @@ const PageEditor = ({ try { // Step 1: Apply DOM changes to document state first const processedDocuments = documentManipulationService.applyDOMChangesToDocument( - mergedPdfDocument || displayDocument, // Original order - displayDocument, // Current display order (includes reordering) - splitPositions // Position-based splits + mergedPdfDocument || displayDocument, + displayDocument, + splitPositions ); - // Step 2: Check if we have multiple documents (splits) or single document - if (Array.isArray(processedDocuments)) { - // Multiple documents (splits) - export as ZIP - const blobs: Blob[] = []; - const filenames: string[] = []; + // Step 2: Export to files + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename); - const sourceFiles = getSourceFiles(); - const baseExportFilename = getExportFilename(); - const baseName = baseExportFilename.replace(/\.pdf$/i, ''); - - for (let i = 0; i < processedDocuments.length; i++) { - const doc = processedDocuments[i]; - const partFilename = `${baseName}_part_${i + 1}.pdf`; - - const result = sourceFiles - ? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename }) - : await pdfExportService.exportPDF(doc, [], { filename: partFilename }); - blobs.push(result.blob); - filenames.push(result.filename); - } - - // Create ZIP file + // Step 3: Download + if (files.length > 1) { + // Multiple files - create ZIP const JSZip = await import('jszip'); const zip = new JSZip.default(); - blobs.forEach((blob, index) => { - zip.file(filenames[index], blob); + files.forEach((file) => { + zip.file(file.name, file); }); const zipBlob = await zip.generateAsync({ type: 'blob' }); - const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip'); + const exportFilename = getExportFilename(); + const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip'); pdfExportService.downloadFile(zipBlob, zipFilename); - setHasUnsavedChanges(false); // Clear unsaved changes after successful export } else { - // Single document - regular export - const sourceFiles = getSourceFiles(); - const exportFilename = getExportFilename(); - const result = sourceFiles - ? await pdfExportService.exportPDFMultiFile( - processedDocuments, - sourceFiles, - [], - { selectedOnly: false, filename: exportFilename } - ) - : await pdfExportService.exportPDF( - processedDocuments, - [], - { selectedOnly: false, filename: exportFilename } - ); - - pdfExportService.downloadFile(result.blob, result.filename); - setHasUnsavedChanges(false); // Clear unsaved changes after successful export + // Single file - download directly + const file = files[0]; + pdfExportService.downloadFile(file, file.name); } + setHasUnsavedChanges(false); setExportLoading(false); } catch (error) { console.error('Export failed:', error); @@ -592,21 +566,39 @@ const PageEditor = ({ }, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]); // Apply DOM changes to document state using dedicated service - const applyChanges = useCallback(() => { + const applyChanges = useCallback(async () => { if (!displayDocument) return; - // Pass current display document (which includes reordering) to get both reordering AND DOM changes - const processedDocuments = documentManipulationService.applyDOMChangesToDocument( - mergedPdfDocument || displayDocument, // Original order - displayDocument, // Current display order (includes reordering) - splitPositions // Position-based splits - ); + setExportLoading(true); + try { + // Step 1: Apply DOM changes to document state first + const processedDocuments = documentManipulationService.applyDOMChangesToDocument( + mergedPdfDocument || displayDocument, + displayDocument, + splitPositions + ); - // For apply changes, we only set the first document if it's an array (splits shouldn't affect document state) - const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments; - setEditedDocument(documentToSet); + // Step 2: Export to files + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename); - }, [displayDocument, mergedPdfDocument, splitPositions]); + // Step 3: Create StirlingFiles and stubs for version history + const parentStub = selectors.getStirlingFileStub(activeFileIds[0]); + if (!parentStub) throw new Error('Parent stub not found'); + + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool'); + + // Step 4: Consume files (replace in context) + await actions.consumeFiles(activeFileIds, stirlingFiles, stubs); + + setHasUnsavedChanges(false); + setExportLoading(false); + } catch (error) { + console.error('Apply changes failed:', error); + setExportLoading(false); + } + }, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]); const closePdf = useCallback(() => { @@ -793,7 +785,7 @@ const PageEditor = ({ { - applyChanges(); + await applyChanges(); }} onExportAndContinue={async () => { await onExportAll(); diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 6abda0c78..59e5819d9 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -375,6 +375,7 @@ const PageThumbnail: React.FC = ({ src={thumbnailUrl} alt={`Page ${page.pageNumber}`} draggable={false} + data-original-rotation={page.rotation} style={{ width: '100%', height: '100%', diff --git a/frontend/src/components/pageEditor/commands/pageCommands.ts b/frontend/src/components/pageEditor/commands/pageCommands.ts index 26cb9e09c..7ac6a8377 100644 --- a/frontend/src/components/pageEditor/commands/pageCommands.ts +++ b/frontend/src/components/pageEditor/commands/pageCommands.ts @@ -17,32 +17,34 @@ export class RotatePageCommand extends DOMCommand { } execute(): void { - // Only update DOM for immediate visual feedback const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`); if (pageElement) { const img = pageElement.querySelector('img'); if (img) { - // Extract current rotation from transform property to match the animated CSS const currentTransform = img.style.transform || ''; const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; - const newRotation = currentRotation + this.degrees; + let newRotation = currentRotation + this.degrees; + + newRotation = ((newRotation % 360) + 360) % 360; + img.style.transform = `rotate(${newRotation}deg)`; } } } undo(): void { - // Only update DOM const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`); if (pageElement) { const img = pageElement.querySelector('img'); if (img) { - // Extract current rotation from transform property const currentTransform = img.style.transform || ''; const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; - const previousRotation = currentRotation - this.degrees; + let previousRotation = currentRotation - this.degrees; + + previousRotation = ((previousRotation % 360) + 360) % 360; + img.style.transform = `rotate(${previousRotation}deg)`; } } diff --git a/frontend/src/components/shared/NavigationWarningModal.tsx b/frontend/src/components/shared/NavigationWarningModal.tsx index 203e66ff7..b1b935738 100644 --- a/frontend/src/components/shared/NavigationWarningModal.tsx +++ b/frontend/src/components/shared/NavigationWarningModal.tsx @@ -8,7 +8,7 @@ interface NavigationWarningModalProps { } const NavigationWarningModal = ({ - onApplyAndContinue: _onApplyAndContinue, + onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => { @@ -30,6 +30,13 @@ const NavigationWarningModal = ({ confirmNavigation(); }; + const handleApplyAndContinue = async () => { + if (onApplyAndContinue) { + await onApplyAndContinue(); + } + setHasUnsavedChanges(false); + confirmNavigation(); + }; const handleExportAndContinue = async () => { if (onExportAndContinue) { @@ -49,26 +56,25 @@ const NavigationWarningModal = ({ onClose={handleKeepWorking} title={t("unsavedChangesTitle", "Unsaved Changes")} centered - size="lg" + size="xl" closeOnClickOutside={false} closeOnEscape={false} > - - + + {t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")} + + + - - - - + - {/* TODO:: Add this back in when it works */} - {/* {_onApplyAndContinue && ( + + {onExportAndContinue && ( + + )} + + {onApplyAndContinue && ( - )} */} - - {onExportAndContinue && ( - )} diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx index f8a7102fa..f8c6d0e4f 100644 --- a/frontend/src/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx @@ -1,16 +1,18 @@ -import React from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { Box, Center, Text, ActionIcon } from '@mantine/core'; import { useMantineTheme, useMantineColorScheme } from '@mantine/core'; import CloseIcon from '@mui/icons-material/Close'; -import { useFileState } from "../../contexts/FileContext"; +import { useFileState, useFileActions } from "../../contexts/FileContext"; import { useFileWithUrl } from "../../hooks/useFileWithUrl"; import { useViewer } from "../../contexts/ViewerContext"; import { LocalEmbedPDF } from './LocalEmbedPDF'; import { PdfViewerToolbar } from './PdfViewerToolbar'; import { ThumbnailSidebar } from './ThumbnailSidebar'; -import { useNavigationState } from '../../contexts/NavigationContext'; +import { useNavigationGuard, useNavigationState } from '../../contexts/NavigationContext'; import { useSignature } from '../../contexts/SignatureContext'; +import { createStirlingFilesAndStubs } from '../../services/fileStubHelpers'; +import NavigationWarningModal from '../shared/NavigationWarningModal'; export interface EmbedPdfViewerProps { sidebarsVisible: boolean; @@ -29,11 +31,33 @@ const EmbedPdfViewerContent = ({ const { colorScheme: _colorScheme } = useMantineColorScheme(); const viewerRef = React.useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); - const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, isAnnotationMode, isAnnotationsVisible } = useViewer(); + + const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); const scrollState = getScrollState(); const zoomState = getZoomState(); const spreadState = getSpreadState(); + const rotationState = getRotationState(); + + // Track initial rotation to detect changes + const initialRotationRef = useRef(null); + useEffect(() => { + if (initialRotationRef.current === null && rotationState.rotation !== undefined) { + initialRotationRef.current = rotationState.rotation; + } + }, [rotationState.rotation]); + + // Get signature context + const { signatureApiRef, historyApiRef } = useSignature(); + + // Get current file from FileContext + const { selectors } = useFileState(); + const { actions } = useFileActions(); + const activeFiles = selectors.getFiles(); + const activeFileIds = activeFiles.map(f => f.fileId); + + // Navigation guard for unsaved changes + const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard(); // Check if we're in signature mode OR viewer annotation mode const { selectedTool } = useNavigationState(); @@ -42,13 +66,6 @@ const EmbedPdfViewerContent = ({ // Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible; - // Get signature context - const { signatureApiRef, historyApiRef } = useSignature(); - - // Get current file from FileContext - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); - // Determine which file to display const currentFile = React.useMemo(() => { if (previewFile) { @@ -134,6 +151,65 @@ const EmbedPdfViewerContent = ({ }; }, [isViewerHovered]); + // Register checker for unsaved changes (annotations only for now) + useEffect(() => { + if (previewFile) { + return; + } + + const checkForChanges = () => { + // Check for annotation changes via history + const hasAnnotationChanges = historyApiRef.current?.canUndo() || false; + + console.log('[Viewer] Checking for unsaved changes:', { + hasAnnotationChanges + }); + return hasAnnotationChanges; + }; + + console.log('[Viewer] Registering unsaved changes checker'); + registerUnsavedChangesChecker(checkForChanges); + + return () => { + console.log('[Viewer] Unregistering unsaved changes checker'); + unregisterUnsavedChangesChecker(); + }; + }, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); + + // Apply changes - save annotations to new file version + const applyChanges = useCallback(async () => { + if (!currentFile || activeFileIds.length === 0) return; + + try { + console.log('[Viewer] Applying changes - exporting PDF with annotations'); + + // Step 1: Export PDF with annotations using EmbedPDF + const arrayBuffer = await exportActions.saveAsCopy(); + if (!arrayBuffer) { + throw new Error('Failed to export PDF'); + } + + console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength); + + // Step 2: Convert ArrayBuffer to File + const blob = new Blob([arrayBuffer], { type: 'application/pdf' }); + const filename = currentFile.name || 'document.pdf'; + const file = new File([blob], filename, { type: 'application/pdf' }); + + // Step 3: Create StirlingFiles and stubs for version history + const parentStub = selectors.getStirlingFileStub(activeFileIds[0]); + if (!parentStub) throw new Error('Parent stub not found'); + + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool'); + + // Step 4: Consume files (replace in context) + await actions.consumeFiles(activeFileIds, stirlingFiles, stubs); + + setHasUnsavedChanges(false); + } catch (error) { + console.error('Apply changes failed:', error); + } + }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]); return ( + + {/* Navigation Warning Modal */} + {!previewFile && ( + { + await applyChanges(); + }} + /> + )} ); }; diff --git a/frontend/src/components/viewer/SignatureAPIBridge.tsx b/frontend/src/components/viewer/SignatureAPIBridge.tsx index 59fbe43e6..6ff048575 100644 --- a/frontend/src/components/viewer/SignatureAPIBridge.tsx +++ b/frontend/src/components/viewer/SignatureAPIBridge.tsx @@ -28,7 +28,7 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI useEffect(() => { if (!annotationApi || (!isPlacementMode && !isAnnotationMode)) return; - const handleKeyDown = (event: KeyboardEvent) => { + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Delete' || event.key === 'Backspace') { const selectedAnnotation = annotationApi.getSelectedAnnotation?.(); diff --git a/frontend/src/contexts/NavigationContext.tsx b/frontend/src/contexts/NavigationContext.tsx index b71664b6b..3d9b4849c 100644 --- a/frontend/src/contexts/NavigationContext.tsx +++ b/frontend/src/contexts/NavigationContext.tsx @@ -74,6 +74,8 @@ export interface NavigationContextActions { setSelectedTool: (toolId: ToolId | null) => void; setToolAndWorkbench: (toolId: ToolId | null, workbench: WorkbenchType) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; + registerUnsavedChangesChecker: (checker: () => boolean) => void; + unregisterUnsavedChangesChecker: () => void; showNavigationWarning: (show: boolean) => void; requestNavigation: (navigationFn: () => void) => void; confirmNavigation: () => void; @@ -106,11 +108,29 @@ export const NavigationProvider: React.FC<{ }> = ({ children }) => { const [state, dispatch] = useReducer(navigationReducer, initialState); const toolRegistry = useFlatToolRegistry(); + const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null); const actions: NavigationContextActions = { setWorkbench: useCallback((workbench: WorkbenchType) => { - // If we're leaving pageEditor workbench and have unsaved changes, request navigation - if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) { + // Check for unsaved changes using registered checker or state + const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; + console.log('[NavigationContext] setWorkbench:', { + from: state.workbench, + to: workbench, + hasChecker: !!unsavedChangesCheckerRef.current, + hasUnsavedChanges + }); + + // If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation + const leavingWorkbenchWithChanges = + (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) || + (state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges); + + if (leavingWorkbenchWithChanges) { + // Update state to reflect unsaved changes so modal knows + if (!state.hasUnsavedChanges) { + dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges: true } }); + } const performWorkbenchChange = () => { dispatch({ type: 'SET_WORKBENCH', payload: { workbench } }); }; @@ -126,8 +146,15 @@ export const NavigationProvider: React.FC<{ }, []), setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => { - // If we're leaving pageEditor workbench and have unsaved changes, request navigation - if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) { + // Check for unsaved changes using registered checker or state + const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; + + // If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation + const leavingWorkbenchWithChanges = + (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) || + (state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges); + + if (leavingWorkbenchWithChanges) { const performWorkbenchChange = () => { dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } }); }; @@ -142,6 +169,14 @@ export const NavigationProvider: React.FC<{ dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); }, []), + registerUnsavedChangesChecker: useCallback((checker: () => boolean) => { + unsavedChangesCheckerRef.current = checker; + }, []), + + unregisterUnsavedChangesChecker: useCallback(() => { + unsavedChangesCheckerRef.current = null; + }, []), + showNavigationWarning: useCallback((show: boolean) => { dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } }); }, []), @@ -254,6 +289,8 @@ export const useNavigationGuard = () => { confirmNavigation: actions.confirmNavigation, cancelNavigation: actions.cancelNavigation, setHasUnsavedChanges: actions.setHasUnsavedChanges, - setShowNavigationWarning: actions.showNavigationWarning + setShowNavigationWarning: actions.showNavigationWarning, + registerUnsavedChangesChecker: actions.registerUnsavedChangesChecker, + unregisterUnsavedChangesChecker: actions.unregisterUnsavedChangesChecker }; }; diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index 1c817132a..3f3ec07c7 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -57,13 +57,13 @@ const addFilesMutex = new SimpleMutex(); /** * Helper to create ProcessedFile metadata structure */ -export function createProcessedFile(pageCount: number, thumbnail?: string) { +export function createProcessedFile(pageCount: number, thumbnail?: string, pageRotations?: number[]) { return { totalPages: pageCount, pages: Array.from({ length: pageCount }, (_, index) => ({ pageNumber: index + 1, thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially - rotation: 0, + rotation: pageRotations?.[index] ?? 0, splitBefore: false })), thumbnailUrl: thumbnail, @@ -82,8 +82,22 @@ export async function generateProcessedFileMetadata(file: File): Promise): PDFDocument | PDFDocument[] { - console.log('DocumentManipulationService: Applying DOM changes to document'); - console.log('Original document page order:', pdfDocument.pages.map(p => p.pageNumber)); - console.log('Current display order:', currentDisplayOrder?.pages.map(p => p.pageNumber) || 'none provided'); - console.log('Split positions:', splitPositions ? Array.from(splitPositions).sort() : 'none'); - // Use current display order (from React state) if provided, otherwise use original order const baseDocument = currentDisplayOrder || pdfDocument; - console.log('Using page order:', baseDocument.pages.map(p => p.pageNumber)); // Apply DOM changes to each page (rotation only now, splits are position-based) let updatedPages = baseDocument.pages.map(page => this.applyPageChanges(page)); @@ -57,32 +51,25 @@ export class DocumentManipulationService { private createSplitDocuments(document: PDFDocument): PDFDocument[] { const documents: PDFDocument[] = []; const splitPoints: number[] = []; - + // Find split points document.pages.forEach((page, index) => { if (page.splitAfter) { - console.log(`Found split marker at page ${page.pageNumber} (index ${index}), adding split point at ${index + 1}`); splitPoints.push(index + 1); } }); - + // Add end point if not already there if (splitPoints.length === 0 || splitPoints[splitPoints.length - 1] !== document.pages.length) { splitPoints.push(document.pages.length); } - - console.log('Final split points:', splitPoints); - console.log('Total pages to split:', document.pages.length); - + let startIndex = 0; let partNumber = 1; - + for (const endIndex of splitPoints) { const segmentPages = document.pages.slice(startIndex, endIndex); - - console.log(`Creating split document ${partNumber}: pages ${startIndex}-${endIndex-1} (${segmentPages.length} pages)`); - console.log(`Split document ${partNumber} page numbers:`, segmentPages.map(p => p.pageNumber)); - + if (segmentPages.length > 0) { documents.push({ ...document, @@ -93,11 +80,10 @@ export class DocumentManipulationService { }); partNumber++; } - + startIndex = endIndex; } - - console.log(`Created ${documents.length} split documents`); + return documents; } @@ -108,7 +94,6 @@ export class DocumentManipulationService { // Find the DOM element for this page const pageElement = document.querySelector(`[data-page-id="${page.id}"]`); if (!pageElement) { - console.log(`Page ${page.pageNumber}: No DOM element found, keeping original state`); return page; } @@ -116,8 +101,7 @@ export class DocumentManipulationService { // Apply rotation changes from DOM updatedPage.rotation = this.getRotationFromDOM(pageElement, page); - - + return updatedPage; } @@ -126,16 +110,21 @@ export class DocumentManipulationService { */ private getRotationFromDOM(pageElement: Element, originalPage: PDFPage): number { const img = pageElement.querySelector('img'); - if (img && img.style.transform) { - // Parse rotation from transform property (e.g., "rotate(90deg)" -> 90) - const rotationMatch = img.style.transform.match(/rotate\((-?\d+)deg\)/); - const domRotation = rotationMatch ? parseInt(rotationMatch[1]) : 0; - - console.log(`Page ${originalPage.pageNumber}: DOM rotation = ${domRotation}°, original = ${originalPage.rotation}°`); - return domRotation; + if (img) { + const originalRotation = parseInt(img.getAttribute('data-original-rotation') || '0'); + + const currentTransform = img.style.transform || ''; + const rotationMatch = currentTransform.match(/rotate\((-?\d+)deg\)/); + const visualRotation = rotationMatch ? parseInt(rotationMatch[1]) : originalRotation; + + const userChange = ((visualRotation - originalRotation) % 360 + 360) % 360; + + let finalRotation = (originalPage.rotation + userChange) % 360; + if (finalRotation === 360) finalRotation = 0; + + return finalRotation; } - - console.log(`Page ${originalPage.pageNumber}: No DOM rotation found, keeping original = ${originalPage.rotation}°`); + return originalPage.rotation; } diff --git a/frontend/src/services/enhancedPDFProcessingService.ts b/frontend/src/services/enhancedPDFProcessingService.ts index bee6e200a..be58d0a6e 100644 --- a/frontend/src/services/enhancedPDFProcessingService.ts +++ b/frontend/src/services/enhancedPDFProcessingService.ts @@ -200,11 +200,13 @@ export class EnhancedPDFProcessingService { const page = await pdf.getPage(i); const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); + const rotation = page.rotate || 0; + pages.push({ id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, - rotation: 0, + rotation, selected: false }); @@ -254,7 +256,7 @@ export class EnhancedPDFProcessingService { id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, - rotation: 0, + rotation: page.rotate || 0, selected: false }); @@ -265,11 +267,15 @@ export class EnhancedPDFProcessingService { // Create placeholder pages for remaining pages for (let i = priorityCount + 1; i <= totalPages; i++) { + // Load page just to get rotation + const page = await pdf.getPage(i); + const rotation = page.rotate || 0; + pages.push({ id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail: null, // Will be loaded lazily - rotation: 0, + rotation, selected: false }); } @@ -316,7 +322,7 @@ export class EnhancedPDFProcessingService { id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, - rotation: 0, + rotation: page.rotate || 0, selected: false }); @@ -333,11 +339,15 @@ export class EnhancedPDFProcessingService { // Create placeholders for remaining pages for (let i = firstChunkEnd + 1; i <= totalPages; i++) { + // Load page just to get rotation + const page = await pdf.getPage(i); + const rotation = page.rotate || 0; + pages.push({ id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail: null, - rotation: 0, + rotation, selected: false }); } @@ -367,11 +377,15 @@ export class EnhancedPDFProcessingService { // Create placeholder pages without thumbnails const pages: PDFPage[] = []; for (let i = 1; i <= totalPages; i++) { + // Load page just to get rotation + const page = await pdf.getPage(i); + const rotation = page.rotate || 0; + pages.push({ id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail: null, - rotation: 0, + rotation, selected: false }); } @@ -390,7 +404,7 @@ export class EnhancedPDFProcessingService { const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor const scale = scales[quality]; - const viewport = page.getViewport({ scale }); + const viewport = page.getViewport({ scale, rotation: 0 }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; diff --git a/frontend/src/services/fileStubHelpers.ts b/frontend/src/services/fileStubHelpers.ts new file mode 100644 index 000000000..5f5545e76 --- /dev/null +++ b/frontend/src/services/fileStubHelpers.ts @@ -0,0 +1,34 @@ +import { StirlingFile, StirlingFileStub } from '../types/fileContext'; +import { createChildStub, generateProcessedFileMetadata } from '../contexts/file/fileActions'; +import { createStirlingFile } from '../types/fileContext'; +import { ToolId } from '../types/toolId'; + +/** + * Create StirlingFiles and StirlingFileStubs from exported files + * Used when saving page editor changes to create version history + */ +export async function createStirlingFilesAndStubs( + files: File[], + parentStub: StirlingFileStub, + toolId: ToolId +): Promise<{ stirlingFiles: StirlingFile[], stubs: StirlingFileStub[] }> { + const stirlingFiles: StirlingFile[] = []; + const stubs: StirlingFileStub[] = []; + + for (const file of files) { + const processedFileMetadata = await generateProcessedFileMetadata(file); + const childStub = createChildStub( + parentStub, + { toolId, timestamp: Date.now() }, + file, + processedFileMetadata?.thumbnailUrl, + processedFileMetadata + ); + + const stirlingFile = createStirlingFile(file, childStub.id); + stirlingFiles.push(stirlingFile); + stubs.push(childStub); + } + + return { stirlingFiles, stubs }; +} diff --git a/frontend/src/services/pdfExportHelpers.ts b/frontend/src/services/pdfExportHelpers.ts new file mode 100644 index 000000000..fa6d4775f --- /dev/null +++ b/frontend/src/services/pdfExportHelpers.ts @@ -0,0 +1,46 @@ +import { PDFDocument } from '../types/pageEditor'; +import { pdfExportService } from './pdfExportService'; +import { FileId } from '../types/file'; + +/** + * Export processed documents to File objects + * Handles both single documents and split documents (multiple PDFs) + */ +export async function exportProcessedDocumentsToFiles( + processedDocuments: PDFDocument | PDFDocument[], + sourceFiles: Map | null, + exportFilename: string +): Promise { + console.log('exportProcessedDocumentsToFiles called with:', { + isArray: Array.isArray(processedDocuments), + numDocs: Array.isArray(processedDocuments) ? processedDocuments.length : 1, + hasSourceFiles: sourceFiles !== null, + sourceFilesSize: sourceFiles?.size + }); + + if (Array.isArray(processedDocuments)) { + // Multiple documents (splits) + const files: File[] = []; + const baseName = exportFilename.replace(/\.pdf$/i, ''); + + for (let i = 0; i < processedDocuments.length; i++) { + const doc = processedDocuments[i]; + const partFilename = `${baseName}_part_${i + 1}.pdf`; + + const result = sourceFiles + ? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { selectedOnly: false, filename: partFilename }) + : await pdfExportService.exportPDF(doc, [], { selectedOnly: false, filename: partFilename }); + + files.push(new File([result.blob], result.filename, { type: 'application/pdf' })); + } + + return files; + } else { + // Single document + const result = sourceFiles + ? await pdfExportService.exportPDFMultiFile(processedDocuments, sourceFiles, [], { selectedOnly: false, filename: exportFilename }) + : await pdfExportService.exportPDF(processedDocuments, [], { selectedOnly: false, filename: exportFilename }); + + return [new File([result.blob], result.filename, { type: 'application/pdf' })]; + } +} diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts index 42ad672d2..d42eeeab2 100644 --- a/frontend/src/services/pdfExportService.ts +++ b/frontend/src/services/pdfExportService.ts @@ -98,10 +98,7 @@ export class PDFExportService { // Create a blank page const blankPage = newDoc.addPage(PageSizes.A4); - // Apply rotation if needed - if (page.rotation !== 0) { - blankPage.setRotation(degrees(page.rotation)); - } + blankPage.setRotation(degrees(page.rotation)); } else if (page.originalFileId && loadedDocs.has(page.originalFileId)) { // Get the correct source document for this page const sourceDoc = loadedDocs.get(page.originalFileId)!; @@ -111,10 +108,7 @@ export class PDFExportService { // Copy the page from the correct source document const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); - // Apply rotation - if (page.rotation !== 0) { - copiedPage.setRotation(degrees(page.rotation)); - } + copiedPage.setRotation(degrees(page.rotation)); newDoc.addPage(copiedPage); } @@ -147,10 +141,7 @@ export class PDFExportService { // Create a blank page const blankPage = newDoc.addPage(PageSizes.A4); - // Apply rotation if needed - if (page.rotation !== 0) { - blankPage.setRotation(degrees(page.rotation)); - } + blankPage.setRotation(degrees(page.rotation)); } else { // Get the original page from source document using originalPageNumber const sourcePageIndex = page.originalPageNumber - 1; @@ -159,10 +150,7 @@ export class PDFExportService { // Copy the page const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); - // Apply rotation - if (page.rotation !== 0) { - copiedPage.setRotation(degrees(page.rotation)); - } + copiedPage.setRotation(degrees(page.rotation)); newDoc.addPage(copiedPage); } diff --git a/frontend/src/services/thumbnailGenerationService.ts b/frontend/src/services/thumbnailGenerationService.ts index fa0f17bdd..2d76a623f 100644 --- a/frontend/src/services/thumbnailGenerationService.ts +++ b/frontend/src/services/thumbnailGenerationService.ts @@ -164,7 +164,7 @@ export class ThumbnailGenerationService { for (const pageNumber of batch) { try { const page = await pdf.getPage(pageNumber); - const viewport = page.getViewport({ scale }); + const viewport = page.getViewport({ scale, rotation: 0 }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index d00b67413..84dc64457 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -3,6 +3,7 @@ import { pdfWorkerManager } from '../services/pdfWorkerManager'; export interface ThumbnailWithMetadata { thumbnail: string; // Always returns a thumbnail (placeholder if needed) pageCount: number; + pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270) } interface ColorScheme { @@ -377,8 +378,10 @@ export async function generateThumbnailForFile(file: File): Promise { /** * Generate thumbnail and extract page count for a PDF file - always returns a valid thumbnail + * @param applyRotation - If true, render thumbnail with PDF rotation applied (for static display). + * If false, render without rotation (for CSS-based rotation in PageEditor) */ -export async function generateThumbnailWithMetadata(file: File): Promise { +export async function generateThumbnailWithMetadata(file: File, applyRotation: boolean = true): Promise { // Non-PDF files have no page count if (!file.type.startsWith('application/pdf')) { const thumbnail = await generateThumbnailForFile(file); @@ -399,7 +402,13 @@ export async function generateThumbnailWithMetadata(file: File): Promise Date: Fri, 3 Oct 2025 09:19:44 +0100 Subject: [PATCH 4/4] Bug/v2/mime (#4582) # Description of Changes --- ## 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. --- frontend/src/components/fileEditor/AddFileCard.tsx | 2 +- frontend/src/components/shared/FileUploadButton.tsx | 2 +- frontend/src/components/shared/LandingPage.tsx | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/fileEditor/AddFileCard.tsx b/frontend/src/components/fileEditor/AddFileCard.tsx index dbc319c68..a873b7211 100644 --- a/frontend/src/components/fileEditor/AddFileCard.tsx +++ b/frontend/src/components/fileEditor/AddFileCard.tsx @@ -15,7 +15,7 @@ interface AddFileCardProps { const AddFileCard = ({ onFileSelect, - accept = "*/*", + accept, multiple = true }: AddFileCardProps) => { const { t } = useTranslation(); diff --git a/frontend/src/components/shared/FileUploadButton.tsx b/frontend/src/components/shared/FileUploadButton.tsx index f09cc19d0..85c3d5817 100644 --- a/frontend/src/components/shared/FileUploadButton.tsx +++ b/frontend/src/components/shared/FileUploadButton.tsx @@ -15,7 +15,7 @@ interface FileUploadButtonProps { const FileUploadButton = ({ file, onChange, - accept = "*/*", + accept, disabled = false, placeholder, variant = "outline", diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 5f1fe8d8e..293efc1b4 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -41,7 +41,6 @@ const LandingPage = () => { {/* White PDF Page Background */} { ref={fileInputRef} type="file" multiple - accept="*/*" onChange={handleFileSelect} style={{ display: 'none' }} />