Restructure frontend code to allow for extensions (#4721)

# Description of Changes
Move frontend code into `core` folder and add infrastructure for
`proprietary` folder to include premium, non-OSS features
This commit is contained in:
James Brunton
2025-10-28 10:29:36 +00:00
committed by GitHub
parent 960d48f80c
commit d2b38ef4b8
725 changed files with 2485 additions and 2226 deletions
@@ -0,0 +1,301 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { Box, useMantineTheme, MantineTheme } from '@mantine/core';
import {
PDFBounds,
Rectangle,
domToPDFCoordinates,
pdfToDOMCoordinates,
constrainDOMRectToThumbnail,
isPointInThumbnail
} from '@app/utils/cropCoordinates';
import { type ResizeHandle } from '@app/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',
transition: (isDragging || isResizing) ? undefined : 'all 1s ease-in-out'
}}
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;
@@ -0,0 +1,41 @@
/**
* CropAutomationSettings - Used for automation only
*
* Simplified crop settings for automation that doesn't require a file preview.
* Allows users to manually enter crop coordinates and dimensions.
*/
import { Stack } from "@mantine/core";
import { CropParameters } from "@app/hooks/tools/crop/useCropParameters";
import { Rectangle } from "@app/utils/cropCoordinates";
import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs";
interface CropAutomationSettingsProps {
parameters: CropParameters;
onParameterChange: <K extends keyof CropParameters>(key: K, value: CropParameters[K]) => void;
disabled?: boolean;
}
const CropAutomationSettings = ({ parameters, onParameterChange, disabled = false }: CropAutomationSettingsProps) => {
// Handle coordinate changes
const handleCoordinateChange = (field: keyof Rectangle, value: number | string) => {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) return;
const newCropArea = { ...parameters.cropArea, [field]: numValue };
onParameterChange('cropArea', newCropArea);
};
return (
<Stack gap="md">
<CropCoordinateInputs
cropArea={parameters.cropArea}
onCoordinateChange={handleCoordinateChange}
disabled={disabled}
showAutomationInfo={true}
/>
</Stack>
);
};
export default CropAutomationSettings;
@@ -0,0 +1,101 @@
import { Stack, Text, Group, NumberInput, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Rectangle, PDFBounds } from "@app/utils/cropCoordinates";
interface CropCoordinateInputsProps {
cropArea: Rectangle;
onCoordinateChange: (field: keyof Rectangle, value: number | string) => void;
disabled?: boolean;
pdfBounds?: PDFBounds;
showAutomationInfo?: boolean;
}
const CropCoordinateInputs = ({
cropArea,
onCoordinateChange,
disabled = false,
pdfBounds,
showAutomationInfo = false
}: CropCoordinateInputsProps) => {
const { t } = useTranslation();
return (
<Stack gap="xs">
{showAutomationInfo && (
<Alert color="blue" variant="light">
<Text size="xs">
{t("crop.automation.info", "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.")}
</Text>
</Alert>
)}
<Text size="sm" fw={500}>
{t("crop.coordinates.title", "Position and Size")}
</Text>
<Group grow>
<NumberInput
label={t("crop.coordinates.x.label", "X Position")}
description={showAutomationInfo ? t("crop.coordinates.x.desc", "Left edge (points)") : undefined}
value={Math.round(cropArea.x * 10) / 10}
onChange={(value) => onCoordinateChange('x', value)}
disabled={disabled}
min={0}
max={pdfBounds?.actualWidth}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
<NumberInput
label={t("crop.coordinates.y.label", "Y Position")}
description={showAutomationInfo ? t("crop.coordinates.y.desc", "Bottom edge (points)") : undefined}
value={Math.round(cropArea.y * 10) / 10}
onChange={(value) => onCoordinateChange('y', value)}
disabled={disabled}
min={0}
max={pdfBounds?.actualHeight}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
</Group>
<Group grow>
<NumberInput
label={t("crop.coordinates.width.label", "Width")}
description={showAutomationInfo ? t("crop.coordinates.width.desc", "Crop width (points)") : undefined}
value={Math.round(cropArea.width * 10) / 10}
onChange={(value) => onCoordinateChange('width', value)}
disabled={disabled}
min={0.1}
max={pdfBounds?.actualWidth}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
<NumberInput
label={t("crop.coordinates.height.label", "Height")}
description={showAutomationInfo ? t("crop.coordinates.height.desc", "Crop height (points)") : undefined}
value={Math.round(cropArea.height * 10) / 10}
onChange={(value) => onCoordinateChange('height', value)}
disabled={disabled}
min={0.1}
max={pdfBounds?.actualHeight}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
</Group>
{showAutomationInfo && (
<Alert color="gray" variant="light">
<Text size="xs">
{t("crop.automation.reference", "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points.")}
</Text>
</Alert>
)}
</Stack>
);
};
export default CropCoordinateInputs;
@@ -0,0 +1,227 @@
import { useMemo, useState, useEffect } from "react";
import { Stack, Text, Box, Group, ActionIcon, Center, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters";
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector";
import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs";
import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants";
import { PAGE_SIZES } from "@app/constants/pageSizeConstants";
import {
calculatePDFBounds,
PDFBounds,
Rectangle
} from "@app/utils/cropCoordinates";
import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
import DocumentThumbnail from "@app/components/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]);
// Listen for tour events to set crop area
useEffect(() => {
const handleSetCropArea = (event: Event) => {
const customEvent = event as CustomEvent<Rectangle>;
if (customEvent.detail && pdfBounds) {
parameters.setCropArea(customEvent.detail, pdfBounds);
}
};
window.addEventListener('tour:setCropArea', handleSetCropArea);
return () => window.removeEventListener('tour:setCropArea', handleSetCropArea);
}, [parameters, pdfBounds]);
// 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" data-tour="crop-settings">
{/* 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 */}
<CropCoordinateInputs
cropArea={cropArea}
onCoordinateChange={handleCoordinateChange}
disabled={disabled}
pdfBounds={pdfBounds}
showAutomationInfo={false}
/>
{/* Validation Alert */}
{!isCropValid && (
<Alert color="red" variant="light">
<Text size="xs">
{t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")}
</Text>
</Alert>
)}
</Stack>
);
};
export default CropSettings;