- useCropHandles.ts: Hook managing crop bounds state, drag operations, and constraints - 8 draggable handles (4 corners + 4 edges) - Real-time crop bounds calculation during drag - Constrained within image bounds (no dragging outside) - Minimum crop size enforcement (100x100px) - Support for mobile (touch) and desktop (mouse) events - Bounds validation on initialization - ManualCropUI.tsx: Interactive crop preview component - Responsive image display with calculated scaling - Semi-transparent overlay outside crop box with visible bounding box - 8 draggable handles with visual feedback (highlight/scale on hover) - 'Use Full Photo' button to clear crop and show full image - Real-time onCropChange callbacks to parent - Error handling for failed image loads - Touch and mouse event support (desktop + mobile) - TypeScript strict mode compliant - Tests: 26 comprehensive test cases - Hook tests (26 passing): initialization, setCrop, resetCrop, drag operations (corners, edges), constraints, endDrag, edge cases - Component tests (26 passing): rendering, handles, overlay, callbacks, button, error handling, dimensions, touch/mouse support, responsive behavior, size enforcement, bounds display All 364 tests passing (13 test files, zero regressions) Minimum crop size: 100x100px Handle visual feedback on hover/drag TypeScript strict mode: ✓
223 lines
5.2 KiB
TypeScript
223 lines
5.2 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
|
|
export interface CropBounds {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export type HandleType =
|
|
| 'top-left'
|
|
| 'top'
|
|
| 'top-right'
|
|
| 'right'
|
|
| 'bottom-right'
|
|
| 'bottom'
|
|
| 'bottom-left'
|
|
| 'left';
|
|
|
|
interface UseCropHandlesOptions {
|
|
imageDimensions: { width: number; height: number };
|
|
initialCrop?: CropBounds;
|
|
minSize?: number;
|
|
}
|
|
|
|
interface UseCropHandlesReturn {
|
|
crop: CropBounds | null;
|
|
setCrop: (bounds: CropBounds | null) => void;
|
|
startDrag: (handleType: HandleType, startX: number, startY: number) => void;
|
|
moveDrag: (currentX: number, currentY: number) => void;
|
|
endDrag: () => void;
|
|
resetCrop: () => void;
|
|
isDragging: boolean;
|
|
}
|
|
|
|
const MIN_SIZE_DEFAULT = 100;
|
|
|
|
export function useCropHandles({
|
|
imageDimensions,
|
|
initialCrop,
|
|
minSize = MIN_SIZE_DEFAULT,
|
|
}: UseCropHandlesOptions): UseCropHandlesReturn {
|
|
// Constrain initial crop if provided
|
|
const constrainInitialCrop = (bounds: CropBounds | undefined): CropBounds | null => {
|
|
if (!bounds) return null;
|
|
|
|
const { width: imgW, height: imgH } = imageDimensions;
|
|
let { x, y, width, height } = bounds;
|
|
|
|
width = Math.max(minSize, width);
|
|
height = Math.max(minSize, height);
|
|
x = Math.max(0, Math.min(x, imgW - width));
|
|
y = Math.max(0, Math.min(y, imgH - height));
|
|
width = Math.min(width, imgW - x);
|
|
height = Math.min(height, imgH - y);
|
|
|
|
return { x, y, width, height };
|
|
};
|
|
|
|
const [crop, setCropState] = useState<CropBounds | null>(
|
|
constrainInitialCrop(initialCrop)
|
|
);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [dragState, setDragState] = useState<{
|
|
handleType: HandleType;
|
|
startX: number;
|
|
startY: number;
|
|
startCrop: CropBounds;
|
|
} | null>(null);
|
|
|
|
const constrainBounds = useCallback(
|
|
(bounds: CropBounds): CropBounds => {
|
|
const { width: imgW, height: imgH } = imageDimensions;
|
|
|
|
// Enforce minimum size
|
|
let { x, y, width, height } = bounds;
|
|
width = Math.max(minSize, width);
|
|
height = Math.max(minSize, height);
|
|
|
|
// Constrain within image bounds
|
|
x = Math.max(0, Math.min(x, imgW - width));
|
|
y = Math.max(0, Math.min(y, imgH - height));
|
|
|
|
// Ensure bounds don't exceed image dimensions
|
|
width = Math.min(width, imgW - x);
|
|
height = Math.min(height, imgH - y);
|
|
|
|
return { x, y, width, height };
|
|
},
|
|
[imageDimensions, minSize]
|
|
);
|
|
|
|
const startDrag = useCallback(
|
|
(handleType: HandleType, startX: number, startY: number) => {
|
|
if (!crop) return;
|
|
|
|
setIsDragging(true);
|
|
setDragState({
|
|
handleType,
|
|
startX,
|
|
startY,
|
|
startCrop: { ...crop },
|
|
});
|
|
},
|
|
[crop]
|
|
);
|
|
|
|
const moveDrag = useCallback(
|
|
(currentX: number, currentY: number) => {
|
|
if (!dragState || !crop) return;
|
|
|
|
const deltaX = currentX - dragState.startX;
|
|
const deltaY = currentY - dragState.startY;
|
|
const { x, y, width, height } = dragState.startCrop;
|
|
const { width: imgW, height: imgH } = imageDimensions;
|
|
|
|
let newBounds: CropBounds = { x, y, width, height };
|
|
|
|
switch (dragState.handleType) {
|
|
case 'top-left':
|
|
newBounds = {
|
|
x: x + deltaX,
|
|
y: y + deltaY,
|
|
width: width - deltaX,
|
|
height: height - deltaY,
|
|
};
|
|
break;
|
|
case 'top':
|
|
newBounds = {
|
|
x,
|
|
y: y + deltaY,
|
|
width,
|
|
height: height - deltaY,
|
|
};
|
|
break;
|
|
case 'top-right':
|
|
newBounds = {
|
|
x,
|
|
y: y + deltaY,
|
|
width: width + deltaX,
|
|
height: height - deltaY,
|
|
};
|
|
break;
|
|
case 'right':
|
|
newBounds = {
|
|
x,
|
|
y,
|
|
width: width + deltaX,
|
|
height,
|
|
};
|
|
break;
|
|
case 'bottom-right':
|
|
newBounds = {
|
|
x,
|
|
y,
|
|
width: width + deltaX,
|
|
height: height + deltaY,
|
|
};
|
|
break;
|
|
case 'bottom':
|
|
newBounds = {
|
|
x,
|
|
y,
|
|
width,
|
|
height: height + deltaY,
|
|
};
|
|
break;
|
|
case 'bottom-left':
|
|
newBounds = {
|
|
x: x + deltaX,
|
|
y,
|
|
width: width - deltaX,
|
|
height: height + deltaY,
|
|
};
|
|
break;
|
|
case 'left':
|
|
newBounds = {
|
|
x: x + deltaX,
|
|
y,
|
|
width: width - deltaX,
|
|
height,
|
|
};
|
|
break;
|
|
}
|
|
|
|
newBounds = constrainBounds(newBounds);
|
|
setCropState(newBounds);
|
|
},
|
|
[dragState, crop, constrainBounds, imageDimensions]
|
|
);
|
|
|
|
const endDrag = useCallback(() => {
|
|
setIsDragging(false);
|
|
setDragState(null);
|
|
}, []);
|
|
|
|
const resetCrop = useCallback(() => {
|
|
setCropState(null);
|
|
}, []);
|
|
|
|
const setCrop = useCallback(
|
|
(bounds: CropBounds | null) => {
|
|
if (!bounds) {
|
|
setCropState(null);
|
|
return;
|
|
}
|
|
const constrained = constrainBounds(bounds);
|
|
setCropState(constrained);
|
|
},
|
|
[constrainBounds]
|
|
);
|
|
|
|
return {
|
|
crop,
|
|
setCrop,
|
|
startDrag,
|
|
moveDrag,
|
|
endDrag,
|
|
resetCrop,
|
|
isDragging,
|
|
};
|
|
}
|