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

Merge branch 'fix/107120-fix-trash-page-list' of https://github.com/weseek/growi into fix/107120-fix-trash-page-list

Yuken Tezuka 3 лет назад
Родитель
Сommit
5bd8b23972
37 измененных файлов с 436 добавлено и 364 удалено
  1. 4 3
      packages/app/package.json
  2. 3 3
      packages/app/public/static/locales/en_US/translation.json
  3. 3 3
      packages/app/public/static/locales/ja_JP/translation.json
  4. 3 3
      packages/app/public/static/locales/zh_CN/translation.json
  5. 4 45
      packages/app/src/client/util/smooth-scroll.ts
  6. 2 4
      packages/app/src/components/Admin/FullTextSearchManagement.tsx
  7. 35 37
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  8. 10 9
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx
  9. 22 31
      packages/app/src/components/ContentLinkButtons.tsx
  10. 35 24
      packages/app/src/components/Fab.tsx
  11. 2 25
      packages/app/src/components/ForbiddenPage.tsx
  12. 39 19
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  13. 19 2
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  14. 4 2
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  15. 2 1
      packages/app/src/components/Page/DisplaySwitcher.tsx
  16. 14 8
      packages/app/src/components/Page/RevisionLoader.tsx
  17. 9 0
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.module.scss
  18. 9 1
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  19. 60 56
      packages/app/src/components/PageComment.tsx
  20. 0 1
      packages/app/src/components/PageComment/Comment.tsx
  21. 1 3
      packages/app/src/components/PageComment/ReplyComments.tsx
  22. 0 1
      packages/app/src/components/PageTimeline.tsx
  23. 14 9
      packages/app/src/components/ReactMarkdownComponents/Header.tsx
  24. 2 1
      packages/app/src/components/ReactMarkdownComponents/NextLink.tsx
  25. 58 30
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  26. 2 1
      packages/app/src/pages/[[...path]].page.tsx
  27. 2 1
      packages/app/src/pages/_app.page.tsx
  28. 4 2
      packages/app/src/pages/admin/[[...path]].page.tsx
  29. 2 1
      packages/app/src/pages/share/[[...path]].page.tsx
  30. 33 4
      packages/app/src/pages/trash.page.tsx
  31. 2 0
      packages/app/src/pages/utils/commons.ts
  32. 1 1
      packages/app/src/server/routes/page.js
  33. 11 9
      packages/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts
  34. 4 0
      packages/app/src/stores/context.tsx
  35. 0 10
      packages/app/src/styles/_attachments.scss
  36. 14 14
      packages/app/test/cypress/integration/30-search/search.spec.ts
  37. 7 0
      yarn.lock

+ 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",

+ 3 - 3
packages/app/public/static/locales/en_US/translation.json

@@ -184,8 +184,7 @@
     "could_not_creata_path": "Couldn't create path."
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page.",
-    "link_sharing_is_disabled": "Link sharing is disabled."
+    "no_page_list": "There are no pages under this page."
   },
   "installer": {
     "setup": "Setup",
@@ -254,7 +253,8 @@
     "Unlimited": "unlimited",
     "Issue": "Issue",
     "share_settings" :"Share settings",
-    "Invalid_Number_of_Date" : "You entered invalid value"
+    "Invalid_Number_of_Date" : "You entered invalid value",
+    "link_sharing_is_disabled": "Link sharing is disabled"
   },
   "API Settings": "API settings",
   "API Token Settings": "API token settings",

+ 3 - 3
packages/app/public/static/locales/ja_JP/translation.json

@@ -177,8 +177,7 @@
     "could_not_creata_path": "パスを作成できませんでした。"
   },
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。",
-    "link_sharing_is_disabled": "リンクのシェアは無効化されています"
+    "no_page_list": "このページの配下にはページが存在しません。"
   },
   "installer": {
     "setup": "セットアップ",
@@ -247,7 +246,8 @@
     "Unlimited": "無期限",
     "Issue": "発行",
     "share_settings" :"共有設定",
-    "Invalid_Number_of_Date" : "有効期限の日数には整数を入力してください"
+    "Invalid_Number_of_Date" : "有効期限の日数には整数を入力してください",
+    "link_sharing_is_disabled": "リンクのシェアは無効化されています"
   },
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",

+ 3 - 3
packages/app/public/static/locales/zh_CN/translation.json

@@ -179,8 +179,7 @@
     "could_not_creata_path": "无法创建路径"
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page.",
-    "link_sharing_is_disabled": "链接共享已被禁用"
+    "no_page_list": "There are no pages under this page."
   },
 	"installer": {
 		"setup": "安装",
@@ -600,7 +599,8 @@
     "Unlimited": "unlimited",
     "Issue": "Issue",
     "share_settings" :"Share settings",
-    "Invalid_Number_of_Date" : "You entered invalid value"
+    "Invalid_Number_of_Date" : "You entered invalid value",
+    "link_sharing_is_disabled": "链接共享已被禁用"
   },
 	"notification_setting": {
 		"slack_incoming_configuration": "Slack Incoming Webhooks configuration",

+ 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,
 };

+ 2 - 4
packages/app/src/components/Admin/FullTextSearchManagement.tsx

@@ -4,8 +4,8 @@ import { useTranslation } from 'next-i18next';
 
 import ElasticsearchManagement from './ElasticsearchManagement/ElasticsearchManagement';
 
-const FullTextSearchManagement = (): JSX.Element => {
-  const { t } = useTranslation();
+export const FullTextSearchManagement = (): JSX.Element => {
+  const { t } = useTranslation('admin');
 
   return (
     <div data-testid="admin-full-text-search">
@@ -14,5 +14,3 @@ const FullTextSearchManagement = (): JSX.Element => {
     </div>
   );
 };
-
-export default FullTextSearchManagement;

+ 35 - 37
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -103,54 +103,48 @@ class ImportForm extends React.Component {
   setupWebsocketEventHandler() {
     const { socket } = this.props;
 
-    if (socket != null) {
-      // websocket event
-      // eslint-disable-next-line object-curly-newline
-      socket.on('admin:onProgressForImport', ({ collectionName, collectionProgress, appendedErrors }) => {
-        const { progressMap, errorsMap } = this.state;
-        progressMap[collectionName] = collectionProgress;
-
-        const errors = errorsMap[collectionName] || [];
-        errorsMap[collectionName] = errors.concat(appendedErrors);
-
-        this.setState({
-          isImporting: true,
-          progressMap,
-          errorsMap,
-        });
+    // websocket event
+    // eslint-disable-next-line object-curly-newline
+    socket.on('admin:onProgressForImport', ({ collectionName, collectionProgress, appendedErrors }) => {
+      const { progressMap, errorsMap } = this.state;
+      progressMap[collectionName] = collectionProgress;
+
+      const errors = errorsMap[collectionName] || [];
+      errorsMap[collectionName] = errors.concat(appendedErrors);
+
+      this.setState({
+        isImporting: true,
+        progressMap,
+        errorsMap,
       });
+    });
 
-      // websocket event
-      socket.on('admin:onTerminateForImport', () => {
-        this.setState({
-          isImporting: false,
-          isImported: true,
-        });
-
-        toastSuccess(undefined, 'Import process has completed.');
+    // websocket event
+    socket.on('admin:onTerminateForImport', () => {
+      this.setState({
+        isImporting: false,
+        isImported: true,
       });
 
-      // websocket event
-      socket.on('admin:onErrorForImport', (err) => {
-        this.setState({
-          isImporting: false,
-          isImported: false,
-        });
+      toastSuccess(undefined, 'Import process has completed.');
+    });
 
-        toastError(err, 'Import process has failed.');
+    // websocket event
+    socket.on('admin:onErrorForImport', (err) => {
+      this.setState({
+        isImporting: false,
+        isImported: false,
       });
 
-    }
-
+      toastError(err, 'Import process has failed.');
+    });
   }
 
   teardownWebsocketEventHandler() {
     const { socket } = this.props;
 
-    if (socket != null) {
-      socket.removeAllListeners('admin:onProgressForImport');
-      socket.removeAllListeners('admin:onTerminateForImport');
-    }
+    socket.removeAllListeners('admin:onProgressForImport');
+    socket.removeAllListeners('admin:onTerminateForImport');
   }
 
   async toggleCheckbox(collectionName, bool) {
@@ -500,7 +494,7 @@ class ImportForm extends React.Component {
 
 ImportForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  socket: PropTypes.object,
+  socket: PropTypes.object.isRequired,
 
   fileName: PropTypes.string,
   innerFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
@@ -512,6 +506,10 @@ const ImportFormWrapperFc = (props) => {
   const { t } = useTranslation('admin');
   const { data: socket } = useAdminSocket();
 
+  if (socket == null) {
+    return;
+  }
+
   return <ImportForm t={t} socket={socket} {...props} />;
 };
 

+ 10 - 9
packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx

@@ -32,26 +32,27 @@ const ManageGlobalNotification = (props) => {
   const [slackChannelToSend, setSlackChannelToSend] = useState('');
   const [triggerEvents, setTriggerEvents] = useState(new Set(globalNotification?.triggerEvents));
 
-  const onChangeTriggerEvents = (triggerEvent) => {
+  const onChangeTriggerEvents = useCallback((triggerEvent) => {
+    let newTriggerEvents;
 
     if (triggerEvents.has(triggerEvent)) {
-      triggerEvents.delete(triggerEvent);
-      setTriggerEvents(triggerEvents);
+      newTriggerEvents = ([...triggerEvents].filter(item => item !== triggerEvent));
+      setTriggerEvents(new Set(newTriggerEvents));
     }
     else {
-      triggerEvents.add(triggerEvent);
-      setTriggerEvents(triggerEvents);
+      newTriggerEvents = [...triggerEvents, triggerEvent];
+      setTriggerEvents(new Set(newTriggerEvents));
     }
-  };
+  }, [triggerEvents]);
 
   const submitHandler = useCallback(async() => {
 
     const requestParams = {
       triggerPath,
       notifyToType,
-      emailToSend,
-      slackChannelToSend,
-      triggerEvents,
+      toEmail: emailToSend,
+      slackChannels: slackChannelToSend,
+      triggerEvents: [...triggerEvents],
     };
 
     try {

+ 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 - 25
packages/app/src/components/ForbiddenPage.tsx

@@ -1,12 +1,7 @@
-import React, { useMemo } from 'react';
+import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
-import PageListIcon from './Icons/PageListIcon';
-
-
 type Props = {
   isLinkSharingDisabled?: boolean,
 }
@@ -14,17 +9,6 @@ type Props = {
 const ForbiddenPage = React.memo((props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const navTabMapping = useMemo(() => {
-    return {
-      pagelist: {
-        Icon: PageListIcon,
-        Content: DescendantsPageListForCurrentPath,
-        i18n: t('page_list'),
-        index: 0,
-      },
-    };
-  }, [t]);
-
   return (
     <>
       <div className="row not-found-message-row mb-4">
@@ -40,17 +24,10 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
             <i className="icon-fw icon-lock" aria-hidden="true" />
-            { props.isLinkSharingDisabled ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
+            { props.isLinkSharingDisabled ? t('share_links.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
           </p>
         </div>
       </div>
-
-      { !props.isLinkSharingDisabled && (
-        <div className="mt-5">
-          <CustomNavAndContents navTabMapping={navTabMapping} />
-        </div>
-      ) }
-
     </>
   );
 });

+ 39 - 19
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -61,25 +61,19 @@ const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
   loading: AuthorInfoSkelton,
 });
 
-type AdditionalMenuItemsProps = {
+type PageOperationMenuItemsProps = {
   pageId: string,
   revisionId: string,
   isLinkSharingDisabled?: boolean,
-  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
-
 }
 
-const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    pageId, revisionId, isLinkSharingDisabled, onClickTemplateMenuItem,
+    pageId, revisionId, isLinkSharingDisabled,
   } = props;
 
-  const openPageTemplateModalHandler = () => {
-    onClickTemplateMenuItem(true);
-  };
-
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
 
@@ -151,9 +145,25 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
         </span>
         {t('share_links.share_link_management')}
       </DropdownItem>
+    </>
+  );
+};
 
-      <DropdownItem divider />
+type CreateTemplateMenuItemsProps = {
+  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
+}
 
+const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { onClickTemplateMenuItem } = props;
+
+  const openPageTemplateModalHandler = () => {
+    onClickTemplateMenuItem(true);
+  };
+
+  return (
+    <>
       {/* Create template */}
       <DropdownItem
         onClick={openPageTemplateModalHandler}
@@ -175,7 +185,6 @@ type GrowiContextualSubNavigationProps = {
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
-  const path = currentPage?.path;
 
   const revision = currentPage?.revision;
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
@@ -203,6 +212,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: templateTagData } = useTemplateTagData();
 
+  const path = currentPage?.path ?? currentPathname;
 
   useEffect(() => {
     // Run only when tagsInfoData has been updated
@@ -306,15 +316,25 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const RightComponent = useCallback(() => {
     const additionalMenuItemsRenderer = () => {
       if (revisionId == null || pageId == null) {
-        return <></>;
+        return (
+          <>
+            <CreateTemplateMenuItems
+              onClickTemplateMenuItem={templateMenuItemClickHandler}
+            />
+          </>);
       }
       return (
-        <AdditionalMenuItems
-          pageId={pageId}
-          revisionId={revisionId}
-          isLinkSharingDisabled={isLinkSharingDisabled}
-          onClickTemplateMenuItem={templateMenuItemClickHandler}
-        />
+        <>
+          <PageOperationMenuItems
+            pageId={pageId}
+            revisionId={revisionId}
+            isLinkSharingDisabled={isLinkSharingDisabled}
+          />
+          <DropdownItem divider />
+          <CreateTemplateMenuItems
+            onClickTemplateMenuItem={templateMenuItemClickHandler}
+          />
+        </>
       );
     };
 
@@ -377,7 +397,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       </>
     );
   // eslint-disable-next-line max-len
-  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, isSharedUser, isAbleToShowPageManagement, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser, editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler, mutateEditorMode]);
+  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, currentPathname, isSharedUser, isAbleToShowPageManagement, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser, editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler, mutateEditorMode]);
 
 
   const pagePath = isNotFound

+ 19 - 2
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -5,12 +5,13 @@ import React, {
 import { isServer } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Image from 'next/image';
 import Link from 'next/link';
 import { useRipple } from 'react-use-ripple';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import {
-  useIsSearchPage, useCurrentPagePath, useIsGuestUser, useIsSearchServiceConfigured, useAppTitle, useConfidential,
+  useIsSearchPage, useCurrentPagePath, useIsGuestUser, useIsSearchServiceConfigured, useAppTitle, useConfidential, useCustomizedLogoSrc,
 } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { useIsDeviceSmallerThanMd } from '~/stores/ui';
@@ -119,6 +120,21 @@ const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps): JSX
 });
 Confidential.displayName = 'Confidential';
 
+interface NavbarLogoProps {
+  logoSrc?: string,
+}
+
+const GrowiNavbarLogo: FC<NavbarLogoProps> = memo((props: NavbarLogoProps) => {
+  const { logoSrc } = props;
+
+  return logoSrc != null
+    // eslint-disable-next-line @next/next/no-img-element
+    ? (<img src={logoSrc} alt="custom logo" className="picture picture-lg p-2 mx-2" id="settingBrandLogo" width="32" />)
+    : <GrowiLogo />;
+});
+
+GrowiNavbarLogo.displayName = 'GrowiNavbarLogo';
+
 export const GrowiNavbar = (): JSX.Element => {
 
   const GlobalSearch = dynamic<GlobalSearchProps>(() => import('./GlobalSearch').then(mod => mod.GlobalSearch), { ssr: false });
@@ -128,6 +144,7 @@ export const GrowiNavbar = (): JSX.Element => {
   const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isSearchPage } = useIsSearchPage();
+  const { data: customizedLogoSrc } = useCustomizedLogoSrc();
 
   return (
     <nav id="grw-navbar" className={`navbar grw-navbar ${styles['grw-navbar']} navbar-expand navbar-dark sticky-top mb-0 px-0`}>
@@ -135,7 +152,7 @@ export const GrowiNavbar = (): JSX.Element => {
       <div className="navbar-brand mr-0">
         <Link href="/" prefetch={false}>
           <a className="grw-logo d-block">
-            <GrowiLogo />
+            <GrowiNavbarLogo logoSrc={customizedLogoSrc}/>
           </a>
         </Link>
       </div>

+ 4 - 2
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -32,7 +32,7 @@ export type GrowiSubNavigationProps = {
   isCompactMode?: boolean,
   tags?: string[],
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
-  rightComponent: React.FunctionComponent,
+  rightComponent?: React.FunctionComponent,
   additionalClasses?: string[],
 }
 
@@ -81,7 +81,9 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
         </div>
       </div>
       {/* Right side. */}
-      <RightComponent />
+      { RightComponent && (
+        <RightComponent />
+      ) }
     </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>
   );
 };

+ 9 - 0
packages/app/src/components/PageAttachment/DeleteAttachmentModal.module.scss

@@ -0,0 +1,9 @@
+.attachment-delete-modal :global {
+  .attachment-delete-image {
+    text-align: center;
+
+    img {
+      max-width: 100%;
+    }
+  }
+}

+ 9 - 1
packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -10,6 +10,8 @@ import {
 
 import Username from '../User/Username';
 
+import styles from './DeleteAttachmentModal.module.scss';
+
 
 function iconNameByFormat(format: string): string {
   if (format.match(/image\/.+/i)) {
@@ -74,7 +76,13 @@ export const DeleteAttachmentModal = (props: Props): JSX.Element => {
 
 
   return (
-    <Modal isOpen={isOpen} className="attachment-delete-modal" size="lg" aria-labelledby="contained-modal-title-lg" fade={false}>
+    <Modal
+      isOpen={isOpen}
+      className={`${styles['attachment-delete-modal']} attachment-delete-modal`}
+      size="lg"
+      aria-labelledby="contained-modal-title-lg"
+      fade={false}
+    >
       <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
         <span id="contained-modal-title-lg">Delete attachment?</span>
       </ModalHeader>

+ 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
         />

+ 2 - 1
packages/app/src/pages/[[...path]].page.tsx

@@ -65,7 +65,7 @@ import {
   useIsAclEnabled, useIsUserPage, useIsSearchPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useLayoutSetting,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useLayoutSetting, useCustomizedLogoSrc,
 } from '../stores/context';
 
 import {
@@ -187,6 +187,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // commons
   useEditorConfig(props.editorConfig);
   useCsrfToken(props.csrfToken);
+  useCustomizedLogoSrc(props.customizedLogoSrc);
 
   // UserUISettings
   usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);

+ 2 - 1
packages/app/src/pages/_app.page.tsx

@@ -9,7 +9,7 @@ import * as nextI18nConfig from '^/config/next-i18next.config';
 
 import { useI18nextHMR } from '~/services/i18next-hmr';
 import {
-  useAppTitle, useConfidential, useGrowiTheme, useGrowiVersion, useSiteUrl,
+  useAppTitle, useConfidential, useGrowiTheme, useGrowiVersion, useSiteUrl, useCustomizedLogoSrc,
 } from '~/stores/context';
 import { SWRConfigValue, swrGlobalConfiguration } from '~/utils/swr-utils';
 
@@ -53,6 +53,7 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
   useConfidential(commonPageProps.confidential);
   useGrowiTheme(commonPageProps.theme);
   useGrowiVersion(commonPageProps.growiVersion);
+  useCustomizedLogoSrc(commonPageProps.customizedLogoSrc);
 
   return (
     <SWRConfig value={swrConfig}>

+ 4 - 2
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -58,7 +58,9 @@ const SlackIntegration = dynamic(() => import('../../components/Admin/SlackInteg
 const LegacySlackIntegration = dynamic(() => import('../../components/Admin/LegacySlackIntegration/LegacySlackIntegration'), { ssr: false });
 const UserManagement = dynamic(() => import('../../components/Admin/UserManagement'), { ssr: false });
 const ManageExternalAccount = dynamic(() => import('../../components/Admin/ManageExternalAccount'), { ssr: false });
-const ElasticsearchManagement = dynamic(() => import('../../components/Admin/ElasticsearchManagement/ElasticsearchManagement'), { ssr: false });
+const FullTextSearchManagement = dynamic(
+  () => import('../../components/Admin/FullTextSearchManagement').then(mod => mod.FullTextSearchManagement), { ssr: false },
+);
 const UserGroupDetailPage = dynamic(() => import('../../components/Admin/UserGroupDetail/UserGroupDetailPage'), { ssr: false });
 const AdminLayout = dynamic(() => import('../../components/Layout/AdminLayout'), { ssr: false });
 // named export
@@ -176,7 +178,7 @@ const AdminPage: NextPage<Props> = (props: Props) => {
     },
     search: {
       title: t('full_text_search_management.full_text_search_management'),
-      component: <ElasticsearchManagement />,
+      component: <FullTextSearchManagement />,
     },
     'audit-log': {
       title: t('audit_log_management.audit_log'),

+ 2 - 1
packages/app/src/pages/share/[[...path]].page.tsx

@@ -91,8 +91,9 @@ const SharedPage: NextPage<Props> = (props: Props) => {
                 </div>
               )}
 
-              { (props.isExpired && !props.disableLinkSharing) && (
+              { (props.isExpired && !props.disableLinkSharing && shareLink != null) && (
                 <div className="container-lg">
+                  <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
                   <h2 className="text-muted mt-4">
                     <i className="icon-ban" aria-hidden="true" />
                     <span> Page is expired</span>

+ 33 - 4
packages/app/src/pages/trash.page.tsx

@@ -5,16 +5,20 @@ import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 
+import { GrowiSubNavigation } from '~/components/Navbar/GrowiSubNavigation';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
+import {
+  useCurrentProductNavWidth, useCurrentSidebarContents, useDrawerMode, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed,
+} from '~/stores/ui';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
-import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import {
   useCurrentUser, useCurrentPageId, useCurrentPagePath, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL,
+  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser,
 } from '../stores/context';
 
 import {
@@ -30,8 +34,12 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
-  userUISettings?: IUserUISettings,
   showPageLimitationXL: number,
+
+  // UI
+  userUISettings?: IUserUISettings
+  // Sidebar
+  sidebarConfig: ISidebarConfig,
 };
 
 const TrashPage: NextPage<CommonProps> = (props: Props) => {
@@ -46,13 +54,29 @@ const TrashPage: NextPage<CommonProps> = (props: Props) => {
   useCurrentPathname('/trash');
   useCurrentPagePath('/trash');
 
+  // UserUISettings
+  usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
+  usePreferDrawerModeOnEditByUser(props.userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(props.userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
+  useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
+
   useShowPageLimitationXL(props.showPageLimitationXL);
 
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: isGuestUser } = useIsGuestUser();
+
   return (
     <>
       <BasicLayout title={useCustomTitle(props, 'GROWI')} >
         <header className="py-0 position-relative">
-          <GrowiContextualSubNavigation isLinkSharingDisabled={false} />
+          <GrowiSubNavigation
+            pagePath="/trash"
+            showDrawerToggler={isDrawerMode}
+            isGuestUser={isGuestUser}
+            isDrawerMode={isDrawerMode}
+            additionalClasses={['container-fluid']}
+          />
         </header>
 
         <div className="grw-container-convertible mb-5 pb-5">
@@ -93,6 +117,11 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
   props.showPageLimitationXL = crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL');
+
+  props.sidebarConfig = {
+    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
+    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+  };
 }
 
 /**

+ 2 - 0
packages/app/src/pages/utils/commons.ts

@@ -21,6 +21,7 @@ export type CommonProps = {
   growiVersion: string,
   isMaintenanceMode: boolean,
   redirectDestination: string | null,
+  customizedLogoSrc?: string,
 } & Partial<SSRConfig>;
 
 // eslint-disable-next-line max-len
@@ -53,6 +54,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     growiVersion: crowi.version,
     isMaintenanceMode,
     redirectDestination,
+    customizedLogoSrc: configManager.getConfig('crowi', 'customize:customizedLogoSrc'),
   };
 
   return { props };

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -1077,7 +1077,7 @@ module.exports = function(crowi, app) {
       target: page,
       action: SupportedAction.ACTION_PAGE_UPDATE,
     };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+    activityEvent.emit('update', res.locals.activity._id, parameters, { path: page.path, creator: page.creator._id.toString() });
   };
 
   /**

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

+ 4 - 0
packages/app/src/stores/context.tsx

@@ -271,6 +271,10 @@ export const useCustomizeTitle = (initialData?: string): SWRResponse<string, Err
   return useStaticSWR('CustomizeTitle', initialData);
 };
 
+export const useCustomizedLogoSrc = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('customizedLogoSrc', initialData);
+};
+
 /** **********************************************************
  *                     Computed contexts
  *********************************************************** */

+ 0 - 10
packages/app/src/styles/_attachments.scss

@@ -1,13 +1,3 @@
-.attachment-delete-modal {
-  .attachment-delete-image {
-    text-align: center;
-
-    img {
-      max-width: 100%;
-    }
-  }
-}
-
 .attachment-userpicture .picture {
   vertical-align: text-bottom;
 }

+ 14 - 14
packages/app/test/cypress/integration/30-search/search.spec.ts

@@ -16,7 +16,7 @@ context('Access to search result page', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // for avoid mismatch by auto scrolling
     cy.get('.search-result-content-body-container').scrollTo('top');
     cy.screenshot(`${ssPrefix}with-q`);
@@ -28,7 +28,7 @@ context('Access to search result page', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // for avoid mismatch by auto scrolling
     cy.get('.search-result-content-body-container').scrollTo('top');
 
@@ -105,7 +105,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     // for avoid mismatch by auto scrolling
@@ -116,7 +116,7 @@ context('Search all pages', () => {
 
     cy.getByTestid('open-page-item-control-btn').eq(1).click();
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // for avoid mismatch by auto scrolling
     cy.get('.search-result-content-body-container').scrollTo('top');
     cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});
@@ -168,7 +168,7 @@ context('Search all pages', () => {
     });
 
     cy.visit('/');
-    cy.get('.rbt-input').click();
+    cy.get('.rbt-input').click({force: true});
     cy.get('.rbt-input-main').type(`${searchText}`);
     cy.screenshot(`${ssPrefix}1-insert-search-text-with-tag`, { capture: 'viewport'});
     cy.get('.rbt-input-main').type('{enter}');
@@ -176,14 +176,14 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.screenshot(`${ssPrefix}2-search-with-tag-result`, {capture: 'viewport'});
 
     cy.getByTestid('open-page-item-control-btn').first().click();
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}3-click-three-dots-menu-search-with-tag`, {capture: 'viewport'});
 
   });
@@ -192,11 +192,11 @@ context('Search all pages', () => {
     const tag = 'help';
 
     cy.visit('/');
-    cy.get('.grw-taglabels-container > form > a').contains(tag).click();
+    cy.get('.grw-taglabels-container > div > a').contains(tag).click();
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.screenshot(`${ssPrefix}1-tag-order-click-tag-name`, {capture: 'viewport'});
@@ -209,7 +209,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}2-tag-order-by-relevance`);
 
     cy.get('.grw-search-page-nav').within(() => {
@@ -220,7 +220,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}3-tag-order-by-creation-date`);
 
     cy.get('.grw-search-page-nav').within(() => {
@@ -231,7 +231,7 @@ context('Search all pages', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}4-tag-order-by-last-update-date`);
   });
 
@@ -265,7 +265,7 @@ context('Search current tree with "prefix":', () => {
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     // for avoid mismatch by auto scrolling
@@ -276,7 +276,7 @@ context('Search current tree with "prefix":', () => {
 
     cy.getByTestid('open-page-item-control-btn').first().click();
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    cy.get('.wiki').should('be.visible');
     // for avoid mismatch by auto scrolling
     cy.get('.search-result-content-body-container').scrollTo('top');
     cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});

+ 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"