From 5a24e004972c2f5fac0af37e3bf55e0f507a2595 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 19 Sep 2025 14:28:12 +0100 Subject: [PATCH] Initial working Crop --- .../tools/crop/CropAreaSelector.tsx | 339 ++++++++++++++++++ .../components/tools/crop/CropSettings.tsx | 328 +++++++++++++++++ frontend/src/constants/cropConstants.ts | 62 ++++ .../src/data/useTranslatedToolRegistry.tsx | 9 +- .../src/hooks/tools/crop/useCropOperation.ts | 38 ++ .../src/hooks/tools/crop/useCropParameters.ts | 166 +++++++++ frontend/src/tools/Crop.tsx | 71 ++++ frontend/src/utils/cropCoordinates.ts | 218 +++++++++++ 8 files changed, 1230 insertions(+), 1 deletion(-) 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/constants/cropConstants.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 diff --git a/frontend/src/components/tools/crop/CropAreaSelector.tsx b/frontend/src/components/tools/crop/CropAreaSelector.tsx new file mode 100644 index 000000000..ead99d234 --- /dev/null +++ b/frontend/src/components/tools/crop/CropAreaSelector.tsx @@ -0,0 +1,339 @@ +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { Box } from '@mantine/core'; +import { + PDFBounds, + CropArea, + DOMRect, + domToPDFCoordinates, + pdfToDOMCoordinates, + constrainDOMRectToThumbnail, + isPointInThumbnail +} from '../../../utils/cropCoordinates'; + +interface CropAreaSelectorProps { + /** PDF bounds for coordinate conversion */ + pdfBounds: PDFBounds; + /** Current crop area in PDF coordinates */ + cropArea: CropArea; + /** Callback when crop area changes */ + onCropAreaChange: (cropArea: CropArea) => void; + /** Whether the selector is disabled */ + disabled?: boolean; + /** Child content (typically the PDF thumbnail) */ + children: React.ReactNode; +} + +type ResizeHandle = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 'e' | 's' | 'w' | null; + +const CropAreaSelector: React.FC = ({ + pdfBounds, + cropArea, + onCropAreaChange, + disabled = false, + children +}) => { + 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) + 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 + const handle = getResizeHandle(x, y, domRect); + + if (handle) { + setIsResizing(handle); + setInitialCropArea(cropArea); + } else { + // Check if we're clicking within the crop area for dragging + if (isPointInCropArea(x, y, domRect)) { + setIsDragging(true); + 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: DOMRect = { 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: DOMRect = { + 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, initialCropArea, pdfBounds); + 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]); + + // Update cursor based on mouse position + const getCursor = useCallback((clientX: number, clientY: number): string => { + if (disabled || !containerRef.current) return 'default'; + + const rect = containerRef.current.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + if (!isPointInThumbnail(x, y, pdfBounds)) return 'default'; + + const handle = getResizeHandle(x, y, domRect); + if (handle) { + return getResizeCursor(handle); + } + + if (isPointInCropArea(x, y, domRect)) { + return 'move'; + } + + return 'crosshair'; + }, [disabled, pdfBounds, domRect]); + + return ( + + {/* PDF Thumbnail Content */} + {children} + + {/* Crop Area Overlay */} + {!disabled && ( + + {/* Resize Handles */} + {renderResizeHandles(disabled)} + + )} + + ); +}; + +// Helper functions + +function getResizeHandle(x: number, y: number, domRect: DOMRect): ResizeHandle { + const handleSize = 8; + const tolerance = 4; + + // Corner handles + 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 + 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: DOMRect): boolean { + return x >= domRect.x && x <= domRect.x + domRect.width && + y >= domRect.y && y <= domRect.y + domRect.height; +} + +function getResizeCursor(handle: ResizeHandle): string { + const cursors = { + 'nw': 'nw-resize', + 'ne': 'ne-resize', + 'sw': 'sw-resize', + 'se': 'se-resize', + 'n': 'n-resize', + 'e': 'e-resize', + 's': 's-resize', + 'w': 'w-resize' + }; + return cursors[handle!] || 'default'; +} + +function calculateResizedRect( + handle: ResizeHandle, + currentRect: DOMRect, + mouseX: number, + mouseY: number, + initialCropArea: CropArea, + pdfBounds: PDFBounds +): DOMRect { + 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) { + if (disabled) return null; + + const handleSize = 8; + const handleStyle = { + position: 'absolute' as const, + width: handleSize, + height: handleSize, + backgroundColor: '#ff4757', + border: '1px solid white', + 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..b5506bec8 --- /dev/null +++ b/frontend/src/components/tools/crop/CropSettings.tsx @@ -0,0 +1,328 @@ +import React, { 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 InfoIcon from "@mui/icons-material/Info"; +import { CropParametersHook } from "../../../hooks/tools/crop/useCropParameters"; +import { useSelectedFiles } from "../../../contexts/file/fileHooks"; +import { useFileContext } from "../../../contexts/FileContext"; +import DocumentThumbnail from "../../shared/filePreview/DocumentThumbnail"; +import CropAreaSelector from "./CropAreaSelector"; +import { + calculatePDFBounds, + PDFBounds, + CropArea, + getPDFAspectRatio, + createFullPDFCropArea +} from "../../../utils/cropCoordinates"; +import { pdfWorkerManager } from "../../../services/pdfWorkerManager"; + +interface CropSettingsProps { + parameters: CropParametersHook; + disabled?: boolean; +} + +const CONTAINER_SIZE = 400; // Larger container for better crop precision + +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); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadPDFDimensions = async () => { + if (!selectedStub || !selectedFile) { + setPdfBounds(null); + setThumbnail(null); + return; + } + + setThumbnail(selectedStub.thumbnailUrl || null); + setLoading(true); + + 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 + const isDefault = parameters.parameters.width === 595 && + parameters.parameters.height === 842 && + parameters.parameters.x === 0 && + parameters.parameters.y === 0; + + if (isDefault) { + 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(595, 842, CONTAINER_SIZE, CONTAINER_SIZE); + setPdfBounds(bounds); + + if (parameters.parameters.width === 595 && parameters.parameters.height === 842) { + parameters.resetToFullPDF(bounds); + } + } finally { + setLoading(false); + } + }; + + loadPDFDimensions(); + }, [selectedStub, selectedFile, parameters]); + + // Current crop area + const cropArea = parameters.getCropArea(); + + // Handle crop area changes from the selector + const handleCropAreaChange = (newCropArea: CropArea) => { + if (pdfBounds) { + parameters.setCropArea(newCropArea, pdfBounds); + } + }; + + // Handle manual coordinate input changes + const handleCoordinateChange = (field: keyof CropArea, 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); + } + }; + + // Calculate crop percentage + const cropPercentage = useMemo(() => { + if (!pdfBounds) return 100; + const totalArea = pdfBounds.actualWidth * pdfBounds.actualHeight; + const cropAreaSize = cropArea.width * cropArea.height; + return Math.round((cropAreaSize / totalArea) * 100); + }, [cropArea, pdfBounds]); + + // Get aspect ratio information + const aspectRatioInfo = useMemo(() => { + if (!pdfBounds) return null; + const pdfRatio = getPDFAspectRatio(pdfBounds); + const cropRatio = cropArea.width / cropArea.height; + + return { + pdf: pdfRatio.toFixed(2), + crop: cropRatio.toFixed(2), + orientation: pdfRatio > 1 ? 'Landscape' : 'Portrait' + }; + }, [pdfBounds, cropArea]); + + 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")} + + + + + + +
+ + + + + +
+ + {/* Crop Info */} + + + {t("crop.info.percentage", "Area: {{percentage}}%", { percentage: cropPercentage })} + + {aspectRatioInfo && ( + + {t("crop.info.ratio", "Ratio: {{ratio}} ({{orientation}})", { + ratio: aspectRatioInfo.crop, + orientation: parseFloat(aspectRatioInfo.crop) > 1 ? 'Landscape' : 'Portrait' + })} + + )} + +
+ + {/* Manual Coordinate Input */} + + + {t("crop.coordinates.title", "Precise Coordinates (PDF Points)")} + + + + 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" + /> + + + {/* PDF Dimensions Info */} + } + color="blue" + variant="light" + style={{ fontSize: '0.75rem' }} + > + + {t("crop.info.pdfDimensions", "PDF Size: {{width}} × {{height}} points", { + width: Math.round(pdfBounds.actualWidth), + height: Math.round(pdfBounds.actualHeight) + })} + + {aspectRatioInfo && ( + + {t("crop.info.aspectRatio", "Aspect Ratio: {{ratio}} ({{orientation}})", { + ratio: aspectRatioInfo.pdf, + orientation: aspectRatioInfo.orientation + })} + + )} + + + {/* Validation Alert */} + {!isCropValid && ( + + + {t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")} + + + )} + +
+ ); +}; + +export default CropSettings; diff --git a/frontend/src/constants/cropConstants.ts b/frontend/src/constants/cropConstants.ts new file mode 100644 index 000000000..2787bb156 --- /dev/null +++ b/frontend/src/constants/cropConstants.ts @@ -0,0 +1,62 @@ +/** + * Constants and configuration for the crop tool + */ + +// 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; + +// Minimum crop area dimensions (in points) +export const MIN_CROP_SIZE = { + width: 10, + height: 10, +} as const; + +// Maximum container size for thumbnail display +export const CROP_CONTAINER_SIZE = 400; + +// Crop overlay styling +export const CROP_OVERLAY = { + borderColor: '#ff4757', + backgroundColor: 'rgba(255, 71, 87, 0.1)', + borderWidth: 2, + handleSize: 8, + handleColor: '#ff4757', + handleBorderColor: 'white', +} as const; + +// Coordinate precision (decimal places) +export const COORDINATE_PRECISION = 1; + +// 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; + +// Resize handle positions +export const RESIZE_HANDLES = [ + 'nw', 'ne', 'sw', 'se', // corners + 'n', 'e', 's', 'w' // edges +] as const; + +export type ResizeHandle = typeof RESIZE_HANDLES[number]; + +// Cursor styles for resize handles +export const RESIZE_CURSORS: Record = { + 'nw': 'nw-resize', + 'ne': 'ne-resize', + 'sw': 'sw-resize', + 'se': 'se-resize', + 'n': 'n-resize', + 'e': 'e-resize', + 's': 's-resize', + 'w': 'w-resize', +} as const; diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 47ef33d36..147d1a6f2 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 { cropPdf: { 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..3a9b5e370 --- /dev/null +++ b/frontend/src/hooks/tools/crop/useCropOperation.ts @@ -0,0 +1,38 @@ +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); + + // Backend expects precise float values for PDF coordinates + formData.append("x", parameters.x.toString()); + formData.append("y", parameters.y.toString()); + formData.append("width", parameters.width.toString()); + formData.append("height", parameters.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..aa1712fc3 --- /dev/null +++ b/frontend/src/hooks/tools/crop/useCropParameters.ts @@ -0,0 +1,166 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; +import { useMemo, useCallback } from 'react'; +import { CropArea, PDFBounds, constrainCropAreaToPDF, createFullPDFCropArea, roundCropArea } from '../../../utils/cropCoordinates'; + +export interface CropParameters extends BaseParameters { + /** X coordinate of crop area (PDF points, left edge) */ + x: number; + /** Y coordinate of crop area (PDF points, bottom edge in PDF coordinate system) */ + y: number; + /** Width of crop area (PDF points) */ + width: number; + /** Height of crop area (PDF points) */ + height: number; +} + +export const defaultParameters: CropParameters = { + x: 0, + y: 0, + width: 595, // Default A4 width in points + height: 842, // Default A4 height in points +}; + +export type CropParametersHook = BaseParametersHook & { + /** Set crop area with PDF bounds validation */ + setCropArea: (cropArea: CropArea, pdfBounds?: PDFBounds) => void; + /** Get current crop area as CropArea object */ + getCropArea: () => CropArea; + /** 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) => { + // Basic validation - coordinates and dimensions must be positive + return params.x >= 0 && + params.y >= 0 && + params.width > 0 && + params.height > 0; + }, + }); + + // Get current crop area as CropArea object + const getCropArea = useCallback((): CropArea => { + return { + x: baseHook.parameters.x, + y: baseHook.parameters.y, + width: baseHook.parameters.width, + height: baseHook.parameters.height, + }; + }, [baseHook.parameters]); + + // Set crop area with optional PDF bounds validation + const setCropArea = useCallback((cropArea: CropArea, pdfBounds?: PDFBounds) => { + let finalCropArea = roundCropArea(cropArea); + + // Apply PDF bounds constraints if provided + if (pdfBounds) { + finalCropArea = constrainCropAreaToPDF(finalCropArea, pdfBounds); + } + + baseHook.updateParameter('x', finalCropArea.x); + baseHook.updateParameter('y', finalCropArea.y); + baseHook.updateParameter('width', finalCropArea.width); + baseHook.updateParameter('height', finalCropArea.height); + }, [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) { + return cropArea.x + cropArea.width <= pdfBounds.actualWidth && + cropArea.y + cropArea.height <= pdfBounds.actualHeight; + } + + 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] + ) => { + // Ensure numeric parameters are positive + if (typeof value === 'number' && parameter !== 'x' && parameter !== 'y') { + value = Math.max(0.1, value) as CropParameters[K]; // Minimum 0.1 point + } else if (typeof value === 'number') { + value = Math.max(0, value) as CropParameters[K]; // x,y can be 0 + } + + baseHook.updateParameter(parameter, value); + }, [baseHook]); + + // Calculate crop area percentage of original PDF + const getCropPercentage = useCallback((pdfBounds?: PDFBounds): number => { + if (!pdfBounds) return 100; + + const cropArea = getCropArea(); + const totalArea = pdfBounds.actualWidth * pdfBounds.actualHeight; + const cropAreaSize = cropArea.width * cropArea.height; + + return (cropAreaSize / totalArea) * 100; + }, [getCropArea]); + + return { + ...baseHook, + updateParameter, + validateParameters: () => validateParameters(), + setCropArea, + getCropArea, + resetToFullPDF, + isCropAreaValid, + isFullPDFCrop, + updateCropAreaConstrained, + }; +}; diff --git a/frontend/src/tools/Crop.tsx b/frontend/src/tools/Crop.tsx new file mode 100644 index 000000000..dd3185917 --- /dev/null +++ b/frontend/src/tools/Crop.tsx @@ -0,0 +1,71 @@ +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 { BaseToolProps, ToolComponent } from "../types/tool"; + +const Crop = (props: BaseToolProps) => { + const { t } = useTranslation(); + + const base = useBaseTool( + 'crop', + useCropParameters, + useCropOperation, + props + ); + + 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: { + content: ( +
+

{t("crop.tooltip.description", "Select the area to crop from your PDF by dragging and resizing the red overlay on the thumbnail.")}

+
+ ), + tips: [ + 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"), + t("crop.tooltip.constraints", "Crop area is automatically constrained to PDF bounds") + ], + header: { + title: t("crop.tooltip.title", "How to Crop PDFs"), + } + }, + content: ( + + ), + }, + ], + executeButton: { + text: t("crop.submit", "Crop PDF"), + 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..ce961ab98 --- /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 CropArea { + /** X coordinate in PDF points (0 = left edge) */ + x: number; + /** Y coordinate in PDF points (0 = bottom edge, PDF coordinate system) */ + y: number; + /** Width in PDF points */ + width: number; + /** Height in PDF points */ + height: number; +} + +export interface DOMRect { + /** X coordinate in DOM pixels relative to thumbnail container */ + x: number; + /** Y coordinate in DOM pixels relative to thumbnail container */ + y: number; + /** Width in DOM pixels */ + width: number; + /** Height in DOM pixels */ + height: number; +} + +/** + * 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 + 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: DOMRect, + pdfBounds: PDFBounds +): CropArea => { + // 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: CropArea, + pdfBounds: PDFBounds +): DOMRect => { + // 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: CropArea, + pdfBounds: PDFBounds +): CropArea => { + // 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: DOMRect, + pdfBounds: PDFBounds +): DOMRect => { + 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; +}; + +/** + * Get the aspect ratio of the PDF + */ +export const getPDFAspectRatio = (pdfBounds: PDFBounds): number => { + return pdfBounds.actualWidth / pdfBounds.actualHeight; +}; + +/** + * Create a default crop area that covers the entire PDF + */ +export const createFullPDFCropArea = (pdfBounds: PDFBounds): CropArea => { + return { + x: 0, + y: 0, + width: pdfBounds.actualWidth, + height: pdfBounds.actualHeight + }; +}; + +/** + * Round crop coordinates to reasonable precision (0.1 point) + */ +export const roundCropArea = (cropArea: CropArea): CropArea => { + 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 + }; +};