Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions components/Sidebar/index.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import SideBar from '@node-core/ui-components/Containers/Sidebar';
import { sidebar } from '../../site.json' with { type: 'json' };
import { useRef, useLayoutEffect } from 'react';
import useScrollToElement from '../../hooks/useScrollToElement';

/** @param {string} url */
const redirect = url => (window.location.href = url);
Expand All @@ -9,12 +11,26 @@ const PrefetchLink = props => <a {...props} rel="prefetch" />;
/**
* Sidebar component for MDX documentation with page navigation
*/
export default ({ metadata }) => (
<SideBar
pathname={`/learn${metadata.path.replace('/index', '')}`}
groups={sidebar}
onSelect={redirect}
as={PrefetchLink}
title="Navigation"
/>
);
export default ({ metadata }) => {
const sidebarRef = useRef(null);

// SideBar from @node-core/ui-components does not support forwardRef,
// so ref={sidebarRef} is silently ignored. useLayoutEffect runs before
// useEffect, so by the time useScroll's effect attaches the scroll
// listener, sidebarRef.current already points to the real <aside> element.
useLayoutEffect(() => {
sidebarRef.current = document.querySelector('aside');
}, []);

useScrollToElement('sidebar', sidebarRef);

return (
<SideBar
pathname={`/learn${metadata.path.replace('/index', '')}`}
groups={sidebar}
onSelect={redirect}
as={PrefetchLink}
title="Navigation"
/>
);
};
51 changes: 51 additions & 0 deletions hooks/useScroll.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect, useRef } from 'react';

// Custom hook to handle scroll events with optional debouncing
const useScroll = (ref, { debounceTime = 300, onScroll }) => {
const timeoutRef = useRef(undefined);
const onScrollRef = useRef(onScroll);

// Keep onScrollRef updated with the latest callback
useEffect(() => {
onScrollRef.current = onScroll;
}, [onScroll]);
useEffect(() => {
// Get the current element
const element = ref.current;

// Return early if no element or onScroll callback is provided
if (!element || !onScrollRef.current) {
return;
}

// Debounced scroll handler
const handleScroll = () => {
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}

// Set new timeout to call onScroll after debounceTime
timeoutRef.current = setTimeout(() => {
if (element && onScrollRef.current) {
onScrollRef.current({
x: element.scrollLeft,
y: element.scrollTop,
});
}
}, debounceTime);
};

element.addEventListener('scroll', handleScroll, { passive: true });

return () => {
element.removeEventListener('scroll', handleScroll);
// Clear any pending debounced calls
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [ref.current, debounceTime]);
};

export default useScroll;
55 changes: 55 additions & 0 deletions hooks/useScrollToElement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useContext, useEffect } from 'react';

import { NavigationStateContext } from '../providers/navigationStateProvider';

import useScroll from './useScroll';

const useScrollToElement = (id, ref, debounceTime = 300) => {
const navigationState = useContext(NavigationStateContext);

// Restore scroll position on mount
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}

// Prefer in-memory context state (set during same session/SPA navigation).
// Fall back to localStorage so position is restored after a full page refresh.
let savedState = navigationState[id];

if (!savedState) {
try {
const raw = localStorage.getItem(`navigationState`);
if (raw) {
savedState = JSON.parse(raw);
// Hydrate context so it's available for the rest of the session
navigationState[id] = savedState;
}
} catch {
localStorage.removeItem(`navigationState`);
}
}

// Scroll only if the saved position differs from current
if (savedState && savedState.y !== element.scrollTop) {
element.scroll({ top: savedState.y, behavior: 'auto' });
}
// navigationState is intentionally excluded
// it's a stable object reference that doesn't need to trigger re-runs
}, [id, ref.current]);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

// Save scroll position on scroll
const handleScroll = position => {
console.log('handleScroll', position);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
localStorage.setItem(`navigationState`, JSON.stringify(position));
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
// Save the current scroll position in the navigation state
const state = navigationState;
state[id] = position;
};
Comment thread
cursor[bot] marked this conversation as resolved.

// Use the useScroll hook to handle scroll events with debouncing
useScroll(ref, { debounceTime, onScroll: handleScroll });
};

export default useScrollToElement;
15 changes: 15 additions & 0 deletions providers/navigationStateProvider.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client';

import { createContext, useRef } from 'react';

export const NavigationStateContext = createContext({});

export const NavigationStateProvider = ({children}) => {
const navigationStateRef = useRef({});

return (
<NavigationStateContext.Provider value={navigationStateRef.current}>
{children}
</NavigationStateContext.Provider>
);
Comment thread
cursor[bot] marked this conversation as resolved.
};
Loading