react-intersection-observer-ng
Version:
Monitor if a component is inside the viewport, using IntersectionObserver API
394 lines (327 loc) • 12.2 kB
JavaScript
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 };