Browse Source

Merge pull request #5476 from weseek/fix/scroll-to-specific-pagetree-item

feat: Auto scroll pagetree on init
Yuki Takei 4 years ago
parent
commit
c95793160b

+ 1 - 0
packages/app/config/logger/config.dev.js

@@ -36,6 +36,7 @@ module.exports = {
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:cli:StickyStretchableScroller': 'debug',
+  // 'growi:cli:ItemsTree': 'debug',
   'growi:searchResultList': 'debug',
 
 };

+ 4 - 3
packages/app/package.json

@@ -58,6 +58,8 @@
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@growi/codemirror-textlint": "^5.0.0-RC.8",
@@ -90,8 +92,6 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
-    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
-    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "diff_match_patch": "^0.1.1",
     "entities": "^2.0.0",
     "esa-node": "^0.2.2",
@@ -187,8 +187,8 @@
     "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
-    "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-regex": "^1.8.0",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "hard-source-webpack-plugin": "^0.13.1",
@@ -238,6 +238,7 @@
     "sass": "^1.43.4",
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",
+    "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.2.0",
     "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",

+ 13 - 18
packages/app/src/components/Sidebar.tsx

@@ -9,6 +9,7 @@ import {
   useCurrentSidebarContents,
   useCurrentProductNavWidth,
   useSidebarResizeDisabled,
+  useSidebarScrollerRef,
 } from '~/stores/ui';
 
 import DrawerToggler from './Navbar/DrawerToggler';
@@ -16,7 +17,7 @@ import DrawerToggler from './Navbar/DrawerToggler';
 import SidebarNav from './Sidebar/SidebarNav';
 import SidebarContents from './Sidebar/SidebarContents';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
-import StickyStretchableScroller from './StickyStretchableScroller';
+import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 const sidebarMinWidth = 240;
 const sidebarMinimizeWidth = 20;
@@ -50,31 +51,25 @@ const GlobalNavigation = () => {
 };
 
 const SidebarContentsWrapper = () => {
-  const [resetKey, setResetKey] = useState(0);
-
-  const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
+  const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
 
   const calcViewHeight = useCallback(() => {
-    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
-    return scrollTargetElem != null
-      ? window.innerHeight - scrollTargetElem?.getBoundingClientRect().top
+    const elem = document.querySelector('#grw-sidebar-contents-wrapper');
+    return elem != null
+      ? window.innerHeight - elem?.getBoundingClientRect().top
       : window.innerHeight;
   }, []);
 
   return (
     <>
-      <StickyStretchableScroller
-        scrollTargetSelector={scrollTargetSelector}
-        contentsElemSelector="#grw-sidebar-content-container"
-        stickyElemSelector=".grw-sidebar"
-        calcViewHeightFunc={calcViewHeight}
-        resetKey={resetKey}
-      />
-
-      <div id="grw-sidebar-contents-scroll-target" style={{ minHeight: '100%' }}>
-        <div id="grw-sidebar-content-container" className="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
+      <div id="grw-sidebar-contents-wrapper" style={{ minHeight: '100%' }}>
+        <StickyStretchableScroller
+          simplebarRef={mutateSidebarScroller}
+          stickyElemSelector=".grw-sidebar"
+          calcViewHeight={calcViewHeight}
+        >
           <SidebarContents />
-        </div>
+        </StickyStretchableScroller>
       </div>
 
       <DrawerToggler iconClass="icon-arrow-left" />

+ 2 - 2
packages/app/src/components/Sidebar/PageTree.tsx

@@ -16,10 +16,10 @@ const PageTree: FC = memo(() => {
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
-  const { data: notFoundTargetPathOrIdData } = useNotFoundTargetPathOrId();
+  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
-  const targetPathOrId = targetId || notFoundTargetPathOrIdData?.notFoundTargetPathOrId;
+  const targetPathOrId = targetId || notFoundTargetPathOrId;
 
   if (migrationStatus == null) {
     return (

+ 0 - 8
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -36,7 +36,6 @@ interface ItemProps {
   isEnableActions: boolean
   itemNode: ItemNode
   targetPathOrId?: string
-  isScrolled: boolean,
   isOpen?: boolean
   isEnabledAttachTitleHeader?: boolean
   onRenamed?(): void
@@ -364,12 +363,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   };
 
 
-  useEffect(() => {
-    if (!props.isScrolled && page.isTarget) {
-      document.dispatchEvent(new CustomEvent('targetItemRendered'));
-    }
-  }, [props.isScrolled, page.isTarget]);
-
   // didMount
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
@@ -479,7 +472,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               isEnableActions={isEnableActions}
               itemNode={node}
               isOpen={false}
-              isScrolled={props.isScrolled}
               targetPathOrId={targetPathOrId}
               isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
               onRenamed={onRenamed}

+ 92 - 74
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,6 +1,12 @@
-import React, { FC, useEffect, useState } from 'react';
+import React, {
+  useEffect, useRef, useState, useMemo, useCallback,
+} from 'react';
 import { useTranslation } from 'react-i18next';
 
+import { debounce } from 'throttle-debounce';
+
+import loggerFactory from '~/utils/logger';
+
 import { usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage } from '~/stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { IPageHasId, IPageToDeleteWithMeta } from '~/interfaces/page';
@@ -10,17 +16,17 @@ import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
 } from '~/stores/modal';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import { useFullTextSearchTermManager } from '~/stores/search';
 import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 import { useGlobalSocket } from '~/stores/websocket';
-import { usePageTreeDescCountMap } from '~/stores/ui';
+import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 
 import { ItemNode } from './ItemNode';
 import Item from './Item';
 
+const logger = loggerFactory('growi:cli:ItemsTree');
 
 /*
  * Utility to generate initial node
@@ -61,6 +67,7 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
   return rootNode;
 };
 
+
 type ItemsTreeProps = {
   isEnableActions: boolean
   targetPath: string
@@ -68,53 +75,10 @@ type ItemsTreeProps = {
   targetAndAncestorsData?: TargetAndAncestors
 }
 
-const renderByInitialNode = (
-    initialNode: ItemNode,
-    isEnableActions: boolean,
-    isScrolled: boolean,
-    targetPathOrId?: string,
-    isEnabledAttachTitleHeader?: boolean,
-    onRenamed?: () => void,
-    onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
-): JSX.Element => {
-
-  return (
-    <ul className="grw-pagetree list-group p-3">
-      <Item
-        key={initialNode.page.path}
-        targetPathOrId={targetPathOrId}
-        itemNode={initialNode}
-        isOpen
-        isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
-        isEnableActions={isEnableActions}
-        onRenamed={onRenamed}
-        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-        onClickDeleteMenuItem={onClickDeleteMenuItem}
-        isScrolled={isScrolled}
-      />
-    </ul>
-  );
-};
-
-// --- Auto scroll related vars and util ---
-
-const SCROLL_OFFSET_TOP = window.innerHeight / 2;
-
-const scrollTargetItem = () => {
-  const scrollElement = document.getElementById('grw-sidebar-contents-scroll-target');
-  const target = document.getElementById('grw-pagetree-is-target');
-  if (scrollElement != null && target != null) {
-    smoothScrollIntoView(target, SCROLL_OFFSET_TOP, scrollElement);
-  }
-};
-// --- end ---
-
-
 /*
  * ItemsTree
  */
-const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
+const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
     targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions,
   } = props;
@@ -126,28 +90,22 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDeleteModal } = usePageDeleteModal();
-  const [isScrolled, setIsScrolled] = useState(false);
+  const { data: sidebarScrollerRef } = useSidebarScrollerRef();
 
   const { data: socket } = useGlobalSocket();
   const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap();
 
-
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advanceFts } = useFullTextSearchTermManager();
   const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
 
-  const scrollItem = () => {
-    scrollTargetItem();
-    setIsScrolled(true);
-  };
+  const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
 
-  useEffect(() => {
-    document.addEventListener('targetItemRendered', scrollItem);
-    return () => {
-      document.removeEventListener('targetItemRendered', scrollItem);
-    };
-  }, []);
+  const rootElemRef = useRef(null);
+
+
+  const isSecondStageRendering = ancestorsChildrenData != null && rootPageData != null;
 
   useEffect(() => {
     if (socket == null) {
@@ -207,35 +165,95 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   };
 
+  // ***************************  Scroll on init ***************************
+  const scrollOnInit = useCallback(() => {
+    const scrollTargetElement = document.getElementById('grw-pagetree-is-target');
+
+    if (sidebarScrollerRef?.current == null || scrollTargetElement == null) {
+      return;
+    }
+
+    logger.debug('scrollOnInit has invoked');
+
+    const scrollElement = sidebarScrollerRef.current.getScrollElement();
+
+    // NOTE: could not use scrollIntoView
+    //  https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
+
+    // calculate the center point
+    const scrollTop = scrollTargetElement.offsetTop - scrollElement.getBoundingClientRect().height / 2;
+    scrollElement.scrollTo({ top: scrollTop });
+
+    setIsInitialScrollCompleted(true);
+  }, [sidebarScrollerRef]);
+
+  const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
+
+  useEffect(() => {
+    if (!isSecondStageRendering || isInitialScrollCompleted) {
+      return;
+    }
+
+    const rootElement = rootElemRef.current as HTMLElement | null;
+    if (rootElement == null) {
+      return;
+    }
+
+    const observerCallback = (mutationRecords: MutationRecord[]) => {
+      mutationRecords.forEach(() => scrollOnInitDebounced());
+    };
+
+    const observer = new MutationObserver(observerCallback);
+    observer.observe(rootElement, { childList: true, subtree: true });
+
+    // first call for the situation that all rendering is complete at this point
+    scrollOnInitDebounced();
+
+    return () => {
+      observer.disconnect();
+    };
+  }, [isInitialScrollCompleted, isSecondStageRendering, scrollOnInitDebounced]);
+  // *******************************  end  *******************************
+
   if (error1 != null || error2 != null) {
     // TODO: improve message
     toastError('Error occurred while fetching pages to render PageTree');
-    return null;
+    return <></>;
   }
 
+  let initialItemNode;
   /*
-   * Render completely
+   * Render second stage
    */
-  if (ancestorsChildrenData != null && rootPageData != null) {
-    const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(
-      // eslint-disable-next-line max-len
-      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem,
-    );
+  if (isSecondStageRendering) {
+    initialItemNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
   }
-
   /*
    * Before swr response comes back
    */
-  if (targetAndAncestorsData != null) {
-    const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(
-      // eslint-disable-next-line max-len
-      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+  else if (targetAndAncestorsData != null) {
+    initialItemNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
+  }
+
+  if (initialItemNode != null) {
+    return (
+      <ul className="grw-pagetree list-group p-3" ref={rootElemRef}>
+        <Item
+          key={initialItemNode.page.path}
+          targetPathOrId={targetPathOrId}
+          itemNode={initialItemNode}
+          isOpen
+          isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
+          isEnableActions={isEnableActions}
+          onRenamed={onRenamed}
+          onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+          onClickDeleteMenuItem={onClickDeleteMenuItem}
+        />
+      </ul>
     );
   }
 
-  return null;
+  return <></>;
 };
 
 export default ItemsTree;

+ 1 - 1
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -121,7 +121,7 @@ SmallPageItem.propTypes = {
 };
 
 
-const RecentChanges: FC<void> = () => {
+const RecentChanges = (): JSX.Element => {
 
   const { t } = useTranslation();
   const { data: pages, mutate } = useSWRxRecentlyUpdated();

+ 0 - 168
packages/app/src/components/StickyStretchableScroller.jsx

@@ -1,168 +0,0 @@
-import React, { useEffect, useCallback } from 'react';
-import PropTypes from 'prop-types';
-
-import { debounce } from 'throttle-debounce';
-import StickyEvents from 'sticky-events';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:cli:StickyStretchableScroller');
-
-
-/**
- * USAGE:
- *
-  const calcViewHeight = useCallback(() => {
-    const containerElem = document.querySelector('#sticky-elem');
-    const containerTop = containerElem.getBoundingClientRect().top;
-
-    // stretch to the bottom of window
-    return window.innerHeight - containerTop;
-  });
-
-  return (
-    <StickyStretchableScroller
-      contentsElemSelector="#long-contents-elem"
-      stickyElemSelector="#sticky-elem"
-      calcViewHeightFunc={calcViewHeight}
-    >
-      <div id="scroll-elem">
-        ...
-      </div>
-    </StickyStretchableScroller>
-  );
-
-  or
-
-  return (
-    <StickyStretchableScroller
-      scrollTargetId="scroll-elem"
-      contentsElemSelector="#long-contents-elem"
-      stickyElemSelector="#sticky-elem"
-      calcViewHeightFunc={calcViewHeight}
-    />
-  );
- */
-const StickyStretchableScroller = (props) => {
-
-  let { scrollTargetSelector } = props;
-  const {
-    children, contentsElemSelector, stickyElemSelector,
-    calcViewHeightFunc, calcContentsHeightFunc,
-    resetKey,
-  } = props;
-
-  if (scrollTargetSelector == null && children == null) {
-    throw new Error('Either of scrollTargetSelector or children is required');
-  }
-
-  if (scrollTargetSelector == null) {
-    scrollTargetSelector = `#${children.props.id}`;
-  }
-
-  /**
-   * Reset scrollbar
-   */
-  const resetScrollbar = useCallback(() => {
-    const contentsElem = document.querySelector(contentsElemSelector);
-    if (contentsElem == null) {
-      return;
-    }
-
-    const viewHeight = calcViewHeightFunc != null
-      ? calcViewHeightFunc()
-      : 'auto';
-    const contentsHeight = calcContentsHeightFunc != null
-      ? calcContentsHeightFunc(contentsElem)
-      : contentsElem.getBoundingClientRect().height;
-
-    logger.debug(`[${scrollTargetSelector}] viewHeight`, viewHeight);
-    logger.debug(`[${scrollTargetSelector}] contentsHeight`, contentsHeight);
-
-    const isScrollEnabled = viewHeight === 'auto' || (viewHeight < contentsHeight);
-
-    $(scrollTargetSelector).slimScroll({
-      color: '#666',
-      railColor: '#999',
-      railVisible: true,
-      position: 'right',
-      height: isScrollEnabled ? viewHeight : contentsHeight,
-      wheelStep: 10,
-      allowPageScroll: true,
-    });
-
-    // destroy
-    if (!isScrollEnabled) {
-      $(scrollTargetSelector).slimScroll({ destroy: true });
-    }
-
-  }, [contentsElemSelector, calcViewHeightFunc, calcContentsHeightFunc, scrollTargetSelector]);
-
-  const resetScrollbarDebounced = debounce(100, resetScrollbar);
-
-
-  const stickyChangeHandler = useCallback((event) => {
-    logger.debug('StickyEvents.CHANGE detected');
-    setTimeout(resetScrollbar, 100);
-  }, [resetScrollbar]);
-
-  // setup effect by sticky event
-  useEffect(() => {
-    if (stickyElemSelector == null) {
-      return;
-    }
-
-    // sticky
-    // See: https://github.com/ryanwalters/sticky-events
-    const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
-    const { stickySelector } = stickyEvents;
-    const elem = document.querySelector(stickySelector);
-    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-    // return clean up handler
-    return () => {
-      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-    };
-  }, [stickyElemSelector, stickyChangeHandler]);
-
-  // setup effect by resizing event
-  useEffect(() => {
-    const resizeHandler = (event) => {
-      resetScrollbarDebounced();
-    };
-
-    window.addEventListener('resize', resizeHandler);
-
-    // return clean up handler
-    return () => {
-      window.removeEventListener('resize', resizeHandler);
-    };
-  }, [resetScrollbarDebounced]);
-
-  // setup effect on init
-  useEffect(() => {
-    if (resetKey != null) {
-      resetScrollbarDebounced();
-    }
-  }, [resetKey, resetScrollbarDebounced]);
-
-  return (
-    <>
-      { children }
-    </>
-  );
-};
-
-StickyStretchableScroller.propTypes = {
-  contentsElemSelector: PropTypes.string.isRequired,
-
-  children: PropTypes.node,
-  scrollTargetSelector: PropTypes.string,
-  stickyElemSelector: PropTypes.string,
-
-  resetKey: PropTypes.any,
-
-  calcViewHeightFunc: PropTypes.func,
-  calcContentsHeightFunc: PropTypes.func,
-};
-
-export default StickyStretchableScroller;

+ 125 - 0
packages/app/src/components/StickyStretchableScroller.tsx

@@ -0,0 +1,125 @@
+import React, {
+  useEffect, useCallback, ReactNode, useRef, useState, useMemo, RefObject,
+} from 'react';
+
+import { debounce } from 'throttle-debounce';
+import StickyEvents from 'sticky-events';
+import SimpleBar from 'simplebar-react';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:cli:StickyStretchableScroller');
+
+
+export type StickyStretchableScrollerProps = {
+  stickyElemSelector: string,
+  simplebarRef?: (ref: RefObject<SimpleBar>) => void,
+  calcViewHeight?: (scrollElement: HTMLElement) => number,
+  children?: ReactNode,
+}
+
+/**
+ * USAGE:
+ *
+  const calcViewHeight = useCallback(() => {
+    const containerElem = document.querySelector('#sticky-elem');
+    const containerTop = containerElem.getBoundingClientRect().top;
+
+    // stretch to the bottom of window
+    return window.innerHeight - containerTop;
+  });
+
+  return (
+    <StickyStretchableScroller
+      stickyElemSelector="#sticky-elem"
+      calcViewHeight={calcViewHeight}
+    >
+      <div>
+        ...
+      </div>
+    </StickyStretchableScroller>
+  );
+ */
+export const StickyStretchableScroller = (props: StickyStretchableScrollerProps): JSX.Element => {
+
+  const {
+    children, stickyElemSelector, calcViewHeight, simplebarRef: setSimplebarRef,
+  } = props;
+
+  const simplebarRef = useRef<SimpleBar>(null);
+  const [simplebarMaxHeight, setSimplebarMaxHeight] = useState<number|undefined>();
+
+  /**
+   * Reset scrollbar
+   */
+  const resetScrollbar = useCallback(() => {
+    if (simplebarRef.current == null || calcViewHeight == null) {
+      return;
+    }
+
+    const scrollElement = simplebarRef.current.getScrollElement();
+    const newHeight = calcViewHeight(scrollElement);
+
+    logger.debug('Set new height to simplebar', newHeight);
+
+    // set new height
+    setSimplebarMaxHeight(newHeight);
+    // reculculate
+    simplebarRef.current.recalculate();
+  }, [calcViewHeight]);
+
+  const resetScrollbarDebounced = useMemo(() => debounce(100, resetScrollbar), [resetScrollbar]);
+
+  const stickyChangeHandler = useCallback(() => {
+    logger.debug('StickyEvents.CHANGE detected');
+    resetScrollbarDebounced();
+  }, [resetScrollbarDebounced]);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
+    stickyEvents.enableEvents();
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyElemSelector, stickyChangeHandler]);
+
+  // setup effect by resizing event
+  useEffect(() => {
+    const resizeHandler = () => {
+      resetScrollbarDebounced();
+    };
+
+    window.addEventListener('resize', resizeHandler);
+
+    // return clean up handler
+    return () => {
+      window.removeEventListener('resize', resizeHandler);
+    };
+  }, [resetScrollbarDebounced]);
+
+  // setup effect on init
+  useEffect(() => {
+    resetScrollbarDebounced();
+  }, [resetScrollbarDebounced]);
+
+  // update ref
+  useEffect(() => {
+    if (setSimplebarRef != null) {
+      setSimplebarRef(simplebarRef);
+    }
+  }, [setSimplebarRef]);
+
+  return (
+    <SimpleBar style={{ maxHeight: simplebarMaxHeight }} ref={simplebarRef}>
+      { children }
+    </SimpleBar>
+  );
+};

+ 2 - 3
packages/app/src/components/TableOfContents.jsx

@@ -10,7 +10,7 @@ import { blinkElem } from '~/client/util/blink-section-header';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
-import StickyStretchableScroller from './StickyStretchableScroller';
+import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
@@ -56,9 +56,8 @@ const TableOfContents = (props) => {
 
   return (
     <StickyStretchableScroller
-      contentsElemSelector=".revision-toc .markdownIt-TOC"
       stickyElemSelector=".grw-side-contents-sticky-container"
-      calcViewHeightFunc={calcViewHeight}
+      calcViewHeight={calcViewHeight}
     >
       { tocHtml !== ''
         ? (

+ 0 - 4
packages/app/src/interfaces/page-listing-results.ts

@@ -23,10 +23,6 @@ export interface TargetAndAncestors {
 }
 
 
-export interface NotFoundTargetPathOrId {
-  notFoundTargetPathOrId: string
-}
-
 export interface IsNotFoundPermalink {
   isNotFoundPermalink: boolean
 }

+ 3 - 3
packages/app/src/stores/context.tsx

@@ -7,7 +7,7 @@ import { IUser } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
 
-import { TargetAndAncestors, NotFoundTargetPathOrId, IsNotFoundPermalink } from '../interfaces/page-listing-results';
+import { TargetAndAncestors, IsNotFoundPermalink } from '../interfaces/page-listing-results';
 
 type Nullable<T> = T | null;
 
@@ -132,8 +132,8 @@ export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResp
   return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData);
 };
 
-export const useNotFoundTargetPathOrId = (initialData?: Nullable<NotFoundTargetPathOrId>): SWRResponse<Nullable<NotFoundTargetPathOrId>, Error> => {
-  return useStaticSWR<Nullable<NotFoundTargetPathOrId>, Error>('notFoundTargetPathOrId', initialData);
+export const useNotFoundTargetPathOrId = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR<string, Error>('notFoundTargetPathOrId', initialData);
 };
 
 export const useIsNotFoundPermalink = (initialData?: Nullable<IsNotFoundPermalink>): SWRResponse<Nullable<IsNotFoundPermalink>, Error> => {

+ 13 - 2
packages/app/src/stores/ui.tsx

@@ -1,12 +1,14 @@
-import useSWR, {
+import { RefObject } from 'react';
+import {
   useSWRConfig, SWRResponse, Key, Fetcher,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
+import SimpleBar from 'simplebar-react';
+
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import { pagePathUtils } from '@growi/core';
 
-import { RefObject } from 'react';
 import { SidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -38,6 +40,15 @@ export const EditorMode = {
 export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
 
 
+/** **********************************************************
+ *                     Storing RefObjects
+ *********************************************************** */
+
+export const useSidebarScrollerRef = (initialData?: RefObject<SimpleBar>): SWRResponse<RefObject<SimpleBar>, Error> => {
+  return useStaticSWR<RefObject<SimpleBar>, Error>('sidebarScrollerRef', initialData);
+};
+
+
 /** **********************************************************
  *                          SWR Hooks
  *                      for switching UI

+ 3 - 0
packages/app/src/styles/_override-simplebar.scss

@@ -0,0 +1,3 @@
+.simplebar-scrollbar::before {
+  background-color: #666;
+}

+ 3 - 4
packages/app/src/styles/_sidebar.scss

@@ -74,13 +74,12 @@
             flex-direction: column;
             width: 100%;
             height: 100%;
-            overflow: hidden auto;
+            overflow: hidden;
           }
         }
       }
 
-      .grw-sidebar-content-container {
-        position: relative;
+      .simplebar-mask {
         z-index: 110; // greater than the value of .grw-navigation-draggable to fix https://redmine.weseek.co.jp/issues/86678
       }
     }
@@ -89,7 +88,7 @@
       top: 0px;
       bottom: 0px;
       left: 100%;
-      z-index: 100; // greater than the value of slimScrollBar
+      z-index: 10; // greater than the value of SimpleBar
       width: 0;
       .grw-navigation-draggable-hitarea {
         position: relative;

+ 3 - 0
packages/app/src/styles/_vendor.scss

@@ -23,3 +23,6 @@
 
 // import Handsontable styles
 @import '~handsontable/dist/handsontable.full.css';
+
+// import SimpleBar styles
+@import '~simplebar/dist/simplebar.min.css';

+ 3 - 0
packages/app/src/styles/style-app.scss

@@ -16,6 +16,9 @@
 // override react-bootstrap-typeahead styles
 @import 'override-rbt';
 
+// override simplebar-react styles
+@import 'override-simplebar';
+
 // atoms
 @import 'atoms/buttons';
 @import 'atoms/code';

+ 1 - 1
packages/app/test/cypress/integration/1-install/install.spec.ts

@@ -53,7 +53,7 @@ context('Installing', () => {
     cy.getByTestid('btnSubmit').click();
 
     cy.screenshot(`${ssPrefix}-installed`, {
-      blackout: ['.grw-sidebar-content-container'],
+      blackout: ['#grw-sidebar-contents-wrapper'],
     });
   });
 

+ 10 - 0
packages/app/test/cypress/support/index.ts

@@ -20,6 +20,16 @@ import './screenshot'
 // Alternatively you can use CommonJS syntax:
 // require('./commands')
 
+// Ignore 'ResizeObserver loop limit exceeded' exception
+// https://github.com/cypress-io/cypress/issues/8418
+const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/
+Cypress.on('uncaught:exception', (err) => {
+    /* returning false here prevents Cypress from failing the test */
+    if (resizeObserverLoopErrRe.test(err.message)) {
+        return false
+    }
+})
+
 declare global {
   // eslint-disable-next-line @typescript-eslint/no-namespace
   namespace Cypress {

+ 45 - 0
yarn.lock

@@ -1213,6 +1213,11 @@
     comment-json "^4.1.0"
     find-up "^4.1.0"
 
+"@juggle/resize-observer@^3.3.1":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
+  integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
+
 "@kaishuu0123/markdown-it-fence@^0.2.0":
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/@kaishuu0123/markdown-it-fence/-/markdown-it-fence-0.2.0.tgz#f46722bfce4ab7eb3e051def5090dcae1bd6e36b"
@@ -5273,6 +5278,11 @@ camelize@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
 
+can-use-dom@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/can-use-dom/-/can-use-dom-0.1.0.tgz#22cc4a34a0abc43950f42c6411024a3f6366b45a"
+  integrity sha1-IsxKNKCrxDlQ9CxkEQJKP2NmtFo=
+
 caniuse-api@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
@@ -6394,6 +6404,11 @@ core-js@^2.4.0:
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
   integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
 
+core-js@^3.0.1:
+  version "3.21.1"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.21.1.tgz#f2e0ddc1fc43da6f904706e8e955bc19d06a0d94"
+  integrity sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==
+
 core-js@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.2.1.tgz#cd41f38534da6cc59f7db050fe67307de9868b09"
@@ -12568,6 +12583,11 @@ lodash._reinterpolate@^3.0.0:
   resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
   integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
 
+lodash.debounce@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+  integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
+
 lodash.defaults@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
@@ -12677,6 +12697,11 @@ lodash.templatesettings@^4.0.0:
   dependencies:
     lodash._reinterpolate "^3.0.0"
 
+lodash.throttle@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
+  integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=
+
 lodash.truncate@^4.4.2:
   version "4.4.2"
   resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
@@ -18455,6 +18480,26 @@ simple-swizzle@^0.2.2:
   dependencies:
     is-arrayish "^0.3.1"
 
+simplebar-react@^2.3.6:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/simplebar-react/-/simplebar-react-2.3.6.tgz#27945a80b221caae8f241155cdba1bb6c201ebff"
+  integrity sha512-Igm/MRdt+LQ8edTTzjRoaATfXPPMByuUsVvHQHrkX7SH4jmvL85VshtOVcXFrOBspv9vqQtnIrOq/j9VmRSNDQ==
+  dependencies:
+    prop-types "^15.6.1"
+    simplebar "^5.3.6"
+
+simplebar@^5.3.6:
+  version "5.3.6"
+  resolved "https://registry.yarnpkg.com/simplebar/-/simplebar-5.3.6.tgz#d9383576a09a6fd09e19543f8e9906a3e87c3896"
+  integrity sha512-FJUMbV+hNDd/m+1/fvD41TXKd5mSdlI5zgBygkaQIV3SffNbcLhSbJT6ufTs8ZNRLJ6i+qc/KCFMqWmvlGWMhA==
+  dependencies:
+    "@juggle/resize-observer" "^3.3.1"
+    can-use-dom "^0.1.0"
+    core-js "^3.0.1"
+    lodash.debounce "^4.0.8"
+    lodash.memoize "^4.1.2"
+    lodash.throttle "^4.1.1"
+
 sisteransi@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.0.tgz#77d9622ff909080f1c19e5f4a1df0c1b0a27b88c"