Просмотр исходного кода

Merge pull request #8731 from weseek/imprv/comment-editor-performance

imprv: Comment editor performance
Yuki Takei 2 лет назад
Родитель
Сommit
1980e796b3

+ 3 - 7
apps/app/src/components/Comments.tsx

@@ -6,19 +6,16 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { debounce } from 'throttle-debounce';
 
-import { type PageCommentProps } from '~/components/PageComment';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useIsTrashPage, useSWRMUTxPageInfo } from '~/stores/page';
 
 import { useCurrentUser } from '../stores/context';
 
-import type { CommentEditorProps } from './PageComment/CommentEditor';
-
 const { isTopPage } = pagePathUtils;
 
 
-const PageComment = dynamic<PageCommentProps>(() => import('~/components/PageComment').then(mod => mod.PageComment), { ssr: false });
-const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
+const PageComment = dynamic(() => import('~/components/PageComment').then(mod => mod.PageComment), { ssr: false });
+const CommentEditorPre = dynamic(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditorPre), { ssr: false });
 
 export type CommentsProps = {
   pageId: string,
@@ -84,9 +81,8 @@ export const Comments = (props: CommentsProps): JSX.Element => {
       </div>
       {!isDeleted && (
         <div id="page-comment-write">
-          <CommentEditor
+          <CommentEditorPre
             pageId={pageId}
-            isForNewComment
             onCommentButtonClicked={onCommentButtonClickHandler}
             revisionId={revision._id}
           />

+ 3 - 3
apps/app/src/components/Page/DisplaySwitcher.tsx

@@ -27,11 +27,11 @@ export const DisplaySwitcher = (props: Props): JSX.Element => {
   usePageUpdatedEffect();
   useHashChangedEffect();
 
-  const isViewMode = editorMode === EditorMode.View;
-
   return (
     <>
-      { isViewMode && pageView }
+      <div className="d-edit-none">
+        {pageView}
+      </div>
 
       <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>
         { isLatestRevision

+ 1 - 1
apps/app/src/components/PageComment.tsx

@@ -26,7 +26,7 @@ import { ReplyComments } from './PageComment/ReplyComments';
 import styles from './PageComment.module.scss';
 
 
-export type PageCommentProps = {
+type PageCommentProps = {
   rendererOptions?: RendererOptions,
   pageId: string,
   pagePath: string,

+ 162 - 186
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -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();
+};

+ 2 - 3
apps/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,6 +1,6 @@
 import type { FC } from 'react';
 import React, {
-  useCallback, useEffect, useRef, useState,
+  useCallback, useEffect, useRef,
 } from 'react';
 
 import { getIdForRef } from '@growi/core';
@@ -27,7 +27,6 @@ import { mutateSearching } from '~/stores/search';
 import type { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PagePathNav } from '../Common/PagePathNav';
 import { type RevisionLoaderProps } from '../Page/RevisionLoader';
-import { type PageCommentProps } from '../PageComment';
 import type { PageContentFooterProps } from '../PageContentFooter';
 
 import styles from './SearchResultContent.module.scss';
@@ -38,7 +37,7 @@ const _fluidLayoutClass = styles['fluid-layout'];
 
 const PageControls = dynamic(() => import('../PageControls').then(mod => mod.PageControls), { ssr: false });
 const RevisionLoader = dynamic<RevisionLoaderProps>(() => import('../Page/RevisionLoader').then(mod => mod.RevisionLoader), { ssr: false });
-const PageComment = dynamic<PageCommentProps>(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
+const PageComment = dynamic(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
 const PageContentFooter = dynamic<PageContentFooterProps>(() => import('../PageContentFooter').then(mod => mod.PageContentFooter), { ssr: false });
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {

+ 4 - 0
apps/app/src/server/routes/comment.js

@@ -244,6 +244,10 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
 
+    if (comment === '') {
+      return res.json(ApiResponse.error('Comment text is required'));
+    }
+
     let createdComment;
     try {
       createdComment = await Comment.add(pageId, req.user._id, revisionId, comment, position, replyTo);

+ 11 - 28
apps/app/src/stores/comment.tsx

@@ -1,11 +1,12 @@
+import { useCallback } from 'react';
+
 import type { Nullable } from '@growi/core';
-import useSWR, { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+import useSWR from 'swr';
 
 import { apiGet, apiPost } from '~/client/util/apiv1-client';
 
-import { ICommentHasIdList, ICommentPostArgs } from '../interfaces/comment';
-
-import { useStaticSWR } from './use-static-swr';
+import type { ICommentHasIdList, ICommentPostArgs } from '../interfaces/comment';
 
 type IResponseComment = {
   comments: ICommentHasIdList,
@@ -25,8 +26,9 @@ export const useSWRxPageComment = (pageId: Nullable<string>): SWRResponse<IComme
     ([endpoint, pageId]) => apiGet(endpoint, { page_id: pageId }).then((response:IResponseComment) => response.comments),
   );
 
-  const update = async(comment: string, revisionId: string, commentId: string) => {
-    const { mutate } = swrResponse;
+  const { mutate } = swrResponse;
+
+  const update = useCallback(async(comment: string, revisionId: string, commentId: string) => {
     await apiPost('/comments.update', {
       commentForm: {
         comment,
@@ -35,10 +37,9 @@ export const useSWRxPageComment = (pageId: Nullable<string>): SWRResponse<IComme
       },
     });
     mutate();
-  };
+  }, [mutate]);
 
-  const post = async(args: ICommentPostArgs) => {
-    const { mutate } = swrResponse;
+  const post = useCallback(async(args: ICommentPostArgs) => {
     const { commentForm, slackNotificationForm } = args;
     const { comment, revisionId, replyTo } = commentForm;
     const { isSlackEnabled, slackChannels } = slackNotificationForm;
@@ -56,7 +57,7 @@ export const useSWRxPageComment = (pageId: Nullable<string>): SWRResponse<IComme
       },
     });
     mutate();
-  };
+  }, [mutate, pageId]);
 
   return {
     ...swrResponse,
@@ -64,21 +65,3 @@ export const useSWRxPageComment = (pageId: Nullable<string>): SWRResponse<IComme
     post,
   };
 };
-
-type EditingCommentsNumOperation = {
-  increment(): Promise<number | undefined>,
-  decrement(): Promise<number | undefined>,
-}
-
-export const useSWRxEditingCommentsNum = (): SWRResponse<number, Error> & EditingCommentsNumOperation => {
-  const swrResponse = useStaticSWR<number, Error>('editingCommentsNum', undefined, { fallbackData: 0 });
-
-  return {
-    ...swrResponse,
-    increment: () => swrResponse.mutate((swrResponse.data ?? 0) + 1),
-    decrement: () => {
-      const newValue = (swrResponse.data ?? 0) - 1;
-      return swrResponse.mutate(Math.max(0, newValue));
-    },
-  };
-};

+ 57 - 2
apps/app/src/stores/ui.tsx

@@ -1,5 +1,6 @@
 import {
   type RefObject, useCallback, useEffect,
+  useLayoutEffect,
 } from 'react';
 
 import { PageGrant, type Nullable } from '@growi/core';
@@ -7,6 +8,7 @@ import { type SWRResponseWithUtils, useSWRStatic, withUtils } from '@growi/core/
 import { pagePathUtils, isClient, isServer } from '@growi/core/dist/utils';
 import { Breakpoint } from '@growi/ui/dist/interfaces';
 import { addBreakpointListener, cleanupBreakpointListener } from '@growi/ui/dist/utils';
+import { useRouter } from 'next/router';
 import type { HtmlElementNode } from 'rehype-toc';
 import type SimpleBar from 'simplebar-react';
 import type { MutatorOptions } from 'swr';
@@ -15,9 +17,8 @@ import {
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
-import type { IFocusable } from '~/client/interfaces/focusable';
 import { scheduleToPut } from '~/client/services/user-ui-settings';
-import type { IPageGrantData, IPageSelectedGrant } from '~/interfaces/page';
+import type { IPageSelectedGrant } from '~/interfaces/page';
 import { SidebarContentsType, SidebarMode } from '~/interfaces/ui';
 import type { UpdateDescCountData } from '~/interfaces/websocket';
 import {
@@ -384,6 +385,60 @@ export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRR
   };
 };
 
+
+type UseCommentEditorDirtyMapOperation = {
+  evaluate(key: string, commentBody: string): Promise<number>,
+  clean(key: string): Promise<number>,
+}
+
+export const useCommentEditorDirtyMap = (): SWRResponse<Map<string, boolean>, Error> & UseCommentEditorDirtyMapOperation => {
+  const router = useRouter();
+
+  const swrResponse = useSWRStatic<Map<string, boolean>, Error>('editingCommentsNum', undefined, { fallbackData: new Map() });
+
+  const { mutate } = swrResponse;
+
+  const evaluate = useCallback(async(key: string, commentBody: string) => {
+    const newMap = await mutate((map) => {
+      if (map == null) return new Map();
+
+      if (commentBody.length === 0) {
+        map.delete(key);
+      }
+      else {
+        map.set(key, true);
+      }
+
+      return map;
+    });
+    return newMap?.size ?? 0;
+  }, [mutate]);
+  const clean = useCallback(async(key: string) => {
+    const newMap = await mutate((map) => {
+      if (map == null) return new Map();
+      map.delete(key);
+      return map;
+    });
+    return newMap?.size ?? 0;
+  }, [mutate]);
+
+  const reset = useCallback(() => mutate(new Map()), [mutate]);
+
+  useLayoutEffect(() => {
+    router.events.on('routeChangeComplete', reset);
+    return () => {
+      router.events.off('routeChangeComplete', reset);
+    };
+  }, [reset, router.events]);
+
+  return {
+    ...swrResponse,
+    evaluate,
+    clean,
+  };
+};
+
+
 /** **********************************************************
  *                          SWR Hooks
  *                Determined value by context

+ 14 - 11
packages/editor/src/components/CodeMirrorEditorComment.tsx

@@ -1,27 +1,30 @@
-import { useEffect } from 'react';
+import { memo, useEffect } from 'react';
 
 import type { Extension } from '@codemirror/state';
 import { keymap, scrollPastEnd } from '@codemirror/view';
 
-import { GlobalCodeMirrorEditorKey } from '../consts';
 import { useCodeMirrorEditorIsolated } from '../stores';
 
-import { CodeMirrorEditor, CodeMirrorEditorProps } from '.';
+import { CodeMirrorEditor, type CodeMirrorEditorProps } from '.';
+
+import type { GlobalCodeMirrorEditorKey } from 'src/consts';
 
 
 const additionalExtensions: Extension[] = [
   scrollPastEnd(),
 ];
 
+type Props = CodeMirrorEditorProps & {
+  editorKey: string | GlobalCodeMirrorEditorKey,
+}
 
-type Props = CodeMirrorEditorProps & object
-
-export const CodeMirrorEditorComment = (props: Props): JSX.Element => {
+export const CodeMirrorEditorComment = memo((props: Props): JSX.Element => {
   const {
-    onSave, ...otherProps
+    editorKey,
+    onSave, ...rest
   } = props;
 
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.COMMENT);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
 
   // setup additional extensions
   useEffect(() => {
@@ -55,9 +58,9 @@ export const CodeMirrorEditorComment = (props: Props): JSX.Element => {
 
   return (
     <CodeMirrorEditor
-      editorKey={GlobalCodeMirrorEditorKey.COMMENT}
+      editorKey={editorKey}
       onSave={onSave}
-      {...otherProps}
+      {...rest}
     />
   );
-};
+});

+ 1 - 1
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -8,7 +8,7 @@ import { GlobalCodeMirrorEditorKey } from '../consts';
 import { setDataLine } from '../services/extensions/setDataLine';
 import { useCodeMirrorEditorIsolated, useCollaborativeEditorMode } from '../stores';
 
-import { CodeMirrorEditor, CodeMirrorEditorProps } from '.';
+import { CodeMirrorEditor, type CodeMirrorEditorProps } from '.';
 
 const additionalExtensions: Extension[] = [
   [

+ 1 - 1
packages/editor/src/consts/global-code-mirror-editor-key.ts

@@ -1,6 +1,6 @@
 export const GlobalCodeMirrorEditorKey = {
   MAIN: 'main',
-  COMMENT: 'comment',
+  COMMENT_NEW: 'comment_new',
   DIFF: 'diff',
   READONLY: 'readonly',
 } as const;