CommentEditor.jsx 13 KB

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