Parcourir la source

Merge pull request #6826 from weseek/imprv/smooth-scroll

imprv: Smooth scroll
Yuki Takei il y a 3 ans
Parent
commit
b83ae5bfc9

+ 4 - 3
packages/app/package.json

@@ -162,9 +162,9 @@
     "react-image-crop": "^8.3.0",
     "react-markdown": "^8.0.3",
     "react-multiline-clamp": "^2.0.0",
+    "react-scroll": "^1.8.7",
     "react-syntax-highlighter": "^15.5.0",
     "react-use-ripple": "^1.5.2",
-    "react-scroll": "^1.8.7",
     "reactstrap": "^8.9.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
@@ -192,13 +192,13 @@
     "toastr": "^2.1.2",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
+    "unstated": "^2.1.1",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
     "ws": "^8.3.0",
-    "xss": "^1.0.6",
-    "unstated": "^2.1.1"
+    "xss": "^1.0.6"
   },
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
@@ -214,6 +214,7 @@
     "@types/express": "^4.17.11",
     "@types/jquery": "^3.5.8",
     "@types/multer": "^1.4.5",
+    "@types/react-scroll": "^1.8.4",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "bootstrap": "^4.6.1",

+ 4 - 45
packages/app/src/client/util/smooth-scroll.ts

@@ -1,46 +1,5 @@
-const WIKI_HEADER_LINK = 120;
-
-export const smoothScrollIntoView = (
-    element: HTMLElement = window.document.body, offsetTop = 0, scrollElement: HTMLElement | Window = window,
-): void => {
-
-  // get the distance to the target element top
-  const rectTop = element.getBoundingClientRect().top;
-
-  const top = window.pageYOffset + rectTop - offsetTop;
-
-  scrollElement.scrollTo({
-    top,
-    behavior: 'smooth',
-  });
-};
-
-export type SmoothScrollEventCallback = (elem: HTMLElement) => void;
-
-export const addSmoothScrollEvent = (elements: HTMLAnchorElement[], callback?: SmoothScrollEventCallback): void => {
-  elements.forEach((link) => {
-    const href = link.getAttribute('href');
-
-    if (href == null) {
-      return;
-    }
-
-    link.addEventListener('click', (e) => {
-      e.preventDefault();
-
-      // modify location.hash without scroll
-      window.history.pushState({}, '', link.href);
-
-      // smooth scroll
-      const elemId = href.replace('#', '');
-      const targetDom = document.getElementById(elemId);
-      if (targetDom != null) {
-        smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
-
-        if (callback != null) {
-          callback(targetDom);
-        }
-      }
-    });
-  });
+// option object for react-scroll
+export const DEFAULT_AUTO_SCROLL_OPTS = {
+  smooth: 'easeOutQuint',
+  duration: 1200,
 };

+ 22 - 31
packages/app/src/components/ContentLinkButtons.tsx

@@ -1,31 +1,27 @@
-import React, { useCallback } from 'react';
+import React from 'react';
 
 import { IUserHasId } from '@growi/core';
+import { Link as ScrollLink } from 'react-scroll';
 
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 
 import styles from './ContentLinkButtons.module.scss';
 
-const WIKI_HEADER_LINK = 120;
+const OFFSET = -120;
 
 const BookMarkLinkButton = React.memo(() => {
 
-  const BookMarkLinkButtonClickHandler = useCallback(() => {
-    const getBookMarkListHeaderDom = document.getElementById('bookmarks-list');
-    if (getBookMarkListHeaderDom == null) { return }
-    smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK);
-  }, []);
-
   return (
-    <button
-      type="button"
-      className="btn btn-outline-secondary btn-sm px-2"
-      onClick={BookMarkLinkButtonClickHandler}
-    >
-      <i className="fa fa-fw fa-bookmark-o"></i>
-      <span>Bookmarks</span>
-    </button>
+    <ScrollLink to="bookmarks-list" offset={OFFSET} {...DEFAULT_AUTO_SCROLL_OPTS}>
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-2"
+      >
+        <i className="fa fa-fw fa-bookmark-o"></i>
+        <span>Bookmarks</span>
+      </button>
+    </ScrollLink>
   );
 });
 
@@ -33,21 +29,16 @@ BookMarkLinkButton.displayName = 'BookMarkLinkButton';
 
 const RecentlyCreatedLinkButton = React.memo(() => {
 
-  const RecentlyCreatedListButtonClickHandler = useCallback(() => {
-    const getRecentlyCreatedListHeaderDom = document.getElementById('recently-created-list');
-    if (getRecentlyCreatedListHeaderDom == null) { return }
-    smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK);
-  }, []);
-
   return (
-    <button
-      type="button"
-      className="btn btn-outline-secondary btn-sm px-3"
-      onClick={RecentlyCreatedListButtonClickHandler}
-    >
-      <i className={`${styles['grw-icon-container-recently-created']} grw-icon-container-recently-created mr-2`}><RecentlyCreatedIcon /></i>
-      <span>Recently Created</span>
-    </button>
+    <ScrollLink to="recently-created-list" offset={OFFSET} {...DEFAULT_AUTO_SCROLL_OPTS}>
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-3"
+      >
+        <i className={`${styles['grw-icon-container-recently-created']} grw-icon-container-recently-created mr-2`}><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+    </ScrollLink>
   );
 });
 

+ 35 - 24
packages/app/src/components/Fab.tsx

@@ -1,11 +1,12 @@
 import React, {
-  useState, useCallback, useEffect, useRef,
+  useState, useCallback, useRef,
 } from 'react';
 
+import { animateScroll } from 'react-scroll';
 import { useRipple } from 'react-use-ripple';
 import StickyEvents from 'sticky-events';
 
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
@@ -59,39 +60,49 @@ export const Fab = (): JSX.Element => {
   //   };
   // }, [stickyChangeHandler]);
 
-  if (currentPath == null) {
-    return <></>;
-  }
-
-  const renderPageCreateButton = () => {
+  const PageCreateButton = useCallback(() => {
     return (
-      <>
-        <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
-          <button
-            type="button"
-            className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 ${buttonClasses}`}
-            ref={createBtnRef}
-            onClick={() => openCreateModal(currentPath)}
-          >
-            <CreatePageIcon />
-          </button>
-        </div>
-      </>
+      <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
+        <button
+          type="button"
+          className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 ${buttonClasses}`}
+          ref={createBtnRef}
+          onClick={currentPath != null
+            ? () => openCreateModal(currentPath)
+            : undefined}
+        >
+          <CreatePageIcon />
+        </button>
+      </div>
     );
-  };
+  }, [animateClasses, buttonClasses, currentPath, openCreateModal]);
 
-  return (
-    <div className={`${styles['grw-fab']} grw-fab d-none d-md-block d-edit-none`} data-testid="grw-fab-container">
-      {currentUser != null && renderPageCreateButton()}
+  const ScrollToTopButton = useCallback(() => {
+    const clickHandler = () => {
+      animateScroll.scrollToTop(DEFAULT_AUTO_SCROLL_OPTS);
+    };
+
+    return (
       <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }} data-testid="grw-fab-return-to-top">
         <button
           type="button"
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
-          onClick={() => smoothScrollIntoView()}
+          onClick={clickHandler}
         >
           <ReturnTopIcon />
         </button>
       </div>
+    );
+  }, [animateClasses, buttonClasses]);
+
+  if (currentPath == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={`${styles['grw-fab']} grw-fab d-none d-md-block d-edit-none`} data-testid="grw-fab-container">
+      {currentUser != null && <PageCreateButton />}
+      <ScrollToTopButton />
     </div>
   );
 

+ 2 - 1
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { Link } from 'react-scroll';
 
+import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import {
   useCurrentPagePath, useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
@@ -81,7 +82,7 @@ const PageView = React.memo((): JSX.Element => {
             {/* Comments */}
             { !isTopPagePath && (
               <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-                <Link to={'page-comments'} smooth="easeOutQuart" offset={-100} duration={800}>
+                <Link to={'page-comments'} offset={-100} {...DEFAULT_AUTO_SCROLL_OPTS}>
                   <button
                     type="button"
                     className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"

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

@@ -10,19 +10,23 @@ 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,
   revisionId: Ref<IRevision>,
   lazy?: boolean,
   onRevisionLoaded?: (revision: IRevisionHasId) => void,
-
-  pagePath: string,
-  highlightKeywords?: string[],
 }
 
 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
  */
@@ -81,7 +85,7 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   if (lazy && !isLoaded) {
     return (
       <Waypoint onPositionChange={onWaypointChange} bottomOffset="-100px">
-        <div className="wiki"></div>
+        <></>
       </Waypoint>
     );
   }
@@ -110,9 +114,11 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   }
 
   return (
-    <RevisionRenderer
-      rendererOptions={rendererOptions}
-      markdown={markdown}
-    />
+    <RevisionLoaderRoot>
+      <RevisionRenderer
+        rendererOptions={rendererOptions}
+        markdown={markdown}
+      />
+    </RevisionLoaderRoot>
   );
 };

+ 60 - 56
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,
@@ -34,7 +42,6 @@ export type PageCommentProps = {
   currentUser: any,
   isReadOnly: boolean,
   titleAlign?: 'center' | 'left' | 'right',
-  highlightKeywords?: string[],
   hideIfEmpty?: boolean,
 }
 
@@ -42,7 +49,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
   const {
     rendererOptions: rendererOptionsByProps,
-    pageId, revision, currentUser, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
+    pageId, revision, currentUser, isReadOnly, titleAlign, hideIfEmpty,
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
@@ -103,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';
@@ -113,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}/>
@@ -131,7 +138,6 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       revisionCreatedAt={revisionCreatedAt as Date}
       currentUser={currentUser}
       isReadOnly={isReadOnly}
-      highlightKeywords={highlightKeywords}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
     />
@@ -151,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>
@@ -214,7 +218,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
           confirmToDelete={onDeleteComment}
         />
       )}
-    </>
+    </PageCommentRoot>
   );
 });
 

+ 0 - 1
packages/app/src/components/PageComment/Comment.tsx

@@ -29,7 +29,6 @@ type CommentProps = {
   revisionCreatedAt: Date,
   currentUser: IUser,
   isReadOnly: boolean,
-  highlightKeywords?: string[],
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
 }

+ 1 - 3
packages/app/src/components/PageComment/ReplyComments.tsx

@@ -21,7 +21,6 @@ type ReplycommentsProps = {
   revisionCreatedAt: Date,
   currentUser: IUser,
   replyList: ICommentHasIdList,
-  highlightKeywords?: string[],
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
 }
@@ -29,7 +28,7 @@ type ReplycommentsProps = {
 export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
   const {
-    rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList, highlightKeywords,
+    rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList,
     deleteBtnClicked, onComment,
   } = props;
 
@@ -47,7 +46,6 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
           revisionCreatedAt={revisionCreatedAt}
           currentUser={currentUser}
           isReadOnly={isReadOnly}
-          highlightKeywords={highlightKeywords}
           deleteBtnClicked={deleteBtnClicked}
           onComment={onComment}
         />

+ 0 - 1
packages/app/src/components/PageTimeline.tsx

@@ -65,7 +65,6 @@ export const PageTimeline = (): JSX.Element => {
                   lazy
                   rendererOptions={rendererOptions}
                   pageId={page._id}
-                  pagePath={page.path}
                   revisionId={page.revision}
                 />
               </div>

+ 14 - 9
packages/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
 
 import EventEmitter from 'events';
 
@@ -57,19 +57,24 @@ export const Header = (props: HeaderProps): JSX.Element => {
 
   const CustomTag = `h${level}` as keyof JSX.IntrinsicElements;
 
-  // update isActive when hash is changed
+  const activateByHash = useCallback((url: string) => {
+    const hash = (new URL(url, 'https://example.com')).hash.slice(1);
+    setActive(hash === id);
+  }, [id]);
+
+  // init
   useEffect(() => {
-    const handler = (url: string) => {
-      const hash = (new URL(url, 'https://example.com')).hash.slice(1);
-      setActive(hash === id);
-    };
+    activateByHash(window.location.href);
+  }, [activateByHash]);
 
-    router.events.on('hashChangeComplete', handler);
+  // update isActive when hash is changed
+  useEffect(() => {
+    router.events.on('hashChangeComplete', activateByHash);
 
     return () => {
-      router.events.off('hashChangeComplete', handler);
+      router.events.off('hashChangeComplete', activateByHash);
     };
-  }, [id, router.events]);
+  }, [activateByHash, router.events]);
 
   return (
     <CustomTag id={id} className={`revision-head ${styles['revision-head']} ${isActive ? 'blink' : ''}`}>

+ 2 - 1
packages/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -1,6 +1,7 @@
 import Link, { LinkProps } from 'next/link';
 import { Link as ScrollLink } from 'react-scroll';
 
+import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { useSiteUrl } from '~/stores/context';
 
 const isAnchorLink = (href: string): boolean => {
@@ -35,7 +36,7 @@ export const NextLink = ({
     const to = href.slice(1);
     return (
       <Link href={href} scroll={false}>
-        <ScrollLink href={href} to={to} className={className} smooth="easeOutQuart" offset={-100} duration={800}>
+        <ScrollLink href={href} to={to} className={className} offset={-100} {...DEFAULT_AUTO_SCROLL_OPTS}>
           {children}
         </ScrollLink>
       </Link>

+ 58 - 30
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,20 +240,19 @@ 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}
-          pagePath={page.path}
           revisionId={page.revision}
-          highlightKeywords={highlightKeywords}
         />
+        {/* PageComment will render '#page-comment' after loaded */}
         <PageComment
           rendererOptions={rendererOptions}
           pageId={page._id}
           revision={page.revision}
           currentUser={currentUser}
-          highlightKeywords={highlightKeywords}
           isReadOnly
           hideIfEmpty
         />

+ 11 - 9
packages/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts

@@ -10,7 +10,7 @@ import { Plugin } from 'unified';
  * @param value
  * @returns
  */
-function splitWithKeyword(keyword: string, value: string): string[] {
+function splitWithKeyword(lowercasedKeyword: string, value: string): string[] {
   if (value.length === 0) {
     return [];
   }
@@ -21,7 +21,7 @@ function splitWithKeyword(keyword: string, value: string): string[] {
   const splitted: string[] = [];
 
   do {
-    cursorEnd = value.indexOf(keyword, cursorStart);
+    cursorEnd = value.toLowerCase().indexOf(lowercasedKeyword, cursorStart);
 
     // not found
     if (cursorEnd === -1) {
@@ -29,7 +29,7 @@ function splitWithKeyword(keyword: string, value: string): string[] {
     }
     // keyword is found
     else if (cursorEnd === cursorStart) {
-      cursorEnd += keyword.length;
+      cursorEnd += lowercasedKeyword.length;
     }
 
     splitted.push(value.slice(cursorStart, cursorEnd));
@@ -50,17 +50,17 @@ function wrapWithEm(textElement: Text): Element {
   };
 }
 
-function highlight(keyword: string, node: Text, index: number, parent: Root | Element): void {
-  if (node.value.includes(keyword)) {
-    const splitted = splitWithKeyword(keyword, node.value);
+function highlight(lowercasedKeyword: string, node: Text, index: number, parent: Root | Element): void {
+  if (node.value.toLowerCase().includes(lowercasedKeyword)) {
+    const splitted = splitWithKeyword(lowercasedKeyword, node.value);
 
     parent.children[index] = {
       type: 'element',
       tagName: 'span',
       properties: {},
       children: splitted.map((text) => {
-        return text === keyword
-          ? wrapWithEm({ type: 'text', value: keyword })
+        return text.toLowerCase() === lowercasedKeyword
+          ? wrapWithEm({ type: 'text', value: text })
           : { type: 'text', value: text };
       }),
     };
@@ -79,11 +79,13 @@ export const rehypePlugin: Plugin<[KeywordHighlighterPluginParams]> = (options)
 
   const keywords = (typeof options.keywords === 'string') ? [options.keywords] : options.keywords;
 
+  const lowercasedKeywords = keywords.map(keyword => keyword.toLowerCase());
+
   // return rehype-rewrite with hithlighter
   return rehypeRewrite.bind(this)({
     rewrite: (node, index, parent) => {
       if (parent != null && index != null && node.type === 'text') {
-        keywords.forEach(keyword => highlight(keyword, node, index, parent));
+        lowercasedKeywords.forEach(keyword => highlight(keyword, node, index, parent));
       }
     },
   });

+ 7 - 0
yarn.lock

@@ -4490,6 +4490,13 @@
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
   integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
 
+"@types/react-scroll@^1.8.4":
+  version "1.8.4"
+  resolved "https://registry.yarnpkg.com/@types/react-scroll/-/react-scroll-1.8.4.tgz#2b6258fb34104d3fcc7a22e8eceaadc669ba3ad1"
+  integrity sha512-DpHA9PYw42/rBrfKbGE/kAEvHRfyDL/ACfKB/ORWUYuCLi/yGrntxSzYXmg/7TLgQsJ5ma13GCDOzFSOz+8XOA==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react@*":
   version "16.9.23"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.23.tgz#1a66c6d468ba11a8943ad958a8cb3e737568271c"