chore: Change crop to remove locked aspect ratio
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m21s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 22s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

This commit is contained in:
Eric Gullickson
2026-02-06 22:15:39 -06:00
parent e4336ce9da
commit 75ce316aa5
2 changed files with 222 additions and 116 deletions

View File

@@ -25,11 +25,13 @@ export const CropTool: React.FC<CropToolProps> = ({
const imageAreaRef = useRef<HTMLDivElement>(null);
const [imageMaxHeight, setImageMaxHeight] = useState(0);
const { cropArea, isDragging, resetCrop, executeCrop, handleDragStart } =
const { cropArea, cropDrawn, isDragging, resetCrop, executeCrop, handleDragStart, handleDrawStart } =
useImageCrop({
aspectRatio: lockAspectRatio ? aspectRatio : undefined,
});
const showCropArea = cropDrawn || (isDragging && cropArea.width > 1 && cropArea.height > 1);
// Measure available height for the image so the crop container
// matches the rendered image exactly (fixes mobile crop offset)
useEffect(() => {
@@ -108,126 +110,150 @@ export const CropTool: React.FC<CropToolProps> = ({
draggable={false}
/>
{/* Draw surface for free-form rectangle drawing */}
{!cropDrawn && (
<Box
onMouseDown={handleDrawStart}
onTouchStart={handleDrawStart}
sx={{
position: 'absolute',
inset: 0,
cursor: 'crosshair',
zIndex: 5,
touchAction: 'none',
}}
/>
)}
{/* Dark overlay outside crop area */}
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
}}
>
{/* Top overlay */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${cropArea.y}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
{/* Bottom overlay */}
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: `${100 - cropArea.y - cropArea.height}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
{/* Left overlay */}
<Box
sx={{
position: 'absolute',
top: `${cropArea.y}%`,
left: 0,
width: `${cropArea.x}%`,
height: `${cropArea.height}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
{/* Right overlay */}
<Box
sx={{
position: 'absolute',
top: `${cropArea.y}%`,
right: 0,
width: `${100 - cropArea.x - cropArea.width}%`,
height: `${cropArea.height}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
</Box>
{/* Crop area with handles */}
<Box
sx={{
position: 'absolute',
top: `${cropArea.y}%`,
left: `${cropArea.x}%`,
width: `${cropArea.width}%`,
height: `${cropArea.height}%`,
border: '2px solid white',
boxSizing: 'border-box',
}}
>
{/* Move handle (center area) */}
<CropHandleArea
handle="move"
onDragStart={handleDragStart}
sx={{
position: 'absolute',
inset: 8,
cursor: 'move',
}}
/>
{/* Corner handles */}
<CropHandle handle="nw" onDragStart={handleDragStart} position="top-left" />
<CropHandle handle="ne" onDragStart={handleDragStart} position="top-right" />
<CropHandle handle="sw" onDragStart={handleDragStart} position="bottom-left" />
<CropHandle handle="se" onDragStart={handleDragStart} position="bottom-right" />
{/* Edge handles */}
<CropHandle handle="n" onDragStart={handleDragStart} position="top" />
<CropHandle handle="s" onDragStart={handleDragStart} position="bottom" />
<CropHandle handle="w" onDragStart={handleDragStart} position="left" />
<CropHandle handle="e" onDragStart={handleDragStart} position="right" />
{/* Grid lines for alignment */}
{showCropArea && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gridTemplateRows: '1fr 1fr 1fr',
pointerEvents: 'none',
opacity: isDragging ? 1 : 0.5,
transition: 'opacity 0.2s',
}}
>
{Array.from({ length: 9 }).map((_, i) => (
<Box
key={i}
sx={{
borderRight: i % 3 !== 2 ? '1px solid rgba(255,255,255,0.3)' : 'none',
borderBottom: i < 6 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
))}
{/* Top overlay */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${cropArea.y}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
{/* Bottom overlay */}
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: `${100 - cropArea.y - cropArea.height}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
{/* Left overlay */}
<Box
sx={{
position: 'absolute',
top: `${cropArea.y}%`,
left: 0,
width: `${cropArea.x}%`,
height: `${cropArea.height}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
{/* Right overlay */}
<Box
sx={{
position: 'absolute',
top: `${cropArea.y}%`,
right: 0,
width: `${100 - cropArea.x - cropArea.width}%`,
height: `${cropArea.height}%`,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
</Box>
</Box>
)}
{/* Crop area border and handles */}
{showCropArea && (
<Box
sx={{
position: 'absolute',
top: `${cropArea.y}%`,
left: `${cropArea.x}%`,
width: `${cropArea.width}%`,
height: `${cropArea.height}%`,
border: '2px solid white',
boxSizing: 'border-box',
}}
>
{/* Handles only appear after drawing is complete */}
{cropDrawn && (
<>
{/* Move handle (center area) */}
<CropHandleArea
handle="move"
onDragStart={handleDragStart}
sx={{
position: 'absolute',
inset: 8,
cursor: 'move',
}}
/>
{/* Corner handles */}
<CropHandle handle="nw" onDragStart={handleDragStart} position="top-left" />
<CropHandle handle="ne" onDragStart={handleDragStart} position="top-right" />
<CropHandle handle="sw" onDragStart={handleDragStart} position="bottom-left" />
<CropHandle handle="se" onDragStart={handleDragStart} position="bottom-right" />
{/* Edge handles */}
<CropHandle handle="n" onDragStart={handleDragStart} position="top" />
<CropHandle handle="s" onDragStart={handleDragStart} position="bottom" />
<CropHandle handle="w" onDragStart={handleDragStart} position="left" />
<CropHandle handle="e" onDragStart={handleDragStart} position="right" />
</>
)}
{/* Grid lines for alignment */}
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gridTemplateRows: '1fr 1fr 1fr',
pointerEvents: 'none',
opacity: isDragging ? 1 : 0.5,
transition: 'opacity 0.2s',
}}
>
{Array.from({ length: 9 }).map((_, i) => (
<Box
key={i}
sx={{
borderRight: i % 3 !== 2 ? '1px solid rgba(255,255,255,0.3)' : 'none',
borderBottom: i < 6 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
))}
</Box>
</Box>
)}
</Box>
</Box>
{/* Instructions */}
<Box sx={{ px: 2, py: 1, textAlign: 'center' }}>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
Drag to adjust crop area
{cropDrawn ? 'Drag handles to adjust crop area' : 'Tap and drag to select crop area'}
</Typography>
</Box>
@@ -255,7 +281,7 @@ export const CropTool: React.FC<CropToolProps> = ({
onClick={handleReset}
startIcon={<RefreshIcon />}
sx={{ color: 'white' }}
disabled={isProcessing}
disabled={isProcessing || !cropDrawn}
>
Reset
</Button>
@@ -271,7 +297,7 @@ export const CropTool: React.FC<CropToolProps> = ({
<IconButton
onClick={handleConfirm}
disabled={isProcessing}
disabled={isProcessing || !cropDrawn}
aria-label="Confirm crop"
sx={{
width: 56,

View File

@@ -18,16 +18,20 @@ interface UseImageCropOptions {
interface UseImageCropReturn {
/** Current crop area */
cropArea: CropArea;
/** Whether user has drawn a crop rectangle */
cropDrawn: boolean;
/** Whether user is actively dragging */
isDragging: boolean;
/** Set crop area */
setCropArea: (area: CropArea) => void;
/** Reset crop to initial/default */
/** Reset crop to drawing mode */
resetCrop: () => void;
/** Execute crop and return cropped blob */
executeCrop: (imageSrc: string, mimeType?: string) => Promise<Blob>;
/** Handle drag start for crop handles */
handleDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void;
/** Handle draw start for free-form rectangle drawing */
handleDrawStart: (event: React.MouseEvent | React.TouchEvent) => void;
/** Handle move during drag */
handleMove: (event: MouseEvent | TouchEvent) => void;
/** Handle drag end */
@@ -78,12 +82,22 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet
const [cropArea, setCropAreaState] = useState<CropArea>(
getAspectRatioAdjustedCrop(initialCrop)
);
const [cropDrawn, setCropDrawn] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const activeHandleRef = useRef<CropHandle | null>(null);
const startPosRef = useRef({ x: 0, y: 0 });
const startCropRef = useRef<CropArea>(cropArea);
const containerRef = useRef<{ width: number; height: number }>({ width: 100, height: 100 });
const containerRef = useRef<{ width: number; height: number; left: number; top: number }>({
width: 100, height: 100, left: 0, top: 0,
});
const isDrawingRef = useRef(false);
const drawOriginRef = useRef({ x: 0, y: 0 });
const cropAreaRef = useRef(cropArea);
useEffect(() => {
cropAreaRef.current = cropArea;
}, [cropArea]);
const setCropArea = useCallback(
(area: CropArea) => {
@@ -94,6 +108,7 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet
const resetCrop = useCallback(() => {
setCropAreaState(getAspectRatioAdjustedCrop(initialCrop));
setCropDrawn(false);
}, [initialCrop, getAspectRatioAdjustedCrop]);
const constrainCrop = useCallback(
@@ -136,19 +151,75 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet
const container = target.closest('[data-crop-container]');
if (container) {
const rect = container.getBoundingClientRect();
containerRef.current = { width: rect.width, height: rect.height };
containerRef.current = { width: rect.width, height: rect.height, left: rect.left, top: rect.top };
}
},
[cropArea]
);
const handleMove = useCallback(
(event: MouseEvent | TouchEvent) => {
if (!activeHandleRef.current || !isDragging) return;
const handleDrawStart = useCallback(
(event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
const container = target.closest('[data-crop-container]');
if (!container) return;
const rect = container.getBoundingClientRect();
containerRef.current = { width: rect.width, height: rect.height, left: rect.left, top: rect.top };
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
const x = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
const y = Math.max(0, Math.min(100, ((clientY - rect.top) / rect.height) * 100));
startPosRef.current = { x: clientX, y: clientY };
drawOriginRef.current = { x, y };
setCropAreaState({ x, y, width: 0, height: 0 });
isDrawingRef.current = true;
activeHandleRef.current = null;
setIsDragging(true);
},
[]
);
const handleMove = useCallback(
(event: MouseEvent | TouchEvent) => {
if (!isDragging) return;
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
// Free-form drawing mode: compute rectangle from origin to current pointer
if (isDrawingRef.current) {
const currentX = Math.max(0, Math.min(100,
((clientX - containerRef.current.left) / containerRef.current.width) * 100));
const currentY = Math.max(0, Math.min(100,
((clientY - containerRef.current.top) / containerRef.current.height) * 100));
const originX = drawOriginRef.current.x;
const originY = drawOriginRef.current.y;
let newCrop: CropArea = {
x: Math.min(originX, currentX),
y: Math.min(originY, currentY),
width: Math.abs(currentX - originX),
height: Math.abs(currentY - originY),
};
if (aspectRatio) {
newCrop.height = newCrop.width / aspectRatio;
}
setCropAreaState(newCrop);
return;
}
if (!activeHandleRef.current) return;
// Calculate delta as percentage of container
const deltaX = ((clientX - startPosRef.current.x) / containerRef.current.width) * 100;
const deltaY = ((clientY - startPosRef.current.y) / containerRef.current.height) * 100;
@@ -234,13 +305,20 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet
setCropAreaState(constrainCrop(newCrop));
},
[isDragging, constrainCrop]
[isDragging, constrainCrop, aspectRatio]
);
const handleDragEnd = useCallback(() => {
if (isDrawingRef.current) {
isDrawingRef.current = false;
const area = cropAreaRef.current;
if (area.width >= minSize && area.height >= minSize) {
setCropDrawn(true);
}
}
activeHandleRef.current = null;
setIsDragging(false);
}, []);
}, [minSize]);
// Add global event listeners for drag
useEffect(() => {
@@ -320,11 +398,13 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet
return {
cropArea,
cropDrawn,
isDragging,
setCropArea,
resetCrop,
executeCrop,
handleDragStart,
handleDrawStart,
handleMove,
handleDragEnd,
};