Rich-text editor with react-native: Upload photo
GuySerfaty
Posted on February 19, 2024
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>`;
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:
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 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>
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:
- take a photo with the
camera
ref - generate a name for the new photo
- move the photo to the cache directory
- close the camera - so it will be going back to the editor
- use
setImage
of EditorBridge to insert the new image - change the editor selection
- 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:
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}
/>
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:
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>
);
};
Posted on February 19, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.