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:
2026-04-21 13:05:37 +03:00
parent 5a64dadc1e
commit b2e2daf40d
9 changed files with 1440 additions and 1 deletions

View 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,
};
}