@redocly/theme
Version:
Shared UI components lib
181 lines (146 loc) • 5.66 kB
text/typescript
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import type { CodeWalkthroughStepAttr } from '@redocly/config';
import { getAdjacentValues } from '@redocly/theme/core/utils';
import { ACTIVE_STEP_QUERY_PARAM } from '@redocly/theme/core/constants';
type ActiveStep = string | null;
type CodeWalkthroughStep = CodeWalkthroughStepAttr & {
compRef?: HTMLElement;
};
export type WalkthroughStepsState = {
activeStep: ActiveStep;
setActiveStep: (stepId: ActiveStep) => void;
register: (element: HTMLElement) => void;
unregister: (element: HTMLElement) => void;
lockObserver?: React.RefObject<boolean>;
filtersElementRef?: React.RefObject<HTMLDivElement | null>;
};
export function useCodeWalkthroughSteps(
steps: CodeWalkthroughStep[],
enableDeepLink: boolean,
): WalkthroughStepsState {
const location = useLocation();
const navigate = useNavigate();
const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
const observerRef = useRef<IntersectionObserver | null>(null);
const filtersElementRef = useRef<HTMLDivElement>(null);
const lockObserver = useRef<boolean>(false);
// Track observed elements in case new observer needs to be created
const observedElementsRef = useRef(new Set<HTMLElement>());
const [activeStep, setActiveStep] = useState<ActiveStep>(
enableDeepLink ? searchParams.get(ACTIVE_STEP_QUERY_PARAM) : null,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const _steps = useMemo(() => steps, [JSON.stringify(steps)]);
const register = useCallback(
(element: HTMLElement) => {
// for some reason, the observer is not ready immediately
setTimeout(() => {
if (observerRef.current) {
const stepKey = Number(element.dataset.stepKey);
if (Number.isInteger(stepKey) && stepKey >= 0 && _steps[stepKey]) {
_steps[stepKey].compRef = element;
}
observerRef.current.observe(element);
observedElementsRef.current.add(element);
}
}, 10);
},
[_steps],
);
const unregister = useCallback(
(element: HTMLElement) => {
if (observerRef.current) {
const stepKey = Number(element.dataset.stepKey);
if (Number.isInteger(stepKey) && stepKey >= 0 && _steps[stepKey]) {
_steps[stepKey].compRef = undefined;
}
observerRef.current.unobserve(element);
observedElementsRef.current.delete(element);
}
},
[_steps],
);
const observerCallback = useCallback(
(entries: IntersectionObserverEntry[]) => {
if (lockObserver.current) {
return;
}
const renderedSteps = _steps.filter((step) => Boolean(step.compRef));
if (renderedSteps.length < 2) {
setActiveStep(renderedSteps[0]?.id || null);
return;
}
for (const entry of entries) {
const stepKey = Number((entry.target as HTMLElement)?.dataset?.stepKey);
if (!Number.isInteger(stepKey) || stepKey < 0) {
continue;
}
const { intersectionRatio, boundingClientRect, rootBounds, isIntersecting } = entry;
const step = _steps[stepKey];
const stepIndex = renderedSteps.findIndex(
(renderedStep) => renderedStep.stepKey === step.stepKey,
);
const { next } = getAdjacentValues(renderedSteps, stepIndex);
const intersectionAtTop =
rootBounds?.bottom !== undefined && boundingClientRect.top < rootBounds.top;
const stepGoesIn = isIntersecting;
if (
intersectionRatio > 0.8 &&
intersectionRatio < 1 &&
intersectionAtTop &&
activeStep === null
) {
setActiveStep(step.id);
break;
}
if (intersectionRatio < 1 && intersectionRatio !== 0 && intersectionAtTop) {
let newStep: string | null = null;
if (stepGoesIn) {
newStep = step.id;
} else if (next) {
newStep = next.id;
}
if (newStep !== activeStep) {
setActiveStep(newStep);
}
break;
}
}
},
[_steps, activeStep],
);
useEffect(() => {
const filtersElementHeight = filtersElementRef.current?.clientHeight || 0;
const navbarHeight = document.querySelector('nav')?.clientHeight || 0;
const newObserver = new IntersectionObserver(observerCallback, {
threshold: [0.8, 0.85, 0.9, 0.95],
rootMargin: `-${filtersElementHeight + navbarHeight}px 0px 0px 0px`,
});
for (const observedElement of observedElementsRef.current) {
newObserver.observe(observedElement);
}
// Unobserve all from the old observer
observerRef.current?.disconnect();
observerRef.current = newObserver;
}, [observerCallback]);
/**
* Update the URL search params with the current state of the filters and inputs
*/
useEffect(() => {
if (!enableDeepLink) {
return;
}
const newSearchParams = new URLSearchParams(Array.from(searchParams.entries()));
if (activeStep) {
newSearchParams.set(ACTIVE_STEP_QUERY_PARAM, activeStep);
} else {
newSearchParams.delete(ACTIVE_STEP_QUERY_PARAM);
}
const newSearch = newSearchParams.toString();
if (newSearch === location.search.substring(1)) return;
navigate({ search: newSearch }, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStep]);
return { register, unregister, lockObserver, filtersElementRef, activeStep, setActiveStep };
}