CommentEditor.jsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  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. <button
  210. type="button"
  211. className="btn btn-lg btn-link center-block"
  212. onClick={() => this.setState({ isReadyToUse: true })}
  213. >
  214. <i className="icon-bubble"></i> Add Comment
  215. </button>
  216. );
  217. }
  218. renderReady() {
  219. const { appContainer, commentContainer } = this.props;
  220. const { activeTab } = this.state;
  221. const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
  222. const emojiStrategy = appContainer.getEmojiStrategy();
  223. const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
  224. const cancelButton = (
  225. <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={this.cancelButtonClickedHandler}>
  226. Cancel
  227. </Button>
  228. );
  229. const submitButton = (
  230. <Button
  231. outline
  232. color="primary"
  233. className="btn btn-outline-primary rounded-pill"
  234. onClick={this.commentButtonClickedHandler}
  235. >
  236. Comment
  237. </Button>
  238. );
  239. return (
  240. <>
  241. <div className="comment-write">
  242. <Nav tabs>
  243. <NavItem>
  244. <NavLink type="button" className={activeTab === 1 ? 'active' : ''} onClick={() => this.handleSelect(1)}>
  245. Write
  246. </NavLink>
  247. </NavItem>
  248. { this.state.isMarkdown && (
  249. <NavItem>
  250. <NavLink type="button" className={activeTab === 2 ? 'active' : ''} onClick={() => this.handleSelect(2)}>
  251. Preview
  252. </NavLink>
  253. </NavItem>
  254. ) }
  255. </Nav>
  256. <TabContent activeTab={activeTab}>
  257. <TabPane tabId={1}>
  258. <Editor
  259. ref={(c) => { this.editor = c }}
  260. value={this.state.comment}
  261. isGfmMode={this.state.isMarkdown}
  262. lineNumbers={false}
  263. isMobile={appContainer.isMobile}
  264. isUploadable={this.state.isUploadable}
  265. isUploadableFile={this.state.isUploadableFile}
  266. emojiStrategy={emojiStrategy}
  267. onChange={this.updateState}
  268. onUpload={this.uploadHandler}
  269. onCtrlEnter={this.ctrlEnterHandler}
  270. />
  271. </TabPane>
  272. <TabPane tabId={2}>
  273. <div className="comment-form-preview">
  274. {commentPreview}
  275. </div>
  276. </TabPane>
  277. </TabContent>
  278. </div>
  279. <div className="comment-submit">
  280. <div className="d-flex">
  281. <label className="mr-2">
  282. {activeTab === 1 && (
  283. <span className="custom-control custom-checkbox">
  284. <input
  285. type="checkbox"
  286. className="custom-control-input"
  287. id="comment-form-is-markdown"
  288. name="isMarkdown"
  289. checked={this.state.isMarkdown}
  290. value="1"
  291. onChange={this.updateStateCheckbox}
  292. />
  293. <label
  294. className="ml-2 custom-control-label"
  295. htmlFor="comment-form-is-markdown"
  296. >
  297. Markdown
  298. </label>
  299. </span>
  300. ) }
  301. </label>
  302. <span className="flex-grow-1" />
  303. <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
  304. { this.state.hasSlackConfig
  305. && (
  306. <div className="form-inline align-self-center mr-md-2">
  307. <SlackNotification
  308. isSlackEnabled={commentContainer.state.isSlackEnabled}
  309. slackChannels={commentContainer.state.slackChannels}
  310. onEnabledFlagChange={this.onSlackEnabledFlagChange}
  311. onChannelChange={this.onSlackChannelsChange}
  312. />
  313. </div>
  314. )
  315. }
  316. <div className="d-none d-sm-block">
  317. <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
  318. </div>
  319. </div>
  320. <div className="d-block d-sm-none mt-2">
  321. <div className="d-flex justify-content-end">
  322. { this.state.errorMessage && errorMessage }
  323. <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
  324. </div>
  325. </div>
  326. </div>
  327. </>
  328. );
  329. }
  330. render() {
  331. const { appContainer } = this.props;
  332. const { isReadyToUse } = this.state;
  333. return (
  334. <div className="form page-comment-form">
  335. <div className="comment-form">
  336. <div className="comment-form-user">
  337. <UserPicture user={appContainer.currentUser} noLink noTooltip />
  338. </div>
  339. <div className="comment-form-main">
  340. { !isReadyToUse
  341. ? this.renderBeforeReady()
  342. : this.renderReady()
  343. }
  344. </div>
  345. </div>
  346. </div>
  347. );
  348. }
  349. }
  350. CommentEditor.propTypes = {
  351. appContainer: PropTypes.instanceOf(AppContainer).isRequired,
  352. pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
  353. editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
  354. commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
  355. growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
  356. isForNewComment: PropTypes.bool,
  357. replyTo: PropTypes.string,
  358. currentCommentId: PropTypes.string,
  359. commentBody: PropTypes.string,
  360. commentCreator: PropTypes.string,
  361. onCancelButtonClicked: PropTypes.func,
  362. onCommentButtonClicked: PropTypes.func,
  363. };
  364. /**
  365. * Wrapper component for using unstated
  366. */
  367. const CommentEditorWrapper = (props) => {
  368. return createSubscribedElement(CommentEditor, props, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
  369. };
  370. export default CommentEditorWrapper;