Add Crop to V2 (#4471)

# Description of Changes
Add Crop to V2

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
This commit is contained in:
James Brunton 2025-09-22 14:06:20 +01:00 committed by GitHub
parent f6df414425
commit c76edebf0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1108 additions and 2 deletions

View File

@ -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",

View File

@ -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<CropAreaSelectorProps> = ({
pdfBounds,
cropArea,
onCropAreaChange,
disabled = false,
children
}) => {
const theme = useMantineTheme();
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<Rectangle>(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 (
<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 ${theme.other.crop.overlayBorder}`,
backgroundColor: theme.other.crop.overlayBackground,
cursor: 'move',
pointerEvents: 'auto'
}}
onMouseDown={handleOverlayMouseDown}
>
{/* Resize Handles */}
{renderResizeHandles(disabled, theme)}
</Box>
)}
</Box>
);
};
// 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 */}
<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,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<string | null>(null);
const [pdfBounds, setPdfBounds] = useState<PDFBounds | null>(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 (
<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>
</Stack>
{/* Manual Coordinate Input */}
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("crop.coordinates.title", "Position and Size")}
</Text>
<Group grow>
<NumberInput
label={t("crop.coordinates.x", "X Position")}
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 Position")}
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>
{/* 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,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"),
]
}
]
};
}

View File

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

View File

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

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

View File

@ -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<CropParameters> & {
/** 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<Rectangle>, 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<Rectangle>,
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]
) => {
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,
};
};

View File

@ -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: {

View File

@ -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: (
<CropSettings
parameters={base.params}
disabled={base.endpointLoading}
/>
),
},
],
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;

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 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<string, unknown>;
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
};
};

BIN
testing/crop_test.pdf Normal file

Binary file not shown.