expo-dynamic-image-crop
Version:
Dynamic image cropping component for Expo/React Native with free-form and fixed aspect ratio support
463 lines (462 loc) • 19.7 kB
JavaScript
import { jsx as _jsx } from "react/jsx-runtime";
import { useContext, useEffect, useState } from "react";
import { Animated, StyleSheet, View } from "react-native";
import { GestureHandlerRootView, PanGestureHandler, State, } from "react-native-gesture-handler";
import { EditorContext } from "./context/editor";
import { useEditorStore } from "./store";
const horizontalSections = ["top", "middle", "bottom"];
const verticalSections = ["left", "middle", "right"];
const ImageCropOverlay = () => {
const [selectedFrameSection, setSelectedFrameSection] = useState("");
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const cropSize = useEditorStore((s) => s.cropSize);
const setCropSize = useEditorStore((s) => s.setCropSize);
const imageBounds = useEditorStore((s) => s.imageBounds);
const accumulatedPan = useEditorStore((s) => s.accumulatedPan);
const setAccumulatedPan = useEditorStore((s) => s.setAccumulatedPan);
const { gridOverlayColor, coverMarker, overlayCropColor } = useEditorStore((s) => s.editorOptions);
const { fixedAspectRatio, minimumCropDimensions, dynamicCrop } = useContext(EditorContext);
const [animatedCropSize] = useState({
width: new Animated.Value(cropSize.width),
height: new Animated.Value(cropSize.height),
});
const [animatedPosition] = useState({
x: new Animated.Value(0),
y: new Animated.Value(0),
});
useEffect(() => {
checkCropBounds({
translationX: 0,
translationY: 0,
});
animatedCropSize.height.setValue(cropSize.height);
animatedCropSize.width.setValue(cropSize.width);
}, [cropSize]);
useEffect(() => {
// Reset animated position when accumulatedPan changes (after drag ends)
if (!isDragging) {
animatedPosition.x.setValue(0);
animatedPosition.y.setValue(0);
}
}, [accumulatedPan, isDragging]);
useEffect(() => {
const newSize = { width: 0, height: 0 };
const { width, height } = imageBounds;
if (dynamicCrop) {
// For dynamic crop, start with a reasonable default size (80% of smaller dimension)
const smallerDimension = Math.min(width, height);
newSize.width = smallerDimension * 0.8;
newSize.height = smallerDimension * 0.8;
}
else {
// Original fixed aspect ratio logic
const imageAspectRatio = width / height;
if (fixedAspectRatio < imageAspectRatio) {
newSize.height = height * 0.8;
newSize.width = newSize.height * fixedAspectRatio;
}
else {
newSize.width = width * 0.8;
newSize.height = newSize.width / fixedAspectRatio;
}
}
setCropSize(newSize);
// Center the crop area within the image bounds
const centeredX = imageBounds.x + (imageBounds.width - newSize.width) / 2;
const centeredY = imageBounds.y + (imageBounds.height - newSize.height) / 2;
setAccumulatedPan({
x: centeredX,
y: centeredY,
});
}, [imageBounds, dynamicCrop]);
const isMovingSection = () => selectedFrameSection === "topmiddle" ||
selectedFrameSection === "middleleft" ||
selectedFrameSection === "middleright" ||
selectedFrameSection === "middlemiddle" ||
selectedFrameSection === "bottommiddle";
const isLeft = selectedFrameSection.endsWith("left");
const isRight = selectedFrameSection.endsWith("right");
const isTop = selectedFrameSection.startsWith("top");
const isBottom = selectedFrameSection.startsWith("bottom");
const onOverlayMove = ({ nativeEvent }) => {
if (selectedFrameSection !== "") {
if (isMovingSection()) {
setDragOffset({
x: nativeEvent.translationX,
y: nativeEvent.translationY,
});
}
else {
const { x, y } = getTargetCropFrameBounds(nativeEvent);
// Update animated size
animatedCropSize.width.setValue(cropSize.width + x);
animatedCropSize.height.setValue(cropSize.height + y);
// Update animated position for left/top handles
let positionOffsetX = 0;
let positionOffsetY = 0;
if (isLeft) {
positionOffsetX = -x; // Move opposite to width change
}
if (isTop) {
positionOffsetY = -y; // Move opposite to height change
}
animatedPosition.x.setValue(positionOffsetX);
animatedPosition.y.setValue(positionOffsetY);
}
}
else {
const { x, y } = nativeEvent;
const { width: initialWidth, height: initialHeight } = cropSize;
let position = "";
if (y / initialHeight < 0.333) {
position = position + "top";
}
else if (y / initialHeight < 0.667) {
position = position + "middle";
}
else {
position = position + "bottom";
}
if (x / initialWidth < 0.333) {
position = position + "left";
}
else if (x / initialWidth < 0.667) {
position = position + "middle";
}
else {
position = position + "right";
}
setSelectedFrameSection(position);
}
};
const getTargetCropFrameBounds = ({ translationX, translationY, }) => {
let x = 0;
let y = 0;
if (dynamicCrop) {
// For dynamic crop, allow independent width and height adjustment
if (isLeft || isRight) {
x = (isLeft ? -1 : 1) * translationX;
}
if (isTop || isBottom) {
y = (isTop ? -1 : 1) * translationY;
}
// For corner handles, allow both directions
if ((isLeft || isRight) && (isTop || isBottom)) {
x = (isLeft ? -1 : 1) * translationX;
y = (isTop ? -1 : 1) * translationY;
}
}
else {
// Fixed aspect ratio logic - improved corner handling
const isCorner = (isLeft || isRight) && (isTop || isBottom);
if (isCorner) {
// For corners, use the larger translation to maintain aspect ratio
const absX = Math.abs(translationX);
const absY = Math.abs(translationY);
if (absX > absY) {
x = (isLeft ? -1 : 1) * translationX;
y = x / fixedAspectRatio;
}
else {
y = (isTop ? -1 : 1) * translationY;
x = y * fixedAspectRatio;
}
}
else if (isLeft || isRight) {
// Side edges
x = (isLeft ? -1 : 1) * translationX;
y = x / fixedAspectRatio;
}
else if (isTop || isBottom) {
// Top/bottom edges
y = (isTop ? -1 : 1) * translationY;
x = y * fixedAspectRatio;
}
}
return { x, y };
};
const onOverlayRelease = (nativeEvent) => {
if (isMovingSection()) {
let newX = accumulatedPan.x + dragOffset.x;
let newY = accumulatedPan.y + dragOffset.y;
// Clamp to bounds
if (newX <= imageBounds.x) {
newX = imageBounds.x;
}
else if (newX + cropSize.width > imageBounds.width + imageBounds.x) {
newX = imageBounds.x + imageBounds.width - cropSize.width;
}
if (newY <= imageBounds.y) {
newY = imageBounds.y;
}
else if (newY + cropSize.height > imageBounds.height + imageBounds.y) {
newY = imageBounds.y + imageBounds.height - cropSize.height;
}
setAccumulatedPan({ x: newX, y: newY });
setDragOffset({ x: 0, y: 0 });
}
else {
checkResizeBounds(nativeEvent);
}
setSelectedFrameSection("");
};
const onHandlerStateChange = ({ nativeEvent, }) => {
if (nativeEvent.state === State.BEGAN)
setIsDragging(true);
if (nativeEvent.state === State.END) {
onOverlayRelease(nativeEvent);
setIsDragging(false);
}
};
const checkCropBounds = ({ translationX, translationY }) => {
let accDx = accumulatedPan.x + translationX;
if (accDx <= imageBounds.x) {
accDx = imageBounds.x;
}
else if (accDx + cropSize.width > imageBounds.width + imageBounds.x) {
accDx = imageBounds.x + imageBounds.width - cropSize.width;
}
let accDy = accumulatedPan.y + translationY;
if (accDy <= imageBounds.y) {
accDy = imageBounds.y;
}
else if (accDy + cropSize.height > imageBounds.height + imageBounds.y) {
accDy = imageBounds.y + imageBounds.height - cropSize.height;
}
setAccumulatedPan({ x: accDx, y: accDy });
};
const checkResizeBounds = ({ translationX, translationY }) => {
let { width: maxWidth, height: maxHeight } = imageBounds;
const { width: minWidth, height: minHeight } = minimumCropDimensions;
if (dynamicCrop) {
// For dynamic crop, handle width and height independently
const { x, y } = getTargetCropFrameBounds({ translationX, translationY });
// Calculate new dimensions
let newWidth = cropSize.width + x;
let newHeight = cropSize.height + y;
// Calculate new position - account for current animated offset
let newX = accumulatedPan.x;
let newY = accumulatedPan.y;
// For left edges/corners: when width increases, position stays same
// When width decreases, position moves right by the difference
if (isLeft) {
newX = accumulatedPan.x - x; // Move opposite to width change
}
// For top edges/corners: when height increases, position stays same
// When height decreases, position moves down by the difference
if (isTop) {
newY = accumulatedPan.y - y; // Move opposite to height change
}
// Apply minimum size constraints first
if (newWidth < minWidth) {
if (isLeft) {
// If we're dragging left and hit minimum, adjust position back
newX = newX + (newWidth - minWidth);
}
newWidth = minWidth;
}
if (newHeight < minHeight) {
if (isTop) {
// If we're dragging top and hit minimum, adjust position back
newY = newY + (newHeight - minHeight);
}
newHeight = minHeight;
}
// Apply boundary constraints
// Left boundary
if (newX < imageBounds.x) {
if (isLeft) {
newWidth = newWidth - (imageBounds.x - newX);
}
newX = imageBounds.x;
}
// Top boundary
if (newY < imageBounds.y) {
if (isTop) {
newHeight = newHeight - (imageBounds.y - newY);
}
newY = imageBounds.y;
}
// Right boundary
if (newX + newWidth > imageBounds.x + imageBounds.width) {
newWidth = imageBounds.x + imageBounds.width - newX;
}
// Bottom boundary
if (newY + newHeight > imageBounds.y + imageBounds.height) {
newHeight = imageBounds.y + imageBounds.height - newY;
}
// Update state and reset animated values smoothly
setCropSize({ height: newHeight, width: newWidth });
setAccumulatedPan({ x: newX, y: newY });
}
else {
// Fixed aspect ratio logic
const height = maxWidth / fixedAspectRatio;
if (maxHeight > height)
maxHeight = height;
const width = maxHeight * fixedAspectRatio;
if (maxWidth > width)
maxWidth = width;
const { x, y } = getTargetCropFrameBounds({ translationX, translationY });
let newWidth = cropSize.width + x;
let newHeight = cropSize.height + y;
// Maintain aspect ratio
if (Math.abs(x) > Math.abs(y)) {
newHeight = newWidth / fixedAspectRatio;
}
else {
newWidth = newHeight * fixedAspectRatio;
}
// Calculate position adjustments
let newX = accumulatedPan.x;
let newY = accumulatedPan.y;
if (isLeft) {
newX = accumulatedPan.x - (newWidth - cropSize.width);
}
if (isTop) {
newY = accumulatedPan.y - (newHeight - cropSize.height);
}
// Apply size constraints
if (newWidth > maxWidth) {
const ratio = maxWidth / newWidth;
newWidth = maxWidth;
newHeight = newHeight * ratio;
if (isLeft) {
newX = accumulatedPan.x - (newWidth - cropSize.width);
}
if (isTop) {
newY = accumulatedPan.y - (newHeight - cropSize.height);
}
}
if (newHeight > maxHeight) {
const ratio = maxHeight / newHeight;
newHeight = maxHeight;
newWidth = newWidth * ratio;
if (isLeft) {
newX = accumulatedPan.x - (newWidth - cropSize.width);
}
if (isTop) {
newY = accumulatedPan.y - (newHeight - cropSize.height);
}
}
if (newWidth < minWidth) {
const ratio = minWidth / newWidth;
newWidth = minWidth;
newHeight = newHeight * ratio;
if (isLeft) {
newX = accumulatedPan.x - (newWidth - cropSize.width);
}
if (isTop) {
newY = accumulatedPan.y - (newHeight - cropSize.height);
}
}
if (newHeight < minHeight) {
const ratio = minHeight / newHeight;
newHeight = minHeight;
newWidth = newWidth * ratio;
if (isLeft) {
newX = accumulatedPan.x - (newWidth - cropSize.width);
}
if (isTop) {
newY = accumulatedPan.y - (newHeight - cropSize.height);
}
}
// Apply boundary constraints
if (newX < imageBounds.x) {
const diff = imageBounds.x - newX;
newX = imageBounds.x;
if (isLeft) {
newWidth = newWidth - diff;
newHeight = newWidth / fixedAspectRatio;
}
}
if (newY < imageBounds.y) {
const diff = imageBounds.y - newY;
newY = imageBounds.y;
if (isTop) {
newHeight = newHeight - diff;
newWidth = newHeight * fixedAspectRatio;
}
}
if (newX + newWidth > imageBounds.x + imageBounds.width) {
newWidth = imageBounds.x + imageBounds.width - newX;
newHeight = newWidth / fixedAspectRatio;
}
if (newY + newHeight > imageBounds.y + imageBounds.height) {
newHeight = imageBounds.y + imageBounds.height - newY;
newWidth = newHeight * fixedAspectRatio;
}
// Update state and reset animated values smoothly
setCropSize({ height: newHeight, width: newWidth });
setAccumulatedPan({ x: newX, y: newY });
}
};
return (_jsx(View, { style: styles.container, children: _jsx(GestureHandlerRootView, { style: { flex: 1 }, children: _jsx(PanGestureHandler, { onGestureEvent: onOverlayMove, onHandlerStateChange: (e) => onHandlerStateChange(e), children: _jsx(Animated.View, { style: [
styles.overlay,
{
backgroundColor: overlayCropColor,
borderColor: gridOverlayColor,
},
animatedCropSize,
{
transform: [
{
translateX: Animated.add(accumulatedPan.x + dragOffset.x, animatedPosition.x),
},
{
translateY: Animated.add(accumulatedPan.y + dragOffset.y, animatedPosition.y),
},
],
},
], children: horizontalSections.map((horizontalSection) => {
return (_jsx(View, { style: styles.sectionRow, children: verticalSections.map((verticalSection) => {
const key = horizontalSection + verticalSection;
return (_jsx(View, { style: [
styles.defaultSection,
{ borderColor: gridOverlayColor },
], children: (key === "topleft" ||
key === "topright" ||
key === "bottomleft" ||
key === "bottomright") &&
coverMarker?.show && (_jsx(View, { style: [
styles.cornerMarker,
{ borderColor: coverMarker?.color },
horizontalSection === "top"
? { top: -4, borderTopWidth: 7 }
: { bottom: -4, borderBottomWidth: 7 },
verticalSection === "left"
? { left: -4, borderLeftWidth: 7 }
: { right: -4, borderRightWidth: 7 },
] })) }, key));
}) }, horizontalSection));
}) }) }) }) }));
};
export { ImageCropOverlay };
const styles = StyleSheet.create({
container: {
height: "100%",
width: "100%",
position: "absolute",
},
overlay: {
height: 40,
width: 40,
borderWidth: 1,
},
sectionRow: {
flexDirection: "row",
flex: 1,
},
defaultSection: {
flex: 1,
borderWidth: 0.5,
justifyContent: "center",
alignItems: "center",
},
cornerMarker: {
position: "absolute",
height: 30,
width: 30,
},
});