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 0049825cb..ad726085f 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 000000000..75d326210 --- /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 000000000..dafcdeaab --- /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 000000000..5ca0ebaec --- /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 000000000..50352ae07 --- /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 000000000..66876baba --- /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 8ef194f55..be6d2b0a7 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 000000000..452b3ddf1 --- /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 000000000..d16cb5af5 --- /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 47bb1393d..b91bbe83a 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 000000000..d185e3877 --- /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 000000000..a8c7192f4 --- /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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 literal 0 HcmV?d00001