import _inheritsLoose from '@babel/runtime/helpers/esm/inheritsLoose'; import { addEventListener } from 'consolidated-events'; import PropTypes from 'prop-types'; import React from 'react'; import { isForwardRef } from 'react-is'; /** * Attempts to parse the offset provided as a prop as a percentage. For * instance, if the component has been provided with the string "20%" as * a value of one of the offset props. If the value matches, then it returns * a numeric version of the prop. For instance, "20%" would become `0.2`. * If `str` isn't a percentage, then `undefined` will be returned. * * @param {string} str The value of an offset prop to be converted to a * number. * @return {number|undefined} The numeric version of `str`. Undefined if `str` * was not a percentage. */ function parseOffsetAsPercentage(str) { if (str.slice(-1) === '%') { return parseFloat(str.slice(0, -1)) / 100; } return undefined; } /** * Attempts to parse the offset provided as a prop as a pixel value. If * parsing fails, then `undefined` is returned. Three examples of values that * will be successfully parsed are: * `20` * "20px" * "20" * * @param {string|number} str A string of the form "{number}" or "{number}px", * or just a number. * @return {number|undefined} The numeric version of `str`. Undefined if `str` * was neither a number nor string ending in "px". */ function parseOffsetAsPixels(str) { if (!isNaN(parseFloat(str)) && isFinite(str)) { return parseFloat(str); } if (str.slice(-2) === 'px') { return parseFloat(str.slice(0, -2)); } return undefined; } /** * @param {string|number} offset * @param {number} contextHeight * @return {number} A number representing `offset` converted into pixels. */ function computeOffsetPixels(offset, contextHeight) { var pixelOffset = parseOffsetAsPixels(offset); if (typeof pixelOffset === 'number') { return pixelOffset; } var percentOffset = parseOffsetAsPercentage(offset); if (typeof percentOffset === 'number') { return percentOffset * contextHeight; } return undefined; } var ABOVE = 'above'; var INSIDE = 'inside'; var BELOW = 'below'; var INVISIBLE = 'invisible'; function debugLog() { if (process.env.NODE_ENV !== 'production') { var _console; (_console = console).log.apply(_console, arguments); // eslint-disable-line no-console } } /** * When an element's type is a string, it represents a DOM node with that tag name * https://facebook.github.io/react/blog/2015/12/18/react-components-elements-and-instances.html#dom-elements * * @param {React.element} Component * @return {bool} Whether the component is a DOM Element */ function isDOMElement(Component) { return typeof Component.type === 'string'; } var errorMessage = ' 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\n' + 'See https://goo.gl/LrBNgw for more info.'; /** * Raise an error if "children" is not a DOM Element and there is no ref provided to Waypoint * * @param {?React.element} children * @param {?HTMLElement} ref * @return {undefined} */ function ensureRefIsProvidedByChild(children, ref) { if (children && !isDOMElement(children) && !ref) { throw new Error(errorMessage); } } /** * @param {object} bounds An object with bounds data for the waypoint and * scrollable parent * @return {string} The current position of the waypoint in relation to the * visible portion of the scrollable parent. One of the constants `ABOVE`, * `BELOW`, `INSIDE` or `INVISIBLE`. */ function getCurrentPosition(bounds) { if (bounds.viewportBottom - bounds.viewportTop === 0) { return INVISIBLE; } // top is within the viewport if (bounds.viewportTop <= bounds.waypointTop && bounds.waypointTop <= bounds.viewportBottom) { return INSIDE; } // bottom is within the viewport if (bounds.viewportTop <= bounds.waypointBottom && bounds.waypointBottom <= bounds.viewportBottom) { return INSIDE; } // top is above the viewport and bottom is below the viewport if (bounds.waypointTop <= bounds.viewportTop && bounds.viewportBottom <= bounds.waypointBottom) { return INSIDE; } if (bounds.viewportBottom < bounds.waypointTop) { return BELOW; } if (bounds.waypointTop < bounds.viewportTop) { return ABOVE; } return INVISIBLE; } var timeout; var timeoutQueue = []; function onNextTick(cb) { timeoutQueue.push(cb); if (!timeout) { timeout = setTimeout(function () { timeout = null; // Drain the timeoutQueue var item; // eslint-disable-next-line no-cond-assign while (item = timeoutQueue.shift()) { item(); } }, 0); } var isSubscribed = true; return function unsubscribe() { if (!isSubscribed) { return; } isSubscribed = false; var index = timeoutQueue.indexOf(cb); if (index === -1) { return; } timeoutQueue.splice(index, 1); if (!timeoutQueue.length && timeout) { clearTimeout(timeout); timeout = null; } }; } function resolveScrollableAncestorProp(scrollableAncestor) { // When Waypoint is rendered on the server, `window` is not available. // To make Waypoint easier to work with, we allow this to be specified in // string form and safely convert to `window` here. if (scrollableAncestor === 'window') { return global.window; } return scrollableAncestor; } var hasWindow = typeof window !== 'undefined'; var defaultProps = { debug: false, scrollableAncestor: undefined, children: undefined, topOffset: '0px', bottomOffset: '0px', horizontal: false, onEnter: function onEnter() {}, onLeave: function onLeave() {}, onPositionChange: function onPositionChange() {}, fireOnRapidScroll: true }; // Calls a function when you scroll to the element. var Waypoint = /*#__PURE__*/function (_React$PureComponent) { _inheritsLoose(Waypoint, _React$PureComponent); function Waypoint(props) { var _this; _this = _React$PureComponent.call(this, props) || this; _this.refElement = function (e) { _this._ref = e; }; return _this; } var _proto = Waypoint.prototype; _proto.componentDidMount = function componentDidMount() { var _this2 = this; if (!hasWindow) { return; } // this._ref 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 = onNextTick(function () { _this2.cancelOnNextTick = null; var _this2$props = _this2.props, children = _this2$props.children, debug = _this2$props.debug; // Berofe doing anything, we want to check that this._ref is avaliable in Waypoint ensureRefIsProvidedByChild(children, _this2._ref); _this2._handleScroll = _this2._handleScroll.bind(_this2); _this2.scrollableAncestor = _this2._findScrollableAncestor(); if (process.env.NODE_ENV !== 'production' && debug) { debugLog('scrollableAncestor', _this2.scrollableAncestor); } _this2.scrollEventListenerUnsubscribe = addEventListener(_this2.scrollableAncestor, 'scroll', _this2._handleScroll, { passive: true }); _this2.resizeEventListenerUnsubscribe = addEventListener(window, 'resize', _this2._handleScroll, { passive: true }); _this2._handleScroll(null); }); }; _proto.componentDidUpdate = function componentDidUpdate() { var _this3 = this; if (!hasWindow) { 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 = onNextTick(function () { _this3.cancelOnNextTick = null; _this3._handleScroll(null); }); }; _proto.componentWillUnmount = function componentWillUnmount() { if (!hasWindow) { return; } if (this.scrollEventListenerUnsubscribe) { this.scrollEventListenerUnsubscribe(); } if (this.resizeEventListenerUnsubscribe) { this.resizeEventListenerUnsubscribe(); } if (this.cancelOnNextTick) { this.cancelOnNextTick(); } } /** * 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. */ ; _proto._findScrollableAncestor = function _findScrollableAncestor() { var _this$props = this.props, horizontal = _this$props.horizontal, scrollableAncestor = _this$props.scrollableAncestor; if (scrollableAncestor) { return resolveScrollableAncestorProp(scrollableAncestor); } var node = this._ref; while (node.parentNode) { node = node.parentNode; if (node === document.body) { // We've reached all the way to the root node. return window; } var style = window.getComputedStyle(node); var overflowDirec = horizontal ? style.getPropertyValue('overflow-x') : style.getPropertyValue('overflow-y'); var overflow = overflowDirec || 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; } /** * @param {Object} event the native scroll event coming from the scrollable * ancestor, or resize event coming from the window. Will be undefined if * called by a React lifecyle method */ ; _proto._handleScroll = function _handleScroll(event) { if (!this._ref) { // There's a chance we end up here after the component has been unmounted. return; } var bounds = this._getBounds(); var currentPosition = getCurrentPosition(bounds); var previousPosition = this._previousPosition; var _this$props2 = this.props, debug = _this$props2.debug, onPositionChange = _this$props2.onPositionChange, onEnter = _this$props2.onEnter, onLeave = _this$props2.onLeave, fireOnRapidScroll = _this$props2.fireOnRapidScroll; if (process.env.NODE_ENV !== 'production' && debug) { debugLog('currentPosition', currentPosition); debugLog('previousPosition', previousPosition); } // Save previous position as early as possible to prevent cycles this._previousPosition = currentPosition; if (previousPosition === currentPosition) { // No change since last trigger return; } var callbackArg = { currentPosition: currentPosition, previousPosition: previousPosition, event: event, waypointTop: bounds.waypointTop, waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom }; onPositionChange.call(this, callbackArg); if (currentPosition === INSIDE) { onEnter.call(this, callbackArg); } else if (previousPosition === INSIDE) { onLeave.call(this, callbackArg); } var isRapidScrollDown = previousPosition === BELOW && currentPosition === ABOVE; var isRapidScrollUp = previousPosition === ABOVE && currentPosition === 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.call(this, { currentPosition: INSIDE, previousPosition: previousPosition, event: event, waypointTop: bounds.waypointTop, waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom }); onLeave.call(this, { currentPosition: currentPosition, previousPosition: INSIDE, event: event, waypointTop: bounds.waypointTop, waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom }); } }; _proto._getBounds = function _getBounds() { var _this$props3 = this.props, horizontal = _this$props3.horizontal, debug = _this$props3.debug; var _this$_ref$getBoundin = this._ref.getBoundingClientRect(), left = _this$_ref$getBoundin.left, top = _this$_ref$getBoundin.top, right = _this$_ref$getBoundin.right, bottom = _this$_ref$getBoundin.bottom; var waypointTop = horizontal ? left : top; var waypointBottom = horizontal ? right : bottom; var contextHeight; var contextScrollTop; if (this.scrollableAncestor === window) { contextHeight = horizontal ? window.innerWidth : window.innerHeight; contextScrollTop = 0; } else { contextHeight = horizontal ? this.scrollableAncestor.offsetWidth : this.scrollableAncestor.offsetHeight; contextScrollTop = horizontal ? this.scrollableAncestor.getBoundingClientRect().left : this.scrollableAncestor.getBoundingClientRect().top; } if (process.env.NODE_ENV !== 'production' && debug) { debugLog('waypoint top', waypointTop); debugLog('waypoint bottom', waypointBottom); debugLog('scrollableAncestor height', contextHeight); debugLog('scrollableAncestor scrollTop', contextScrollTop); } var _this$props4 = this.props, bottomOffset = _this$props4.bottomOffset, topOffset = _this$props4.topOffset; var topOffsetPx = computeOffsetPixels(topOffset, contextHeight); var bottomOffsetPx = computeOffsetPixels(bottomOffset, contextHeight); var contextBottom = contextScrollTop + contextHeight; return { waypointTop: waypointTop, waypointBottom: waypointBottom, viewportTop: contextScrollTop + topOffsetPx, viewportBottom: contextBottom - bottomOffsetPx }; } /** * @return {Object} */ ; _proto.render = function render() { var _this4 = this; var children = this.props.children; 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 /*#__PURE__*/React.createElement("span", { ref: this.refElement, style: { fontSize: 0 } }); } if (isDOMElement(children) || isForwardRef(children)) { var ref = function ref(node) { _this4.refElement(node); if (children.ref) { if (typeof children.ref === 'function') { children.ref(node); } else { children.ref.current = node; } } }; return /*#__PURE__*/React.cloneElement(children, { ref: ref }); } return /*#__PURE__*/React.cloneElement(children, { innerRef: this.refElement }); }; return Waypoint; }(React.PureComponent); if (process.env.NODE_ENV !== 'production') { Waypoint.propTypes = { children: PropTypes.element, debug: PropTypes.bool, onEnter: PropTypes.func, onLeave: PropTypes.func, onPositionChange: PropTypes.func, fireOnRapidScroll: PropTypes.bool, // eslint-disable-next-line react/forbid-prop-types scrollableAncestor: PropTypes.any, horizontal: PropTypes.bool, // `topOffset` can either be a number, in which case its a distance from the // top of the container in pixels, or a string value. Valid string values are // of the form "20px", which is parsed as pixels, or "20%", which is parsed // as a percentage of the height of the containing element. // For instance, if you pass "-20%", and the containing element is 100px tall, // then the waypoint will be triggered when it has been scrolled 20px beyond // the top of the containing element. topOffset: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), // `bottomOffset` can either be a number, in which case its a distance from the // bottom of the container in pixels, or a string value. Valid string values are // of the form "20px", which is parsed as pixels, or "20%", which is parsed // as a percentage of the height of the containing element. // For instance, if you pass "20%", and the containing element is 100px tall, // then the waypoint will be triggered when it has been scrolled 20px beyond // the bottom of the containing element. // Similar to `topOffset`, but for the bottom of the container. bottomOffset: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) }; } Waypoint.above = ABOVE; Waypoint.below = BELOW; Waypoint.inside = INSIDE; Waypoint.invisible = INVISIBLE; Waypoint.defaultProps = defaultProps; Waypoint.displayName = 'Waypoint'; export { Waypoint };