├── .expo-shared
└── assets.json
├── .gitignore
├── App.tsx
├── Context.tsx
├── Editor.tsx
├── Formatter
├── Group.tsx
├── GroupButton.tsx
├── ParagraphFormatButton.tsx
└── index.tsx
├── Icon.tsx
├── Link
└── index.tsx
├── Provider.tsx
├── README.md
├── Toolbar
├── Button.tsx
├── FormatButton.tsx
├── LinkButton.tsx
├── UndoRedoButtons.tsx
└── index.tsx
├── Tools.tsx
├── app.json
├── assets-src
├── editor.html
└── editor.js
├── assets
├── icon.png
└── splash.png
├── babel.config.js
├── index.tsx
├── package.json
├── tsconfig.json
├── types.ts
├── webpack.config.js
└── yarn.lock
/.expo-shared/assets.json:
--------------------------------------------------------------------------------
1 | {
2 | "f9155ac790fd02fadcdeca367b02581c04a353aa6d5aa84409a59f6804c87acd": true,
3 | "89ed26367cdb9b771858e026f2eb95bfdb90e5ae943e716575327ec325f39c44": true
4 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/**/*
2 | .cache/*
3 | .expo/*
4 | assets/SFSymbolsFallback.ttf
5 | assets/editor/*
6 | npm-debug.*
7 | *.jks
8 | *.p8
9 | *.p12
10 | *.key
11 | *.mobileprovision
12 | *.orig.*
13 | web-build/
14 | web-report/
15 |
16 | # macOS
17 | .DS_Store
18 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import * as Font from 'expo-font';
2 | import React from 'react';
3 | import {
4 | Button,
5 | SafeAreaView,
6 | StyleSheet,
7 | View
8 | } from 'react-native';
9 |
10 | import Editor from './Editor';
11 | import EditorProvider from './Provider';
12 | import Tools from './Tools';
13 |
14 | const styles = StyleSheet.create( {
15 | safeArea: {
16 | flex: 1,
17 | },
18 | container: {
19 | flex: 1,
20 | backgroundColor: '#fff',
21 | justifyContent: 'center',
22 | },
23 | } );
24 |
25 | export default class App extends React.Component {
26 | state = {
27 | // content: '
Hello world!
',
28 | content: '',
29 | }
30 |
31 | editor: Editor = null;
32 |
33 | componentDidMount() {
34 | Font.loadAsync( {
35 | 'sfsymbols': require( './assets/SFSymbolsFallback.ttf' ),
36 | } );
37 | }
38 |
39 | getContent = async () => {
40 | const content = await this.editor.getContent();
41 | console.log( content );
42 | }
43 |
44 | render() {
45 | return (
46 |
47 |
48 |
51 |
55 | this.editor = ref }
57 | placeholder="Start writing…"
58 | value={ this.state.content }
59 | />
60 |
61 |
62 |
63 |
64 |
65 | );
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Context.tsx:
--------------------------------------------------------------------------------
1 | import React, { Context } from 'react';
2 | import { WebViewMessageEvent } from 'react-native-webview';
3 |
4 | import { EditorState } from './types';
5 |
6 | export interface ContextValue {
7 | state: EditorState;
8 | getContent: () => Promise;
9 | setWebViewRef: ( ref: any ) => void;
10 | onCommand: ( commandId: string, showUI?: boolean, value?: string ) => void;
11 | onDismissToolbar: () => void;
12 | onFormat: ( format: string ) => void;
13 | onMessage: ( event: WebViewMessageEvent ) => void;
14 | onShowFormat: () => void;
15 | onShowLink: () => void;
16 | onUpdateContent: ( content: string ) => void;
17 | }
18 |
19 | export const defaultValue: ContextValue = {
20 | state: {
21 | // showingFormat: false,
22 | showingFormat: false,
23 | showingLink: false,
24 | // showingLink: true,
25 | textStatus: {
26 | bold: false,
27 | italic: false,
28 | underline: false,
29 | strikethrough: false,
30 | paraType: 'p',
31 | undo: {
32 | hasUndo: false,
33 | hasRedo: false,
34 | },
35 | link: {
36 | href: null,
37 | target: null,
38 | },
39 | },
40 | },
41 | getContent: null,
42 | setWebViewRef: null,
43 | onCommand: null,
44 | onDismissToolbar: null,
45 | onFormat: null,
46 | onMessage: null,
47 | onShowFormat: null,
48 | onShowLink: null,
49 | onUpdateContent: null,
50 | };
51 |
52 | const EditorContext: Context = React.createContext( defaultValue );
53 | export default EditorContext;
54 |
--------------------------------------------------------------------------------
/Editor.tsx:
--------------------------------------------------------------------------------
1 | import { Asset } from 'expo-asset';
2 | import React from 'react';
3 | import {
4 | StyleSheet,
5 | StyleProp,
6 | ViewStyle,
7 | } from 'react-native';
8 | import { WebView } from 'react-native-webview';
9 |
10 | import EditorContext from './Context';
11 |
12 | const editorHtml = require( './assets/editor/editor.html' );
13 | const editorUri = Asset.fromModule( editorHtml ).uri;
14 |
15 | const styles = StyleSheet.create( {
16 | webView: {
17 | flex: 1,
18 | backgroundColor: '#fff',
19 | },
20 | } );
21 |
22 | interface EditorProps {
23 | /**
24 | * CSS to apply to the HTML content inside the editor.
25 | *
26 | * https://www.tiny.cloud/docs/configure/content-appearance/#content_style
27 | */
28 | contentCss?: string;
29 |
30 | /**
31 | * Placeholder text to show in the field.
32 | */
33 | placeholder?: string;
34 |
35 | /**
36 | * Styles to apply to the web view.
37 | */
38 | style?: StyleProp;
39 |
40 | /**
41 | * Initial HTML content for the editor.
42 | */
43 | value?: string;
44 | }
45 |
46 | export default class Editor extends React.Component {
47 | declare context: React.ContextType;
48 | static contextType = EditorContext;
49 |
50 | static defaultProps: EditorProps = {
51 | contentCss: 'body { font-family: sans-serif; }',
52 | style: null,
53 | }
54 |
55 | componentDidUpdate( prevProps ) {
56 | if ( prevProps.value !== this.props.value ) {
57 | this.context.onUpdateContent( this.props.value );
58 | }
59 | }
60 |
61 | protected getInitScript() {
62 | const config = {
63 | content: this.props.value,
64 | content_style: this.props.contentCss,
65 | placeholder: this.props.placeholder || null,
66 | };
67 |
68 | return `
69 | // Initialize the editor.
70 | const initConfig = ${ JSON.stringify( config ) };
71 | window.init( initConfig );
72 |
73 | // Ensure string evaluates to true.
74 | true;
75 | `;
76 | }
77 |
78 | public async getContent(): Promise {
79 | return await this.context.getContent();
80 | }
81 |
82 | render() {
83 | return (
84 |
95 | );
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Formatter/Group.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | StyleSheet,
4 | View
5 | } from 'react-native';
6 |
7 | const styles = StyleSheet.create( {
8 | base: {
9 | flex: 1,
10 | flexDirection: 'row',
11 | alignItems: 'center',
12 | justifyContent: 'center',
13 | },
14 | } );
15 |
16 | export default function ButtonGroup( props ) {
17 | return (
18 |
21 | { props.children }
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/Formatter/GroupButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | StyleSheet,
4 | TouchableOpacity,
5 | } from 'react-native';
6 |
7 | import Icon from '../Icon';
8 |
9 | const styles = StyleSheet.create( {
10 | base: {
11 | flex: 1,
12 | height: 50,
13 | alignItems: 'center',
14 | justifyContent: 'center',
15 | backgroundColor: '#fff',
16 | marginLeft: 2,
17 | },
18 | first: {
19 | marginLeft: 0,
20 | borderTopLeftRadius: 7,
21 | borderBottomLeftRadius: 7,
22 | },
23 | last: {
24 | borderTopRightRadius: 7,
25 | borderBottomRightRadius: 7,
26 | },
27 | selected: {
28 | backgroundColor: '#ff9000',
29 | },
30 | text: {
31 | fontSize: 20,
32 | },
33 | selectedText: {
34 | color: '#fff',
35 | },
36 | } );
37 |
38 | export default function ButtonGroupButton( props ) {
39 | const style = [
40 | styles.base,
41 | props.first && styles.first,
42 | props.last && styles.last,
43 | props.selected && styles.selected,
44 | props.style,
45 | ];
46 |
47 | const textStyle = [
48 | styles.text,
49 | props.selected && styles.selectedText,
50 | ];
51 |
52 | return (
53 |
58 |
64 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/Formatter/ParagraphFormatButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | StyleSheet,
4 | Text,
5 | TouchableOpacity,
6 | } from 'react-native';
7 |
8 | const styles = StyleSheet.create( {
9 | base: {
10 | flex: 1,
11 | height: 50,
12 | alignItems: 'center',
13 | justifyContent: 'center',
14 | paddingLeft: 20,
15 | paddingRight: 20,
16 | borderTopLeftRadius: 7,
17 | borderBottomLeftRadius: 7,
18 | borderTopRightRadius: 7,
19 | borderBottomRightRadius: 7,
20 | },
21 | selected: {
22 | backgroundColor: '#ff9000',
23 | },
24 | text: {
25 | fontSize: 16,
26 | },
27 | selectedText: {
28 | color: '#fff',
29 | },
30 | } );
31 |
32 | export default function ParagraphFormatButton( props ) {
33 | const style = [
34 | styles.base,
35 | props.selected && styles.selected,
36 | props.style,
37 | ];
38 |
39 | const textStyle = [
40 | styles.text,
41 | props.selected && styles.selectedText,
42 | props.textStyle,
43 | ];
44 |
45 | return (
46 |
50 |
53 | { props.text }
54 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/Formatter/index.tsx:
--------------------------------------------------------------------------------
1 | import icons from '@rmccue/sfsymbols';
2 | import React from 'react';
3 | import {
4 | Animated,
5 | LayoutAnimation,
6 | ScrollView,
7 | StyleProp,
8 | StyleSheet,
9 | TouchableOpacity,
10 | View,
11 | ViewStyle,
12 | } from 'react-native';
13 |
14 | import Group from './Group';
15 | import GroupButton from './GroupButton';
16 | import ParagraphFormatButton from './ParagraphFormatButton';
17 | import Icon from '../Icon';
18 | import { EditorStatus } from '../types';
19 |
20 | const HEIGHT_BUMPER = 15;
21 | const HEIGHT_SAFE_AREA = 20;
22 | const HEIGHT_TOOLBAR = 220;
23 | const PADDING_Y = 20;
24 | const SHADOW_RADIUS = 10;
25 | const SHADOW_OFFSET = 10;
26 |
27 | const styles = StyleSheet.create( {
28 | container: {
29 | height: HEIGHT_TOOLBAR + HEIGHT_BUMPER + HEIGHT_SAFE_AREA,
30 | paddingLeft: 20,
31 | paddingRight: 20,
32 | paddingTop: PADDING_Y,
33 | paddingBottom: PADDING_Y + HEIGHT_BUMPER + HEIGHT_SAFE_AREA,
34 | backgroundColor: '#f2f2f7',
35 |
36 | shadowColor: '#8e8e93',
37 | shadowOffset: {
38 | width: 0,
39 | height: 0 - SHADOW_OFFSET,
40 | },
41 | shadowOpacity: 0.2,
42 | shadowRadius: SHADOW_RADIUS,
43 |
44 | position: 'absolute',
45 | left: 0,
46 | right: 0,
47 | },
48 | hidden: {
49 | bottom: 0 - PADDING_Y - HEIGHT_TOOLBAR - HEIGHT_BUMPER - HEIGHT_SAFE_AREA - SHADOW_RADIUS - SHADOW_OFFSET,
50 | },
51 | visible: {
52 | bottom: 0 - HEIGHT_BUMPER - HEIGHT_SAFE_AREA,
53 | },
54 | topRow: {
55 | flex: 1,
56 | flexDirection: 'row',
57 | alignItems: 'center',
58 | },
59 | closer: {
60 | paddingLeft: 10,
61 | alignSelf: 'stretch',
62 | alignItems: 'center',
63 | flexDirection: 'row',
64 | },
65 | paraType: {
66 | flex: 1,
67 | },
68 | lists: {
69 | flex: 1,
70 | flexDirection: 'row',
71 | },
72 | listType: {
73 | flex: 3,
74 | },
75 | listIndent: {
76 | flex: 2,
77 | marginLeft: 20,
78 | },
79 | } );
80 |
81 | interface ToolbarProps {
82 | status: EditorStatus;
83 | style: StyleProp;
84 | visible: boolean;
85 | onCommand: ( ...args: Array ) => void;
86 | onDismiss: () => void;
87 | onFormat: ( type: string ) => void;
88 | }
89 |
90 | export default class Toolbar extends React.Component {
91 | state = {
92 | bottomOffset: new Animated.Value( styles.hidden.bottom ),
93 | }
94 |
95 | componentDidUpdate( prevProps: ToolbarProps ) {
96 | if ( prevProps.visible !== this.props.visible ) {
97 | Animated.timing(
98 | this.state.bottomOffset,
99 | {
100 | toValue: this.props.visible ? styles.visible.bottom : styles.hidden.bottom,
101 | duration: 250,
102 | }
103 | ).start();
104 | }
105 | }
106 |
107 | render() {
108 | const { status, style, onCommand, onDismiss, onFormat } = this.props;
109 |
110 | const combinedStyle = [
111 | styles.container,
112 | {
113 | bottom: this.state.bottomOffset,
114 | },
115 | style,
116 | ];
117 |
118 | return (
119 |
122 |
123 |
129 | onFormat( 'h1' ) }
134 | />
135 | onFormat( 'h2' ) }
140 | />
141 | onFormat( 'p' ) }
145 | />
146 | onFormat( 'pre' ) }
151 | />
152 |
153 |
157 |
162 |
163 |
164 |
165 | onCommand( 'Bold' ) }
173 | />
174 | onCommand( 'Italic' ) }
181 | />
182 | onCommand( 'Underline' ) }
189 | />
190 | onCommand( 'Strikethrough' ) }
198 | />
199 |
200 |
201 |
204 | onCommand( 'InsertUnorderedList' ) }
211 | />
212 | onCommand( 'InsertOrderedList' ) }
218 | />
219 |
224 |
225 |
228 | onCommand( 'outdent' ) }
234 | />
235 | onCommand( 'indent' ) }
241 | />
242 |
243 |
244 |
245 | );
246 | }
247 | }
248 |
--------------------------------------------------------------------------------
/Icon.tsx:
--------------------------------------------------------------------------------
1 | import { isLoaded } from 'expo-font';
2 | import React from 'react';
3 | import { StyleSheet, StyleProp, Text, TextStyle } from 'react-native';
4 |
5 | const styles = StyleSheet.create( {
6 | base: {},
7 | icon: {
8 | fontFamily: 'sfsymbols',
9 | },
10 | fallback: {},
11 | } );
12 |
13 | interface IconProps {
14 | fallback: string;
15 | fallbackStyle?: StyleProp;
16 | icon: string;
17 | iconStyle?: StyleProp;
18 | style?: StyleProp;
19 | }
20 |
21 | export default function Icon( props: IconProps ) {
22 | const useIcon = isLoaded( 'sfsymbols' );
23 | const style = [
24 | styles.base,
25 | useIcon ? styles.icon : styles.fallback,
26 | props.style,
27 | useIcon ? props.iconStyle : props.fallbackStyle,
28 | ];
29 |
30 | return (
31 |
32 | { useIcon ? props.icon : props.fallback }
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/Link/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | LayoutAnimation,
4 | StyleProp,
5 | StyleSheet,
6 | Switch,
7 | Text,
8 | TextInput,
9 | TouchableOpacity,
10 | TouchableWithoutFeedback,
11 | View,
12 | ViewStyle,
13 | } from 'react-native';
14 | import { KeyboardAccessoryView } from 'react-native-keyboard-accessory';
15 |
16 | import { EditorStatus } from '../types';
17 |
18 | const HEIGHT_DIALOG = 140;
19 |
20 | const styles = StyleSheet.create( {
21 | backdrop: {
22 | position: 'absolute',
23 | top: 0,
24 | bottom: 0,
25 | left: 0,
26 | right: 0,
27 |
28 | backgroundColor: '#00000011',
29 | },
30 | accessoryView: {
31 | backgroundColor: '#f2f2f7',
32 |
33 | shadowColor: '#8e8e93',
34 | shadowOffset: {
35 | width: 0,
36 | height: -10,
37 | },
38 | shadowOpacity: 0.2,
39 | shadowRadius: 10,
40 | },
41 | container: {
42 | height: HEIGHT_DIALOG,
43 | paddingLeft: 20,
44 | paddingRight: 20,
45 | },
46 | topRow: {
47 | flex: 1,
48 | flexDirection: 'row',
49 | alignItems: 'center',
50 | },
51 | closer: {
52 | marginLeft: 10,
53 | alignSelf: 'stretch',
54 | alignItems: 'center',
55 | flexDirection: 'row',
56 | },
57 |
58 | fieldRow: {
59 | flex: 1,
60 | flexDirection: 'row',
61 | alignItems: 'center',
62 | },
63 | fieldLabel: {
64 | fontSize: 16,
65 | marginRight: 10,
66 | width: 'auto',
67 | },
68 | textInput: {
69 | flex: 1,
70 | fontSize: 16,
71 | height: 30,
72 | textAlign: 'right',
73 | },
74 | switchWrap: {
75 | flex: 1,
76 | alignItems: 'flex-end',
77 | justifyContent: 'flex-end',
78 | },
79 | remove: {
80 | height: 30,
81 | },
82 | removeText: {
83 | fontSize: 13,
84 | textAlign: 'center',
85 | color: '#ff3b30',
86 | },
87 | } );
88 |
89 | interface LinkProps {
90 | status: EditorStatus;
91 | style?: StyleProp | null;
92 | onCommand: ( commandId: string, showUI?: boolean, value?: any ) => void;
93 | onDismiss: () => void;
94 | onFormat: ( type: string ) => void;
95 | }
96 |
97 | const Row = ( { children, style = null } ) => (
98 |
99 | { children }
100 |
101 | );
102 |
103 | const Label = ( { title } ) => (
104 |
105 | { title }
106 |
107 | );
108 |
109 | export default class Link extends React.Component {
110 | state = {
111 | url: '',
112 | newTab: false,
113 | }
114 |
115 | constructor( props: LinkProps ) {
116 | super( props );
117 |
118 | // Hook up to current link element.
119 | if ( props.status.link.href ) {
120 | this.state.url = props.status.link.href;
121 | }
122 | if ( props.status.link.target ) {
123 | this.state.newTab = props.status.link.target === '_blank';
124 | }
125 | }
126 |
127 | componentDidUpdate() {
128 | LayoutAnimation.configureNext(
129 | LayoutAnimation.create(
130 | 250,
131 | LayoutAnimation.Types.keyboard,
132 | LayoutAnimation.Properties.scaleXY,
133 | )
134 | );
135 | }
136 |
137 | onCancel = () => {
138 | this.props.onDismiss();
139 | }
140 |
141 | onSave = () => {
142 | if ( ! this.state.url ) {
143 | return;
144 | }
145 |
146 | const value = {
147 | href: this.state.url,
148 | target: this.state.newTab ? '_blank' : null,
149 | };
150 | this.props.onCommand( 'mceInsertLink', false, value );
151 | this.props.onDismiss();
152 | }
153 |
154 | onToggleNewTab = ( value: boolean ) => {
155 | this.setState( {
156 | newTab: value,
157 | } );
158 | }
159 |
160 | onRemoveLink = () => {
161 | this.props.onCommand( 'unlink' );
162 | this.props.onDismiss();
163 | }
164 |
165 | render() {
166 | return (
167 | <>
168 |
171 |
174 |
175 |
176 |
183 |
184 |
185 |
186 | this.setState( { url } ) }
197 | onSubmitEditing={ this.onSave }
198 | />
199 |
200 |
201 |
202 |
203 |
207 |
208 |
209 |
212 |
213 |
214 | Remove link
215 |
216 |
217 |
218 |
219 |
220 | >
221 | );
222 | }
223 | }
224 |
--------------------------------------------------------------------------------
/Provider.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactChild } from 'react';
2 | import {
3 | EmitterSubscription,
4 | Keyboard,
5 | } from 'react-native';
6 | import { WebViewMessageEvent } from 'react-native-webview';
7 |
8 | import EditorContext, { defaultValue, ContextValue } from './Context';
9 | import { EditorEvent, EditorState } from './types';
10 |
11 | /**
12 | * Time to debounce a keyboard show event.
13 | *
14 | * Experimentally tested on an iPhone 11 Pro, most events take 10-25ms to
15 | * execute, while some outliers occur around 50ms, with occasional events a
16 | * bit higher when lag occurs.
17 | *
18 | * 100ms should be plenty to cover all events including outliers.
19 | */
20 | const KEYBOARD_DEBOUNCE = 100;
21 |
22 | interface ProviderProps {
23 | /**
24 | * Render prop for the toolbar.
25 | */
26 | children: ReactChild;
27 | }
28 |
29 | export default class Provider extends React.Component {
30 | state: EditorState = defaultValue.state;
31 |
32 | private keyboardShowListener: EmitterSubscription = null;
33 | private keyboardHideListener: EmitterSubscription = null;
34 | private keyboardTimer: number = null;
35 | private resolveContent: ( content: string ) => void = null;
36 | private webref = null;
37 |
38 | componentDidMount() {
39 | this.keyboardShowListener = Keyboard.addListener( 'keyboardWillShow', this.onKeyboardShow );
40 | this.keyboardHideListener = Keyboard.addListener( 'keyboardDidHide', this.onKeyboardHide );
41 | }
42 |
43 | componentWillUnmount() {
44 | this.keyboardShowListener.remove();
45 | this.keyboardHideListener.remove();
46 | }
47 |
48 | public getContent = async (): Promise => {
49 | return new Promise( ( resolve, reject ) => {
50 | this.resolveContent = resolve;
51 |
52 | this.webref.injectJavaScript( `
53 | window.ReactNativeWebView.postMessage( JSON.stringify( {
54 | type: 'getContent',
55 | payload: {
56 | html: tinymce.activeEditor.getContent(),
57 | },
58 | } ) );
59 | ` );
60 | } );
61 | }
62 |
63 | protected setWebViewRef = ref => {
64 | this.webref = ref;
65 | }
66 |
67 | /**
68 | * Hide the formatting pane, but debounce the event.
69 | *
70 | * When formatting is applied, TinyMCE internally triggers focus on the
71 | * contenteditable element, which triggers the keyboard. We then
72 | * hide it as soon as possible via the .blur() call in onCommand.
73 | *
74 | * By debouncing the event, we leave enough time for TinyMCE to do its
75 | * magic. For "real" keyboard events (i.e. user moves cursor or selects
76 | * another field), the keyboard takes ~250ms to show anyway, so a slight
77 | * delay doesn't have a huge visual impact.
78 | *
79 | * @see KEYBOARD_DEBOUNCE
80 | */
81 | protected onKeyboardShow = e => {
82 | this.keyboardTimer = window.setTimeout( () => {
83 | this.keyboardTimer = null;
84 | this.onDebouncedKeyboardShow( e );
85 | }, KEYBOARD_DEBOUNCE );
86 | }
87 |
88 | /**
89 | * Cancel any keyboard timers if set.
90 | */
91 | protected onKeyboardHide = e => {
92 | if ( this.keyboardTimer ) {
93 | window.clearTimeout( this.keyboardTimer );
94 | }
95 | }
96 |
97 | /**
98 | * Hide the formatting pane if the keyboard is shown.
99 | *
100 | * @see onKeyboardShow
101 | */
102 | protected onDebouncedKeyboardShow = e => {
103 | if ( this.state.showingFormat ) {
104 | this.setState( {
105 | showingFormat: false,
106 | } );
107 | }
108 | }
109 |
110 | protected onMessage = ( event: WebViewMessageEvent ) => {
111 | const data: EditorEvent = JSON.parse( event.nativeEvent.data );
112 | switch ( data.type ) {
113 | case 'updateStatus':
114 | this.setState( {
115 | textStatus: data.payload,
116 | } );
117 | break;
118 |
119 | case 'getContent':
120 | if ( ! this.resolveContent ) {
121 | return;
122 | }
123 |
124 | this.resolveContent( data.payload.html );
125 | break;
126 |
127 | default:
128 | return;
129 | }
130 | }
131 |
132 | protected onShowFormat = () => {
133 | if ( ! this.webref ) {
134 | return;
135 | }
136 |
137 | // Hide the keyboard.
138 | this.webref.injectJavaScript( "document.activeElement.blur()" );
139 |
140 | // Show the formatting tools.
141 | this.setState( {
142 | showingFormat: true,
143 | showingLink: false,
144 | } );
145 | }
146 |
147 | protected onDismissToolbar = () => {
148 | this.setState( {
149 | showingFormat: false,
150 | showingLink: false,
151 | } );
152 |
153 | this.webref.injectJavaScript( `
154 | // Refocus the editor.
155 | tinymce.activeEditor.focus();
156 | ` );
157 | }
158 |
159 | protected onCommand = ( commandId: string, showUI?: boolean, value?: string ) => {
160 | const args = [ commandId, showUI, value ];
161 | this.webref.injectJavaScript( `
162 | // Execute the command first.
163 | tinymce.activeEditor.execCommand(
164 | ...${ JSON.stringify( args ) }
165 | );
166 |
167 | // Hide the keyboard again.
168 | document.activeElement.blur();
169 | ` );
170 | }
171 |
172 | protected onFormat = format => {
173 | this.onCommand(
174 | 'mceToggleFormat',
175 | false,
176 | format
177 | );
178 | }
179 |
180 | protected onUpdateContent = ( content: string ) => {
181 | if ( ! this.webref ) {
182 | return;
183 | }
184 |
185 | this.webref.injectJavaScript( `
186 | tinymce.activeEditor.setContent( ${ JSON.stringify( content ) } );
187 | ` );
188 | }
189 |
190 | protected onShowLink = () => {
191 | if ( ! this.webref ) {
192 | return;
193 | }
194 |
195 | // Preserve selection.
196 | this.webref.injectJavaScript( "document.activeElement.blur()" );
197 |
198 | this.setState( {
199 | showingFormat: false,
200 | showingLink: true,
201 | } );
202 | }
203 |
204 | render() {
205 | const { children } = this.props;
206 |
207 | const value: ContextValue = {
208 | state: this.state,
209 | getContent: this.getContent,
210 | setWebViewRef: this.setWebViewRef,
211 | onCommand: this.onCommand,
212 | onDismissToolbar: this.onDismissToolbar,
213 | onFormat: this.onFormat,
214 | onMessage: this.onMessage,
215 | onShowFormat: this.onShowFormat,
216 | onShowLink: this.onShowLink,
217 | onUpdateContent: this.onUpdateContent,
218 | };
219 |
220 | return (
221 |
222 | { children }
223 |
224 | );
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-native-tinymce
2 |
3 | HTML WYSIWYG for React Native, in pure JS. (Works in Expo apps!)
4 |
5 | Combine the power of TinyMCE with the usability of native UI.
6 |
7 | ## Demo
8 |
9 | A demo of the editor is included in this repo. You can run this in the iOS Simulator or on your native device using [Expo](https://docs.expo.io/versions/v36.0.0/get-started/installation/#2-mobile-app-expo-client-for-ios) in minutes.
10 |
11 | ```sh
12 | # Clone down this repo.
13 | git clone https://github.com/rmccue/react-native-tinymce.git
14 | cd react-native-tinymce
15 |
16 | # Install dependencies.
17 | npm install
18 |
19 | # Build the app using Expo.
20 | npm start
21 | ```
22 |
23 | Once the app has started, follow the instructions in the build tool to run in the simulator or on-device.
24 |
25 |
26 | ## Usage
27 |
28 | ```jsx
29 | import React from 'react';
30 | import { Editor, Provider, Tools } from 'react-native-tinymce';
31 |
32 | const MyEditor = props => (
33 |
34 | this.editor = ref }
36 | value="Hello world!
"
37 | />
38 |
39 |
40 |
41 | )
42 | ```
43 |
44 | Place the Editor component wherever you want the main WYSIWYG editor view.
45 |
46 | The `Tools` component must be placed in the view-root of the current screen, as it uses absolute positioning to attach to the keyboard.
47 |
48 | The `Provider` must be a common ancestor to both the `Editor` and the `Tools` components; typically as a wrapper around your `SafeAreaView` or similar root.
49 |
50 |
51 | ### Props
52 |
53 | Pass the initial HTML content as the `value` prop to `Editor`.
54 |
55 | To retrieve the content from the editor, call the `getContent()` method on the `Editor` instance. This returns a promise which will resolve to the HTML string.
56 |
57 | Avoid changing the `value` prop too often, as it causes TinyMCE to re-parse and re-render the value unnecessarily.
58 |
59 |
60 | ## Architecture
61 |
62 | The main component is the Editor component. This renders a TinyMCE-based WYSIWYG into a webview, and sets up the interactions with it.
63 |
64 | The Provider component contains the bulk of the logic for the editor, and it tracks state and actions. These are provided to the Editor and Tools components via context.
65 |
66 | When focussed on the WYSIWYG, the Tools component renders the toolbar as a keyboard accessory view.
67 |
68 | The toolbar can be overridden via the `children` render prop, and by default renders the included Toolbar component. Override this prop to add additional buttons as needed.
69 |
70 | When the user presses the format button, the toolbar and keyboard are hidden, and the formatter pane is shown. This pane interacts with TinyMCE's underlying formatting utilities.
71 |
72 |
73 | ## Icons
74 |
75 | By default, react-native-tinymce uses a set of fallback icons.
76 |
77 | To use native iOS icons, load in SF Symbols as a font using `expo-font`, with the name `sfsymbols`.
78 |
79 |
80 | ## Credits
81 |
82 | Copyright 2019 Ryan McCue
83 |
84 | Licensed under the MIT license.
85 |
--------------------------------------------------------------------------------
/Toolbar/Button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | StyleSheet,
4 | TouchableOpacity,
5 | } from 'react-native';
6 |
7 | import Icon from '../Icon';
8 |
9 | export interface ButtonProps {
10 | fallback?: string;
11 | icon: string;
12 | label: string;
13 | onPress(): void;
14 | }
15 |
16 | const styles = StyleSheet.create( {
17 | button: {
18 | flex: 1,
19 | },
20 | buttonText: {
21 | padding: 8,
22 | fontSize: 20,
23 | color: '#007aff',
24 | textAlign: 'center',
25 | },
26 | } );
27 |
28 | export default function FormatButton( props: ButtonProps ) {
29 | return (
30 |
36 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/Toolbar/FormatButton.tsx:
--------------------------------------------------------------------------------
1 | import icons from '@rmccue/sfsymbols';
2 | import React from 'react';
3 |
4 | import Button from './Button';
5 | import { EditorChildrenProps } from '../types';
6 |
7 | export default function FormatButton( props: EditorChildrenProps ) {
8 | return (
9 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/Toolbar/LinkButton.tsx:
--------------------------------------------------------------------------------
1 | import icons from '@rmccue/sfsymbols';
2 | import React from 'react';
3 |
4 | import Button from './Button';
5 | import { EditorChildrenProps } from '../types';
6 |
7 | export default function FormatButton( props: EditorChildrenProps ) {
8 | return (
9 | props.onShowLink() }
14 | />
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/Toolbar/UndoRedoButtons.tsx:
--------------------------------------------------------------------------------
1 | import icons from '@rmccue/sfsymbols';
2 | import React from 'react';
3 |
4 | import Button from './Button';
5 | import { EditorChildrenProps } from '../types';
6 |
7 | export default function FormatButton( props: EditorChildrenProps ) {
8 | return (
9 | <>
10 | props.onCommand( 'undo' ) }
15 | />
16 | props.onCommand( 'redo' ) }
21 | />
22 | >
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/Toolbar/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | StyleSheet,
4 | View,
5 | } from 'react-native';
6 |
7 | import FormatButton from './FormatButton';
8 | import LinkButton from './LinkButton';
9 | import UndoRedoButtons from './UndoRedoButtons';
10 | import { EditorChildrenProps } from '../types';
11 |
12 | const styles = StyleSheet.create( {
13 | container: {
14 | flexDirection: 'row',
15 | justifyContent: 'flex-start',
16 | },
17 | } );
18 |
19 | export default function Toolbar( props: EditorChildrenProps ) {
20 | return (
21 |
22 |
25 |
28 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/Tools.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { StyleProp, StyleSheet, ViewStyle } from 'react-native';
3 | import { KeyboardAccessoryView } from 'react-native-keyboard-accessory';
4 |
5 | import EditorContext from './Context';
6 | import Formatter from './Formatter';
7 | import Link from './Link';
8 | import Toolbar from './Toolbar';
9 | import { EditorChildrenProps } from './types';
10 |
11 | const styles = StyleSheet.create( {
12 | toolbar: {
13 | height: 50,
14 | backgroundColor: '#f2f2f7',
15 | },
16 | } );
17 |
18 | interface ToolsProps {
19 | /**
20 | * Styles to apply to the formatter.
21 | */
22 | formatterStyle?: StyleProp;
23 |
24 | /**
25 | * Render prop for the toolbar.
26 | */
27 | children( props: EditorChildrenProps ): JSX.Element;
28 | }
29 |
30 | export default function Tools( props: ToolsProps ) {
31 | return (
32 |
33 | { context => (
34 | <>
35 |
43 |
44 | { context.state.showingLink ? (
45 |
51 | ) : (
52 |
58 | { ! context.state.showingFormat ? (
59 | props.children( {
60 | onCommand: context.onCommand,
61 | onShowFormat: context.onShowFormat,
62 | onShowLink: context.onShowLink,
63 | } )
64 | ) : null }
65 |
66 | ) }
67 | >
68 | ) }
69 |
70 | );
71 | }
72 |
73 | Tools.defaultProps = {
74 | children: props => ,
75 | formatterStyle: null,
76 | } as ToolsProps;
77 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "Nativeish Editor",
4 | "slug": "nativeish-editor",
5 | "privacy": "public",
6 | "sdkVersion": "36.0.0",
7 | "platforms": [
8 | "ios",
9 | "android",
10 | "web"
11 | ],
12 | "entryPoint": "node_modules/expo/AppEntry.js",
13 | "version": "1.0.0",
14 | "orientation": "portrait",
15 | "icon": "./assets/icon.png",
16 | "splash": {
17 | "image": "./assets/splash.png",
18 | "resizeMode": "contain",
19 | "backgroundColor": "#ffffff"
20 | },
21 | "updates": {
22 | "fallbackToCacheTimeout": 0
23 | },
24 | "assetBundlePatterns": [
25 | "**/*"
26 | ],
27 | "ios": {
28 | "supportsTablet": true
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/assets-src/editor.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
21 |
22 |
23 |
24 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/assets-src/editor.js:
--------------------------------------------------------------------------------
1 | import tinymce from 'tinymce';
2 |
3 | // Import appropriate dependencies.
4 | import 'tinymce/plugins/link';
5 | import 'tinymce/plugins/lists';
6 |
7 | let status = {
8 | bold: false,
9 | italic: false,
10 | underline: false,
11 | strikethrough: false,
12 | paraType: 'p',
13 | undo: {
14 | hasUndo: false,
15 | hasRedo: false,
16 | },
17 | link: {
18 | href: null,
19 | target: null,
20 | },
21 | };
22 | const sendStatus = () => {
23 | if ( window.ReactNativeWebView ) {
24 | window.ReactNativeWebView.postMessage( JSON.stringify( {
25 | type: 'updateStatus',
26 | payload: status,
27 | } ) );
28 | }
29 | };
30 |
31 | const CORE_CSS = `
32 | .mce-content-body.empty {
33 | position: relative;
34 | }
35 | .mce-content-body.empty::before {
36 | opacity: 0.35;
37 | display: block;
38 | position: absolute;
39 | content: attr( data-placeholder );
40 | }
41 | `;
42 |
43 | window.init = config => {
44 | const textarea = document.getElementById( 'editor' );
45 |
46 | tinymce.init( {
47 | target: textarea,
48 |
49 | // Remove all UI.
50 | menubar: false,
51 | statusbar: false,
52 | toolbar: false,
53 | theme: false,
54 | skin: false,
55 |
56 | // Reset content styles.
57 | content_css: false,
58 | content_style: CORE_CSS + ( config.content_style || '' ),
59 |
60 | // No need for inputs.
61 | hidden_input: false,
62 |
63 | // Add some basic plugins.
64 | plugins: [
65 | 'link',
66 | 'lists',
67 | ],
68 | } ).then( editors => {
69 | const editor = editors[0];
70 | window.tinyEditor = editor;
71 |
72 | // Add our custom class to the editor container.
73 | editor.editorContainer.className = 'editor-wrap';
74 |
75 | editor.on( 'NodeChange', api => {
76 | // Find the nearest list item.
77 | for ( let i = 0; i < api.parents.length; i++ ) {
78 | if ( api.parents[ i ].tagName !== 'LI' ) {
79 | continue;
80 | }
81 |
82 | // Found a list item, check the parent.
83 | const parentIndex = i + 1;
84 | if ( parentIndex >= api.parents.length ) {
85 | continue;
86 | }
87 |
88 | const parent = api.parents[ parentIndex ];
89 | switch ( parent.tagName ) {
90 | case 'UL':
91 | case 'OL':
92 | status = {
93 | ...status,
94 | paraType: parent.tagName.toLowerCase(),
95 | };
96 | sendStatus();
97 | break;
98 |
99 | default:
100 | break;
101 | }
102 | }
103 | } );
104 |
105 | const formats = [
106 | 'bold',
107 | 'italic',
108 | 'underline',
109 | 'strikethrough',
110 | ];
111 | formats.forEach( format => {
112 | editor.formatter.formatChanged( format, value => {
113 | status = {
114 | ...status,
115 | [ format ]: value,
116 | };
117 | sendStatus();
118 | }, true );
119 | } );
120 |
121 | const paraType = [
122 | 'p',
123 | 'blockquote',
124 | 'h1',
125 | 'h2',
126 | 'pre',
127 | 'UL',
128 | 'OL',
129 | ];
130 | paraType.forEach( type => {
131 | editor.formatter.formatChanged( type, value => {
132 | if ( ! value ) {
133 | return;
134 | }
135 |
136 | status = {
137 | ...status,
138 | paraType: type,
139 | };
140 | sendStatus();
141 | } );
142 | } );
143 |
144 | const getLinkStatus = () => {
145 | const selected = editor.selection.getNode();
146 | if ( ! selected ) {
147 | return {
148 | href: null,
149 | target: null,
150 | };
151 | }
152 |
153 | const anchor = editor.dom.getParent( selected, 'a[href]' );
154 | return {
155 | href: anchor ? editor.dom.getAttrib( anchor, 'href' ) : null,
156 | target: anchor ? editor.dom.getAttrib( anchor, 'target' ) : null,
157 | };
158 | };
159 |
160 | // Subscribe to events.
161 | editor.on( 'Undo Redo AddUndo TypingUndo ClearUndos SwitchMode SelectionChange', () => {
162 | status = {
163 | ...status,
164 | undo: {
165 | hasUndo: editor.undoManager.hasUndo(),
166 | hasRedo: editor.undoManager.hasRedo(),
167 | },
168 | link: getLinkStatus(),
169 | };
170 | sendStatus();
171 | } );
172 |
173 | if ( config.placeholder ) {
174 | editor.getBody().dataset.placeholder = config.placeholder;
175 | }
176 |
177 | // If we have content, initialize the editor.
178 | if ( config.content && config.content.length > 0 ) {
179 | editor.setContent( config.content );
180 | } else {
181 | editor.getBody().classList.add( 'empty' );
182 | editor.once( 'focus', () => {
183 | editor.getBody().classList.remove( 'empty' );
184 | } );
185 | }
186 | } );
187 | };
188 |
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rmccue/react-native-tinymce/1c1c2cf2dbee8488c21e11f1d2dde4c6ef259b17/assets/icon.png
--------------------------------------------------------------------------------
/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rmccue/react-native-tinymce/1c1c2cf2dbee8488c21e11f1d2dde4c6ef259b17/assets/splash.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/index.tsx:
--------------------------------------------------------------------------------
1 | // Basic exports.
2 | export { default as Editor } from './Editor';
3 | export { default as Provider } from './Provider';
4 | export { default as Tools } from './Tools';
5 |
6 | // Advanced exports.
7 | export { default as Toolbar } from './Toolbar';
8 | export { default as ToolbarButton } from './Toolbar/Button';
9 | export { default as Context } from './Context';
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-native-tinymce",
3 | "main": "index.tsx",
4 | "files": [
5 | "assets/editor/",
6 | "Formatter/",
7 | "Link/",
8 | "Toolbar/",
9 | "*.tsx",
10 | "*.ts"
11 | ],
12 | "scripts": {
13 | "start": "expo start",
14 | "build:editor": "webpack --mode production",
15 | "android": "expo start --android",
16 | "ios": "expo start --ios",
17 | "web": "expo start --web",
18 | "eject": "expo eject"
19 | },
20 | "dependencies": {
21 | "@rmccue/sfsymbols": "^0.0.2",
22 | "react-native-keyboard-accessory": "^0.1.10",
23 | "tinymce": "^5.1.5"
24 | },
25 | "peerDependencies": {
26 | "react": "~16.9.0",
27 | "react-native": "*",
28 | "react-native-webview": "7.4.3"
29 | },
30 | "devDependencies": {
31 | "@babel/core": "^7.0.0",
32 | "@types/react": "~16.9.0",
33 | "@types/react-native": "~0.60.23",
34 | "babel-preset-expo": "~8.0.0",
35 | "clean-webpack-plugin": "^3.0.0",
36 | "expo": "~36.0.0",
37 | "html-webpack-inline-source-plugin": "^0.0.10",
38 | "html-webpack-plugin": "^3.2.0",
39 | "react": "~16.9.0",
40 | "react-native": "https://github.com/expo/react-native/archive/sdk-36.0.0.tar.gz",
41 | "react-native-webview": "7.4.3",
42 | "typescript": "~3.6.3",
43 | "webpack": "^4.41.5",
44 | "webpack-cli": "^3.3.10"
45 | },
46 | "version": "0.1.2"
47 | }
48 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "jsx": "react-native",
5 | "lib": ["dom", "esnext"],
6 | "moduleResolution": "node",
7 | "noEmit": true,
8 | "skipLibCheck": true,
9 | "resolveJsonModule": true
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/types.ts:
--------------------------------------------------------------------------------
1 | export interface EditorStatus {
2 | bold: boolean;
3 | italic: boolean;
4 | underline: boolean;
5 | strikethrough: boolean;
6 | paraType: 'p' | 'blockquote' | 'h1' | 'h2' | 'pre' | 'ul' | 'ol';
7 | undo: {
8 | hasUndo: boolean,
9 | hasRedo: boolean,
10 | },
11 | link: {
12 | href: string | null;
13 | target: string | null;
14 | }
15 | }
16 |
17 | export interface BaseEditorEvent {
18 | type: string;
19 | payload: object;
20 | }
21 |
22 | export interface UpdateStatusEvent extends BaseEditorEvent {
23 | type: 'updateStatus';
24 | payload: EditorStatus;
25 | }
26 |
27 | export interface GetContentEvent extends BaseEditorEvent {
28 | type: 'getContent';
29 | payload: {
30 | html: string;
31 | }
32 | }
33 |
34 | export type EditorEvent = UpdateStatusEvent | GetContentEvent;
35 |
36 | export interface EditorChildrenProps {
37 | onCommand( commandId: string, showUI?: boolean, value?: any ): void;
38 | onShowFormat(): void;
39 | onShowLink(): void;
40 | }
41 |
42 | export interface EditorState {
43 | showingFormat: boolean;
44 | showingLink: boolean;
45 | textStatus: EditorStatus;
46 | }
47 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const { CleanWebpackPlugin } = require('clean-webpack-plugin');
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 | const HtmlWebpackInlineSourcePlugin = require( 'html-webpack-inline-source-plugin' );
4 | const path = require('path');
5 |
6 | module.exports = {
7 | entry: './assets-src/editor.js',
8 | output: {
9 | filename: 'editor.js',
10 | path: path.resolve( __dirname, 'assets/editor' ),
11 | },
12 | performance: {
13 | hints: false,
14 | },
15 | plugins: [
16 | new HtmlWebpackPlugin( {
17 | inlineSource: '.(js|css)$',
18 | filename: 'editor.html',
19 | template: 'assets-src/editor.html',
20 | minify: {
21 | collapseWhitespace: true,
22 | minifyCSS: true,
23 | removeComments: true,
24 | removeRedundantAttributes: true,
25 | removeScriptTypeAttributes: true,
26 | removeStyleLinkTypeAttributes: true,
27 | useShortDoctype: true,
28 | },
29 | } ),
30 | new HtmlWebpackInlineSourcePlugin(),
31 |
32 | // Remove the unused JS file.
33 | new CleanWebpackPlugin( {
34 | protectWebpackAssets: false,
35 | cleanAfterEveryBuildPatterns: [
36 | '*.js',
37 | ],
38 | } ),
39 | ],
40 | };
41 |
--------------------------------------------------------------------------------