You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

RichTextEditor.js 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. import React, {Component} from 'react';
  2. import PropTypes from 'prop-types';
  3. // import WebViewBridge from 'react-native-webview-bridge-updated';
  4. import WebView from 'react-native-webview';
  5. import {MessageConverter} from './WebviewMessageHandler';
  6. import {actions, messages} from './const';
  7. import {Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, Platform, PixelRatio, Keyboard, Dimensions} from 'react-native';
  8. // const injectScript = `
  9. // (function () {
  10. // ${InjectedMessageHandler}
  11. // }());
  12. // `;
  13. const PlatformIOS = Platform.OS === 'ios';
  14. export default class RichTextEditor extends Component {
  15. static propTypes = {
  16. initialTitleHTML: PropTypes.string,
  17. initialContentHTML: PropTypes.string,
  18. titlePlaceholder: PropTypes.string,
  19. contentPlaceholder: PropTypes.string,
  20. editorInitializedCallback: PropTypes.func,
  21. customCSS: PropTypes.string,
  22. hiddenTitle: PropTypes.bool,
  23. enableOnChange: PropTypes.bool,
  24. footerHeight: PropTypes.number,
  25. contentInset: PropTypes.object
  26. };
  27. static defaultProps = {
  28. contentInset: {},
  29. style: {}
  30. };
  31. constructor(props) {
  32. super(props);
  33. this._sendAction = this._sendAction.bind(this);
  34. this.registerToolbar = this.registerToolbar.bind(this);
  35. this.onMessage = this.onMessage.bind(this);
  36. this._onKeyboardWillShow = this._onKeyboardWillShow.bind(this);
  37. this._onKeyboardWillHide = this._onKeyboardWillHide.bind(this);
  38. this.state = {
  39. selectionChangeListeners: [],
  40. onChange: [],
  41. showLinkDialog: false,
  42. linkInitialUrl: '',
  43. linkTitle: '',
  44. linkUrl: '',
  45. keyboardHeight: 0
  46. };
  47. this._selectedTextChangeListeners = [];
  48. }
  49. componentDidMount() {
  50. if(PlatformIOS) {
  51. this.keyboardEventListeners = [
  52. Keyboard.addListener('keyboardWillShow', this._onKeyboardWillShow),
  53. Keyboard.addListener('keyboardWillHide', this._onKeyboardWillHide)
  54. ];
  55. } else {
  56. this.keyboardEventListeners = [
  57. Keyboard.addListener('keyboardDidShow', this._onKeyboardWillShow),
  58. Keyboard.addListener('keyboardDidHide', this._onKeyboardWillHide)
  59. ];
  60. }
  61. }
  62. componentWillUnmount() {
  63. this.keyboardEventListeners.forEach((eventListener) => eventListener.remove());
  64. }
  65. _onKeyboardWillShow(event) {
  66. console.log('!!!!', event);
  67. const newKeyboardHeight = event.endCoordinates.height;
  68. if (this.state.keyboardHeight === newKeyboardHeight) {
  69. return;
  70. }
  71. if (newKeyboardHeight) {
  72. this.setEditorAvailableHeightBasedOnKeyboardHeight(newKeyboardHeight);
  73. }
  74. this.setState({keyboardHeight: newKeyboardHeight});
  75. }
  76. _onKeyboardWillHide(event) {
  77. this.setState({keyboardHeight: 0});
  78. }
  79. setEditorAvailableHeightBasedOnKeyboardHeight(keyboardHeight) {
  80. const {top = 0, bottom = 0} = this.props.contentInset;
  81. const {marginTop = 0, marginBottom = 0} = this.props.style;
  82. const spacing = marginTop + marginBottom + top + bottom;
  83. const editorAvailableHeight = Dimensions.get('window').height - keyboardHeight - spacing;
  84. this.setEditorHeight(editorAvailableHeight);
  85. }
  86. onMessage({ nativeEvent }){
  87. const { data: str } = nativeEvent;
  88. try {
  89. const message = JSON.parse(str);
  90. switch (message.type) {
  91. case messages.TITLE_HTML_RESPONSE:
  92. if (this.titleResolve) {
  93. this.titleResolve(message.data);
  94. this.titleResolve = undefined;
  95. this.titleReject = undefined;
  96. if (this.pendingTitleHtml) {
  97. clearTimeout(this.pendingTitleHtml);
  98. this.pendingTitleHtml = undefined;
  99. }
  100. }
  101. break;
  102. case messages.TITLE_TEXT_RESPONSE:
  103. if (this.titleTextResolve) {
  104. this.titleTextResolve(message.data);
  105. this.titleTextResolve = undefined;
  106. this.titleTextReject = undefined;
  107. if (this.pendingTitleText) {
  108. clearTimeout(this.pendingTitleText);
  109. this.pendingTitleText = undefined;
  110. }
  111. }
  112. break;
  113. case messages.CONTENT_HTML_RESPONSE:
  114. if (this.contentResolve) {
  115. this.contentResolve(message.data);
  116. this.contentResolve = undefined;
  117. this.contentReject = undefined;
  118. if (this.pendingContentHtml) {
  119. clearTimeout(this.pendingContentHtml);
  120. this.pendingContentHtml = undefined;
  121. }
  122. }
  123. break;
  124. case messages.SELECTED_TEXT_RESPONSE:
  125. if (this.selectedTextResolve) {
  126. this.selectedTextResolve(message.data);
  127. this.selectedTextResolve = undefined;
  128. this.selectedTextReject = undefined;
  129. if (this.pendingSelectedText) {
  130. clearTimeout(this.pendingSelectedText);
  131. this.pendingSelectedText = undefined;
  132. }
  133. }
  134. break;
  135. case messages.ZSS_INITIALIZED:
  136. if (this.props.customCSS) {
  137. this.setCustomCSS(this.props.customCSS);
  138. }
  139. this.setTitlePlaceholder(this.props.titlePlaceholder);
  140. this.setContentPlaceholder(this.props.contentPlaceholder);
  141. this.setTitleHTML(this.props.initialTitleHTML || '');
  142. this.setContentHTML(this.props.initialContentHTML || '');
  143. this.props.hiddenTitle && this.hideTitle();
  144. this.props.enableOnChange && this.enableOnChange();
  145. this.props.editorInitializedCallback && this.props.editorInitializedCallback();
  146. break;
  147. case messages.LINK_TOUCHED:
  148. this.prepareInsert();
  149. const {title, url} = message.data;
  150. this.showLinkDialog(title, url);
  151. break;
  152. case messages.LOG:
  153. console.log('FROM ZSS', message.data);
  154. break;
  155. case messages.SCROLL:
  156. this.webview.setNativeProps({contentOffset: {y: message.data}});
  157. break;
  158. case messages.TITLE_FOCUSED:
  159. this.titleFocusHandler && this.titleFocusHandler();
  160. break;
  161. case messages.CONTENT_FOCUSED:
  162. this.contentFocusHandler && this.contentFocusHandler();
  163. break;
  164. case messages.SELECTION_CHANGE: {
  165. const items = message.data.items;
  166. this.state.selectionChangeListeners.map((listener) => {
  167. listener(items);
  168. });
  169. break;
  170. }
  171. case messages.CONTENT_CHANGE: {
  172. const content = message.data.content;
  173. this.state.onChange.map((listener) => listener(content));
  174. break;
  175. }
  176. case messages.SELECTED_TEXT_CHANGED: {
  177. const selectedText = message.data;
  178. this._selectedTextChangeListeners.forEach((listener) => {
  179. listener(selectedText);
  180. });
  181. break;
  182. }
  183. }
  184. } catch(e) {
  185. //alert('NON JSON MESSAGE');
  186. }
  187. }
  188. _renderLinkModal() {
  189. return (
  190. <Modal
  191. animationType={"fade"}
  192. transparent
  193. visible={this.state.showLinkDialog}
  194. onRequestClose={() => this.setState({showLinkDialog: false})}
  195. >
  196. <View style={styles.modal}>
  197. <View style={[styles.innerModal, {marginBottom: PlatformIOS ? this.state.keyboardHeight : 0}]}>
  198. <Text style={styles.inputTitle}>Title</Text>
  199. <View style={styles.inputWrapper}>
  200. <TextInput
  201. style={styles.input}
  202. onChangeText={(text) => this.setState({linkTitle: text})}
  203. value={this.state.linkTitle}
  204. />
  205. </View>
  206. <Text style={[styles.inputTitle ,{marginTop: 10}]}>URL</Text>
  207. <View style={styles.inputWrapper}>
  208. <TextInput
  209. style={styles.input}
  210. onChangeText={(text) => this.setState({linkUrl: text})}
  211. value={this.state.linkUrl}
  212. keyboardType="url"
  213. autoCapitalize="none"
  214. autoCorrect={false}
  215. />
  216. </View>
  217. {PlatformIOS && <View style={styles.lineSeparator}/>}
  218. {this._renderModalButtons()}
  219. </View>
  220. </View>
  221. </Modal>
  222. );
  223. }
  224. _hideModal() {
  225. this.setState({
  226. showLinkDialog: false,
  227. linkInitialUrl: '',
  228. linkTitle: '',
  229. linkUrl: ''
  230. })
  231. }
  232. _renderModalButtons() {
  233. const insertUpdateDisabled = this.state.linkTitle.trim().length <= 0 || this.state.linkUrl.trim().length <= 0;
  234. const containerPlatformStyle = PlatformIOS ? {justifyContent: 'space-between'} : {paddingTop: 15};
  235. const buttonPlatformStyle = PlatformIOS ? {flex: 1, height: 45, justifyContent: 'center'} : {};
  236. return (
  237. <View style={[{alignSelf: 'stretch', flexDirection: 'row'}, containerPlatformStyle]}>
  238. {!PlatformIOS && <View style={{flex: 1}}/>}
  239. <TouchableOpacity
  240. onPress={() => this._hideModal()}
  241. style={buttonPlatformStyle}
  242. >
  243. <Text style={[styles.button, {paddingRight: 10}]}>
  244. {this._upperCaseButtonTextIfNeeded('Cancel')}
  245. </Text>
  246. </TouchableOpacity>
  247. <TouchableOpacity
  248. onPress={() => {
  249. if (this._linkIsNew()) {
  250. this.insertLink(this.state.linkUrl, this.state.linkTitle);
  251. } else {
  252. this.updateLink(this.state.linkUrl, this.state.linkTitle);
  253. }
  254. this._hideModal();
  255. }}
  256. disabled={insertUpdateDisabled}
  257. style={buttonPlatformStyle}
  258. >
  259. <Text style={[styles.button, {opacity: insertUpdateDisabled ? 0.5 : 1}]}>
  260. {this._upperCaseButtonTextIfNeeded(this._linkIsNew() ? 'Insert' : 'Update')}
  261. </Text>
  262. </TouchableOpacity>
  263. </View>
  264. );
  265. }
  266. _linkIsNew() {
  267. return !this.state.linkInitialUrl;
  268. }
  269. _upperCaseButtonTextIfNeeded(buttonText) {
  270. return PlatformIOS ? buttonText : buttonText.toUpperCase();
  271. }
  272. render() {
  273. //in release build, external html files in Android can't be required, so they must be placed in the assets folder and accessed via uri
  274. const pageSource = PlatformIOS ? require('./editor.html') : { uri: 'file:///android_asset/editor.html' };
  275. return (
  276. <View style={{flex: 1}}>
  277. <WebView
  278. {...this.props}
  279. hideKeyboardAccessoryView={true}
  280. keyboardDisplayRequiresUserAction={false}
  281. ref={(r) => {this.webview = r}}
  282. onMessage={(message) => this.onMessage(message)}
  283. // injectedJavaScript={injectScript}
  284. source={pageSource}
  285. onLoad={() => this.init()}
  286. />
  287. {this._renderLinkModal()}
  288. </View>
  289. );
  290. }
  291. escapeJSONString = function(string) {
  292. return string
  293. .replace(/[\\]/g, '\\\\')
  294. .replace(/[\"]/g, '\\\"')
  295. .replace(/[\']/g, '\\\'')
  296. .replace(/[\/]/g, '\\/')
  297. .replace(/[\b]/g, '\\b')
  298. .replace(/[\f]/g, '\\f')
  299. .replace(/[\n]/g, '\\n')
  300. .replace(/[\r]/g, '\\r')
  301. .replace(/[\t]/g, '\\t');
  302. };
  303. _sendAction(action, data) {
  304. let jsToBeExecutedOnPage = MessageConverter({ type: action, data });
  305. this.webview.injectJavaScript(jsToBeExecutedOnPage + ';true;');
  306. }
  307. //-------------------------------------------------------------------------------
  308. //--------------- Public API
  309. showLinkDialog(optionalTitle = '', optionalUrl = '') {
  310. this.setState({
  311. linkInitialUrl: optionalUrl,
  312. linkTitle: optionalTitle,
  313. linkUrl: optionalUrl,
  314. showLinkDialog: true
  315. });
  316. }
  317. focusTitle() {
  318. this._sendAction(actions.focusTitle);
  319. }
  320. focusContent() {
  321. this._sendAction(actions.focusContent);
  322. }
  323. registerToolbar(listener) {
  324. this.setState({
  325. selectionChangeListeners: [...this.state.selectionChangeListeners, listener]
  326. });
  327. }
  328. enableOnChange() {
  329. this._sendAction(actions.enableOnChange);
  330. }
  331. registerContentChangeListener(listener) {
  332. this.setState({
  333. onChange: [...this.state.onChange, listener]
  334. });
  335. }
  336. setTitleHTML(html) {
  337. this._sendAction(actions.setTitleHtml, html);
  338. }
  339. hideTitle() {
  340. this._sendAction(actions.hideTitle);
  341. }
  342. showTitle() {
  343. this._sendAction(actions.showTitle);
  344. }
  345. toggleTitle() {
  346. this._sendAction(actions.toggleTitle);
  347. }
  348. setContentHTML(html) {
  349. this._sendAction(actions.setContentHtml, html);
  350. }
  351. blurTitleEditor() {
  352. this._sendAction(actions.blurTitleEditor);
  353. }
  354. blurContentEditor() {
  355. this._sendAction(actions.blurContentEditor);
  356. }
  357. setBold() {
  358. this._sendAction(actions.setBold);
  359. }
  360. setItalic() {
  361. this._sendAction(actions.setItalic);
  362. }
  363. setUnderline() {
  364. this._sendAction(actions.setUnderline);
  365. }
  366. heading1() {
  367. this._sendAction(actions.heading1);
  368. }
  369. heading2() {
  370. this._sendAction(actions.heading2);
  371. }
  372. heading3() {
  373. this._sendAction(actions.heading3);
  374. }
  375. heading4() {
  376. this._sendAction(actions.heading4);
  377. }
  378. heading5() {
  379. this._sendAction(actions.heading5);
  380. }
  381. heading6() {
  382. this._sendAction(actions.heading6);
  383. }
  384. setParagraph() {
  385. this._sendAction(actions.setParagraph);
  386. }
  387. removeFormat() {
  388. this._sendAction(actions.removeFormat);
  389. }
  390. alignLeft() {
  391. this._sendAction(actions.alignLeft);
  392. }
  393. alignCenter() {
  394. this._sendAction(actions.alignCenter);
  395. }
  396. alignRight() {
  397. this._sendAction(actions.alignRight);
  398. }
  399. alignFull() {
  400. this._sendAction(actions.alignFull);
  401. }
  402. insertBulletsList() {
  403. this._sendAction(actions.insertBulletsList);
  404. }
  405. insertOrderedList() {
  406. this._sendAction(actions.insertOrderedList);
  407. }
  408. insertLink(url, title) {
  409. this._sendAction(actions.insertLink, {url, title});
  410. }
  411. updateLink(url, title) {
  412. this._sendAction(actions.updateLink, {url, title});
  413. }
  414. insertImage(attributes) {
  415. this._sendAction(actions.insertImage, attributes);
  416. this.prepareInsert(); //This must be called BEFORE insertImage. But WebViewBridge uses a stack :/
  417. }
  418. setSubscript() {
  419. this._sendAction(actions.setSubscript);
  420. }
  421. setSuperscript() {
  422. this._sendAction(actions.setSuperscript);
  423. }
  424. setStrikethrough() {
  425. this._sendAction(actions.setStrikethrough);
  426. }
  427. setHR() {
  428. this._sendAction(actions.setHR);
  429. }
  430. setIndent() {
  431. this._sendAction(actions.setIndent);
  432. }
  433. setOutdent() {
  434. this._sendAction(actions.setOutdent);
  435. }
  436. setBackgroundColor(color) {
  437. this._sendAction(actions.setBackgroundColor, color);
  438. }
  439. setTextColor(color) {
  440. this._sendAction(actions.setTextColor, color);
  441. }
  442. setTitlePlaceholder(placeholder) {
  443. this._sendAction(actions.setTitlePlaceholder, placeholder);
  444. }
  445. setContentPlaceholder(placeholder) {
  446. this._sendAction(actions.setContentPlaceholder, placeholder);
  447. }
  448. setCustomCSS(css) {
  449. this._sendAction(actions.setCustomCSS, css);
  450. }
  451. prepareInsert() {
  452. this._sendAction(actions.prepareInsert);
  453. }
  454. restoreSelection() {
  455. this._sendAction(actions.restoreSelection);
  456. }
  457. init() {
  458. this._sendAction(actions.init);
  459. this.setPlatform();
  460. if (this.props.footerHeight) {
  461. this.setFooterHeight();
  462. }
  463. }
  464. setEditorHeight(height) {
  465. this._sendAction(actions.setEditorHeight, height);
  466. }
  467. setFooterHeight() {
  468. this._sendAction(actions.setFooterHeight, this.props.footerHeight);
  469. }
  470. setPlatform() {
  471. this._sendAction(actions.setPlatform, Platform.OS);
  472. }
  473. async getTitleHtml() {
  474. return new Promise((resolve, reject) => {
  475. this.titleResolve = resolve;
  476. this.titleReject = reject;
  477. this._sendAction(actions.getTitleHtml);
  478. this.pendingTitleHtml = setTimeout(() => {
  479. if (this.titleReject) {
  480. this.titleReject('timeout')
  481. }
  482. }, 5000);
  483. });
  484. }
  485. async getTitleText() {
  486. return new Promise((resolve, reject) => {
  487. this.titleTextResolve = resolve;
  488. this.titleTextReject = reject;
  489. this._sendAction(actions.getTitleText);
  490. this.pendingTitleText = setTimeout(() => {
  491. if (this.titleTextReject) {
  492. this.titleTextReject('timeout');
  493. }
  494. }, 5000);
  495. });
  496. }
  497. async getContentHtml() {
  498. return new Promise((resolve, reject) => {
  499. this.contentResolve = resolve;
  500. this.contentReject = reject;
  501. this._sendAction(actions.getContentHtml);
  502. this.pendingContentHtml = setTimeout(() => {
  503. if (this.contentReject) {
  504. this.contentReject('timeout')
  505. }
  506. }, 5000);
  507. });
  508. }
  509. async getSelectedText() {
  510. return new Promise((resolve, reject) => {
  511. this.selectedTextResolve = resolve;
  512. this.selectedTextReject = reject;
  513. this._sendAction(actions.getSelectedText);
  514. this.pendingSelectedText = setTimeout(() => {
  515. if (this.selectedTextReject) {
  516. this.selectedTextReject('timeout')
  517. }
  518. }, 5000);
  519. });
  520. }
  521. setTitleFocusHandler(callbackHandler) {
  522. this.titleFocusHandler = callbackHandler;
  523. this._sendAction(actions.setTitleFocusHandler);
  524. }
  525. setContentFocusHandler(callbackHandler) {
  526. this.contentFocusHandler = callbackHandler;
  527. this._sendAction(actions.setContentFocusHandler);
  528. }
  529. addSelectedTextChangeListener(listener) {
  530. this._selectedTextChangeListeners.push(listener);
  531. }
  532. }
  533. const styles = StyleSheet.create({
  534. modal: {
  535. flex: 1,
  536. justifyContent: 'center',
  537. alignItems: 'center',
  538. backgroundColor: 'rgba(0, 0, 0, 0.5)'
  539. },
  540. innerModal: {
  541. backgroundColor: 'rgba(255, 255, 255, 0.9)',
  542. paddingTop: 20,
  543. paddingBottom: PlatformIOS ? 0 : 20,
  544. paddingLeft: 20,
  545. paddingRight: 20,
  546. alignSelf: 'stretch',
  547. margin: 40,
  548. borderRadius: PlatformIOS ? 8 : 2
  549. },
  550. button: {
  551. fontSize: 16,
  552. color: '#4a4a4a',
  553. textAlign: 'center'
  554. },
  555. inputWrapper: {
  556. marginTop: 5,
  557. marginBottom: 10,
  558. borderBottomColor: '#4a4a4a',
  559. borderBottomWidth: PlatformIOS ? 1 / PixelRatio.get() : 0
  560. },
  561. inputTitle: {
  562. color: '#4a4a4a'
  563. },
  564. input: {
  565. height: PlatformIOS ? 20 : 40,
  566. paddingTop: 0
  567. },
  568. lineSeparator: {
  569. height: 1 / PixelRatio.get(),
  570. backgroundColor: '#d5d5d5',
  571. marginLeft: -20,
  572. marginRight: -20,
  573. marginTop: 20
  574. }
  575. });