DEV Community

GuySerfaty
GuySerfaty

Posted on

Rich-text editor with react-native: Upload photo

demo

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) that can open the camera and add photos (with 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 just to have something to start with:

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>`;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

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}
        />
...
Enter fullscreen mode Exit fullscreen mode

So here we extend the DEFAULT_TOOLBAR_ITEMS from tentap-editor and we add a new 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:

...
  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>
Enter fullscreen mode Exit fullscreen mode

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

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 GitHub thread

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}
      />
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Top comments (0)