UNPKG

react-intersection-observer-ng

Version:

Monitor if a component is inside the viewport, using IntersectionObserver API

394 lines (327 loc) 12.2 kB
import _extends from '@babel/runtime/helpers/esm/extends'; import _objectWithoutPropertiesLoose from '@babel/runtime/helpers/esm/objectWithoutPropertiesLoose'; import _assertThisInitialized from '@babel/runtime/helpers/esm/assertThisInitialized'; import _inheritsLoose from '@babel/runtime/helpers/esm/inheritsLoose'; import _defineProperty from '@babel/runtime/helpers/esm/defineProperty'; import { createElement, Component, useState, useEffect, useDebugValue } from 'react'; import invariant from 'invariant'; import debounce from 'lodash/debounce'; var BATCH_MAP = new Map(); var INSTANCE_MAP = new Map(); var OBSERVER_MAP = new Map(); var ROOT_IDS = new Map(); var consecutiveRootId = 0; /** * Generate a unique ID for the root element * @param root */ function getRootId(root) { if (!root) return ''; if (ROOT_IDS.has(root)) return ROOT_IDS.get(root); consecutiveRootId += 1; ROOT_IDS.set(root, consecutiveRootId.toString()); return ROOT_IDS.get(root) + '_'; } /** * Monitor element, and trigger callback when element becomes inView * @param element {HTMLElement} * @param callback {Function} Called with inView * @param options {Object} InterSection observer options * @param options.threshold {Number} Number between 0 and 1, indicating how much of the element should be inView before triggering * @param options.root {HTMLElement} * @param options.rootMargin {String} The CSS margin to apply to the root element. */ function observe(element, callback, options) { if (options === void 0) { options = {}; } // IntersectionObserver needs a threshold to trigger, so set it to 0 if it's not defined. // Modify the options object, since it's used in the onChange handler. if (!options.threshold) options.threshold = 0; var _options = options, root = _options.root, rootMargin = _options.rootMargin, threshold = _options.threshold; // Validate that the element is not being used in another <Observer /> invariant(!INSTANCE_MAP.has(element), "react-intersection-observer: Trying to observe %s, but it's already being observed by another instance.\nMake sure the `ref` is only used by a single <Observer /> instance.\n\n%s", element); /* istanbul ignore if */ if (!element) return; // Create a unique ID for this observer instance, based on the root, root margin and threshold. // An observer with the same options can be reused, so lets use this fact var observerId = getRootId(root) + (rootMargin ? threshold.toString() + "_" + rootMargin : threshold.toString()); var observerInstance = OBSERVER_MAP.get(observerId); if (!observerInstance) { var debounceBatch = debounce(function () { timeToRunBatch(observerId); }, options.debounce === null ? 100 : options.debounce, { leading: false, trailing: true }); observerInstance = new IntersectionObserver(function (changes) { onChange(changes, observerId); // we have run batch here for particular IntersectionObserver debounceBatch(); }, options); /* istanbul ignore else */ if (observerId) OBSERVER_MAP.set(observerId, observerInstance); } var instance = { callback: callback, element: element, inView: false, observerId: observerId, batch: options.debounce ? true : false, observer: observerInstance, // Make sure we have the thresholds value. It's undefined on a browser like Chrome 51. thresholds: observerInstance.thresholds || (Array.isArray(threshold) ? threshold : [threshold]) }; INSTANCE_MAP.set(element, instance); observerInstance.observe(element); return instance; } function timeToRunBatch(observerId) { var batch = BATCH_MAP.get(observerId); if (batch) { batch.forEach(function (item, element) { var instance = INSTANCE_MAP.get(element); var intersection = item.intersection, inView = item.inView; if (instance && instance.inView !== inView) { instance.inView = inView; instance.callback(inView, intersection); } }); BATCH_MAP.delete(observerId); } } /** * Stop observing an element. If an element is removed from the DOM or otherwise destroyed, * make sure to call this method. * @param element {Element} */ function unobserve(element) { if (!element) return; var instance = INSTANCE_MAP.get(element); if (instance) { var observerId = instance.observerId, observer = instance.observer; var root = observer.root; observer.unobserve(element); // Check if we are still observing any elements with the same threshold. var itemsLeft = false; // Check if we still have observers configured with the same root. var rootObserved = false; /* istanbul ignore else */ if (observerId) { INSTANCE_MAP.forEach(function (item, key) { if (key !== element) { if (item.observerId === observerId) { itemsLeft = true; rootObserved = true; } if (item.observer.root === root) { rootObserved = true; } } }); } if (!rootObserved && root) ROOT_IDS.delete(root); if (observer && !itemsLeft) { // No more elements to observe for threshold, disconnect observer observer.disconnect(); } // Remove reference to element INSTANCE_MAP.delete(element); } } function onChange(changes, observerId) { var batchMap = BATCH_MAP.get(observerId); if (!batchMap) { batchMap = new Map(); BATCH_MAP.set(observerId, batchMap); } changes.forEach(function (intersection) { var isIntersecting = intersection.isIntersecting, intersectionRatio = intersection.intersectionRatio, target = intersection.target; var instance = INSTANCE_MAP.get(target); // Firefox can report a negative intersectionRatio when scrolling. /* istanbul ignore else */ if (instance && intersectionRatio >= 0) { // If threshold is an array, check if any of them intersects. This just triggers the onChange event multiple times. var _inView = instance.thresholds.some(function (threshold) { return instance.inView ? intersectionRatio > threshold : intersectionRatio >= threshold; }); if (isIntersecting !== undefined) { // If isIntersecting is defined, ensure that the element is actually intersecting. // Otherwise it reports a threshold of 0 _inView = _inView && isIntersecting; } if (instance.batch) { if (batchMap) { var batchItem = batchMap.get(target); if (batchItem) { batchItem.inView = _inView; batchItem.intersection = intersection; } else { batchMap.set(target, { intersection: intersection, inView: _inView }); } } } else { if (instance.inView !== _inView) { instance.inView = _inView; instance.callback(_inView, intersection); } } } }); } function isPlainChildren(props) { return typeof props.children !== 'function'; } /** * Monitors scroll, and triggers the children function with updated props * <InView> {({inView, ref}) => ( <h1 ref={ref}>{`${inView}`}</h1> )} </InView> */ var InView = /*#__PURE__*/ function (_React$Component) { _inheritsLoose(InView, _React$Component); function InView() { var _this; for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } _this = _React$Component.call.apply(_React$Component, [this].concat(args)) || this; _defineProperty(_assertThisInitialized(_this), "state", { inView: false, entry: undefined }); _defineProperty(_assertThisInitialized(_this), "node", null); _defineProperty(_assertThisInitialized(_this), "handleNode", function (node) { if (_this.node) unobserve(_this.node); _this.node = node ? node : null; _this.observeNode(); }); _defineProperty(_assertThisInitialized(_this), "handleChange", function (inView, entry) { // Only trigger a state update if inView has changed. // This prevents an unnecessary extra state update during mount, when the element stats outside the viewport if (inView !== _this.state.inView || inView) { _this.setState({ inView: inView, entry: entry }); } if (_this.props.onChange) { // If the user is actively listening for onChange, always trigger it _this.props.onChange(inView, entry); } }); return _this; } var _proto = InView.prototype; _proto.componentDidMount = function componentDidMount() { /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { invariant(this.node, "react-intersection-observer: No DOM node found. Make sure you forward \"ref\" to the root DOM element you want to observe."); } }; _proto.componentDidUpdate = function componentDidUpdate(prevProps, prevState) { // If a IntersectionObserver option changed, reinit the observer if (prevProps.rootMargin !== this.props.rootMargin || prevProps.root !== this.props.root || prevProps.threshold !== this.props.threshold) { unobserve(this.node); this.observeNode(); } if (prevState.inView !== this.state.inView) { if (this.state.inView && this.props.triggerOnce) { unobserve(this.node); this.node = null; } } }; _proto.componentWillUnmount = function componentWillUnmount() { if (this.node) { unobserve(this.node); this.node = null; } }; _proto.observeNode = function observeNode() { if (!this.node) return; var _this$props = this.props, threshold = _this$props.threshold, root = _this$props.root, rootMargin = _this$props.rootMargin; observe(this.node, this.handleChange, { threshold: threshold, root: root, rootMargin: rootMargin }); }; _proto.render = function render() { var _this$state = this.state, inView = _this$state.inView, entry = _this$state.entry; if (!isPlainChildren(this.props)) { return this.props.children({ inView: inView, entry: entry, ref: this.handleNode }); } var _this$props2 = this.props, children = _this$props2.children, as = _this$props2.as, tag = _this$props2.tag, triggerOnce = _this$props2.triggerOnce, threshold = _this$props2.threshold, root = _this$props2.root, rootMargin = _this$props2.rootMargin, props = _objectWithoutPropertiesLoose(_this$props2, ["children", "as", "tag", "triggerOnce", "threshold", "root", "rootMargin"]); return createElement(as || tag || 'div', _extends({ ref: this.handleNode }, props), children); }; return InView; }(Component); _defineProperty(InView, "displayName", 'InView'); _defineProperty(InView, "defaultProps", { threshold: 0, triggerOnce: false }); /* eslint-disable react-hooks/exhaustive-deps */ function useInView(options) { if (options === void 0) { options = {}; } var _React$useState = useState(null), ref = _React$useState[0], setRef = _React$useState[1]; var _React$useState2 = useState({ inView: false, entry: undefined }), state = _React$useState2[0], setState = _React$useState2[1]; useEffect(function () { if (!ref) return; observe(ref, function (inView, intersection) { setState({ inView: inView, entry: intersection }); if (inView && options.triggerOnce) { // If it should only trigger once, unobserve the element after it's inView unobserve(ref); } }, options); return function () { unobserve(ref); }; }, [// Only create a new Observer instance if the ref or any of the options have been changed. ref, options.threshold, options.root, options.rootMargin, options.triggerOnce]); useDebugValue(state.inView); return [setRef, state.inView, state.entry]; } export default InView; export { InView, useInView };