@redocly/theme
Version:
Shared UI components lib
443 lines (363 loc) • 12.7 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 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);
}