This commit is contained in:
Greg Bergé
2025-04-13 10:02:36 +02:00
parent f29d2b8e9c
commit 5eba878657
5 changed files with 181 additions and 45 deletions
@@ -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 (
<ul className={tcls('sidebar-list-line:border-l', 'border-tint-subtle')}>
@@ -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) ? (
<AsideSectionHighlight
transition={springCurve}
className={tcls(
@@ -102,7 +106,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
'sidebar-list-default:border-tint',
],
activeId === section.id && [
activeIds.includes(section.id) && [
'text-primary-subtle',
'hover:text-primary',
'contrast-more:text-primary',
@@ -1,24 +1,26 @@
'use client';
import { useParams } from 'next/navigation';
import React from 'react';
import { useCallback, useEffect, useState } from 'react';
function getHash(): string | null {
if (typeof window === 'undefined') {
return null;
}
return window.location.hash.slice(1);
}
/**
* Hook to get the current hash from the URL.
* @see https://github.com/vercel/next.js/discussions/49465
*/
// How do I get the pathname with hash.
// source: https://github.com/vercel/next.js/discussions/49465
export function useHash() {
const params = useParams();
const [hash, setHash] = React.useState<string | null>(getHash);
React.useEffect(() => {
setHash(getHash());
}, [params]);
const getCurrentHash = useCallback(
() => (typeof window !== 'undefined' ? window.location.hash.replace(/^#!?/, '') : ''),
[]
);
const [hash, setHash] = useState<string>(getCurrentHash());
useEffect(() => {
const handleHashChange = () => {
setHash(getCurrentHash());
};
window.addEventListener('hashchange', handleHashChange);
return () => {
window.removeEventListener('hashchange', handleHashChange);
};
}, [getCurrentHash]);
return hash;
}
@@ -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<HTMLElement | Window | null>
): string[] {
const [activeSections, setActiveSections] = React.useState<string[]>(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;
}
@@ -17,7 +17,7 @@ import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react';
*/
export function useScrollListener(
listener: (event: Event) => void,
elementRef: RefObject<HTMLElement | Window> | null
elementRef: RefObject<HTMLElement | Window | null> | null
) {
const listenerRef = useRef(listener);
useLayoutEffect(() => {
@@ -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]);
}