diff --git a/apps/admin-x-settings/src/App.tsx b/apps/admin-x-settings/src/App.tsx index 218c616501..a0cb1b68b1 100644 --- a/apps/admin-x-settings/src/App.tsx +++ b/apps/admin-x-settings/src/App.tsx @@ -8,6 +8,7 @@ import {DefaultHeaderTypes} from './unsplash/UnsplashTypes'; import {FetchKoenigLexical, OfficialTheme, ServicesProvider} from './components/providers/ServiceProvider'; import {GlobalDirtyStateProvider} from './hooks/useGlobalDirtyState'; import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; +import {ScrollSectionProvider} from './hooks/useScrollSection'; import {ErrorBoundary as SentryErrorBoundary} from '@sentry/react'; import {Toaster} from 'react-hot-toast'; import {UpgradeStatusType} from './utils/globalTypes'; @@ -51,22 +52,24 @@ function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, d - - - -
- - - - -
-
-
-
+ + + + +
+ + + + +
+
+
+
+
diff --git a/apps/admin-x-settings/src/components/providers/RoutingProvider.tsx b/apps/admin-x-settings/src/components/providers/RoutingProvider.tsx index 3491ad0337..2c31a6766a 100644 --- a/apps/admin-x-settings/src/components/providers/RoutingProvider.tsx +++ b/apps/admin-x-settings/src/components/providers/RoutingProvider.tsx @@ -1,6 +1,6 @@ import NiceModal from '@ebay/nice-modal-react'; import React, {createContext, useCallback, useEffect, useState} from 'react'; -import {ScrollSectionProvider} from '../../hooks/useScrollSection'; +import {useScrollSectionContext} from '../../hooks/useScrollSection'; import type {ModalComponent, ModalName} from './routing/modals'; export type RouteParams = {[key: string]: string} @@ -124,6 +124,7 @@ type RouteProviderProps = { const RoutingProvider: React.FC = ({externalNavigate, children}) => { const [route, setRoute] = useState(undefined); const [loadingModal, setLoadingModal] = useState(false); + const {updateNavigatedSection, scrollToSection} = useScrollSectionContext(); useEffect(() => { // Preload all the modals after initial render to avoid a delay when opening them @@ -142,12 +143,14 @@ const RoutingProvider: React.FC = ({externalNavigate, childr const newPath = options.route; - if (newPath) { + if (newPath === route) { + scrollToSection(newPath.split('/')[0]); + } else if (newPath) { window.location.hash = `/settings/${newPath}`; } else { window.location.hash = `/settings`; } - }, [externalNavigate]); + }, [externalNavigate, route, scrollToSection]); useEffect(() => { const handleHashChange = () => { @@ -172,6 +175,12 @@ const RoutingProvider: React.FC = ({externalNavigate, childr }; }, []); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (route !== undefined) { + updateNavigatedSection(route.split('/')[0]); + } + }, [route, updateNavigatedSection]); + if (route === undefined) { return null; } @@ -184,9 +193,7 @@ const RoutingProvider: React.FC = ({externalNavigate, childr loadingModal }} > - - {children} - + {children} ); }; diff --git a/apps/admin-x-settings/src/hooks/useScrollSection.tsx b/apps/admin-x-settings/src/hooks/useScrollSection.tsx index d378a34252..bd5b0dedf2 100644 --- a/apps/admin-x-settings/src/hooks/useScrollSection.tsx +++ b/apps/admin-x-settings/src/hooks/useScrollSection.tsx @@ -4,12 +4,16 @@ interface ScrollSectionContextData { updateSection: (id: string, element: HTMLDivElement) => void; updateNav: (id: string, element: HTMLLIElement) => void; currentSection: string | null; + updateNavigatedSection: (id: string) => void; + scrollToSection: (id: string) => void; } const ScrollSectionContext = createContext({ updateSection: () => {}, updateNav: () => {}, - currentSection: null + currentSection: null, + updateNavigatedSection: () => {}, + scrollToSection: () => {} }); export const useScrollSectionContext = () => useContext(ScrollSectionContext); @@ -63,51 +67,70 @@ const scrollSidebarNav = (navElement: HTMLLIElement, doneInitialScroll: boolean) } }; +const getIntersectingSections = (current: string[], entries: IntersectionObserverEntry[], sectionElements: Record) => { + const entriesWithId = entries.map(({isIntersecting, target}) => ({ + isIntersecting, + id: Object.entries(sectionElements).find(([, element]) => element === target)?.[0] + })).filter(entry => entry.id) as {id: string; isIntersecting: boolean}[]; + + const newlyIntersectingIds = entriesWithId.filter(entry => !current.includes(entry.id) && entry.isIntersecting).map(entry => entry.id); + const unintersectingIds = entriesWithId.filter(entry => !entry.isIntersecting).map(entry => entry.id); + + const newSections = current.filter(section => !unintersectingIds.includes(section)).concat(newlyIntersectingIds); + + newSections.sort((first, second) => { + const firstElement = sectionElements[first]; + const secondElement = sectionElements[second]; + + if (!firstElement || !secondElement) { + return 0; + } + + return firstElement.getBoundingClientRect().top - secondElement.getBoundingClientRect().top; + }); + + return newSections; +}; + export const ScrollSectionProvider: React.FC<{ - navigatedSection: string; children: ReactNode; -}> = ({navigatedSection, children}) => { +}> = ({children}) => { + const [navigatedSection, _setNavigatedSection] = useState(null); const sectionElements = useRef>({}); + const intersectionObserver = useRef(null); const [intersectingSections, setIntersectingSections] = useState([]); const [lastIntersectedSection, setLastIntersectedSection] = useState(null); + const [hasUpdatedNavigatedSection, setHasUpdatedNavigatedSection] = useState(false); const [doneInitialScroll, setDoneInitialScroll] = useState(false); const [, setDoneSidebarScroll] = useState(false); + const setNavigatedSection = useCallback((value: string) => { + _setNavigatedSection(value); + setHasUpdatedNavigatedSection(true); + }, []); + const navElements = useRef>({}); - const intersectionObserver = useMemo(() => new IntersectionObserver((entries) => { - const entriesWithId = entries.map(({isIntersecting, target}) => ({ - isIntersecting, - id: Object.entries(sectionElements.current).find(([, element]) => element === target)?.[0] - })).filter(entry => entry.id) as {id: string; isIntersecting: boolean}[]; + const setupIntersectionObserver = useCallback(() => { + const observer = new IntersectionObserver((entries) => { + setIntersectingSections((sections) => { + const newSections = getIntersectingSections(sections, entries, sectionElements.current); - setIntersectingSections((sections) => { - const newlyIntersectingIds = entriesWithId.filter(entry => !sections.includes(entry.id) && entry.isIntersecting).map(entry => entry.id); - const unintersectingIds = entriesWithId.filter(entry => !entry.isIntersecting).map(entry => entry.id); - - const newSections = sections.filter(section => !unintersectingIds.includes(section)).concat(newlyIntersectingIds); - - newSections.sort((first, second) => { - const firstElement = sectionElements.current[first]; - const secondElement = sectionElements.current[second]; - - if (!firstElement || !secondElement) { - return 0; + if (newSections.length) { + setLastIntersectedSection(newSections[0]); } - return firstElement.getBoundingClientRect().top - secondElement.getBoundingClientRect().top; + return newSections; }); - - if (newSections.length) { - setLastIntersectedSection(newSections[0]); - } - - return newSections; + }, { + rootMargin: `-${scrollMargin - 50}px 0px -40% 0px` }); - }, { - rootMargin: `-${scrollMargin - 50}px 0px -40% 0px` - }), []); + + Object.values(sectionElements.current).forEach(element => observer.observe(element)); + + return observer; + }, []); const updateSection = useCallback((id: string, element: HTMLDivElement) => { if (sectionElements.current[id] === element) { @@ -115,11 +138,11 @@ export const ScrollSectionProvider: React.FC<{ } if (sectionElements.current[id]) { - intersectionObserver.unobserve(sectionElements.current[id]); + intersectionObserver.current?.unobserve(sectionElements.current[id]); } sectionElements.current[id] = element; - intersectionObserver.observe(element); + intersectionObserver.current?.observe(element); if (!doneInitialScroll && id === navigatedSection) { scrollToSection(element, false); @@ -131,6 +154,12 @@ export const ScrollSectionProvider: React.FC<{ navElements.current[id] = element; }, []); + const scrollTo = useCallback((id: string) => { + if (sectionElements.current[id]) { + scrollToSection(sectionElements.current[id], true); + } + }, []); + const currentSection = useMemo(() => { if (navigatedSection && intersectingSections.includes(navigatedSection)) { return navigatedSection; @@ -144,28 +173,40 @@ export const ScrollSectionProvider: React.FC<{ }, [intersectingSections, lastIntersectedSection, navigatedSection]); useEffect(() => { + if (!hasUpdatedNavigatedSection) { + return; + } + if (navigatedSection && sectionElements.current[navigatedSection]) { setDoneInitialScroll((done) => { scrollToSection(sectionElements.current[navigatedSection], done); return true; }); + } else { + // No navigated section means opening settings without a path + setDoneInitialScroll(true); } - }, [navigatedSection]); + + // Wait for the initial scroll so that the intersecting sections are correct + setTimeout(() => setupIntersectionObserver()); + }, [hasUpdatedNavigatedSection, navigatedSection, setupIntersectionObserver]); useEffect(() => { - if (currentSection && navElements.current[currentSection]) { + if (hasUpdatedNavigatedSection && currentSection && navElements.current[currentSection]) { setDoneSidebarScroll((done) => { scrollSidebarNav(navElements.current[currentSection], done); return true; }); } - }, [currentSection]); + }, [hasUpdatedNavigatedSection, currentSection]); return ( {children}