Rich-text editor with react-native: Upload photo
Tags: reactnative, mobile, javascript, opensource
Published: 2024-02-19 14:33:17

![demo](https://res.cloudinary.com/dkofpy6k6/image/upload/v1708349080/output-onlinegiftools_oy5l6g.gif)

There are many scenarios where you need a rich-text editor in your app and sometimes you want to let your users embed photos in it, for example:

- Compose a message, email app, chat app or any messing scenario
- Create a ticket
- Edit a doc
- Feedback popup 
- and many more...

Here I am going to show how I created a rich-text editor app (with [10tap](https://github.com/10play/10tap-editor)) that can open the camera and add photos (with [vision-camera](https://github.com/mrousavy/react-native-vision-camera))

**FULL EXAMPLE IS ON THE BOTTOM OF THIS POST**

First:
`yarn add react-native-webview @10play/tentap-editor react-native-vision-camera react-native-fs`

Then we can add the very basic tentap-editor [example](https://10play.github.io/10tap-editor/docs/examples/basic#full-example) just to have something to start with:

```jsx
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import React from 'react';
import {
  SafeAreaView,
  View,
  KeyboardAvoidingView,
  Platform,
  StyleSheet,
} from 'react-native';
import { RichText, Toolbar, useEditorBridge } from '@10play/tentap-editor';

export const Basic = () => {
  const editor = useEditorBridge({
    autofocus: true,
    avoidIosKeyboard: true,
    initialContent,
  });

  return (
    <SafeAreaView style={exampleStyles.fullScreen}>
      <View style={exampleStyles.fullScreen}>
        <RichText editor={editor} />
      </View>
      <KeyboardAvoidingView
        behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
        style={exampleStyles.keyboardAvoidingView}
      >
        <Toolbar editor={editor} />
      </KeyboardAvoidingView>
    </SafeAreaView>
  );
};

const exampleStyles = StyleSheet.create({
  fullScreen: {
    flex: 1,
  },
  keyboardAvoidingView: {
    position: 'absolute',
    width: '100%',
    bottom: 0,
  },
});

const initialContent = `<p>This is a basic example!</p>`;
```

This will add an editor to our app and a nice toolbar that we will extend in a bit with the camera button :)

Now let's edit the info.plist, add this:
```
	<key>NSCameraUsageDescription</key>
	<string>$(PRODUCT_NAME) needs access to your Camera.</string>
	<key>LSApplicationQueriesSchemes</key>
	<array>
		<string>file</string>
	</array>
```
`NSCameraUsageDescription` - so we have permission on camera
`LSApplicationQueriesSchemes` - so we have permission to access local files, will explain later

Now let's add camera button to the default toolbar:

```jsx
import cameraPng from '../assets/camera.png';
import {DEFAULT_TOOLBAR_ITEMS} from '@10play/tentap-editor';

...
const [cameraIsOn, setCameraIsOn] = React.useState(false);
...
        <Toolbar
          items={[
            {
              onPress: () => () => {
                editor.blur();
                setCameraIsOn(true);
              },
              active: () => false,
              disabled: () => false,
              image: () => cameraPng,
            },
            ...DEFAULT_TOOLBAR_ITEMS,
          ]}
          editor={editor}
        />
...
```
So here we extend the `DEFAULT_TOOLBAR_ITEMS` from tentap-editor and we add a new [ToolbarItem](https://10play.github.io/10tap-editor/docs/api/RichText#toolbaritem) that will close the keyboard and the state for if the camera is ON


Now let's add the camera part to our app:

```jsx
...
  const camera = useRef<Camera>(null);
  const { hasPermission, requestPermission } = useCameraPermission();
  useEffect(() => {
    if (!hasPermission) {
      requestPermission();
    }
  }, [hasPermission]);


  const device = useCameraDevice('back');

  const EditorCamera = device ? (
    <>
      <Camera
        ref={camera}
        style={StyleSheet.absoluteFill}
        device={device}
        isActive={true}
        photo={true}
      />
      <View
        style={{
          width: '100%',
          justifyContent: 'center',
          alignItems: 'center',
          position: 'absolute',
          bottom: 50,
          left: 0,
        }}
      >
        <TouchableOpacity
          style={{
            height: 80,
            width: 80,
            borderRadius: 50,
            borderWidth: 5,
            borderColor: 'white',
          }}
          onPress={async () => {
            if (!camera.current) {
              return;
            }
            const file = await camera.current.takePhoto();
            const name = 'test' + new Date().getTime() + '.jpeg';
            await rnFS.moveFile(
              file.path ?? '',
              rnFS.CachesDirectoryPath + '/' + name
            );
            setCameraIsOn(false);
            editor.setImage(`file://${rnFS.CachesDirectoryPath}` + '/' + name);
            const editorState = editor.getEditorState();
            editor.setSelection(
              editorState.selection.from,
              editorState.selection.from
            );
            editor.focus();
          }}
        />
      </View>
    </>
  ) : null;
...
      {cameraIsOn && EditorCamera}
    </View>
```

First, we check for permissions and ask for them if needed. Then we build `EditorCamera` this is a jsx that wraps the `Camera` component and adds to it a button to take a photo, you can add any additional functionality you want like zoom.

The onPress on the "take photo" button does:
1. take a photo with the `camera` ref
2. generate a name for the new photo
3. move the photo to the cache directory
4. close the camera - so it will be going back to the editor
5. use `setImage` of EditorBridge to insert the new image
6. change the editor selection
7. refocus the editor

In the last part, we are only rendering `EditorCamera` when the cameraIsOn state is true


Great! now we have almost everything, we have an app with a rich-text editor, a toolbar with a button for opening the camera and when the user takes a photo we insert the image into the doc, the only problem is this:

![Example of issue with load local image on iOS](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fzcy4y498jk466ubk42g.PNG)

To fix that we will have to modify the `RichText` component of tentap-editor, to allow access to local assets like photos we just took.

We will have to import the "simple editor" bundle from tentap-editor and store it in the same place with our photos, for additional reading please see [that](https://github.com/react-native-webview/react-native-webview/issues/1179#issuecomment-597187252) GitHub thread

```jsx
import {editorHtml} from '@10play/tentap-editor';
...
  useEffect(() => {
    rnFS.writeFile(
      rnFS.CachesDirectoryPath + '/editorOnDevice.html',
      editorHtml,
      'utf8'
    );
  }, []);

...

      <RichText
        editor={editor}
        source={{
          uri: 'file://' + rnFS.CachesDirectoryPath + '/editorOnDevice.html',
        }}
        allowFileAccess={true}
        allowFileAccessFromFileURLs={true}
        allowUniversalAccessFromFileURLs={true}
        originWhitelist={['*']}
        mixedContentMode="always"
        allowingReadAccessToURL={'file://' + rnFS.CachesDirectoryPath}
      />
```

When the user takes a photo we move it to CachesDirectoryPath (can be what path you want) we need to store the editor bundle there as well. So when the app mounts we will write editorHtml there.

Then we also need to modify `RichText` component and add to it:

```
        source={{
          uri: 'file://' + rnFS.CachesDirectoryPath + '/editorOnDevice.html',
        }}
        allowFileAccess={true}
        allowFileAccessFromFileURLs={true}
        allowUniversalAccessFromFileURLs={true}
        originWhitelist={['*']}
        mixedContentMode="always"
        allowingReadAccessToURL={'file://' + rnFS.CachesDirectoryPath}
```

we override the source to load from the path we want, add some props that allow to access files, and add `allowingReadAccessToURL` for the cache path


And we're done :)

**here is a full example**:

```jsx
import React, { useEffect, useRef } from 'react';
import rnFS from 'react-native-fs';
import {
  View,
  StyleSheet,
  TouchableOpacity,
  KeyboardAvoidingView,
  Platform,
} from 'react-native';
import {
  RichText,
  useEditorBridge,
  editorHtml,
  Toolbar,
  DEFAULT_TOOLBAR_ITEMS,
} from '@10play/tentap-editor';
import cameraPng from '../assets/camera.png';
import {
  Camera,
  useCameraDevice,
  useCameraPermission,
} from 'react-native-vision-camera';

const exampleStyles = StyleSheet.create({
  fullScreen: {
    flex: 1,
    backgroundColor: 'white',
  },
  keyboardAvoidingView: {
    position: 'absolute',
    width: '100%',
    bottom: 0,
  },
});

export const WithVisionCamera = () => {
  const { hasPermission, requestPermission } = useCameraPermission();
  const [cameraIsOn, setCameraIsOn] = React.useState(false);
  const camera = useRef<Camera>(null);

  useEffect(() => {
    rnFS.writeFile(
      rnFS.CachesDirectoryPath + '/indexr.html',
      editorHtml,
      'utf8'
    );
  }, []);
  useEffect(() => {
    if (!hasPermission) {
      requestPermission();
    }
  }, [hasPermission]);
  const editor = useEditorBridge({
    autofocus: true,
    avoidIosKeyboard: true,
  });

  const device = useCameraDevice('back');

  const EditorCamera = device ? (
    <>
      <Camera
        ref={camera}
        style={StyleSheet.absoluteFill}
        device={device}
        isActive={true}
        photo={true}
      />
      <View
        style={{
          width: '100%',
          justifyContent: 'center',
          alignItems: 'center',
          position: 'absolute',
          bottom: 50,
          left: 0,
        }}
      >
        <TouchableOpacity
          style={{
            height: 80,
            width: 80,
            borderRadius: 50,
            borderWidth: 5,
            borderColor: 'white',
          }}
          onPress={async () => {
            if (!camera.current) {
              return;
            }
            const file = await camera.current.takePhoto();
            const name = 'test' + new Date().getTime() + '.jpeg';
            await rnFS.moveFile(
              file.path ?? '',
              rnFS.CachesDirectoryPath + '/' + name
            );
            setCameraIsOn(false);
            editor.setImage(`file://${rnFS.CachesDirectoryPath}` + '/' + name);
            const editorState = editor.getEditorState();
            editor.setSelection(
              editorState.selection.from,
              editorState.selection.from
            );
            editor.focus();
          }}
        />
      </View>
    </>
  ) : null;

  return (
    <View style={exampleStyles.fullScreen}>
      <RichText
        editor={editor}
        source={{
          uri: 'file://' + rnFS.CachesDirectoryPath + '/indexr.html',
        }}
        allowFileAccess={true}
        allowFileAccessFromFileURLs={true}
        allowUniversalAccessFromFileURLs={true}
        originWhitelist={['*']}
        mixedContentMode="always"
        allowingReadAccessToURL={'file://' + rnFS.CachesDirectoryPath}
      />
      <KeyboardAvoidingView
        behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
        style={exampleStyles.keyboardAvoidingView}
      >
        <Toolbar
          items={[
            {
              onPress: () => () => {
                editor.blur();
                setCameraIsOn(true);
              },
              active: () => false,
              disabled: () => false,
              image: () => cameraPng,
            },
            ...DEFAULT_TOOLBAR_ITEMS,
          ]}
          editor={editor}
        />
      </KeyboardAvoidingView>
      {cameraIsOn && EditorCamera}
    </View>
  );
};
```