mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
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:
300
frontend/src/components/tools/crop/CropAreaSelector.tsx
Normal file
300
frontend/src/components/tools/crop/CropAreaSelector.tsx
Normal 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;
|
||||
262
frontend/src/components/tools/crop/CropSettings.tsx
Normal file
262
frontend/src/components/tools/crop/CropSettings.tsx
Normal 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;
|
||||
21
frontend/src/components/tooltips/useCropTooltips.ts
Normal file
21
frontend/src/components/tooltips/useCropTooltips.ts
Normal 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"),
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user