This commit is contained in:
Connor Yoh 2025-09-22 12:52:58 +01:00
parent afa726be14
commit 885e9cb119
7 changed files with 88 additions and 143 deletions

View File

@ -2,29 +2,27 @@ import React, { useRef, useState, useCallback, useEffect } from 'react';
import { Box, useMantineTheme, MantineTheme } from '@mantine/core';
import {
PDFBounds,
CropArea,
DOMRect,
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: CropArea;
cropArea: Rectangle;
/** Callback when crop area changes */
onCropAreaChange: (cropArea: CropArea) => void;
onCropAreaChange: (cropArea: Rectangle) => 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,
@ -39,7 +37,7 @@ const CropAreaSelector: React.FC<CropAreaSelectorProps> = ({
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);
const [initialCropArea, setInitialCropArea] = useState<Rectangle>(cropArea);
// Convert PDF crop area to DOM coordinates for display
const domRect = pdfToDOMCoordinates(cropArea, pdfBounds);
@ -85,7 +83,7 @@ const CropAreaSelector: React.FC<CropAreaSelectorProps> = ({
e.stopPropagation();
// Start new crop selection
const newDomRect: DOMRect = { x, y, width: 20, height: 20 };
const newDomRect: Rectangle = { x, y, width: 20, height: 20 };
const constrainedRect = constrainDOMRectToThumbnail(newDomRect, pdfBounds);
const newCropArea = domToPDFCoordinates(constrainedRect, pdfBounds);
@ -107,7 +105,7 @@ const CropAreaSelector: React.FC<CropAreaSelectorProps> = ({
const newX = x - dragStart.x;
const newY = y - dragStart.y;
const newDomRect: DOMRect = {
const newDomRect: Rectangle = {
x: newX,
y: newY,
width: domRect.width,
@ -188,7 +186,7 @@ const CropAreaSelector: React.FC<CropAreaSelectorProps> = ({
// Helper functions
function getResizeHandle(x: number, y: number, domRect: DOMRect): ResizeHandle {
function getResizeHandle(x: number, y: number, domRect: Rectangle): ResizeHandle {
const handleSize = 8;
const tolerance = handleSize;
@ -211,17 +209,17 @@ function isNear(a: number, b: number, tolerance: number): boolean {
return Math.abs(a - b) <= tolerance;
}
function isPointInCropArea(x: number, y: number, domRect: DOMRect): boolean {
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: DOMRect,
currentRect: Rectangle,
mouseX: number,
mouseY: number,
): DOMRect {
): Rectangle {
let { x, y, width, height } = currentRect;
switch (handle) {

View File

@ -5,10 +5,12 @@ 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 "src/constants/pageSizeConstants";
import {
calculatePDFBounds,
PDFBounds,
CropArea
Rectangle
} from "../../../utils/cropCoordinates";
import { pdfWorkerManager } from "../../../services/pdfWorkerManager";
import DocumentThumbnail from "../../shared/filePreview/DocumentThumbnail";
@ -69,12 +71,7 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
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) {
if (parameters.parameters.cropArea === DEFAULT_CROP_AREA) {
parameters.resetToFullPDF(bounds);
}
@ -83,10 +80,10 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
} 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);
const bounds = calculatePDFBounds(PAGE_SIZES.A4.width, PAGE_SIZES.A4.height, CONTAINER_SIZE, CONTAINER_SIZE);
setPdfBounds(bounds);
if (parameters.parameters.width === 595 && parameters.parameters.height === 842) {
if (parameters.parameters.cropArea.width === PAGE_SIZES.A4.width && parameters.parameters.cropArea.height === PAGE_SIZES.A4.height) {
parameters.resetToFullPDF(bounds);
}
}
@ -100,14 +97,14 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
// Handle crop area changes from the selector
const handleCropAreaChange = (newCropArea: CropArea) => {
const handleCropAreaChange = (newCropArea: Rectangle) => {
if (pdfBounds) {
parameters.setCropArea(newCropArea, pdfBounds);
}
};
// Handle manual coordinate input changes
const handleCoordinateChange = (field: keyof CropArea, value: number | string) => {
const handleCoordinateChange = (field: keyof Rectangle, value: number | string) => {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) return;

View File

@ -1,37 +1,4 @@
/**
* 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;
import { PAGE_SIZES } from "./pageSizeConstants";
// Default crop area (covers entire page)
export const DEFAULT_CROP_AREA = {
@ -41,22 +8,5 @@ export const DEFAULT_CROP_AREA = {
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;
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

@ -7,12 +7,13 @@ import { CropParameters, defaultParameters } from './useCropParameters';
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", parameters.x.toString());
formData.append("y", parameters.y.toString());
formData.append("width", parameters.width.toString());
formData.append("height", parameters.height.toString());
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;
};

View File

@ -1,31 +1,22 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
import { useCallback } from 'react';
import { CropArea, PDFBounds, constrainCropAreaToPDF, createFullPDFCropArea, roundCropArea } from '../../../utils/cropCoordinates';
import { Rectangle, PDFBounds, constrainCropAreaToPDF, createFullPDFCropArea, roundCropArea, isRectangle } from '../../../utils/cropCoordinates';
import { DEFAULT_CROP_AREA } from 'src/constants/cropConstants';
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;
cropArea: Rectangle;
}
export const defaultParameters: CropParameters = {
x: 0,
y: 0,
width: 595, // Default A4 width in points
height: 842, // Default A4 height in points
cropArea: DEFAULT_CROP_AREA,
};
export type CropParametersHook = BaseParametersHook<CropParameters> & {
/** Set crop area with PDF bounds validation */
setCropArea: (cropArea: CropArea, pdfBounds?: PDFBounds) => void;
setCropArea: (cropArea: Rectangle, pdfBounds?: PDFBounds) => void;
/** Get current crop area as CropArea object */
getCropArea: () => CropArea;
getCropArea: () => Rectangle;
/** Reset to full PDF dimensions */
resetToFullPDF: (pdfBounds: PDFBounds) => void;
/** Check if current crop area is valid for the PDF */
@ -33,7 +24,7 @@ export type CropParametersHook = BaseParametersHook<CropParameters> & {
/** 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;
updateCropAreaConstrained: (cropArea: Partial<Rectangle>, pdfBounds?: PDFBounds) => void;
};
export const useCropParameters = (): CropParametersHook => {
@ -41,37 +32,29 @@ export const useCropParameters = (): CropParametersHook => {
defaultParameters,
endpointName: 'crop',
validateFn: (params) => {
const rect = params.cropArea;
// Basic validation - coordinates and dimensions must be positive
return params.x >= 0 &&
params.y >= 0 &&
params.width > 0 &&
params.height > 0;
return rect.x >= 0 &&
rect.y >= 0 &&
rect.width > 0 &&
rect.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,
};
const getCropArea = useCallback((): Rectangle => {
return baseHook.parameters.cropArea;
}, [baseHook.parameters]);
// Set crop area with optional PDF bounds validation
const setCropArea = useCallback((cropArea: CropArea, pdfBounds?: PDFBounds) => {
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('x', finalCropArea.x);
baseHook.updateParameter('y', finalCropArea.y);
baseHook.updateParameter('width', finalCropArea.width);
baseHook.updateParameter('height', finalCropArea.height);
baseHook.updateParameter('cropArea', finalCropArea);
}, [baseHook]);
// Reset to cover entire PDF
@ -114,7 +97,7 @@ export const useCropParameters = (): CropParametersHook => {
// Update crop area with constraints applied
const updateCropAreaConstrained = useCallback((
partialCropArea: Partial<CropArea>,
partialCropArea: Partial<Rectangle>,
pdfBounds?: PDFBounds
) => {
const currentCropArea = getCropArea();
@ -132,11 +115,12 @@ export const useCropParameters = (): CropParametersHook => {
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
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);

View File

@ -19,26 +19,33 @@ export interface PDFBounds {
scale: number;
}
export interface CropArea {
/** X coordinate in PDF points (0 = left edge) */
export interface Rectangle {
/** X coordinate */
x: number;
/** Y coordinate in PDF points (0 = bottom edge, PDF coordinate system) */
/** Y coordinate */
y: number;
/** Width in PDF points */
/** Width */
width: number;
/** Height in PDF points */
/** Height */
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;
/** 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
);
}
/**
@ -79,9 +86,9 @@ export const calculatePDFBounds = (
* Handles coordinate system conversion (DOM uses top-left, PDF uses bottom-left origin)
*/
export const domToPDFCoordinates = (
domRect: DOMRect,
domRect: Rectangle,
pdfBounds: PDFBounds
): CropArea => {
): Rectangle => {
// Convert DOM coordinates to thumbnail-relative coordinates
const thumbX = domRect.x - pdfBounds.offsetX;
const thumbY = domRect.y - pdfBounds.offsetY;
@ -104,9 +111,9 @@ export const domToPDFCoordinates = (
* Convert PDF coordinates to DOM coordinates (relative to container)
*/
export const pdfToDOMCoordinates = (
cropArea: CropArea,
cropArea: Rectangle,
pdfBounds: PDFBounds
): DOMRect => {
): 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;
@ -126,9 +133,9 @@ export const pdfToDOMCoordinates = (
* Constrain a crop area to stay within PDF bounds
*/
export const constrainCropAreaToPDF = (
cropArea: CropArea,
cropArea: Rectangle,
pdfBounds: PDFBounds
): CropArea => {
): 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);
@ -145,9 +152,9 @@ export const constrainCropAreaToPDF = (
* Constrain DOM coordinates to stay within thumbnail bounds
*/
export const constrainDOMRectToThumbnail = (
domRect: DOMRect,
domRect: Rectangle,
pdfBounds: PDFBounds
): DOMRect => {
): Rectangle => {
const thumbnailLeft = pdfBounds.offsetX;
const thumbnailTop = pdfBounds.offsetY;
const thumbnailRight = pdfBounds.offsetX + pdfBounds.thumbnailWidth;
@ -189,7 +196,7 @@ export const isPointInThumbnail = (
/**
* Create a default crop area that covers the entire PDF
*/
export const createFullPDFCropArea = (pdfBounds: PDFBounds): CropArea => {
export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => {
return {
x: 0,
y: 0,
@ -201,7 +208,7 @@ export const createFullPDFCropArea = (pdfBounds: PDFBounds): CropArea => {
/**
* Round crop coordinates to reasonable precision (0.1 point)
*/
export const roundCropArea = (cropArea: CropArea): CropArea => {
export const roundCropArea = (cropArea: Rectangle): Rectangle => {
return {
x: Math.round(cropArea.x * 10) / 10,
y: Math.round(cropArea.y * 10) / 10,