CommentEditor.jsx 14 KB

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