CommentEditor.jsx 13 KB

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