/** * Modified from https://github.com/civiccc/react-waypoint */ import { Children, cloneElement, createRef, PureComponent } from 'react'; import { addEventListener } from '../utils/component/event-handler'; import { isForwardRef } from 'react-is'; import { computeOffsetPixels } from './offset'; import isDOMElement from '../utils/isDOMElement'; import { getCurrentPosition, WaypointPosition } from './position'; import isBrowser from '../utils/isBrowser'; import defer from '../utils/defer'; import { ICancelable } from '../utils/types'; import { parseBorderWidth, parseFontSize } from './cssom'; export interface IWaypointCallbackData { currentPosition: WaypointPosition; previousPosition: WaypointPosition; event: Event | null; waypointTop: number; waypointBottom: number; viewportTop: number; viewportBottom: number; } export interface IWaypointProps { scrollableAncestor?: HTMLElement | Window; children?: React.ReactNode; topOffset?: number | string; bottomOffset?: number | string; horizontal?: boolean; fireOnRapidScroll?: boolean; onEnter?: (data: IWaypointCallbackData) => void; onLeave?: (data: IWaypointCallbackData) => void; onPositionChange?: (data: IWaypointCallbackData) => void; } const DEFAULT_OFFSET = 0; export class Waypoint extends PureComponent { static defaultProps = { topOffset: DEFAULT_OFFSET, bottomOffset: DEFAULT_OFFSET, horizontal: false, fireOnRapidScroll: true, }; private refElement: React.MutableRefObject = createRef(); private cancelOnNextTick: ICancelable; private scrollEventListenerUnsubscribe: () => void; private resizeEventListenerUnsubscribe: () => void; private scrollableAncestor: HTMLElement | Window; private previousPosition = WaypointPosition.Unknown; componentDidMount() { if (!isBrowser) { return; } // this.refElement may occasionally not be set at this time. To help ensure that // this works smoothly and to avoid layout thrashing, we want to delay the // initial execution until the next tick. this.cancelOnNextTick = defer(() => { this.cancelOnNextTick = null; const { children } = this.props; // Before doing anything, we want to check that this.refElement is available in Waypoint ensureRefIsUsedByChild(children, this.refElement.current); this.scrollableAncestor = this.findScrollableAncestor(); this.scrollEventListenerUnsubscribe = addEventListener( this.scrollableAncestor, 'scroll', this.handleScroll, { passive: true } ); this.resizeEventListenerUnsubscribe = addEventListener( window, 'resize', this.handleScroll, { passive: true } ); this.handleScroll(null); }); } componentDidUpdate() { if (!isBrowser) { return; } if (!this.scrollableAncestor) { // The Waypoint has not yet initialized. return; } // The element may have moved, so we need to recompute its position on the // page. This happens via handleScroll in a way that forces layout to be // computed. // // We want this to be deferred to avoid forcing layout during render, which // causes layout thrashing. And, if we already have this work enqueued, we // can just wait for that to happen instead of enqueueing again. if (this.cancelOnNextTick) { return; } this.cancelOnNextTick = defer(() => { this.cancelOnNextTick = null; this.handleScroll(null); }); } componentWillUnmount() { if (!isBrowser) { return; } this.scrollEventListenerUnsubscribe?.(); this.resizeEventListenerUnsubscribe?.(); this.cancelOnNextTick?.cancel(); } /** * Traverses up the DOM to find an ancestor container which has an overflow * style that allows for scrolling. * * @returns the closest ancestor element with an overflow style that * allows for scrolling. If none is found, the `window` object is returned * as a fallback. */ private findScrollableAncestor(): HTMLElement | Window { const { horizontal, scrollableAncestor } = this.props; if (scrollableAncestor) { return scrollableAncestor; } let node: Node = this.refElement.current; while (node.parentNode) { node = node.parentNode; if (node === document.body) { // We've reached all the way to the root node. return window; } const style = getComputedStyle(node as Element); const overflowDirec = horizontal ? style.getPropertyValue('overflow-x') : style.getPropertyValue('overflow-y'); const overflow = overflowDirec || style.getPropertyValue('overflow'); if (overflow === 'auto' || overflow === 'scroll') { return node as HTMLElement; } } // A scrollable ancestor element was not found, which means that we need to // do stuff on window. return window; } /** * @param event the native scroll event coming from the scrollable * ancestor, or resize event coming from the window. Will be null if * called by a React life cycle method */ private handleScroll = (event: Event | null) => { if (!this.refElement.current) { // There's a chance we end up here after the component has been unmounted. return; } const bounds = this.getBounds(); const currentPosition = getCurrentPosition(bounds); const previousPosition = this.previousPosition; const { onPositionChange, onEnter, onLeave, fireOnRapidScroll } = this.props; // Save previous position as early as possible to prevent cycles this.previousPosition = currentPosition; if (previousPosition === currentPosition) { // No change since last trigger return; } const callbackArg = { currentPosition, previousPosition, event, waypointTop: bounds.waypointTop, waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom, }; onPositionChange?.(callbackArg); if (currentPosition === WaypointPosition.Inside) { onEnter?.(callbackArg); } else if ( previousPosition === WaypointPosition.Inside || // Trigger `onLeave` if waypoint is below/above when mount previousPosition === WaypointPosition.Unknown ) { onLeave?.(callbackArg); } const isRapidScrollDown = previousPosition === WaypointPosition.Below && currentPosition === WaypointPosition.Above; const isRapidScrollUp = previousPosition === WaypointPosition.Above && currentPosition === WaypointPosition.Below; if (fireOnRapidScroll && (isRapidScrollDown || isRapidScrollUp)) { // If the scroll event isn't fired often enough to occur while the // waypoint was visible, we trigger both callbacks anyway. onEnter?.({ currentPosition: WaypointPosition.Inside, previousPosition, event, waypointTop: bounds.waypointTop, waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom, }); onLeave?.({ currentPosition, previousPosition: WaypointPosition.Inside, event, waypointTop: bounds.waypointTop, waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom, }); } }; private getBounds() { const { horizontal } = this.props; const { left, top, right, bottom } = this.refElement.current.getBoundingClientRect(); const waypointTop = horizontal ? left : top; const waypointBottom = horizontal ? right : bottom; let contextHeight: number; let contextScrollTop: number; if (this.scrollableAncestor === window) { contextHeight = horizontal ? window.innerWidth : window.innerHeight; contextScrollTop = 0; } else { const node = this.scrollableAncestor as HTMLElement; const boundingBox = node.getBoundingClientRect(); contextHeight = horizontal ? boundingBox.width : boundingBox.height; contextScrollTop = horizontal ? boundingBox.left : boundingBox.top; } const topOffsetPx = this.getOffset('top', contextHeight); const bottomOffsetPx = this.getOffset('bottom', contextHeight); const contextBottom = contextScrollTop + contextHeight; return { waypointTop, waypointBottom, viewportTop: contextScrollTop + topOffsetPx, viewportBottom: contextBottom - bottomOffsetPx, }; } private getOffset(pos: 'top' | 'bottom', contextHeight: number) { const { horizontal } = this.props; const prop = `${pos}Offset`; const border = pos === 'top' ? `border${horizontal ? 'Left' : 'Top'}Width` : `border${horizontal ? 'Right' : 'Bottom'}Width`; const propOffset = this.props[prop]; if (propOffset !== 'auto') { return computeOffsetPixels(propOffset, contextHeight); } if (this.scrollableAncestor === window) { const styles = getComputedStyle(document.documentElement); const getFontSize = () => parseFontSize(styles.fontSize); return ( parseBorderWidth(styles[border], getFontSize, getFontSize) ?? DEFAULT_OFFSET ); } else { const styles = getComputedStyle(this.scrollableAncestor as HTMLElement); const getFontSize = () => parseFontSize(styles.fontSize); const getRootFontSize = () => parseFontSize(getComputedStyle(document.documentElement).fontSize); return ( parseBorderWidth(styles[border], getFontSize, getRootFontSize) ?? DEFAULT_OFFSET ); } } render() { const { children } = this.props; if (!children) { // We need an element that we can locate in the DOM to determine where it is // rendered relative to the top of its context. return ( } style={{ fontSize: 0 }} className="zent-waypoint-marker" /> ); } const child = Children.only(children); if (isDOMElement(child) || isForwardRef(child)) { const ref = (node: Element) => { this.refElement.current = node; const chRef: React.Ref = (child as any).ref; if (chRef) { if (typeof chRef === 'function') { chRef(node); } else { (chRef as React.MutableRefObject).current = node; } } }; return cloneElement(child, { ref }); } return cloneElement(child as any, { innerRef: this.refElement }); } } function ensureRefIsUsedByChild(children, ref) { if (children && !isDOMElement(children) && !ref) { throw new Error( ' needs a DOM element to compute boundaries. The child you passed is neither a ' + 'DOM element (e.g.
) nor does it use the innerRef prop.\n' ); } }