mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-22 19:46:39 +00:00
Initial working Crop
This commit is contained in:
parent
f29bbfd15b
commit
5a24e00497
339
frontend/src/components/tools/crop/CropAreaSelector.tsx
Normal file
339
frontend/src/components/tools/crop/CropAreaSelector.tsx
Normal 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;
|
328
frontend/src/components/tools/crop/CropSettings.tsx
Normal file
328
frontend/src/components/tools/crop/CropSettings.tsx
Normal 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;
|
62
frontend/src/constants/cropConstants.ts
Normal file
62
frontend/src/constants/cropConstants.ts
Normal 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;
|
@ -22,6 +22,7 @@ import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
|||||||
import Flatten from "../tools/Flatten";
|
import Flatten from "../tools/Flatten";
|
||||||
import Rotate from "../tools/Rotate";
|
import Rotate from "../tools/Rotate";
|
||||||
import ChangeMetadata from "../tools/ChangeMetadata";
|
import ChangeMetadata from "../tools/ChangeMetadata";
|
||||||
|
import Crop from "../tools/Crop";
|
||||||
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
||||||
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
||||||
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
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 { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
||||||
import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation";
|
import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation";
|
||||||
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||||
|
import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation";
|
||||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
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 { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
|
||||||
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||||
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
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
|
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||||
|
|
||||||
@ -314,10 +317,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
cropPdf: {
|
cropPdf: {
|
||||||
icon: <LocalIcon icon="crop-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="crop-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.crop.title", "Crop PDF"),
|
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!)"),
|
description: t("home.crop.desc", "Crop a PDF to reduce its size (maintains text!)"),
|
||||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||||
|
maxFiles: -1,
|
||||||
|
endpoints: ["crop"],
|
||||||
|
operationConfig: cropOperationConfig,
|
||||||
|
settingsComponent: CropSettings,
|
||||||
},
|
},
|
||||||
rotate: {
|
rotate: {
|
||||||
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
|
38
frontend/src/hooks/tools/crop/useCropOperation.ts
Normal file
38
frontend/src/hooks/tools/crop/useCropOperation.ts
Normal 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.')
|
||||||
|
)
|
||||||
|
});
|
||||||
|
};
|
166
frontend/src/hooks/tools/crop/useCropParameters.ts
Normal file
166
frontend/src/hooks/tools/crop/useCropParameters.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
71
frontend/src/tools/Crop.tsx
Normal file
71
frontend/src/tools/Crop.tsx
Normal 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;
|
218
frontend/src/utils/cropCoordinates.ts
Normal file
218
frontend/src/utils/cropCoordinates.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user