import * as React from "react"; import { ImageURISource, StyleSheet, View } from "react-native"; import { PanGestureHandler, PanGestureHandlerGestureEvent } from "react-native-gesture-handler"; import Animated, { useAnimatedGestureHandler, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { BOX_BORDER, BOX_INDICATOR_BORDER, BOX_INDICATOR_SIZE } from "./constants"; import Gridlines from "./Gridlines"; export type RotationAngles = -270 | -180 | -90 | 90 | 180 | 270; export type Adjustments = { rotate: number; flipHorizontal: boolean; originX: number; originY: number; width: number; height: number; }; interface EditBoxProps { gridlines?: boolean; maxWidth?: number; maxHeight?: number; source: ImageURISource & { height: number; width: number }; } interface EditBoxRefMethods { calculateAdjustments: () => Adjustments; flip: () => void; reset: () => void; rotate: (degrees: RotationAngles) => void; } interface GestureEventContext extends Record { topY: number; bottomY: number; leftX: number; rightX: number; verticalMode: "top" | "bottom" | null; horizontalMode: "left" | "right" | null; } interface Bounds { topY: number; bottomY: number; leftX: number; rightX: number; } function createBoundsValues(initialBounds: Bounds) { return { topY: useSharedValue(initialBounds.topY), bottomY: useSharedValue(initialBounds.bottomY), leftX: useSharedValue(initialBounds.leftX), rightX: useSharedValue(initialBounds.rightX), }; } function EditBox({ gridlines = true, maxWidth, maxHeight, source }: EditBoxProps, ref: React.Ref) { const aspectRatio = source.width / source.height; let imageWidth = (undefined !== maxWidth ? Math.min(maxWidth, source.width) : source.width) - 2 * BOX_BORDER; let imageHeight = imageWidth / aspectRatio; if (undefined !== maxHeight && imageHeight + 2 * BOX_BORDER > maxHeight) { imageHeight = maxHeight - 2 * BOX_BORDER; imageWidth = imageHeight * aspectRatio; } const initialBounds: Bounds = { topY: -BOX_INDICATOR_BORDER + BOX_BORDER, bottomY: imageHeight - BOX_INDICATOR_SIZE + BOX_INDICATOR_BORDER + BOX_BORDER, leftX: -BOX_INDICATOR_BORDER + BOX_BORDER, rightX: imageWidth - BOX_INDICATOR_SIZE + BOX_INDICATOR_BORDER + BOX_BORDER, }; const isActive = useSharedValue(false); const rotationZ = useSharedValue(0); const rotationY = useSharedValue(0); const scale = useSharedValue(1); const currentBounds = createBoundsValues(initialBounds); const boundingBoxRect = createBoundsValues(initialBounds); const resetBoundingBox = () => { boundingBoxRect.topY.value = withTiming(initialBounds.topY); boundingBoxRect.bottomY.value = withTiming(initialBounds.bottomY); boundingBoxRect.leftX.value = withTiming(initialBounds.leftX); boundingBoxRect.rightX.value = withTiming(initialBounds.rightX); }; const resetCurrentBounds = () => { currentBounds.topY.value = initialBounds.topY; currentBounds.bottomY.value = initialBounds.bottomY; currentBounds.leftX.value = initialBounds.leftX; currentBounds.rightX.value = initialBounds.rightX; }; const handleOnReset = () => { resetCurrentBounds(); resetBoundingBox(); rotationZ.value = withTiming(0); scale.value = withTiming(1); }; const handleOnRotate = (degrees: RotationAngles) => { const isOriginalOrientation = degrees % 180 === 0; let nextScale = isOriginalOrientation ? 1 : aspectRatio; // Check if scaled and rotated height doesnt exceed max height if (undefined !== maxHeight && imageWidth * nextScale > maxHeight) { nextScale = maxHeight / imageWidth; } rotationZ.value = withTiming(degrees); scale.value = withTiming(nextScale); if (isOriginalOrientation) { resetBoundingBox(); resetCurrentBounds(); return; } const horizontal = imageWidth * nextScale - imageHeight; const vertical = imageHeight * nextScale - imageWidth; const topY = initialBounds.topY - horizontal / 2; const bottomY = initialBounds.bottomY + horizontal / 2; const leftX = initialBounds.leftX - vertical / 2; const rightX = initialBounds.rightX + vertical / 2; boundingBoxRect.topY.value = withTiming(topY); boundingBoxRect.bottomY.value = withTiming(bottomY); boundingBoxRect.leftX.value = withTiming(leftX); boundingBoxRect.rightX.value = withTiming(rightX); currentBounds.topY.value = topY; currentBounds.bottomY.value = bottomY; currentBounds.leftX.value = leftX; currentBounds.rightX.value = rightX; }; React.useImperativeHandle(ref, () => ({ calculateAdjustments: () => { const rotate = rotationZ.value; const indicatorSpace = BOX_INDICATOR_SIZE - 2 * BOX_INDICATOR_BORDER; const viewBoxWidth = boundingBoxRect.rightX.value - boundingBoxRect.leftX.value + indicatorSpace; const viewBoxHeight = boundingBoxRect.bottomY.value - boundingBoxRect.topY.value + indicatorSpace; const viewOriginX = boundingBoxRect.leftX.value - currentBounds.leftX.value; const viewOriginY = boundingBoxRect.topY.value - currentBounds.topY.value; let actualBoxWidth = (viewBoxWidth / imageWidth) * source.width; let actualBoxHeight = (viewBoxHeight / imageHeight) * source.height; if (!(rotate % 180 === 0)) { const scaledWidth = imageHeight * scale.value; const scaledHeight = imageWidth * scale.value; actualBoxHeight = (viewBoxHeight / scaledHeight) * source.width; actualBoxWidth = (viewBoxWidth / scaledWidth) * source.height; } return { rotate, flipHorizontal: rotationY.value === 180, originX: (viewOriginX / viewBoxWidth) * actualBoxWidth, originY: (viewOriginY / viewBoxHeight) * actualBoxHeight, width: actualBoxWidth, height: actualBoxHeight, }; }, flip: () => { rotationY.value = (rotationY.value + 180) % 360; }, reset: handleOnReset, rotate: handleOnRotate, })); const gestureHandler = useAnimatedGestureHandler({ onStart: (event, context) => { isActive.value = true; context.topY = boundingBoxRect.topY.value; context.bottomY = boundingBoxRect.bottomY.value; context.leftX = boundingBoxRect.leftX.value; context.rightX = boundingBoxRect.rightX.value; const boxWidth = boundingBoxRect.rightX.value - boundingBoxRect.leftX.value; const boxHeight = boundingBoxRect.bottomY.value - boundingBoxRect.topY.value; context.verticalMode = event.y <= (1 / 3) * boxHeight ? "top" : event.y >= (2 / 3) * boxHeight ? "bottom" : null; context.horizontalMode = event.x <= (1 / 3) * boxWidth ? "left" : event.x >= (2 / 3) * boxWidth ? "right" : null; }, onActive: (event, context) => { if (context.verticalMode === "top") { boundingBoxRect.topY.value = Math.min( Math.max(currentBounds.topY.value, context.topY + event.translationY), currentBounds.bottomY.value ); } else if (context.verticalMode === "bottom") { boundingBoxRect.bottomY.value = Math.max( Math.min(currentBounds.bottomY.value, context.bottomY + event.translationY), currentBounds.topY.value ); } if (context.horizontalMode === "left") { boundingBoxRect.leftX.value = Math.min( Math.max(currentBounds.leftX.value, context.leftX + event.translationX), currentBounds.rightX.value ); } else if (context.horizontalMode === "right") { boundingBoxRect.rightX.value = Math.max( Math.min(currentBounds.rightX.value, context.rightX + event.translationX), currentBounds.leftX.value ); } }, onFinish: () => { isActive.value = false; }, }); const backgroundImageStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }, { rotateZ: `${rotationZ.value}deg` }, { rotateY: `${rotationY.value}deg` }], })); const foregroundImageStyle = useAnimatedStyle(() => ({ top: -boundingBoxRect.topY.value + initialBounds.topY, left: -boundingBoxRect.leftX.value + initialBounds.leftX, transform: [{ scale: scale.value }, { rotateZ: `${rotationZ.value}deg` }, { rotateY: `${rotationY.value}deg` }], })); const boundingBoxStyles = useAnimatedStyle(() => ({ top: boundingBoxRect.topY.value - initialBounds.topY, left: boundingBoxRect.leftX.value - initialBounds.leftX, bottom: initialBounds.bottomY - boundingBoxRect.bottomY.value, right: initialBounds.rightX - boundingBoxRect.rightX.value, })); const foregroundImageBoxStyles = useAnimatedStyle(() => ({ top: boundingBoxRect.topY.value - initialBounds.topY, left: boundingBoxRect.leftX.value - initialBounds.leftX, bottom: initialBounds.bottomY - boundingBoxRect.bottomY.value, right: initialBounds.rightX - boundingBoxRect.rightX.value, })); const imageDimensions = { height: imageHeight, width: imageWidth }; return ( {gridlines && ( )} ); } const styles = StyleSheet.create({ boundingBox: { borderColor: "white", borderWidth: BOX_BORDER, position: "absolute", }, imageBoundingBox: { borderColor: "transparent", borderWidth: BOX_BORDER, position: "absolute", overflow: "hidden", }, backgroundImage: { opacity: 0.25, }, image: { ...StyleSheet.absoluteFillObject, top: BOX_BORDER, left: BOX_BORDER, }, indicator: { position: "absolute", height: BOX_INDICATOR_SIZE, width: BOX_INDICATOR_SIZE, }, indicatorTopLeft: { borderTopColor: "white", borderTopWidth: BOX_INDICATOR_BORDER, borderStartColor: "white", borderStartWidth: BOX_INDICATOR_BORDER, top: -BOX_INDICATOR_BORDER, left: -BOX_INDICATOR_BORDER, }, indicatorTopRight: { borderTopColor: "white", borderTopWidth: BOX_INDICATOR_BORDER, borderEndColor: "white", borderEndWidth: BOX_INDICATOR_BORDER, top: -BOX_INDICATOR_BORDER, right: -BOX_INDICATOR_BORDER, }, indicatorBottomLeft: { borderBottomColor: "white", borderBottomWidth: BOX_INDICATOR_BORDER, borderStartColor: "white", borderStartWidth: BOX_INDICATOR_BORDER, bottom: -BOX_INDICATOR_BORDER, left: -BOX_INDICATOR_BORDER, }, indicatorBottomRight: { borderBottomColor: "white", borderBottomWidth: BOX_INDICATOR_BORDER, borderEndColor: "white", borderEndWidth: BOX_INDICATOR_BORDER, bottom: -BOX_INDICATOR_BORDER, right: -BOX_INDICATOR_BORDER, }, }); export default React.forwardRef(EditBox);