├── example ├── .gitignore ├── tsconfig.json ├── assets │ ├── icon.png │ ├── favicon.png │ └── splash.png ├── .expo-shared │ └── assets.json ├── index.js ├── babel.config.js ├── app.json ├── package.json ├── metro.config.js ├── webpack.config.js └── App.tsx ├── .npmignore ├── .github └── FUNDING.yml ├── tsconfig.build.json ├── babel.config.js ├── .yarnrc ├── .gitignore ├── src ├── components │ ├── IconButton.tsx │ └── Icon.tsx ├── Processing.tsx ├── UniversalModal.tsx ├── OperationBar │ ├── Crop.tsx │ ├── OperationBar.tsx │ ├── Rotate.tsx │ ├── OperationSelection.tsx │ └── Blur.tsx ├── Store.tsx ├── customHooks │ └── usePerformCrop.tsx ├── ControlBar.tsx ├── EditingWindow.tsx ├── index.tsx └── ImageCropOverlay.tsx ├── tsconfig.json ├── scripts └── bootstrap.js ├── package.json ├── CHANGELOG.md └── README.md /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Local Netlify folder 2 | .netlify -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example 2 | yarn.lock 3 | yarn.log 4 | .expo 5 | .vscode -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | ko_fi: thomascoldwell 3 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "extends": "./tsconfig", 4 | "exclude": ["example"] 5 | } 6 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } 5 | -------------------------------------------------------------------------------- /example/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomas-coldwell/expo-image-editor/HEAD/example/assets/icon.png -------------------------------------------------------------------------------- /example/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomas-coldwell/expo-image-editor/HEAD/example/assets/favicon.png -------------------------------------------------------------------------------- /example/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomas-coldwell/expo-image-editor/HEAD/example/assets/splash.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["module:metro-react-native-babel-preset", "babel-preset-expo"], 3 | }; 4 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | # Override Yarn command so we can automatically setup the repo on running `yarn` 2 | 3 | yarn-path "scripts/bootstrap.js" 4 | -------------------------------------------------------------------------------- /example/.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import { registerRootComponent } from "expo"; 2 | 3 | import App from "./App"; 4 | 5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App); 6 | // It also ensures that whether you load the app in the Expo client or in a native build, 7 | // the environment is set up appropriately 8 | registerRootComponent(App); 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | 3 | example/node_modules/**/* 4 | example/.expo/* 5 | example/npm-debug.* 6 | example/*.jks 7 | example/*.p8 8 | example/*.p12 9 | example/*.key 10 | example/*.mobileprovision 11 | example/*.orig.* 12 | example/web-build/ 13 | 14 | # macOS 15 | example/.DS_Store 16 | 17 | example/lib/* 18 | 19 | 20 | # Local Netlify folder 21 | .netlify 22 | # generated by bob 23 | lib/ 24 | -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const pak = require("../package.json"); 3 | 4 | module.exports = function (api) { 5 | api.cache(true); 6 | 7 | return { 8 | presets: ["babel-preset-expo"], 9 | plugins: [ 10 | [ 11 | "module-resolver", 12 | { 13 | alias: { 14 | // For development, we want to alias the library to the source 15 | [pak.name]: path.join(__dirname, "..", pak.source), 16 | }, 17 | }, 18 | ], 19 | ], 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { TouchableOpacity, TouchableOpacityProps } from "react-native"; 3 | import { Icon, IIconProps } from "./Icon"; 4 | 5 | type IIconButtonProps = IIconProps & TouchableOpacityProps; 6 | 7 | export function IconButton(props: IIconButtonProps) { 8 | const { text, iconID, ...buttonProps } = props; 9 | const iconProps = { text, iconID, disabled: buttonProps.disabled }; 10 | return ( 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/Processing.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { View, StyleSheet, ActivityIndicator } from 'react-native'; 3 | 4 | function Processing() { 5 | 6 | return( 7 | 8 | 9 | 10 | ); 11 | 12 | } 13 | 14 | export { Processing }; 15 | 16 | const styles = StyleSheet.create({ 17 | container: { 18 | position: 'absolute', 19 | height: '100%', 20 | width: '100%', 21 | backgroundColor: '#33333355', 22 | justifyContent: 'center', 23 | alignItems: 'center' 24 | } 25 | }); -------------------------------------------------------------------------------- /src/UniversalModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Modal as RNModal, Platform } from "react-native"; 3 | //@ts-ignore 4 | import WebModal from "modal-enhanced-react-native-web"; 5 | 6 | interface IUniversalModalProps extends React.ComponentProps { 7 | children: React.ReactNode; 8 | } 9 | 10 | export const UniversalModal = (props: IUniversalModalProps) => { 11 | if (Platform.OS === "web") { 12 | return ( 13 | 14 | {props.children} 15 | 16 | ); 17 | } 18 | 19 | return ; 20 | }; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "expo-image-editor": ["./src/index"] 6 | }, 7 | "allowUnreachableCode": false, 8 | "allowUnusedLabels": false, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "jsx": "react", 12 | "lib": ["esnext", "DOM"], 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitUseStrict": false, 17 | "noStrictGenericChecks": false, 18 | "resolveJsonModule": true, 19 | "skipLibCheck": true, 20 | "strict": true, 21 | "target": "esnext" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "example", 4 | "slug": "example", 5 | "version": "1.0.1", 6 | "orientation": "default", 7 | "icon": "./assets/icon.png", 8 | "splash": { 9 | "image": "./assets/splash.png", 10 | "resizeMode": "contain", 11 | "backgroundColor": "#ffffff" 12 | }, 13 | "updates": { 14 | "fallbackToCacheTimeout": 0 15 | }, 16 | "assetBundlePatterns": [ 17 | "**/*" 18 | ], 19 | "ios": { 20 | "supportsTablet": true 21 | }, 22 | "web": { 23 | "favicon": "./assets/favicon.png" 24 | }, 25 | "platforms": [ 26 | "android", 27 | "ios", 28 | "web" 29 | ], 30 | "android": { 31 | "package": "com.thomascoldwell.expoimageeditor" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /scripts/bootstrap.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | const child_process = require('child_process'); 4 | 5 | const root = path.resolve(__dirname, '..'); 6 | const args = process.argv.slice(2); 7 | const options = { 8 | cwd: process.cwd(), 9 | env: process.env, 10 | stdio: 'inherit', 11 | encoding: 'utf-8', 12 | }; 13 | 14 | if (os.type() === 'Windows_NT') { 15 | options.shell = true 16 | } 17 | 18 | let result; 19 | 20 | if (process.cwd() !== root || args.length) { 21 | // We're not in the root of the project, or additional arguments were passed 22 | // In this case, forward the command to `yarn` 23 | result = child_process.spawnSync('yarn', args, options); 24 | } else { 25 | // If `yarn` is run without arguments, perform bootstrap 26 | result = child_process.spawnSync('yarn', ['bootstrap'], options); 27 | } 28 | 29 | process.exitCode = result.status; 30 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "index", 3 | "scripts": { 4 | "start": "expo start", 5 | "android": "expo start --android", 6 | "ios": "expo start --ios", 7 | "web": "expo start --web", 8 | "eject": "expo eject" 9 | }, 10 | "dependencies": { 11 | "@expo/match-media": "^0.1.0", 12 | "expo": "^42.0.0", 13 | "expo-gl": "~10.4.2", 14 | "expo-gl-cpp": "~10.4.1", 15 | "expo-image-manipulator": "~9.2.2", 16 | "expo-image-picker": "~10.2.2", 17 | "react": "16.13.1", 18 | "react-dom": "16.13.1", 19 | "react-hot-loader": "^4.12.21", 20 | "react-native": "https://github.com/expo/react-native/archive/sdk-42.0.0.tar.gz", 21 | "react-native-web": "~0.13.12", 22 | "react-responsive": "^9.0.0-beta.4" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "~7.9.0", 26 | "babel-plugin-module-resolver": "^4.1.0", 27 | "babel-preset-expo": "8.3.0" 28 | }, 29 | "private": true 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, Text, StyleSheet } from "react-native"; 3 | import { MaterialIcons } from "@expo/vector-icons"; 4 | 5 | export interface IIconProps { 6 | disabled?: boolean; 7 | iconID: React.ComponentProps["name"]; 8 | text: string; 9 | } 10 | 11 | export function Icon(props: IIconProps) { 12 | return ( 13 | 14 | 19 | 20 | {props.text} 21 | 22 | 23 | ); 24 | } 25 | 26 | const styles = StyleSheet.create({ 27 | container: { 28 | height: 64, 29 | width: 80, 30 | flexDirection: "column", 31 | justifyContent: "space-between", 32 | alignItems: "center", 33 | paddingVertical: 8, 34 | }, 35 | text: { 36 | color: "#fff", 37 | textAlign: "center", 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/OperationBar/Crop.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { StyleSheet, View, Text, Platform, Alert } from "react-native"; 3 | import { useRecoilState } from "recoil"; 4 | import { IconButton } from "../components/IconButton"; 5 | import { editingModeState } from "../Store"; 6 | import { usePerformCrop } from "../customHooks/usePerformCrop"; 7 | 8 | export function Crop() { 9 | const [, setEditingMode] = useRecoilState(editingModeState); 10 | 11 | const onPerformCrop = usePerformCrop(); 12 | 13 | return ( 14 | 15 | setEditingMode("operation-select")} 19 | /> 20 | Adjust window to crop 21 | 22 | 23 | ); 24 | } 25 | 26 | const styles = StyleSheet.create({ 27 | container: { 28 | flex: 1, 29 | flexDirection: "row", 30 | justifyContent: "space-between", 31 | alignItems: "center", 32 | paddingHorizontal: "2%", 33 | }, 34 | prompt: { 35 | color: "#fff", 36 | fontSize: 21, 37 | textAlign: "center", 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /example/metro.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const blacklist = require("metro-config/src/defaults/blacklist"); 3 | const escape = require("escape-string-regexp"); 4 | const pak = require("../package.json"); 5 | const { keys } = require("lodash"); 6 | 7 | const root = path.resolve(__dirname, ".."); 8 | 9 | const modules = Object.keys({ ...pak.peerDependencies }); 10 | 11 | module.exports = { 12 | projectRoot: __dirname, 13 | watchFolders: [root], 14 | 15 | // We need to make sure that only one version is loaded for peerDependencies 16 | // So we blacklist them at the root, and alias them to the versions in example's node_modules 17 | resolver: { 18 | blacklistRE: blacklist( 19 | modules.map( 20 | (m) => 21 | new RegExp(`^${escape(path.join(root, "node_modules", m))}\\/.*$`) 22 | ) 23 | ), 24 | 25 | extraNodeModules: modules.reduce((acc, name) => { 26 | acc[name] = path.join(__dirname, "node_modules", name); 27 | return acc; 28 | }, {}), 29 | }, 30 | 31 | transformer: { 32 | getTransformOptions: async () => ({ 33 | transform: { 34 | experimentalImportSupport: false, 35 | inlineRequires: true, 36 | }, 37 | }), 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const createExpoWebpackConfigAsync = require("@expo/webpack-config"); 2 | const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin"); 3 | const webpack = require("webpack"); 4 | const path = require("path"); 5 | const { resolver } = require("./metro.config"); 6 | 7 | const root = path.resolve(__dirname, ".."); 8 | const node_modules = path.join(__dirname, "node_modules"); 9 | 10 | module.exports = async function (env, argv) { 11 | const config = await createExpoWebpackConfigAsync( 12 | { ...env, offline: false }, 13 | argv 14 | ); 15 | 16 | if (env.mode === "development") { 17 | // config.plugins.push(new webpack.HotModuleReplacementPlugin()); 18 | config.plugins.push(new ReactRefreshWebpackPlugin()); 19 | } 20 | 21 | config.module.rules.push({ 22 | test: /\.(js|ts|tsx)$/, 23 | include: path.resolve(root, "src"), 24 | use: "babel-loader", 25 | }); 26 | 27 | // We need to make sure that only one version is loaded for peerDependencies 28 | // So we alias them to the versions in example's node_modules 29 | Object.assign(config.resolve.alias, { 30 | ...resolver.extraNodeModules, 31 | "react-native-web": path.join(node_modules, "react-native-web"), 32 | }); 33 | 34 | return config; 35 | }; 36 | -------------------------------------------------------------------------------- /src/OperationBar/OperationBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Animated, LayoutRectangle, StyleSheet, View } from "react-native"; 3 | import { editingModeState } from "../Store"; 4 | import { useRecoilState } from "recoil"; 5 | import { OperationSelection } from "./OperationSelection"; 6 | import { Crop } from "./Crop"; 7 | import { Rotate } from "./Rotate"; 8 | import { Blur } from "./Blur"; 9 | import { useState } from "react"; 10 | 11 | export function OperationBar() { 12 | // 13 | const [editingMode] = useRecoilState(editingModeState); 14 | 15 | const getOperationWindow = () => { 16 | switch (editingMode) { 17 | case "crop": 18 | return ; 19 | case "rotate": 20 | return ; 21 | case "blur": 22 | return ; 23 | default: 24 | return null; 25 | } 26 | }; 27 | 28 | return ( 29 | 30 | 31 | {editingMode !== "operation-select" && ( 32 | 33 | {getOperationWindow()} 34 | 35 | )} 36 | 37 | ); 38 | } 39 | 40 | const styles = StyleSheet.create({ 41 | container: { 42 | height: 160, 43 | width: "100%", 44 | backgroundColor: "#333", 45 | justifyContent: "center", 46 | }, 47 | }); 48 | -------------------------------------------------------------------------------- /src/Store.tsx: -------------------------------------------------------------------------------- 1 | import { ExpoWebGLRenderingContext } from "expo-gl"; 2 | import { EditingOperations } from "./index"; 3 | import { atom } from "recoil"; 4 | 5 | export interface ImageData { 6 | uri: string; 7 | height: number; 8 | width: number; 9 | } 10 | 11 | export const imageDataState = atom({ 12 | key: "imageDataState", 13 | default: { 14 | uri: "", 15 | width: 0, 16 | height: 0, 17 | }, 18 | }); 19 | 20 | export const imageScaleFactorState = atom({ 21 | key: "imageScaleFactorState", 22 | default: 1, 23 | }); 24 | 25 | export interface ImageBounds { 26 | x: number; 27 | y: number; 28 | height: number; 29 | width: number; 30 | } 31 | 32 | export const imageBoundsState = atom({ 33 | key: "imageBoundsState", 34 | default: { 35 | x: 0, 36 | y: 0, 37 | width: 0, 38 | height: 0, 39 | }, 40 | }); 41 | 42 | export const readyState = atom({ 43 | key: "readyState", 44 | default: false, 45 | }); 46 | 47 | export const processingState = atom({ 48 | key: "processingState", 49 | default: false, 50 | }); 51 | 52 | export interface AccumulatedPan { 53 | x: number; 54 | y: number; 55 | } 56 | 57 | export const accumulatedPanState = atom({ 58 | key: "accumulatedPanState", 59 | default: { 60 | x: 0, 61 | y: 0, 62 | }, 63 | }); 64 | 65 | export interface ImageDimensions { 66 | width: number; 67 | height: number; 68 | } 69 | 70 | export const cropSizeState = atom({ 71 | key: "cropSizeState", 72 | default: { 73 | width: 0, 74 | height: 0, 75 | }, 76 | }); 77 | 78 | export type EditingModes = "operation-select" | EditingOperations; 79 | 80 | export const editingModeState = atom({ 81 | key: "editingModeState", 82 | default: "operation-select", 83 | }); 84 | 85 | interface GLContext { 86 | gl: ExpoWebGLRenderingContext | null; 87 | program: WebGLProgram; 88 | verts: Float32Array; 89 | } 90 | 91 | export const glContextState = atom({ 92 | key: "glContextState", 93 | default: null, 94 | }); 95 | 96 | export const glProgramState = atom({ 97 | key: "glProgramState", 98 | default: null, 99 | }); 100 | -------------------------------------------------------------------------------- /src/customHooks/usePerformCrop.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { useRecoilState } from "recoil"; 3 | import { 4 | accumulatedPanState, 5 | cropSizeState, 6 | editingModeState, 7 | imageBoundsState, 8 | imageDataState, 9 | imageScaleFactorState, 10 | processingState, 11 | } from "../Store"; 12 | import * as ImageManipulator from "expo-image-manipulator"; 13 | import { Alert, Platform } from "react-native"; 14 | 15 | export const usePerformCrop = () => { 16 | const [accumulatedPan] = useRecoilState(accumulatedPanState); 17 | const [imageBounds] = useRecoilState(imageBoundsState); 18 | const [imageScaleFactor] = useRecoilState(imageScaleFactorState); 19 | const [cropSize] = useRecoilState(cropSizeState); 20 | const [, setProcessing] = useRecoilState(processingState); 21 | const [imageData, setImageData] = useRecoilState(imageDataState); 22 | const [, setEditingMode] = useRecoilState(editingModeState); 23 | const onPerformCrop = async () => { 24 | try { 25 | // Calculate cropping bounds 26 | const croppingBounds = { 27 | originX: Math.round( 28 | (accumulatedPan.x - imageBounds.x) * imageScaleFactor 29 | ), 30 | originY: Math.round( 31 | (accumulatedPan.y - imageBounds.y) * imageScaleFactor 32 | ), 33 | width: Math.round(cropSize.width * imageScaleFactor), 34 | height: Math.round(cropSize.height * imageScaleFactor), 35 | }; 36 | // Set the editor state to processing and perform the crop 37 | setProcessing(true); 38 | const cropResult = await ImageManipulator.manipulateAsync(imageData.uri, [ 39 | { crop: croppingBounds }, 40 | ]); 41 | // Check if on web - currently there is a weird bug where it will keep 42 | // the canvas from ImageManipualtor at originX + width and so we'll just crop 43 | // the result again for now if on web - TODO write github issue! 44 | if (Platform.OS === "web") { 45 | const webCorrection = await ImageManipulator.manipulateAsync( 46 | cropResult.uri, 47 | [{ crop: { ...croppingBounds, originX: 0, originY: 0 } }] 48 | ); 49 | const { uri, width, height } = webCorrection; 50 | setImageData({ uri, width, height }); 51 | } else { 52 | const { uri, width, height } = cropResult; 53 | setImageData({ uri, width, height }); 54 | } 55 | setProcessing(false); 56 | setEditingMode("operation-select"); 57 | } catch (error) { 58 | // If there's an error dismiss the the editor and alert the user 59 | setProcessing(false); 60 | Alert.alert("An error occurred while editing."); 61 | } 62 | }; 63 | return onPerformCrop; 64 | }; 65 | -------------------------------------------------------------------------------- /src/ControlBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, StyleSheet } from "react-native"; 3 | import _ from "lodash"; 4 | import { useRecoilState } from "recoil"; 5 | import { editingModeState, imageDataState, processingState } from "./Store"; 6 | import { IconButton } from "./components/IconButton"; 7 | import { useContext } from "react"; 8 | import { EditorContext } from "./index"; 9 | import { useEffect } from "react"; 10 | import { usePerformCrop } from "./customHooks/usePerformCrop"; 11 | 12 | function ControlBar() { 13 | // 14 | const [editingMode, setEditingMode] = useRecoilState(editingModeState); 15 | const [imageData] = useRecoilState(imageDataState); 16 | const [processing, setProcessing] = useRecoilState(processingState); 17 | const { mode, onCloseEditor, onEditingComplete } = useContext(EditorContext); 18 | 19 | const performCrop = usePerformCrop(); 20 | 21 | const shouldDisableDoneButton = 22 | editingMode !== "operation-select" && mode !== "crop-only"; 23 | 24 | const onFinishEditing = async () => { 25 | if (mode === "full") { 26 | setProcessing(false); 27 | onEditingComplete(imageData); 28 | onCloseEditor(); 29 | } else if (mode === "crop-only") { 30 | await performCrop(); 31 | } 32 | }; 33 | 34 | const onPressBack = () => { 35 | if (mode === "full") { 36 | if (editingMode === "operation-select") { 37 | onCloseEditor(); 38 | } else { 39 | setEditingMode("operation-select"); 40 | } 41 | } else if (mode === "crop-only") { 42 | onCloseEditor(); 43 | } 44 | }; 45 | 46 | // Complete the editing process if we are in crop only mode after the editingMode gets set 47 | // back to operation select (happens internally in usePerformCrop) - can't do it in onFinishEditing 48 | // else it gets stale state - may need to refactor the hook as this feels hacky 49 | useEffect(() => { 50 | if ( 51 | mode === "crop-only" && 52 | imageData.uri && 53 | editingMode === "operation-select" 54 | ) { 55 | onEditingComplete(imageData); 56 | onCloseEditor(); 57 | } 58 | }, [imageData, editingMode]); 59 | 60 | return ( 61 | 62 | 63 | 69 | 70 | ); 71 | } 72 | 73 | export { ControlBar }; 74 | 75 | const styles = StyleSheet.create({ 76 | container: { 77 | width: "100%", 78 | height: 80, 79 | backgroundColor: "#333", 80 | flexDirection: "row", 81 | justifyContent: "space-between", 82 | alignItems: "center", 83 | paddingHorizontal: 4, 84 | }, 85 | }); 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-image-editor", 3 | "version": "1.7.1", 4 | "description": "", 5 | "main": "lib/commonjs/index.js", 6 | "types": "lib/typescript/src/index.d.ts", 7 | "react-native": "src/index", 8 | "source": "src/index", 9 | "module": "lib/module/index.js", 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1", 12 | "sv:patch": "standard-version --release-as patch", 13 | "sv:minor": "standard-version --release-as minor", 14 | "sv:major": "standard-version --release-as major", 15 | "prepare": "bob build", 16 | "release": "release-it", 17 | "example": "yarn --cwd example", 18 | "bootstrap": "yarn example && yarn && yarn pods" 19 | }, 20 | "keywords": [ 21 | "react", 22 | "react-native", 23 | "image", 24 | "image editor", 25 | "image cropping", 26 | "blur", 27 | "expo" 28 | ], 29 | "author": "Thomas Coldwell", 30 | "license": "MIT", 31 | "homepage": "https://github.com/thomas-coldwell/expo-image-editor", 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/thomas-coldwell/expo-image-editor" 35 | }, 36 | "dependencies": { 37 | "@miblanchard/react-native-slider": "^1.5.0", 38 | "@react-hook/throttle": "^2.2.0", 39 | "modal-enhanced-react-native-web": "^0.2.0", 40 | "no-scroll": "^2.1.1" 41 | }, 42 | "peerDependencies": { 43 | "@expo/vector-icons": "*", 44 | "expo-asset": "*", 45 | "expo-file-system": "*", 46 | "expo-gl": "*", 47 | "expo-gl-cpp": "*", 48 | "expo-image-manipulator": "*", 49 | "react": "*", 50 | "react-native": "*" 51 | }, 52 | "devDependencies": { 53 | "@babel/core": "^7.8.6", 54 | "@expo/vector-icons": "^12.0.5", 55 | "@expo/webpack-config": "^0.12.11", 56 | "@release-it/conventional-changelog": "^3.3.0", 57 | "@types/lodash": "^4.14.157", 58 | "@types/react": "~16.9.35", 59 | "@types/react-dom": "^16.9.8", 60 | "@types/react-native": "~0.63.2", 61 | "babel-preset-expo": "~8.1.0", 62 | "expo-asset": "^8.1.7", 63 | "expo-file-system": "^9.2.0", 64 | "expo-gl": "^9.1.1", 65 | "expo-gl-cpp": "^9.1.2", 66 | "expo-image-manipulator": "^9.2.2", 67 | "react": "^16.13.1", 68 | "react-dom": "^16.13.1", 69 | "react-native": "https://github.com/expo/react-native/archive/sdk-42.0.0.tar.gz", 70 | "react-native-builder-bob": "^0.18.2", 71 | "react-native-gesture-handler": "^1.10.3", 72 | "react-native-safe-area-context": "^3.2.0", 73 | "react-native-web": "~0.13.12", 74 | "recoil": "^0.0.10", 75 | "release-it": "^14.11.6", 76 | "standard-version": "^9.2.0", 77 | "typescript": "^3.9.3" 78 | }, 79 | "react-native-builder-bob": { 80 | "source": "src", 81 | "output": "lib", 82 | "targets": [ 83 | "commonjs", 84 | "module", 85 | "typescript" 86 | ] 87 | }, 88 | "files": [ 89 | "src", 90 | "lib", 91 | "!**/__tests__", 92 | "!**/__fixtures__", 93 | "!**/__mocks__" 94 | ], 95 | "eslintIgnore": [ 96 | "node_modules/", 97 | "lib/" 98 | ], 99 | "jest": { 100 | "preset": "react-native", 101 | "modulePathIgnorePatterns": [ 102 | "/example/node_modules", 103 | "/lib/" 104 | ] 105 | }, 106 | "release-it": { 107 | "git": { 108 | "commitMessage": "chore: release ${version}", 109 | "tagName": "v${version}" 110 | }, 111 | "npm": { 112 | "publish": true 113 | }, 114 | "github": { 115 | "release": true 116 | }, 117 | "plugins": { 118 | "@release-it/conventional-changelog": { 119 | "preset": "angular" 120 | } 121 | } 122 | }, 123 | "publishConfig": { 124 | "access": "public", 125 | "registry": "https://registry.npmjs.org" 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/OperationBar/Rotate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { StyleSheet, View, Text, Platform, Alert } from "react-native"; 3 | import { useRecoilState } from "recoil"; 4 | import { IconButton } from "../components/IconButton"; 5 | import { editingModeState, imageDataState, processingState } from "../Store"; 6 | import * as ImageManipulator from "expo-image-manipulator"; 7 | 8 | export function Rotate() { 9 | // 10 | const [, setProcessing] = useRecoilState(processingState); 11 | const [imageData, setImageData] = useRecoilState(imageDataState); 12 | const [, setEditingMode] = useRecoilState(editingModeState); 13 | 14 | const [originalImageData] = React.useState(imageData); 15 | 16 | const [rotation, setRotation] = React.useState(0); 17 | 18 | React.useEffect(() => { 19 | if (rotation !== 0) { 20 | onRotate(rotation); 21 | } else { 22 | setImageData(originalImageData); 23 | } 24 | }, [rotation]); 25 | 26 | const onRotate = async (angle: number) => { 27 | setProcessing(true); 28 | // Rotate the image by the specified angle 29 | // To get rid of thing white line caused by context its being painted onto 30 | // crop 1 px border off https://github.com/expo/expo/issues/7325 31 | const { 32 | uri: rotateUri, 33 | width: rotateWidth, 34 | height: rotateHeight, 35 | } = await ImageManipulator.manipulateAsync(originalImageData.uri, [ 36 | { rotate: angle }, 37 | ]); 38 | const { uri, width, height } = await ImageManipulator.manipulateAsync( 39 | rotateUri, 40 | [ 41 | { 42 | crop: { 43 | originX: 1, 44 | originY: 1, 45 | width: rotateWidth - 2, 46 | height: rotateHeight - 2, 47 | }, 48 | }, 49 | ] 50 | ); 51 | setImageData({ uri, width, height }); 52 | setProcessing(false); 53 | }; 54 | 55 | const onClose = () => { 56 | // If closing reset the image back to its original 57 | setImageData(originalImageData); 58 | setEditingMode("operation-select"); 59 | }; 60 | 61 | const rotate = (direction: "cw" | "ccw") => { 62 | const webDirection = Platform.OS === "web" ? 1 : -1; 63 | let rotateBy = rotation - 90 * webDirection * (direction === "cw" ? 1 : -1); 64 | // keep it in the -180 to 180 range 65 | if (rotateBy > 180) { 66 | rotateBy = -90; 67 | } else if (rotateBy < -180) { 68 | rotateBy = 90; 69 | } 70 | setRotation(rotateBy); 71 | }; 72 | 73 | return ( 74 | 75 | 76 | rotate("ccw")} 80 | /> 81 | rotate("cw")} 85 | /> 86 | 87 | 88 | onClose()} /> 89 | Rotate 90 | setEditingMode("operation-select")} 94 | /> 95 | 96 | 97 | ); 98 | } 99 | 100 | const styles = StyleSheet.create({ 101 | container: { 102 | flex: 1, 103 | flexDirection: "column", 104 | justifyContent: "space-between", 105 | alignItems: "center", 106 | }, 107 | prompt: { 108 | color: "#fff", 109 | fontSize: 21, 110 | textAlign: "center", 111 | }, 112 | row: { 113 | width: "100%", 114 | height: 80, 115 | flexDirection: "row", 116 | justifyContent: "space-between", 117 | alignItems: "center", 118 | paddingHorizontal: "2%", 119 | }, 120 | }); 121 | -------------------------------------------------------------------------------- /example/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { StyleSheet, View, Button, Image, Alert } from "react-native"; 3 | import * as ImagePicker from "expo-image-picker"; 4 | import { ImageEditor } from "expo-image-editor"; 5 | import "@expo/match-media"; 6 | import { useMediaQuery } from "react-responsive"; 7 | import { Platform } from "react-native"; 8 | 9 | export default function App() { 10 | // 11 | const isLandscape = useMediaQuery({ orientation: "landscape" }); 12 | 13 | const [imageUri, setImageUri] = React.useState(undefined); 14 | const [editorVisible, setEditorVisible] = React.useState(false); 15 | 16 | const [croppedUri, setCroppedUri] = React.useState( 17 | undefined 18 | ); 19 | 20 | const [aspectLock, setAspectLock] = React.useState(false); 21 | 22 | const selectPhoto = async () => { 23 | // Get the permission to access the camera roll 24 | const response = await ImagePicker.requestMediaLibraryPermissionsAsync(); 25 | // If they said yes then launch the image picker 26 | if (response.granted) { 27 | const pickerResult = await ImagePicker.launchImageLibraryAsync(); 28 | // Check they didn't cancel the picking 29 | if (!pickerResult.cancelled) { 30 | launchEditor(pickerResult.uri); 31 | } 32 | } else { 33 | // If not then alert the user they need to enable it 34 | Alert.alert( 35 | "Please enable camera roll permissions for this app in your settings." 36 | ); 37 | } 38 | }; 39 | 40 | const launchEditor = (uri: any) => { 41 | // Then set the image uri 42 | setImageUri(uri); 43 | // And set the image editor to be visible 44 | setEditorVisible(true); 45 | }; 46 | 47 | return ( 48 | 49 | 55 | 56 | 60 | 61 | 62 |