/*
This is a slimmed down copy of https://github.com/civiccc/react-waypoint
The MIT License (MIT)
Copyright (c) 2015 Brigade
*/
import React, {createRef, ReactNode} from 'react';
type ScrollContainer = Window | HTMLElement;
function addEventListener(
element: ScrollContainer,
type: 'scroll' | 'resize',
listener: () => void,
options: AddEventListenerOptions,
) {
element.addEventListener(type, listener, options);
return () => element.removeEventListener(type, listener, options);
}
// Because waypoint may fire before the setState() updates due to batching
// queueMicrotask is a better option than setTimeout() or React.flushSync()
// See https://github.com/facebook/docusaurus/issues/11018
// See https://github.com/civiccc/react-waypoint/blob/0905ac5a073131147c96dd0694bd6f1b6ee8bc97/src/onNextTick.js
function subscribeMicrotask(callback: () => void) {
let subscribed = true;
queueMicrotask(() => {
if (subscribed) callback();
});
return () => (subscribed = false);
}
type Position = 'above' | 'inside' | 'below' | 'invisible';
type Props = {
topOffset: number;
bottomOffset: number;
onEnter: () => void;
onLeave: () => void;
children: ReactNode;
};
export function Waypoint(props: Props) {
return typeof window !== 'undefined' ? (
{props.children}
) : (
props.children
);
}
// TODO maybe replace this with IntersectionObserver later?
// IntersectionObserver doesn't support the "fast scroll" thing
// but it's probably not a big deal
class WaypointClient extends React.Component {
static defaultProps = {
topOffset: 0,
bottomOffset: 0,
onEnter() {},
onLeave() {},
};
scrollableAncestor?: ScrollContainer;
previousPosition: Position | null = null;
unsubscribe?: () => void;
innerRef = createRef();
override componentDidMount() {
this.scrollableAncestor = findScrollableAncestor(this.innerRef.current!);
const unsubscribeScroll = addEventListener(
this.scrollableAncestor!,
'scroll',
this._handleScroll,
{passive: true},
);
const unsubscribeResize = addEventListener(
window,
'resize',
this._handleScroll,
{passive: true},
);
const unsubscribeInitialScroll = subscribeMicrotask(() => {
this._handleScroll();
});
this.unsubscribe = () => {
unsubscribeScroll();
unsubscribeResize();
unsubscribeInitialScroll();
};
}
override componentDidUpdate() {
this._handleScroll();
}
override componentWillUnmount() {
this.unsubscribe?.();
}
_handleScroll = () => {
const node = this.innerRef.current;
const {topOffset, bottomOffset, onEnter, onLeave} = this.props;
const bounds = getBounds({
node: node!,
scrollableAncestor: this.scrollableAncestor!,
topOffset,
bottomOffset,
});
const currentPosition = getCurrentPosition(bounds);
const previousPosition = this.previousPosition;
this.previousPosition = currentPosition;
if (previousPosition === currentPosition) {
return;
}
if (currentPosition === 'inside') {
onEnter();
} else if (previousPosition === 'inside') {
onLeave();
}
const isRapidScrollDown =
previousPosition === 'below' && currentPosition === 'above';
const isRapidScrollUp =
previousPosition === 'above' && currentPosition === 'below';
if (isRapidScrollDown || isRapidScrollUp) {
onEnter();
onLeave();
}
};
override render() {
// @ts-expect-error: fix this implicit API
return React.cloneElement(this.props.children, {innerRef: this.innerRef});
}
}
/**
* Traverses up the DOM to find an ancestor container which has an overflow
* style that allows for scrolling.
*
* @return {Object} the closest ancestor element with an overflow style that
* allows for scrolling. If none is found, the `window` object is returned
* as a fallback.
*/
function findScrollableAncestor(inputNode: HTMLElement): ScrollContainer {
let node: HTMLElement = inputNode;
while (node.parentNode) {
// @ts-expect-error: it's fine
node = node.parentNode!;
if (node === document.body) {
// We've reached all the way to the root node.
return window;
}
const style = window.getComputedStyle(node);
const overflow =
style.getPropertyValue('overflow-y') ||
style.getPropertyValue('overflow');
if (
overflow === 'auto' ||
overflow === 'scroll' ||
overflow === 'overlay'
) {
return node;
}
}
// A scrollable ancestor element was not found, which means that we need to
// do stuff on window.
return window;
}
type Bounds = {
top: number;
bottom: number;
viewportTop: number;
viewportBottom: number;
};
function getBounds({
node,
scrollableAncestor,
topOffset,
bottomOffset,
}: {
node: Element;
scrollableAncestor: ScrollContainer;
topOffset: number;
bottomOffset: number;
}): Bounds {
const {top, bottom} = node.getBoundingClientRect();
let contextHeight;
let contextScrollTop;
if (scrollableAncestor === window) {
contextHeight = window.innerHeight;
contextScrollTop = 0;
} else {
const ancestorElement = scrollableAncestor as HTMLElement;
contextHeight = ancestorElement.offsetHeight;
contextScrollTop = ancestorElement.getBoundingClientRect().top;
}
const contextBottom = contextScrollTop + contextHeight;
return {
top,
bottom,
viewportTop: contextScrollTop + topOffset,
viewportBottom: contextBottom - bottomOffset,
};
}
function getCurrentPosition(bounds: Bounds): Position {
if (bounds.viewportBottom - bounds.viewportTop === 0) {
return 'invisible';
}
// top is within the viewport
if (bounds.viewportTop <= bounds.top && bounds.top <= bounds.viewportBottom) {
return 'inside';
}
// bottom is within the viewport
if (
bounds.viewportTop <= bounds.bottom &&
bounds.bottom <= bounds.viewportBottom
) {
return 'inside';
}
// top is above the viewport and bottom is below the viewport
if (
bounds.top <= bounds.viewportTop &&
bounds.viewportBottom <= bounds.bottom
) {
return 'inside';
}
if (bounds.viewportBottom < bounds.top) {
return 'below';
}
if (bounds.top < bounds.viewportTop) {
return 'above';
}
return 'invisible';
}