From 334fd5c20ae3fe9449cc3037c8f1e6c0360259b0 Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Fri, 12 Aug 2022 22:27:46 +0300 Subject: [PATCH] Pre-release version --- .browserslistrc | 1 + .editorconfig | 12 ++ .eslintrc.json | 23 +++ .github/workflows/ci.yml | 31 +++ .github/workflows/npm-publish.yml | 35 ++++ .gitignore | 29 +++ .husky/pre-commit | 4 + .lintstagedrc.json | 4 + .prettierignore | 5 + .prettierrc | 18 ++ LICENCE | 20 ++ README.MD | 314 ++++++++++++++++++++++++++++++ package.json | 53 +++++ src/index.js | 5 + src/module/Base.js | 44 +++++ src/module/Filler.js | 184 +++++++++++++++++ src/module/Reeller.js | 253 ++++++++++++++++++++++++ src/plugin/ScrollerPlugin.js | 135 +++++++++++++ tsconfig.json | 20 ++ 19 files changed, 1190 insertions(+) create mode 100644 .browserslistrc create mode 100644 .editorconfig create mode 100644 .eslintrc.json create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/npm-publish.yml create mode 100644 .gitignore create mode 100644 .husky/pre-commit create mode 100644 .lintstagedrc.json create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 LICENCE create mode 100644 README.MD create mode 100644 package.json create mode 100644 src/index.js create mode 100644 src/module/Base.js create mode 100644 src/module/Filler.js create mode 100644 src/module/Reeller.js create mode 100644 src/plugin/ScrollerPlugin.js create mode 100644 tsconfig.json diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000..e8d24c5 --- /dev/null +++ b/.browserslistrc @@ -0,0 +1 @@ +since 2015 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..69fbfa8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.yml] +indent_size = 2 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..1e1ff81 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "env": { + "browser": true, + "es6": true + }, + "extends": ["eslint:recommended"], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "ignorePatterns": ["node_modules", "dist/", "src/scss/", "tmp"], + "rules": { + "require-jsdoc": "off", + "indent": ["error", 4], + "no-prototype-builtins": "off", + "max-len": [ + "error", + { + "code": 120 + } + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5e0f495 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [ master, dev ] + pull_request: + branches: [ master, dev ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: Install dependencies + run: npm install + + - name: Run ESLint + run: npm run lint + + - name: Run Prettier + run: npm run prettier + + - name: Build + run: npm run build diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..5351273 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,35 @@ +name: Publish on NPM + +on: + release: + types: [ created ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 16 + registry-url: https://registry.npmjs.org/ + + - name: Install dependencies + run: npm install + + - name: Run ESLint + run: npm run lint + + - name: Run Prettier + run: npm run prettier + + - name: Build + run: npm run build + + - name: Publish package + run: npm publish + env: + NODE_AUTH_TOKEN: ${{secrets.npm_token}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19882c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# System +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.Spotlight-V100 +.Trashes +Desktop.ini +Thumbs.db +ehthumbs.db + +# IDEs +nbproject/ +.idea +*.iml +.vscode +*.sublime-project +*.sublime-workspace + +# ENVs +node_modules +bower_components +.env + +# Project +/dist/ +/tmp/ +package-lock.json diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..d24fdfc --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 0000000..f005695 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,4 @@ +{ + "*.+(js)": ["eslint --fix"], + "*.+(js|json|scss|css|less|ts)": ["prettier --write"] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..416fa86 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +.github +.husky +dist +node_modules +tmp diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a789e74 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,18 @@ +{ + "arrowParens": "always", + "bracketSpacing": false, + "htmlWhitespaceSensitivity": "css", + "insertPragma": false, + "jsxSingleQuote": false, + "printWidth": 120, + "proseWrap": "preserve", + "quoteProps": "as-needed", + "requirePragma": false, + "endOfLine": "auto", + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "all", + "useTabs": false, + "vueIndentScriptAndStyle": false +} diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..2ba2c43 --- /dev/null +++ b/LICENCE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) Cuberto, Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..c133595 --- /dev/null +++ b/README.MD @@ -0,0 +1,314 @@ +# Cuberto Reeller + +NPM Version +Licence +Bundle file size +Bundle file size (gzip) +NPM Downloads +GitHub Workflow Status + +A powerful, flexible and modern library for creating the running text effect, also known as marquee or ticker. + +The library uses [GSAP](https://greensock.com/gsap/), +[IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) +and [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) to achieve the best performance +results. + +⚠️ +**Notice: This library is currently in beta.** + +## Dependencies + +GSAP v3 (https://greensock.com/gsap/) + +## Quick start + +### Install from NPM + +Reeller requires GSAP library to work. + +``` +npm install gsap --save +npm install reeller --save +``` + +Import GSAP, Reeller and initialize it: + +```js +import Reeller from 'reeller'; +import gsap from 'gsap'; + +Reeller.registerGSAP(gsap); + +const reeller = new Reeller({ + container: '.my-reel', + wrapper: '.my-reel-wrap', + itemSelector: '.my-reel-item', + speed: 10, +}); +``` + +### Use from CDN + +If you don't want to include Reeller files in your project, you can use library from CDN: + +```html + +``` + +Reeller requires GSAP to work. You need to import it above the Reeller if you didn't have it before: + +```html + + + +``` + +## Options + +You can configure Reeller via options. + +The following options with defaults is available: + +```js +const reeller = new Reeller({ + container: null, + wrapper: null, + itemSelector: null, + cloneClassName: '-clone', + speed: 10, + ease: 'none', + initialSeek: 10, + loop: true, + paused: true, + reversed: false, + autoStop: true, + autoUpdate: true, + clonesOverflow: true, + clonesFinish: false, + clonesMin: 0, +}); +``` + +| Option | Type | Default | Description | +| :--------------- | :---------------------------: | :------: | :----------------------------------------------------------------------- | +| `container` | `string` | `HTMLElement` | `null` | **Required.** Container element or selector. | +| `wrapper` | `string` | `HTMLElement` | `null` | **Required.** Inner element or selector. | +| `itemSelector` | `string` | `null` | **Required.** Items CSS selector. | +| `cloneClassName` | `string` | `-clone` | Class name of the new clones. | +| `speed` | `number` | `10` | Movement speed. | +| `ease` | `string` | `'none'` | Timing function. See [gsap easing](https://greensock.com/docs/v3/Eases). | +| `initialSeek` | `number` | `10` | Initial seek of timeline. | +| `loop` | `boolean` | `true` | Loop movement. | +| `pause` | `boolean` | `true` | Initialize in paused mode. | +| `reversed` | `boolean` | `false` | Reverse mode. | +| `autoStop` | `boolean` | `true` | Use IntersectionObserver to auto stop movement. | +| `autoUpdate` | `boolean` | `true` | Use ResizeObserver to auto update clones number. | +| `clonesOverflow` | `boolean` | `true` | Create artificial overflow with clones. | +| `clonesFinish` | `boolean` | `false` | Bring the cycle of clones to an end. | +| `clonesMin` | `boolean` | `0` | Minimum number of clones. | +| `plugins` | `Object` | `null` | Options for plugins. See [Plugins section](#plugins). | + +## API + +### Methods + +| Method | Description | +| :----------------------------------- | :------------------------------------------------------------------------------------------------------- | +| `reeller.resume()` | Resumes movement. | +| `reeller.pause()` | Pauses movement. | +| `reeller.reverse()` | Reverse movement. | +| `reeller.invalidate()` | Refresh GSAP Timeline. | +| `reeller.update()` | Calculates and sets the number of clones and update movement position. | +| `reeller.refresh(update=true)` | Fully refresh and update all clones and position. Use this only after adding or removing original items. | +| `reeller.destroy(removeClones=true)` | Destroy Reeller instance, detach all observers and remove clones. | +| `Reeller.registerGSAP(gsap)` | Static method to register the gsap library. | +| `Reeller.use(...plugins)` | Static method to register a plugins. | + +### Properties + +| Property | Type | Description | +| :---------------- | :--------------: | :---------------------------------------------------- | +| `reeller.paused` | `boolean` | Indicates that the movement is stopped. | +| `reeller.tl` | `Timeline` | GSAP Timeline. | +| `reeller.filler` | `Filler` | Inner Filler instance. See [Filler section](#filler). | +| `reeller.options` | `ReellerOptions` | Current Reeller options. | +| `reeller.plugin` | `Object` | Initialized plugins. | + +### Events + +Reeller comes with a useful events you can listen. Events can be assigned in this way: + +```js +reeller.on('update', () => { + console.log('Reeller update happened!'); +}); +``` + +You can also delete an event that you no longer want to listen in these ways: + +```js +reeller.off('update'); +reeller.off('update', myHandler); +``` + +| Event | Arguments | Description | +| :-------- | :-------------------- | :--------------------------------- | +| `update` | `(reeller, calcData)` | Event will be fired after update. | +| `refresh` | `(reeller)` | Event will be fired after refresh. | +| `destroy` | `(reeller)` | Event will be fired after destroy. | + +### Plugins + +Reeller support plugins to extend functionality. + +At this moment there is only one plugin that comes with the official package: **ScrollerPlugin**. + +This plugin allows you to attach movement to the scroll: + +```js +import {Reeller, ScrollerPlugin} from 'reeller'; +import gsap from 'gsap'; + +Reeller.registerGSAP(gsap); +Reeller.use(ScrollerPlugin); + +const reeller = new Reeller({ + container: '.my-reel', + wrapper: '.my-reel-wrap', + itemSelector: '.my-reel-item', + speed: 10, + plugins: { + scroller: { + speed: 1, + multiplier: 0.5, + threshold: 1, + }, + }, +}); +``` + +The following options of ScrollerPlugin is available: + +| Option | Type | Default | Description | +| :-------------- | :--------: | :----------: | :------------------------------------------------------------------------------------ | +| `speed` | `number` | `1` | Movement and inertia speed. | +| `multiplier` | `number` | `0.5` | Movement multiplier. | +| `threshold` | `number` | `1` | Movement threshold. | +| `ease` | `string` | `'expo.out'` | Timing function. See [gsap easing](https://greensock.com/docs/v3/Eases). | +| `overwrite` | `boolean` | `true` | See [gsap overwrite modes](https://greensock.com/conflict/). | +| `bothDirection` | `boolean` | `true` | Allow movement in both directions. | +| `reversed` | `boolean` | `false` | Reverse scroll movement. | +| `stopOnEnd` | `boolean` | `false` | Stop movement on end scrolling. | +| `scrollProxy` | `function` | `null` | A function that returns the scroll position. Can be used in cases with custom scroll. | + +In cases with using of custom scroll libraries (e.g. [smooth-scrollbar](https://github.com/idiotWu/smooth-scrollbar)), +you can use the `scrollProxy` option to return the current scroll position: + +```js +import Scrollbar from 'smooth-scrollbar'; +import {Reeller, ScrollerPlugin} from 'reeller'; +import gsap from 'gsap'; + +Reeller.registerGSAP(gsap); +Reeller.use(ScrollerPlugin); + +const scrollbar = Scrollbar.init(document.querySelector('#my-scrollbar')); + +const reeller = new Reeller({ + container: '.my-reel', + wrapper: '.my-reel-wrap', + itemSelector: '.my-reel-item', + speed: 10, + plugins: { + scroller: { + speed: 1, + multiplier: 0.5, + threshold: 1, + scrollProxy: () => scrollbar.scrollTop, + }, + }, +}); +``` + +## Filler + +Reeller library works on top of the **Filler** module. This is a separate tool for automatically calculating the number +of clones and filling the container with them. + +You can use only Filler (without Reeller) if you need to fill the whole space with clones, without movement, animations +and GSAP: + +```js +import {Filler} from 'reeller'; + +const filler = new Filler({ + container: '.my-container', + itemSelector: '.-my-item', + cloneClassName: '-clone', +}); +``` + +### Options + +| Option | Type | Default | Description | +| :--------------- | :---------------------------: | :--------: | :----------------------------------------------- | +| `container` | `string` | `HTMLElement` | `null` | **Required.** Container element or selector. | +| `itemSelector` | `string` | `null` | **Required.** Items CSS selector. | +| `wrapper` | `string` | `HTMLElement` | `null` | Inner element or selector. | +| `cloneClassName` | `string` | `'-clone'` | Class name of the new clones. | +| `autoUpdate` | `boolean` | `true` | Use ResizeObserver to auto update clones number. | +| `clonesOverflow` | `boolean` | `false` | Create artificial overflow with clones. | +| `clonesFinish` | `boolean` | `false` | Bring the cycle of clones to an end. | +| `clonesMin` | `boolean` | `false` | Minimum number of clones. | + +### Methods + +| Method | Description | +| :---------------------------------- | :------------------------------------------------------------------------------------------ | +| `filler.addClones(count, offset=0)` | Creates and adds clones to end in the desired number from given offset. | +| `filler.removeClones(count)` | Removes the desired number of clones from the end. | +| `filler.setClonesCount(count)` | Sets the desired number of clones. | +| `filler.getCalcData()` | Returns a calculated data object (number of clones, widths, etc.) | +| `filler.update()` | Calculates and sets the number of clones. | +| `filler.refresh(update=true)` | Fully refresh and update all clones. Use this only after adding or removing original items. | +| `filler.destroy(removeClones=true)` | Destroy Filler instance, detach all observers and remove clones. | + +### Properties + +| Property | Type | Description | +| :----------------- | :-------------------: | :------------------------------------------------ | +| `filler.container` | `HTMLElement` | Container element. | +| `filler.wrapper` | `HTMLElement` | Inner element. | +| `filler.item` | `Array.` | Items array. | +| `filler.calcData` | `Object` | Calculated data (number of clones, widths, etc.). | +| `filler.options` | `ReellerOptions` | Current Filler options. | + +### Events + +| Event | Arguments | Description | +| :-------- | :------------------- | :--------------------------------- | +| `update` | `(filler, calcData)` | Event will be fired after update. | +| `refresh` | `(filler)` | Event will be fired after refresh. | +| `destroy` | `(filler)` | Event will be fired after destroy. | + +## Examples of use + +- [Cuberto](https://cuberto.com/): Leading digital agency. +- [Potion](https://www.sendpotion.com/): Video email for top sales professionals. +- [Weltio](https://weltio.com/): More ways to grow your money. +- [WorkJam](https://www.workjam.com/): Drive Employee Engagement. +- [Spendwisor](https://spendwisor.app/): Make your shopping easier. +- [Sleepiest](https://www.sleepiest.com/): The Sleepiest App. +- [Perform](https://perform.fm/): Unlock workout superpowers. + +## License + +[The MIT License (MIT)](LICENSE) diff --git a/package.json b/package.json new file mode 100644 index 0000000..3121997 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "reeller", + "version": "0.0.1", + "description": "A powerful, flexible and modern library for creating the running text effect, also known as marquee or ticker.", + "keywords": [ + "marquee", + "ticker", + "reel", + "running text", + "scrolling text", + "clones", + "scroll", + "filler", + "animation", + "ui", + "library", + "microlibrary", + "magic", + "css", + "effects", + "gsap" + ], + "homepage": "https://github.com/Cuberto/reller", + "bugs": "https://github.com/Cuberto/reller/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/Cuberto/reller.git" + }, + "license": "MIT", + "main": "./dist/index.js", + "unpkg": "./dist/index.umd.js", + "module": "./dist/index.module.js", + "source": "./src/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/", + "src/" + ], + "scripts": { + "build": "microbundle -f esm,cjs,umd --name Reeller --compress false --sourcemap false --generateTypes true && microbundle -f umd --name Reeller -o dist/reeller.min.js --no-pkg-main --generateTypes false", + "dev": "microbundle -f esm,cjs,umd --name Reeller --compress false --sourcemap false --generateTypes true --watch", + "lint": "eslint .", + "prepare": "husky install", + "prettier": "prettier --check ." + }, + "devDependencies": { + "eslint": "^8.21.0", + "husky": "^8.0.0", + "lint-staged": "^13.0.3", + "microbundle": "^0.15.0", + "prettier": "^2.7.1" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..544c3e1 --- /dev/null +++ b/src/index.js @@ -0,0 +1,5 @@ +import Filler from './module/Filler'; +import Reeller from './module/Reeller'; +import ScrollerPlugin from './plugin/ScrollerPlugin'; + +export {Reeller as default, Filler, ScrollerPlugin}; diff --git a/src/module/Base.js b/src/module/Base.js new file mode 100644 index 0000000..8c19ab3 --- /dev/null +++ b/src/module/Base.js @@ -0,0 +1,44 @@ +export default class Base { + /** + * Base class. + */ + constructor() { + this.events = {}; + } + + /** + * Attach an event handler function. + * + * @param {string} event Event name. + * @param {function} callback Callback. + */ + on(event, callback) { + if (!(this.events[event] instanceof Array)) this.events[event] = []; + this.events[event].push(callback); + } + + /** + * Remove an event handler. + * + * @param {string} event Event name. + * @param {function} [callback] Callback. + */ + off(event, callback) { + if (callback) { + this.events[event] = this.events[event].filter((f) => f !== callback); + } else { + this.events[event] = []; + } + } + + /** + * Execute all handlers for the given event type. + * + * @param {string} event Event name. + * @param params Extra parameters. + */ + trigger(event, ...params) { + if (!this.events[event]) return; + this.events[event].forEach((f) => f.call(this, this, ...params)); + } +} diff --git a/src/module/Filler.js b/src/module/Filler.js new file mode 100644 index 0000000..8dfbfe3 --- /dev/null +++ b/src/module/Filler.js @@ -0,0 +1,184 @@ +import Base from './Base'; + +export default class Filler extends Base { + /** + * @typedef {Object} FillerOptions + * @property {string|HTMLElement|null} container Container element or selector. + * @property {string|HTMLElement|null} wrapper Inner element or selector. + * @property {string|null} itemSelector Items CSS selector. + * @property {string} [cloneClassName] Class name of the new clones. + * @property {boolean} [autoUpdate] Use ResizeObserver to auto update clones number. + * @property {boolean} [clonesOverflow] Create artificial overflow with clones. + * @property {boolean} [clonesFinish] Bring the cycle of clones to an end. + * @property {boolean} [clonesMin] Minimum number of clones. + */ + + /** + * Default options. + * + * @type {FillerOptions} + */ + static defaultOptions = { + container: null, + wrapper: null, + itemSelector: null, + cloneClassName: '-clone', + autoUpdate: true, + clonesOverflow: false, + clonesFinish: false, + clonesMin: 0, + }; + + /** + * Create Filler instance. + * + * @param {FillerOptions} [options] Filler options. + */ + constructor(options) { + super(); + + /** @type {FillerOptions} **/ + this.options = {...Filler.defaultOptions, ...options}; + this.container = + typeof this.options.container === 'string' + ? document.querySelector(this.options.container) + : this.options.container; + this.wrapper = + typeof this.options.wrapper === 'string' + ? this.container.querySelector(this.options.wrapper) + : this.options.wrapper || this.options.container; + + this.refresh(false); + + if (this.options.autoUpdate) { + this.bindResizeObserver(); + } else { + this.update(); + } + } + + /** + * Bind ResizeObserver to container for auto update. + */ + bindResizeObserver() { + this.resizeObserver = new ResizeObserver(() => { + this.update(); + }); + this.resizeObserver.observe(this.container); + } + + /** + * Creates and adds clones to end in the desired number from given offset. + * + * @param {number} [count] Number of clones to add. + * @param {number} [offset] Offset from start. + */ + addClones(count, offset = 0) { + const clones = []; + + for (let i = 0; i < count; i++) { + const item = this.item[(offset + i) % this.item.length].cloneNode(true); + item.classList.add(this.options.cloneClassName); + clones.push(item); + } + + this.wrapper.append(...clones); + } + + /** + * Removes the desired number of clones from the end. + * + * @param {number} [count] Number of clones to remove. + */ + removeClones(count = 0) { + const clones = Array.from(this.wrapper.getElementsByClassName(this.options.cloneClassName)); + clones.slice(-count).forEach((el) => el.remove()); + } + + /** + * Sets the desired number of clones. + * + * @param {number} [count] Number of clones. + */ + setClonesCount(count) { + if (this.clonesCount === count) return; + if (this.clonesCount < count) this.addClones(count - this.clonesCount, this.clonesCount); + if (this.clonesCount > count) this.removeClones(this.clonesCount - count); + this.clonesCount = count; + } + + /** + * Get calculated data object. + * + * @return {Object} Calculated data. + */ + getCalcData() { + const data = { + clonesCount: 0, + clonesWidth: 0, + containerWidth: this.container.offsetWidth, + fullWidth: 0, + itemWidth: [], + itemsWidth: 0, + lastIndex: 0, + }; + + this.item.map((el) => { + const rect = el.getBoundingClientRect(); + const width = rect.width; + data.itemWidth.push(width); + data.itemsWidth += width; + }); + + const itemLength = data.itemWidth.length; + const width = this.options.clonesOverflow ? data.containerWidth : data.containerWidth - data.itemsWidth; + + while ( + width > data.clonesWidth || + data.clonesCount < this.options.clonesMin || + (this.options.clonesFinish && data.clonesCount % itemLength > 0) + ) { + data.lastIndex = data.clonesCount % itemLength; + data.clonesWidth += data.itemWidth[data.lastIndex]; + data.clonesCount++; + } + + data.fullWidth = data.clonesWidth + data.itemsWidth; + + return data; + } + + /** + * Calculates and sets the number of clones. + */ + update() { + this.calcData = this.getCalcData(); + this.setClonesCount(this.calcData.clonesCount); + this.trigger('update', this.calcData); + } + + /** + * Fully refresh and update all clones. + * + * @param {boolean} [update] Update after refresh. + */ + refresh(update = true) { + this.removeClones(); + this.item = Array.from(this.container.querySelectorAll(this.options.itemSelector)); + this.clonesData = {}; + this.clonesCount = 0; + this.trigger('refresh'); + if (update) this.update(); + } + + /** + * Destroy Reeller instance. + * + * @param {boolean} [removeClones] Remove clones from DOM. + */ + destroy(removeClones = false) { + if (removeClones) this.removeClones(); + if (this.resizeObserver) this.resizeObserver.disconnect(); + this.trigger('destroy'); + } +} diff --git a/src/module/Reeller.js b/src/module/Reeller.js new file mode 100644 index 0000000..48e6c76 --- /dev/null +++ b/src/module/Reeller.js @@ -0,0 +1,253 @@ +import Base from './Base'; +import Filler from './Filler'; + +export default class Reeller extends Base { + /** + * @typedef {Object} ReellerOptions + * @property {string|HTMLElement|null} container Container element or selector. + * @property {string|HTMLElement|null} wrapper Inner element or selector. + * @property {string|null} itemSelector Items CSS selector. + * @property {string} [cloneClassName] Class name of the new clones. + * @property {number} [speed] Movement speed. + * @property {string} [ease] Timing function. + * @property {number} [initialSeek] Initial seek of timeline. + * @property {boolean} [loop] Loop movement. + * @property {boolean} [paused] Initialize in paused mode. + * @property {boolean} [reversed] Reverse mode. + * @property {boolean} [autoStop] Use IntersectionObserver to auto stop movement. + * @property {boolean} [autoUpdate] Use ResizeObserver to auto update clones number. + * @property {boolean} [clonesOverflow] Create artificial overflow with clones. + * @property {boolean} [clonesFinish] Bring the cycle of clones to an end. + * @property {boolean} [clonesMin] Minimum number of clones. + * @property {Object|null} [plugins] Options for plugins. + */ + + /** + * Default options. + * + * @type {ReellerOptions} + */ + static defaultOptions = { + container: null, + wrapper: null, + itemSelector: null, + cloneClassName: '-clone', + speed: 10, + ease: 'none', + initialSeek: 10, + loop: true, + paused: true, + reversed: false, + autoStop: true, + autoUpdate: true, + clonesOverflow: true, + clonesFinish: false, + clonesMin: 0, + plugins: null, + }; + + /** + * Registered plugin storage. + * + * @type {Object} + */ + static plugins = {}; + + /** + * Create Reeller instance. + * + * @param {ReellerOptions} [options] Reeller options. + */ + constructor(options) { + super(); + + /** @type {ReellerOptions} **/ + this.options = {...Reeller.defaultOptions, ...options}; + this.gsap = Reeller.gsap || window.gsap; + this.paused = this.options.paused; + + this.createFiller(); + this.createTimeline(); + if (this.options.autoStop) this.bindIntersectionObserver(); + if (this.options.plugins) this.initPlugins(); + } + + /** + * Register GSAP animation library. + * + * @param {GSAP} gsap GSAP library. + */ + static registerGSAP(gsap) { + Reeller.gsap = gsap; + } + + /** + * Register plugins. + */ + static use(...plugins) { + plugins.forEach((plugin) => { + const name = plugin.pluginName; + if (typeof name !== 'string') throw new TypeError('Invalid plugin. Name is required.'); + Reeller.plugins[name] = plugin; + }); + } + + /** + * Create filler. + */ + createFiller() { + this.filler = new Filler(this.options); + + this.filler.on('update', (filler, calcData) => { + this.invalidate(); + this.trigger('update', calcData); + }); + + this.filler.on('refresh', () => { + this.trigger('refresh'); + }); + } + + /** + * Create timeline. + */ + createTimeline() { + this.tl = new this.gsap.timeline({ + paused: this.options.paused, + repeat: -1, + yoyo: !this.options.loop, + onReverseComplete: function () { + this.progress(1); + }, + }); + + this.tl.fromTo( + this.filler.wrapper, + { + x: () => { + if (!this.options.clonesOverflow) { + return -(this.filler.calcData.fullWidth - this.filler.calcData.containerWidth); + } + return -this.filler.calcData.itemsWidth; + }, + }, + { + x: 0, + duration: this.options.speed, + ease: this.options.ease, + }, + ); + + this.tl.seek(this.options.seek); + + return this.tl; + } + + /** + * Bind IntersectionObserver to container for autoplay. + */ + bindIntersectionObserver() { + this.intersectionObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting) { + this.resume(); + } else { + this.pause(); + } + }); + this.intersectionObserver.observe(this.filler.container); + } + + /** + * Init plugins from options. + */ + initPlugins() { + this.plugin = {}; + for (const [name, options] of Object.entries(this.options.plugins)) { + const factory = Reeller.plugins[name]; + if (factory) { + this.plugin[name] = new factory(this, options); + } else { + console.error(`Plugin ${name} not found. Make sure you register it with Reeller.use()`); + } + } + } + + /** + * Destroy initialized plugins. + */ + destroyPlugins() { + for (const instance of Object.values(this.plugin)) { + if (instance.destroy) instance.destroy(); + } + } + + /** + * Resume moving. + */ + resume() { + this.gsap.set(this.filler.container, {z: '0'}); + this.gsap.set(this.filler.wrapper, {willChange: 'transform'}); + this.paused = false; + this.tl.resume(); + this.trigger('resume'); + } + + /** + * Reverse moving. + */ + reverse() { + this.paused = false; + this.tl.reverse(); + this.trigger('reverse'); + } + + /** + * Pause moving. + */ + pause() { + this.gsap.set(this.filler.container, {clearProps: 'z'}); + this.gsap.set(this.filler.wrapper, {willChange: 'auto'}); + this.paused = true; + this.tl.pause(); + this.trigger('pause'); + } + + /** + * Refresh timeline. + */ + invalidate() { + this.tl.invalidate(); + this.trigger('invalidate'); + } + + /** + * Recalculate data. + */ + update() { + this.filler.update(); + } + + /** + * Fully refresh and update all clones and position. + * + * @param {boolean} [update] Update after refresh. + */ + refresh(update = true) { + this.filler.refresh(update); + } + + /** + * Destroy Reeller instance. + * + * @param {boolean} [removeClones] Remove clones from DOM. + * @param {boolean} [clearProps] Remove transformations. + */ + destroy(removeClones = false, clearProps = false) { + if (this.intersectionObserver) this.intersectionObserver.disconnect(); + if (this.options.plugins) this.destroyPlugins(); + this.tl.kill(); + this.filler.destroy(removeClones); + if (clearProps) this.gsap.set(this.filler.wrapper, {clearProps: 'x,willChange'}); + this.trigger('destroy'); + } +} diff --git a/src/plugin/ScrollerPlugin.js b/src/plugin/ScrollerPlugin.js new file mode 100644 index 0000000..8ed52d5 --- /dev/null +++ b/src/plugin/ScrollerPlugin.js @@ -0,0 +1,135 @@ +export default class ScrollerPlugin { + /** + * @typedef {Object} ScrollerPluginOptions + * @property {number} [speed] Movement and inertia speed. + * @property {number} [multiplier] Movement multiplier. + * @property {number} [threshold] Movement threshold. + * @property {string} [ease] Timing function. + * @property {boolean} [overwrite] GSAP overwrite mode. + * @property {boolean} [bothDirection] Allow movement in both directions. + * @property {boolean} [reversed] Reverse scroll movement. + * @property {boolean} [stopOnEnd] Use IntersectionObserver to auto stop movement. + * @property {function} [scrollProxy] Use ResizeObserver to auto update clones number. + */ + + /** + * Plugin name. + * + * @type {string} + */ + static pluginName = 'scroller'; + + /** + * Default options. + * + * @type {ScrollerPluginOptions} + */ + static defaultOptions = { + speed: 1, + multiplier: 0.5, + threshold: 1, + ease: 'expo.out', + overwrite: true, + bothDirection: true, + reversed: false, + stopOnEnd: false, + scrollProxy: null, + }; + + /** + * Reeller ScrollerPlugin. + * + * @param {Reeller} reeller Reeller instance. + * @param {object} options Options + */ + constructor(reeller, options) { + /** @type {ScrollerPluginOptions} **/ + this.options = {...ScrollerPlugin.defaultOptions, ...options}; + this.reeller = reeller; + this.gsap = this.reeller.gsap; + this.tl = this.reeller.tl; + + this.init(); + } + + /** + * Return scroll position. + * + * @return {number} Scroll position. + */ + getScrollPos() { + if (this.options.scrollProxy) return this.options.scrollProxy(); + return window.pageYOffset; + } + + /** + * Initialize plugin. + */ + init() { + let lastScrollPos = this.getScrollPos(); + let lastDirection = 1; + let reachedEnd = true; + + this.tickerFn = () => { + if (this.reeller.paused) { + this.gsap.killTweensOf(this.tl); + this.tl.timeScale(lastDirection * this.options.threshold); + return; + } + + const scrollPos = this.getScrollPos(); + let velocity = scrollPos - lastScrollPos; + + if (!this.options.bothDirection) { + velocity = Math.abs(velocity); + } + + if (this.options.reversed) { + velocity *= -1; + } + + if (velocity) { + const delta = velocity * this.options.multiplier; + const timeScale = + delta > 0 ? Math.max(this.options.threshold, delta) : Math.min(-this.options.threshold, delta); + + this.tween = this.gsap.to(this.tl, { + timeScale: timeScale, + duration: this.options.speed, + ease: this.options.ease, + overwrite: this.options.overwrite, + }); + + reachedEnd = false; + } else { + if (!reachedEnd) { + const timeScale = this.options.stopOnEnd ? 0 : lastDirection * this.options.threshold; + + this.gsap.killTweensOf(this.tl); + this.tween = this.gsap.to(this.tl, { + timeScale: timeScale, + duration: this.options.speed, + overwrite: this.options.overwrite, + ease: this.options.ease, + }); + reachedEnd = true; + } + } + + lastDirection = Math.sign(velocity); + lastScrollPos = scrollPos; + }; + this.gsap.ticker.add(this.tickerFn); + } + + /** + * Destroy plugin. + */ + destroy() { + if (this.tickerFn) { + this.gsap.ticker.remove(this.tickerFn); + this.tickerFn = null; + } + if (this.tween) this.tween.kill(); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7122f03 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": ["src/**/*"], + "compilerOptions": { + // Tells TypeScript to read JS files, as + // normally they are ignored as source files + "allowJs": true, + // Generate d.ts files + "declaration": true, + // This compiler run should + // only output d.ts files + "emitDeclarationOnly": true, + // Types should go into this directory. + // Removing this would place the .d.ts files + // next to the .js files + "outDir": "dist", + // go to js file when using IDE functions like + // "Go to Definition" in VSCode + "declarationMap": true + } +}