CommentEditor.jsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import {
  4. Button,
  5. TabContent, TabPane, Nav, NavItem, NavLink,
  6. } from 'reactstrap';
  7. import * as toastr from 'toastr';
  8. import AppContainer from '../../services/AppContainer';
  9. import PageContainer from '../../services/PageContainer';
  10. import CommentContainer from '../../services/CommentContainer';
  11. import EditorContainer from '../../services/EditorContainer';
  12. import GrowiRenderer from '../../util/GrowiRenderer';
  13. import { createSubscribedElement } from '../UnstatedUtils';
  14. import UserPicture from '../User/UserPicture';
  15. import Editor from '../PageEditor/Editor';
  16. import SlackNotification from '../SlackNotification';
  17. import CommentPreview from './CommentPreview';
  18. /**
  19. *
  20. * @author Yuki Takei <yuki@weseek.co.jp>
  21. *
  22. * @extends {React.Component}
  23. */
  24. class CommentEditor extends React.Component {
  25. constructor(props) {
  26. super(props);
  27. const config = this.props.appContainer.getConfig();
  28. const isUploadable = config.upload.image || config.upload.file;
  29. const isUploadableFile = config.upload.file;
  30. this.state = {
  31. isReadyToUse: !this.props.isForNewComment,
  32. comment: this.props.commentBody || '',
  33. isMarkdown: true,
  34. html: '',
  35. activeTab: 1,
  36. isUploadable,
  37. isUploadableFile,
  38. errorMessage: undefined,
  39. hasSlackConfig: config.hasSlackConfig,
  40. };
  41. this.updateState = this.updateState.bind(this);
  42. this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
  43. this.cancelButtonClickedHandler = this.cancelButtonClickedHandler.bind(this);
  44. this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
  45. this.ctrlEnterHandler = this.ctrlEnterHandler.bind(this);
  46. this.postComment = this.postComment.bind(this);
  47. this.uploadHandler = this.uploadHandler.bind(this);
  48. this.renderHtml = this.renderHtml.bind(this);
  49. this.handleSelect = this.handleSelect.bind(this);
  50. this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
  51. this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
  52. }
  53. updateState(value) {
  54. this.setState({ comment: value });
  55. }
  56. updateStateCheckbox(event) {
  57. const value = event.target.checked;
  58. this.setState({ isMarkdown: value });
  59. // changeMode
  60. this.editor.setGfmMode(value);
  61. }
  62. handleSelect(activeTab) {
  63. this.setState({ activeTab });
  64. this.renderHtml(this.state.comment);
  65. }
  66. onSlackEnabledFlagChange(isSlackEnabled) {
  67. this.props.commentContainer.setState({ isSlackEnabled });
  68. }
  69. onSlackChannelsChange(slackChannels) {
  70. this.props.commentContainer.setState({ slackChannels });
  71. }
  72. initializeEditor() {
  73. this.setState({
  74. comment: '',
  75. isMarkdown: true,
  76. html: '',
  77. activeTab: 1,
  78. errorMessage: undefined,
  79. });
  80. // reset value
  81. this.editor.setValue('');
  82. }
  83. cancelButtonClickedHandler() {
  84. const { isForNewComment, onCancelButtonClicked } = this.props;
  85. // change state to not ready
  86. // when this editor is for the new comment mode
  87. if (isForNewComment) {
  88. this.setState({ isReadyToUse: false });
  89. }
  90. if (onCancelButtonClicked != null) {
  91. const { replyTo, currentCommentId } = this.props;
  92. onCancelButtonClicked(replyTo || currentCommentId);
  93. }
  94. }
  95. commentButtonClickedHandler() {
  96. this.postComment();
  97. }
  98. ctrlEnterHandler(event) {
  99. if (event != null) {
  100. event.preventDefault();
  101. }
  102. this.postComment();
  103. }
  104. /**
  105. * Post comment with CommentContainer and update state
  106. */
  107. async postComment() {
  108. const {
  109. commentContainer, replyTo, currentCommentId, commentCreator, onCommentButtonClicked,
  110. } = this.props;
  111. try {
  112. if (currentCommentId != null) {
  113. await commentContainer.putComment(
  114. this.state.comment,
  115. this.state.isMarkdown,
  116. currentCommentId,
  117. commentCreator,
  118. );
  119. }
  120. else {
  121. await this.props.commentContainer.postComment(
  122. this.state.comment,
  123. this.state.isMarkdown,
  124. replyTo,
  125. commentContainer.state.isSlackEnabled,
  126. commentContainer.state.slackChannels,
  127. );
  128. }
  129. this.initializeEditor();
  130. if (onCommentButtonClicked != null) {
  131. onCommentButtonClicked(replyTo || currentCommentId);
  132. }
  133. }
  134. catch (err) {
  135. const errorMessage = err.message || 'An unknown error occured when posting comment';
  136. this.setState({ errorMessage });
  137. }
  138. }
  139. uploadHandler(file) {
  140. this.props.commentContainer.uploadAttachment(file)
  141. .then((res) => {
  142. const attachment = res.attachment;
  143. const fileName = attachment.originalName;
  144. let insertText = `[${fileName}](${attachment.filePathProxied})`;
  145. // when image
  146. if (attachment.fileFormat.startsWith('image/')) {
  147. // modify to "![fileName](url)" syntax
  148. insertText = `!${insertText}`;
  149. }
  150. this.editor.insertText(insertText);
  151. })
  152. .catch(this.apiErrorHandler)
  153. // finally
  154. .then(() => {
  155. this.editor.terminateUploadingState();
  156. });
  157. }
  158. apiErrorHandler(error) {
  159. toastr.error(error.message, 'Error occured', {
  160. closeButton: true,
  161. progressBar: true,
  162. newestOnTop: false,
  163. showDuration: '100',
  164. hideDuration: '100',
  165. timeOut: '3000',
  166. });
  167. }
  168. getCommentHtml() {
  169. return (
  170. <CommentPreview
  171. inputRef={(el) => { this.previewElement = el }}
  172. html={this.state.html}
  173. />
  174. );
  175. }
  176. renderHtml(markdown) {
  177. const context = {
  178. markdown,
  179. };
  180. const { growiRenderer } = this.props;
  181. const interceptorManager = this.props.appContainer.interceptorManager;
  182. interceptorManager.process('preRenderCommnetPreview', context)
  183. .then(() => { return interceptorManager.process('prePreProcess', context) })
  184. .then(() => {
  185. context.markdown = growiRenderer.preProcess(context.markdown);
  186. })
  187. .then(() => { return interceptorManager.process('postPreProcess', context) })
  188. .then(() => {
  189. const parsedHTML = growiRenderer.process(context.markdown);
  190. context.parsedHTML = parsedHTML;
  191. })
  192. .then(() => { return interceptorManager.process('prePostProcess', context) })
  193. .then(() => {
  194. context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
  195. })
  196. .then(() => { return interceptorManager.process('postPostProcess', context) })
  197. .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
  198. .then(() => {
  199. this.setState({ html: context.parsedHTML });
  200. })
  201. // process interceptors for post rendering
  202. .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
  203. }
  204. generateInnerHtml(html) {
  205. return { __html: html };
  206. }
  207. renderBeforeReady() {
  208. return (
  209. <div className="text-center">
  210. <button
  211. type="button"
  212. className="btn btn-lg btn-link"
  213. onClick={() => this.setState({ isReadyToUse: true })}
  214. >
  215. <i className="icon-bubble"></i> Add Comment
  216. </button>
  217. </div>
  218. );
  219. }
  220. renderReady() {
  221. const { appContainer, commentContainer } = this.props;
  222. const { activeTab } = this.state;
  223. const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
  224. const emojiStrategy = appContainer.getEmojiStrategy();
  225. const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
  226. const cancelButton = (
  227. <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={this.cancelButtonClickedHandler}>
  228. Cancel
  229. </Button>
  230. );
  231. const submitButton = (
  232. <Button
  233. outline
  234. color="primary"
  235. className="btn btn-outline-primary rounded-pill"
  236. onClick={this.commentButtonClickedHandler}
  237. >
  238. Comment
  239. </Button>
  240. );
  241. return (
  242. <>
  243. <div className="comment-write">
  244. <Nav tabs>
  245. <NavItem>
  246. <NavLink type="button" className={activeTab === 1 ? 'active' : ''} onClick={() => this.handleSelect(1)}>
  247. Write
  248. </NavLink>
  249. </NavItem>
  250. { this.state.isMarkdown && (
  251. <NavItem>
  252. <NavLink type="button" className={activeTab === 2 ? 'active' : ''} onClick={() => this.handleSelect(2)}>
  253. Preview
  254. </NavLink>
  255. </NavItem>
  256. ) }
  257. </Nav>
  258. <TabContent activeTab={activeTab}>
  259. <TabPane tabId={1}>
  260. <Editor
  261. ref={(c) => { this.editor = c }}
  262. value={this.state.comment}
  263. isGfmMode={this.state.isMarkdown}
  264. lineNumbers={false}
  265. isMobile={appContainer.isMobile}
  266. isUploadable={this.state.isUploadable}
  267. isUploadableFile={this.state.isUploadableFile}
  268. emojiStrategy={emojiStrategy}
  269. onChange={this.updateState}
  270. onUpload={this.uploadHandler}
  271. onCtrlEnter={this.ctrlEnterHandler}
  272. />
  273. </TabPane>
  274. <TabPane tabId={2}>
  275. <div className="comment-form-preview">
  276. {commentPreview}
  277. </div>
  278. </TabPane>
  279. </TabContent>
  280. </div>
  281. <div className="comment-submit">
  282. <div className="d-flex">
  283. <label className="mr-2">
  284. {activeTab === 1 && (
  285. <span className="custom-control custom-checkbox">
  286. <input
  287. type="checkbox"
  288. className="custom-control-input"
  289. id="comment-form-is-markdown"
  290. name="isMarkdown"
  291. checked={this.state.isMarkdown}
  292. value="1"
  293. onChange={this.updateStateCheckbox}
  294. />
  295. <label
  296. className="ml-2 custom-control-label"
  297. htmlFor="comment-form-is-markdown"
  298. >
  299. Markdown
  300. </label>
  301. </span>
  302. ) }
  303. </label>
  304. <span className="flex-grow-1" />
  305. <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
  306. { this.state.hasSlackConfig
  307. && (
  308. <div className="form-inline align-self-center mr-md-2">
  309. <SlackNotification
  310. isSlackEnabled={commentContainer.state.isSlackEnabled}
  311. slackChannels={commentContainer.state.slackChannels}
  312. onEnabledFlagChange={this.onSlackEnabledFlagChange}
  313. onChannelChange={this.onSlackChannelsChange}
  314. />
  315. </div>
  316. )
  317. }
  318. <div className="d-none d-sm-block">
  319. <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
  320. </div>
  321. </div>
  322. <div className="d-block d-sm-none mt-2">
  323. <div className="d-flex justify-content-end">
  324. { this.state.errorMessage && errorMessage }
  325. <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
  326. </div>
  327. </div>
  328. </div>
  329. </>
  330. );
  331. }
  332. render() {
  333. const { appContainer } = this.props;
  334. const { isReadyToUse } = this.state;
  335. return (
  336. <div className="form page-comment-form">
  337. <div className="comment-form">
  338. <div className="comment-form-user">
  339. <UserPicture user={appContainer.currentUser} noLink noTooltip />
  340. </div>
  341. <div className="comment-form-main">
  342. { !isReadyToUse
  343. ? this.renderBeforeReady()
  344. : this.renderReady()
  345. }
  346. </div>
  347. </div>
  348. </div>
  349. );
  350. }
  351. }
  352. CommentEditor.propTypes = {
  353. appContainer: PropTypes.instanceOf(AppContainer).isRequired,
  354. pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
  355. editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
  356. commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
  357. growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
  358. isForNewComment: PropTypes.bool,
  359. replyTo: PropTypes.string,
  360. currentCommentId: PropTypes.string,
  361. commentBody: PropTypes.string,
  362. commentCreator: PropTypes.string,
  363. onCancelButtonClicked: PropTypes.func,
  364. onCommentButtonClicked: PropTypes.func,
  365. };
  366. /**
  367. * Wrapper component for using unstated
  368. */
  369. const CommentEditorWrapper = (props) => {
  370. return createSubscribedElement(CommentEditor, props, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
  371. };
  372. export default CommentEditorWrapper;