diff --git a/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx b/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx
index a2adf315e..82688786e 100644
--- a/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx
+++ b/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx
@@ -1,18 +1,19 @@
'use client';
import { motion } from 'framer-motion';
-import React from 'react';
+import React, { useRef } from 'react';
-import { useScrollActiveId } from '@/components/hooks';
+// import { useScrollActiveId } from '@/components/hooks';
import type { DocumentSection } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
-import { HEADER_HEIGHT_DESKTOP } from '../layout';
+import { usePageActiveSections } from '../hooks/usePageActiveSections';
+// import { HEADER_HEIGHT_DESKTOP } from '../layout';
import { AsideSectionHighlight } from './AsideSectionHighlight';
/**
* The threshold at which we consider a section as intersecting the viewport.
*/
-const SECTION_INTERSECTING_THRESHOLD = 0.9;
+// const SECTION_INTERSECTING_THRESHOLD = 0.9;
const springCurve = {
type: 'spring',
@@ -30,10 +31,13 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
});
}, [sections]);
- const activeId = useScrollActiveId(ids, {
- rootMargin: `-${HEADER_HEIGHT_DESKTOP}px 0px -40% 0px`,
- threshold: SECTION_INTERSECTING_THRESHOLD,
- });
+ const windowRef = useRef(typeof window === 'undefined' ? null : window);
+ const activeIds = usePageActiveSections(ids, windowRef);
+
+ // const activeId = useScrollActiveId(ids, {
+ // rootMargin: `-${HEADER_HEIGHT_DESKTOP}px 0px -40% 0px`,
+ // threshold: SECTION_INTERSECTING_THRESHOLD,
+ // });
return (
@@ -49,7 +53,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0']
)}
>
- {activeId === section.id ? (
+ {activeIds.includes(section.id) ? (
(getHash);
- React.useEffect(() => {
- setHash(getHash());
- }, [params]);
+ const getCurrentHash = useCallback(
+ () => (typeof window !== 'undefined' ? window.location.hash.replace(/^#!?/, '') : ''),
+ []
+ );
+ const [hash, setHash] = useState(getCurrentHash());
+
+ useEffect(() => {
+ const handleHashChange = () => {
+ setHash(getCurrentHash());
+ };
+ window.addEventListener('hashchange', handleHashChange);
+
+ return () => {
+ window.removeEventListener('hashchange', handleHashChange);
+ };
+ }, [getCurrentHash]);
+
return hash;
}
diff --git a/packages/gitbook/src/components/hooks/usePageActiveSections.ts b/packages/gitbook/src/components/hooks/usePageActiveSections.ts
new file mode 100644
index 000000000..8e7be6c22
--- /dev/null
+++ b/packages/gitbook/src/components/hooks/usePageActiveSections.ts
@@ -0,0 +1,128 @@
+'use client';
+import * as React from 'react';
+import { useEventCallback } from 'usehooks-ts';
+import { useScrollListener } from './useScrollListener';
+
+/**
+ * Hook that returns the active section key of the page.
+ * The active section is the section that is currently visible on the viewport.
+ *
+ * Here's a diagram: https://user-images.githubusercontent.com/8937991/206921462-14e5b8b7-5a19-4c8b-912d-ed1f17710bc4.png
+ */
+export function usePageActiveSections(
+ ids: string[],
+ scrollRef: React.RefObject
+): string[] {
+ const [activeSections, setActiveSections] = React.useState(noActiveSections);
+
+ const update = useEventCallback(() => {
+ const element = scrollRef.current;
+ if (element) {
+ const rect =
+ element instanceof Window
+ ? new DOMRect(0, 0, window.innerWidth, window.innerHeight)
+ : element.getBoundingClientRect();
+ const activeSections = findActiveSectionIds({
+ ids,
+ containerRect: rect,
+ });
+ setActiveSections((previous) => {
+ if (activeSections.length !== previous.length) {
+ return activeSections;
+ }
+ if (activeSections.some((key, i) => key !== previous[i])) {
+ return activeSections;
+ }
+ return previous;
+ });
+ }
+ });
+
+ useScrollListener(update, scrollRef);
+
+ React.useEffect(() => {
+ update();
+ }, [update]);
+
+ return activeSections;
+}
+
+const noActiveSections: string[] = [];
+
+// We consider anything in [-MARGINpx; +MARGINpx] to be at the boundary
+const MARGIN = 24;
+
+/**
+ * findActiveSectionIndex finds the section that is in the window's view
+ * it uses binary-search to efficiently (minimize DOM queries)
+ * find the DOM node whose .top is closest to but below `0` (the window's top)
+ */
+function findActiveSectionIndex(ids: string[], scrollTop: number): number {
+ const N = ids.length;
+ // Number of max iterations to find the element (so our search is bounded)
+ // in theory, at most floor(log(n)+1) steps to find the specific element
+ const maxIterations = Math.floor(Math.log2(N) + 1);
+ // Our binary search variables
+ let start = 0;
+ let end = N - 1;
+ let mid = Math.ceil((start + end) / 2);
+ let idx = -1;
+ let y: number | null = null;
+ for (let i = 0; i < maxIterations; i++) {
+ const id = ids[mid];
+ // Double check that we have a section, else exit
+ if (!id) {
+ return -1;
+ }
+ const element = document.getElementById(id);
+ const sy = element ? element.getBoundingClientRect().top - scrollTop : null;
+ if (sy === null || sy > MARGIN) {
+ // Go left
+ end = mid;
+ mid = Math.floor((start + end) / 2);
+ } else if (sy >= -MARGIN && sy <= MARGIN) {
+ // Hit
+ return mid;
+ } else if (sy < MARGIN) {
+ // If bigger than previous (but still smaller than 0)
+ // that becomes our new active index
+ if (y === null || sy > y) {
+ y = sy;
+ idx = mid;
+ }
+ // Go right
+ start = mid;
+ mid = Math.ceil((start + end) / 2);
+ }
+ }
+ return idx;
+}
+
+/**
+ * Find the active sections in the viewport.
+ */
+function findActiveSectionIds(input: {
+ ids: string[];
+ containerRect: DOMRect;
+}): string[] {
+ const activeIndex = findActiveSectionIndex(input.ids, input.containerRect.top);
+
+ // Check if the next sections are visible, stop when they are not.
+ const activeSections: string[] = input.ids[activeIndex] ? [input.ids[activeIndex]] : [];
+
+ for (let i = activeIndex + 1; i < input.ids.length; i++) {
+ const id = input.ids[i];
+ const element = document.getElementById(id);
+ if (!element) {
+ continue;
+ }
+
+ const rect = element.getBoundingClientRect();
+ if (rect.bottom + MARGIN > input.containerRect.bottom) {
+ break;
+ }
+
+ activeSections.push(id);
+ }
+ return activeSections;
+}
diff --git a/packages/gitbook/src/components/hooks/useScrollListener.ts b/packages/gitbook/src/components/hooks/useScrollListener.ts
index 639465691..c623b5bb5 100644
--- a/packages/gitbook/src/components/hooks/useScrollListener.ts
+++ b/packages/gitbook/src/components/hooks/useScrollListener.ts
@@ -17,7 +17,7 @@ import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react';
*/
export function useScrollListener(
listener: (event: Event) => void,
- elementRef: RefObject | null
+ elementRef: RefObject | null
) {
const listenerRef = useRef(listener);
useLayoutEffect(() => {
diff --git a/packages/gitbook/src/components/hooks/useScrollPage.ts b/packages/gitbook/src/components/hooks/useScrollPage.ts
index 0b3c7cb1b..f421fe9d1 100644
--- a/packages/gitbook/src/components/hooks/useScrollPage.ts
+++ b/packages/gitbook/src/components/hooks/useScrollPage.ts
@@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation';
import React from 'react';
import { useHash } from './useHash';
+import { usePrevious } from './usePrevious';
/**
* Scroll the page to an anchor point or
@@ -13,28 +14,29 @@ import { useHash } from './useHash';
export function useScrollPage(props: { scrollMarginTop?: number }) {
const hash = useHash();
const pathname = usePathname();
+ const prevPathname = usePrevious(pathname);
+ const scrollMarginTopRef = React.useRef(props.scrollMarginTop);
React.useLayoutEffect(() => {
if (hash) {
const element = document.getElementById(hash);
if (element) {
- if (props.scrollMarginTop) {
- element.style.scrollMarginTop = `${props.scrollMarginTop}px`;
+ const originalScrollMarginTop = element.style.scrollMarginTop;
+ if (scrollMarginTopRef.current) {
+ element.style.scrollMarginTop = `${scrollMarginTopRef.current}px`;
}
- element.scrollIntoView({
- block: 'start',
- behavior: 'smooth',
- });
+ element.scrollIntoView({ block: 'start', behavior: 'smooth' });
+ return () => {
+ const element = document.getElementById(hash);
+ if (element) {
+ element.style.scrollMarginTop = originalScrollMarginTop;
+ }
+ };
}
- } else {
+ return;
+ }
+
+ if (prevPathname && pathname !== prevPathname) {
window.scrollTo(0, 0);
}
- return () => {
- if (hash) {
- const element = document.getElementById(hash);
- if (element) {
- element.style.scrollMarginTop = '';
- }
- }
- };
- }, [hash, pathname, props.scrollMarginTop]);
+ }, [hash, pathname, prevPathname]);
}