From c76edebf0f4564971dc101208a5ca90c5d09c176 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 22 Sep 2025 14:06:20 +0100 Subject: [PATCH] Add Crop to V2 (#4471) # Description of Changes Add Crop to V2 --------- Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Co-authored-by: Connor Yoh Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> --- .../public/locales/en-GB/translation.json | 31 +- .../tools/crop/CropAreaSelector.tsx | 300 ++++++++++++++++++ .../components/tools/crop/CropSettings.tsx | 262 +++++++++++++++ .../components/tooltips/useCropTooltips.ts | 21 ++ frontend/src/constants/cropConstants.ts | 12 + frontend/src/constants/pageSizeConstants.ts | 8 + .../src/data/useTranslatedToolRegistry.tsx | 9 +- .../src/hooks/tools/crop/useCropOperation.ts | 39 +++ .../src/hooks/tools/crop/useCropParameters.ts | 141 ++++++++ frontend/src/theme/mantineTheme.ts | 10 + frontend/src/tools/Crop.tsx | 59 ++++ frontend/src/utils/cropCoordinates.ts | 218 +++++++++++++ testing/crop_test.pdf | Bin 0 -> 26033 bytes 13 files changed, 1108 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/tools/crop/CropAreaSelector.tsx create mode 100644 frontend/src/components/tools/crop/CropSettings.tsx create mode 100644 frontend/src/components/tooltips/useCropTooltips.ts create mode 100644 frontend/src/constants/cropConstants.ts create mode 100644 frontend/src/constants/pageSizeConstants.ts create mode 100644 frontend/src/hooks/tools/crop/useCropOperation.ts create mode 100644 frontend/src/hooks/tools/crop/useCropParameters.ts create mode 100644 frontend/src/tools/Crop.tsx create mode 100644 frontend/src/utils/cropCoordinates.ts create mode 100644 testing/crop_test.pdf diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 0049825cb7..ad726085fd 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1897,7 +1897,36 @@ "tags": "trim,shrink,edit,shape", "title": "Crop", "header": "Crop PDF", - "submit": "Submit" + "submit": "Apply Crop", + "noFileSelected": "Select a PDF file to begin cropping", + "preview": { + "title": "Crop Area Selection" + }, + "reset": "Reset to full PDF", + "coordinates": { + "title": "Position and Size", + "x": "X Position", + "y": "Y Position", + "width": "Width", + "height": "Height" + }, + "error": { + "invalidArea": "Crop area extends beyond PDF boundaries", + "failed": "Failed to crop PDF" + }, + "steps": { + "selectArea": "Select Crop Area" + }, + "tooltip": { + "title": "How to Crop PDFs", + "description": "Select the area to crop from your PDF by dragging and resizing the blue overlay on the thumbnail.", + "drag": "Drag the overlay to move the crop area", + "resize": "Drag the corner and edge handles to resize", + "precision": "Use coordinate inputs for precise positioning" + }, + "results": { + "title": "Crop Results" + } }, "autoSplitPDF": { "tags": "QR-based,separate,scan-segment,organize", diff --git a/frontend/src/components/tools/crop/CropAreaSelector.tsx b/frontend/src/components/tools/crop/CropAreaSelector.tsx new file mode 100644 index 0000000000..75d326210e --- /dev/null +++ b/frontend/src/components/tools/crop/CropAreaSelector.tsx @@ -0,0 +1,300 @@ +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { Box, useMantineTheme, MantineTheme } from '@mantine/core'; +import { + PDFBounds, + Rectangle, + domToPDFCoordinates, + pdfToDOMCoordinates, + constrainDOMRectToThumbnail, + isPointInThumbnail +} from '../../../utils/cropCoordinates'; +import { type ResizeHandle } from '../../../constants/cropConstants'; + +interface CropAreaSelectorProps { + /** PDF bounds for coordinate conversion */ + pdfBounds: PDFBounds; + /** Current crop area in PDF coordinates */ + cropArea: Rectangle; + /** Callback when crop area changes */ + onCropAreaChange: (cropArea: Rectangle) => void; + /** Whether the selector is disabled */ + disabled?: boolean; + /** Child content (typically the PDF thumbnail) */ + children: React.ReactNode; +} + +const CropAreaSelector: React.FC = ({ + pdfBounds, + cropArea, + onCropAreaChange, + disabled = false, + children +}) => { + const theme = useMantineTheme(); + const containerRef = useRef(null); + const overlayRef = useRef(null); + + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(null); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [initialCropArea, setInitialCropArea] = useState(cropArea); + + // Convert PDF crop area to DOM coordinates for display + const domRect = pdfToDOMCoordinates(cropArea, pdfBounds); + + // Handle mouse down on overlay (start dragging or resizing) + const handleOverlayMouseDown = useCallback((e: React.MouseEvent) => { + if (disabled || !containerRef.current) return; + + e.preventDefault(); + e.stopPropagation(); + + const rect = containerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Check if we're clicking on a resize handle first (higher priority) + const handle = getResizeHandle(x, y, domRect); + + if (handle) { + setIsResizing(handle); + setInitialCropArea(cropArea); + setIsDragging(false); // Ensure we're not dragging when resizing + } else if (isPointInCropArea(x, y, domRect)) { + // Only allow dragging if we're not on a resize handle + setIsDragging(true); + setIsResizing(null); // Ensure we're not resizing when dragging + setDragStart({ x: x - domRect.x, y: y - domRect.y }); + } + }, [disabled, cropArea, domRect]); + + // Handle mouse down on container (start new selection) + const handleContainerMouseDown = useCallback((e: React.MouseEvent) => { + if (disabled || !containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Only start new selection if clicking within thumbnail area + if (!isPointInThumbnail(x, y, pdfBounds)) return; + + e.preventDefault(); + e.stopPropagation(); + + // Start new crop selection + const newDomRect: Rectangle = { x, y, width: 20, height: 20 }; + const constrainedRect = constrainDOMRectToThumbnail(newDomRect, pdfBounds); + const newCropArea = domToPDFCoordinates(constrainedRect, pdfBounds); + + onCropAreaChange(newCropArea); + setIsResizing('se'); // Start resizing from the southeast corner + setInitialCropArea(newCropArea); + }, [disabled, pdfBounds, onCropAreaChange]); + + // Handle mouse move + const handleMouseMove = useCallback((e: MouseEvent) => { + if (disabled || !containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (isDragging) { + // Dragging the entire crop area + const newX = x - dragStart.x; + const newY = y - dragStart.y; + + const newDomRect: Rectangle = { + x: newX, + y: newY, + width: domRect.width, + height: domRect.height + }; + + const constrainedRect = constrainDOMRectToThumbnail(newDomRect, pdfBounds); + const newCropArea = domToPDFCoordinates(constrainedRect, pdfBounds); + onCropAreaChange(newCropArea); + + } else if (isResizing) { + // Resizing the crop area + const newDomRect = calculateResizedRect(isResizing, domRect, x, y); + const constrainedRect = constrainDOMRectToThumbnail(newDomRect, pdfBounds); + const newCropArea = domToPDFCoordinates(constrainedRect, pdfBounds); + onCropAreaChange(newCropArea); + } + }, [disabled, isDragging, isResizing, dragStart, domRect, initialCropArea, pdfBounds, onCropAreaChange]); + + // Handle mouse up + const handleMouseUp = useCallback(() => { + setIsDragging(false); + setIsResizing(null); + }, []); + + // Add global mouse event listeners + useEffect(() => { + if (isDragging || isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, isResizing, handleMouseMove, handleMouseUp]); + + return ( + + {/* PDF Thumbnail Content */} + {children} + + {/* Crop Area Overlay */} + {!disabled && ( + + {/* Resize Handles */} + {renderResizeHandles(disabled, theme)} + + )} + + ); +}; + +// Helper functions + +function getResizeHandle(x: number, y: number, domRect: Rectangle): ResizeHandle { + const handleSize = 8; + const tolerance = handleSize; + + // Corner handles (check these first, they have priority) + if (isNear(x, domRect.x, tolerance) && isNear(y, domRect.y, tolerance)) return 'nw'; + if (isNear(x, domRect.x + domRect.width, tolerance) && isNear(y, domRect.y, tolerance)) return 'ne'; + if (isNear(x, domRect.x, tolerance) && isNear(y, domRect.y + domRect.height, tolerance)) return 'sw'; + if (isNear(x, domRect.x + domRect.width, tolerance) && isNear(y, domRect.y + domRect.height, tolerance)) return 'se'; + + // Edge handles (only if not in corner area) + if (isNear(x, domRect.x + domRect.width / 2, tolerance) && isNear(y, domRect.y, tolerance)) return 'n'; + if (isNear(x, domRect.x + domRect.width, tolerance) && isNear(y, domRect.y + domRect.height / 2, tolerance)) return 'e'; + if (isNear(x, domRect.x + domRect.width / 2, tolerance) && isNear(y, domRect.y + domRect.height, tolerance)) return 's'; + if (isNear(x, domRect.x, tolerance) && isNear(y, domRect.y + domRect.height / 2, tolerance)) return 'w'; + + return null; +} + +function isNear(a: number, b: number, tolerance: number): boolean { + return Math.abs(a - b) <= tolerance; +} + +function isPointInCropArea(x: number, y: number, domRect: Rectangle): boolean { + return x >= domRect.x && x <= domRect.x + domRect.width && + y >= domRect.y && y <= domRect.y + domRect.height; +} + +function calculateResizedRect( + handle: ResizeHandle, + currentRect: Rectangle, + mouseX: number, + mouseY: number, +): Rectangle { + let { x, y, width, height } = currentRect; + + switch (handle) { + case 'nw': + width += x - mouseX; + height += y - mouseY; + x = mouseX; + y = mouseY; + break; + case 'ne': + width = mouseX - x; + height += y - mouseY; + y = mouseY; + break; + case 'sw': + width += x - mouseX; + height = mouseY - y; + x = mouseX; + break; + case 'se': + width = mouseX - x; + height = mouseY - y; + break; + case 'n': + height += y - mouseY; + y = mouseY; + break; + case 'e': + width = mouseX - x; + break; + case 's': + height = mouseY - y; + break; + case 'w': + width += x - mouseX; + x = mouseX; + break; + } + + // Enforce minimum size + width = Math.max(10, width); + height = Math.max(10, height); + + return { x, y, width, height }; +} + +function renderResizeHandles(disabled: boolean, theme: MantineTheme) { + if (disabled) return null; + + const handleSize = 8; + const handleStyle = { + position: 'absolute' as const, + width: handleSize, + height: handleSize, + backgroundColor: theme.other.crop.handleColor, + border: `1px solid ${theme.other.crop.handleBorder}`, + borderRadius: '2px', + pointerEvents: 'auto' as const + }; + + return ( + <> + {/* Corner handles */} + + + + + + {/* Edge handles */} + + + + + + ); +} + +export default CropAreaSelector; diff --git a/frontend/src/components/tools/crop/CropSettings.tsx b/frontend/src/components/tools/crop/CropSettings.tsx new file mode 100644 index 0000000000..dafcdeaabe --- /dev/null +++ b/frontend/src/components/tools/crop/CropSettings.tsx @@ -0,0 +1,262 @@ +import { useMemo, useState, useEffect } from "react"; +import { Stack, Text, Box, Group, NumberInput, ActionIcon, Center, Alert } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import RestartAltIcon from "@mui/icons-material/RestartAlt"; +import { CropParametersHook } from "../../../hooks/tools/crop/useCropParameters"; +import { useSelectedFiles } from "../../../contexts/file/fileHooks"; +import CropAreaSelector from "./CropAreaSelector"; +import { DEFAULT_CROP_AREA } from "../../../constants/cropConstants"; +import { PAGE_SIZES } from "../../../constants/pageSizeConstants"; +import { + calculatePDFBounds, + PDFBounds, + Rectangle +} from "../../../utils/cropCoordinates"; +import { pdfWorkerManager } from "../../../services/pdfWorkerManager"; +import DocumentThumbnail from "../../shared/filePreview/DocumentThumbnail"; + +interface CropSettingsProps { + parameters: CropParametersHook; + disabled?: boolean; +} + +const CONTAINER_SIZE = 250; // Fit within actual pane width + +const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { + const { t } = useTranslation(); + const { selectedFiles, selectedFileStubs } = useSelectedFiles(); + + // Get the first selected file for preview + const selectedStub = useMemo(() => { + return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null; + }, [selectedFileStubs]); + + // Get the first selected file for PDF processing + const selectedFile = useMemo(() => { + return selectedFiles.length > 0 ? selectedFiles[0] : null; + }, [selectedFiles]); + + // Get thumbnail for the selected file + const [thumbnail, setThumbnail] = useState(null); + const [pdfBounds, setPdfBounds] = useState(null); + + useEffect(() => { + const loadPDFDimensions = async () => { + if (!selectedStub || !selectedFile) { + setPdfBounds(null); + setThumbnail(null); + return; + } + + setThumbnail(selectedStub.thumbnailUrl || null); + + try { + // Get PDF dimensions from the actual file + const arrayBuffer = await selectedFile.arrayBuffer(); + + // Load PDF to get actual dimensions + const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { + disableAutoFetch: true, + disableStream: true, + stopAtErrors: false + }); + + const firstPage = await pdf.getPage(1); + const viewport = firstPage.getViewport({ scale: 1 }); + + const pdfWidth = viewport.width; + const pdfHeight = viewport.height; + + const bounds = calculatePDFBounds(pdfWidth, pdfHeight, CONTAINER_SIZE, CONTAINER_SIZE); + setPdfBounds(bounds); + + // Initialize crop area to full PDF if parameters are still default + if (parameters.parameters.cropArea === DEFAULT_CROP_AREA) { + parameters.resetToFullPDF(bounds); + } + + // Cleanup PDF + pdfWorkerManager.destroyDocument(pdf); + } catch (error) { + console.error('Failed to load PDF dimensions:', error); + // Fallback to A4 dimensions if PDF loading fails + const bounds = calculatePDFBounds(PAGE_SIZES.A4.width, PAGE_SIZES.A4.height, CONTAINER_SIZE, CONTAINER_SIZE); + setPdfBounds(bounds); + + if (parameters.parameters.cropArea.width === PAGE_SIZES.A4.width && parameters.parameters.cropArea.height === PAGE_SIZES.A4.height) { + parameters.resetToFullPDF(bounds); + } + } + }; + + loadPDFDimensions(); + }, [selectedStub, selectedFile, parameters]); + + // Current crop area + const cropArea = parameters.getCropArea(); + + + // Handle crop area changes from the selector + const handleCropAreaChange = (newCropArea: Rectangle) => { + if (pdfBounds) { + parameters.setCropArea(newCropArea, pdfBounds); + } + }; + + // Handle manual coordinate input changes + const handleCoordinateChange = (field: keyof Rectangle, value: number | string) => { + const numValue = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(numValue)) return; + + const newCropArea = { ...cropArea, [field]: numValue }; + if (pdfBounds) { + parameters.setCropArea(newCropArea, pdfBounds); + } + }; + + // Reset to full PDF + const handleReset = () => { + if (pdfBounds) { + parameters.resetToFullPDF(pdfBounds); + } + }; + + + if (!selectedStub || !pdfBounds) { + return ( +
+ + {t("crop.noFileSelected", "Select a PDF file to begin cropping")} + +
+ ); + } + + const isCropValid = parameters.isCropAreaValid(pdfBounds); + const isFullCrop = parameters.isFullPDFCrop(pdfBounds); + + return ( + + {/* PDF Preview with Crop Selector */} + + + + {t("crop.preview.title", "Crop Area Selection")} + + + + + + +
+ + + + + +
+ +
+ + {/* Manual Coordinate Input */} + + + {t("crop.coordinates.title", "Position and Size")} + + + + handleCoordinateChange('x', value)} + disabled={disabled} + min={0} + max={pdfBounds.actualWidth} + step={0.1} + decimalScale={1} + size="xs" + /> + handleCoordinateChange('y', value)} + disabled={disabled} + min={0} + max={pdfBounds.actualHeight} + step={0.1} + decimalScale={1} + size="xs" + /> + + + + handleCoordinateChange('width', value)} + disabled={disabled} + min={0.1} + max={pdfBounds.actualWidth} + step={0.1} + decimalScale={1} + size="xs" + /> + handleCoordinateChange('height', value)} + disabled={disabled} + min={0.1} + max={pdfBounds.actualHeight} + step={0.1} + decimalScale={1} + size="xs" + /> + + + + {/* Validation Alert */} + {!isCropValid && ( + + + {t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")} + + + )} + +
+ ); +}; + +export default CropSettings; diff --git a/frontend/src/components/tooltips/useCropTooltips.ts b/frontend/src/components/tooltips/useCropTooltips.ts new file mode 100644 index 0000000000..5ca0ebaecc --- /dev/null +++ b/frontend/src/components/tooltips/useCropTooltips.ts @@ -0,0 +1,21 @@ +import { useTranslation } from 'react-i18next'; + +export function useCropTooltips() { + const { t } = useTranslation(); + + return { + header: { + title: t("crop.tooltip.title", "How to Crop PDFs") + }, + tips: [ + { + description: t("crop.tooltip.description", "Select the area to crop from your PDF by dragging and resizing the blue overlay on the thumbnail."), + bullets: [ + t("crop.tooltip.drag", "Drag the overlay to move the crop area"), + t("crop.tooltip.resize", "Drag the corner and edge handles to resize"), + t("crop.tooltip.precision", "Use coordinate inputs for precise positioning"), + ] + } + ] + }; +} diff --git a/frontend/src/constants/cropConstants.ts b/frontend/src/constants/cropConstants.ts new file mode 100644 index 0000000000..50352ae070 --- /dev/null +++ b/frontend/src/constants/cropConstants.ts @@ -0,0 +1,12 @@ +import { PAGE_SIZES } from "./pageSizeConstants"; + +// Default crop area (covers entire page) +export const DEFAULT_CROP_AREA = { + x: 0, + y: 0, + width: PAGE_SIZES.A4.width, + height: PAGE_SIZES.A4.height, +} as const; + + +export type ResizeHandle = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 'e' | 's' | 'w' | null; diff --git a/frontend/src/constants/pageSizeConstants.ts b/frontend/src/constants/pageSizeConstants.ts new file mode 100644 index 0000000000..66876baba7 --- /dev/null +++ b/frontend/src/constants/pageSizeConstants.ts @@ -0,0 +1,8 @@ +// Default PDF page sizes in points (1 point = 1/72 inch) +export const PAGE_SIZES = { + A4: { width: 595, height: 842 }, + LETTER: { width: 612, height: 792 }, + A3: { width: 842, height: 1191 }, + A5: { width: 420, height: 595 }, + LEGAL: { width: 612, height: 1008 }, +} as const; diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 8ef194f555..be6d2b0a79 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -22,6 +22,7 @@ import RemoveCertificateSign from "../tools/RemoveCertificateSign"; import Flatten from "../tools/Flatten"; import Rotate from "../tools/Rotate"; import ChangeMetadata from "../tools/ChangeMetadata"; +import Crop from "../tools/Crop"; import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation"; import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation"; import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation"; @@ -41,6 +42,7 @@ import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperati import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation"; import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation"; import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation"; +import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation"; import CompressSettings from "../components/tools/compress/CompressSettings"; import SplitSettings from "../components/tools/split/SplitSettings"; import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings"; @@ -62,6 +64,7 @@ import MergeSettings from '../components/tools/merge/MergeSettings'; import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation"; import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings"; import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep"; +import CropSettings from "../components/tools/crop/CropSettings"; const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI @@ -314,10 +317,14 @@ export function useFlatToolRegistry(): ToolRegistry { crop: { icon: , name: t("home.crop.title", "Crop PDF"), - component: null, + component: Crop, description: t("home.crop.desc", "Crop a PDF to reduce its size (maintains text!)"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + maxFiles: -1, + endpoints: ["crop"], + operationConfig: cropOperationConfig, + settingsComponent: CropSettings, }, rotate: { icon: , diff --git a/frontend/src/hooks/tools/crop/useCropOperation.ts b/frontend/src/hooks/tools/crop/useCropOperation.ts new file mode 100644 index 0000000000..452b3ddf11 --- /dev/null +++ b/frontend/src/hooks/tools/crop/useCropOperation.ts @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation, ToolType } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { CropParameters, defaultParameters } from './useCropParameters'; + +// Static configuration that can be used by both the hook and automation executor +export const buildCropFormData = (parameters: CropParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + const cropArea = parameters.cropArea; + + // Backend expects precise float values for PDF coordinates + formData.append("x", cropArea.x.toString()); + formData.append("y", cropArea.y.toString()); + formData.append("width", cropArea.width.toString()); + formData.append("height", cropArea.height.toString()); + + return formData; +}; + +// Static configuration object +export const cropOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildCropFormData, + operationType: 'crop', + endpoint: '/api/v1/general/crop', + defaultParameters, +} as const; + +export const useCropOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...cropOperationConfig, + getErrorMessage: createStandardErrorHandler( + t('crop.error.failed', 'An error occurred while cropping the PDF.') + ) + }); +}; diff --git a/frontend/src/hooks/tools/crop/useCropParameters.ts b/frontend/src/hooks/tools/crop/useCropParameters.ts new file mode 100644 index 0000000000..d16cb5af5d --- /dev/null +++ b/frontend/src/hooks/tools/crop/useCropParameters.ts @@ -0,0 +1,141 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; +import { useCallback } from 'react'; +import { Rectangle, PDFBounds, constrainCropAreaToPDF, createFullPDFCropArea, roundCropArea, isRectangle } from '../../../utils/cropCoordinates'; +import { DEFAULT_CROP_AREA } from '../../../constants/cropConstants'; + +export interface CropParameters extends BaseParameters { + cropArea: Rectangle; +} + +export const defaultParameters: CropParameters = { + cropArea: DEFAULT_CROP_AREA, +}; + +export type CropParametersHook = BaseParametersHook & { + /** Set crop area with PDF bounds validation */ + setCropArea: (cropArea: Rectangle, pdfBounds?: PDFBounds) => void; + /** Get current crop area as CropArea object */ + getCropArea: () => Rectangle; + /** Reset to full PDF dimensions */ + resetToFullPDF: (pdfBounds: PDFBounds) => void; + /** Check if current crop area is valid for the PDF */ + isCropAreaValid: (pdfBounds?: PDFBounds) => boolean; + /** Check if crop area covers the entire PDF */ + isFullPDFCrop: (pdfBounds?: PDFBounds) => boolean; + /** Update crop area with constraints applied */ + updateCropAreaConstrained: (cropArea: Partial, pdfBounds?: PDFBounds) => void; +}; + +export const useCropParameters = (): CropParametersHook => { + const baseHook = useBaseParameters({ + defaultParameters, + endpointName: 'crop', + validateFn: (params) => { + const rect = params.cropArea; + // Basic validation - coordinates and dimensions must be positive + return rect.x >= 0 && + rect.y >= 0 && + rect.width > 0 && + rect.height > 0; + }, + }); + + // Get current crop area as CropArea object + const getCropArea = useCallback((): Rectangle => { + return baseHook.parameters.cropArea; + }, [baseHook.parameters]); + + // Set crop area with optional PDF bounds validation + const setCropArea = useCallback((cropArea: Rectangle, pdfBounds?: PDFBounds) => { + let finalCropArea = roundCropArea(cropArea); + + // Apply PDF bounds constraints if provided + if (pdfBounds) { + finalCropArea = constrainCropAreaToPDF(finalCropArea, pdfBounds); + } + baseHook.updateParameter('cropArea', finalCropArea); + }, [baseHook]); + + // Reset to cover entire PDF + const resetToFullPDF = useCallback((pdfBounds: PDFBounds) => { + const fullCropArea = createFullPDFCropArea(pdfBounds); + setCropArea(fullCropArea); + }, [setCropArea]); + + // Check if current crop area is valid for the given PDF bounds + const isCropAreaValid = useCallback((pdfBounds?: PDFBounds): boolean => { + const cropArea = getCropArea(); + + // Basic validation + if (cropArea.x < 0 || cropArea.y < 0 || cropArea.width <= 0 || cropArea.height <= 0) { + return false; + } + + // PDF bounds validation if provided + if (pdfBounds) { + const tolerance = 0.01; // Small tolerance for floating point precision + return cropArea.x + cropArea.width <= pdfBounds.actualWidth + tolerance && + cropArea.y + cropArea.height <= pdfBounds.actualHeight + tolerance; + } + + return true; + }, [getCropArea]); + + // Check if crop area covers the entire PDF + const isFullPDFCrop = useCallback((pdfBounds?: PDFBounds): boolean => { + if (!pdfBounds) return false; + + const cropArea = getCropArea(); + const tolerance = 0.5; // Allow 0.5 point tolerance for floating point precision + + return Math.abs(cropArea.x) < tolerance && + Math.abs(cropArea.y) < tolerance && + Math.abs(cropArea.width - pdfBounds.actualWidth) < tolerance && + Math.abs(cropArea.height - pdfBounds.actualHeight) < tolerance; + }, [getCropArea]); + + // Update crop area with constraints applied + const updateCropAreaConstrained = useCallback(( + partialCropArea: Partial, + pdfBounds?: PDFBounds + ) => { + const currentCropArea = getCropArea(); + const newCropArea = { ...currentCropArea, ...partialCropArea }; + setCropArea(newCropArea, pdfBounds); + }, [getCropArea, setCropArea]); + + // Enhanced validation that considers PDF bounds + const validateParameters = useCallback((pdfBounds?: PDFBounds): boolean => { + return baseHook.validateParameters() && isCropAreaValid(pdfBounds); + }, [baseHook, isCropAreaValid]); + + // Override updateParameter to ensure positive values + const updateParameter = useCallback(( + parameter: K, + value: CropParameters[K] + ) => { + + if(isRectangle(value)) { + value.x = Math.max(0.1, value.x); // Minimum 0.1 point + value.x = Math.max(0.1, value.y); // Minimum 0.1 point + value.width = Math.max(0, value.width); // Minimum 0 point + value.height = Math.max(0, value.height); // Minimum 0 point + } + + baseHook.updateParameter(parameter, value); + }, [baseHook]); + + + return { + ...baseHook, + updateParameter, + validateParameters: () => validateParameters(), + setCropArea, + getCropArea, + resetToFullPDF, + isCropAreaValid, + isFullPDFCrop, + updateCropAreaConstrained, + }; +}; diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index 47bb1393dc..b91bbe83a1 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -64,6 +64,16 @@ export const mantineTheme = createTheme({ xl: 'var(--shadow-xl)', }, + // Custom variables for specific components + other: { + crop: { + overlayBorder: 'var(--color-primary-500)', + overlayBackground: 'rgba(59, 130, 246, 0.1)', // Blue with 10% opacity + handleColor: 'var(--color-primary-500)', + handleBorder: 'var(--bg-surface)', + }, + }, + // Component customizations components: { Button: { diff --git a/frontend/src/tools/Crop.tsx b/frontend/src/tools/Crop.tsx new file mode 100644 index 0000000000..d185e38772 --- /dev/null +++ b/frontend/src/tools/Crop.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import CropSettings from "../components/tools/crop/CropSettings"; +import { useCropParameters } from "../hooks/tools/crop/useCropParameters"; +import { useCropOperation } from "../hooks/tools/crop/useCropOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { useCropTooltips } from "../components/tooltips/useCropTooltips"; +import { BaseToolProps, ToolComponent } from "../types/tool"; + +const Crop = (props: BaseToolProps) => { + const { t } = useTranslation(); + + const base = useBaseTool( + 'crop', + useCropParameters, + useCropOperation, + props + ); + + const tooltips = useCropTooltips(); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + minFiles: 1, + }, + steps: [ + { + title: t("crop.steps.selectArea", "Select Crop Area"), + isCollapsed: !base.hasFiles, // Collapsed until files selected + onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined, + tooltip: tooltips, + content: ( + + ), + }, + ], + executeButton: { + text: t("crop.submit", "Apply Crop"), + loadingText: t("loading"), + onClick: base.handleExecute, + isVisible: !base.hasResults, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("crop.results.title", "Crop Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default Crop as ToolComponent; diff --git a/frontend/src/utils/cropCoordinates.ts b/frontend/src/utils/cropCoordinates.ts new file mode 100644 index 0000000000..a8c7192f4e --- /dev/null +++ b/frontend/src/utils/cropCoordinates.ts @@ -0,0 +1,218 @@ +/** + * Utility functions for crop coordinate conversion and PDF bounds handling + */ + +export interface PDFBounds { + /** PDF width in points (actual PDF dimensions) */ + actualWidth: number; + /** PDF height in points (actual PDF dimensions) */ + actualHeight: number; + /** Thumbnail display width in pixels */ + thumbnailWidth: number; + /** Thumbnail display height in pixels */ + thumbnailHeight: number; + /** Horizontal offset for centering thumbnail in container */ + offsetX: number; + /** Vertical offset for centering thumbnail in container */ + offsetY: number; + /** Scale factor: thumbnailSize / actualSize */ + scale: number; +} + +export interface Rectangle { + /** X coordinate */ + x: number; + /** Y coordinate */ + y: number; + /** Width */ + width: number; + /** Height */ + height: number; +} + +/** Runtime type guard */ +export function isRectangle(value: unknown): value is Rectangle { + if (value === null || typeof value !== "object") return false; + + const r = value as Record; + const isNum = (n: unknown): n is number => + typeof n === "number" && Number.isFinite(n); + + return ( + isNum(r.x) && + isNum(r.y) && + isNum(r.width) && + isNum(r.height) && + r.width >= 0 && + r.height >= 0 + ); +} + +/** + * Calculate PDF bounds for coordinate conversion based on thumbnail dimensions + */ +export const calculatePDFBounds = ( + actualPDFWidth: number, + actualPDFHeight: number, + containerWidth: number, + containerHeight: number +): PDFBounds => { + // Calculate scale to fit PDF within container while maintaining aspect ratio + const scaleX = containerWidth / actualPDFWidth; + const scaleY = containerHeight / actualPDFHeight; + const scale = Math.min(scaleX, scaleY); + + // Calculate actual thumbnail display size + const thumbnailWidth = actualPDFWidth * scale; + const thumbnailHeight = actualPDFHeight * scale; + + // Calculate centering offsets - these represent where the thumbnail is positioned within the container + const offsetX = (containerWidth - thumbnailWidth) / 2; + const offsetY = (containerHeight - thumbnailHeight) / 2; + + return { + actualWidth: actualPDFWidth, + actualHeight: actualPDFHeight, + thumbnailWidth, + thumbnailHeight, + offsetX, + offsetY, + scale + }; +}; + +/** + * Convert DOM coordinates (relative to container) to PDF coordinates + * Handles coordinate system conversion (DOM uses top-left, PDF uses bottom-left origin) + */ +export const domToPDFCoordinates = ( + domRect: Rectangle, + pdfBounds: PDFBounds +): Rectangle => { + // Convert DOM coordinates to thumbnail-relative coordinates + const thumbX = domRect.x - pdfBounds.offsetX; + const thumbY = domRect.y - pdfBounds.offsetY; + + // Convert to PDF coordinates (scale and flip Y-axis) + const pdfX = thumbX / pdfBounds.scale; + const pdfY = pdfBounds.actualHeight - ((thumbY + domRect.height) / pdfBounds.scale); + const pdfWidth = domRect.width / pdfBounds.scale; + const pdfHeight = domRect.height / pdfBounds.scale; + + return { + x: pdfX, + y: pdfY, + width: pdfWidth, + height: pdfHeight + }; +}; + +/** + * Convert PDF coordinates to DOM coordinates (relative to container) + */ +export const pdfToDOMCoordinates = ( + cropArea: Rectangle, + pdfBounds: PDFBounds +): Rectangle => { + // Convert PDF coordinates to thumbnail coordinates (scale and flip Y-axis) + const thumbX = cropArea.x * pdfBounds.scale; + const thumbY = (pdfBounds.actualHeight - cropArea.y - cropArea.height) * pdfBounds.scale; + const thumbWidth = cropArea.width * pdfBounds.scale; + const thumbHeight = cropArea.height * pdfBounds.scale; + + // Add container offsets to get DOM coordinates + return { + x: thumbX + pdfBounds.offsetX, + y: thumbY + pdfBounds.offsetY, + width: thumbWidth, + height: thumbHeight + }; +}; + +/** + * Constrain a crop area to stay within PDF bounds + */ +export const constrainCropAreaToPDF = ( + cropArea: Rectangle, + pdfBounds: PDFBounds +): Rectangle => { + // Ensure crop area doesn't extend beyond PDF boundaries + const maxX = Math.max(0, pdfBounds.actualWidth - cropArea.width); + const maxY = Math.max(0, pdfBounds.actualHeight - cropArea.height); + + return { + x: Math.max(0, Math.min(cropArea.x, maxX)), + y: Math.max(0, Math.min(cropArea.y, maxY)), + width: Math.min(cropArea.width, pdfBounds.actualWidth - Math.max(0, cropArea.x)), + height: Math.min(cropArea.height, pdfBounds.actualHeight - Math.max(0, cropArea.y)) + }; +}; + +/** + * Constrain DOM coordinates to stay within thumbnail bounds + */ +export const constrainDOMRectToThumbnail = ( + domRect: Rectangle, + pdfBounds: PDFBounds +): Rectangle => { + const thumbnailLeft = pdfBounds.offsetX; + const thumbnailTop = pdfBounds.offsetY; + const thumbnailRight = pdfBounds.offsetX + pdfBounds.thumbnailWidth; + const thumbnailBottom = pdfBounds.offsetY + pdfBounds.thumbnailHeight; + + // Constrain position + const maxX = Math.max(thumbnailLeft, thumbnailRight - domRect.width); + const maxY = Math.max(thumbnailTop, thumbnailBottom - domRect.height); + + const constrainedX = Math.max(thumbnailLeft, Math.min(domRect.x, maxX)); + const constrainedY = Math.max(thumbnailTop, Math.min(domRect.y, maxY)); + + // Constrain size to fit within thumbnail bounds from current position + const maxWidth = thumbnailRight - constrainedX; + const maxHeight = thumbnailBottom - constrainedY; + + return { + x: constrainedX, + y: constrainedY, + width: Math.min(domRect.width, maxWidth), + height: Math.min(domRect.height, maxHeight) + }; +}; + +/** + * Check if a point is within the thumbnail area (not just the container) + */ +export const isPointInThumbnail = ( + x: number, + y: number, + pdfBounds: PDFBounds +): boolean => { + return x >= pdfBounds.offsetX && + x <= pdfBounds.offsetX + pdfBounds.thumbnailWidth && + y >= pdfBounds.offsetY && + y <= pdfBounds.offsetY + pdfBounds.thumbnailHeight; +}; + +/** + * Create a default crop area that covers the entire PDF + */ +export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => { + return { + x: 0, + y: 0, + width: pdfBounds.actualWidth, + height: pdfBounds.actualHeight + }; +}; + +/** + * Round crop coordinates to reasonable precision (0.1 point) + */ +export const roundCropArea = (cropArea: Rectangle): Rectangle => { + return { + x: Math.round(cropArea.x * 10) / 10, + y: Math.round(cropArea.y * 10) / 10, + width: Math.round(cropArea.width * 10) / 10, + height: Math.round(cropArea.height * 10) / 10 + }; +}; diff --git a/testing/crop_test.pdf b/testing/crop_test.pdf new file mode 100644 index 0000000000000000000000000000000000000000..39316ea58c416f1751dac9bf1aa44bbd9f02fc4d GIT binary patch literal 26033 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