|
|
@@ -1,5 +1,7 @@
|
|
|
+import type { ReactNode } from 'react';
|
|
|
import React, {
|
|
|
- useCallback, useState, useRef, useEffect,
|
|
|
+ useCallback, useState, useEffect,
|
|
|
+ useMemo,
|
|
|
} from 'react';
|
|
|
|
|
|
import {
|
|
|
@@ -8,15 +10,13 @@ import {
|
|
|
import { UserPicture } from '@growi/ui/dist/components';
|
|
|
import { useTranslation } from 'next-i18next';
|
|
|
import dynamic from 'next/dynamic';
|
|
|
-import { useRouter } from 'next/router';
|
|
|
import {
|
|
|
TabContent, TabPane,
|
|
|
} from 'reactstrap';
|
|
|
|
|
|
import { uploadAttachments } from '~/client/services/upload-attachments';
|
|
|
import { toastError } from '~/client/util/toastr';
|
|
|
-import type { IEditorMethods } from '~/interfaces/editor-methods';
|
|
|
-import { useSWRxPageComment, useSWRxEditingCommentsNum } from '~/stores/comment';
|
|
|
+import { useSWRxPageComment } from '~/stores/comment';
|
|
|
import {
|
|
|
useCurrentUser, useIsSlackConfigured, useAcceptedUploadFileType,
|
|
|
} from '~/stores/context';
|
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning, useEditorSettings,
|
|
|
} from '~/stores/editor';
|
|
|
import { useCurrentPagePath } from '~/stores/page';
|
|
|
+import { useCommentEditorDirtyMap } from '~/stores/ui';
|
|
|
import { useNextThemes } from '~/stores/use-next-themes';
|
|
|
import loggerFactory from '~/utils/logger';
|
|
|
|
|
|
@@ -43,9 +44,22 @@ const logger = loggerFactory('growi:components:CommentEditor');
|
|
|
|
|
|
const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
|
|
|
|
|
|
-export type CommentEditorProps = {
|
|
|
+
|
|
|
+const CommentEditorLayout = ({ children }: { children: ReactNode }): JSX.Element => {
|
|
|
+ return (
|
|
|
+ <div className={`${styles['comment-editor-styles']} form`}>
|
|
|
+ <div className="comment-form">
|
|
|
+ <div className="bg-comment rounded">
|
|
|
+ {children}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+
|
|
|
+type CommentEditorProps = {
|
|
|
pageId: string,
|
|
|
- isForNewComment?: boolean,
|
|
|
replyTo?: string,
|
|
|
revisionId: string,
|
|
|
currentCommentId?: string,
|
|
|
@@ -54,11 +68,10 @@ export type CommentEditorProps = {
|
|
|
onCommentButtonClicked?: () => void,
|
|
|
}
|
|
|
|
|
|
-
|
|
|
export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
|
|
|
|
|
|
const {
|
|
|
- pageId, isForNewComment, replyTo, revisionId,
|
|
|
+ pageId, replyTo, revisionId,
|
|
|
currentCommentId, commentBody, onCancelButtonClicked, onCommentButtonClicked,
|
|
|
} = props;
|
|
|
|
|
|
@@ -72,39 +85,31 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
|
|
|
const { data: editorSettings } = useEditorSettings();
|
|
|
const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
|
|
|
const {
|
|
|
- increment: incrementEditingCommentsNum,
|
|
|
- decrement: decrementEditingCommentsNum,
|
|
|
- } = useSWRxEditingCommentsNum();
|
|
|
+ evaluate: evaluateEditorDirtyMap,
|
|
|
+ clean: cleanEditorDirtyMap,
|
|
|
+ } = useCommentEditorDirtyMap();
|
|
|
const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
|
|
|
- const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.COMMENT);
|
|
|
const { resolvedTheme } = useNextThemes();
|
|
|
mutateResolvedTheme({ themeData: resolvedTheme });
|
|
|
|
|
|
- const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
|
|
|
- const [comment, setComment] = useState(commentBody ?? '');
|
|
|
+ const editorKey = useMemo(() => {
|
|
|
+ if (replyTo != null) {
|
|
|
+ return `comment_replyTo_${replyTo}`;
|
|
|
+ }
|
|
|
+ if (currentCommentId != null) {
|
|
|
+ return `comment_edit_${currentCommentId}`;
|
|
|
+ }
|
|
|
+ return GlobalCodeMirrorEditorKey.COMMENT_NEW;
|
|
|
+ }, [currentCommentId, replyTo]);
|
|
|
+
|
|
|
+ const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
|
|
|
+
|
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
|
const [error, setError] = useState();
|
|
|
const [slackChannels, setSlackChannels] = useState<string>('');
|
|
|
- const [incremented, setIncremented] = useState(false);
|
|
|
|
|
|
const { t } = useTranslation('');
|
|
|
|
|
|
- const editorRef = useRef<IEditorMethods>(null);
|
|
|
-
|
|
|
- const router = useRouter();
|
|
|
-
|
|
|
- // UnControlled CodeMirror value is not reset on page transition, so explicitly set the value to the initial value
|
|
|
- const onRouterChangeComplete = useCallback(() => {
|
|
|
- editorRef.current?.setValue('');
|
|
|
- }, []);
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- router.events.on('routeChangeComplete', onRouterChangeComplete);
|
|
|
- return () => {
|
|
|
- router.events.off('routeChangeComplete', onRouterChangeComplete);
|
|
|
- };
|
|
|
- }, [onRouterChangeComplete, router.events]);
|
|
|
-
|
|
|
const handleSelect = useCallback((showPreview: boolean) => {
|
|
|
setShowPreview(showPreview);
|
|
|
}, []);
|
|
|
@@ -129,47 +134,34 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
|
|
|
}, []);
|
|
|
|
|
|
const initializeEditor = useCallback(async() => {
|
|
|
- const editingCommentsNum = comment !== '' ? await decrementEditingCommentsNum() : undefined;
|
|
|
+ const dirtyNum = await cleanEditorDirtyMap(editorKey);
|
|
|
+ mutateIsEnabledUnsavedWarning(dirtyNum > 0);
|
|
|
|
|
|
- setComment('');
|
|
|
setShowPreview(false);
|
|
|
setError(undefined);
|
|
|
- initializeSlackEnabled();
|
|
|
- // reset value
|
|
|
- if (editorRef.current == null) { return }
|
|
|
- editorRef.current.setValue('');
|
|
|
|
|
|
- if (editingCommentsNum != null && editingCommentsNum === 0) {
|
|
|
- mutateIsEnabledUnsavedWarning(false); // must be after clearing comment or else onChange will override bool
|
|
|
- }
|
|
|
+ initializeSlackEnabled();
|
|
|
|
|
|
- }, [initializeSlackEnabled, comment, decrementEditingCommentsNum, mutateIsEnabledUnsavedWarning]);
|
|
|
+ }, [editorKey, cleanEditorDirtyMap, mutateIsEnabledUnsavedWarning, initializeSlackEnabled]);
|
|
|
|
|
|
const cancelButtonClickedHandler = useCallback(() => {
|
|
|
- // change state to not ready
|
|
|
- // when this editor is for the new comment mode
|
|
|
- if (isForNewComment) {
|
|
|
- setIsReadyToUse(false);
|
|
|
- }
|
|
|
-
|
|
|
initializeEditor();
|
|
|
-
|
|
|
- if (onCancelButtonClicked != null) {
|
|
|
- onCancelButtonClicked();
|
|
|
- }
|
|
|
- }, [isForNewComment, onCancelButtonClicked, initializeEditor]);
|
|
|
+ onCancelButtonClicked?.();
|
|
|
+ }, [onCancelButtonClicked, initializeEditor]);
|
|
|
|
|
|
const postCommentHandler = useCallback(async() => {
|
|
|
+ const commentBodyToPost = codeMirrorEditor?.getDoc() ?? '';
|
|
|
+
|
|
|
try {
|
|
|
if (currentCommentId != null) {
|
|
|
// update current comment
|
|
|
- await updateComment(comment, revisionId, currentCommentId);
|
|
|
+ await updateComment(commentBodyToPost, revisionId, currentCommentId);
|
|
|
}
|
|
|
else {
|
|
|
// post new comment
|
|
|
const postCommentArgs = {
|
|
|
commentForm: {
|
|
|
- comment,
|
|
|
+ comment: commentBodyToPost,
|
|
|
revisionId,
|
|
|
replyTo,
|
|
|
},
|
|
|
@@ -183,9 +175,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
|
|
|
|
|
|
initializeEditor();
|
|
|
|
|
|
- if (onCommentButtonClicked != null) {
|
|
|
- onCommentButtonClicked();
|
|
|
- }
|
|
|
+ onCommentButtonClicked?.();
|
|
|
|
|
|
// Insert empty string as new comment editor is opened after comment
|
|
|
codeMirrorEditor?.initDoc('');
|
|
|
@@ -194,10 +184,8 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
|
|
|
const errorMessage = err.message || 'An unknown error occured when posting comment';
|
|
|
setError(errorMessage);
|
|
|
}
|
|
|
- }, [
|
|
|
- currentCommentId, initializeEditor, onCommentButtonClicked, codeMirrorEditor,
|
|
|
- updateComment, comment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment,
|
|
|
- ]);
|
|
|
+ // eslint-disable-next-line max-len
|
|
|
+ }, [currentCommentId, initializeEditor, onCommentButtonClicked, codeMirrorEditor, updateComment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment]);
|
|
|
|
|
|
// the upload event handler
|
|
|
const uploadHandler = useCallback((files: File[]) => {
|
|
|
@@ -218,52 +206,10 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
|
|
|
});
|
|
|
}, [codeMirrorEditor, pageId]);
|
|
|
|
|
|
- const getCommentHtml = useCallback(() => {
|
|
|
- if (currentPagePath == null) {
|
|
|
- return <></>;
|
|
|
- }
|
|
|
-
|
|
|
- return <CommentPreview markdown={comment} />;
|
|
|
- }, [currentPagePath, comment]);
|
|
|
-
|
|
|
- const renderBeforeReady = useCallback((): JSX.Element => {
|
|
|
- return (
|
|
|
- <div>
|
|
|
- <NotAvailableForGuest>
|
|
|
- <NotAvailableForReadOnlyUser>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="btn btn-outline-primary w-100 text-start py-3"
|
|
|
- onClick={() => setIsReadyToUse(true)}
|
|
|
- data-testid="open-comment-editor-button"
|
|
|
- >
|
|
|
- <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-3" />
|
|
|
- <span className="material-symbols-outlined me-1 fs-5">add_comment</span>
|
|
|
- <small>{t('page_comment.add_a_comment')}...</small>
|
|
|
- </button>
|
|
|
- </NotAvailableForReadOnlyUser>
|
|
|
- </NotAvailableForGuest>
|
|
|
- </div>
|
|
|
- );
|
|
|
- }, [currentUser]);
|
|
|
-
|
|
|
- // const onChangeHandler = useCallback((newValue: string, isClean: boolean) => {
|
|
|
- // setComment(newValue);
|
|
|
- // if (!isClean && !incremented) {
|
|
|
- // incrementEditingCommentsNum();
|
|
|
- // setIncremented(true);
|
|
|
- // }
|
|
|
- // mutateIsEnabledUnsavedWarning(!isClean);
|
|
|
- // }, [mutateIsEnabledUnsavedWarning, incrementEditingCommentsNum, incremented]);
|
|
|
-
|
|
|
- const onChangeHandler = useCallback((newValue: string) => {
|
|
|
- setComment(newValue);
|
|
|
-
|
|
|
- if (!incremented) {
|
|
|
- incrementEditingCommentsNum();
|
|
|
- setIncremented(true);
|
|
|
- }
|
|
|
- }, [incrementEditingCommentsNum, incremented]);
|
|
|
+ const onChangeHandler = useCallback(async(value: string) => {
|
|
|
+ const dirtyNum = await evaluateEditorDirtyMap(editorKey, value);
|
|
|
+ mutateIsEnabledUnsavedWarning(dirtyNum > 0);
|
|
|
+ }, [editorKey, evaluateEditorDirtyMap, mutateIsEnabledUnsavedWarning]);
|
|
|
|
|
|
// initialize CodeMirrorEditor
|
|
|
useEffect(() => {
|
|
|
@@ -274,20 +220,18 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
|
|
|
}, [codeMirrorEditor, commentBody]);
|
|
|
|
|
|
|
|
|
- const renderReady = () => {
|
|
|
- const commentPreview = getCommentHtml();
|
|
|
-
|
|
|
- const errorMessage = <span className="text-danger text-end me-2">{error}</span>;
|
|
|
- const cancelButton = (
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="btn btn-outline-neutral-secondary"
|
|
|
- onClick={cancelButtonClickedHandler}
|
|
|
- >
|
|
|
- {t('Cancel')}
|
|
|
- </button>
|
|
|
- );
|
|
|
- const submitButton = (
|
|
|
+ const errorMessage = useMemo(() => <span className="text-danger text-end me-2">{error}</span>, [error]);
|
|
|
+ const cancelButton = useMemo(() => (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="btn btn-outline-neutral-secondary"
|
|
|
+ onClick={cancelButtonClickedHandler}
|
|
|
+ >
|
|
|
+ {t('Cancel')}
|
|
|
+ </button>
|
|
|
+ ), [cancelButtonClickedHandler, t]);
|
|
|
+ const submitButton = useMemo(() => {
|
|
|
+ return (
|
|
|
<button
|
|
|
type="button"
|
|
|
data-testid="comment-submit-button"
|
|
|
@@ -297,79 +241,111 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
|
|
|
{t('page_comment.comment')}
|
|
|
</button>
|
|
|
);
|
|
|
+ }, [postCommentHandler, t]);
|
|
|
|
|
|
- return (
|
|
|
- <>
|
|
|
- <div className="px-4 pt-3 pb-1">
|
|
|
- <div className="d-flex justify-content-between align-items-center mb-2">
|
|
|
- <div className="d-flex">
|
|
|
- <UserPicture user={currentUser} noLink noTooltip />
|
|
|
- <p className="ms-2 mb-0">{t('page_comment.add_a_comment')}</p>
|
|
|
- </div>
|
|
|
- <SwitchingButtonGroup showPreview={showPreview} onSelected={handleSelect} />
|
|
|
- </div>
|
|
|
- <TabContent activeTab={showPreview ? 'comment_preview' : 'comment_editor'}>
|
|
|
- <TabPane tabId="comment_editor">
|
|
|
- <CodeMirrorEditorComment
|
|
|
- acceptedUploadFileType={acceptedUploadFileType}
|
|
|
- onChange={onChangeHandler}
|
|
|
- onSave={postCommentHandler}
|
|
|
- onUpload={uploadHandler}
|
|
|
- editorSettings={editorSettings}
|
|
|
- />
|
|
|
- </TabPane>
|
|
|
- <TabPane tabId="comment_preview">
|
|
|
- <div className="comment-preview-container">
|
|
|
- {commentPreview}
|
|
|
- </div>
|
|
|
- </TabPane>
|
|
|
- </TabContent>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="comment-submit px-4 pb-3 mb-2">
|
|
|
+ return (
|
|
|
+ <CommentEditorLayout>
|
|
|
+ <div className="px-4 pt-3 pb-1">
|
|
|
+ <div className="d-flex justify-content-between align-items-center mb-2">
|
|
|
<div className="d-flex">
|
|
|
- <span className="flex-grow-1" />
|
|
|
- <span className="d-none d-sm-inline">{errorMessage && errorMessage}</span>
|
|
|
-
|
|
|
- {isSlackConfigured && isSlackEnabled != null
|
|
|
- && (
|
|
|
- <div className="align-self-center me-md-3">
|
|
|
- <SlackNotification
|
|
|
- isSlackEnabled={isSlackEnabled}
|
|
|
- slackChannels={slackChannels}
|
|
|
- onEnabledFlagChange={isSlackEnabledToggleHandler}
|
|
|
- onChannelChange={slackChannelsChangedHandler}
|
|
|
- id="idForComment"
|
|
|
- />
|
|
|
- </div>
|
|
|
- )
|
|
|
- }
|
|
|
- <div className="d-none d-sm-block">
|
|
|
- <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div className="d-block d-sm-none mt-2">
|
|
|
- <div className="d-flex justify-content-end">
|
|
|
- {error && errorMessage}
|
|
|
- <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
|
|
|
- </div>
|
|
|
+ <UserPicture user={currentUser} noLink noTooltip />
|
|
|
+ <p className="ms-2 mb-0">{t('page_comment.add_a_comment')}</p>
|
|
|
</div>
|
|
|
+ <SwitchingButtonGroup showPreview={showPreview} onSelected={handleSelect} />
|
|
|
</div>
|
|
|
- </>
|
|
|
- );
|
|
|
- };
|
|
|
+ <TabContent activeTab={showPreview ? 'comment_preview' : 'comment_editor'}>
|
|
|
+ <TabPane tabId="comment_editor">
|
|
|
+ <CodeMirrorEditorComment
|
|
|
+ editorKey={editorKey}
|
|
|
+ acceptedUploadFileType={acceptedUploadFileType}
|
|
|
+ onChange={onChangeHandler}
|
|
|
+ onSave={postCommentHandler}
|
|
|
+ onUpload={uploadHandler}
|
|
|
+ editorSettings={editorSettings}
|
|
|
+ />
|
|
|
+ </TabPane>
|
|
|
+ <TabPane tabId="comment_preview">
|
|
|
+ <div className="comment-preview-container">
|
|
|
+ <CommentPreview markdown={codeMirrorEditor?.getDoc() ?? ''} />
|
|
|
+ </div>
|
|
|
+ </TabPane>
|
|
|
+ </TabContent>
|
|
|
+ </div>
|
|
|
|
|
|
- return (
|
|
|
- <div className={`${styles['comment-editor-styles']} form page-comment-form`}>
|
|
|
- <div className="comment-form">
|
|
|
- <div className="comment-form-main bg-comment rounded">
|
|
|
- {isReadyToUse
|
|
|
- ? renderReady()
|
|
|
- : renderBeforeReady()
|
|
|
+ <div className="comment-submit px-4 pb-3 mb-2">
|
|
|
+ <div className="d-flex">
|
|
|
+ <span className="flex-grow-1" />
|
|
|
+ <span className="d-none d-sm-inline">{errorMessage && errorMessage}</span>
|
|
|
+
|
|
|
+ {isSlackConfigured && isSlackEnabled != null
|
|
|
+ && (
|
|
|
+ <div className="align-self-center me-md-3">
|
|
|
+ <SlackNotification
|
|
|
+ isSlackEnabled={isSlackEnabled}
|
|
|
+ slackChannels={slackChannels}
|
|
|
+ onEnabledFlagChange={isSlackEnabledToggleHandler}
|
|
|
+ onChannelChange={slackChannelsChangedHandler}
|
|
|
+ id="idForComment"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
}
|
|
|
+ <div className="d-none d-sm-block">
|
|
|
+ <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="d-block d-sm-none mt-2">
|
|
|
+ <div className="d-flex justify-content-end">
|
|
|
+ {error && errorMessage}
|
|
|
+ <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </CommentEditorLayout>
|
|
|
);
|
|
|
|
|
|
};
|
|
|
+
|
|
|
+
|
|
|
+export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
|
|
|
+
|
|
|
+ const { data: currentUser } = useCurrentUser();
|
|
|
+ const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
|
|
|
+ const { resolvedTheme } = useNextThemes();
|
|
|
+ mutateResolvedTheme({ themeData: resolvedTheme });
|
|
|
+
|
|
|
+ const [isReadyToUse, setIsReadyToUse] = useState(false);
|
|
|
+
|
|
|
+ const { t } = useTranslation('');
|
|
|
+
|
|
|
+ const render = useCallback((): JSX.Element => {
|
|
|
+ return (
|
|
|
+ <CommentEditorLayout>
|
|
|
+ <NotAvailableForGuest>
|
|
|
+ <NotAvailableForReadOnlyUser>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="btn btn-outline-primary w-100 text-start py-3"
|
|
|
+ onClick={() => setIsReadyToUse(true)}
|
|
|
+ data-testid="open-comment-editor-button"
|
|
|
+ >
|
|
|
+ <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-3" />
|
|
|
+ <span className="material-symbols-outlined me-1 fs-5">add_comment</span>
|
|
|
+ <small>{t('page_comment.add_a_comment')}...</small>
|
|
|
+ </button>
|
|
|
+ </NotAvailableForReadOnlyUser>
|
|
|
+ </NotAvailableForGuest>
|
|
|
+ </CommentEditorLayout>
|
|
|
+ );
|
|
|
+ }, [currentUser, t]);
|
|
|
+
|
|
|
+ return isReadyToUse
|
|
|
+ ? (
|
|
|
+ <CommentEditor
|
|
|
+ onCommentButtonClicked={() => setIsReadyToUse(false)}
|
|
|
+ onCancelButtonClicked={() => setIsReadyToUse(false)}
|
|
|
+ {...props}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ : render();
|
|
|
+};
|