diff --git a/.gitignore b/.gitignore index b6682fa..3f235ca 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ build/ dist/ lib/ es/ +esm/ +es2017/ +.docusaurus/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 357d3f8..8449b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.2.0 + +- [refactor] Using ICE PKG to build the package. +- [feature] Support minimum version of appear-polyfill. +- [refactor] Support passive event listener. ## 0.1.3 - [feat] Support custom option `endReachedThreshold` \ No newline at end of file diff --git a/README.md b/README.md index 9f20638..5b6836a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -[![npm](https://img.shields.io/npm/v/rax-appear.svg)](https://www.npmjs.com/package/rax-appear) +[![npm](https://img.shields.io/npm/v/rax-appear.svg)](https://www.npmjs.com/package/appear-polyfill) **描述:** -封装了组件 Appear 和 Disappear 的监听。 +封装了 appear 和 disappear 事件监听的 Polyfill。 ## 安装 @@ -11,50 +11,59 @@ $ npm install appear-polyfill --save ## 示例 - ```jsx -import { createElement, render } from 'rax'; -import * as DriverDOM from 'driver-dom'; -import { isWeb } from 'universal-env'; +// app.js +import { useEffect } from 'react'; import { setupAppear } from 'appear-polyfill'; - -if (isWeb) { - setupAppear(window); +setupAppear(window); + +function App() { + useEffect(() => { + const item50 = document.getElementById('item50'); + + item50.addEventListener('appear', (event) => { + console.log('appear at', event.target, event); + }); + + item50.addEventListener('disappear', (event) => { + console.log('disappear at', event.target, event); + }); + }, []); + return Array.from({ length: 100 }) + .map((_, index) => ( +
Item {index}
+ )) } +render(); +``` +## 配置项 + +**intersectionObserverLoader** -const list = []; -for (let index = 1; index <= 100; index++) { - list.push(index); +- 类型:`function` + +> Tip: 从 0.2.0 版本开始, appear-polyfill 移除了内置的 IntersectionObserver Polyfill,如有必要可以自行引入。 + +用于在浏览器不支持 IntersectionObserver 的情况下,动态加载 IntersectionObserver Polyfill。 + +```js +import { setupAppear } from 'appear-polyfill'; + +const INTERSECTION_OBSERVER_POLYFILL = 'https://cdn.jsdelivr.net/npm/intersection-observer@0.12.2/intersection-observer.js'; +function intersectionObserverLoader() { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + // Polyfill 加载完成后,会在 window 上挂载 IntersectionObserver 对象 + script.onload = () => resolve(window.IntersectionObserver); + script.onerror = () => reject(); + script.src = INTERSECTION_OBSERVER_POLYFILL; + document.head.appendChild(script); + }); } -render(( -
- {list.map((item) => { - return ( -
{ - console.log('appear: ', item, event.detail.direction); - }} - onDisappear={() => { - console.log('disappear: ', item, event.detail.direction); - }} - > - 第 {item} 个 -
- ); - })} -
-), document.body, { driver: DriverDOM }); +// 启动监听 +setupAppear(window, { intersectionObserverLoader }); ``` -## 配置项 **preAppear** @@ -64,7 +73,6 @@ render(( ```jsx import { createElement, render } from 'rax'; -import DriverUniversal from 'driver-universal'; import Image from 'rax-image'; import { setupAppear } from 'appear-polyfill'; @@ -97,5 +105,5 @@ const App = () => { ); }; -render(, document.body, { driver: DriverUniversal }); +render(, document.body); ``` \ No newline at end of file diff --git a/build.config.mts b/build.config.mts new file mode 100644 index 0000000..64f7fea --- /dev/null +++ b/build.config.mts @@ -0,0 +1,14 @@ +import { defineConfig } from '@ice/pkg'; + +export default defineConfig({ + transform: { + formats: ['esm'], + }, + bundle: { + formats: ['umd', 'es2017'], + minify: true, + }, + plugins: [ + '@ice/pkg-plugin-docusaurus', + ], +}); \ No newline at end of file diff --git a/build.json b/build.json deleted file mode 100644 index fbb1418..0000000 --- a/build.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "type": "rax", - "plugins": [ - "build-plugin-component" - ], - "targets": [ - "web", - "weex" - ] -} diff --git a/demo/index.js b/demo/index.js deleted file mode 100644 index 7a177a6..0000000 --- a/demo/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import { createElement, render } from 'rax'; -import * as DriverDOM from 'driver-dom'; -import { isWeb } from 'universal-env'; -import { setupAppear } from '../src/index'; - -setupAppear(); - -const list = []; -for (let index = 1; index <= 100; index++) { - list.push(index); -} - -render(( -
- {list.map((item) => { - return ( -
{ - console.log('appear: ', item, event.detail.direction); - }} - onDisappear={() => { - console.log('disappear: ', item, event.detail.direction); - }} - isonce - > - 第 {item} 个 -
- ); - })} -
-), document.body, { driver: DriverDOM }); diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..7bdf803 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,41 @@ +# Example + +展开代码块以查看使用的例子 + +```jsx preview +import { useEffect } from 'react'; +import { setupAppear } from '../src/index'; + +// https://caniuse.com/?search=IntersectionObserver +function intersectionObserverLoader() { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.onload = () => resolve(window.IntersectionObserver); + script.onerror = () => reject(); + script.src = 'https://g.alicdn.com/ice/intersection-observer/0.12.2/intersection-observer.min.js'; + document.head.appendChild(script); + }); +} +setupAppear(window, { intersectionObserverLoader }); + +const styles = { + item: { + padding: '10px 0', + borderBottom: '1px solid #eee', + }, +}; +// Ignore following line. +export default function List() { + useEffect(() => { + document.getElementById('item50').addEventListener('appear', (event) => { + console.log('appear at', event.target, event); + }); + + document.getElementById('item50').addEventListener('disappear', (event) => { + console.log('disappear at', event.target, event); + }); + }, []); + return Array.from({ length: 100 }) + .map((_, index) => (
Item {index}
)); +} +``` \ No newline at end of file diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 234b9b7..0000000 --- a/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -const getJestConfig = require('rax-jest-config'); - -module.exports = { - ...getJestConfig(), -}; diff --git a/package.json b/package.json index b42e37d..6dc0a5b 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "appear-polyfill", - "version": "0.1.3", - "description": "", - "author": "rax", + "version": "0.2.0", + "description": "Simulate appear & disappear events.", + "author": "Rax Team & ICE Team", "license": "BSD-3-Clause", - "main": "lib/index.js", + "main": "esm/index.js", + "module": "esm/index.js", + "types": "esm/index.d.ts", "scripts": { - "start": "build-scripts start", - "build": "build-scripts build", - "test": "build-scripts test", + "start": "ice-pkg start", + "build": "ice-pkg build", "prepublishOnly": "npm run build" }, "keywords": [ @@ -18,18 +19,14 @@ "engines": { "npm": ">=3.0.0" }, - "peerDependencies": { - "rax": "^1.0.0" - }, "devDependencies": { - "@alib/build-scripts": "^0.1.28", - "build-plugin-component": "^1.0.0", - "driver-dom": "^1.0.0", - "rax": "^1.0.0", - "rax-jest-config": "^1.0.0", - "rax-test-renderer": "^1.0.0", - "rax-view": "^1.0.2", - "universal-env": "^1.0.1" + "@ice/pkg": "^1.5.0", + "@ice/pkg-plugin-docusaurus": "^1.4.3", + "react-dom": "^18.2.0" + }, + "homepage": "https://unpkg.com/appear-polyfill@0.2.0/build/index.html", + "dependencies": { + "@swc/helpers": "^0.4.14" }, - "homepage": "https://unpkg.com/appear-polyfill@0.1.3/build/index.html" + "repository": "git@github.com:raxjs/appear-polyfill.git" } diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 6de1a78..0000000 --- a/public/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Rax Component Demo - - - - - diff --git a/src/IntersectionObserver.js b/src/IntersectionObserver.js deleted file mode 100644 index f5dca15..0000000 --- a/src/IntersectionObserver.js +++ /dev/null @@ -1,652 +0,0 @@ -/** - * An IntersectionObserver registry. This registry exists to hold a strong - * reference to IntersectionObserver instances currently observing a target - * element. Without this registry, instances without another reference may be - * garbage collected. - */ -const registry = []; - - -/** - * Creates the global IntersectionObserverEntry constructor. - * https://w3c.github.io/IntersectionObserver/#intersection-observer-entry - * @param {Object} entry A dictionary of instance properties. - * @constructor - */ -export function IntersectionObserverEntry(entry) { - this.time = entry.time; - this.target = entry.target; - this.rootBounds = entry.rootBounds; - this.boundingClientRect = entry.boundingClientRect; - this.intersectionRect = entry.intersectionRect || getEmptyRect(); - this.isIntersecting = !!entry.intersectionRect; - - // Calculates the intersection ratio. - const targetRect = this.boundingClientRect; - const targetArea = targetRect.width * targetRect.height; - const intersectionRect = this.intersectionRect; - const intersectionArea = intersectionRect.width * intersectionRect.height; - - // Sets intersection ratio. - if (targetArea) { - // Round the intersection ratio to avoid floating point math issues: - // https://github.com/w3c/IntersectionObserver/issues/324 - this.intersectionRatio = Number((intersectionArea / targetArea).toFixed(4)); - } else { - // If area is zero and is intersecting, sets to 1, otherwise to 0 - this.intersectionRatio = this.isIntersecting ? 1 : 0; - } -} - -export default class IntersectionObserver { - /** - * The minimum interval within which the document will be checked for - * intersection changes. - */ - THROTTLE_TIMEOUT = 100; - - /** - * The frequency in which the polyfill polls for intersection changes. - * this can be updated on a per instance basis and must be set prior to - * calling `observe` on the first target. - */ - POLL_INTERVAL = null; - - /** - * Use a mutation observer on the root element - * to detect intersection changes. - */ - USE_MUTATION_OBSERVER = true; - - /** - * Creates the global IntersectionObserver constructor. - * https://w3c.github.io/IntersectionObserver/#intersection-observer-interface - * @param {Function} callback The function to be invoked after intersection - * changes have queued. The function is not invoked if the queue has - * been emptied by calling the `takeRecords` method. - * @param {Object=} optOptions Optional configuration options. - * @constructor - */ - constructor(callback, optOptions) { - const options = optOptions || {}; - - if (typeof callback != 'function') { - throw new Error('callback must be a function'); - } - - if (options.root && options.root.nodeType != 1) { - throw new Error('root must be an Element'); - } - - // Throttles `this._checkForIntersections`. - this._checkForIntersections = throttle(this._checkForIntersections, this.THROTTLE_TIMEOUT); - - // Private properties. - this._callback = callback; - this._observationTargets = []; - this._queuedEntries = []; - this._rootMarginValues = this._parseRootMargin(options.rootMargin); - - // Public properties. - this.thresholds = this._initThresholds(options.threshold); - this.root = options.root || null; - this.rootMargin = this._rootMarginValues.map((margin) => margin.value + margin.unit).join(' '); - } - - /** - * Starts observing a target element for intersection changes based on - * the thresholds values. - * @param {Element} target The DOM element to observe. - */ - observe(target) { - const isTargetAlreadyObserved = this._observationTargets.some((item) => item.element === target); - - if (isTargetAlreadyObserved) { - return; - } - - if (!(target && target.nodeType == 1)) { - throw new Error('target must be an Element'); - } - - this._registerInstance(); - this._observationTargets.push({element: target, entry: null}); - this._monitorIntersections(); - this._checkForIntersections(); - } - - /** - * Stops observing a target element for intersection changes. - * @param {Element} target The DOM element to observe. - */ - unobserve(target) { - this._observationTargets = - this._observationTargets.filter(function(item) { - return item.element !== target; - }); - if (!this._observationTargets.length) { - this._unmonitorIntersections(); - this._unregisterInstance(); - } - } - - /** - * Stops observing all target elements for intersection changes. - */ - disconnect() { - this._observationTargets = []; - this._unmonitorIntersections(); - this._unregisterInstance(); - } - - /** - * Returns any queue entries that have not yet been reported to the - * callback and clears the queue. This can be used in conjunction with the - * callback to obtain the absolute most up-to-date intersection information. - * @return {Array} The currently queued entries. - */ - takeRecords() { - const records = this._queuedEntries.slice(); - this._queuedEntries = []; - return records; - } - - /** - * Accepts the threshold value from the user configuration object and - * returns a sorted array of unique threshold values. If a value is not - * between 0 and 1 and error is thrown. - * @private - * @param {Array|number=} optThreshold An optional threshold value or - * a list of threshold values, defaulting to [0]. - * @return {Array} A sorted list of unique and valid threshold values. - */ - _initThresholds(optThreshold) { - let threshold = optThreshold || [0]; - if (!Array.isArray(threshold)) threshold = [threshold]; - - return threshold.sort().filter(function(t, i, a) { - if (typeof t != 'number' || isNaN(t) || t < 0 || t > 1) { - throw new Error('threshold must be a number between 0 and 1 inclusively'); - } - return t !== a[i - 1]; - }); - } - - /** - * Accepts the rootMargin value from the user configuration object - * and returns an array of the four margin values as an object containing - * the value and unit properties. If any of the values are not properly - * formatted or use a unit other than px or %, and error is thrown. - * @private - * @param {string=} optRootMargin An optional rootMargin value, - * defaulting to '0px'. - * @return {Array} An array of margin objects with the keys - * value and unit. - */ - _parseRootMargin(optRootMargin) { - let marginString = optRootMargin || '0px'; - let margins = marginString.split(/\s+/).map(function(margin) { - let parts = /^(-?\d*\.?\d+)(px|%)$/.exec(margin); - if (!parts) { - throw new Error('rootMargin must be specified in pixels or percent'); - } - return { value: parseFloat(parts[1]), unit: parts[2] }; - }); - - // Handles shorthand. - margins[1] = margins[1] || margins[0]; - margins[2] = margins[2] || margins[0]; - margins[3] = margins[3] || margins[1]; - - return margins; - } - - /** - * Starts polling for intersection changes if the polling is not already - * happening, and if the page's visibility state is visible. - * @private - */ - _monitorIntersections() { - if (!this._monitoringIntersections) { - this._monitoringIntersections = true; - - // If a poll interval is set, use polling instead of listening to - // resize and scroll events or DOM mutations. - if (this.POLL_INTERVAL) { - this._monitoringInterval = setInterval(this._checkForIntersections, this.POLL_INTERVAL); - } else { - addEvent(window, 'resize', this._checkForIntersections, true); - addEvent(document, 'scroll', this._checkForIntersections, true); - - if (this.USE_MUTATION_OBSERVER && 'MutationObserver' in window) { - this._domObserver = new MutationObserver(this._checkForIntersections); - this._domObserver.observe(document, { - attributes: true, - childList: true, - characterData: true, - subtree: true - }); - } - } - } - } - - /** - * Stops polling for intersection changes. - * @private - */ - _unmonitorIntersections() { - if (this._monitoringIntersections) { - this._monitoringIntersections = false; - - clearInterval(this._monitoringInterval); - this._monitoringInterval = null; - - removeEvent(window, 'resize', this._checkForIntersections, true); - removeEvent(document, 'scroll', this._checkForIntersections, true); - - if (this._domObserver) { - this._domObserver.disconnect(); - this._domObserver = null; - } - } - } - - /** - * Scans each observation target for intersection changes and adds them - * to the internal entries queue. If new entries are found, it - * schedules the callback to be invoked. - * @NOTE Using arrow function to bind to `this` instance. - * @private - */ - _checkForIntersections = () => { - let rootIsInDom = this._rootIsInDom(); - let rootRect = rootIsInDom ? this._getRootRect() : getEmptyRect(); - - this._observationTargets.forEach(function(item) { - let target = item.element; - let targetRect = getBoundingClientRect(target); - let rootContainsTarget = this._rootContainsTarget(target); - let oldEntry = item.entry; - let intersectionRect = rootIsInDom && rootContainsTarget && - this._computeTargetAndRootIntersection(target, rootRect); - - let newEntry = item.entry = new IntersectionObserverEntry({ - time: now(), - target: target, - boundingClientRect: targetRect, - rootBounds: rootRect, - intersectionRect: intersectionRect - }); - - if (!oldEntry) { - this._queuedEntries.push(newEntry); - } else if (rootIsInDom && rootContainsTarget) { - // If the new entry intersection ratio has crossed any of the - // thresholds, add a new entry. - if (this._hasCrossedThreshold(oldEntry, newEntry)) { - this._queuedEntries.push(newEntry); - } - } else { - // If the root is not in the DOM or target is not contained within - // root but the previous entry for this target had an intersection, - // add a new record indicating removal. - if (oldEntry && oldEntry.isIntersecting) { - this._queuedEntries.push(newEntry); - } - } - }, this); - - if (this._queuedEntries.length) { - this._callback(this.takeRecords(), this); - } - } - - /** - * Accepts a target and root rect computes the intersection between then - * following the algorithm in the spec. - * TODO(philipwalton): at this time clip-path is not considered. - * https://w3c.github.io/IntersectionObserver/#calculate-intersection-rect-algo - * @param {Element} target The target DOM element - * @param {Object} rootRect The bounding rect of the root after being - * expanded by the rootMargin value. - * @return {?Object} The final intersection rect object or undefined if no - * intersection is found. - * @private - */ - _computeTargetAndRootIntersection(target, rootRect) { - // If the element isn't displayed, an intersection can't happen. - if (window.getComputedStyle(target).display == 'none') return; - - let targetRect = getBoundingClientRect(target); - let intersectionRect = targetRect; - let parent = getParentNode(target); - let atRoot = false; - - while (!atRoot) { - let parentRect = null; - let parentComputedStyle = parent.nodeType == 1 ? - window.getComputedStyle(parent) : {}; - - // If the parent isn't displayed, an intersection can't happen. - if (parentComputedStyle.display === 'none') return; - - if (parent === this.root || parent === document) { - atRoot = true; - parentRect = rootRect; - } else { - // If the element has a non-visible overflow, and it's not the - // or element, update the intersection rect. - // Note: and cannot be clipped to a rect that's not also - // the document rect, so no need to compute a new intersection. - if (parent !== document.body && - parent !== document.documentElement && - parentComputedStyle.overflow !== 'visible') { - parentRect = getBoundingClientRect(parent); - } - } - - // If either of the above conditionals set a new parentRect, - // calculate new intersection data. - if (parentRect) { - intersectionRect = computeRectIntersection(parentRect, intersectionRect); - - if (!intersectionRect) break; - } - parent = getParentNode(parent); - } - return intersectionRect; - } - - /** - * Returns the root rect after being expanded by the rootMargin value. - * @return {Object} The expanded root rect. - * @private - */ - _getRootRect() { - let rootRect; - if (this.root) { - rootRect = getBoundingClientRect(this.root); - } else { - // Use / instead of window since scroll bars affect size. - let html = document.documentElement; - let body = document.body; - rootRect = { - top: 0, - left: 0, - right: html.clientWidth || body.clientWidth, - width: html.clientWidth || body.clientWidth, - bottom: html.clientHeight || body.clientHeight, - height: html.clientHeight || body.clientHeight - }; - } - return this._expandRectByRootMargin(rootRect); - } - - /** - * Accepts a rect and expands it by the rootMargin value. - * @param {Object} rect The rect object to expand. - * @return {Object} The expanded rect. - * @private - */ - _expandRectByRootMargin(rect) { - let margins = this._rootMarginValues.map(function(margin, i) { - return margin.unit === 'px' ? margin.value : - margin.value * (i % 2 ? rect.width : rect.height) / 100; - }); - let newRect = { - top: rect.top - margins[0], - right: rect.right + margins[1], - bottom: rect.bottom + margins[2], - left: rect.left - margins[3] - }; - newRect.width = newRect.right - newRect.left; - newRect.height = newRect.bottom - newRect.top; - - return newRect; - } - - /** - * Accepts an old and new entry and returns true if at least one of the - * threshold values has been crossed. - * @param {?IntersectionObserverEntry} oldEntry The previous entry for a - * particular target element or null if no previous entry exists. - * @param {IntersectionObserverEntry} newEntry The current entry for a - * particular target element. - * @return {boolean} Returns true if a any threshold has been crossed. - * @private - */ - _hasCrossedThreshold(oldEntry, newEntry) { - // To make comparing easier, an entry that has a ratio of 0 - // but does not actually intersect is given a value of -1 - const oldRatio = oldEntry && oldEntry.isIntersecting ? - oldEntry.intersectionRatio || 0 : -1; - const newRatio = newEntry.isIntersecting ? - newEntry.intersectionRatio || 0 : -1; - - // Ignore unchanged ratios - if (oldRatio === newRatio) return; - - for (let i = 0; i < this.thresholds.length; i++) { - const threshold = this.thresholds[i]; - - // Return true if an entry matches a threshold or if the new ratio - // and the old ratio are on the opposite sides of a threshold. - if (threshold == oldRatio || threshold == newRatio || - threshold < oldRatio !== threshold < newRatio) { - return true; - } - } - } - - /** - * Returns whether or not the root element is an element and is in the DOM. - * @return {boolean} True if the root element is an element and is in the DOM. - * @private - */ - _rootIsInDom() { - return !this.root || containsDeep(document, this.root); - } - - /** - * Returns whether or not the target element is a child of root. - * @param {Element} target The target element to check. - * @return {boolean} True if the target element is a child of root. - * @private - */ - _rootContainsTarget(target) { - return containsDeep(this.root || document, target); - } - - /** - * Adds the instance to the global IntersectionObserver registry if it isn't - * already present. - * @private - */ - _registerInstance() { - if (registry.indexOf(this) < 0) { - registry.push(this); - } - } - - /** - * Removes the instance from the global IntersectionObserver registry. - * @private - */ - _unregisterInstance() { - const index = registry.indexOf(this); - if (index !== -1) registry.splice(index, 1); - } -} - -/** - * Returns the result of the performance.now() method or null in browsers - * that don't support the API. - * @return {number} The elapsed time since the page was requested. - */ -function now() { - return window.performance && performance.now && performance.now(); -} - - -/** - * Throttles a function and delays its execution, so it's only called at most - * once within a given time period. - * @param {Function} fn The function to throttle. - * @param {number} timeout The amount of time that must pass before the - * function can be called again. - * @return {Function} The throttled function. - */ -function throttle(fn, timeout) { - let timer = null; - return function() { - if (!timer) { - timer = setTimeout(function() { - fn(); - timer = null; - }, timeout); - } - }; -} - - -/** - * Adds an event handler to a DOM node ensuring cross-browser compatibility. - * @param {Node} node The DOM node to add the event handler to. - * @param {string} event The event name. - * @param {Function} fn The event handler to add. - * @param {boolean} opt_useCapture Optionally adds the even to the capture - * phase. Note: this only works in modern browsers. - */ -function addEvent(node, event, fn, opt_useCapture) { - if (typeof node.addEventListener == 'function') { - node.addEventListener(event, fn, opt_useCapture || false); - } else if (typeof node.attachEvent == 'function') { - node.attachEvent('on' + event, fn); - } -} - - -/** - * Removes a previously added event handler from a DOM node. - * @param {Node} node The DOM node to remove the event handler from. - * @param {string} event The event name. - * @param {Function} fn The event handler to remove. - * @param {boolean} opt_useCapture If the event handler was added with this - * flag set to true, it should be set to true here in order to remove it. - */ -function removeEvent(node, event, fn, opt_useCapture) { - if (typeof node.removeEventListener == 'function') { - node.removeEventListener(event, fn, opt_useCapture || false); - } else if (typeof node.detatchEvent == 'function') { - node.detatchEvent('on' + event, fn); - } -} - - -/** - * Returns the intersection between two rect objects. - * @param {Object} rect1 The first rect. - * @param {Object} rect2 The second rect. - * @return {?Object} The intersection rect or undefined if no intersection - * is found. - */ -function computeRectIntersection(rect1, rect2) { - const top = Math.max(rect1.top, rect2.top); - const bottom = Math.min(rect1.bottom, rect2.bottom); - const left = Math.max(rect1.left, rect2.left); - const right = Math.min(rect1.right, rect2.right); - const width = right - left; - const height = bottom - top; - - return width >= 0 && height >= 0 && { top, bottom, left, right, width, height }; -} - - -/** - * Shims the native getBoundingClientRect for compatibility with older IE. - * @param {Element} el The element whose bounding rect to get. - * @return {Object} The (possibly shimmed) rect of the element. - */ -function getBoundingClientRect(el) { - let rect; - - try { - rect = el.getBoundingClientRect(); - } catch (err) { - // Ignore Windows 7 IE11 "Unspecified error" - // https://github.com/w3c/IntersectionObserver/pull/205 - } - - if (!rect) return getEmptyRect(); - - // Older IE - if (!(rect.width && rect.height)) { - rect = { - top: rect.top, - right: rect.right, - bottom: rect.bottom, - left: rect.left, - width: rect.right - rect.left, - height: rect.bottom - rect.top - }; - } - return rect; -} - - -/** - * Returns an empty rect object. An empty rect is returned when an element - * is not in the DOM. - * @return {Object} The empty rect. - */ -function getEmptyRect() { - return { - top: 0, - bottom: 0, - left: 0, - right: 0, - width: 0, - height: 0 - }; -} - -/** - * Checks to see if a parent element contains a child element (including inside - * shadow DOM). - * @param {Node} parent The parent element. - * @param {Node} child The child element. - * @return {boolean} True if the parent node contains the child node. - */ -function containsDeep(parent, child) { - let node = child; - while (node) { - if (node === parent) return true; - - node = getParentNode(node); - } - return false; -} - - -/** - * Gets the parent node of an element or its host element if the parent node - * is a shadow root. - * @param {Node} node The node whose parent to get. - * @return {Node|null} The parent node or null if no parent exists. - */ -function getParentNode(node) { - let parent = node.parentNode; - - if (parent && parent.nodeType == 11 && parent.host) { - // If the parent is a shadow root, return the host element. - return parent.host; - } - - if (parent && parent.assignedSlot) { - // If the parent is distributed in a , return the parent of a slot. - return parent.assignedSlot.parentNode; - } - - return parent; -} \ No newline at end of file diff --git a/src/__tests__/index.js b/src/__tests__/index.js deleted file mode 100644 index cb5da00..0000000 --- a/src/__tests__/index.js +++ /dev/null @@ -1,51 +0,0 @@ -/* eslint-env jest */ -import { createElement } from 'rax'; -import renderer from 'rax-test-renderer'; -import View from 'rax-view'; -import { setupAppear } from '../'; - -setupAppear(); - -function AppearNode() { - return ( -
{ - console.log('appear'); - }}>appear node
- ); -} - -function ViewAppearNode() { - return ( - { - console.log('appear'); - }}>view appear node - ); -} - -describe('AppearComponent', () => { - let componentDiv; - let componentView; - - beforeEach(() => { - componentDiv = renderer.create( - - ); - componentView = renderer.create( - - ); - }); - - it('should render div appear prop', () => { - let tree = componentDiv.toJSON(); - - expect(tree.tagName).toEqual('DIV'); - expect(typeof tree.eventListeners.appear).toEqual('function'); - }); - - it('should render view appear prop', () => { - let tree = componentView.toJSON(); - - expect(tree.tagName).toEqual('DIV'); - expect(typeof tree.eventListeners.appear).toEqual('function'); - }); -}); diff --git a/src/index.d.ts b/src/index.d.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/index.js b/src/index.js deleted file mode 100644 index fbf28a1..0000000 --- a/src/index.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Simulate appear & disappear events. - */ -import { createIntersectionObserver, destroyAllIntersectionObserver, IntersectionObserverMode } from './intersectionObserverManager'; - -// hijack Node.prototype.addEventListener -const injectEventListenerHook = (events = [], Node, observerElement) => { - let nativeAddEventListener = Node.prototype.addEventListener; - - Node.prototype.addEventListener = function (eventName, eventHandler, useCapture, doNotWatch) { - const lowerCaseEventName = eventName && String(eventName).toLowerCase(); - const isAppearEvent = events.some((item) => (item === lowerCaseEventName)); - if (isAppearEvent) observerElement(this); - - nativeAddEventListener.call(this, eventName, eventHandler, useCapture); - }; - - return function unsetup() { - Node.prototype.addEventListener = nativeAddEventListener; - destroyAllIntersectionObserver(); - }; -}; - -export function setupPreAppear(win, options) { - const observerElement = createIntersectionObserver(IntersectionObserverMode.PRE_APPEAR, options); - injectEventListenerHook(['preappear'], win.Node, observerElement); -} - -export function setupAppear(win, options) { - if (!win) { - if (typeof window !== 'undefined') { - win = window; - } else { - return; - } - } - - if (options?.preAppear) { - setupPreAppear(win, options); - } - - const observerElement = createIntersectionObserver(IntersectionObserverMode.DEFAULT, options); - return injectEventListenerHook(['appear', 'disappear'], win.Node, observerElement); -} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..de3c06d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,109 @@ +/** + * Simulate appear & disappear events. + */ +import { createIntersectionObserver, destroyAllIntersectionObserver } from './intersection-observer-manager'; +import { SetupOptions, IntersectionObserverMode } from './types'; + +let SharedIntersectionObserver: typeof window.IntersectionObserver; +async function getIntersectionObserver(options?: SetupOptions) { + // Check if IntersectionObserver is available. + if (typeof window !== 'undefined' && + 'IntersectionObserver' in window && + 'IntersectionObserverEntry' in window && + 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { + // Features are natively supported + SharedIntersectionObserver = window.IntersectionObserver; + } else { + // Initialize the polyfill only once. + if (!SharedIntersectionObserver && options?.intersectionObserverLoader) { + SharedIntersectionObserver = await options.intersectionObserverLoader?.(); + } + } + + if (!SharedIntersectionObserver) { + throw new Error('IntersectionObserver is not available, please set intersectionObserverLoader properly.'); + } + return SharedIntersectionObserver; +} + +const dehijackKey = '__dehijack__'; +const hijackListener = (Constructor: typeof Element, callback: (eventName: string) => void) => { + const nativeAddEventListener = Constructor.prototype.addEventListener; + // Avoid double hijack. + if (Constructor[dehijackKey]) return Constructor[dehijackKey]; + + function hijackAddEventListener(eventName) { + try { + callback.call(this, eventName); + } catch (e) { + console.warn('Error when calling appear polyfill listener.'); + console.warn(e); + } finally { + return nativeAddEventListener.apply(this, arguments); + } + } + + Constructor.prototype.addEventListener = hijackAddEventListener; + return function dehijack() { + Constructor.prototype.addEventListener = nativeAddEventListener; + }; +}; + +// hijack Node.prototype.addEventListener +function injectEventListenerHook(events: string[] = [], Node, observerElement) { + function callback(eventName) { + const lowerCaseEventName = String(eventName).toLowerCase(); + + // Tip: String#indexOf is faster than other methods in most cases. + const matchEvent = events.indexOf(lowerCaseEventName) > -1; + if (matchEvent) observerElement(this); + } + + const teardownFns: Function[] = []; + teardownFns.push( + hijackListener(Node, callback) + ); + // iOS <= 10.2.2, in App built-in WebView, `addEventListener` of `
` + // and `` are inconsistent with Node.prototype.addEventListener. + // The Node.prototype.addEventListener of the corresponding function is required. + if (HTMLDivElement.prototype.addEventListener !== Node.prototype.addEventListener) { + teardownFns.push( + hijackListener(HTMLDivElement, callback) + ); + } + if (HTMLObjectElement.prototype.addEventListener !== Node.prototype.addEventListener) { + teardownFns.push( + hijackListener(HTMLObjectElement, callback) + ); + } + + return function teardown() { + destroyAllIntersectionObserver(); + let fn; + while (fn = teardownFns.shift()) { + fn(); + } + }; +}; + +function getWindow(): Window | null { + if (typeof window !== 'undefined') { + return window; + } else { + return null; + } +} + +export async function setupPreAppear(window = getWindow(), options: SetupOptions = {}) { + const IntersectionObserver = await getIntersectionObserver(options); + const observerElement = createIntersectionObserver(IntersectionObserverMode.PRE_APPEAR, IntersectionObserver, options); + injectEventListenerHook(['preappear'], Node, observerElement); +} + +export async function setupAppear(window = getWindow(), options: SetupOptions = {}) { + if (!window) return; + if (options?.preAppear) await setupPreAppear(window, options); + const IntersectionObserver = await getIntersectionObserver(options); + const observerElement = createIntersectionObserver(IntersectionObserverMode.DEFAULT, IntersectionObserver, options); + return injectEventListenerHook(['appear', 'disappear'], Node, observerElement); +} diff --git a/src/intersectionObserverManager.js b/src/intersection-observer-manager.ts similarity index 67% rename from src/intersectionObserverManager.js rename to src/intersection-observer-manager.ts index 6d5811a..fe28864 100644 --- a/src/intersectionObserverManager.js +++ b/src/intersection-observer-manager.ts @@ -1,33 +1,15 @@ -import PolyfilledIntersectionObserver from './IntersectionObserver'; +import { IntersectionObserverMode, SetupOptions } from './types'; // Shared intersectionObserver instance. let intersectionObserverMap = {} -export const IntersectionObserverMode = { - DEFAULT: 'default', - PRE_APPEAR: 'pre' -} - const intersectionObserverHandleIMap = { [IntersectionObserverMode.DEFAULT]: handleIntersect, [IntersectionObserverMode.PRE_APPEAR]: handlePreIntersect } -const IntersectionObserver = (function () { - if (typeof window !== 'undefined' && - 'IntersectionObserver' in window && - 'IntersectionObserverEntry' in window && - 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { - // features are natively supported - return window.IntersectionObserver; - } else { - // polyfilled IntersectionObserver - return PolyfilledIntersectionObserver; - } -})(); - function generateThreshold(number) { - const thresholds = []; + const thresholds: number[] = []; for (let index = 0; index < number; index++) { thresholds.push(index / number); } @@ -35,39 +17,46 @@ function generateThreshold(number) { return thresholds; } -const defaultCustomOptions = { +const defaultOptions = { threshold: generateThreshold(10), rootMargin: '0px 0px 0px 0px', }; -/** suggest default & pre modes */ -export function createIntersectionObserver(type, customOptions = defaultCustomOptions) { - const { threshold, preAppear } = customOptions; - const options = { - root: null, - rootMargin: type === IntersectionObserverMode.PRE_APPEAR ? preAppear : defaultCustomOptions.rootMargin, - threshold: threshold ?? defaultCustomOptions.threshold - }; - intersectionObserverMap[type] = new IntersectionObserver(intersectionObserverHandleIMap[type], options); - - return _observerElement(type) +export function createIntersectionObserver( + mode: IntersectionObserverMode, + IntersectionObserver, + options: SetupOptions = {}, +) { + const shallowOption = Object.assign({}, options, defaultOptions); + const { threshold, preAppear, rootMargin } = shallowOption; + intersectionObserverMap[mode] = new IntersectionObserver( + intersectionObserverHandleIMap[mode], + { + root: null, + rootMargin: mode === IntersectionObserverMode.PRE_APPEAR ? preAppear : rootMargin, + threshold: threshold + } + ); + return _observerElement(mode) } export function destroyAllIntersectionObserver() { - (Object.keys(intersectionObserverMap) || []).forEach((key) => { + for (let key in intersectionObserverMap) { const current = intersectionObserverMap[key]; if (current) { current.disconnect(); } - }); + } intersectionObserverMap = {}; } -function _observerElement(type) { - return function observerElement(element) { - if (!intersectionObserverMap[type]) createIntersectionObserver(); - if (element === document) element = document.documentElement; - intersectionObserverMap[type].observe(element); +function _observerElement(mode: IntersectionObserverMode) { + return function observerElement(eventTarget: EventTarget) { + if (eventTarget === document) { + eventTarget = document.documentElement; + } + const observer = intersectionObserverMap[mode]; + observer.observe(eventTarget); } } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..cc595be --- /dev/null +++ b/src/types.ts @@ -0,0 +1,17 @@ +export interface SetupOptions { + // The method to load the IntersectionObserver instance. + intersectionObserverLoader?: () => Promise; + + // The thresholds array of the IntersectionObserver instance. + threshold?: number[]; + + // The root margin of the IntersectionObserver instance. + rootMargin?: string; + + preAppear?: string; +} + +export enum IntersectionObserverMode { + DEFAULT = 'default', + PRE_APPEAR = 'pre', +} \ No newline at end of file diff --git a/tests/index.js b/tests/index.js new file mode 100644 index 0000000..5cef6e0 --- /dev/null +++ b/tests/index.js @@ -0,0 +1,51 @@ +// /* eslint-env jest */ +// import { createElement } from 'rax'; +// import renderer from 'rax-test-renderer'; +// import View from 'rax-view'; +// import { setupAppear } from '../'; + +// setupAppear(); + +// function AppearNode() { +// return ( +//
{ +// console.log('appear'); +// }}>appear node
+// ); +// } + +// function ViewAppearNode() { +// return ( +// { +// console.log('appear'); +// }}>view appear node +// ); +// } + +// describe('AppearComponent', () => { +// let componentDiv; +// let componentView; + +// beforeEach(() => { +// componentDiv = renderer.create( +// +// ); +// componentView = renderer.create( +// +// ); +// }); + +// it('should render div appear prop', () => { +// let tree = componentDiv.toJSON(); + +// expect(tree.tagName).toEqual('DIV'); +// expect(typeof tree.eventListeners.appear).toEqual('function'); +// }); + +// it('should render view appear prop', () => { +// let tree = componentView.toJSON(); + +// expect(tree.tagName).toEqual('DIV'); +// expect(typeof tree.eventListeners.appear).toEqual('function'); +// }); +// });