Initial working Crop

This commit is contained in:
James Brunton 2025-09-19 14:28:12 +01:00
parent f29bbfd15b
commit 5a24e00497
8 changed files with 1230 additions and 1 deletions

View File

@ -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<CropAreaSelectorProps> = ({
pdfBounds,
cropArea,
onCropAreaChange,
disabled = false,
children
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState<ResizeHandle>(null);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [initialCropArea, setInitialCropArea] = useState<CropArea>(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 (
<Box
ref={containerRef}
style={{
position: 'relative',
width: '100%',
height: '100%',
cursor: 'crosshair',
userSelect: 'none'
}}
onMouseDown={handleContainerMouseDown}
>
{/* PDF Thumbnail Content */}
{children}
{/* Crop Area Overlay */}
{!disabled && (
<Box
ref={overlayRef}
style={{
position: 'absolute',
left: domRect.x,
top: domRect.y,
width: domRect.width,
height: domRect.height,
border: '2px solid #ff4757',
backgroundColor: 'rgba(255, 71, 87, 0.1)',
cursor: 'move',
pointerEvents: 'auto'
}}
onMouseDown={handleOverlayMouseDown}
>
{/* Resize Handles */}
{renderResizeHandles(disabled)}
</Box>
)}
</Box>
);
};
// 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 */}
<Box style={{ ...handleStyle, left: -handleSize/2, top: -handleSize/2, cursor: 'nw-resize' }} />
<Box style={{ ...handleStyle, right: -handleSize/2, top: -handleSize/2, cursor: 'ne-resize' }} />
<Box style={{ ...handleStyle, left: -handleSize/2, bottom: -handleSize/2, cursor: 'sw-resize' }} />
<Box style={{ ...handleStyle, right: -handleSize/2, bottom: -handleSize/2, cursor: 'se-resize' }} />
{/* Edge handles */}
<Box style={{ ...handleStyle, left: '50%', marginLeft: -handleSize/2, top: -handleSize/2, cursor: 'n-resize' }} />
<Box style={{ ...handleStyle, right: -handleSize/2, top: '50%', marginTop: -handleSize/2, cursor: 'e-resize' }} />
<Box style={{ ...handleStyle, left: '50%', marginLeft: -handleSize/2, bottom: -handleSize/2, cursor: 's-resize' }} />
<Box style={{ ...handleStyle, left: -handleSize/2, top: '50%', marginTop: -handleSize/2, cursor: 'w-resize' }} />
</>
);
}
export default CropAreaSelector;

View File

@ -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<string | null>(null);
const [pdfBounds, setPdfBounds] = useState<PDFBounds | null>(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 (
<Center style={{ height: '200px' }}>
<Text color="dimmed">
{t("crop.noFileSelected", "Select a PDF file to begin cropping")}
</Text>
</Center>
);
}
const isCropValid = parameters.isCropAreaValid(pdfBounds);
const isFullCrop = parameters.isFullPDFCrop(pdfBounds);
return (
<Stack gap="md">
{/* PDF Preview with Crop Selector */}
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="sm" fw={500}>
{t("crop.preview.title", "Crop Area Selection")}
</Text>
<ActionIcon
variant="outline"
onClick={handleReset}
disabled={disabled || isFullCrop}
title={t("crop.reset", "Reset to full PDF")}
aria-label={t("crop.reset", "Reset to full PDF")}
>
<RestartAltIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
</Group>
<Center>
<Box
style={{
width: CONTAINER_SIZE,
height: CONTAINER_SIZE,
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: '8px',
backgroundColor: 'var(--mantine-color-gray-0)',
overflow: 'hidden',
position: 'relative'
}}
>
<CropAreaSelector
pdfBounds={pdfBounds}
cropArea={cropArea}
onCropAreaChange={handleCropAreaChange}
disabled={disabled}
>
<DocumentThumbnail
file={selectedStub}
thumbnail={thumbnail}
style={{
width: pdfBounds.thumbnailWidth,
height: pdfBounds.thumbnailHeight,
position: 'absolute',
left: pdfBounds.offsetX,
top: pdfBounds.offsetY
}}
/>
</CropAreaSelector>
</Box>
</Center>
{/* Crop Info */}
<Group justify="center" gap="lg">
<Text size="xs" color="dimmed">
{t("crop.info.percentage", "Area: {{percentage}}%", { percentage: cropPercentage })}
</Text>
{aspectRatioInfo && (
<Text size="xs" color="dimmed">
{t("crop.info.ratio", "Ratio: {{ratio}} ({{orientation}})", {
ratio: aspectRatioInfo.crop,
orientation: parseFloat(aspectRatioInfo.crop) > 1 ? 'Landscape' : 'Portrait'
})}
</Text>
)}
</Group>
</Stack>
{/* Manual Coordinate Input */}
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("crop.coordinates.title", "Precise Coordinates (PDF Points)")}
</Text>
<Group grow>
<NumberInput
label={t("crop.coordinates.x", "X (Left)")}
value={Math.round(cropArea.x * 10) / 10}
onChange={(value) => handleCoordinateChange('x', value)}
disabled={disabled}
min={0}
max={pdfBounds.actualWidth}
step={0.1}
decimalScale={1}
size="xs"
/>
<NumberInput
label={t("crop.coordinates.y", "Y (Bottom)")}
value={Math.round(cropArea.y * 10) / 10}
onChange={(value) => handleCoordinateChange('y', value)}
disabled={disabled}
min={0}
max={pdfBounds.actualHeight}
step={0.1}
decimalScale={1}
size="xs"
/>
</Group>
<Group grow>
<NumberInput
label={t("crop.coordinates.width", "Width")}
value={Math.round(cropArea.width * 10) / 10}
onChange={(value) => handleCoordinateChange('width', value)}
disabled={disabled}
min={0.1}
max={pdfBounds.actualWidth}
step={0.1}
decimalScale={1}
size="xs"
/>
<NumberInput
label={t("crop.coordinates.height", "Height")}
value={Math.round(cropArea.height * 10) / 10}
onChange={(value) => handleCoordinateChange('height', value)}
disabled={disabled}
min={0.1}
max={pdfBounds.actualHeight}
step={0.1}
decimalScale={1}
size="xs"
/>
</Group>
{/* PDF Dimensions Info */}
<Alert
icon={<InfoIcon />}
color="blue"
variant="light"
style={{ fontSize: '0.75rem' }}
>
<Text size="xs">
{t("crop.info.pdfDimensions", "PDF Size: {{width}} × {{height}} points", {
width: Math.round(pdfBounds.actualWidth),
height: Math.round(pdfBounds.actualHeight)
})}
</Text>
{aspectRatioInfo && (
<Text size="xs">
{t("crop.info.aspectRatio", "Aspect Ratio: {{ratio}} ({{orientation}})", {
ratio: aspectRatioInfo.pdf,
orientation: aspectRatioInfo.orientation
})}
</Text>
)}
</Alert>
{/* Validation Alert */}
{!isCropValid && (
<Alert color="red" variant="light">
<Text size="xs">
{t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")}
</Text>
</Alert>
)}
</Stack>
</Stack>
);
};
export default CropSettings;

View File

@ -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<ResizeHandle, string> = {
'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;

View File

@ -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: <LocalIcon icon="crop-rounded" width="1.5rem" height="1.5rem" />,
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: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,

View File

@ -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<CropParameters>({
...cropOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('crop.error.failed', 'An error occurred while cropping the PDF.')
)
});
};

View File

@ -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<CropParameters> & {
/** 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<CropArea>, 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<CropArea>,
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(<K extends keyof CropParameters>(
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,
};
};

View File

@ -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: (
<div>
<p>{t("crop.tooltip.description", "Select the area to crop from your PDF by dragging and resizing the red overlay on the thumbnail.")}</p>
</div>
),
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: (
<CropSettings
parameters={base.params}
disabled={base.endpointLoading}
/>
),
},
],
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;

View File

@ -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
};
};