CommentEditor.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import type { ReactNode } from 'react';
  2. import React, {
  3. useCallback, useState, useEffect,
  4. useMemo,
  5. } from 'react';
  6. import { GlobalCodeMirrorEditorKey } from '@growi/editor';
  7. import {
  8. CodeMirrorEditorComment, useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
  9. } from '@growi/editor/dist/client';
  10. import { UserPicture } from '@growi/ui/dist/components';
  11. import { useTranslation } from 'next-i18next';
  12. import dynamic from 'next/dynamic';
  13. import {
  14. TabContent, TabPane,
  15. } from 'reactstrap';
  16. import { uploadAttachments } from '~/client/services/upload-attachments';
  17. import { toastError } from '~/client/util/toastr';
  18. import { useSWRxPageComment } from '~/stores/comment';
  19. import {
  20. useCurrentUser, useIsSlackConfigured, useAcceptedUploadFileType,
  21. } from '~/stores/context';
  22. import {
  23. useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning, useEditorSettings,
  24. } from '~/stores/editor';
  25. import { useCurrentPagePath } from '~/stores/page';
  26. import { useCommentEditorDirtyMap } from '~/stores/ui';
  27. import { useNextThemes } from '~/stores/use-next-themes';
  28. import loggerFactory from '~/utils/logger';
  29. import { NotAvailableForGuest } from '../NotAvailableForGuest';
  30. import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
  31. import { CommentPreview } from './CommentPreview';
  32. import { SwitchingButtonGroup } from './SwitchingButtonGroup';
  33. import '@growi/editor/dist/style.css';
  34. import styles from './CommentEditor.module.scss';
  35. const logger = loggerFactory('growi:components:CommentEditor');
  36. const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
  37. const CommentEditorLayout = ({ children }: { children: ReactNode }): JSX.Element => {
  38. return (
  39. <div className={`${styles['comment-editor-styles']} form`}>
  40. <div className="comment-form">
  41. <div className="bg-comment rounded">
  42. {children}
  43. </div>
  44. </div>
  45. </div>
  46. );
  47. };
  48. type CommentEditorProps = {
  49. pageId: string,
  50. replyTo?: string,
  51. revisionId: string,
  52. currentCommentId?: string,
  53. commentBody?: string,
  54. onCanceled?: () => void,
  55. onCommented?: () => void,
  56. }
  57. export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
  58. const {
  59. pageId, replyTo, revisionId,
  60. currentCommentId, commentBody, onCanceled, onCommented,
  61. } = props;
  62. const { data: currentUser } = useCurrentUser();
  63. const { data: currentPagePath } = useCurrentPagePath();
  64. const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
  65. const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
  66. const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
  67. const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
  68. const { data: isSlackConfigured } = useIsSlackConfigured();
  69. const { data: editorSettings } = useEditorSettings();
  70. const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
  71. const {
  72. evaluate: evaluateEditorDirtyMap,
  73. clean: cleanEditorDirtyMap,
  74. } = useCommentEditorDirtyMap();
  75. const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
  76. const { resolvedTheme } = useNextThemes();
  77. mutateResolvedTheme({ themeData: resolvedTheme });
  78. const editorKey = useMemo(() => {
  79. if (replyTo != null) {
  80. return `comment_replyTo_${replyTo}`;
  81. }
  82. if (currentCommentId != null) {
  83. return `comment_edit_${currentCommentId}`;
  84. }
  85. return GlobalCodeMirrorEditorKey.COMMENT_NEW;
  86. }, [currentCommentId, replyTo]);
  87. const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
  88. const [showPreview, setShowPreview] = useState(false);
  89. const [error, setError] = useState();
  90. const [slackChannels, setSlackChannels] = useState<string>('');
  91. const { t } = useTranslation('');
  92. const handleSelect = useCallback((showPreview: boolean) => {
  93. setShowPreview(showPreview);
  94. }, []);
  95. // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
  96. const slackChannelsDataString = slackChannelsData?.toString();
  97. const initializeSlackEnabled = useCallback(() => {
  98. setSlackChannels(slackChannelsDataString ?? '');
  99. mutateIsSlackEnabled(false);
  100. }, [mutateIsSlackEnabled, slackChannelsDataString]);
  101. useEffect(() => {
  102. initializeSlackEnabled();
  103. }, [initializeSlackEnabled]);
  104. const isSlackEnabledToggleHandler = (isSlackEnabled: boolean) => {
  105. mutateIsSlackEnabled(isSlackEnabled, false);
  106. };
  107. const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
  108. setSlackChannels(slackChannels);
  109. }, []);
  110. const initializeEditor = useCallback(async() => {
  111. const dirtyNum = await cleanEditorDirtyMap(editorKey);
  112. mutateIsEnabledUnsavedWarning(dirtyNum > 0);
  113. setShowPreview(false);
  114. setError(undefined);
  115. initializeSlackEnabled();
  116. }, [editorKey, cleanEditorDirtyMap, mutateIsEnabledUnsavedWarning, initializeSlackEnabled]);
  117. const cancelButtonClickedHandler = useCallback(() => {
  118. initializeEditor();
  119. onCanceled?.();
  120. }, [onCanceled, initializeEditor]);
  121. const postCommentHandler = useCallback(async() => {
  122. const commentBodyToPost = codeMirrorEditor?.getDoc() ?? '';
  123. try {
  124. if (currentCommentId != null) {
  125. // update current comment
  126. await updateComment(commentBodyToPost, revisionId, currentCommentId);
  127. }
  128. else {
  129. // post new comment
  130. const postCommentArgs = {
  131. commentForm: {
  132. comment: commentBodyToPost,
  133. revisionId,
  134. replyTo,
  135. },
  136. slackNotificationForm: {
  137. isSlackEnabled,
  138. slackChannels,
  139. },
  140. };
  141. await postComment(postCommentArgs);
  142. }
  143. initializeEditor();
  144. onCommented?.();
  145. // Insert empty string as new comment editor is opened after comment
  146. codeMirrorEditor?.initDoc('');
  147. }
  148. catch (err) {
  149. const errorMessage = err.message || 'An unknown error occured when posting comment';
  150. setError(errorMessage);
  151. }
  152. // eslint-disable-next-line max-len
  153. }, [currentCommentId, initializeEditor, onCommented, codeMirrorEditor, updateComment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment]);
  154. // the upload event handler
  155. const uploadHandler = useCallback((files: File[]) => {
  156. uploadAttachments(pageId, files, {
  157. onUploaded: (attachment) => {
  158. const fileName = attachment.originalName;
  159. const prefix = attachment.fileFormat.startsWith('image/')
  160. ? '!' // use "![fileName](url)" syntax when image
  161. : '';
  162. const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
  163. codeMirrorEditor?.insertText(insertText);
  164. },
  165. onError: (error) => {
  166. toastError(error);
  167. },
  168. });
  169. }, [codeMirrorEditor, pageId]);
  170. const onChangeHandler = useCallback(async(value: string) => {
  171. const dirtyNum = await evaluateEditorDirtyMap(editorKey, value);
  172. mutateIsEnabledUnsavedWarning(dirtyNum > 0);
  173. }, [editorKey, evaluateEditorDirtyMap, mutateIsEnabledUnsavedWarning]);
  174. // initialize CodeMirrorEditor
  175. useEffect(() => {
  176. if (commentBody == null) {
  177. return;
  178. }
  179. codeMirrorEditor?.initDoc(commentBody);
  180. }, [codeMirrorEditor, commentBody]);
  181. const errorMessage = useMemo(() => <span className="text-danger text-end me-2">{error}</span>, [error]);
  182. const cancelButton = useMemo(() => (
  183. <button
  184. type="button"
  185. className="btn btn-outline-neutral-secondary"
  186. onClick={cancelButtonClickedHandler}
  187. >
  188. {t('Cancel')}
  189. </button>
  190. ), [cancelButtonClickedHandler, t]);
  191. const submitButton = useMemo(() => {
  192. return (
  193. <button
  194. type="button"
  195. data-testid="comment-submit-button"
  196. className="btn btn-primary"
  197. onClick={postCommentHandler}
  198. >
  199. {t('page_comment.comment')}
  200. </button>
  201. );
  202. }, [postCommentHandler, t]);
  203. return (
  204. <CommentEditorLayout>
  205. <div className="px-4 pt-3 pb-1">
  206. <div className="d-flex justify-content-between align-items-center mb-2">
  207. <div className="d-flex">
  208. <UserPicture user={currentUser} noLink noTooltip />
  209. <p className="ms-2 mb-0">{t('page_comment.add_a_comment')}</p>
  210. </div>
  211. <SwitchingButtonGroup showPreview={showPreview} onSelected={handleSelect} />
  212. </div>
  213. <TabContent activeTab={showPreview ? 'comment_preview' : 'comment_editor'}>
  214. <TabPane tabId="comment_editor">
  215. <CodeMirrorEditorComment
  216. editorKey={editorKey}
  217. acceptedUploadFileType={acceptedUploadFileType}
  218. onChange={onChangeHandler}
  219. onSave={postCommentHandler}
  220. onUpload={uploadHandler}
  221. editorSettings={editorSettings}
  222. />
  223. </TabPane>
  224. <TabPane tabId="comment_preview">
  225. <div className="comment-preview-container">
  226. <CommentPreview markdown={codeMirrorEditor?.getDoc() ?? ''} />
  227. </div>
  228. </TabPane>
  229. </TabContent>
  230. </div>
  231. <div className="comment-submit px-4 pb-3 mb-2">
  232. <div className="d-flex">
  233. <span className="flex-grow-1" />
  234. <span className="d-none d-sm-inline">{errorMessage && errorMessage}</span>
  235. {isSlackConfigured && isSlackEnabled != null
  236. && (
  237. <div className="align-self-center me-md-3">
  238. <SlackNotification
  239. isSlackEnabled={isSlackEnabled}
  240. slackChannels={slackChannels}
  241. onEnabledFlagChange={isSlackEnabledToggleHandler}
  242. onChannelChange={slackChannelsChangedHandler}
  243. id="idForComment"
  244. />
  245. </div>
  246. )
  247. }
  248. <div className="d-none d-sm-block">
  249. <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
  250. </div>
  251. </div>
  252. <div className="d-block d-sm-none mt-2">
  253. <div className="d-flex justify-content-end">
  254. {error && errorMessage}
  255. <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
  256. </div>
  257. </div>
  258. </div>
  259. </CommentEditorLayout>
  260. );
  261. };
  262. export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
  263. const { onCommented, onCanceled, ...rest } = props;
  264. const { data: currentUser } = useCurrentUser();
  265. const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
  266. const { resolvedTheme } = useNextThemes();
  267. mutateResolvedTheme({ themeData: resolvedTheme });
  268. const [isReadyToUse, setIsReadyToUse] = useState(false);
  269. const { t } = useTranslation('');
  270. const render = useCallback((): JSX.Element => {
  271. return (
  272. <CommentEditorLayout>
  273. <NotAvailableForGuest>
  274. <NotAvailableForReadOnlyUser>
  275. <button
  276. type="button"
  277. className="btn btn-outline-primary w-100 text-start py-3"
  278. onClick={() => setIsReadyToUse(true)}
  279. data-testid="open-comment-editor-button"
  280. >
  281. <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-3" />
  282. <span className="material-symbols-outlined me-1 fs-5">add_comment</span>
  283. <small>{t('page_comment.add_a_comment')}...</small>
  284. </button>
  285. </NotAvailableForReadOnlyUser>
  286. </NotAvailableForGuest>
  287. </CommentEditorLayout>
  288. );
  289. }, [currentUser, t]);
  290. return isReadyToUse
  291. ? (
  292. <CommentEditor
  293. onCommented={() => {
  294. onCommented?.();
  295. setIsReadyToUse(false);
  296. }}
  297. onCanceled={() => {
  298. onCanceled?.();
  299. setIsReadyToUse(false);
  300. }}
  301. {...rest}
  302. />
  303. )
  304. : render();
  305. };