Yuki Takei 3 лет назад
Родитель
Сommit
3e1dd8dfd6

+ 14 - 5
packages/app/src/components/Page/RevisionLoader.tsx

@@ -10,6 +10,8 @@ import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './RevisionRenderer';
 
+export const ROOT_ELEM_ID = 'revision-loader' as const;
+
 export type RevisionLoaderProps = {
   rendererOptions: RendererOptions,
   pageId: string,
@@ -20,6 +22,11 @@ export type RevisionLoaderProps = {
 
 const logger = loggerFactory('growi:Page:RevisionLoader');
 
+// Always render '#revision-loader' for MutationObserver of SearchResultContent
+const RevisionLoaderRoot = (props: React.HTMLAttributes<HTMLDivElement>): JSX.Element => (
+  <div id={ROOT_ELEM_ID} {...props}>{props.children}</div>
+);
+
 /**
  * Load data from server and render RevisionBody component
  */
@@ -78,7 +85,7 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   if (lazy && !isLoaded) {
     return (
       <Waypoint onPositionChange={onWaypointChange} bottomOffset="-100px">
-        <div className="wiki"></div>
+        <></>
       </Waypoint>
     );
   }
@@ -107,9 +114,11 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   }
 
   return (
-    <RevisionRenderer
-      rendererOptions={rendererOptions}
-      markdown={markdown}
-    />
+    <RevisionLoaderRoot>
+      <RevisionRenderer
+        rendererOptions={rendererOptions}
+        markdown={markdown}
+      />
+    </RevisionLoaderRoot>
   );
 };

+ 59 - 53
packages/app/src/components/PageComment.tsx

@@ -27,6 +27,14 @@ const DeleteCommentModal = dynamic<DeleteCommentModalProps>(
   () => import('./PageComment/DeleteCommentModal').then(mod => mod.DeleteCommentModal), { ssr: false },
 );
 
+export const ROOT_ELEM_ID = 'page-comments' as const;
+
+// Always render '#page-comments' for MutationObserver of SearchResultContent
+const PageCommentRoot = (props: React.HTMLAttributes<HTMLDivElement>): JSX.Element => (
+  <div id={ROOT_ELEM_ID} {...props}>{props.children}</div>
+);
+
+
 export type PageCommentProps = {
   rendererOptions?: RendererOptions,
   pageId: string,
@@ -102,7 +110,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   }, []);
 
   if (hideIfEmpty && comments?.length === 0) {
-    return <></>;
+    return <PageCommentRoot />;
   }
 
   let commentTitleClasses = 'border-bottom py-3 mb-3';
@@ -112,7 +120,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
   if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
     if (hideIfEmpty) {
-      return <></>;
+      return <PageCommentRoot />;
     }
     return (
       <PageCommentSkelton commentTitleClasses={commentTitleClasses}/>
@@ -149,57 +157,55 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   );
 
   return (
-    <>
-      <div id="page-comments" className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
-        <div className="container-lg">
-          <div className="page-comments">
-            <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
-            <div className="page-comments-list" id="page-comments-list">
-              { commentsExceptReply.map((comment) => {
-
-                const defaultCommentThreadClasses = 'page-comment-thread pb-5';
-                const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
-
-                let commentThreadClasses = '';
-                commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
-
-                return (
-                  <div key={comment._id} className={commentThreadClasses}>
-                    {generateCommentElement(comment)}
-                    {hasReply && generateReplyCommentsElement(allReplies[comment._id])}
-                    {(!isReadOnly && !showEditorIds.has(comment._id)) && (
-                      <div className="text-right">
-                        <Button
-                          outline
-                          color="secondary"
-                          size="sm"
-                          className="btn-comment-reply"
-                          onClick={() => {
-                            setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
-                          }}
-                        >
-                          <i className="icon-fw icon-action-undo"></i> Reply
-                        </Button>
-                      </div>
-                    )}
-                    {(!isReadOnly && showEditorIds.has(comment._id)) && (
-                      <CommentEditor
-                        pageId={pageId}
-                        replyTo={comment._id}
-                        onCancelButtonClicked={() => {
-                          removeShowEditorId(comment._id);
-                        }}
-                        onCommentButtonClicked={() => {
-                          removeShowEditorId(comment._id);
-                          mutate();
+    <PageCommentRoot className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
+      <div className="container-lg">
+        <div className="page-comments">
+          <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
+          <div className="page-comments-list" id="page-comments-list">
+            { commentsExceptReply.map((comment) => {
+
+              const defaultCommentThreadClasses = 'page-comment-thread pb-5';
+              const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
+
+              let commentThreadClasses = '';
+              commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
+
+              return (
+                <div key={comment._id} className={commentThreadClasses}>
+                  {generateCommentElement(comment)}
+                  {hasReply && generateReplyCommentsElement(allReplies[comment._id])}
+                  {(!isReadOnly && !showEditorIds.has(comment._id)) && (
+                    <div className="text-right">
+                      <Button
+                        outline
+                        color="secondary"
+                        size="sm"
+                        className="btn-comment-reply"
+                        onClick={() => {
+                          setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
                         }}
-                      />
-                    )}
-                  </div>
-                );
-
-              })}
-            </div>
+                      >
+                        <i className="icon-fw icon-action-undo"></i> Reply
+                      </Button>
+                    </div>
+                  )}
+                  {(!isReadOnly && showEditorIds.has(comment._id)) && (
+                    <CommentEditor
+                      pageId={pageId}
+                      replyTo={comment._id}
+                      onCancelButtonClicked={() => {
+                        removeShowEditorId(comment._id);
+                      }}
+                      onCommentButtonClicked={() => {
+                        removeShowEditorId(comment._id);
+                        mutate();
+                      }}
+                    />
+                  )}
+                </div>
+              );
+
+            })}
           </div>
         </div>
       </div>
@@ -212,7 +218,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
           confirmToDelete={onDeleteComment}
         />
       )}
-    </>
+    </PageCommentRoot>
   );
 });
 

+ 58 - 27
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,15 +1,16 @@
 import React, {
-  FC, useCallback, useEffect, useRef,
+  FC, useCallback, useEffect, useRef, useState,
 } from 'react';
 
 import { getIdForRef } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
 
+
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/apiNotification';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import { IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
@@ -24,8 +25,8 @@ import { useFullTextSearchTermManager } from '~/stores/search';
 import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { GrowiSubNavigationProps } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtonsProps } from '../Navbar/SubNavButtons';
-import { RevisionLoaderProps } from '../Page/RevisionLoader';
-import { PageCommentProps } from '../PageComment';
+import { ROOT_ELEM_ID as RevisionLoaderRoomElemId, RevisionLoaderProps } from '../Page/RevisionLoader';
+import { ROOT_ELEM_ID as PageCommentRootElemId, PageCommentProps } from '../PageComment';
 import { PageContentFooterProps } from '../PageContentFooter';
 
 
@@ -57,8 +58,8 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   );
 };
 
-const SCROLL_OFFSET_TOP = 175; // approximate height of (navigation + subnavigation)
-const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true };
+const SCROLL_OFFSET_TOP = 30;
+const MUTATION_OBSERVER_CONFIG = { childList: true }; // omit 'subtree: true'
 
 type Props ={
   pageWithMeta : IPageWithSearchMeta,
@@ -67,28 +68,26 @@ type Props ={
   forceHideMenuItems?: ForceHideMenuItems,
 }
 
-const scrollTo = (scrollElement:HTMLElement) => {
+const scrollToFirstHighlightedKeyword = (scrollElement: HTMLElement): boolean => {
   // use querySelector to intentionally get the first element found
-  const highlightedKeyword = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
-  if (highlightedKeyword != null) {
-    smoothScrollIntoView(highlightedKeyword, SCROLL_OFFSET_TOP, scrollElement);
+  const toElem = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
+  if (toElem == null) {
+    return false;
   }
-};
 
-const generateObserverCallback = (doScroll: ()=>void) => {
-  return (mutationRecords:MutationRecord[]) => {
-    mutationRecords.forEach((record:MutationRecord) => {
-      const target = record.target as HTMLElement;
-      const targetId = target.id as string;
-      if (targetId !== 'wiki') return;
-      doScroll();
-    });
-  };
+  animateScroll.scrollTo(toElem.offsetTop - SCROLL_OFFSET_TOP, {
+    containerId: scrollElement.id,
+    duration: 200,
+  });
+  return true;
 };
 
 export const SearchResultContent: FC<Props> = (props: Props) => {
 
-  const scrollElementRef = useRef(null);
+  const scrollElementRef = useRef<HTMLDivElement|null>(null);
+
+  const [isRevisionLoaded, setRevisionLoaded] = useState(false);
+  const [isPageCommentLoaded, setPageCommentLoaded] = useState(false);
 
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
@@ -97,19 +96,49 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
-    const scrollElement = scrollElementRef.current as HTMLElement | null;
+    const scrollElement = scrollElementRef.current;
     if (scrollElement == null) return;
 
-    const observerCallback = generateObserverCallback(() => {
-      scrollTo(scrollElement);
-    });
+    const observerCallback = (mutationRecords:MutationRecord[], thisObs: MutationObserver) => {
+      mutationRecords.forEach((record:MutationRecord) => {
+        const target = record.target as HTMLElement;
+
+        // turn on boolean if loaded
+        Array.from(target.children).forEach((child) => {
+          const childId = (child as HTMLElement).id;
+          if (childId === RevisionLoaderRoomElemId) {
+            setRevisionLoaded(true);
+          }
+          else if (childId === PageCommentRootElemId) {
+            setPageCommentLoaded(true);
+          }
+        });
+      });
+    };
 
     const observer = new MutationObserver(observerCallback);
     observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
     return () => {
       observer.disconnect();
     };
-  });
+  }, []);
+
+  useEffect(() => {
+    if (!isRevisionLoaded || !isPageCommentLoaded) {
+      return;
+    }
+    if (scrollElementRef.current == null) {
+      return;
+    }
+
+    const scrollElement = scrollElementRef.current;
+    const isScrollProcessed = scrollToFirstHighlightedKeyword(scrollElement);
+    // retry after 1000ms if highlighted element is absense
+    if (!isScrollProcessed) {
+      setTimeout(() => scrollToFirstHighlightedKeyword(scrollElement), 1000);
+    }
+
+  }, [isPageCommentLoaded, isRevisionLoaded]);
   // *******************************  end  *******************************
 
   const {
@@ -211,12 +240,14 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           additionalClasses={['px-4']}
         />
       </div>
-      <div className="search-result-content-body-container" ref={scrollElementRef}>
+      <div id="search-result-content-body-container" className="search-result-content-body-container" ref={scrollElementRef}>
+        {/* RevisionLoader will render '#revision-loader' after loaded */}
         <RevisionLoader
           rendererOptions={rendererOptions}
           pageId={page._id}
           revisionId={page.revision}
         />
+        {/* PageComment will render '#page-comment' after loaded */}
         <PageComment
           rendererOptions={rendererOptions}
           pageId={page._id}