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 000000000..39316ea58 Binary files /dev/null and b/testing/crop_test.pdf differ