├── 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 |
68 |
69 | setEditorVisible(false)}
72 | imageUri={imageUri}
73 | fixedCropAspectRatio={1.6}
74 | lockAspectRatio={aspectLock}
75 | minimumCropDimensions={{
76 | width: 100,
77 | height: 100,
78 | }}
79 | onEditingComplete={(result) => {
80 | setCroppedUri(result.uri);
81 | console.log(result);
82 | }}
83 | throttleBlur={false}
84 | mode="crop-only"
85 | // allowedTransformOperations={["crop"]}
86 | // allowedAdjustmentOperations={["blur"]}
87 | />
88 |
89 | );
90 | }
91 |
92 | const styles = StyleSheet.create({
93 | container: {
94 | flex: 1,
95 | justifyContent: "space-around",
96 | alignItems: "center",
97 | },
98 | imageRow: {
99 | justifyContent: "center",
100 | alignItems: "center",
101 | ...(Platform.OS === "web" ? { width: "100%" } : { flexShrink: 1 }),
102 | },
103 | image: {
104 | resizeMode: "contain",
105 | backgroundColor: "#ccc",
106 | margin: "3%",
107 | ...(Platform.OS === "web"
108 | ? { width: 300, height: 300 }
109 | : { flex: 1, aspectRatio: 1 }),
110 | },
111 | buttonRow: {
112 | width: "100%",
113 | minHeight: 100,
114 | flexDirection: "column",
115 | justifyContent: "space-around",
116 | alignItems: "center",
117 | marginVertical: 40,
118 | },
119 | });
120 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4 |
5 | ### [1.7.1](https://github.com/thomas-coldwell/expo-image-editor/compare/v1.7.0...v1.7.1) (2021-10-18)
6 |
7 |
8 | ### Features
9 |
10 | * Add release-it ([14b8398](https://github.com/thomas-coldwell/expo-image-editor/commit/14b839857c1af155939b1f4719afa943119ad47f))
11 |
12 |
13 | ### Bug Fixes
14 |
15 | * Add yarnrc to override `yarn` cmd ([b353ac8](https://github.com/thomas-coldwell/expo-image-editor/commit/b353ac8fab8ddd37f76d914ccba72d44eeed5fc8))
16 | * Dependency setup ([07fc54b](https://github.com/thomas-coldwell/expo-image-editor/commit/07fc54b150f34b07a1d7699ab0ca5513087ecf70))
17 | * iconID prop type ([062643b](https://github.com/thomas-coldwell/expo-image-editor/commit/062643bb72fbabed555d3c3933f5681a03a50296))
18 | * Remove lib from checked in files ([576e4a0](https://github.com/thomas-coldwell/expo-image-editor/commit/576e4a097577a28535b9acaa3fd13e5b7cac66a4))
19 | * Remove workflow ([7e9f067](https://github.com/thomas-coldwell/expo-image-editor/commit/7e9f0675f6316f47b34f1dd1528b897c7520858c))
20 | * ts ([aee4335](https://github.com/thomas-coldwell/expo-image-editor/commit/aee4335c2e100ca3e9b6f1a05c45e45422c6fbf6))
21 |
22 | ### [1.6.3](https://github.com/thomas-coldwell/expo-image-editor/compare/v1.6.2...v1.6.3) (2021-08-05)
23 |
24 |
25 | ### Bug Fixes
26 |
27 | * Update readme ([dd5a8b3](https://github.com/thomas-coldwell/expo-image-editor/commit/dd5a8b3388c1dfbaf7b904f42e8701a871ecd997))
28 |
29 | ### [1.6.2](https://github.com/thomas-coldwell/expo-image-editor/compare/v1.6.0...v1.6.2) (2021-07-28)
30 |
31 |
32 | ### Bug Fixes
33 |
34 | * Changed ImageEditorView for `asView` prop ([d9a6fdd](https://github.com/thomas-coldwell/expo-image-editor/commit/d9a6fdd600f1cadc98baf4f29d2a56a84d8f15ed))
35 |
36 | ### [1.6.1](https://github.com/thomas-coldwell/expo-image-editor/compare/v1.6.0...v1.6.1) (2021-07-28)
37 |
38 |
39 | ### Bug Fixes
40 |
41 | * Changed ImageEditorView for `asView` prop ([d9a6fdd](https://github.com/thomas-coldwell/expo-image-editor/commit/d9a6fdd600f1cadc98baf4f29d2a56a84d8f15ed))
42 |
43 | ## [1.6.0](https://github.com/thomas-coldwell/expo-image-editor/compare/v1.5.1...v1.6.0) (2021-07-28)
44 |
45 |
46 | ### Features
47 |
48 | * Updated to export the core image editor view ([47c159f](https://github.com/thomas-coldwell/expo-image-editor/commit/47c159fa973da0e4fddbe470b3c911ccd3d59fb8))
49 | * Upgraded to expo SDK 42 ([9ad9c9b](https://github.com/thomas-coldwell/expo-image-editor/commit/9ad9c9b6c0112134cc253b85752e4b9c77134820))
50 |
51 |
52 | ### Bug Fixes
53 |
54 | * Updated to use latest permission methods for sdk42 ([2fb775d](https://github.com/thomas-coldwell/expo-image-editor/commit/2fb775dc0ae241c104c8387d6f156ca2ccf482b1))
55 |
56 | ### [1.5.1](https://github.com/thomas-coldwell/expo-image-editor/compare/v1.5.0...v1.5.1) (2021-05-06)
57 |
58 |
59 | ### Features
60 |
61 | * Created NPM publish action ([1dc0feb](https://github.com/thomas-coldwell/expo-image-editor/commit/1dc0febdd4a66d09eef17b2c605a8756d4e3db96))
62 |
63 |
64 | ### Bug Fixes
65 |
66 | * Updated NPM publish workflow ([4e97d7b](https://github.com/thomas-coldwell/expo-image-editor/commit/4e97d7b426f7232776798ea2259179396f813eae))
67 |
68 | ## [1.5.0](https://github.com/thomas-coldwell/expo-image-editor/compare/v1.4.4...v1.5.0) (2021-05-06)
69 |
70 |
71 | ### Features
72 |
73 | * Ability to specify `allowedTransformOperations` and `allowedAdjustmentOperations` to control what operations the use has access to in full mode ([9a7224d](https://github.com/thomas-coldwell/expo-image-editor/commit/9a7224d4df3b67fb485532f2cef6212f933e5ad5))
74 | * Crop only mode with custom hook to make cropping functionaility reusable ([798e9b0](https://github.com/thomas-coldwell/expo-image-editor/commit/798e9b02d9ccfce4af216233a5a0db6d26529b10))
75 | * Improved layout of the example ([6d52dee](https://github.com/thomas-coldwell/expo-image-editor/commit/6d52dee99eb4de2e5c922e3aed2b76bd5501b703))
76 | * Updated RNGH deps ([e2daf03](https://github.com/thomas-coldwell/expo-image-editor/commit/e2daf031975bacac51dd7bef4f640e90557123db))
77 |
78 |
79 | ### Bug Fixes
80 |
81 | * Added Standard Version ([cbd2bb7](https://github.com/thomas-coldwell/expo-image-editor/commit/cbd2bb7d0bfdc797946f0230545110de3f078afd))
82 | * Image crop overlay window not regsitering touches on Android in modal ([6aeb120](https://github.com/thomas-coldwell/expo-image-editor/commit/6aeb1201923ad00116e3672f8834d6042a4d66be))
83 | * Missing types and typescript errors ([5fbdad6](https://github.com/thomas-coldwell/expo-image-editor/commit/5fbdad6948460e2ddfa988ca73cfa93d77d8d1bf))
84 |
85 | ### 1.4.4 (2021-04-23)
86 |
--------------------------------------------------------------------------------
/src/EditingWindow.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Image, PixelRatio, StyleSheet, View } from "react-native";
3 | import { ImageCropOverlay } from "./ImageCropOverlay";
4 | import { useRecoilState } from "recoil";
5 | import {
6 | imageDataState,
7 | imageBoundsState,
8 | imageScaleFactorState,
9 | editingModeState,
10 | glContextState,
11 | } from "./Store";
12 | import { ExpoWebGLRenderingContext, GLView } from "expo-gl";
13 |
14 | type ImageLayout = {
15 | height: number;
16 | width: number;
17 | } | null;
18 |
19 | function EditingWindow() {
20 | //
21 | const [imageLayout, setImageLayout] = React.useState(null);
22 |
23 | const [imageData] = useRecoilState(imageDataState);
24 | const [, setImageBounds] = useRecoilState(imageBoundsState);
25 | const [, setImageScaleFactor] = useRecoilState(imageScaleFactorState);
26 | const [editingMode] = useRecoilState(editingModeState);
27 | const [, setGLContext] = useRecoilState(glContextState);
28 |
29 | // Get some readable boolean states
30 | const isCropping = editingMode === "crop";
31 | const isBlurring = editingMode === "blur";
32 | const usesGL = isBlurring;
33 |
34 | const getImageFrame = (layout: {
35 | width: number;
36 | height: number;
37 | [key: string]: any;
38 | }) => {
39 | onUpdateCropLayout(layout);
40 | };
41 |
42 | const onUpdateCropLayout = (layout: ImageLayout) => {
43 | // Check layout is not null
44 | if (layout) {
45 | // Find the start point of the photo on the screen and its
46 | // width / height from there
47 | const editingWindowAspectRatio = layout.height / layout.width;
48 | //
49 | const imageAspectRatio = imageData.height / imageData.width;
50 | let bounds = { x: 0, y: 0, width: 0, height: 0 };
51 | let imageScaleFactor = 1;
52 | // Check which is larger
53 | if (imageAspectRatio > editingWindowAspectRatio) {
54 | // Then x is non-zero, y is zero; calculate x...
55 | bounds.x =
56 | (((imageAspectRatio - editingWindowAspectRatio) / imageAspectRatio) *
57 | layout.width) /
58 | 2;
59 | bounds.width = layout.height / imageAspectRatio;
60 | bounds.height = layout.height;
61 | imageScaleFactor = imageData.height / layout.height;
62 | } else {
63 | // Then y is non-zero, x is zero; calculate y...
64 | bounds.y =
65 | (((1 / imageAspectRatio - 1 / editingWindowAspectRatio) /
66 | (1 / imageAspectRatio)) *
67 | layout.height) /
68 | 2;
69 | bounds.width = layout.width;
70 | bounds.height = layout.width * imageAspectRatio;
71 | imageScaleFactor = imageData.width / layout.width;
72 | }
73 | setImageBounds(bounds);
74 | setImageScaleFactor(imageScaleFactor);
75 | setImageLayout({
76 | height: layout.height,
77 | width: layout.width,
78 | });
79 | }
80 | };
81 |
82 | const getGLLayout = () => {
83 | if (imageLayout) {
84 | const { height: windowHeight, width: windowWidth } = imageLayout;
85 | const windowAspectRatio = windowWidth / windowHeight;
86 | const { height: imageHeight, width: imageWidth } = imageData;
87 | const imageAspectRatio = imageWidth / imageHeight;
88 | // If the window is taller than img...
89 | if (windowAspectRatio < imageAspectRatio) {
90 | return { width: windowWidth, height: windowWidth / imageAspectRatio };
91 | } else {
92 | return { height: windowHeight, width: windowHeight * imageAspectRatio };
93 | }
94 | }
95 | };
96 |
97 | React.useEffect(() => {
98 | onUpdateCropLayout(imageLayout);
99 | }, [imageData]);
100 |
101 | const onGLContextCreate = async (gl: ExpoWebGLRenderingContext) => {
102 | setGLContext(gl);
103 | };
104 |
105 | return (
106 |
107 | {usesGL ? (
108 |
109 |
121 |
122 | ) : (
123 | getImageFrame(nativeEvent.layout)}
127 | />
128 | )}
129 | {isCropping && imageLayout != null ? : null}
130 |
131 | );
132 | }
133 |
134 | export { EditingWindow };
135 |
136 | const styles = StyleSheet.create({
137 | container: {
138 | flex: 1,
139 | },
140 | image: {
141 | flex: 1,
142 | resizeMode: "contain",
143 | },
144 | glContainer: {
145 | flex: 1,
146 | justifyContent: "center",
147 | alignItems: "center",
148 | },
149 | });
150 |
--------------------------------------------------------------------------------
/src/OperationBar/OperationSelection.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | Platform,
4 | StyleSheet,
5 | View,
6 | TouchableOpacity,
7 | ScrollView,
8 | } from "react-native";
9 | import { Icon } from "../components/Icon";
10 | import { IconButton } from "../components/IconButton";
11 | import { editingModeState, EditingModes } from "../Store";
12 | import { useRecoilState } from "recoil";
13 | import { useContext } from "react";
14 | import {
15 | AdjustmentOperations,
16 | EditingOperations,
17 | EditorContext,
18 | TransformOperations,
19 | } from "..";
20 | import { useMemo } from "react";
21 |
22 | interface Operation {
23 | title: string;
24 | iconID: React.ComponentProps["iconID"];
25 | operationID: T;
26 | }
27 |
28 | interface Operations {
29 | transform: Operation[];
30 | adjust: Operation[];
31 | }
32 |
33 | const operations: Operations = {
34 | transform: [
35 | {
36 | title: "Crop",
37 | iconID: "crop",
38 | operationID: "crop",
39 | },
40 | {
41 | title: "Rotate",
42 | iconID: "rotate-90-degrees-ccw",
43 | operationID: "rotate",
44 | },
45 | ],
46 | adjust: [
47 | {
48 | title: "Blur",
49 | iconID: "blur-on",
50 | operationID: "blur",
51 | },
52 | ],
53 | };
54 |
55 | export function OperationSelection() {
56 | //
57 | const { allowedTransformOperations, allowedAdjustmentOperations } =
58 | useContext(EditorContext);
59 |
60 | const isTransformOnly =
61 | allowedTransformOperations && !allowedAdjustmentOperations;
62 | const isAdjustmentOnly =
63 | allowedAdjustmentOperations && !allowedTransformOperations;
64 |
65 | const [selectedOperationGroup, setSelectedOperationGroup] = React.useState<
66 | "transform" | "adjust"
67 | >(isAdjustmentOnly ? "adjust" : "transform");
68 |
69 | const [, setEditingMode] = useRecoilState(editingModeState);
70 |
71 | const filteredOperations = useMemo(() => {
72 | // If neither are specified then allow the full range of operations
73 | if (!allowedTransformOperations && !allowedAdjustmentOperations) {
74 | return operations;
75 | }
76 | const filteredTransforms = allowedTransformOperations
77 | ? operations.transform.filter((op) =>
78 | allowedTransformOperations.includes(op.operationID)
79 | )
80 | : operations.transform;
81 | const filteredAdjustments = allowedAdjustmentOperations
82 | ? operations.adjust.filter((op) =>
83 | allowedAdjustmentOperations.includes(op.operationID)
84 | )
85 | : operations.adjust;
86 | if (isTransformOnly) {
87 | return { transform: filteredTransforms, adjust: [] };
88 | }
89 | if (isAdjustmentOnly) {
90 | return { adjust: filteredAdjustments, transform: [] };
91 | }
92 | return { transform: filteredTransforms, adjust: filteredAdjustments };
93 | }, [
94 | allowedTransformOperations,
95 | allowedAdjustmentOperations,
96 | isTransformOnly,
97 | isAdjustmentOnly,
98 | ]);
99 |
100 | return (
101 | <>
102 |
103 | {
104 | //@ts-ignore
105 | filteredOperations[selectedOperationGroup].map(
106 | (item: Operation, index: number) => (
107 |
108 | setEditingMode(item.operationID)}
112 | />
113 |
114 | )
115 | )
116 | }
117 |
118 | {!isTransformOnly && !isAdjustmentOnly ? (
119 |
120 | setSelectedOperationGroup("transform")}
128 | >
129 |
130 |
131 | setSelectedOperationGroup("adjust")}
139 | >
140 |
141 |
142 |
143 | ) : null}
144 | >
145 | );
146 | }
147 |
148 | const styles = StyleSheet.create({
149 | opRow: {
150 | height: 80,
151 | width: "100%",
152 | backgroundColor: "#333",
153 | },
154 | opContainer: {
155 | height: "100%",
156 | justifyContent: "center",
157 | alignItems: "center",
158 | marginLeft: 16,
159 | },
160 | modeRow: {
161 | height: 80,
162 | width: "100%",
163 | flexDirection: "row",
164 | alignItems: "center",
165 | justifyContent: "space-around",
166 | },
167 | modeButton: {
168 | height: 80,
169 | flex: 1,
170 | justifyContent: "center",
171 | alignItems: "center",
172 | backgroundColor: "#222",
173 | },
174 | });
175 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { StyleSheet, View, StatusBar, Platform } from "react-native";
3 | import { ControlBar } from "./ControlBar";
4 | import { EditingWindow } from "./EditingWindow";
5 | import * as ImageManipulator from "expo-image-manipulator";
6 | import { Processing } from "./Processing";
7 | import { useRecoilState, RecoilRoot } from "recoil";
8 | import {
9 | processingState,
10 | imageDataState,
11 | editingModeState,
12 | readyState,
13 | ImageDimensions,
14 | } from "./Store";
15 | import { OperationBar } from "./OperationBar/OperationBar";
16 | import { UniversalModal } from "./UniversalModal";
17 | const noScroll = require("no-scroll");
18 |
19 | type EditorContextType = {
20 | throttleBlur: boolean;
21 | minimumCropDimensions: ImageDimensions;
22 | fixedAspectRatio: number;
23 | lockAspectRatio: boolean;
24 | mode: Mode;
25 | onCloseEditor: () => void;
26 | onEditingComplete: (result: any) => void;
27 | allowedTransformOperations?: TransformOperations[];
28 | allowedAdjustmentOperations?: AdjustmentOperations[];
29 | };
30 |
31 | export const EditorContext = React.createContext({
32 | throttleBlur: true,
33 | minimumCropDimensions: {
34 | width: 0,
35 | height: 0,
36 | },
37 | fixedAspectRatio: 1.6,
38 | lockAspectRatio: false,
39 | mode: "full",
40 | onCloseEditor: () => {},
41 | onEditingComplete: () => {},
42 | });
43 |
44 | export type Mode = "full" | "crop-only";
45 |
46 | export type TransformOperations = "crop" | "rotate";
47 | export type AdjustmentOperations = "blur";
48 | export type EditingOperations = TransformOperations | AdjustmentOperations;
49 |
50 | export interface ImageEditorProps {
51 | visible: boolean;
52 | onCloseEditor: () => void;
53 | imageUri: string | undefined;
54 | fixedCropAspectRatio?: number;
55 | minimumCropDimensions?: {
56 | width: number;
57 | height: number;
58 | };
59 | onEditingComplete: (result: any) => void;
60 | lockAspectRatio?: boolean;
61 | throttleBlur?: boolean;
62 | mode?: Mode;
63 | allowedTransformOperations?: TransformOperations[];
64 | allowedAdjustmentOperations?: AdjustmentOperations[];
65 | asView?: boolean;
66 | }
67 |
68 | function ImageEditorCore(props: ImageEditorProps) {
69 | //
70 | const {
71 | mode = "full",
72 | throttleBlur = true,
73 | minimumCropDimensions = { width: 0, height: 0 },
74 | fixedCropAspectRatio: fixedAspectRatio = 1.6,
75 | lockAspectRatio = false,
76 | allowedTransformOperations,
77 | allowedAdjustmentOperations,
78 | } = props;
79 |
80 | const [, setImageData] = useRecoilState(imageDataState);
81 | const [, setReady] = useRecoilState(readyState);
82 | const [, setEditingMode] = useRecoilState(editingModeState);
83 |
84 | // Initialise the image data when it is set through the props
85 | React.useEffect(() => {
86 | const initialise = async () => {
87 | if (props.imageUri) {
88 | const enableEditor = () => {
89 | setReady(true);
90 | // Set no-scroll to on
91 | noScroll.on();
92 | };
93 | // Platform check
94 | if (Platform.OS === "web") {
95 | let img = document.createElement("img");
96 | img.onload = () => {
97 | setImageData({
98 | uri: props.imageUri ?? "",
99 | height: img.height,
100 | width: img.width,
101 | });
102 | enableEditor();
103 | };
104 | img.src = props.imageUri;
105 | } else {
106 | const { width: pickerWidth, height: pickerHeight } =
107 | await ImageManipulator.manipulateAsync(props.imageUri, []);
108 | setImageData({
109 | uri: props.imageUri,
110 | width: pickerWidth,
111 | height: pickerHeight,
112 | });
113 | enableEditor();
114 | }
115 | }
116 | };
117 | initialise();
118 | }, [props.imageUri]);
119 |
120 | const onCloseEditor = () => {
121 | // Set no-scroll to off
122 | noScroll.off();
123 | props.onCloseEditor();
124 | };
125 |
126 | React.useEffect(() => {
127 | // Reset the state of things and only render the UI
128 | // when this state has been initialised
129 | if (!props.visible) {
130 | setReady(false);
131 | }
132 | // Check if ther mode is set to crop only if this is the case then set the editingMode
133 | // to crop
134 | if (mode === "crop-only") {
135 | setEditingMode("crop");
136 | }
137 | }, [props.visible]);
138 |
139 | return (
140 |
153 |
154 | {props.asView ? (
155 |
156 | ) : (
157 |
162 |
163 |
164 | )}
165 |
166 | );
167 | }
168 |
169 | export function ImageEditorView(props: ImageEditorProps) {
170 | //
171 | const { mode = "full" } = props;
172 |
173 | const [ready, setReady] = useRecoilState(readyState);
174 | const [processing, setProcessing] = useRecoilState(processingState);
175 |
176 | return (
177 | <>
178 | {ready ? (
179 |
180 |
181 |
182 | {mode === "full" && }
183 |
184 | ) : null}
185 | {processing ? : null}
186 | >
187 | );
188 | }
189 |
190 | export function ImageEditor(props: ImageEditorProps) {
191 | //
192 |
193 | return (
194 |
195 |
196 |
197 | );
198 | }
199 |
200 | const styles = StyleSheet.create({
201 | container: {
202 | flex: 1,
203 | backgroundColor: "#222",
204 | },
205 | });
206 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🌁 Expo Image Editor
2 |
3 | A super simple image cropping and rotation tool for Expo that runs on iOS, Android and Web!
4 |
5 | |  |  |  |
6 | | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
7 |
8 | Check out the demo on Netlify here
9 |
10 | ## Installation
11 |
12 | To get started install the package in your Expo project by running:
13 |
14 | ```
15 | yarn add expo-image-editor
16 | ```
17 |
18 | or
19 |
20 | ```
21 | npm i expo-image-editor
22 | ```
23 |
24 | ## Usage
25 |
26 | The package exports a single component `ImageEditor` that can be placed anywhere in your project. This renders a modal that then returns the editing result when it is dismissed.
27 |
28 | ```typescript
29 | // ...
30 | import { ImageEditor } from "expo-image-editor";
31 |
32 | function App() {
33 | const [imageUri, setImageUri] = useState(undefined);
34 |
35 | const [editorVisible, setEditorVisible] = useState(false);
36 |
37 | const selectPhoto = async () => {
38 | // Get the permission to access the camera roll
39 | const response = await ImagePicker.requestCameraRollPermissionsAsync();
40 | // If they said yes then launch the image picker
41 | if (response.granted) {
42 | const pickerResult = await ImagePicker.launchImageLibraryAsync();
43 | // Check they didn't cancel the picking
44 | if (!pickerResult.cancelled) {
45 | launchEditor(pickerResult.uri);
46 | }
47 | } else {
48 | // If not then alert the user they need to enable it
49 | Alert.alert(
50 | "Please enable camera roll permissions for this app in your settings."
51 | );
52 | }
53 | };
54 |
55 | const launchEditor = (uri: string) => {
56 | // Then set the image uri
57 | setImageUri(uri);
58 | // And set the image editor to be visible
59 | setEditorVisible(true);
60 | };
61 |
62 | return (
63 |
64 |
68 | selectPhoto()} />
69 | setEditorVisible(false)}
72 | imageUri={imageUri}
73 | fixedCropAspectRatio={16 / 9}
74 | lockAspectRatio={aspectLock}
75 | minimumCropDimensions={{
76 | width: 100,
77 | height: 100,
78 | }}
79 | onEditingComplete={(result) => {
80 | setImageData(result);
81 | }}
82 | mode="full"
83 | />
84 |
85 | );
86 | }
87 | ```
88 |
89 | ### Props
90 |
91 | | Name | Type | Description |
92 | | --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93 | | visible | boolean | Whether the editor should be visible or not. |
94 | | asView | boolean | If `true` this will remove the modal wrapper and return the image editor in a regular `` |
95 | | mode | string | Which mode to use the editor in can be either `full` or `crop-only`. |
96 | | onCloseEditor | function | Callback when the editor is dimissed - use this to set hide the editor. |
97 | | imageUri | string | The uri of the image to be edited |
98 | | fixedCropAspectRatio | number | The starting aspect ratio of the cropping window. |
99 | | lockAspectRatio | boolean | Whether the cropping window should maintain this aspect ratio or not. |
100 | | minimumCropDimensions | object | An object of `{width, height}` specifying the minimum dimensions of the crop window. |
101 | | onEditingComplete | function | function that will return the result of the image editing which is an object identical to `imageData` |
102 | | throttleBlur | boolean | Whether to throttle the WebGL update of the blur while adjusting (defaults to false) - useful to set to true on lower performance devices |
103 | | allowedTransformOperations | string[] | Which transform operations you want to exclusively allow to be used. Can include `crop` and `rotate` e.g. `['crop']` to only allow cropping |
104 | | allowedAdjustmentOperations | string[] | Which image adjustment operations you want to exclusively allow to be used. Only `blur` can be specified at the minute e.g. `['blur']` yo only allow blur as an image adjustment |
105 |
--------------------------------------------------------------------------------
/src/ImageCropOverlay.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | Animated,
4 | StyleSheet,
5 | View,
6 | TouchableOpacity,
7 | requireNativeComponent,
8 | Platform,
9 | ViewPropTypes,
10 | } from "react-native";
11 | import _ from "lodash";
12 | import { useRecoilState } from "recoil";
13 | import { cropSizeState, imageBoundsState, accumulatedPanState } from "./Store";
14 | import {
15 | GestureHandlerRootView,
16 | PanGestureHandler,
17 | PanGestureHandlerGestureEvent,
18 | State,
19 | } from "react-native-gesture-handler";
20 | import { useContext } from "react";
21 | import { EditorContext } from "./index";
22 |
23 | const horizontalSections = ["top", "middle", "bottom"];
24 | const verticalSections = ["left", "middle", "right"];
25 |
26 | const ImageCropOverlay = () => {
27 | // Record which section of the fram window has been pressed
28 | // this determines whether it is a translation or scaling gesture
29 | const [selectedFrameSection, setSelectedFrameSection] = React.useState("");
30 |
31 | // Shared state and bits passed through recoil to avoid prop drilling
32 | const [cropSize, setCropSize] = useRecoilState(cropSizeState);
33 | const [imageBounds] = useRecoilState(imageBoundsState);
34 | const [accumulatedPan, setAccumluatedPan] =
35 | useRecoilState(accumulatedPanState);
36 | // Editor context
37 | const { fixedAspectRatio, lockAspectRatio, minimumCropDimensions } =
38 | useContext(EditorContext);
39 |
40 | const [animatedCropSize] = React.useState({
41 | width: new Animated.Value(cropSize.width),
42 | height: new Animated.Value(cropSize.height),
43 | });
44 |
45 | // pan X and Y values to track the current delta of the pan
46 | // in both directions - this should be zeroed out on release
47 | // and the delta added onto the accumulatedPan state
48 | const panX = React.useRef(new Animated.Value(imageBounds.x));
49 | const panY = React.useRef(new Animated.Value(imageBounds.y));
50 |
51 | React.useEffect(() => {
52 | // Move the pan to the origin and check the bounds so it clicks to
53 | // the corner of the image
54 | checkCropBounds({
55 | translationX: 0,
56 | translationY: 0,
57 | });
58 | // When the crop size updates make sure the animated value does too!
59 | animatedCropSize.height.setValue(cropSize.height);
60 | animatedCropSize.width.setValue(cropSize.width);
61 | }, [cropSize]);
62 |
63 | React.useEffect(() => {
64 | // Update the size of the crop window based on the new image bounds
65 | let newSize = { width: 0, height: 0 };
66 | const { width, height } = imageBounds;
67 | const imageAspectRatio = width / height;
68 | // Then check if the cropping aspect ratio is smaller
69 | if (fixedAspectRatio < imageAspectRatio) {
70 | // If so calculate the size so its not greater than the image width
71 | newSize.height = height;
72 | newSize.width = height * fixedAspectRatio;
73 | } else {
74 | // else, calculate the size so its not greater than the image height
75 | newSize.width = width;
76 | newSize.height = width / fixedAspectRatio;
77 | }
78 | // Set the size of the crop overlay
79 | setCropSize(newSize);
80 | }, [imageBounds]);
81 |
82 | // Function that sets which sections allow for translation when
83 | // pressed
84 | const isMovingSection = () => {
85 | return (
86 | selectedFrameSection == "topmiddle" ||
87 | selectedFrameSection == "middleleft" ||
88 | selectedFrameSection == "middleright" ||
89 | selectedFrameSection == "middlemiddle" ||
90 | selectedFrameSection == "bottommiddle"
91 | );
92 | };
93 |
94 | // Check what resizing / translation needs to be performed based on which section was pressed
95 | const isLeft = selectedFrameSection.endsWith("left");
96 | const isTop = selectedFrameSection.startsWith("top");
97 |
98 | const onOverlayMove = ({ nativeEvent }: PanGestureHandlerGestureEvent) => {
99 | if (selectedFrameSection !== "") {
100 | // Check if the section pressed is one to translate the crop window or not
101 | if (isMovingSection()) {
102 | // If it is then use an animated event to directly pass the tranlation
103 | // to the pan refs
104 | Animated.event(
105 | [
106 | {
107 | translationX: panX.current,
108 | translationY: panY.current,
109 | },
110 | ],
111 | { useNativeDriver: false }
112 | )(nativeEvent);
113 | } else {
114 | // Else its a scaling operation
115 | const { x, y } = getTargetCropFrameBounds(nativeEvent);
116 | if (isTop) {
117 | panY.current.setValue(-y);
118 | }
119 | if (isLeft) {
120 | panX.current.setValue(-x);
121 | }
122 | // Finally update the animated width to the values the crop
123 | // window has been resized to
124 | animatedCropSize.width.setValue(cropSize.width + x);
125 | animatedCropSize.height.setValue(cropSize.height + y);
126 | }
127 | } else {
128 | // We need to set which section has been pressed
129 | const { x, y } = nativeEvent;
130 | const { width: initialWidth, height: initialHeight } = cropSize;
131 | let position = "";
132 | // Figure out where we pressed vertically
133 | if (y / initialHeight < 0.333) {
134 | position = position + "top";
135 | } else if (y / initialHeight < 0.667) {
136 | position = position + "middle";
137 | } else {
138 | position = position + "bottom";
139 | }
140 | // Figure out where we pressed horizontally
141 | if (x / initialWidth < 0.333) {
142 | position = position + "left";
143 | } else if (x / initialWidth < 0.667) {
144 | position = position + "middle";
145 | } else {
146 | position = position + "right";
147 | }
148 | setSelectedFrameSection(position);
149 | }
150 | };
151 |
152 | const getTargetCropFrameBounds = ({
153 | translationX,
154 | translationY,
155 | }: Partial) => {
156 | let x = 0;
157 | let y = 0;
158 | if (translationX && translationY) {
159 | if (translationX < translationY) {
160 | x = (isLeft ? -1 : 1) * translationX;
161 | if (lockAspectRatio) {
162 | y = x / fixedAspectRatio;
163 | } else {
164 | y = (isTop ? -1 : 1) * translationY;
165 | }
166 | } else {
167 | y = (isTop ? -1 : 1) * translationY;
168 | if (lockAspectRatio) {
169 | x = y * fixedAspectRatio;
170 | } else {
171 | x = (isLeft ? -1 : 1) * translationX;
172 | }
173 | }
174 | }
175 | return { x, y };
176 | };
177 |
178 | const onOverlayRelease = (
179 | nativeEvent: PanGestureHandlerGestureEvent["nativeEvent"]
180 | ) => {
181 | // Check if the section pressed is one to translate the crop window or not
182 | if (isMovingSection()) {
183 | // Ensure the cropping overlay has not been moved outside of the allowed bounds
184 | checkCropBounds(nativeEvent);
185 | } else {
186 | // Else its a scaling op - check that the resizing didnt take it out of bounds
187 | checkResizeBounds(nativeEvent);
188 | }
189 | // Disable the pan responder so the section tiles can register being pressed again
190 | setSelectedFrameSection("");
191 | };
192 |
193 | const onHandlerStateChange = ({
194 | nativeEvent,
195 | }: PanGestureHandlerGestureEvent) => {
196 | // Handle any state changes from the pan gesture handler
197 | // only looking at when the touch ends atm
198 | if (nativeEvent.state === State.END) {
199 | onOverlayRelease(nativeEvent);
200 | }
201 | };
202 |
203 | const checkCropBounds = ({
204 | translationX,
205 | translationY,
206 | }:
207 | | PanGestureHandlerGestureEvent["nativeEvent"]
208 | | { translationX: number; translationY: number }) => {
209 | // Check if the pan in the x direction exceeds the bounds
210 | let accDx = accumulatedPan.x + translationX;
211 | // Is the new x pos less than zero?
212 | if (accDx <= imageBounds.x) {
213 | // Then set it to be zero and set the pan to zero too
214 | accDx = imageBounds.x;
215 | }
216 | // Is the new x pos plus crop width going to exceed the right hand bound
217 | else if (accDx + cropSize.width > imageBounds.width + imageBounds.x) {
218 | // Then set the x pos so the crop frame touches the right hand edge
219 | let limitedXPos = imageBounds.x + imageBounds.width - cropSize.width;
220 | accDx = limitedXPos;
221 | } else {
222 | // It's somewhere in between - no formatting required
223 | }
224 | // Check if the pan in the y direction exceeds the bounds
225 | let accDy = accumulatedPan.y + translationY;
226 | // Is the new y pos less the top edge?
227 | if (accDy <= imageBounds.y) {
228 | // Then set it to be zero and set the pan to zero too
229 | accDy = imageBounds.y;
230 | }
231 | // Is the new y pos plus crop height going to exceed the bottom bound
232 | else if (accDy + cropSize.height > imageBounds.height + imageBounds.y) {
233 | // Then set the y pos so the crop frame touches the bottom edge
234 | let limitedYPos = imageBounds.y + imageBounds.height - cropSize.height;
235 | accDy = limitedYPos;
236 | } else {
237 | // It's somewhere in between - no formatting required
238 | }
239 | // Record the accumulated pan and reset the pan refs to zero
240 | panX.current.setValue(0);
241 | panY.current.setValue(0);
242 | setAccumluatedPan({ x: accDx, y: accDy });
243 | };
244 |
245 | const checkResizeBounds = ({
246 | translationX,
247 | translationY,
248 | }:
249 | | PanGestureHandlerGestureEvent["nativeEvent"]
250 | | { translationX: number; translationY: number }) => {
251 | // Check we haven't gone out of bounds when resizing - allow it to be
252 | // resized up to the appropriate bounds if so
253 | const { width: maxWidth, height: maxHeight } = imageBounds;
254 | const { width: minWidth, height: minHeight } = minimumCropDimensions;
255 | const { x, y } = getTargetCropFrameBounds({ translationX, translationY });
256 | const animatedWidth = cropSize.width + x;
257 | const animatedHeight = cropSize.height + y;
258 | let finalHeight = animatedHeight;
259 | let finalWidth = animatedWidth;
260 | // Ensure the width / height does not exceed the boundaries -
261 | // resize to the max it can be if so
262 | if (animatedHeight > maxHeight) {
263 | finalHeight = maxHeight;
264 | if (lockAspectRatio) finalWidth = finalHeight * fixedAspectRatio;
265 | } else if (animatedHeight < minHeight) {
266 | finalHeight = minHeight;
267 | if (lockAspectRatio) finalWidth = finalHeight * fixedAspectRatio;
268 | }
269 | if (animatedWidth > maxWidth) {
270 | finalWidth = maxWidth;
271 | if (lockAspectRatio) finalHeight = finalWidth / fixedAspectRatio;
272 | } else if (animatedWidth < minWidth) {
273 | finalWidth = minWidth;
274 | if (lockAspectRatio) finalHeight = finalWidth / fixedAspectRatio;
275 | }
276 | // Update the accumulated pan with the delta from the pan refs
277 | setAccumluatedPan({
278 | x: accumulatedPan.x + (isLeft ? -x : 0),
279 | y: accumulatedPan.y + (isTop ? -y : 0),
280 | });
281 | // Zero out the pan refs
282 | panX.current.setValue(0);
283 | panY.current.setValue(0);
284 | // Update the crop size to the size after resizing
285 | setCropSize({
286 | height: finalHeight,
287 | width: finalWidth,
288 | });
289 | };
290 |
291 | return (
292 |
293 | onHandlerStateChange(e)}
296 | >
297 |
309 | {
310 | // For reendering out each section of the crop overlay frame
311 | horizontalSections.map((hsection) => {
312 | return (
313 |
314 | {verticalSections.map((vsection) => {
315 | const key = hsection + vsection;
316 | return (
317 |
318 | {
319 | // Add the corner markers to the topleft,
320 | // topright, bottomleft and bottomright corners to indicate resizing
321 | key == "topleft" ||
322 | key == "topright" ||
323 | key == "bottomleft" ||
324 | key == "bottomright" ? (
325 |
336 | ) : null
337 | }
338 |
339 | );
340 | })}
341 |
342 | );
343 | })
344 | }
345 |
346 |
347 |
348 | );
349 | };
350 |
351 | export { ImageCropOverlay };
352 |
353 | const styles = StyleSheet.create({
354 | container: {
355 | height: "100%",
356 | width: "100%",
357 | position: "absolute",
358 | },
359 | overlay: {
360 | height: 40,
361 | width: 40,
362 | backgroundColor: "#33333355",
363 | borderColor: "#ffffff88",
364 | borderWidth: 1,
365 | },
366 | sectionRow: {
367 | flexDirection: "row",
368 | flex: 1,
369 | },
370 | defaultSection: {
371 | flex: 1,
372 | borderWidth: 0.5,
373 | borderColor: "#ffffff88",
374 | justifyContent: "center",
375 | alignItems: "center",
376 | },
377 | cornerMarker: {
378 | position: "absolute",
379 | borderColor: "#ffffff",
380 | height: 30,
381 | width: 30,
382 | },
383 | });
384 |
--------------------------------------------------------------------------------
/src/OperationBar/Blur.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { StyleSheet, View, Text, PixelRatio, Platform } from "react-native";
3 | import { useRecoilState } from "recoil";
4 | import { IconButton } from "../components/IconButton";
5 | import {
6 | editingModeState,
7 | glContextState,
8 | glProgramState,
9 | imageBoundsState,
10 | imageDataState,
11 | processingState,
12 | } from "../Store";
13 | import { Slider } from "@miblanchard/react-native-slider";
14 | import { Asset } from "expo-asset";
15 | import { GLView } from "expo-gl";
16 | import * as ImageManinpulator from "expo-image-manipulator";
17 | import * as FileSystem from "expo-file-system";
18 | import _, { debounce, throttle } from "lodash";
19 | import { EditorContext } from "../index";
20 |
21 | const vertShader = `
22 | precision highp float;
23 | attribute vec2 position;
24 | varying vec2 uv;
25 | void main () {
26 | uv = position;
27 | gl_Position = vec4(1.0 - 2.0 * uv, 0, 1);
28 | }`;
29 |
30 | const fragShader = `
31 | precision highp float;
32 | precision highp int;
33 | uniform sampler2D texture;
34 | uniform highp float width;
35 | uniform highp float height;
36 | varying vec2 uv;
37 | uniform highp int radius;
38 | uniform highp int pass;
39 | uniform highp float pixelFrequency;
40 | float gauss (float sigma, float x) {
41 | float g = (1.0/sqrt(2.0*3.142*sigma*sigma))*exp(-0.5*(x*x)/(sigma*sigma));
42 | return g;
43 | }
44 | void main () {
45 | float f_radius = float(radius);
46 | float sigma = (0.5 * f_radius);
47 | // Get the color of the fragment pixel
48 | vec4 color = texture2D(texture, vec2(uv.x, uv.y));
49 | color *= gauss(sigma, 0.0);
50 | // Loop over the neightbouring pixels
51 | for (int i = -30; i <= 30; i++) {
52 | // Make sure we don't include the main pixel which we already sampled!
53 | if (i != 0) {
54 | // Check we are on an index that doesn't exceed the blur radius specified
55 | if (i >= -radius && i <= radius) {
56 | float index = float(i);
57 | // Caclulate the current pixel index
58 | float pixelIndex = 0.0;
59 | if (pass == 0) {
60 | pixelIndex = (uv.y) * height;
61 | }
62 | else {
63 | pixelIndex = uv.x * width;
64 | }
65 | // Get the neighbouring pixel index
66 | float offset = index * pixelFrequency;
67 | pixelIndex += offset;
68 | // Normalise the new index back into the 0.0 to 1.0 range
69 | if (pass == 0) {
70 | pixelIndex /= height;
71 | }
72 | else {
73 | pixelIndex /= width;
74 | }
75 | // Pad the UV
76 | if (pixelIndex < 0.0) {
77 | pixelIndex = 0.0;
78 | }
79 | if (pixelIndex > 1.0) {
80 | pixelIndex = 1.0;
81 | }
82 | // Get gaussian amplitude
83 | float g = gauss(sigma, index);
84 | // Get the color of neighbouring pixel
85 | vec4 previousColor = vec4(0.0, 0.0, 0.0, 0.0);
86 | if (pass == 0) {
87 | previousColor = texture2D(texture, vec2(uv.x, pixelIndex)) * g;
88 | }
89 | else {
90 | previousColor = texture2D(texture, vec2(pixelIndex, uv.y)) * g;
91 | }
92 | color += previousColor;
93 | }
94 | }
95 | }
96 | // Return the resulting color
97 | gl_FragColor = color;
98 | }`;
99 |
100 | export function Blur() {
101 | //
102 | const [, setProcessing] = useRecoilState(processingState);
103 | const [imageData, setImageData] = useRecoilState(imageDataState);
104 | const [, setEditingMode] = useRecoilState(editingModeState);
105 | const [glContext, setGLContext] = useRecoilState(glContextState);
106 | const [imageBounds] = useRecoilState(imageBoundsState);
107 | const { throttleBlur } = React.useContext(EditorContext);
108 |
109 | const [sliderValue, setSliderValue] = React.useState(15);
110 | const [blur, setBlur] = React.useState(15);
111 | const [glProgram, setGLProgram] = React.useState(null);
112 |
113 | const onClose = () => {
114 | // If closing reset the image back to its original
115 | setGLContext(null);
116 | setEditingMode("operation-select");
117 | };
118 |
119 | const onSaveWithBlur = async () => {
120 | // Set the processing to true so no UI can be interacted with
121 | setProcessing(true);
122 | // Take a snapshot of the GLView's current framebuffer and set that as the new image data
123 | const gl = glContext;
124 | if (gl) {
125 | gl.drawArrays(gl.TRIANGLES, 0, 6);
126 | const output = await GLView.takeSnapshotAsync(gl);
127 | // Do any addtional platform processing of the result and set it as the
128 | // new image data
129 | if (Platform.OS === "web") {
130 | const fileReaderInstance = new FileReader();
131 | fileReaderInstance.readAsDataURL(output.uri as any);
132 | fileReaderInstance.onload = async () => {
133 | const base64data = fileReaderInstance.result;
134 | const flippedOutput = await ImageManinpulator.manipulateAsync(
135 | base64data as string,
136 | [{ flip: ImageManinpulator.FlipType.Vertical }]
137 | );
138 | setImageData({
139 | uri: flippedOutput.uri,
140 | width: flippedOutput.width,
141 | height: flippedOutput.height,
142 | });
143 | };
144 | } else {
145 | const flippedOutput = await ImageManinpulator.manipulateAsync(
146 | output.uri as string,
147 | [{ flip: ImageManinpulator.FlipType.Vertical }]
148 | );
149 | setImageData({
150 | uri: flippedOutput.uri as string,
151 | width: flippedOutput.width,
152 | height: flippedOutput.height,
153 | });
154 | }
155 |
156 | // Reset back to operation selection mode
157 | setProcessing(false);
158 | setGLContext(null);
159 | // Small timeout so it can set processing state to flase BEFORE
160 | // Blur component is unmounted...
161 | setTimeout(() => {
162 | setEditingMode("operation-select");
163 | }, 100);
164 | }
165 | };
166 |
167 | React.useEffect(() => {
168 | if (glContext !== null) {
169 | const setupGL = async () => {
170 | // Load in the asset and get its height and width
171 | const gl = glContext;
172 | // Do some magic instead of using asset.download async as this tries to
173 | // redownload the file:// uri on android and iOS
174 | let asset;
175 | if (Platform.OS !== "web") {
176 | asset = {
177 | uri: imageData.uri,
178 | localUri: imageData.uri,
179 | height: imageData.height,
180 | width: imageData.width,
181 | };
182 | await FileSystem.copyAsync({
183 | from: asset.uri,
184 | to: FileSystem.cacheDirectory + "blur.jpg",
185 | });
186 | asset.localUri = FileSystem.cacheDirectory + "blur.jpg";
187 | } else {
188 | asset = Asset.fromURI(imageData.uri);
189 | await asset.downloadAsync();
190 | }
191 | if (asset.width && asset.height) {
192 | // Setup the shaders for our GL context so it draws from texImage2D
193 | const vert = gl.createShader(gl.VERTEX_SHADER);
194 | const frag = gl.createShader(gl.FRAGMENT_SHADER);
195 | if (vert && frag) {
196 | // Set the source of the shaders and compile them
197 | gl.shaderSource(vert, vertShader);
198 | gl.compileShader(vert);
199 | gl.shaderSource(frag, fragShader);
200 | gl.compileShader(frag);
201 | // Create a WebGL program so we can link the shaders together
202 | const program = gl.createProgram();
203 | if (program) {
204 | // Attach both the vertex and frag shader to the program
205 | gl.attachShader(program, vert);
206 | gl.attachShader(program, frag);
207 | // Link the program - ensures that vetex and frag shaders are compatible
208 | // with each other
209 | gl.linkProgram(program);
210 | // Tell GL we ant to now use this program
211 | gl.useProgram(program);
212 | // Create a buffer on the GPU and assign its type as array buffer
213 | const buffer = gl.createBuffer();
214 | gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
215 | // Create the verticies for WebGL to form triangles on the screen
216 | // using the vertex shader which forms a square or rectangle in this case
217 | const verts = new Float32Array([
218 | -1, -1, 1, -1, 1, 1, -1, -1, -1, 1, 1, 1,
219 | ]);
220 | // Actually pass the verticies into the buffer and tell WebGL this is static
221 | // for optimisations
222 | gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
223 | // Get the index in memory for the position attribute defined in the
224 | // vertex shader
225 | const positionAttrib = gl.getAttribLocation(program, "position");
226 | gl.enableVertexAttribArray(positionAttrib); // Enable it i guess
227 | // Tell the vertex shader how to process this attribute buffer
228 | gl.vertexAttribPointer(positionAttrib, 2, gl.FLOAT, false, 0, 0);
229 | // Fetch an expo asset which can passed in as the source for the
230 | // texImage2D
231 |
232 | // Create some space in memory for a texture
233 | const texture = gl.createTexture();
234 | // Set the active texture to the texture 0 binding (0-30)
235 | gl.activeTexture(gl.TEXTURE0);
236 | // Bind the texture to WebGL stating what type of texture it is
237 | gl.bindTexture(gl.TEXTURE_2D, texture);
238 | // Set some parameters for the texture
239 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
240 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
241 | // Then set the data of this texture using texImage2D
242 | gl.texImage2D(
243 | gl.TEXTURE_2D,
244 | 0,
245 | gl.RGBA,
246 | gl.RGBA,
247 | gl.UNSIGNED_BYTE,
248 | asset as any
249 | );
250 | // Set a bunch of uniforms we want to pass into our fragment shader
251 | gl.uniform1i(gl.getUniformLocation(program, "texture"), 0);
252 | gl.uniform1f(
253 | gl.getUniformLocation(program, "width"),
254 | asset.width
255 | );
256 | gl.uniform1f(
257 | gl.getUniformLocation(program, "height"),
258 | asset.height
259 | );
260 | // Calculate the pixel frequency to sample at based on the image resolution
261 | // as the blur radius is in dp
262 | const pixelFrequency = Math.max(
263 | Math.round(imageData.width / imageBounds.width / 2),
264 | 1
265 | );
266 | gl.uniform1f(
267 | gl.getUniformLocation(program, "pixelFrequency"),
268 | pixelFrequency
269 | );
270 | setGLProgram(program);
271 | }
272 | }
273 | }
274 | };
275 | setupGL().catch((e) => console.error(e));
276 | }
277 | }, [glContext, imageData]);
278 |
279 | React.useEffect(() => {
280 | const gl = glContext;
281 | const program = glProgram;
282 | if (gl !== null && program !== null) {
283 | gl.uniform1i(gl.getUniformLocation(program, "texture"), 0);
284 | gl.uniform1i(gl.getUniformLocation(program, "radius"), blur);
285 | gl.uniform1i(gl.getUniformLocation(program, "pass"), 0);
286 | // Setup so first pass renders to a texture rather than to canvas
287 | // Create and bind the framebuffer
288 | const firstPassTexture = gl.createTexture();
289 | // Set the active texture to the texture 0 binding (0-30)
290 | gl.activeTexture(gl.TEXTURE1);
291 | // Bind the texture to WebGL stating what type of texture it is
292 | gl.bindTexture(gl.TEXTURE_2D, firstPassTexture);
293 | // Set some parameters for the texture
294 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
295 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
296 | // Then set the data of this texture using texImage2D
297 | gl.texImage2D(
298 | gl.TEXTURE_2D,
299 | 0,
300 | gl.RGBA,
301 | gl.drawingBufferWidth,
302 | gl.drawingBufferHeight,
303 | 0,
304 | gl.RGBA,
305 | gl.UNSIGNED_BYTE,
306 | null
307 | );
308 | const fb = gl.createFramebuffer();
309 | gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
310 | // attach the texture as the first color attachment
311 | const attachmentPoint = gl.COLOR_ATTACHMENT0;
312 | gl.framebufferTexture2D(
313 | gl.FRAMEBUFFER,
314 | attachmentPoint,
315 | gl.TEXTURE_2D,
316 | firstPassTexture,
317 | 0
318 | );
319 | //gl.viewport(0, 0, imageData.width, imageData.height);
320 | // Actually draw using the shader program we setup!
321 | gl.drawArrays(gl.TRIANGLES, 0, 6);
322 | gl.bindFramebuffer(gl.FRAMEBUFFER, null);
323 | //gl.viewport(0, 0, imageData.width, imageData.height);
324 | gl.uniform1i(gl.getUniformLocation(program, "texture"), 1);
325 | gl.uniform1i(gl.getUniformLocation(program, "pass"), 1);
326 | gl.drawArrays(gl.TRIANGLES, 0, 6);
327 | gl.endFrameEXP();
328 | }
329 | }, [blur, glContext, glProgram]);
330 |
331 | const throttleSliderBlur = React.useRef<(value: number) => void>(
332 | throttle((value) => setBlur(value), 50, { leading: true })
333 | ).current;
334 |
335 | React.useEffect(() => {
336 | return () => {};
337 | });
338 |
339 | if (glContext === null) {
340 | return null;
341 | }
342 |
343 | return (
344 |
345 |
346 | {
349 | setSliderValue(value[0]);
350 | if (throttleBlur) {
351 | throttleSliderBlur(Math.round(value[0]));
352 | } else {
353 | setBlur(Math.round(value[0]));
354 | }
355 | }}
356 | minimumValue={1}
357 | maximumValue={30}
358 | minimumTrackTintColor="#00A3FF"
359 | maximumTrackTintColor="#ccc"
360 | thumbTintColor="#c4c4c4"
361 | containerStyle={styles.slider}
362 | trackStyle={styles.sliderTrack}
363 | />
364 |
365 |
366 | onClose()} />
367 |
368 | Blur Radius: {Math.round(sliderValue)}
369 |
370 | onSaveWithBlur()}
374 | />
375 |
376 |
377 | );
378 | }
379 |
380 | const styles = StyleSheet.create({
381 | container: {
382 | flex: 1,
383 | flexDirection: "column",
384 | justifyContent: "space-between",
385 | alignItems: "center",
386 | },
387 | prompt: {
388 | color: "#fff",
389 | fontSize: 21,
390 | textAlign: "center",
391 | },
392 | row: {
393 | width: "100%",
394 | height: 80,
395 | flexDirection: "row",
396 | justifyContent: "space-between",
397 | alignItems: "center",
398 | paddingHorizontal: "2%",
399 | },
400 | slider: {
401 | height: 20,
402 | width: "90%",
403 | maxWidth: 600,
404 | },
405 | sliderTrack: {
406 | borderRadius: 10,
407 | },
408 | });
409 |
--------------------------------------------------------------------------------