feat(phase2): implement ManualCropUI with drag handles
- 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: ✓
This commit is contained in:
222
frontend/hooks/useCropHandles.ts
Normal file
222
frontend/hooks/useCropHandles.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user