No Description

RichTextEditor.js 18KB

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