import * as React from 'react'; import type { LayoutChangeEvent, ImageURISource } from 'react-native'; import { View, StyleSheet, Platform, Image, Dimensions } from 'react-native'; import { cropPhoto, resolveImagePath } from './'; import { getInitialValues, viewCoordinatesToImageCoordinates, } from './utils/helpers'; import { useImperativeHandle } from 'react'; import Animated, { useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; import { Svg, Path } from 'react-native-svg'; import type { CroppedPhotoResponse } from './types'; import { ActivityIndicator } from 'react-native'; const AnimatedPath = Animated.createAnimatedComponent(Path); const WINDOW_WIDTH = Dimensions.get('window').width; export interface ImageCropperRefOut { crop: () => Promise; } interface ImageCropperProps { source: ImageURISource; cropperSignColor?: string; pointSize?: number; fillColor?: string; handleBgColor?: string; handleBorderColor?: string; } const ImageCropper = ( { source, cropperSignColor, pointSize, fillColor, handleBgColor, handleBorderColor, }: ImageCropperProps, ref: React.Ref ) => { const [init, setInit] = React.useState(false); const viewScaleX = useSharedValue(0.9); const viewScaleY = useSharedValue(0.95); const maxWidth = useSharedValue(0); const maxHeight = useSharedValue(0); const minWidth = useSharedValue(0); const minHeight = useSharedValue(0); const TOP_LEFT = { x: useSharedValue(0), y: useSharedValue(0), }; const TOP_RIGHT = { x: useSharedValue(0), y: useSharedValue(0), }; const BOTTOM_LEFT = { x: useSharedValue(0), y: useSharedValue(0), }; const BOTTOM_RIGHT = { x: useSharedValue(0), y: useSharedValue(0), }; const ZOOM_CONTAINER = { x: useSharedValue(0), y: useSharedValue(0), }; const ZOOM_SIGN_OPACITY_ANIM = useSharedValue(0); const [imageUrl, setImageUrl] = React.useState(''); const [_imgHeight, setHeight] = React.useState(0); const [_imgWidth, setWidth] = React.useState(0); const [viewHeight, setViewHeight] = React.useState(0); React.useEffect(() => { initiateCropper(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewHeight]); const initiateCropper = async ( newImagePath?: string, newWidth?: number, newHeight?: number ) => { let imagePath = imageUrl ? imageUrl : await resolveImagePath((newImagePath || source.uri) as string); if (!imageUrl) setImageUrl(imagePath); // else imagePath = imageUrl; Image.getSize( `file://` + imagePath.replace('file://', ''), (width, height) => { setWidth(newWidth || width); setHeight(newHeight || height); const newViewHeight = WINDOW_WIDTH * ((newHeight || height) / (newWidth || width)); setViewHeight(newViewHeight); const WIDTH = newWidth || width; const HEIGHT = newHeight || height; // setting top left initial values const topLeftValues = getInitialValues( { x: 0, y: 0 }, WIDTH, HEIGHT, newViewHeight ); const topRightValues = getInitialValues( { x: width, y: 0 }, WIDTH, HEIGHT, newViewHeight ); const bottomLeftValues = getInitialValues( { x: 0, y: height }, WIDTH, HEIGHT, newViewHeight ); const bottomRightValues = getInitialValues( { x: width, y: height }, WIDTH, HEIGHT, newViewHeight ); minWidth.value = topLeftValues.x; maxWidth.value = bottomRightValues.x; minHeight.value = topRightValues.y; maxHeight.value = bottomRightValues.y; TOP_LEFT.x.value = withTiming(topLeftValues.x); TOP_LEFT.y.value = withTiming(topLeftValues.y); TOP_RIGHT.x.value = withTiming(topRightValues.x); TOP_RIGHT.y.value = withTiming(topRightValues.y); BOTTOM_LEFT.x.value = withTiming(bottomLeftValues.x); BOTTOM_LEFT.y.value = withTiming(bottomLeftValues.y); BOTTOM_RIGHT.x.value = withTiming(bottomRightValues.x); BOTTOM_RIGHT.y.value = withTiming(bottomRightValues.y); // setting top left initial values setInit(true); } ); }; const createAnimatedPanGesture = (node: any) => { return Gesture.Pan() .onChange((event) => { node.x.value += event.changeX; node.y.value += event.changeY; if (node.x.value > maxWidth.value) { node.x.value = maxWidth.value; } if (node.x.value < minWidth.value) { node.x.value = minWidth.value; } if (node.y.value > maxHeight.value) { node.y.value = maxHeight.value; } if (node.y.value < minHeight.value) { node.y.value = minHeight.value; } ZOOM_CONTAINER.x.value = node.x.value; ZOOM_CONTAINER.y.value = node.y.value; }) .onBegin(() => { ZOOM_SIGN_OPACITY_ANIM.value = withTiming(1); }) .onFinalize(() => { ZOOM_SIGN_OPACITY_ANIM.value = withTiming(0); }); }; const renderPoints = React.useCallback((nodes: any) => { return nodes.map((node: any, index: number) => { return ( ); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const publicRef: ImageCropperRefOut = { crop: () => { return new Promise(async (resolve) => { const coordinates = { topLeft: viewCoordinatesToImageCoordinates( // @ts-ignore { x: TOP_LEFT.x.value, y: TOP_LEFT.y.value }, _imgWidth, _imgHeight, viewHeight ), topRight: viewCoordinatesToImageCoordinates( // @ts-ignore { x: TOP_RIGHT.x.value, y: TOP_RIGHT.y.value }, _imgWidth, _imgHeight, viewHeight ), bottomLeft: viewCoordinatesToImageCoordinates( // @ts-ignore { x: BOTTOM_LEFT.x.value, y: BOTTOM_LEFT.y.value }, _imgWidth, _imgHeight, viewHeight ), bottomRight: viewCoordinatesToImageCoordinates( // @ts-ignore { x: BOTTOM_RIGHT.x.value, y: BOTTOM_RIGHT.y.value }, _imgWidth, _imgHeight, viewHeight ), height: _imgHeight, width: _imgWidth, }; const newPhotoUrl = await cropPhoto(coordinates, imageUrl); setImageUrl(newPhotoUrl.uri); resolve(newPhotoUrl); initiateCropper( newPhotoUrl.uri, newPhotoUrl.width as number, newPhotoUrl.height as number ); }); }, }; useImperativeHandle(ref, () => publicRef); const heightStyle = { height: viewHeight, }; const color = 'rgba(97, 218, 251, .5)'; const animatedZoomImage = useAnimatedStyle(() => { const adjustment = ZOOM_CONTAINER_SIZE / 2; return { marginLeft: -ZOOM_CONTAINER.x.value + adjustment - ZOOM_CURSOR_BORDER_SIZE, marginTop: -ZOOM_CONTAINER.y.value + adjustment - ZOOM_CURSOR_SIZE / 2, }; }); const animatedProps = useAnimatedProps(() => { // draw a circle const path = ` M ${TOP_LEFT.x.value}, ${TOP_LEFT.y.value} L${BOTTOM_LEFT.x.value}, ${BOTTOM_LEFT.y.value} L${BOTTOM_RIGHT.x.value}, ${BOTTOM_RIGHT.y.value} L${TOP_RIGHT.x.value}, ${TOP_RIGHT.y.value} L${TOP_LEFT.x.value}, ${TOP_LEFT.y.value} `; return { d: path, }; }); const animatedZoomImageContainer = useAnimatedStyle(() => { const adjustment = ZOOM_CONTAINER_SIZE / 2; const leftVaue = ZOOM_CONTAINER.x.value - adjustment - 50; return { left: leftVaue < 0 ? 0 : leftVaue, top: ZOOM_CONTAINER.y.value - 50, opacity: ZOOM_SIGN_OPACITY_ANIM.value, }; }); const onLayout = React.useCallback( ({ nativeEvent }: LayoutChangeEvent) => { const { height } = nativeEvent.layout; if (height < viewHeight) { viewScaleY.value = withTiming(0.72); viewScaleX.value = withTiming(0.72); } }, [viewHeight, viewScaleX, viewScaleY] ); const viewScaleAnimatedStyle = useAnimatedStyle(() => { return { transform: [{ scaleX: viewScaleX.value }, { scaleY: viewScaleY.value }], }; }); if (!init) { return ; } return ( {imageUrl && ( )} {imageUrl && ( )} {renderPoints([TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT])} {imageUrl && ( {/* Cursor */} )} ); }; export default React.forwardRef(ImageCropper); const IMAGE_CROPPER_POINT_CONTAINER_SIZE = 130; const IMAGE_CROPPER_POINT_SIZE = 30; const CROPPER_COLOR = 'rgba(255,90,90, 0.8)'; const ZOOM_CONTAINER_SIZE = 120; const ZOOM_CONTAINER_BORDER_WIDTH = 2; const ZOOM_CURSOR_SIZE = 20; const ZOOM_CURSOR_BORDER_SIZE = 2; const dynamicStyles = ({ viewHeight, cropperSignColor, pointSize, handleBgColor, handleBorderColor, }: any) => { return StyleSheet.create({ canvas: { height: viewHeight, position: 'absolute', width: '100%' }, zoomSignHorizontal: { width: pointSize || ZOOM_CURSOR_SIZE, height: ZOOM_CURSOR_BORDER_SIZE, backgroundColor: cropperSignColor || CROPPER_COLOR, }, zoomSignVertical: { width: ZOOM_CURSOR_BORDER_SIZE, height: pointSize || ZOOM_CURSOR_SIZE, marginTop: -(pointSize || ZOOM_CURSOR_SIZE) / 2, backgroundColor: cropperSignColor || CROPPER_COLOR, }, imageCropperPoint: { width: IMAGE_CROPPER_POINT_SIZE, height: IMAGE_CROPPER_POINT_SIZE, borderRadius: IMAGE_CROPPER_POINT_SIZE / 2, backgroundColor: handleBgColor || 'rgba(0, 255, 255, 0.5)', borderWidth: 1, borderColor: handleBorderColor || CROPPER_COLOR, }, }); }; const styles = StyleSheet.create({ containerT: { flex: 1, justifyContent: 'center', }, container: { width: '100%', transform: [ { scale: 0.95, }, ], }, img: { width: '100%', }, handler: { height: 20, width: 140, overflow: 'visible', marginLeft: -70, marginTop: -70, alignItems: 'center', justifyContent: 'center', position: 'absolute', }, handlerI: { borderRadius: 0, height: 20, width: 20, backgroundColor: 'blue', }, handlerIExtras: { left: -10, top: -10 }, handlerRound: { width: 39, position: 'absolute', height: 39, borderRadius: 100, backgroundColor: 'blue', }, handlerRoundExtras: { left: 31, top: 31 }, imageCropperPointContainer: { alignItems: 'center', justifyContent: 'center', position: 'absolute', width: IMAGE_CROPPER_POINT_CONTAINER_SIZE, height: IMAGE_CROPPER_POINT_CONTAINER_SIZE, marginTop: -IMAGE_CROPPER_POINT_CONTAINER_SIZE / 2, marginLeft: -IMAGE_CROPPER_POINT_CONTAINER_SIZE / 2, zIndex: 2, }, zoomContainer: { position: 'absolute', top: 50, left: 10, width: ZOOM_CONTAINER_SIZE, height: ZOOM_CONTAINER_SIZE, borderRadius: ZOOM_CONTAINER_SIZE / 2, borderColor: 'white', borderWidth: ZOOM_CONTAINER_BORDER_WIDTH, overflow: 'hidden', backgroundColor: 'black', }, zoomSign: { position: 'absolute', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', }, });