CommentEditor.jsx 13 KB

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