import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import isNil from '../utils/isNil'; import { smoothScroll } from '../utils/scroll'; import { ElevatorContext } from './context'; import { ElevatorAnchor } from './ElevatorAnchor'; import { ElevatorLinks } from './ElevatorLinks'; const SCROLL_DURATION = 200; export interface IElevatorProps { getContainer?: () => HTMLElement; targetOffset?: number; offsetTop?: number; activeLink?: string; defaultActiveLink?: string; onChange?: (currentLink: string, previousLink: string) => void; } type IElevator = FC & { Links: typeof ElevatorLinks; Anchor: typeof ElevatorAnchor; }; const getDefaultContainer = () => window; export const Elevator: IElevator = ({ children, onChange, targetOffset, getContainer, defaultActiveLink, offsetTop: propOffsetTop, activeLink: propActiveLink, }) => { const getContainerResult = getContainer?.(); const [anchorElementsMap, setAnchorElementsMap] = useState< Map >(new Map()); const [internalActiveLink, setInternalActiveLink] = useState(''); const activeLink = useMemo( () => (!isNil(propActiveLink) ? propActiveLink : internalActiveLink), [propActiveLink, internalActiveLink] ); const isScrolling = useRef(false); const handleAnchorEnter = (link: string) => { setInternalActiveLink(link); if (isScrolling.current || link === activeLink) return; onChange?.(link, activeLink); }; // 计算activeLink切换的锚点偏移量 const offsetTop = useMemo(() => { let containerHeight = getDefaultContainer().innerHeight; if (getContainerResult) { containerHeight = getContainerResult.getBoundingClientRect().height; } // 默认偏移量为container高度的一半 const defaultOffsetTop = containerHeight / 2; return !isNil(propOffsetTop) ? containerHeight - (propOffsetTop || 1) : defaultOffsetTop; }, [propOffsetTop, getContainerResult]); const handleRegisterAnchor = useCallback((link: string, el: HTMLElement) => { setAnchorElementsMap(prev => { const map = new Map(prev); map.set(link, el); return map; }); }, []); const handleUnRegister = useCallback((link: string) => { setAnchorElementsMap(prev => { const map = new Map(prev); map.delete(link); return map; }); }, []); const handleScrollToLink = (link: string, controlled = false) => { const el = anchorElementsMap.get(link); if (!el) return; const bounds = el.getBoundingClientRect(); let container: HTMLElement | Window = getDefaultContainer(); let containerTop = 0; let scrollTop = container.scrollY; let scrollLeft = container.scrollX; if (getContainerResult) { container = getContainerResult; const containerBounds = container.getBoundingClientRect(); containerTop = containerBounds.top; scrollTop = container.scrollTop; scrollLeft = container.scrollLeft; } const scrollTopTarget = bounds.top - containerTop + scrollTop - (targetOffset || 0); isScrolling.current = true; smoothScroll(container, scrollLeft, scrollTopTarget, SCROLL_DURATION).then( () => { isScrolling.current = false; !controlled && onChange?.(link, activeLink); } ); }; const handleLinkClick = (link: string) => { if (isNil(propActiveLink)) { handleScrollToLink(link); } else if (link !== activeLink) { onChange?.(link, activeLink); } }; useEffect(() => { if (!isNil(propActiveLink) && propActiveLink !== internalActiveLink) { handleScrollToLink(propActiveLink, true); } if (isNil(propActiveLink) && defaultActiveLink) { handleScrollToLink(defaultActiveLink); } // 受控状态下仅在外部传入的 activeLink 改变时触发滚动 // eslint-disable-next-line react-hooks/exhaustive-deps }, [propActiveLink, anchorElementsMap, defaultActiveLink]); return ( {children} ); }; Elevator.Links = ElevatorLinks; Elevator.Anchor = ElevatorAnchor; export default Elevator;