UNPKG

@redocly/theme

Version:

Shared UI components lib

443 lines (363 loc) 12.7 kB
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import type { CodeWalkthroughStepAttr } from '@redocly/config'; import type { ActiveStep, WalkthroughStepsState } from '../../types/code-walkthrough'; import type { MarkerArea } from '../../types/marker'; import { getAdjacentValues, insertAt, isBrowser, removeElement } from '../../utils/js-utils'; import { ACTIVE_STEP_QUERY_PARAM } from '../../constants/code-walkthrough'; type CodeWalkthroughStep = CodeWalkthroughStepAttr & { compRef?: HTMLElement; markerRef?: HTMLElement; }; type StepWithIndex = CodeWalkthroughStep & { index: number; }; type Params = { steps: CodeWalkthroughStep[]; enableDeepLink: boolean; root: React.RefObject<HTMLDivElement | null>; }; export function useCodeWalkthroughSteps({ steps, enableDeepLink, root, }: Params): 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, ); const stepsMap = useMemo(() => { const map = new Map<string, StepWithIndex>(); steps.forEach((step, index) => { map.set(step.id, { ...step, index }); }); return map; // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(steps)]); const options = useMemo(() => { if (!isBrowser()) { return null; } const filtersElementHeight = filtersElementRef.current?.clientHeight || 0; const navbarHeight = document.querySelector('nav')?.getBoundingClientRect().height || 0; return { filtersElementHeight, navbarHeight, }; }, []); const [visibleSteps, setVisibleSteps] = useState<StepWithIndex[]>([]); const [markers, setMarkers] = useState<Record<string, MarkerArea>>({}); useEffect(() => { if (!root.current || !visibleSteps.length || !options) { return; } const markersMinTopOffset = options.filtersElementHeight + options.navbarHeight; const rootHeight = root.current?.clientHeight ?? 0; const lastStepOffset = visibleSteps[visibleSteps.length - 1]?.compRef?.offsetTop ?? 0; const deficit = Math.max(lastStepOffset - (rootHeight - window.innerHeight), 0); const groups = getGroups(visibleSteps); let markers: number[] = groups.flatMap((group) => getGroupMarkers(group)); if (deficit) { const startOffset = markersMinTopOffset; const endOffset = Math.max(rootHeight - window.innerHeight, 0); markers = distributeMarkers({ endOffset, markers, startOffset: markersMinTopOffset < endOffset ? startOffset : 0, }); } setMarkers( markers.reduce( (acc, marker, index) => { const step = visibleSteps[index]; acc[step.id] = { offset: marker, height: markers[index + 1] || !step.compRef ? (markers[index + 1] ?? rootHeight) - marker : step.compRef.clientHeight, }; return acc; }, {} as Record<string, MarkerArea>, ), ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [visibleSteps, root.current, options]); const registerMarker = useCallback( (stepId: string, element: HTMLElement) => { if (observerRef.current) { const step = stepsMap.get(stepId); if (step) { step.markerRef = element; } observerRef.current.observe(element); observedElementsRef.current.add(element); } }, [stepsMap], ); const removeMarker = useCallback( (stepId: string, element: HTMLElement) => { if (observerRef.current) { const step = stepsMap.get(stepId); if (step) { step.markerRef = undefined; } observerRef.current.unobserve(element); observedElementsRef.current.delete(element); } }, [stepsMap], ); const registerStep = useCallback( (stepId: string, element: HTMLElement) => { const step = stepsMap.get(stepId); if (!step) { return; } step.compRef = element; setVisibleSteps((prevSteps) => insertAt(prevSteps, step.index, step)); }, [stepsMap], ); const removeStep = useCallback( (stepId: string) => { const step = stepsMap.get(stepId); if (!step) { return; } step.compRef = undefined; setVisibleSteps((prevSteps) => removeElement(prevSteps, step)); setActiveStep((prevStep) => (prevStep === stepId ? null : prevStep)); }, [stepsMap], ); const observerCallback = useCallback( (entries: IntersectionObserverEntry[]) => { if (lockObserver.current || !visibleSteps.length) { return; } if (visibleSteps.length < 2) { setActiveStep(visibleSteps[0]?.id || null); return; } for (const entry of entries) { const stepId = (entry.target as HTMLElement)?.dataset?.stepId; if (!stepId) { continue; } const { intersectionRatio, boundingClientRect, rootBounds, isIntersecting } = entry; const step = stepsMap.get(stepId); if (!step) { continue; } const stepIndex = visibleSteps.findIndex((renderedStep) => renderedStep.id === stepId); const { next } = getAdjacentValues(visibleSteps, stepIndex); const intersectionAtTop = rootBounds?.bottom !== undefined && boundingClientRect.top < rootBounds.top; const stepGoesIn = isIntersecting; if (intersectionRatio > 0.8 && intersectionRatio < 1 && intersectionAtTop) { 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; } setActiveStep((prevStep) => newStep || prevStep); break; } } }, [stepsMap, visibleSteps], ); useEffect(() => { if (!options) { return; } const newObserver = new IntersectionObserver(observerCallback, { threshold: [0.3, 0.8, 0.9, 0.95], rootMargin: `-${options.filtersElementHeight + options.navbarHeight}px 0px 0px 0px`, }); for (const observedElement of observedElementsRef.current.values()) { newObserver.observe(observedElement); } observerRef.current?.disconnect(); observerRef.current = newObserver; }, [observerCallback, markers, options]); useEffect(() => { if (!options) { return; } const rootTopOffset = root.current?.offsetTop ?? 0; if (!activeStep && rootTopOffset <= options.navbarHeight) { setActiveStep(visibleSteps[0]?.id || null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeStep, root.current, options, visibleSteps]); /** * 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 { registerStep, removeStep, markers, registerMarker, removeMarker, lockObserver, filtersElementRef, activeStep, setActiveStep, }; } type StepsGroup = { freeSpace: number; usedSpace: number; offset: number; steps: { offset: number; height: number; ref?: HTMLElement }[]; }; /** * This function analyzes the offset and height of each step to determine * when a new group should be created. A new group is started when there is a free space * between the two steps, treating it as the content of the next group header. * * @param steps - An array of `CodeWalkthroughStep` objects * * @returns An array of `StepsGroup` objects, each containing the offset from the top of the relative * block, the free space at the top of the group, the total space used by the steps within the group * and the steps themselves with relative offset and height. */ function getGroups(steps: CodeWalkthroughStep[]): StepsGroup[] { if (!steps.length) { return []; } const firstStepOffset = steps[0]?.compRef?.offsetTop ?? 0; const firstStepHeight = steps[0]?.compRef?.clientHeight ?? 0; const secondStepOffset = steps[1]?.compRef?.offsetTop ?? 0; const margin = Math.max(secondStepOffset - firstStepOffset - firstStepHeight, 0); let groupIndex = 0; const groups: StepsGroup[] = [ { offset: 0, freeSpace: firstStepOffset, usedSpace: 0, steps: [], }, ]; for (let i = 0; i < steps.length; i++) { let currentGroup = groups[groupIndex]; const step = steps[i]; const stepHeight = step.compRef?.clientHeight ?? 0; const stepOffset = step.compRef?.offsetTop ?? 0; const prevStepOffset = currentGroup.freeSpace + currentGroup.usedSpace; if (prevStepOffset !== Math.max(stepOffset - currentGroup.offset, 0)) { const offset = currentGroup.offset + currentGroup.freeSpace + currentGroup.usedSpace; groupIndex++; groups[groupIndex] = { offset, freeSpace: Math.max(stepOffset - offset, 0), usedSpace: 0, steps: [], }; currentGroup = groups[groupIndex]; } currentGroup.steps.push({ offset: stepOffset - currentGroup.offset, height: stepHeight, ref: step.compRef, }); currentGroup.usedSpace += stepHeight + margin; } return groups; } export function getGroupMarkers(group: StepsGroup) { if (!group.steps.length) { return []; } if (group.steps.length === 1) { return [group.offset + group.steps[0].offset - group.freeSpace]; } const availableFreeSpace = group.freeSpace > 0.3 * window.innerHeight ? 0.3 * window.innerHeight : group.freeSpace; const unusedFreeSpace = group.freeSpace - availableFreeSpace; const lastStepOffset = group.steps[group.steps.length - 1].offset; // distribute group free space between steps return distributeMarkers({ startOffset: 0, endOffset: lastStepOffset - unusedFreeSpace, markers: group.steps.map((step) => step.offset), additionalSteps: [(marker) => group.offset + unusedFreeSpace + marker], }); } /** * Distribute markers preserving the relationship throughout the available space * @param startOffset - the starting point of the available space * @param endOffset - the end point of the available space * @param markers - the markers to distribute * @param additionalSteps - additional steps to apply to the markers * * @returns array of markers positions */ function distributeMarkers({ endOffset, markers, startOffset, additionalSteps = [], }: { startOffset: number; endOffset: number; markers: number[]; additionalSteps?: ((marker: number) => number)[]; }) { return markers.map((marker) => { const normalizedOffset = getNormalizedNumber({ min: markers[0], max: markers[markers.length - 1], value: marker, }); const availableSpace = endOffset - startOffset; let result = startOffset + normalizedOffset * availableSpace; for (const additionalStep of additionalSteps) { result = additionalStep(result); } return result; }); } /** * Normalize a number between a min and max value * @param min - the minimum value of the distribution * @param max - the maximum value of the distribution * @param value - the value to normalize * * @returns normalized number between 0 and 1 */ function getNormalizedNumber(options: { min: number; max: number; value: number }) { const { min, max, value } = options; return (value - min) / (max - min); }