Geen omschrijving

RichTextEditor.js 19KB

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