├── CHANGELOG.md ├── .eslintignore ├── .github ├── FUNDING.yml └── workflows │ ├── test.yml │ └── publish.yml ├── pnpm-workspace.yaml ├── packages ├── polyfill │ ├── src │ │ └── index.ts │ ├── .npmignore │ ├── README.md │ ├── api-extractor.json │ ├── package.json │ ├── LICENSE │ └── lib │ │ └── polyfill.js ├── example │ ├── .vscode │ │ └── extensions.json │ ├── public │ │ └── favicon.ico │ ├── src │ │ ├── assets │ │ │ └── logo.png │ │ ├── App.vue │ │ ├── router │ │ │ ├── index.ts │ │ │ └── routes.ts │ │ ├── main.ts │ │ ├── env.d.ts │ │ ├── pages │ │ │ ├── vue │ │ │ │ ├── threshold.vue │ │ │ │ ├── index.vue │ │ │ │ └── reset.vue │ │ │ └── core │ │ │ │ ├── threshold.vue │ │ │ │ ├── reset.vue │ │ │ │ ├── unobserve.vue │ │ │ │ └── index.vue │ │ └── components │ │ │ └── HelloWorld.vue │ ├── tsconfig.node.json │ ├── vite.config.ts │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── tsconfig.json │ └── README.md ├── core │ ├── .npmignore │ ├── README.md │ ├── src │ │ ├── index.ts │ │ ├── utils.ts │ │ ├── observer.ts │ │ └── exposure.ts │ ├── api-extractor.json │ ├── package.json │ └── LICENSE ├── vue │ ├── .npmignore │ ├── api-extractor.json │ ├── LICENSE │ ├── package.json │ ├── src │ │ └── index.ts │ ├── README.zh-CN.md │ └── README.md └── vue2 │ ├── .npmignore │ ├── api-extractor.json │ ├── package.json │ ├── LICENSE │ ├── src │ └── index.ts │ ├── README.zh-CN.md │ └── README.md ├── .prettierrc ├── renovate.json ├── .gitignore ├── .npmignore ├── .eslintrc ├── jest-puppeteer.config.js ├── tsconfig.json ├── jest.config.ts ├── LICENSE ├── api-extractor.json ├── package.json ├── README.zh-CN.md ├── tests ├── vue.spec.ts └── core.spec.ts ├── README.md └── scripts └── build.js /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | example/ -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: hubvue 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | -------------------------------------------------------------------------------- /packages/polyfill/src/index.ts: -------------------------------------------------------------------------------- 1 | import '../lib/polyfill.js' 2 | -------------------------------------------------------------------------------- /packages/example/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/.npmignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | __test__/ 3 | src/ 4 | node_modules/ 5 | api-extractor.json 6 | -------------------------------------------------------------------------------- /packages/vue/.npmignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | __test__/ 3 | src/ 4 | node_modules/ 5 | api-extractor.json 6 | -------------------------------------------------------------------------------- /packages/vue2/.npmignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | __test__/ 3 | src/ 4 | node_modules/ 5 | api-extractor.json 6 | -------------------------------------------------------------------------------- /packages/polyfill/.npmignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | __test__/ 3 | src/ 4 | node_modules/ 5 | api-extractor.json 6 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @exposure-lib/core 2 | 3 | For more information, see [exposure-lib](../../README.md) -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "singleQuote": true, 5 | "endOfLine": "auto" 6 | } 7 | -------------------------------------------------------------------------------- /packages/polyfill/README.md: -------------------------------------------------------------------------------- 1 | # @exposure-lib/polyfill 2 | 3 | For more information, see [exposure-lib](../../README.md) -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { createExposure, resetExposure, Exposure } from './exposure' 2 | export * from './utils' 3 | -------------------------------------------------------------------------------- /packages/example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubvue/exposure-lib/HEAD/packages/example/public/favicon.ico -------------------------------------------------------------------------------- /packages/example/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubvue/exposure-lib/HEAD/packages/example/src/assets/logo.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | **/node_modules 3 | .DS_Store 4 | **/.DS_Store 5 | dist 6 | temp 7 | packages/*/dist/ 8 | packages/*/node_modules 9 | coverage 10 | -------------------------------------------------------------------------------- /packages/example/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | node_modules/ 3 | example/ 4 | polyfill/ 5 | .eslintignore 6 | .eslintrc 7 | .gitignore 8 | .prettierrc 9 | .travis.yml 10 | package-lock.json 11 | rollup.config.js 12 | tsconfig.json -------------------------------------------------------------------------------- /packages/example/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [vue()] 7 | }) 8 | -------------------------------------------------------------------------------- /packages/example/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router' 2 | import routes from './routes' 3 | 4 | export const router = createRouter({ 5 | history: createWebHashHistory(), 6 | routes 7 | }) 8 | -------------------------------------------------------------------------------- /packages/vue/api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../api-extractor.json", 3 | "mainEntryPointFilePath": "./dist/packages//src/index.d.ts", 4 | "dtsRollup": { 5 | "publicTrimmedFilePath": "./dist/index.d.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /packages/core/api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../api-extractor.json", 3 | "mainEntryPointFilePath": "./dist/packages//src/index.d.ts", 4 | "dtsRollup": { 5 | "publicTrimmedFilePath": "./dist/index.d.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /packages/polyfill/api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../api-extractor.json", 3 | "mainEntryPointFilePath": "./dist/packages//src/index.d.ts", 4 | "dtsRollup": { 5 | "publicTrimmedFilePath": "./dist/index.d.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /packages/vue2/api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../api-extractor.json", 3 | "mainEntryPointFilePath": "./dist/packages//src/index.d.ts", 4 | "dtsRollup": { 5 | "publicTrimmedFilePath": "./dist/index.d.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /packages/example/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import ExposurePlugin from '@exposure-lib/vue' 4 | import { router } from './router' 5 | 6 | const app = createApp(App) 7 | 8 | app.use(router) 9 | app.use(ExposurePlugin) 10 | 11 | app.mount('#app') 12 | -------------------------------------------------------------------------------- /packages/example/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 6, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "modules": true 8 | } 9 | }, 10 | "plugins": ["@typescript-eslint"], 11 | "extends": ["plugin:prettier/recommended"], 12 | "rules": { 13 | "quotes": ["error", "single"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | launch: { 3 | headless: process.env.GITPOD !== undefined, 4 | defaultViewport: { 5 | width: 375, 6 | height: 667, 7 | deviceScaleFactor: 2, 8 | isMobile: true, 9 | hasTouch: true, 10 | }, 11 | args: ['--disable-infobars', '--no-sandbox', '--disable-setuid-sandbox'], 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /packages/example/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vue-tsc --noEmit && vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "vue": "^3.2.25", 12 | "vue-router": "^4.0.15" 13 | }, 14 | "devDependencies": { 15 | "@vitejs/plugin-vue": "3.0.0", 16 | "typescript": "^4.5.4", 17 | "vite": "3.0.4", 18 | "vue-tsc": "0.38.9", 19 | "@exposure-lib/core": "workspace:*", 20 | "@exposure-lib/vue": "workspace:*" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["esnext", "dom"], 14 | "skipLibCheck": true 15 | }, 16 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 17 | "references": [{ "path": "./tsconfig.node.json" }] 18 | } 19 | -------------------------------------------------------------------------------- /packages/example/src/pages/vue/threshold.vue: -------------------------------------------------------------------------------- 1 | 9 | 27 | 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "dist", 5 | "sourceMap": false, 6 | "target": "ES2017", 7 | "module": "ESNext", 8 | "moduleResolution": "node", 9 | "allowJs": false, 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "experimentalDecorators": true, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "removeComments": true, 16 | "jsx": "preserve", 17 | "lib": ["esnext", "dom"], 18 | "types": ["node", "jest"], 19 | "rootDir": ".", 20 | "declaration": true, 21 | "paths": { 22 | "@exposure-lib/*": ["packages/*/src"] 23 | } 24 | }, 25 | "include": ["packages/*/src", "packages/*/__test__"], 26 | "exclude": ["packages/*/dist"] 27 | } 28 | -------------------------------------------------------------------------------- /packages/example/src/pages/vue/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 32 | 34 | -------------------------------------------------------------------------------- /packages/example/src/pages/vue/reset.vue: -------------------------------------------------------------------------------- 1 | 9 | 31 | 33 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@exposure-lib/core", 3 | "version": "2.0.3", 4 | "description": "@exposure-lib/core", 5 | "main": "./dist/index.cjs.js", 6 | "module": "./dist/index.esm.js", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": "./dist/index.cjs.js", 11 | "import": "./dist/index.esm.js", 12 | "types": "./dist/index.d.ts" 13 | } 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/hubvue/exposure-lib.git", 18 | "directory": "packages/core" 19 | }, 20 | "keywords": [ 21 | "InterfaceObserver", 22 | "exposure" 23 | ], 24 | "bugs": { 25 | "url": "https://github.com/hubvue/exposure-lib/issues" 26 | }, 27 | "homepage": "https://github.com/hubvue/exposure-lib/tree/main/packages/core#readme", 28 | "author": "hubvue", 29 | "license": "MIT" 30 | } 31 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | // testEnvironment: 'jest-environment-node', 3 | // testEnvironment: 'jsdom', 4 | preset: 'jest-puppeteer', 5 | transform: { 6 | '^.+\\.ts?$': 'ts-jest', 7 | }, 8 | clearMocks: true, 9 | collectCoverage: true, 10 | coverageDirectory: 'coverage', 11 | coveragePathIgnorePatterns: ['/node_modules/'], 12 | coverageProvider: 'babel', 13 | coverageReporters: ['html', 'lcov', 'text'], 14 | errorOnDeprecated: false, 15 | maxWorkers: '50%', 16 | moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'node'], 17 | rootDir: __dirname, 18 | testMatch: ['/tests/**/*spec.[jt]s?(x)'], 19 | testPathIgnorePatterns: ['/node_modules/'], 20 | watchPathIgnorePatterns: [ 21 | '/node_modules/', 22 | '/dist/', 23 | '/.git/', 24 | '/docs/', 25 | './vscode', 26 | '/.github/', 27 | '/scripts/', 28 | '/temp/', 29 | ], 30 | } 31 | -------------------------------------------------------------------------------- /packages/polyfill/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@exposure-lib/polyfill", 3 | "version": "2.0.3", 4 | "description": "@exposure-lib/polyfill", 5 | "main": "./dist/index.cjs.js", 6 | "module": "./dist/index.esm.js", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": "./dist/index.cjs.js", 11 | "import": "./dist/index.esm.js", 12 | "types": "./dist/index.d.ts" 13 | } 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/hubvue/exposure-lib.git", 18 | "directory": "packages/polyfill" 19 | }, 20 | "keywords": [ 21 | "InterfaceObserver", 22 | "exposure" 23 | ], 24 | "bugs": { 25 | "url": "https://github.com/hubvue/exposure-lib/issues" 26 | }, 27 | "homepage": "https://github.com/hubvue/exposure-lib/tree/main/packages/polyfill#readme", 28 | "author": "hubvue", 29 | "license": "MIT" 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | - name: Install pnpm 19 | uses: pnpm/action-setup@v2.2.2 20 | with: 21 | version: 6.15.1 22 | 23 | - name: Use Node.js v16 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: v16 27 | registry-url: https://registry.npmjs.org/ 28 | cache: "pnpm" 29 | 30 | - name: Install Dependencies 31 | run: pnpm install 32 | 33 | - name: Test 34 | run: pnpm run test 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/vue2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@exposure-lib/vue2", 3 | "version": "2.0.3", 4 | "description": "@exposure-lib/vue2", 5 | "main": "./dist/index.cjs.js", 6 | "module": "./dist/index.esm.js", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": "./dist/index.cjs.js", 11 | "import": "./dist/index.esm.js", 12 | "types": "./dist/index.d.ts" 13 | } 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/hubvue/exposure-lib.git", 18 | "directory": "packages/vue2" 19 | }, 20 | "keywords": [ 21 | "InterfaceObserver", 22 | "exposure" 23 | ], 24 | "bugs": { 25 | "url": "https://github.com/hubvue/exposure-lib/issues" 26 | }, 27 | "homepage": "https://github.com/hubvue/exposure-lib/tree/main/packages/vue2#readme", 28 | "author": "hubvue", 29 | "license": "MIT", 30 | "peerDependencies": { 31 | "vue": ">=2.6.11", 32 | "@exposure-lib/core": ">=2.0.0" 33 | }, 34 | "devDependencies": { 35 | "vue": "^2.6.11" 36 | }, 37 | "dependencies": { 38 | "@exposure-lib/core": "^2.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | - name: Install pnpm 16 | uses: pnpm/action-setup@v2.2.2 17 | with: 18 | version: 6.15.1 19 | 20 | - name: Use Node.js v16 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: v16 24 | registry-url: https://registry.npmjs.org/ 25 | cache: "pnpm" 26 | 27 | - run: npx conventional-github-releaser -p angular 28 | continue-on-error: true 29 | env: 30 | CONVENTIONAL_GITHUB_RELEASER_TOKEN: ${{secrets.GITHUB_TOKEN}} 31 | 32 | - name: Install Dependencies 33 | run: pnpm install 34 | 35 | - name: Build 36 | run: pnpm run build:all 37 | 38 | - name: Publish 39 | run: pnpm -r publish --access public --no-git-checks 40 | env: 41 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 42 | -------------------------------------------------------------------------------- /packages/core/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/vue/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/vue2/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/polyfill/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@exposure-lib/vue", 3 | "version": "2.0.3", 4 | "description": "@exposure-lib/vue", 5 | "main": "./dist/index.cjs.js", 6 | "module": "./dist/index.esm.js", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": "./dist/index.cjs.js", 11 | "import": "./dist/index.esm.js", 12 | "types": "./dist/index.d.ts" 13 | } 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/hubvue/exposure-lib.git", 18 | "directory": "packages/vue" 19 | }, 20 | "keywords": [ 21 | "InterfaceObserver", 22 | "exposure", 23 | "vue" 24 | ], 25 | "bugs": { 26 | "url": "https://github.com/hubvue/exposure-lib/issues" 27 | }, 28 | "homepage": "https://github.com/hubvue/exposure-lib/tree/main/packages/vue#readme", 29 | "author": "hubvue", 30 | "license": "MIT", 31 | "peerDependencies": { 32 | "vue": ">=3.2.31", 33 | "@exposure-lib/core": ">=2.0.0" 34 | }, 35 | "devDependencies": { 36 | "vue": "^3.2.31" 37 | }, 38 | "dependencies": { 39 | "@exposure-lib/core": "^2.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/example/src/router/routes.ts: -------------------------------------------------------------------------------- 1 | import CoreBase from '../pages/core/index.vue' 2 | import CoreThreshold from '../pages/core/threshold.vue' 3 | import CoreReset from '../pages/core/reset.vue' 4 | import CoreUnobserve from '../pages/core/unobserve.vue' 5 | 6 | import VueBase from '../pages/vue/index.vue' 7 | import VueThreshold from '../pages/vue/threshold.vue' 8 | import VueReset from '../pages/vue/reset.vue' 9 | 10 | export default [ 11 | { 12 | name: "CoreBase", 13 | path: '/core-base', 14 | component: CoreBase 15 | }, 16 | { 17 | name: "CoreThreshold", 18 | path: '/core-threshold', 19 | component: CoreThreshold 20 | }, 21 | { 22 | name: "CoreReset", 23 | path: '/core-reset', 24 | component: CoreReset 25 | }, 26 | { 27 | name: "CoreUnobserve", 28 | path: '/core-unobserve', 29 | component: CoreUnobserve 30 | }, 31 | { 32 | name: "VueBase", 33 | path: '/vue-base', 34 | component: VueBase 35 | }, 36 | { 37 | name: "VueThreshold", 38 | path: '/vue-threshold', 39 | component: VueThreshold 40 | }, 41 | { 42 | name: 'VueReset', 43 | path: '/vue-reset', 44 | component: VueReset 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /packages/example/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 35 | 36 | 53 | -------------------------------------------------------------------------------- /packages/example/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + TypeScript + Vite 2 | 3 | This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` 57 | 59 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | // this the shared base config for all packages. 2 | { 3 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 4 | 5 | "apiReport": { 6 | "enabled": true, 7 | "reportFolder": "/temp/" 8 | }, 9 | 10 | "docModel": { 11 | "enabled": true 12 | }, 13 | 14 | "dtsRollup": { 15 | "enabled": true 16 | }, 17 | 18 | "tsdocMetadata": { 19 | "enabled": false 20 | }, 21 | 22 | "messages": { 23 | "compilerMessageReporting": { 24 | "default": { 25 | "logLevel": "warning" 26 | } 27 | }, 28 | 29 | "extractorMessageReporting": { 30 | "default": { 31 | "logLevel": "warning", 32 | "addToApiReportFile": true 33 | }, 34 | 35 | "ae-missing-release-tag": { 36 | "logLevel": "none" 37 | } 38 | }, 39 | 40 | "tsdocMessageReporting": { 41 | "default": { 42 | "logLevel": "warning" 43 | }, 44 | 45 | "tsdoc-undefined-tag": { 46 | "logLevel": "none" 47 | }, 48 | 49 | "tsdoc-escape-greater-than": { 50 | "logLevel": "none" 51 | }, 52 | 53 | "tsdoc-malformed-inline-tag": { 54 | "logLevel": "none" 55 | }, 56 | 57 | "tsdoc-escape-right-brace": { 58 | "logLevel": "none" 59 | }, 60 | 61 | "tsdoc-unnecessary-backslash": { 62 | "logLevel": "none" 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/example/src/pages/core/reset.vue: -------------------------------------------------------------------------------- 1 | 9 | 59 | 61 | -------------------------------------------------------------------------------- /packages/example/src/pages/core/unobserve.vue: -------------------------------------------------------------------------------- 1 | 9 | 59 | 61 | -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | export type FuncHandler = (el?: Element) => void 2 | export type ObjectHandler = { enter?: FuncHandler; leave?: FuncHandler } 3 | export type ExposureHandler = FuncHandler | ObjectHandler 4 | 5 | /** 6 | * @description determines if the value is an ObjectHandler. 7 | * @param value 8 | * @returns 9 | */ 10 | export const isObjectHandler = (value: any): value is ObjectHandler => { 11 | if (!value || typeof value !== 'object') { 12 | return false 13 | } 14 | if (!value.enter && !value.leave) { 15 | return false 16 | } 17 | if (value.enter && typeof value.enter !== 'function') { 18 | return false 19 | } 20 | if (value.leave && typeof value.leave !== 'function') { 21 | return false 22 | } 23 | 24 | return true 25 | } 26 | 27 | /** 28 | * @description determines if the value is a FunctionHandler. 29 | * @param value 30 | * @returns 31 | */ 32 | export const isFuncHandler = (value: any): value is FuncHandler => { 33 | if (typeof value === 'function') { 34 | return true 35 | } 36 | return false 37 | } 38 | 39 | /** 40 | * @description determines if the value is an ExposureHandler. 41 | * @param value 42 | * @returns 43 | */ 44 | export const isExposureHandler = (value: any): value is ExposureHandler => { 45 | return isObjectHandler(value) || isFuncHandler(value) 46 | } 47 | 48 | /** 49 | * @description determine if an element is visible or not 50 | * @param el Element 51 | * @returns boolean 52 | */ 53 | export const isVisibleElement = (el: Element) => { 54 | const { visibility, height, width } = window.getComputedStyle(el, null) 55 | if ( 56 | visibility === 'hidden' || 57 | parseInt(height) === 0 || 58 | parseInt(width) === 0 59 | ) { 60 | return false 61 | } 62 | return true 63 | } 64 | 65 | /** 66 | * @description generate an array of element exposure thresholds 67 | * @param count number 68 | * @param unit number 69 | * @returns number[] 70 | */ 71 | export const genThreshold = (count = 0, unit = 0.1) => { 72 | let threshold: number[] = [] 73 | while (count <= 1) { 74 | threshold.push(count) 75 | count = Number((count + unit).toFixed(2)) 76 | } 77 | return threshold 78 | } 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@exposure-lib/monorepo", 3 | "version": "2.0.3", 4 | "private": true, 5 | "description": "基于InterfaceObserver API,当绑定元素出现在视窗内的时候执行回调", 6 | "scripts": { 7 | "build": "node ./scripts/build.js", 8 | "build:all": "node ./scripts/build.js --all", 9 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s && git add ./CHANGELOG.md", 10 | "format": "eslint --fix . --ext .js,.ts,.cjs,.mjs", 11 | "release": "bumpp package.json packages/*/package.json --commit --push --tag", 12 | "example:start": "pnpm run dev --filter example", 13 | "test:e2e": "jest --runInBand", 14 | "test": "run-p example:start test:e2e" 15 | }, 16 | "publishConfig": { 17 | "directory": "packages/*" 18 | }, 19 | "devDependencies": { 20 | "@commitlint/cli": "17.0.3", 21 | "@commitlint/config-conventional": "17.0.3", 22 | "@microsoft/api-extractor": "^7.19.4", 23 | "@rollup/plugin-replace": "4.0.0", 24 | "@types/expect-puppeteer": "^4.4.7", 25 | "@types/jest": "28.1.6", 26 | "@types/jest-environment-puppeteer": "^5.0.2", 27 | "@types/node": "^17.0.21", 28 | "@types/puppeteer": "^5.4.6", 29 | "@typescript-eslint/eslint-plugin": "^3.3.0", 30 | "@typescript-eslint/parser": "^3.3.0", 31 | "bumpp": "8.2.1", 32 | "chalk": "5.0.1", 33 | "conventional-changelog-cli": "^2.0.34", 34 | "eslint": "^7.2.0", 35 | "eslint-config-prettier": "^6.11.0", 36 | "eslint-plugin-prettier": "4.2.1", 37 | "esmo": "0.16.3", 38 | "inquirer": "^8.2.0", 39 | "jest": "^28.1.0", 40 | "jest-puppeteer": "^6.1.0", 41 | "lint-staged": "^10.2.10", 42 | "minimist": "^1.2.5", 43 | "prettier": "^2.0.5", 44 | "puppeteer": "15.4.0", 45 | "rollup": "^2.16.1", 46 | "rollup-plugin-typescript2": "0.32.1", 47 | "ts-jest": "^28.0.2", 48 | "ts-node": "^10.7.0", 49 | "typescript": "^4.6.4", 50 | "npm-run-all": "^4.1.5" 51 | }, 52 | "lint-staged": { 53 | "{packages, example}/**/*.{ts, cjs, mjs}": [ 54 | "npm run format", 55 | "prettier --write", 56 | "git add ." 57 | ] 58 | }, 59 | "commitlint": { 60 | "extends": [ 61 | "@commitlint/config-conventional" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/example/src/pages/core/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 62 | 106 | -------------------------------------------------------------------------------- /packages/core/src/observer.ts: -------------------------------------------------------------------------------- 1 | import { elementContextMap } from './exposure' 2 | import { 3 | genThreshold, 4 | isFuncHandler, 5 | isObjectHandler, 6 | isVisibleElement, 7 | } from './utils' 8 | 9 | interface ObserverOptions { 10 | delay?: number 11 | threshold?: number[] 12 | trackVisibility?: boolean 13 | } 14 | 15 | const OBSERVER_OPTIONS: ObserverOptions = { 16 | delay: 100, 17 | threshold: genThreshold(), 18 | trackVisibility: true, 19 | } 20 | 21 | /** 22 | * @description create an instance of IntersectionObserver, single instance mode. 23 | */ 24 | export const createObserver = () => { 25 | if (!window.IntersectionObserver) { 26 | return null 27 | } 28 | const intersectionObserver = new window.IntersectionObserver((list) => { 29 | for (let entry of list) { 30 | const { isIntersecting, target, intersectionRatio } = entry 31 | const config = elementContextMap.get(target) 32 | if (config) { 33 | // Skip when handler is a function and callback has been triggered 34 | if (isFuncHandler(config.handler) && config.active.enter) { 35 | continue 36 | } 37 | // Skip when handler is a object and callback has been triggered 38 | if ( 39 | isObjectHandler(config.handler) && 40 | config.active.enter && 41 | config.active.leave 42 | ) { 43 | continue 44 | } 45 | if (!isVisibleElement(target)) { 46 | continue 47 | } 48 | if ( 49 | isIntersecting && 50 | !config.active.enter && 51 | intersectionRatio >= config.threshold 52 | ) { 53 | if (isFuncHandler(config.handler)) { 54 | config.handler(target) 55 | } else { 56 | config.handler.enter && config.handler.enter(target) 57 | } 58 | config.active.enter = true 59 | } else { 60 | if ( 61 | isObjectHandler(config.handler) && 62 | !config.active.leave && 63 | config.active.enter && 64 | intersectionRatio <= 0 65 | ) { 66 | config.handler.leave && config.handler.leave(target) 67 | config.active.leave = true 68 | } 69 | } 70 | elementContextMap.set(target, config) 71 | } 72 | } 73 | }, OBSERVER_OPTIONS) 74 | 75 | return intersectionObserver 76 | } 77 | -------------------------------------------------------------------------------- /packages/vue/src/index.ts: -------------------------------------------------------------------------------- 1 | import { App, VNode, DirectiveBinding, Plugin } from 'vue' 2 | 3 | import { createExposure, resetExposure, Exposure } from '@exposure-lib/core' 4 | 5 | export interface DirectiveHandlerType { 6 | (el: Element, binding: DirectiveBinding, vnode: VNode): void 7 | } 8 | export interface InstallHandlerType { 9 | (_Vue: App, options?: { threshold?: number }): void 10 | } 11 | 12 | let Vue: App 13 | let exposure: Exposure 14 | const Logger = console 15 | 16 | /** 17 | * @description Resets the callback of a listening element to an executable state. 18 | * The purpose is to be compatible with keepAlive, 19 | * Bind the $resetExposure method to a Vue instance and execute it in the deactivated lifecycle. 20 | * If the project is built with Vue 2 + composition-api, you can use useResetExposure to reset the exposure. 21 | */ 22 | export const useResetExposure = resetExposure 23 | 24 | /** 25 | * @param {*} el 26 | * @param {*} binding 27 | * @param {*} vnode 28 | * @description customize the directive bind method, 29 | * execute addElToObserve to listen to the el. 30 | */ 31 | 32 | const mounted: DirectiveHandlerType = (el, binding, vnode) => { 33 | let { value, arg } = binding 34 | let threshold: number 35 | if (!exposure) { 36 | Logger.error('exposure is not initialized, please use Vue.use(Exposure)') 37 | return 38 | } 39 | 40 | threshold = Number(arg) 41 | if ((arg && typeof arg !== 'number') || !arg) { 42 | arg && Logger.error('element arguments must be number type') 43 | threshold = exposure.threshold 44 | } 45 | exposure.observe(el, value, threshold) 46 | } 47 | 48 | /** 49 | * 50 | * @param {*} el 51 | * @description unsubscribe when components are destroyed 52 | */ 53 | const beforeUnmount: DirectiveHandlerType = (el) => { 54 | if (!exposure) { 55 | Logger.error('exposure is not initialized, please use Vue.use(Exposure)') 56 | return 57 | } 58 | exposure.unobserve(el) 59 | } 60 | 61 | /** 62 | * @description Vue global registration of custom directives 63 | */ 64 | const installDirective = () => { 65 | Vue.directive('exposure', { 66 | mounted, 67 | beforeUnmount, 68 | }) 69 | } 70 | 71 | /** 72 | * @param {*} _Vue 73 | * @description the install method of the Vue plugin mechanism to create an observer, i.e. a registration directive. 74 | */ 75 | const install: InstallHandlerType = (_Vue: App, options) => { 76 | if (!Vue) { 77 | Vue = _Vue 78 | } 79 | let golablThreshold 80 | if (options && options.threshold) { 81 | golablThreshold = options.threshold 82 | } 83 | if (!exposure) { 84 | exposure = createExposure(golablThreshold) 85 | } 86 | installDirective() 87 | } 88 | 89 | const ExposurePLugin: Plugin = { 90 | install, 91 | } 92 | 93 | export default ExposurePLugin 94 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # exposure-lib 2 | 3 | 基于 InterfaceObserver API,监听元素是否可见,当元素出现在视窗内的时候执行回调函数。 4 | 5 | ## Quick Start 6 | 7 | ### Install 8 | 9 | > pnpm add @exposure-lib/core 10 | 11 | 由于 InterfaceObserver API 的兼容性在一些低版本浏览器上支持的还是不怎么好,可以事先引入polyfill至于`@exposure-lib/core`正常使用。 12 | 13 | > pnpm add @exposure-lib/polyfill 14 | 15 | **引入包** 16 | 17 | ```ts 18 | import '@exposure-lib/polyfill' 19 | import * as Exposure from '@exposure-lib/core' 20 | ``` 21 | 22 | > 注意:polyfill包一定要在core包之前引入 23 | 24 | ### Usage 25 | 26 | 使用`exposure`来监听元素是否出现在可视区内非常简单,只需要两步即可。 27 | 28 | 1. 首先需要创建一个`Exposure`用来监听元素,它通过`createExposure`方法创建。 29 | 30 | ```ts 31 | import { createExposure } from '@exposure-lib/core' 32 | const exposure = createExposure() 33 | ``` 34 | 35 | 2. 然后调用`Exposure`的`observe`方法监听元素 36 | 37 | ```ts 38 | const el = document.getElementById('el') 39 | exposure.observe(el, () => { 40 | console.log('exposure') 41 | }) 42 | ``` 43 | `exposure.observe`方法至少接受两个参数,第一个参数为Element类型的元素,第二个参数为Handler,当监测元素出现在可视区内执行Handler,第三个参数为监听阈值(可选)。 44 | 45 | 46 | ### threshold 47 | 48 | 默认情况下,曝光回调的执行是等待整个绑定元素全部包裹后才会执行。如果您有需求当元素出现一定比例是曝光, 49 | 可以设置 threshold,使用下面两种方式。 50 | 51 | #### Exposure threshold 52 | 53 | 每次调用`createExposure`方法创建`Exposure`支持传入threshold用于当前`Exposure`作用域下的元素使用。 54 | 55 | ```ts 56 | const exposure = createExposure(0.2) 57 | ``` 58 | 59 | 如上面代码所示,当元素的曝光比例达到 0.2 的时候,就会执行回调函数。 60 | 61 | #### Element threshold 62 | 63 | 如果你想要某个元素的曝光比例与其他元素的不同,可单独为元素设置 threshold, 64 | 65 | ```ts 66 | const el = document.getElementById('el') 67 | const exposure = createExposure(0.2) 68 | 69 | exposure.observe(el, () => { 70 | console.log('exposure') 71 | }, 0.8) 72 | 73 | ``` 74 | 75 | > 需要注意:Element threshold > Exposure threshold 76 | 77 | 78 | ### Handler 79 | Handler 也就是指令的值,有两种类型:函数或对象 80 | 81 | **函数** 82 | 83 | 函数类型是比较普遍的写法, 函数Handler只会在元素进入曝光且符合`threshold`情况下触发一次。 84 | 85 | **对象** 86 | 87 | 对象类型的Handler需要有`enter`和`leave`属性其一,且`enter`和`leave`属性的值为函数类型。 88 | 89 | - enter: enter Handler 会在元素进入曝光且符合`threshold`情况下触发一次; 90 | - leave: leave Handler 会在 enter Handler 触发后,元素彻底离开可视区域后触发一次; 91 | 92 | 93 | ### resetExposure 94 | 95 | 曝光回调的执行是单例的,也就是说当曝光过一次并且回调执行后,再次曝光就不会再执行回调函数。如果需要再次曝光则需要调用`resetExposure`来重置。 96 | 97 | ```ts 98 | import { resetExposure } from '@exposure-lib/core' 99 | // 重置所有元素 100 | resetExposure() 101 | // 重置el元素 102 | const el = document.getElementById('el') 103 | resetExposure(el) 104 | ``` 105 | 106 | ### unobserve 107 | 108 | 当页面销毁需要将当前页面内监听元素取消,调用`exposure.unobserve`方法取消监听元素 109 | 110 | ```ts 111 | const el = document.getElementById('el') 112 | const exposure = createExposure(0.2) 113 | 114 | exposure.observe(el, () => { 115 | console.log('exposure') 116 | }, 0.8) 117 | 118 | // 页面销毁 119 | destory(() => { 120 | exposure.unobserve(el) 121 | }) 122 | ``` 123 | ### 注意事项 124 | 125 | vue-exposure 监听元素是严格模式的,当一个元素的`visibility`为`hidden`或者`width`为`0`或者`height`为`0`都不会去监听。 126 | -------------------------------------------------------------------------------- /packages/core/src/exposure.ts: -------------------------------------------------------------------------------- 1 | import { createObserver } from './observer' 2 | import { ExposureHandler, isExposureHandler } from './utils' 3 | 4 | interface ElementContext { 5 | active: { 6 | enter: boolean 7 | leave: boolean 8 | } 9 | handler: ExposureHandler 10 | threshold: number 11 | } 12 | 13 | export interface Exposure { 14 | threshold: number 15 | observe(el: Element, handler: ExposureHandler, threshold?: number): void 16 | unobserve(el: Element): void 17 | } 18 | 19 | const Logger = console 20 | let Observer: IntersectionObserver | null 21 | export const elementContextMap = new Map() 22 | 23 | /** 24 | * @description resets the callback of a listening element to an executable state. 25 | * @param el? Element 26 | */ 27 | export const resetExposure = (el?: Element) => { 28 | if (el && elementContextMap.has(el)) { 29 | const context = elementContextMap.get(el) 30 | if (context) { 31 | context.active.enter = false 32 | context.active.leave = false 33 | elementContextMap.set(el, context) 34 | } 35 | } else { 36 | for (let [el, context] of elementContextMap.entries()) { 37 | context.active.enter = false 38 | context.active.leave = false 39 | elementContextMap.set(el, context) 40 | } 41 | } 42 | } 43 | 44 | /** 45 | * @description create exposure instance 46 | * @param threshold? number 47 | * @returns Exposure 48 | */ 49 | export const createExposure = (golablThreshold = 1): Exposure => { 50 | if (!Observer) { 51 | Observer = createObserver() 52 | } 53 | if (!Observer) { 54 | Logger.warn( 55 | '[WARN]:current browser does not support IntersectionObserve API' 56 | ) 57 | } 58 | function observe(el: Element, handler: ExposureHandler, threshold?: number) { 59 | if (!isExposureHandler(handler)) { 60 | Logger.error( 61 | `[ERROR]: handler is not ExposureHandler. 62 | ExposureHandler type: 63 | - function: (el?: Element) => void 64 | - object: {enter?: (el?: Element) => void, leave?: (el?: Element) => void} 65 | ` 66 | ) 67 | return 68 | } 69 | if (!Observer) { 70 | Logger.warn('[WRAN]: IntersectionObserver not initialised') 71 | return 72 | } 73 | let th = threshold 74 | if (!th) { 75 | th = golablThreshold 76 | } 77 | if (!elementContextMap.has(el)) { 78 | elementContextMap.set(el, { 79 | active: { 80 | enter: false, 81 | leave: false, 82 | }, 83 | handler, 84 | threshold: th, 85 | }) 86 | Observer.observe(el) 87 | } 88 | } 89 | function unobserve(el: Element) { 90 | if (elementContextMap.has(el) && Observer) { 91 | elementContextMap.delete(el) 92 | Observer.unobserve(el) 93 | } 94 | } 95 | return { 96 | threshold: golablThreshold, 97 | observe, 98 | unobserve, 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/vue.spec.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'puppeteer' 2 | 3 | // set default timeout 4 | jest.setTimeout(1000000) 5 | 6 | const getCount = async (page: Page) => { 7 | return page.$$eval('.count', (els) => { 8 | return els[0].textContent 9 | }) 10 | } 11 | 12 | describe('vue#base', () => { 13 | let page = (global as any).page as Page 14 | beforeAll(async () => { 15 | await page.goto('http://0.0.0.0:3000/#/vue-base') 16 | }) 17 | 18 | test('should be an element trigger at the beginning', async () => { 19 | const count = await getCount(page) 20 | expect(count).toBe('1') 21 | }) 22 | 23 | test('when scrolling to middle appears, count should be added by 1', async () => { 24 | await page.evaluate(() => { 25 | window.scrollTo(0, 700) 26 | }) 27 | await page.waitForTimeout(1000) 28 | const count = await getCount(page) 29 | 30 | expect(count).toBe('2') 31 | }) 32 | 33 | test('when the middle element leaves the visible area, count should be added by 1', async () => { 34 | await page.evaluate(() => { 35 | window.scrollTo(0, 1500) 36 | }) 37 | await page.waitForTimeout(1000) 38 | const count = await getCount(page) 39 | expect(count).toBe('3') 40 | }) 41 | 42 | test('when the bottom element leaves the visible area, count should be added by 1', async () => { 43 | await page.evaluate(() => { 44 | window.scrollTo(0, 2500) 45 | }) 46 | await page.waitForTimeout(1000) 47 | const count = await getCount(page) 48 | expect(count).toBe('4') 49 | }) 50 | }) 51 | 52 | describe('vue#threshold', () => { 53 | let page = (global as any).page as Page 54 | beforeAll(async () => { 55 | await page.goto('http://0.0.0.0:3000/#/vue-threshold') 56 | }) 57 | 58 | test('when the middle element is exposed to half of the visible area, count should be added by 1', async () => { 59 | await page.evaluate(() => { 60 | window.scrollTo(0, 595) 61 | }) 62 | 63 | await page.waitForTimeout(1000) 64 | const count = await getCount(page) 65 | expect(count).toBe('2') 66 | }) 67 | }) 68 | 69 | describe('vue#reset', () => { 70 | let page = (global as any).page as Page 71 | beforeAll(async () => { 72 | await page.goto('http://0.0.0.0:3000/#/vue-reset') 73 | }) 74 | 75 | test('The node state should be reset after resetExposure is triggered', async () => { 76 | await page.evaluate(() => { 77 | window.scrollTo(0, 0) 78 | }) 79 | await page.waitForTimeout(1000) 80 | // 1 滚动到中间触发middle 81 | await page.evaluate(() => { 82 | window.scrollTo(0, 800) 83 | }) 84 | await page.waitForTimeout(1000) 85 | 86 | // 滚动到底部触发bottom 87 | await page.evaluate(() => { 88 | window.scrollTo(0, 2500) 89 | }) 90 | await page.waitForTimeout(1000) 91 | 92 | // 再次滚动到中间触发middle 93 | await page.evaluate(() => { 94 | window.scrollTo(0, 800) 95 | }) 96 | await page.waitForTimeout(1000) 97 | 98 | // 再次滚动到顶部触发top 99 | await page.evaluate(() => { 100 | window.scrollTo(0, 0) 101 | }) 102 | await page.waitForTimeout(1000) 103 | 104 | // // 再次滚动到中间触发middle 105 | await page.evaluate(() => { 106 | window.scrollTo(0, 800) 107 | }) 108 | await page.waitForTimeout(1000) 109 | 110 | const count = await getCount(page) 111 | expect(count).toBe('6') 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /packages/vue2/src/index.ts: -------------------------------------------------------------------------------- 1 | import VueType, { VNode } from 'vue' 2 | import { DirectiveBinding } from 'vue/types/options' 3 | import { createExposure, resetExposure, Exposure } from '@exposure-lib/core' 4 | 5 | interface DirectiveHandlerType { 6 | (el: Element, binding: DirectiveBinding, vnode: VNode): void 7 | } 8 | interface InstallHandlerType { 9 | (_Vue: typeof VueType, options?: { threshold?: number }): void 10 | } 11 | 12 | // statement Merging $resetExposure method 13 | declare module 'vue/types/vue' { 14 | interface Vue { 15 | $resetExposure: typeof useResetExposure 16 | } 17 | } 18 | 19 | let Vue: typeof VueType 20 | let exposure: Exposure 21 | const Logger = console 22 | 23 | /** 24 | * @description Resets the callback of a listening element to an executable state. 25 | * The purpose is to be compatible with keepAlive, 26 | * Bind the $resetExposure method to a Vue instance and execute it in the deactivated lifecycle. 27 | * If the project is built with Vue 2 + composition-api, you can use useResetExposure to reset the exposure. 28 | */ 29 | export const useResetExposure = resetExposure 30 | 31 | /** 32 | * @param {*} el 33 | * @param {*} binding 34 | * @param {*} vnode 35 | * @description customize the directive bind method, 36 | * bind the $resetExposure method to a Vue instance, 37 | * execute addElToObserve to listen to the el. 38 | */ 39 | const bind: DirectiveHandlerType = (el, binding, vnode) => { 40 | let { value, arg } = binding 41 | let threshold: number 42 | const { context } = vnode 43 | if (!context) { 44 | return 45 | } 46 | if (!exposure) { 47 | Logger.error('exposure is not initialized, please use Vue.use(Exposure)') 48 | return 49 | } 50 | threshold = Number(arg) 51 | if ((arg && typeof arg !== 'number') || !arg) { 52 | arg && Logger.error('element arguments must be number type') 53 | threshold = exposure.threshold 54 | } 55 | if (context.$resetExposure && context.$resetExposure !== useResetExposure) { 56 | Logger.error('context bind $resetExposure propertyKey') 57 | return 58 | } 59 | !context.$resetExposure && (context.$resetExposure = useResetExposure) 60 | exposure.observe(el, value, threshold) 61 | } 62 | /** 63 | * 64 | * @param {*} el 65 | * @description unsubscribe when components are destroyed 66 | */ 67 | const unbind: DirectiveHandlerType = (el) => { 68 | if (!exposure) { 69 | Logger.error('exposure is not initialized, please use Vue.use(Exposure)') 70 | return 71 | } 72 | exposure.unobserve(el) 73 | } 74 | /** 75 | * @description Vue global registration of custom directives 76 | */ 77 | const installDirective = () => { 78 | Vue.directive('exposure', { 79 | bind, 80 | unbind, 81 | }) 82 | } 83 | /** 84 | * @param {*} _Vue 85 | * @description the install method of the Vue plugin mechanism to create an observer, i.e. a registration directive. 86 | */ 87 | const install: InstallHandlerType = (_Vue, options) => { 88 | if (!Vue) { 89 | Vue = _Vue 90 | } 91 | let golablThreshold 92 | if (options && options.threshold) { 93 | golablThreshold = options.threshold 94 | } 95 | if (!exposure) { 96 | exposure = createExposure(golablThreshold) 97 | } 98 | installDirective() 99 | } 100 | 101 | const ExposurePLugin = { 102 | install, 103 | } 104 | 105 | export default ExposurePLugin 106 | -------------------------------------------------------------------------------- /packages/vue/README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # @exposure-lib/vue 2 | 3 | [@exposure-lib/core](../../README.md) 4 | 5 | 基于 @exposure-lib/core,采用vue指令的方式绑定元素,当元素出现在视窗内的时候执行回调,支持 `Vue 3.x` 6 | 7 | ## Quick Start 8 | 9 | ### Install 10 | 11 | ```shell 12 | pnpm add @exposure-lib/core @exposure-lib/vue 13 | ``` 14 | 15 | 如果需求浏览器环境不支持` InterfaceObserver API `,则可以引入`ployfill`以便于正常使用 16 | 17 | ```shell 18 | pnpm add @exposure-lib/polyfill 19 | ``` 20 | 21 | **引入包** 22 | 23 | ```js 24 | import '@exposure-lib/polyfill' 25 | import Exposure from '@exposure-lib/vue' 26 | ``` 27 | 28 | ### 使用插件 29 | 30 | @exposure-lib/vue 默认当元素全部区域都展示在视窗时才会执行回调函数。 31 | 32 | ```js 33 | createApp(App).use(Exposure).mount('#app') 34 | ``` 35 | 36 | 37 | ### 在组件中使用 38 | 39 | @exposure-lib/vue 基于 vue 指令封装,使得在开发过程中更加方便,例如下面这个组件。 40 | 41 | ```vue 42 | 49 | 50 | 75 | 76 | 89 | ``` 90 | 91 | 滚动界面,当元素出现在视窗内的时候触发回调函数。 92 | 93 | 94 | #### Handler 95 | 详见[exposure-lib](../../README.md) 96 | #### threshold 97 | 98 | 默认情况下,曝光回调的执行是等待整个绑定元素全部包裹后才会执行。如果您有需求当元素出现一定比例是曝光, 99 | 可以设置 threshold,使用下面两种方式。 100 | 101 | ##### 全局级 threshold 102 | 103 | @exposure-lib/vue 支持全局的 threshold 设置。 104 | 105 | ```js 106 | Vue.use(Exposure, { 107 | threshold: 0.2, 108 | }) 109 | ``` 110 | 111 | 如上面代码所示,当元素的曝光比例达到 0.2 的时候,就会执行回调函数。 112 | 113 | ##### 元素级 threshold 114 | 115 | 如果你想要某个元素的曝光比例与其他元素的不同,可单独为元素设置 threshold, 116 | 117 | ```vue 118 | 125 | 126 | 136 | ``` 137 | 138 | 使用 Vue 动态指令参数的方式对指令传参,所传值必须是`[0,1]`之间的数值,这样在监听曝光的时候就会按照所传值的比例进行曝光。 139 | 140 | > 需要注意:元素级 threshold > 全局级 threshold 141 | 142 | ### \$useResetExposure 143 | 144 | 曝光回调的执行是单例的,也就是说当曝光过一次并且回调执行后,再次曝光就不会再执行回调函数。 145 | 146 | 在 Vue 组件中存在 KeepAlive 的场景,当 KeepAlive 组件切换的时候曝光回调也不会重新执行。这种情况下如果想要重新执行就需要使用`useResetExposure`API 去重置元素状态。 147 | 148 | ```js 149 | export default defineComponent({ 150 | name: 'KeepaliveExposure', 151 | setup (props, context) { 152 | onDeactivated(() => { 153 | useResetExposure() 154 | }) 155 | } 156 | }) 157 | ``` 158 | 159 | 当调用`useResetExposure()`不传入任何参数的时候讲会把当前实例中所有监听元素的执行状态全部重置。如果需要只重置某个元素的执行状态,需要传入当前元素。 160 | 161 | ```js 162 | export default defineComponent({ 163 | name: 'KeepaliveExposure', 164 | setup(props, context) { 165 | onDeactivated(() => { 166 | useResetExposure(element) 167 | }) 168 | }, 169 | }) 170 | ``` -------------------------------------------------------------------------------- /packages/vue2/README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # @exposure-lib/vue2 2 | 3 | [@exposure-lib/core](../../README.md) 4 | 5 | 基于 @exposure-lib/core,采用vue指令的方式绑定元素,当元素出现在视窗内的时候执行回调,支持 `Vue 2.x` 6 | 7 | ## Quick Start 8 | 9 | ### Install 10 | 11 | ```shell 12 | pnpm add @exposure-lib/core @exposure-lib/vue2 13 | ``` 14 | 15 | 如果需求浏览器环境不支持` InterfaceObserver API `,则可以引入`ployfill`以便于正常使用 16 | 17 | ```shell 18 | pnpm add @exposure-lib/polyfill 19 | ``` 20 | 21 | **引入包** 22 | 23 | ```js 24 | import '@exposure-lib/polyfill' 25 | import Exposure from '@exposure-lib/vue2' 26 | ``` 27 | 28 | ### 使用插件 29 | 30 | @exposure-lib/vue2 默认当元素全部区域都展示在视窗时才会执行回调函数。 31 | 32 | ```js 33 | Vue.use(Exposure) 34 | ``` 35 | 36 | 37 | ### 在组件中使用 38 | 39 | @exposure-lib/vue2 基于 vue 指令封装,使得在开发过程中更加方便,例如下面这个组件。 40 | 41 | ```vue 42 | 49 | 50 | 75 | 76 | 89 | ``` 90 | 91 | 滚动界面,当元素出现在视窗内的时候触发回调函数。 92 | 93 | 94 | #### Handler 95 | 详见[exposure-lib](../../README.md) 96 | #### threshold 97 | 98 | 默认情况下,曝光回调的执行是等待整个绑定元素全部包裹后才会执行。如果您有需求当元素出现一定比例是曝光, 99 | 可以设置 threshold,使用下面两种方式。 100 | 101 | ##### 全局级 threshold 102 | 103 | @exposure-lib/vue2 支持全局的 threshold 设置。 104 | 105 | ```js 106 | Vue.use(Exposure, { 107 | threshold: 0.2, 108 | }) 109 | ``` 110 | 111 | 如上面代码所示,当元素的曝光比例达到 0.2 的时候,就会执行回调函数。 112 | 113 | ##### 元素级 threshold 114 | 115 | 如果你想要某个元素的曝光比例与其他元素的不同,可单独为元素设置 threshold, 116 | 117 | ```vue 118 | 125 | 126 | 136 | ``` 137 | 138 | 使用 Vue 动态指令参数的方式对指令传参,所传值必须是`[0,1]`之间的数值,这样在监听曝光的时候就会按照所传值的比例进行曝光。 139 | 140 | > 需要注意:元素级 threshold > 全局级 threshold 141 | 142 | ### \$resetExposure 143 | 144 | 曝光回调的执行是单例的,也就是说当曝光过一次并且回调执行后,再次曝光就不会再执行回调函数。 145 | 146 | 在 Vue 组件中存在 KeepAlive 的场景,当 KeepAlive 组件切换的时候曝光回调也不会重新执行。这种情况下如果想要重新执行就需要使用`$resetExposure`API 去重置元素状态。 147 | 148 | ```js 149 | deactivated() { 150 | this.$resetExposure() 151 | } 152 | ``` 153 | 154 | 当调用`this.$resetExposure()`不传入任何参数的时候讲会把当前实例中所有监听元素的执行状态全部重置。如果需要只重置某个元素的执行状态,需要传入当前元素。 155 | 156 | ```js 157 | deactivated() { 158 | this.$resetExposure(this.$refs.el) 159 | } 160 | ``` 161 | 162 | #### Vue 2 + composition-api 163 | 若项目使用Vue 2 + composition-api构建,为了遵循composition-api 编码规范,则可以使用useResetExposure 重置曝光。 164 | 165 | ```ts 166 | import { useResetExposure } from 'vue-exposure' 167 | import { defineComponent, onDeactivated } from '@vue/composition-api' 168 | export default defineComponent({ 169 | setup() { 170 | onDeactivated(() => { 171 | useResetExposure() 172 | }) 173 | } 174 | }) 175 | ``` -------------------------------------------------------------------------------- /tests/core.spec.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'puppeteer' 2 | 3 | // set default timeout 4 | jest.setTimeout(1000000) 5 | 6 | const getCount = async (page: Page) => { 7 | return page.$$eval('.count', (els) => { 8 | return els[0].textContent 9 | }) 10 | } 11 | 12 | describe('core#base', () => { 13 | let page = (global as any).page as Page 14 | beforeAll(async () => { 15 | await page.goto('http://0.0.0.0:3000/#/core-base') 16 | }) 17 | 18 | test('should be an element trigger at the beginning', async () => { 19 | const count = await getCount(page) 20 | expect(count).toBe('1') 21 | }) 22 | 23 | test('when scrolling to middle appears, count should be added by 1', async () => { 24 | await page.evaluate(() => { 25 | window.scrollTo(0, 700) 26 | }) 27 | await page.waitForTimeout(1000) 28 | const count = await getCount(page) 29 | 30 | expect(count).toBe('2') 31 | }) 32 | 33 | test('when the middle element leaves the visible area, count should be added by 1', async () => { 34 | await page.evaluate(() => { 35 | window.scrollTo(0, 1500) 36 | }) 37 | await page.waitForTimeout(1000) 38 | const count = await getCount(page) 39 | expect(count).toBe('3') 40 | }) 41 | 42 | test('when the bottom element leaves the visible area, count should be added by 1', async () => { 43 | await page.evaluate(() => { 44 | window.scrollTo(0, 2500) 45 | }) 46 | await page.waitForTimeout(1000) 47 | const count = await getCount(page) 48 | expect(count).toBe('4') 49 | }) 50 | }) 51 | 52 | describe('core#threshold', () => { 53 | let page = (global as any).page as Page 54 | beforeAll(async () => { 55 | await page.goto('http://0.0.0.0:3000/#/core-threshold') 56 | }) 57 | 58 | test('when the middle element is exposed to half of the visible area, count should be added by 1', async () => { 59 | await page.evaluate(() => { 60 | window.scrollTo(0, 595) 61 | }) 62 | 63 | await page.waitForTimeout(1000) 64 | const count = await getCount(page) 65 | expect(count).toBe('2') 66 | }) 67 | }) 68 | 69 | describe('core#reset', () => { 70 | let page = (global as any).page as Page 71 | beforeAll(async () => { 72 | await page.goto('http://0.0.0.0:3000/#/core-reset') 73 | }) 74 | 75 | test('The node state should be reset after resetExposure is triggered', async () => { 76 | await page.evaluate(() => { 77 | window.scrollTo(0, 0) 78 | }) 79 | await page.waitForTimeout(1000) 80 | // 1 滚动到中间触发middle 81 | await page.evaluate(() => { 82 | window.scrollTo(0, 800) 83 | }) 84 | await page.waitForTimeout(1000) 85 | 86 | // 滚动到底部触发bottom 87 | await page.evaluate(() => { 88 | window.scrollTo(0, 2500) 89 | }) 90 | await page.waitForTimeout(1000) 91 | 92 | // 再次滚动到中间触发middle 93 | await page.evaluate(() => { 94 | window.scrollTo(0, 800) 95 | }) 96 | await page.waitForTimeout(1000) 97 | 98 | // 再次滚动到顶部触发top 99 | await page.evaluate(() => { 100 | window.scrollTo(0, 0) 101 | }) 102 | await page.waitForTimeout(1000) 103 | 104 | // // 再次滚动到中间触发middle 105 | await page.evaluate(() => { 106 | window.scrollTo(0, 800) 107 | }) 108 | await page.waitForTimeout(1000) 109 | 110 | const count = await getCount(page) 111 | expect(count).toBe('6') 112 | }) 113 | }) 114 | 115 | describe('core#unobserve', () => { 116 | let page = (global as any).page as Page 117 | beforeAll(async () => { 118 | await page.goto('http://0.0.0.0:3000/#/core-unobserve') 119 | }) 120 | 121 | test('The node should be unlistened to after the unobserve is triggered', async () => { 122 | await page.evaluate(() => { 123 | window.scrollTo(0, 0) 124 | }) 125 | await page.waitForTimeout(1000) 126 | // 1 滚动到中间触发middle 127 | await page.evaluate(() => { 128 | window.scrollTo(0, 800) 129 | }) 130 | await page.waitForTimeout(1000) 131 | 132 | // 滚动到底部触发bottom 133 | await page.evaluate(() => { 134 | window.scrollTo(0, 2500) 135 | }) 136 | await page.waitForTimeout(1000) 137 | 138 | // 再次滚动到中间触发middle 139 | await page.evaluate(() => { 140 | window.scrollTo(0, 800) 141 | }) 142 | await page.waitForTimeout(1000) 143 | 144 | // 再次滚动到顶部不会触发top 145 | await page.evaluate(() => { 146 | window.scrollTo(0, 0) 147 | }) 148 | await page.waitForTimeout(1000) 149 | 150 | const count = await getCount(page) 151 | expect(count).toBe('4') 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # exposure-lib 2 | 3 | [中文文档](./README.zh-CN.md) 4 | 5 | [Support Vue 2.x Doc](./packages/vue2/README.md) 6 | 7 | [Support Vue 3.x Doc](./packages/vue/README.md) 8 | 9 | 10 | Based on the InterfaceObserver API, listens for elements to be visible or not, and executes a callback function when the element appears in the viewport. 11 | 12 | ## Quick Start 13 | 14 | ### Install 15 | 16 | > pnpm add @exposure-lib/core 17 | 18 | Since the compatibility of the InterfaceObserver API is still not well supported on some low version browsers, you can introduce polyfill to `@exposure-lib/core` beforehand to use it normally. 19 | 20 | > pnpm add @exposure-lib/polyfill 21 | 22 | **Introducing the package** 23 | 24 | ```ts 25 | import '@exposure-lib/polyfill' 26 | import * as Exposure from '@exposure-lib/core' 27 | ``` 28 | 29 | > Note: the polyfill package must be introduced before the core package 30 | 31 | ### Usage 32 | 33 | Using `exposure` to listen to whether an element appears in the visible area is very simple and requires only two steps. 34 | 35 | 1. First you need to create an `Exposure` to listen to the element, which is created by the `createExposure` method. 36 | 37 | ```ts 38 | import { createExposure } from '@exposure-lib/core' 39 | const exposure = createExposure() 40 | ``` 41 | 42 | 2. Then call the `observe` method of `Exposure` to listen for the element 43 | 44 | ```ts 45 | const el = document.getElementById('el') 46 | exposure.observe(el, () => { 47 | console.log('exposure') 48 | }) 49 | ``` 50 | The `exposure.observe` method accepts at least two arguments, the first one is an element of type Element, the second one is a Handler, which is executed when the monitored element appears in the visible area, and the third one is a listening threshold (optional). 51 | 52 | 53 | ### threshold 54 | 55 | By default, the execution of the exposure callback waits for the entire bound element to be fully wrapped before it is executed. If you have a need to expose an element when a certain percentage of it appears, the 56 | you can set the threshold, using the following two methods. 57 | 58 | #### Exposure threshold 59 | 60 | Each call to the `createExposure` method to create an `Exposure` supports passing in a threshold for use by elements under the current `Exposure` scope. 61 | 62 | ```ts 63 | const exposure = createExposure(0.2) 64 | ``` 65 | 66 | As shown in the code above, the callback function is executed when the exposure ratio of the element reaches 0.2. 67 | 68 | #### Element threshold 69 | 70 | If you want the exposure ratio of an element to be different from that of other elements, you can set the threshold for the element separately 71 | 72 | ```ts 73 | const el = document.getElementById('el') 74 | const exposure = createExposure(0.2) 75 | 76 | exposure.observe(el, () => { 77 | console.log('exposure') 78 | }, 0.8) 79 | 80 | ``` 81 | 82 | > Needs attention:Element threshold > Exposure threshold 83 | 84 | 85 | ### Handler 86 | Handler has two types: function or object 87 | 88 | **Function** 89 | 90 | The function type is the more common way of writing, the function Handler will only be triggered once when the element is exposed and the `threshold` is met. 91 | 92 | **Object** 93 | 94 | A Handler of object type needs to have one of the `enter` and `leave` attributes, and the values of the `enter` and `leave` attributes are of function type. 95 | 96 | - enter: enter Handler is triggered once when an element enters exposure and `threshold` is met. 97 | - leave: the leave Handler is triggered once after the enter Handler is triggered and the element leaves the visible area completely. 98 | 99 | 100 | ### resetExposure 101 | 102 | Exposure callbacks are executed in a single instance, which means that once an exposure has been made and the callback executed, the callback function will not be executed again. If you need to expose again, you need to call `resetExposure` to reset it. 103 | 104 | ```ts 105 | import { resetExposure } from '@exposure-lib/core' 106 | // reset all elements 107 | resetExposure() 108 | // reset el element 109 | const el = document.getElementById('el') 110 | resetExposure(el) 111 | ``` 112 | 113 | ### unobserve 114 | 115 | When the page is destroyed and the listener element in the current page needs to be unobserved, call the `exposure.unobserve` method to unobserve the listener element. 116 | 117 | ```ts 118 | const el = document.getElementById('el') 119 | const exposure = createExposure(0.2) 120 | 121 | exposure.observe(el, () => { 122 | console.log('exposure') 123 | }, 0.8) 124 | 125 | // Page Destroy 126 | destory(() => { 127 | exposure.unobserve(el) 128 | }) 129 | ``` 130 | ### Cautions 131 | 132 | exposure-lib listens to elements in strict mode, when an element's `visibility` is `hidden` or `width` is `0` or `height` is `0` it will not be listened to. 133 | -------------------------------------------------------------------------------- /packages/vue/README.md: -------------------------------------------------------------------------------- 1 | # @exposure-lib/vue 2 | 3 | [@exposure-lib/vue 中文文档](./README.zh-CN.md) 4 | 5 | [@exposure-lib/core](../../README.md) 6 | 7 | Based on @exposure-lib/core, using vue directives to bind elements and execute callbacks when they appear in the viewport, supporting `Vue 3.x`. 8 | 9 | ## Quick Start 10 | 11 | ### Install 12 | 13 | ```shell 14 | pnpm add @exposure-lib/vue 15 | ``` 16 | 17 | If the required browser environment does not support the `InterfaceObserver API`, then `ployfill` can be introduced for normal use 18 | 19 | ```shell 20 | pnpm add @exposure-lib/polyfill 21 | ``` 22 | 23 | **Introducing the package** 24 | 25 | ```js 26 | import '@exposure-lib/polyfill' 27 | import Exposure from '@exposure-lib/vue' 28 | ``` 29 | 30 | ### Using Plugin 31 | 32 | The @exposure-lib/vue callback function is executed by default when all areas of the element are displayed in the viewport. 33 | 34 | ```js 35 | createApp(App).use(Exposure).mount('#app') 36 | ``` 37 | 38 | 39 | ### In Component 40 | 41 | @exposure-lib/vue is based on the vue directive wrapper, making it easier to develop components such as the one below. 42 | 43 | ```vue 44 | 51 | 52 | 77 | 78 | 91 | ``` 92 | 93 | Scrolls the interface and triggers the callback function when the element appears in the viewport. 94 | 95 | 96 | #### Handler 97 | For details, see[exposure-lib](../../README.md) 98 | #### threshold 99 | 100 | By default, the execution of the exposure callback waits for the entire bound element to be fully wrapped before it is executed. If you have a need to expose an element when a certain percentage of it appears, the 101 | you can set the threshold, using the following two methods. 102 | 103 | ##### Golbal threshold 104 | 105 | @exposure-lib/vue supports global threshold settings. 106 | 107 | ```js 108 | Vue.use(Exposure, { 109 | threshold: 0.2, 110 | }) 111 | ``` 112 | 113 | As shown in the code above, the callback function is executed when the exposure ratio of the element reaches 0.2. 114 | 115 | ##### Element threshold 116 | 117 | If you want the exposure ratio of an element to be different from that of other elements, you can set the threshold for the element separately. 118 | 119 | ```vue 120 | 127 | 128 | 138 | ``` 139 | 140 | Using Vue dynamic directive parameters for directives, the value passed must be a value between `[0,1]` so that the exposure will be in proportion to the value passed when listening to the exposure. 141 | 142 | > Needs attention: Golbal threshold > Element threshold 143 | 144 | ### useResetExposure 145 | 146 | Exposure callbacks are executed in a single instance, which means that once an exposure has been made and the callback executed, the callback function will not be executed again after another exposure. 147 | 148 | There is a KeepAlive scenario in Vue components, where the exposure callback is not re-executed when the KeepAlive component is switched. In this case, if you want to re-execute it, you need to use the `useResetExposure` API to reset the element state. 149 | 150 | ```js 151 | export default defineComponent({ 152 | name: 'KeepaliveExposure', 153 | setup (props, context) { 154 | onDeactivated(() => { 155 | useResetExposure() 156 | }) 157 | } 158 | }) 159 | ``` 160 | 161 | When calling `useResetExposure()` without passing any arguments, it will reset the execution state of all the listened elements in the current instance. If you need to reset the execution state of only one element, you need to pass in the current element. 162 | 163 | ```js 164 | export default defineComponent({ 165 | name: 'KeepaliveExposure', 166 | setup(props, context) { 167 | onDeactivated(() => { 168 | useResetExposure(element) 169 | }) 170 | }, 171 | }) 172 | ``` 173 | -------------------------------------------------------------------------------- /packages/vue2/README.md: -------------------------------------------------------------------------------- 1 | # @exposure-lib/vue2 2 | 3 | [@exposure-lib/vue2 中文文档](./README.zh-CN.md) 4 | 5 | [@exposure-lib/core](../../README.md) 6 | 7 | Based on @exposure-lib/core, using vue directives to bind elements and execute callbacks when they appear in the viewport, supporting `Vue 2.x`. 8 | 9 | ## Quick Start 10 | 11 | ### Install 12 | 13 | ```shell 14 | pnpm add @exposure-lib/vue2 15 | ``` 16 | 17 | If the required browser environment does not support the `InterfaceObserver API`, then `ployfill` can be introduced for normal use 18 | 19 | ```shell 20 | pnpm add @exposure-lib/polyfill 21 | ``` 22 | 23 | **Introducing the package** 24 | 25 | ```js 26 | import '@exposure-lib/polyfill' 27 | import Exposure from '@exposure-lib/vue2' 28 | ``` 29 | 30 | ### Using Plugin 31 | 32 | The @exposure-lib/vue2 callback function is executed by default when all areas of the element are displayed in the viewport. 33 | 34 | ```js 35 | Vue.use(Exposure) 36 | ``` 37 | 38 | 39 | ### In Component 40 | 41 | @exposure-lib/vue2 is based on the vue directive wrapper, making it easier to develop components such as the one below. 42 | 43 | ```vue 44 | 51 | 52 | 77 | 78 | 91 | ``` 92 | 93 | Scrolls the interface and triggers the callback function when the element appears in the viewport. 94 | 95 | 96 | #### Handler 97 | For details, see[exposure-lib](../../README.md) 98 | #### threshold 99 | 100 | By default, the execution of the exposure callback waits for the entire bound element to be fully wrapped before it is executed. If you have a need to expose an element when a certain percentage of it appears, the 101 | you can set the threshold, using the following two methods. 102 | 103 | ##### Golbal threshold 104 | 105 | @exposure-lib/vue2 supports global threshold settings. 106 | 107 | ```js 108 | Vue.use(Exposure, { 109 | threshold: 0.2, 110 | }) 111 | ``` 112 | 113 | As shown in the code above, the callback function is executed when the exposure ratio of the element reaches 0.2. 114 | 115 | ##### Element threshold 116 | 117 | If you want the exposure ratio of an element to be different from that of other elements, you can set the threshold for the element separately. 118 | 119 | ```vue 120 | 127 | 128 | 138 | ``` 139 | 140 | Using Vue dynamic directive parameters for directives, the value passed must be a value between `[0,1]` so that the exposure will be in proportion to the value passed when listening to the exposure. 141 | 142 | > Needs attention: Golbal threshold > Element threshold 143 | 144 | ### \$resetExposure 145 | 146 | Exposure callbacks are executed in a single instance, which means that once an exposure has been made and the callback executed, the callback function will not be executed again after another exposure. 147 | 148 | There is a KeepAlive scenario in Vue components, where the exposure callback is not re-executed when the KeepAlive component is switched. In this case, if you want to re-execute it, you need to use the `$resetExposure` API to reset the element state. 149 | 150 | ```js 151 | deactivated() { 152 | this.$resetExposure() 153 | } 154 | ``` 155 | 156 | When calling `this.$resetExposure()` without passing any arguments, it will reset the execution state of all the listened elements in the current instance. If you need to reset the execution state of only one element, you need to pass in the current element. 157 | 158 | ```js 159 | deactivated() { 160 | this.$resetExposure(this.$refs.el) 161 | } 162 | ``` 163 | 164 | #### Vue 2 + composition-api 165 | If the project is built with Vue 2 + composition-api, you can use useResetExposure to reset the exposure in order to follow the composition-api coding specification. 166 | 167 | ```ts 168 | import { useResetExposure } from 'vue-exposure' 169 | import { defineComponent, onDeactivated } from '@vue/composition-api' 170 | export default defineComponent({ 171 | setup() { 172 | onDeactivated(() => { 173 | useResetExposure() 174 | }) 175 | } 176 | }) 177 | ``` 178 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises 2 | const inquirer = require('inquirer') 3 | const typescript = require('rollup-plugin-typescript2') 4 | const chalk = require('chalk') 5 | const rollup = require('rollup') 6 | const { resolve } = require('path') 7 | 8 | const args = require('minimist')(process.argv.slice(2)) 9 | 10 | const getPackagesName = async () => { 11 | const allPackagesName = await fs.readdir(resolve(__dirname, '../packages')) 12 | return allPackagesName 13 | .filter((packageName) => { 14 | const isHiddenFile = /^\./g.test(packageName) 15 | return !isHiddenFile 16 | }) 17 | .filter((packageName) => { 18 | const isPrivatePackage = require(resolve( 19 | __dirname, 20 | `../packages/${packageName}/package.json` 21 | )).private 22 | return !isPrivatePackage 23 | }) 24 | } 25 | 26 | const getAnswersFromInquirer = async (packagesName) => { 27 | const choicePackageQuestion = { 28 | type: 'checkbox', 29 | name: 'packages', 30 | scroll: false, 31 | message: 'Select build repo(Support Multiple selection)', 32 | choices: packagesName.map((packageName) => ({ 33 | value: packageName, 34 | packageName, 35 | })), 36 | } 37 | let { packages } = await inquirer.prompt(choicePackageQuestion) 38 | if (!packages.length) { 39 | console.log( 40 | chalk.yellow(` 41 | It seems that you did't make a choice. 42 | 43 | Please try it again. 44 | `) 45 | ) 46 | return 47 | } 48 | if (packages.some((package) => package === 'all')) { 49 | packagesName.shift() 50 | packages = packagesName 51 | } 52 | const confirmPackageQuestion = { 53 | name: 'confirm', 54 | message: `Confirm build ${packages.join(' and ')} packages?`, 55 | type: 'list', 56 | choices: ['Y', 'N'], 57 | } 58 | const { confirm } = await inquirer.prompt(confirmPackageQuestion) 59 | if (confirm === 'N') { 60 | console.log(chalk.yellow('[release] cancelled.')) 61 | return 62 | } 63 | return packages 64 | } 65 | 66 | const cleanPackagesOldDist = async (packagesName) => { 67 | for (let packageName of packagesName) { 68 | const distPath = resolve(__dirname, `../packages/${packageName}/dist`) 69 | try { 70 | const stat = await fs.stat(distPath) 71 | if (stat.isDirectory()) { 72 | await fs.rm(distPath, { 73 | recursive: true, 74 | }) 75 | } 76 | } catch (err) { 77 | console.log('err', err) 78 | console.log(chalk.red(`remove ${packageName} dist dir error!`)) 79 | } 80 | } 81 | } 82 | 83 | const cleanPackagesDtsDir = async (packageName) => { 84 | const dtsPath = resolve(__dirname, `../packages/${packageName}/dist/packages`) 85 | console.log('dtsPath', dtsPath) 86 | try { 87 | const stat = await fs.stat(dtsPath) 88 | if (stat.isDirectory()) { 89 | await fs.rm(dtsPath, { 90 | recursive: true, 91 | }) 92 | } 93 | } catch (err) { 94 | console.log(err) 95 | console.log(chalk.red(`remove ${packageName} dist/packages dir error!`)) 96 | } 97 | } 98 | 99 | const pascalCase = (str) => { 100 | const re = /-(\w)/g 101 | const newStr = str.replace(re, function (match, group1) { 102 | return group1.toUpperCase() 103 | }) 104 | return newStr.charAt(0).toUpperCase() + newStr.slice(1) 105 | } 106 | 107 | const formats = ['esm', 'cjs'] 108 | const packageOtherConfig = { 109 | vue2: { 110 | external: ['@exposure-lib/core'], 111 | }, 112 | vue: { 113 | external: ['@exposure-lib/core'], 114 | }, 115 | } 116 | const generateBuildConfigs = (packagesName) => { 117 | const packagesFormatConfig = packagesName.map((packageName) => { 118 | const formatConfigs = [] 119 | for (let format of formats) { 120 | formatConfigs.push({ 121 | packageName, 122 | config: { 123 | input: resolve(__dirname, `../packages/${packageName}/src/index.ts`), 124 | output: { 125 | name: pascalCase(packageName), 126 | file: resolve( 127 | __dirname, 128 | `../packages/${packageName}/dist/index.${format}.js` 129 | ), 130 | format, 131 | }, 132 | plugins: [ 133 | typescript({ 134 | verbosity: -1, 135 | tsconfig: resolve(__dirname, '../tsconfig.json'), 136 | tsconfigOverride: { 137 | include: [`package/${packageName}/src`], 138 | }, 139 | }), 140 | ], 141 | ...packageOtherConfig[packageName], 142 | }, 143 | }) 144 | } 145 | return formatConfigs 146 | }) 147 | return packagesFormatConfig.flat() 148 | } 149 | 150 | const extractDts = (packageName) => { 151 | const { Extractor, ExtractorConfig } = require('@microsoft/api-extractor') 152 | const extractorConfigPath = resolve( 153 | __dirname, 154 | `../packages/${packageName}/api-extractor.json` 155 | ) 156 | const extractorConfig = 157 | ExtractorConfig.loadFileAndPrepare(extractorConfigPath) 158 | const result = Extractor.invoke(extractorConfig, { 159 | localBuild: true, 160 | showVerboseMessages: true, 161 | }) 162 | return result 163 | } 164 | 165 | const buildEntry = async (packageConfig) => { 166 | try { 167 | const packageBundle = await rollup.rollup(packageConfig.config) 168 | await packageBundle.write(packageConfig.config.output) 169 | const extractResult = extractDts(packageConfig.packageName) 170 | await cleanPackagesDtsDir(packageConfig.packageName) 171 | if (!extractResult.succeeded) { 172 | console.log(chalk.red(`${packageConfig.packageName} d.ts extract fail!`)) 173 | } 174 | console.log(chalk.green(`${packageConfig.packageName} build successful! `)) 175 | } catch (err) { 176 | console.log(chalk.red(`${packageConfig.packageName} build fail!`)) 177 | } 178 | } 179 | 180 | const build = async (packagesConfig) => { 181 | for (let config of packagesConfig) { 182 | await buildEntry(config) 183 | } 184 | } 185 | 186 | const buildBootstrap = async () => { 187 | const packagesName = await getPackagesName() 188 | let buildPackagesName = packagesName 189 | if (!args.all) { 190 | packagesName.unshift('all') 191 | const answers = await getAnswersFromInquirer(packagesName) 192 | if (!answers) { 193 | return 194 | } 195 | buildPackagesName = answers 196 | } 197 | await cleanPackagesOldDist(buildPackagesName) 198 | const packagesBuildConfig = generateBuildConfigs(buildPackagesName) 199 | await build(packagesBuildConfig) 200 | } 201 | 202 | buildBootstrap().catch((err) => { 203 | console.log('err', err) 204 | process.exit(1) 205 | }) 206 | -------------------------------------------------------------------------------- /packages/polyfill/lib/polyfill.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2016 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE. 5 | * 6 | * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document 7 | * 8 | */ 9 | ;(function () { 10 | 'use strict' 11 | 12 | // Exit early if we're not running in a browser. 13 | if (typeof window !== 'object') { 14 | return 15 | } 16 | 17 | // Exit early if all IntersectionObserver and IntersectionObserverEntry 18 | // features are natively supported. 19 | if ( 20 | 'IntersectionObserver' in window && 21 | 'IntersectionObserverEntry' in window && 22 | 'intersectionRatio' in window.IntersectionObserverEntry.prototype 23 | ) { 24 | // Minimal polyfill for Edge 15's lack of `isIntersecting` 25 | // See: https://github.com/w3c/IntersectionObserver/issues/211 26 | if (!('isIntersecting' in window.IntersectionObserverEntry.prototype)) { 27 | Object.defineProperty( 28 | window.IntersectionObserverEntry.prototype, 29 | 'isIntersecting', 30 | { 31 | get: function () { 32 | return this.intersectionRatio > 0 33 | }, 34 | } 35 | ) 36 | } 37 | return 38 | } 39 | 40 | /** 41 | * A local reference to the document. 42 | */ 43 | var document = window.document 44 | 45 | /** 46 | * An IntersectionObserver registry. This registry exists to hold a strong 47 | * reference to IntersectionObserver instances currently observing a target 48 | * element. Without this registry, instances without another reference may be 49 | * garbage collected. 50 | */ 51 | var registry = [] 52 | 53 | /** 54 | * The signal updater for cross-origin intersection. When not null, it means 55 | * that the polyfill is configured to work in a cross-origin mode. 56 | * @type {function(DOMRect|ClientRect, DOMRect|ClientRect)} 57 | */ 58 | var crossOriginUpdater = null 59 | 60 | /** 61 | * The current cross-origin intersection. Only used in the cross-origin mode. 62 | * @type {DOMRect|ClientRect} 63 | */ 64 | var crossOriginRect = null 65 | 66 | /** 67 | * Creates the global IntersectionObserverEntry constructor. 68 | * https://w3c.github.io/IntersectionObserver/#intersection-observer-entry 69 | * @param {Object} entry A dictionary of instance properties. 70 | * @constructor 71 | */ 72 | function IntersectionObserverEntry(entry) { 73 | this.time = entry.time 74 | this.target = entry.target 75 | this.rootBounds = ensureDOMRect(entry.rootBounds) 76 | this.boundingClientRect = ensureDOMRect(entry.boundingClientRect) 77 | this.intersectionRect = ensureDOMRect( 78 | entry.intersectionRect || getEmptyRect() 79 | ) 80 | this.isIntersecting = !!entry.intersectionRect 81 | 82 | // Calculates the intersection ratio. 83 | var targetRect = this.boundingClientRect 84 | var targetArea = targetRect.width * targetRect.height 85 | var intersectionRect = this.intersectionRect 86 | var intersectionArea = intersectionRect.width * intersectionRect.height 87 | 88 | // Sets intersection ratio. 89 | if (targetArea) { 90 | // Round the intersection ratio to avoid floating point math issues: 91 | // https://github.com/w3c/IntersectionObserver/issues/324 92 | this.intersectionRatio = Number( 93 | (intersectionArea / targetArea).toFixed(4) 94 | ) 95 | } else { 96 | // If area is zero and is intersecting, sets to 1, otherwise to 0 97 | this.intersectionRatio = this.isIntersecting ? 1 : 0 98 | } 99 | } 100 | 101 | /** 102 | * Creates the global IntersectionObserver constructor. 103 | * https://w3c.github.io/IntersectionObserver/#intersection-observer-interface 104 | * @param {Function} callback The function to be invoked after intersection 105 | * changes have queued. The function is not invoked if the queue has 106 | * been emptied by calling the `takeRecords` method. 107 | * @param {Object=} opt_options Optional configuration options. 108 | * @constructor 109 | */ 110 | function IntersectionObserver(callback, opt_options) { 111 | var options = opt_options || {} 112 | 113 | if (typeof callback != 'function') { 114 | throw new Error('callback must be a function') 115 | } 116 | 117 | if (options.root && options.root.nodeType != 1) { 118 | throw new Error('root must be an Element') 119 | } 120 | 121 | // Binds and throttles `this._checkForIntersections`. 122 | this._checkForIntersections = throttle( 123 | this._checkForIntersections.bind(this), 124 | this.THROTTLE_TIMEOUT 125 | ) 126 | 127 | // Private properties. 128 | this._callback = callback 129 | this._observationTargets = [] 130 | this._queuedEntries = [] 131 | this._rootMarginValues = this._parseRootMargin(options.rootMargin) 132 | 133 | // Public properties. 134 | this.thresholds = this._initThresholds(options.threshold) 135 | this.root = options.root || null 136 | this.rootMargin = this._rootMarginValues 137 | .map(function (margin) { 138 | return margin.value + margin.unit 139 | }) 140 | .join(' ') 141 | 142 | /** @private @const {!Array} */ 143 | this._monitoringDocuments = [] 144 | /** @private @const {!Array} */ 145 | this._monitoringUnsubscribes = [] 146 | } 147 | 148 | /** 149 | * The minimum interval within which the document will be checked for 150 | * intersection changes. 151 | */ 152 | IntersectionObserver.prototype.THROTTLE_TIMEOUT = 100 153 | 154 | /** 155 | * The frequency in which the polyfill polls for intersection changes. 156 | * this can be updated on a per instance basis and must be set prior to 157 | * calling `observe` on the first target. 158 | */ 159 | IntersectionObserver.prototype.POLL_INTERVAL = null 160 | 161 | /** 162 | * Use a mutation observer on the root element 163 | * to detect intersection changes. 164 | */ 165 | IntersectionObserver.prototype.USE_MUTATION_OBSERVER = true 166 | 167 | /** 168 | * Sets up the polyfill in the cross-origin mode. The result is the 169 | * updater function that accepts two arguments: `boundingClientRect` and 170 | * `intersectionRect` - just as these fields would be available to the 171 | * parent via `IntersectionObserverEntry`. This function should be called 172 | * each time the iframe receives intersection information from the parent 173 | * window, e.g. via messaging. 174 | * @return {function(DOMRect|ClientRect, DOMRect|ClientRect)} 175 | */ 176 | IntersectionObserver._setupCrossOriginUpdater = function () { 177 | if (!crossOriginUpdater) { 178 | /** 179 | * @param {DOMRect|ClientRect} boundingClientRect 180 | * @param {DOMRect|ClientRect} intersectionRect 181 | */ 182 | crossOriginUpdater = function (boundingClientRect, intersectionRect) { 183 | if (!boundingClientRect || !intersectionRect) { 184 | crossOriginRect = getEmptyRect() 185 | } else { 186 | crossOriginRect = convertFromParentRect( 187 | boundingClientRect, 188 | intersectionRect 189 | ) 190 | } 191 | registry.forEach(function (observer) { 192 | observer._checkForIntersections() 193 | }) 194 | } 195 | } 196 | return crossOriginUpdater 197 | } 198 | 199 | /** 200 | * Resets the cross-origin mode. 201 | */ 202 | IntersectionObserver._resetCrossOriginUpdater = function () { 203 | crossOriginUpdater = null 204 | crossOriginRect = null 205 | } 206 | 207 | /** 208 | * Starts observing a target element for intersection changes based on 209 | * the thresholds values. 210 | * @param {Element} target The DOM element to observe. 211 | */ 212 | IntersectionObserver.prototype.observe = function (target) { 213 | var isTargetAlreadyObserved = this._observationTargets.some(function ( 214 | item 215 | ) { 216 | return item.element == target 217 | }) 218 | 219 | if (isTargetAlreadyObserved) { 220 | return 221 | } 222 | 223 | if (!(target && target.nodeType == 1)) { 224 | throw new Error('target must be an Element') 225 | } 226 | 227 | this._registerInstance() 228 | this._observationTargets.push({ element: target, entry: null }) 229 | this._monitorIntersections(target.ownerDocument) 230 | this._checkForIntersections() 231 | } 232 | 233 | /** 234 | * Stops observing a target element for intersection changes. 235 | * @param {Element} target The DOM element to observe. 236 | */ 237 | IntersectionObserver.prototype.unobserve = function (target) { 238 | this._observationTargets = this._observationTargets.filter(function (item) { 239 | return item.element != target 240 | }) 241 | this._unmonitorIntersections(target.ownerDocument) 242 | if (this._observationTargets.length == 0) { 243 | this._unregisterInstance() 244 | } 245 | } 246 | 247 | /** 248 | * Stops observing all target elements for intersection changes. 249 | */ 250 | IntersectionObserver.prototype.disconnect = function () { 251 | this._observationTargets = [] 252 | this._unmonitorAllIntersections() 253 | this._unregisterInstance() 254 | } 255 | 256 | /** 257 | * Returns any queue entries that have not yet been reported to the 258 | * callback and clears the queue. This can be used in conjunction with the 259 | * callback to obtain the absolute most up-to-date intersection information. 260 | * @return {Array} The currently queued entries. 261 | */ 262 | IntersectionObserver.prototype.takeRecords = function () { 263 | var records = this._queuedEntries.slice() 264 | this._queuedEntries = [] 265 | return records 266 | } 267 | 268 | /** 269 | * Accepts the threshold value from the user configuration object and 270 | * returns a sorted array of unique threshold values. If a value is not 271 | * between 0 and 1 and error is thrown. 272 | * @private 273 | * @param {Array|number=} opt_threshold An optional threshold value or 274 | * a list of threshold values, defaulting to [0]. 275 | * @return {Array} A sorted list of unique and valid threshold values. 276 | */ 277 | IntersectionObserver.prototype._initThresholds = function (opt_threshold) { 278 | var threshold = opt_threshold || [0] 279 | if (!Array.isArray(threshold)) threshold = [threshold] 280 | 281 | return threshold.sort().filter(function (t, i, a) { 282 | if (typeof t != 'number' || isNaN(t) || t < 0 || t > 1) { 283 | throw new Error( 284 | 'threshold must be a number between 0 and 1 inclusively' 285 | ) 286 | } 287 | return t !== a[i - 1] 288 | }) 289 | } 290 | 291 | /** 292 | * Accepts the rootMargin value from the user configuration object 293 | * and returns an array of the four margin values as an object containing 294 | * the value and unit properties. If any of the values are not properly 295 | * formatted or use a unit other than px or %, and error is thrown. 296 | * @private 297 | * @param {string=} opt_rootMargin An optional rootMargin value, 298 | * defaulting to '0px'. 299 | * @return {Array} An array of margin objects with the keys 300 | * value and unit. 301 | */ 302 | IntersectionObserver.prototype._parseRootMargin = function (opt_rootMargin) { 303 | var marginString = opt_rootMargin || '0px' 304 | var margins = marginString.split(/\s+/).map(function (margin) { 305 | var parts = /^(-?\d*\.?\d+)(px|%)$/.exec(margin) 306 | if (!parts) { 307 | throw new Error('rootMargin must be specified in pixels or percent') 308 | } 309 | return { value: parseFloat(parts[1]), unit: parts[2] } 310 | }) 311 | 312 | // Handles shorthand. 313 | margins[1] = margins[1] || margins[0] 314 | margins[2] = margins[2] || margins[0] 315 | margins[3] = margins[3] || margins[1] 316 | 317 | return margins 318 | } 319 | 320 | /** 321 | * Starts polling for intersection changes if the polling is not already 322 | * happening, and if the page's visibility state is visible. 323 | * @param {!Document} doc 324 | * @private 325 | */ 326 | IntersectionObserver.prototype._monitorIntersections = function (doc) { 327 | var win = doc.defaultView 328 | if (!win) { 329 | // Already destroyed. 330 | return 331 | } 332 | if (this._monitoringDocuments.indexOf(doc) != -1) { 333 | // Already monitoring. 334 | return 335 | } 336 | 337 | // Private state for monitoring. 338 | var callback = this._checkForIntersections 339 | var monitoringInterval = null 340 | var domObserver = null 341 | 342 | // If a poll interval is set, use polling instead of listening to 343 | // resize and scroll events or DOM mutations. 344 | if (this.POLL_INTERVAL) { 345 | monitoringInterval = win.setInterval(callback, this.POLL_INTERVAL) 346 | } else { 347 | addEvent(win, 'resize', callback, true) 348 | addEvent(doc, 'scroll', callback, true) 349 | if (this.USE_MUTATION_OBSERVER && 'MutationObserver' in win) { 350 | domObserver = new win.MutationObserver(callback) 351 | domObserver.observe(doc, { 352 | attributes: true, 353 | childList: true, 354 | characterData: true, 355 | subtree: true, 356 | }) 357 | } 358 | } 359 | 360 | this._monitoringDocuments.push(doc) 361 | this._monitoringUnsubscribes.push(function () { 362 | // Get the window object again. When a friendly iframe is destroyed, it 363 | // will be null. 364 | var win = doc.defaultView 365 | 366 | if (win) { 367 | if (monitoringInterval) { 368 | win.clearInterval(monitoringInterval) 369 | } 370 | removeEvent(win, 'resize', callback, true) 371 | } 372 | 373 | removeEvent(doc, 'scroll', callback, true) 374 | if (domObserver) { 375 | domObserver.disconnect() 376 | } 377 | }) 378 | 379 | // Also monitor the parent. 380 | if (doc != ((this.root && this.root.ownerDocument) || document)) { 381 | var frame = getFrameElement(doc) 382 | if (frame) { 383 | this._monitorIntersections(frame.ownerDocument) 384 | } 385 | } 386 | } 387 | 388 | /** 389 | * Stops polling for intersection changes. 390 | * @param {!Document} doc 391 | * @private 392 | */ 393 | IntersectionObserver.prototype._unmonitorIntersections = function (doc) { 394 | var index = this._monitoringDocuments.indexOf(doc) 395 | if (index == -1) { 396 | return 397 | } 398 | 399 | var rootDoc = (this.root && this.root.ownerDocument) || document 400 | 401 | // Check if any dependent targets are still remaining. 402 | var hasDependentTargets = this._observationTargets.some(function (item) { 403 | var itemDoc = item.element.ownerDocument 404 | // Target is in this context. 405 | if (itemDoc == doc) { 406 | return true 407 | } 408 | // Target is nested in this context. 409 | while (itemDoc && itemDoc != rootDoc) { 410 | var frame = getFrameElement(itemDoc) 411 | itemDoc = frame && frame.ownerDocument 412 | if (itemDoc == doc) { 413 | return true 414 | } 415 | } 416 | return false 417 | }) 418 | if (hasDependentTargets) { 419 | return 420 | } 421 | 422 | // Unsubscribe. 423 | var unsubscribe = this._monitoringUnsubscribes[index] 424 | this._monitoringDocuments.splice(index, 1) 425 | this._monitoringUnsubscribes.splice(index, 1) 426 | unsubscribe() 427 | 428 | // Also unmonitor the parent. 429 | if (doc != rootDoc) { 430 | var frame = getFrameElement(doc) 431 | if (frame) { 432 | this._unmonitorIntersections(frame.ownerDocument) 433 | } 434 | } 435 | } 436 | 437 | /** 438 | * Stops polling for intersection changes. 439 | * @param {!Document} doc 440 | * @private 441 | */ 442 | IntersectionObserver.prototype._unmonitorAllIntersections = function () { 443 | var unsubscribes = this._monitoringUnsubscribes.slice(0) 444 | this._monitoringDocuments.length = 0 445 | this._monitoringUnsubscribes.length = 0 446 | for (var i = 0; i < unsubscribes.length; i++) { 447 | unsubscribes[i]() 448 | } 449 | } 450 | 451 | /** 452 | * Scans each observation target for intersection changes and adds them 453 | * to the internal entries queue. If new entries are found, it 454 | * schedules the callback to be invoked. 455 | * @private 456 | */ 457 | IntersectionObserver.prototype._checkForIntersections = function () { 458 | if (!this.root && crossOriginUpdater && !crossOriginRect) { 459 | // Cross origin monitoring, but no initial data available yet. 460 | return 461 | } 462 | 463 | var rootIsInDom = this._rootIsInDom() 464 | var rootRect = rootIsInDom ? this._getRootRect() : getEmptyRect() 465 | 466 | this._observationTargets.forEach(function (item) { 467 | var target = item.element 468 | var targetRect = getBoundingClientRect(target) 469 | var rootContainsTarget = this._rootContainsTarget(target) 470 | var oldEntry = item.entry 471 | var intersectionRect = 472 | rootIsInDom && 473 | rootContainsTarget && 474 | this._computeTargetAndRootIntersection(target, targetRect, rootRect) 475 | 476 | var newEntry = (item.entry = new IntersectionObserverEntry({ 477 | time: now(), 478 | target: target, 479 | boundingClientRect: targetRect, 480 | rootBounds: crossOriginUpdater && !this.root ? null : rootRect, 481 | intersectionRect: intersectionRect, 482 | })) 483 | 484 | if (!oldEntry) { 485 | this._queuedEntries.push(newEntry) 486 | } else if (rootIsInDom && rootContainsTarget) { 487 | // If the new entry intersection ratio has crossed any of the 488 | // thresholds, add a new entry. 489 | if (this._hasCrossedThreshold(oldEntry, newEntry)) { 490 | this._queuedEntries.push(newEntry) 491 | } 492 | } else { 493 | // If the root is not in the DOM or target is not contained within 494 | // root but the previous entry for this target had an intersection, 495 | // add a new record indicating removal. 496 | if (oldEntry && oldEntry.isIntersecting) { 497 | this._queuedEntries.push(newEntry) 498 | } 499 | } 500 | }, this) 501 | 502 | if (this._queuedEntries.length) { 503 | this._callback(this.takeRecords(), this) 504 | } 505 | } 506 | 507 | /** 508 | * Accepts a target and root rect computes the intersection between then 509 | * following the algorithm in the spec. 510 | * TODO(philipwalton): at this time clip-path is not considered. 511 | * https://w3c.github.io/IntersectionObserver/#calculate-intersection-rect-algo 512 | * @param {Element} target The target DOM element 513 | * @param {Object} targetRect The bounding rect of the target. 514 | * @param {Object} rootRect The bounding rect of the root after being 515 | * expanded by the rootMargin value. 516 | * @return {?Object} The final intersection rect object or undefined if no 517 | * intersection is found. 518 | * @private 519 | */ 520 | IntersectionObserver.prototype._computeTargetAndRootIntersection = function ( 521 | target, 522 | targetRect, 523 | rootRect 524 | ) { 525 | // If the element isn't displayed, an intersection can't happen. 526 | if (window.getComputedStyle(target).display == 'none') return 527 | 528 | var intersectionRect = targetRect 529 | var parent = getParentNode(target) 530 | var atRoot = false 531 | 532 | while (!atRoot && parent) { 533 | var parentRect = null 534 | var parentComputedStyle = 535 | parent.nodeType == 1 ? window.getComputedStyle(parent) : {} 536 | 537 | // If the parent isn't displayed, an intersection can't happen. 538 | if (parentComputedStyle.display == 'none') return null 539 | 540 | if (parent == this.root || parent.nodeType == /* DOCUMENT */ 9) { 541 | atRoot = true 542 | if (parent == this.root || parent == document) { 543 | if (crossOriginUpdater && !this.root) { 544 | if ( 545 | !crossOriginRect || 546 | (crossOriginRect.width == 0 && crossOriginRect.height == 0) 547 | ) { 548 | // A 0-size cross-origin intersection means no-intersection. 549 | parent = null 550 | parentRect = null 551 | intersectionRect = null 552 | } else { 553 | parentRect = crossOriginRect 554 | } 555 | } else { 556 | parentRect = rootRect 557 | } 558 | } else { 559 | // Check if there's a frame that can be navigated to. 560 | var frame = getParentNode(parent) 561 | var frameRect = frame && getBoundingClientRect(frame) 562 | var frameIntersect = 563 | frame && 564 | this._computeTargetAndRootIntersection(frame, frameRect, rootRect) 565 | if (frameRect && frameIntersect) { 566 | parent = frame 567 | parentRect = convertFromParentRect(frameRect, frameIntersect) 568 | } else { 569 | parent = null 570 | intersectionRect = null 571 | } 572 | } 573 | } else { 574 | // If the element has a non-visible overflow, and it's not the 575 | // or element, update the intersection rect. 576 | // Note: and cannot be clipped to a rect that's not also 577 | // the document rect, so no need to compute a new intersection. 578 | var doc = parent.ownerDocument 579 | if ( 580 | parent != doc.body && 581 | parent != doc.documentElement && 582 | parentComputedStyle.overflow != 'visible' 583 | ) { 584 | parentRect = getBoundingClientRect(parent) 585 | } 586 | } 587 | 588 | // If either of the above conditionals set a new parentRect, 589 | // calculate new intersection data. 590 | if (parentRect) { 591 | intersectionRect = computeRectIntersection(parentRect, intersectionRect) 592 | } 593 | if (!intersectionRect) break 594 | parent = parent && getParentNode(parent) 595 | } 596 | return intersectionRect 597 | } 598 | 599 | /** 600 | * Returns the root rect after being expanded by the rootMargin value. 601 | * @return {ClientRect} The expanded root rect. 602 | * @private 603 | */ 604 | IntersectionObserver.prototype._getRootRect = function () { 605 | var rootRect 606 | if (this.root) { 607 | rootRect = getBoundingClientRect(this.root) 608 | } else { 609 | // Use / instead of window since scroll bars affect size. 610 | var html = document.documentElement 611 | var body = document.body 612 | rootRect = { 613 | top: 0, 614 | left: 0, 615 | right: html.clientWidth || body.clientWidth, 616 | width: html.clientWidth || body.clientWidth, 617 | bottom: html.clientHeight || body.clientHeight, 618 | height: html.clientHeight || body.clientHeight, 619 | } 620 | } 621 | return this._expandRectByRootMargin(rootRect) 622 | } 623 | 624 | /** 625 | * Accepts a rect and expands it by the rootMargin value. 626 | * @param {DOMRect|ClientRect} rect The rect object to expand. 627 | * @return {ClientRect} The expanded rect. 628 | * @private 629 | */ 630 | IntersectionObserver.prototype._expandRectByRootMargin = function (rect) { 631 | var margins = this._rootMarginValues.map(function (margin, i) { 632 | return margin.unit == 'px' 633 | ? margin.value 634 | : (margin.value * (i % 2 ? rect.width : rect.height)) / 100 635 | }) 636 | var newRect = { 637 | top: rect.top - margins[0], 638 | right: rect.right + margins[1], 639 | bottom: rect.bottom + margins[2], 640 | left: rect.left - margins[3], 641 | } 642 | newRect.width = newRect.right - newRect.left 643 | newRect.height = newRect.bottom - newRect.top 644 | 645 | return newRect 646 | } 647 | 648 | /** 649 | * Accepts an old and new entry and returns true if at least one of the 650 | * threshold values has been crossed. 651 | * @param {?IntersectionObserverEntry} oldEntry The previous entry for a 652 | * particular target element or null if no previous entry exists. 653 | * @param {IntersectionObserverEntry} newEntry The current entry for a 654 | * particular target element. 655 | * @return {boolean} Returns true if a any threshold has been crossed. 656 | * @private 657 | */ 658 | IntersectionObserver.prototype._hasCrossedThreshold = function ( 659 | oldEntry, 660 | newEntry 661 | ) { 662 | // To make comparing easier, an entry that has a ratio of 0 663 | // but does not actually intersect is given a value of -1 664 | var oldRatio = 665 | oldEntry && oldEntry.isIntersecting ? oldEntry.intersectionRatio || 0 : -1 666 | var newRatio = newEntry.isIntersecting 667 | ? newEntry.intersectionRatio || 0 668 | : -1 669 | 670 | // Ignore unchanged ratios 671 | if (oldRatio === newRatio) return 672 | 673 | for (var i = 0; i < this.thresholds.length; i++) { 674 | var threshold = this.thresholds[i] 675 | 676 | // Return true if an entry matches a threshold or if the new ratio 677 | // and the old ratio are on the opposite sides of a threshold. 678 | if ( 679 | threshold == oldRatio || 680 | threshold == newRatio || 681 | threshold < oldRatio !== threshold < newRatio 682 | ) { 683 | return true 684 | } 685 | } 686 | } 687 | 688 | /** 689 | * Returns whether or not the root element is an element and is in the DOM. 690 | * @return {boolean} True if the root element is an element and is in the DOM. 691 | * @private 692 | */ 693 | IntersectionObserver.prototype._rootIsInDom = function () { 694 | return !this.root || containsDeep(document, this.root) 695 | } 696 | 697 | /** 698 | * Returns whether or not the target element is a child of root. 699 | * @param {Element} target The target element to check. 700 | * @return {boolean} True if the target element is a child of root. 701 | * @private 702 | */ 703 | IntersectionObserver.prototype._rootContainsTarget = function (target) { 704 | return ( 705 | containsDeep(this.root || document, target) && 706 | (!this.root || this.root.ownerDocument == target.ownerDocument) 707 | ) 708 | } 709 | 710 | /** 711 | * Adds the instance to the global IntersectionObserver registry if it isn't 712 | * already present. 713 | * @private 714 | */ 715 | IntersectionObserver.prototype._registerInstance = function () { 716 | if (registry.indexOf(this) < 0) { 717 | registry.push(this) 718 | } 719 | } 720 | 721 | /** 722 | * Removes the instance from the global IntersectionObserver registry. 723 | * @private 724 | */ 725 | IntersectionObserver.prototype._unregisterInstance = function () { 726 | var index = registry.indexOf(this) 727 | if (index != -1) registry.splice(index, 1) 728 | } 729 | 730 | /** 731 | * Returns the result of the performance.now() method or null in browsers 732 | * that don't support the API. 733 | * @return {number} The elapsed time since the page was requested. 734 | */ 735 | function now() { 736 | return window.performance && performance.now && performance.now() 737 | } 738 | 739 | /** 740 | * Throttles a function and delays its execution, so it's only called at most 741 | * once within a given time period. 742 | * @param {Function} fn The function to throttle. 743 | * @param {number} timeout The amount of time that must pass before the 744 | * function can be called again. 745 | * @return {Function} The throttled function. 746 | */ 747 | function throttle(fn, timeout) { 748 | var timer = null 749 | return function () { 750 | if (!timer) { 751 | timer = setTimeout(function () { 752 | fn() 753 | timer = null 754 | }, timeout) 755 | } 756 | } 757 | } 758 | 759 | /** 760 | * Adds an event handler to a DOM node ensuring cross-browser compatibility. 761 | * @param {Node} node The DOM node to add the event handler to. 762 | * @param {string} event The event name. 763 | * @param {Function} fn The event handler to add. 764 | * @param {boolean} opt_useCapture Optionally adds the even to the capture 765 | * phase. Note: this only works in modern browsers. 766 | */ 767 | function addEvent(node, event, fn, opt_useCapture) { 768 | if (typeof node.addEventListener == 'function') { 769 | node.addEventListener(event, fn, opt_useCapture || false) 770 | } else if (typeof node.attachEvent == 'function') { 771 | node.attachEvent('on' + event, fn) 772 | } 773 | } 774 | 775 | /** 776 | * Removes a previously added event handler from a DOM node. 777 | * @param {Node} node The DOM node to remove the event handler from. 778 | * @param {string} event The event name. 779 | * @param {Function} fn The event handler to remove. 780 | * @param {boolean} opt_useCapture If the event handler was added with this 781 | * flag set to true, it should be set to true here in order to remove it. 782 | */ 783 | function removeEvent(node, event, fn, opt_useCapture) { 784 | if (typeof node.removeEventListener == 'function') { 785 | node.removeEventListener(event, fn, opt_useCapture || false) 786 | } else if (typeof node.detatchEvent == 'function') { 787 | node.detatchEvent('on' + event, fn) 788 | } 789 | } 790 | 791 | /** 792 | * Returns the intersection between two rect objects. 793 | * @param {Object} rect1 The first rect. 794 | * @param {Object} rect2 The second rect. 795 | * @return {?Object|?ClientRect} The intersection rect or undefined if no 796 | * intersection is found. 797 | */ 798 | function computeRectIntersection(rect1, rect2) { 799 | var top = Math.max(rect1.top, rect2.top) 800 | var bottom = Math.min(rect1.bottom, rect2.bottom) 801 | var left = Math.max(rect1.left, rect2.left) 802 | var right = Math.min(rect1.right, rect2.right) 803 | var width = right - left 804 | var height = bottom - top 805 | 806 | return ( 807 | (width >= 0 && 808 | height >= 0 && { 809 | top: top, 810 | bottom: bottom, 811 | left: left, 812 | right: right, 813 | width: width, 814 | height: height, 815 | }) || 816 | null 817 | ) 818 | } 819 | 820 | /** 821 | * Shims the native getBoundingClientRect for compatibility with older IE. 822 | * @param {Element} el The element whose bounding rect to get. 823 | * @return {DOMRect|ClientRect} The (possibly shimmed) rect of the element. 824 | */ 825 | function getBoundingClientRect(el) { 826 | var rect 827 | 828 | try { 829 | rect = el.getBoundingClientRect() 830 | } catch (err) { 831 | // Ignore Windows 7 IE11 "Unspecified error" 832 | // https://github.com/w3c/IntersectionObserver/pull/205 833 | } 834 | 835 | if (!rect) return getEmptyRect() 836 | 837 | // Older IE 838 | if (!(rect.width && rect.height)) { 839 | rect = { 840 | top: rect.top, 841 | right: rect.right, 842 | bottom: rect.bottom, 843 | left: rect.left, 844 | width: rect.right - rect.left, 845 | height: rect.bottom - rect.top, 846 | } 847 | } 848 | return rect 849 | } 850 | 851 | /** 852 | * Returns an empty rect object. An empty rect is returned when an element 853 | * is not in the DOM. 854 | * @return {ClientRect} The empty rect. 855 | */ 856 | function getEmptyRect() { 857 | return { 858 | top: 0, 859 | bottom: 0, 860 | left: 0, 861 | right: 0, 862 | width: 0, 863 | height: 0, 864 | } 865 | } 866 | 867 | /** 868 | * Ensure that the result has all of the necessary fields of the DOMRect. 869 | * Specifically this ensures that `x` and `y` fields are set. 870 | * 871 | * @param {?DOMRect|?ClientRect} rect 872 | * @return {?DOMRect} 873 | */ 874 | function ensureDOMRect(rect) { 875 | // A `DOMRect` object has `x` and `y` fields. 876 | if (!rect || 'x' in rect) { 877 | return rect 878 | } 879 | // A IE's `ClientRect` type does not have `x` and `y`. The same is the case 880 | // for internally calculated Rect objects. For the purposes of 881 | // `IntersectionObserver`, it's sufficient to simply mirror `left` and `top` 882 | // for these fields. 883 | return { 884 | top: rect.top, 885 | y: rect.top, 886 | bottom: rect.bottom, 887 | left: rect.left, 888 | x: rect.left, 889 | right: rect.right, 890 | width: rect.width, 891 | height: rect.height, 892 | } 893 | } 894 | 895 | /** 896 | * Inverts the intersection and bounding rect from the parent (frame) BCR to 897 | * the local BCR space. 898 | * @param {DOMRect|ClientRect} parentBoundingRect The parent's bound client rect. 899 | * @param {DOMRect|ClientRect} parentIntersectionRect The parent's own intersection rect. 900 | * @return {ClientRect} The local root bounding rect for the parent's children. 901 | */ 902 | function convertFromParentRect(parentBoundingRect, parentIntersectionRect) { 903 | var top = parentIntersectionRect.top - parentBoundingRect.top 904 | var left = parentIntersectionRect.left - parentBoundingRect.left 905 | return { 906 | top: top, 907 | left: left, 908 | height: parentIntersectionRect.height, 909 | width: parentIntersectionRect.width, 910 | bottom: top + parentIntersectionRect.height, 911 | right: left + parentIntersectionRect.width, 912 | } 913 | } 914 | 915 | /** 916 | * Checks to see if a parent element contains a child element (including inside 917 | * shadow DOM). 918 | * @param {Node} parent The parent element. 919 | * @param {Node} child The child element. 920 | * @return {boolean} True if the parent node contains the child node. 921 | */ 922 | function containsDeep(parent, child) { 923 | var node = child 924 | while (node) { 925 | if (node == parent) return true 926 | 927 | node = getParentNode(node) 928 | } 929 | return false 930 | } 931 | 932 | /** 933 | * Gets the parent node of an element or its host element if the parent node 934 | * is a shadow root. 935 | * @param {Node} node The node whose parent to get. 936 | * @return {Node|null} The parent node or null if no parent exists. 937 | */ 938 | function getParentNode(node) { 939 | var parent = node.parentNode 940 | 941 | if (node.nodeType == /* DOCUMENT */ 9 && node != document) { 942 | // If this node is a document node, look for the embedding frame. 943 | return getFrameElement(node) 944 | } 945 | 946 | if (parent && parent.nodeType == 11 && parent.host) { 947 | // If the parent is a shadow root, return the host element. 948 | return parent.host 949 | } 950 | 951 | if (parent && parent.assignedSlot) { 952 | // If the parent is distributed in a , return the parent of a slot. 953 | return parent.assignedSlot.parentNode 954 | } 955 | 956 | return parent 957 | } 958 | 959 | /** 960 | * Returns the embedding frame element, if any. 961 | * @param {!Document} doc 962 | * @return {!Element} 963 | */ 964 | function getFrameElement(doc) { 965 | try { 966 | return (doc.defaultView && doc.defaultView.frameElement) || null 967 | } catch (e) { 968 | // Ignore the error. 969 | return null 970 | } 971 | } 972 | 973 | // Exposes the constructors globally. 974 | window.IntersectionObserver = IntersectionObserver 975 | window.IntersectionObserverEntry = IntersectionObserverEntry 976 | })() 977 | --------------------------------------------------------------------------------