CommentEditor.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import React 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. inputRef={(el) => { this.previewElement = el }}
  195. html={this.state.html}
  196. />
  197. );
  198. }
  199. renderHtml(markdown) {
  200. const context = {
  201. markdown,
  202. };
  203. const { growiRenderer } = this.props;
  204. const interceptorManager = this.props.appContainer.interceptorManager;
  205. interceptorManager.process('preRenderCommnetPreview', context)
  206. .then(() => { return interceptorManager.process('prePreProcess', context) })
  207. .then(() => {
  208. context.markdown = growiRenderer.preProcess(context.markdown, context);
  209. })
  210. .then(() => { return interceptorManager.process('postPreProcess', context) })
  211. .then(() => {
  212. const parsedHTML = growiRenderer.process(context.markdown, context);
  213. context.parsedHTML = parsedHTML;
  214. })
  215. .then(() => { return interceptorManager.process('prePostProcess', context) })
  216. .then(() => {
  217. context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
  218. })
  219. .then(() => { return interceptorManager.process('postPostProcess', context) })
  220. .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
  221. .then(() => {
  222. this.setState({ html: context.parsedHTML });
  223. })
  224. // process interceptors for post rendering
  225. .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
  226. }
  227. generateInnerHtml(html) {
  228. return { __html: html };
  229. }
  230. renderBeforeReady() {
  231. return (
  232. <div className="text-center">
  233. <NotAvailableForGuest>
  234. <button
  235. type="button"
  236. className="btn btn-lg btn-link"
  237. onClick={() => this.setState({ isReadyToUse: true })}
  238. >
  239. <i className="icon-bubble"></i> Add Comment
  240. </button>
  241. </NotAvailableForGuest>
  242. </div>
  243. );
  244. }
  245. renderReady() {
  246. const { appContainer, commentContainer, isMobile } = this.props;
  247. const { activeTab } = this.state;
  248. const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
  249. const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
  250. const cancelButton = (
  251. <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={this.cancelButtonClickedHandler}>
  252. Cancel
  253. </Button>
  254. );
  255. const submitButton = (
  256. <Button
  257. outline
  258. color="primary"
  259. className="btn btn-outline-primary rounded-pill"
  260. onClick={this.commentButtonClickedHandler}
  261. >
  262. Comment
  263. </Button>
  264. );
  265. return (
  266. <>
  267. <div className="comment-write">
  268. <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={this.handleSelect} hideBorderBottom />
  269. <TabContent activeTab={activeTab}>
  270. <TabPane tabId="comment_editor">
  271. <Editor
  272. ref={(c) => { this.editor = c }}
  273. value={this.state.comment}
  274. isGfmMode={this.state.isMarkdown}
  275. lineNumbers={false}
  276. isMobile={isMobile}
  277. isUploadable={this.state.isUploadable}
  278. isUploadableFile={this.state.isUploadableFile}
  279. onChange={this.updateState}
  280. onUpload={this.uploadHandler}
  281. onCtrlEnter={this.ctrlEnterHandler}
  282. isComment
  283. />
  284. {/*
  285. Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
  286. See a review comment in https://github.com/weseek/growi/pull/3473
  287. */}
  288. </TabPane>
  289. <TabPane tabId="comment_preview">
  290. <div className="comment-form-preview">
  291. {commentPreview}
  292. </div>
  293. </TabPane>
  294. </TabContent>
  295. </div>
  296. <div className="comment-submit">
  297. <div className="d-flex">
  298. <label className="mr-2">
  299. {activeTab === 'comment_editor' && (
  300. <span className="custom-control custom-checkbox">
  301. <input
  302. type="checkbox"
  303. className="custom-control-input"
  304. id="comment-form-is-markdown"
  305. name="isMarkdown"
  306. checked={this.state.isMarkdown}
  307. value="1"
  308. onChange={this.updateStateCheckbox}
  309. />
  310. <label
  311. className="ml-2 custom-control-label"
  312. htmlFor="comment-form-is-markdown"
  313. >
  314. Markdown
  315. </label>
  316. </span>
  317. ) }
  318. </label>
  319. <span className="flex-grow-1" />
  320. <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
  321. { this.state.isSlackConfigured
  322. && (
  323. <div className="form-inline align-self-center mr-md-2">
  324. <SlackNotification
  325. isSlackEnabled={this.props.isSlackEnabled}
  326. slackChannels={this.state.slackChannels}
  327. onEnabledFlagChange={this.props.onSlackEnabledFlagChange}
  328. onChannelChange={this.onSlackChannelsChange}
  329. id="idForComment"
  330. />
  331. </div>
  332. )
  333. }
  334. <div className="d-none d-sm-block">
  335. <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
  336. </div>
  337. </div>
  338. <div className="d-block d-sm-none mt-2">
  339. <div className="d-flex justify-content-end">
  340. { this.state.errorMessage && errorMessage }
  341. <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
  342. </div>
  343. </div>
  344. </div>
  345. </>
  346. );
  347. }
  348. render() {
  349. const { currentUser } = this.props;
  350. const { isReadyToUse } = this.state;
  351. return (
  352. <div className="form page-comment-form">
  353. <div className="comment-form">
  354. <div className="comment-form-user">
  355. <UserPicture user={currentUser} noLink noTooltip />
  356. </div>
  357. <div className="comment-form-main">
  358. { !isReadyToUse
  359. ? this.renderBeforeReady()
  360. : this.renderReady()
  361. }
  362. </div>
  363. </div>
  364. </div>
  365. );
  366. }
  367. }
  368. /**
  369. * Wrapper component for using unstated
  370. */
  371. const CommentEditorHOCWrapper = withUnstatedContainers(CommentEditor, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
  372. CommentEditor.propTypes = {
  373. appContainer: PropTypes.instanceOf(AppContainer).isRequired,
  374. pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
  375. editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
  376. commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
  377. slackChannels: PropTypes.string.isRequired,
  378. isSlackEnabled: PropTypes.bool.isRequired,
  379. growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
  380. currentUser: PropTypes.instanceOf(Object),
  381. isMobile: PropTypes.bool,
  382. isForNewComment: PropTypes.bool,
  383. replyTo: PropTypes.string,
  384. currentCommentId: PropTypes.string,
  385. commentBody: PropTypes.string,
  386. commentCreator: PropTypes.string,
  387. onCancelButtonClicked: PropTypes.func,
  388. onCommentButtonClicked: PropTypes.func,
  389. onSlackEnabledFlagChange: PropTypes.func,
  390. };
  391. const CommentEditorWrapper = (props) => {
  392. const { data: isMobile } = useIsMobile();
  393. const { data: currentUser } = useCurrentUser();
  394. const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
  395. const { data: currentPagePath } = useCurrentPagePath();
  396. const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
  397. const onSlackEnabledFlagChange = (isSlackEnabled) => {
  398. mutateIsSlackEnabled(isSlackEnabled, false);
  399. };
  400. return (
  401. <CommentEditorHOCWrapper
  402. {...props}
  403. onSlackEnabledFlagChange={onSlackEnabledFlagChange}
  404. slackChannels={slackChannelsData.toString()}
  405. isSlackEnabled={isSlackEnabled}
  406. currentUser={currentUser}
  407. isMobile={isMobile}
  408. />
  409. );
  410. };
  411. export default CommentEditorWrapper;