├── .browserslistrc
├── .gitignore
├── .npmignore
├── CHANGELOG.md
├── LICENSE
├── babel.config.json
├── config
├── banner.js
├── copy-to-examples.js
├── rollup.config.js
├── shared.js
├── webpack.common.js
├── webpack.dev.js
└── webpack.prod.js
├── example
├── app.js
└── index.html
├── package-lock.json
├── package.json
├── readme.md
├── src
├── AnimateImages.js
├── Animation.js
├── DragInput.js
├── ImagePreloader.js
├── Poster.js
├── Render.js
├── index.js
├── settings.js
└── utils.js
└── tsconfig.json
/.browserslistrc:
--------------------------------------------------------------------------------
1 | >1%
2 | last 2 major versions
3 | not dead
4 | not iOS < 13.4
5 | iOS >= 13.4
6 | not Safari < 13.1
7 | Safari >= 13.1
8 | not Explorer <= 11
9 | not op_mini all
10 | not op_mob > 1
11 | not kaios > 1
12 | not baidu > 1
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 | node_modules
3 | npm-debug.log
4 | yarn-debug.log*
5 | yarn-error.log*
6 | .npm
7 | .yarn-integrity
8 | .idea/
9 | *.sublime-workspace
10 | .vscode/*
11 | *.DS_Store
12 | .AppleDouble
13 | .LSOverride
14 | *.swp
15 | *.psd
16 | Thumbs.db
17 | ehthumbs.db
18 | *.lnk
19 | /example/images/**/*.jpg
20 | /example/images/**/*.png
21 | /example/animate-images*.js
22 | /example/animate-images*.map
23 | /types
24 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | example
2 |
3 | # .gitignore copy
4 | node_modules
5 | npm-debug.log
6 | yarn-debug.log*
7 | yarn-error.log*
8 | .npm
9 | .yarn-integrity
10 | .idea/
11 | *.sublime-workspace
12 | .vscode/*
13 | *.DS_Store
14 | .AppleDouble
15 | .LSOverride
16 | *.swp
17 | *.psd
18 | Thumbs.db
19 | ehthumbs.db
20 | *.lnk
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | ## 2.3.1
3 | - animation started in ```playTo``` or ```playFrames``` in ```fastPreview``` mode now stops
4 | after full preload instead of playing endlessly
5 | - animation-end callback and event are now called after resetting values
6 | - reduced the number of operations in the animation function
7 | - move ```core-js``` to ```devDependencies```
8 | - add ```direction``` to event.detail of ```animate-images:drag-end``` event
9 | ## 2.3.0
10 | - new ```setForward``` method
11 | - new ```responsiveAspect``` option that allows to control height instead of width
12 | - add ```direction``` to event.detail of ```animate-images:drag-change``` event
13 | - reset pixels correction when changing drag direction
14 | - fix dragging when mousemove is called without real movement
15 | ## 2.2.0
16 | - new ```fastPreview``` option, ```onFastPreloadFinished``` callback,
17 | ```animate-images:fast-preload-finished``` event, ```isFastPreloadFinished``` method
18 | - new ```isDragging``` method
19 | - reduce bundle size by 20%
20 | ## 2.1.2
21 | - fix wrong ```onPosterLoaded``` call
22 | ## 2.1.1
23 | - new ```shortestPath``` option for ```playTo()```
24 | ## 2.1.0
25 | - ```inversion``` is now only used while dragging and doesn't affect animation
26 | ## 2.0.0
27 | - plugin import changed
28 | - new initialization with constructor instead of ```init``` method
29 | - ```togglePlay()``` renamed to ```toggle()```
30 | - added types
31 | - new ```onAnimationEnd``` callback
32 | - new ```dragModifier``` option
33 | - ```playTo``` and ```playFrames``` now return plugin instance instead of Promise
34 | - ```onBeforeFrame``` and ```onAfterFrame``` parameters changed
35 | - ```getOption()``` accepts all the options
36 | - fix wrong animation duration
37 | ## 1.6
38 | - new ```inversion``` option
39 | ## 1.5.3
40 | - fix console.log()
41 | ## 1.5.2
42 | - ```preventTouchScroll``` replaced with ```touchScrollMode``` and ```pageScrollTimerDelay```
43 | ## 1.5.1
44 | - add ```preventTouchScroll``` option
45 | ## 1.5.0
46 | - fix blurry images when devicePixelRatio > 1
47 | - add ```onBeforeFrame``` and ```onAfterFrame``` callbacks with access to the
48 | canvas context
49 | ## 1.4.0
50 | - add ```animate-images:drag-start```, ```animate-images:drag-change``` and
51 | ```animate-images:drag-end ``` events
52 | ## 1.3.2
53 | - fix wrong height after resize when canvas width/height ratio is
54 | a fractional number
55 | ## 1.3.1
56 | - fix readme
57 | ## 1.3.0
58 | - change build
59 | ## 1.2.0
60 | - plugin has been rewritten with classes
61 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020-present Dmitry Kovalev
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.
22 |
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "corejs": 3.24,
7 | "useBuiltIns": "usage",
8 | "exclude": ["es.promise", "es.error.cause"]
9 | }
10 | ]
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/config/banner.js:
--------------------------------------------------------------------------------
1 | var pkg = require("../package.json");
2 |
3 | module.exports =
4 | ` ${pkg.name} ${pkg.version}
5 | ${pkg.repository.url}
6 |
7 | Copyright (c) 2020-present ${pkg.author},
8 | Released under the ${pkg.license} license`;
9 |
--------------------------------------------------------------------------------
/config/copy-to-examples.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const { LIB_FILE_NAME } = require( './shared');
4 |
5 | let source = path.join(__dirname, `../build/${LIB_FILE_NAME}.umd.min.js`);
6 | let dest = path.join(__dirname, `../example/${LIB_FILE_NAME}.umd.min.js`);
7 |
8 | fs.copyFile(source, dest, function (err) {
9 | if (err) return console.error(err);
10 | console.log('Copied to ' + dest);
11 | });
12 |
--------------------------------------------------------------------------------
/config/rollup.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'rollup';
2 | import { terser } from "rollup-plugin-terser";
3 | import { babel } from '@rollup/plugin-babel';
4 | import { nodeResolve } from '@rollup/plugin-node-resolve';
5 | import commonjs from '@rollup/plugin-commonjs';
6 | import bundleSize from 'rollup-plugin-bundle-size';
7 | import dts from "rollup-plugin-dts";
8 |
9 | const banner = require("./banner");
10 | const bannerWithComments = "/*!\n" + banner + "\n*/";
11 | const { LIB_FILE_NAME, TERSER_OPTIONS } = require( './shared');
12 |
13 | const terserOptions = TERSER_OPTIONS;
14 |
15 | export default defineConfig([
16 | { // Transpiled bundle
17 | input: `./src/index.js`,
18 | plugins: [
19 | nodeResolve(),
20 | commonjs(),
21 | babel({
22 | babelHelpers: 'bundled',
23 | exclude: "node_modules/**"
24 | }),
25 | bundleSize()
26 | ],
27 | output: [
28 | {
29 | file: `./build/${LIB_FILE_NAME}.esm.js`,
30 | format: 'es',
31 | banner: bannerWithComments,
32 | sourcemap: true,
33 | },
34 | {
35 | file: `./build/${LIB_FILE_NAME}.esm.min.js`,
36 | format: 'es',
37 | banner: bannerWithComments,
38 | sourcemap: true,
39 | plugins: [ terser(terserOptions) ]
40 | }
41 | ],
42 | },
43 | { // Untranspiled bundle
44 | input: `./src/index.js`,
45 | plugins: [
46 | bundleSize()
47 | ],
48 | output: [
49 | {
50 | file: `./build/untranspiled/${LIB_FILE_NAME}.esm.js`,
51 | format: 'es',
52 | banner: bannerWithComments,
53 | sourcemap: true,
54 | },
55 | {
56 | file: `./build/untranspiled/${LIB_FILE_NAME}.esm.min.js`,
57 | format: 'es',
58 | banner: bannerWithComments,
59 | sourcemap: true,
60 | plugins: [ terser(terserOptions) ]
61 | }
62 | ],
63 | },
64 | { // bundle types to hide internal modules, because @internal is not recommended in ts docs
65 | input: "./types/index.d.ts",
66 | output: [{ file: `types/${LIB_FILE_NAME}.d.ts`, format: "es" }],
67 | plugins: [dts()],
68 | },
69 | ]);
70 |
--------------------------------------------------------------------------------
/config/shared.js:
--------------------------------------------------------------------------------
1 | const LIB_FILE_NAME = 'animate-images';
2 | const LIB_NAME = 'AnimateImages';
3 | const TERSER_OPTIONS = {
4 | mangle: {
5 | properties: {
6 | regex: /^_/,
7 | }
8 | },
9 | }
10 | module.exports = {LIB_FILE_NAME, LIB_NAME, TERSER_OPTIONS};
11 |
12 |
13 |
--------------------------------------------------------------------------------
/config/webpack.common.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const { LIB_FILE_NAME, LIB_NAME } = require( './shared');
3 |
4 | const config = {
5 | entry: path.join(__dirname, "../src/index.js"),
6 | output: {
7 | path: path.resolve(__dirname, '../build'),
8 | filename: `${LIB_FILE_NAME}.umd.min.js`,
9 | library: {
10 | name: LIB_NAME,
11 | type: 'umd',
12 | export: 'default',
13 | //umdNamedDefine: true,
14 | },
15 | globalObject: 'this',
16 | },
17 | module: {
18 | rules: [
19 | {
20 | test: /\.js$/,
21 | exclude: /(node_modules)/,
22 | use: {
23 | loader: 'babel-loader',
24 | }
25 | }
26 | ]
27 | },
28 | plugins: [ ],
29 | };
30 | module.exports = config;
31 |
--------------------------------------------------------------------------------
/config/webpack.dev.js:
--------------------------------------------------------------------------------
1 | const { merge } = require('webpack-merge');
2 | const common = require('./webpack.common.js');
3 |
4 | module.exports = merge(common, {
5 | mode: 'development',
6 | devtool: 'eval-source-map',
7 | devServer: {
8 | port: 7701,
9 | historyApiFallback: true,
10 | open: true,
11 | devMiddleware: {
12 | index: 'index.html',
13 | },
14 | client: {
15 | overlay: true,
16 | },
17 | static: {
18 | directory: './example',
19 | watch: true,
20 | }
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/config/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const webpack = require("webpack");
2 | const { merge } = require('webpack-merge');
3 | const TerserPlugin = require("terser-webpack-plugin");
4 | const { CleanWebpackPlugin } = require('clean-webpack-plugin');
5 |
6 | const common = require('./webpack.common.js');
7 | const banner = require("./banner");
8 | const { TERSER_OPTIONS } = require( './shared');
9 |
10 | module.exports = [
11 | merge(common, { // Minified
12 | mode: 'production',
13 | //devtool: 'source-map',
14 | optimization: {
15 | minimizer: [ // fix license.txt from bannerPlugin
16 | new TerserPlugin({
17 | extractComments: false,
18 | terserOptions: TERSER_OPTIONS
19 | }),
20 | ]
21 | },
22 | plugins: [
23 | new webpack.BannerPlugin(banner),
24 | new CleanWebpackPlugin(),
25 | ],
26 | }),
27 | ];
28 |
--------------------------------------------------------------------------------
/example/app.js:
--------------------------------------------------------------------------------
1 | document.addEventListener("DOMContentLoaded", function() {
2 | let element = document.getElementById('canvas1');
3 | let imagesArray = Array.from(new Array(90), (v, k) => {
4 | let number = String(k).padStart(4, "0");
5 | return `https://distracted-villani-e19534.netlify.app/train/rotation${number}.jpg`;
6 | });
7 | let loadingBlock = document.querySelector('.loading1');
8 |
9 | // Initialization
10 | let instance1 = new AnimateImages(element, {
11 | images: imagesArray,
12 | preload: "all",
13 | preloadNumber: 15,
14 | poster: imagesArray[0],
15 | fps: 45,
16 | loop: true,
17 | //reverse: true,
18 | autoplay: false,
19 | //ratio: 2.56,
20 | fillMode: 'cover',
21 | draggable: true,
22 | //inversion: true,
23 | //dragModifier: 1.5,
24 | touchScrollMode: "pageScrollTimer",
25 | //pageScrollTimerDelay: 2500,
26 | //responsiveAspect: "height",
27 | // fastPreview: {
28 | // images: imagesArray.filter( (val, i) => i % 5 === 0 ),
29 | // matchFrame: function (currentFrame){
30 | // return ((currentFrame-1) * 5) + 1;
31 | // },
32 | // fpsAfter: 60,
33 | // },
34 | onPreloadFinished: (plugin) => {
35 | console.log('Callback: onPreloadFinished');
36 | //plugin.play();
37 | },
38 | onFastPreloadFinished: (plugin) => {
39 | console.log('Callback: onFastPreloadFinished');
40 | },
41 | onPosterLoaded(plugin){
42 | console.log('Callback: onPosterLoaded');
43 | },
44 | onAnimationEnd(plugin){
45 | console.log('Callback: onAnimationEnd');
46 | },
47 | // onBeforeFrame(plugin, {context, width, height }){
48 | //
49 | // },
50 | // onAfterFrame(plugin, {context, width, height }){
51 | //
52 | // },
53 | });
54 | //instance1.preloadImages();
55 | setupControls();
56 |
57 | // Events
58 | element.addEventListener('animate-images:loading-progress', function (e){
59 | //console.log(`Event: loading progress: ${e.detail.progress}`);
60 | loadingBlock.querySelector('span').textContent = Math.floor( +e.detail.progress * 100);
61 | });
62 | element.addEventListener('animate-images:preload-finished', function (e){
63 | console.log(`Event: animate-images:preload-finished`);
64 | });
65 | element.addEventListener('animate-images:fast-preload-finished', function (e){
66 | console.log(`Event: animate-images:fast-preload-finished`);
67 | });
68 | element.addEventListener('animate-images:animation-end', function () {
69 | console.log(`Event: animate-images:animation-end`);
70 | });
71 | element.addEventListener('animate-images:poster-loaded', function () {
72 | console.log(`Event: animate-images:poster-loaded`);
73 | });
74 | element.addEventListener('animate-images:loading-error', function () {
75 | console.log(`Event: animate-images:loading-error`);
76 | });
77 | element.addEventListener('animate-images:drag-start', function () {
78 | console.log(`Event: animate-images:drag-start`);
79 | });
80 | element.addEventListener('animate-images:drag-change', function (e) {
81 | //console.log(`Event: animate-images:drag-change`);
82 | //console.log(`Drag direction: ${e.detail.direction}`);
83 | });
84 | element.addEventListener('animate-images:drag-end', function (e) {
85 | console.log(`Event: animate-images:drag-end`);
86 | //console.log(`Drag direction: ${e.detail.direction}`);
87 | });
88 |
89 | // Controls
90 | function setupControls(lib){
91 | console.log('setup controls');
92 | document.querySelector('.js-play').addEventListener('click', () => {
93 | instance1.play();
94 | });
95 | document.querySelector('.js-stop').addEventListener('click', () => {
96 | instance1.stop();
97 | });
98 | document.querySelector('.js-toggle').addEventListener('click', () => {
99 | instance1.toggle();
100 | });
101 | document.querySelector('.js-next').addEventListener('click', () => {
102 | instance1.next();
103 | });
104 | document.querySelector('.js-prev').addEventListener('click', () => {
105 | instance1.prev();
106 | });
107 | document.querySelector('.js-reset').addEventListener('click', () => {
108 | instance1.reset();
109 | });
110 |
111 | let reverse = instance1.getOption('reverse');
112 | document.querySelector('.js-reverse').addEventListener('change', () => {
113 | reverse = !reverse;
114 | instance1.setReverse(reverse);
115 | });
116 | document.querySelector(".js-reverse").checked = reverse;
117 |
118 | let loop = instance1.getOption('loop');
119 | document.querySelector('.js-loop').addEventListener('change', () => {
120 | loop = !loop;
121 | instance1.setOption('loop', loop);
122 | });
123 | document.querySelector('.js-loop').checked = loop;
124 |
125 | let draggable = instance1.getOption('draggable');
126 | document.querySelector('.js-draggable').addEventListener('change', () => {
127 | draggable = !draggable;
128 | instance1.setOption('draggable', draggable);
129 | });
130 | document.querySelector('.js-draggable').checked = draggable;
131 |
132 | let fillMode = instance1.getOption('fillMode');
133 | document.querySelector(".js-fill-mode[value='"+ fillMode +"']").checked = true;
134 | document.querySelectorAll(".js-fill-mode").forEach(function (el){
135 | el.addEventListener('change', function(){
136 | instance1.setOption('fillMode', this.value);
137 | });
138 | });
139 |
140 | let framesInput = document.querySelector('.js-frames-input');
141 | framesInput.setAttribute('max', instance1.getTotalImages());
142 | framesInput.addEventListener('input', function() {
143 | instance1.setFrame(this.value);
144 | });
145 |
146 | // Inputs
147 | document.querySelector('.js-set-frame').addEventListener('click', function() {
148 | instance1.setFrame(+this.closest('.js-option-block').querySelector('input').value);
149 | });
150 | document.querySelector('.js-play-to').addEventListener('click', function() {
151 | instance1.playTo(+this.closest('.js-option-block').querySelector('input').value);
152 | });
153 | document.querySelector('.js-play-to-shortest').addEventListener('click', function() {
154 | instance1.playTo(+this.closest('.js-option-block').querySelector('input').value, {
155 | shortestPath: true,
156 | });
157 | });
158 | document.querySelector('.js-play-frames').addEventListener('click', function() {
159 | instance1.playFrames(+this.closest('.js-option-block').querySelector('input').value);
160 | });
161 | document.querySelector('.js-set-fps').addEventListener('click', function() {
162 | instance1.setOption("fps", this.closest('.js-option-block').querySelector('input').value);
163 | });
164 | document.querySelector('.js-set-ratio').addEventListener('click', function() {
165 | instance1.setOption("ratio", this.closest('.js-option-block').querySelector('input').value);
166 | });
167 | }
168 |
169 | });
170 |
171 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Animation test
10 |
11 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | Play
107 | Stop
108 | Toggle
109 | Next
110 | Prev
111 | Reset
112 |
113 |
114 |
115 |
138 |
139 |
140 | Frames
141 |
143 |
144 |
145 |
146 |
147 |
155 |
163 |
173 |
181 |
189 |
197 |
198 |
199 |
200 |
201 |
202 |
Loading 0 %
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@its2easy/animate-images",
3 | "version": "2.3.1",
4 | "description": "Javascript library that animates a sequence of images to use in complex animations or pseudo 3d product view",
5 | "author": "Dmitry Kovalev",
6 | "license": "MIT",
7 | "main": "build/animate-images.umd.min.js",
8 | "module": "build/animate-images.esm.min.js",
9 | "types": "types/animate-images.d.ts",
10 | "publishConfig": {
11 | "access": "public"
12 | },
13 | "scripts": {
14 | "build": "webpack --config config/webpack.prod.js && npx tsc && rollup --config config/rollup.config.js && node config/copy-to-examples.js",
15 | "start": "webpack-dev-server --progress --config config/webpack.dev.js --open",
16 | "test": "echo \"Error: no test specified\"",
17 | "version": "npm run build && git add .",
18 | "postversion": "git push && git push --tags"
19 | },
20 | "files": [
21 | "build",
22 | "types/animate-images.d.ts"
23 | ],
24 | "keywords": [
25 | "animation",
26 | "animate",
27 | "sequence",
28 | "frames",
29 | "image sequence",
30 | "image animation",
31 | "frames animation",
32 | "360 animation",
33 | "3D",
34 | "3D spin",
35 | "3Dview",
36 | "3d rotation",
37 | "360",
38 | "view360"
39 | ],
40 | "repository": {
41 | "type": "git",
42 | "url": "https://github.com/its2easy/animate-images"
43 | },
44 | "sideEffects": false,
45 | "devDependencies": {
46 | "@babel/core": "^7.17.8",
47 | "@babel/preset-env": "^7.16.11",
48 | "@rollup/plugin-babel": "^5.3.1",
49 | "@rollup/plugin-commonjs": "^21.0.2",
50 | "@rollup/plugin-node-resolve": "^13.1.3",
51 | "babel-loader": "^8.2.3",
52 | "clean-webpack-plugin": "^4.0.0",
53 | "core-js": "^3.24.1",
54 | "rollup": "^2.70.1",
55 | "rollup-plugin-bundle-size": "^1.0.3",
56 | "rollup-plugin-dts": "^4.2.0",
57 | "rollup-plugin-terser": "^7.0.2",
58 | "typescript": "^4.6.2",
59 | "webpack": "^5.70.0",
60 | "webpack-cli": "^4.9.2",
61 | "webpack-dev-server": "^4.7.4",
62 | "webpack-merge": "^5.8.0"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 | AnimateImages
3 |
4 |
5 | 
6 |
7 | Demo - [codepen](https://codepen.io/its2easy/pen/powQJmd)
8 |
9 | **AnimateImages** is a lightweight library (17kb) that animates a sequence of images
10 | to use in animations or pseudo 3d product view. It works WITHOUT BUILT-IN UI and mainly
11 | developed for complex animations.
12 |
13 | To use it, you have to get a series of frames from a video or 3d app.
14 | The frames must be separated images of the same size.
15 |
16 | * [Installation](#installation)
17 | * [Usage](#usage)
18 | * [Sizes and responsive behavior](#responsive)
19 | * [Options](#options)
20 | * [Methods](#methods)
21 | * [Events](#events)
22 | * [Browser support](#browser_support)
23 | * [License](#license)
24 |
25 | ## Installation
26 | ### Browser script tag
27 | Add with CDN link:
28 | ```html
29 |
30 | ```
31 | Or download minified version
32 | and include in html:
33 | ```html
34 |
35 | ```
36 | ```javascript
37 | let instance = new AnimateImages(element, options);
38 | ```
39 | ### npm
40 | ```
41 | npm i @its2easy/animate-images --save
42 | ```
43 | ```javascript
44 | import AnimateImages from "@its2easy/animate-images";
45 | let instance = new AnimateImages(element, options);
46 | ```
47 |
48 |
49 | It is possible to directly import untranspiled esm version:
50 |
51 | This version has not been processed by babel:
52 | ```javascript
53 | import AnimateImages from '@its2easy/animate-images/build/untranspiled/animate-images.esm.min.js'; //or animate-images.esm.js
54 | ```
55 | > :warning: You should probably add it to your build process if you use untranspiled version. Example for webpack:
56 | ```javascript
57 | rules: [
58 | {
59 | test: /\.js$/,
60 | exclude: /node_modules(?!(\/|\\)@its2easy(\/|\\)animate-images(\/|\\)build)/,
61 | use: {
62 | loader: 'babel-loader',
63 | }
64 | }
65 | ]
66 | ```
67 | or
68 | ```javascript
69 | rules: [
70 | {
71 | // basic js rule
72 | test: /\.js$/,
73 | exclude: /node_modules/,
74 | use: {
75 | loader: 'babel-loader',
76 | }
77 | },
78 | {
79 | // additional rule
80 | test: /\.js$/,
81 | include: /node_modules(\/|\\)@its2easy(\/|\\)animate-images(\/|\\)build/,
82 | use: {
83 | loader: 'babel-loader',
84 | }
85 | },
86 | ]
87 | ```
88 |
89 |
90 |
91 |
92 | All available versions:
93 |
94 | umd build:
95 |
96 | `@its2easy/animate-images/build/animate-images.umd.min.js` - default for browser script tag and legacy bundlers
97 |
98 | esm builds processed whit babel:
99 |
100 | `@its2easy/animate-images/build/animate-images.esm.min.js` - default for webpack and module environments
101 |
102 | `@its2easy/animate-images/build/animate-images.esm.js`
103 |
104 | esm builds without babel transformation:
105 |
106 | `@its2easy/animate-images/build/untranspiled/animate-images.esm.min.js`
107 |
108 | `@its2easy/animate-images/build/untranspiled/animate-images.esm.js`
109 |
110 | :information_source: If you are using **webpack 4** and babel with modern target browsers,
111 | then you might get an error while importing, because webpack 4 doesn't support some modern js
112 | syntax and babel doesn't transpile it because browsers support for this syntax is high enough now.
113 | Use **webpack 5** to fix it.
114 |
115 |
116 |
117 | ## Usage
118 | Create canvas element
119 | ```html
120 |
121 | ```
122 |
123 | Initialize with options
124 | ```javascript
125 | let element = document.querySelector('canvas.canvas-el');
126 | let imagesArray = Array.from(new Array(90), (v, k) => { // generate array of urls
127 | let number = String(k).padStart(4, "0");
128 | return `path/to/your/images/frame-${number}.jpg`;
129 | });
130 | let instance = new AnimateImages(element, {
131 | images: imagesArray, /* required */
132 | preload: "partial",
133 | preloadNumber: 20,
134 | loop: true,
135 | fps: 60,
136 | poster: 'path/to/poster/image.jpg',
137 | });
138 | instance.play();
139 | ```
140 |
141 | Methods called from `onPreloadFinished` callback will start immediately, but you have to use `options.preload: 'all'`
142 | or call `plugin.preload()`. The plugin loads each image only once, so it's safe to call `preload` multiple times,
143 | even after the load has been completed. If `autoplay: true`, full preload will start immediately.
144 | ```javascript
145 | let instance = new AnimateImages(element, {
146 | images: imagesArray, /* required */
147 | preload: "none", // if 'all', you don't need to call preload()
148 | onPreloadFinished: function (plugin){
149 | plugin.play();
150 | }
151 | });
152 | instance.preload(30);//load the first part
153 |
154 | setTimeout(() => {
155 | instance.preload(60);// laod the rest
156 | }, 1000);
157 | // or instance.preload() to load all the images
158 | ```
159 | Methods that were called from outside of `onPreloadFinished` will trigger the load of all remaining images,
160 | wait for the full load, and then execute the action. If multiple methods have been called before
161 | load, only the last will be executed
162 | ```javascript
163 | let instance = animateImages.init(element,
164 | {
165 | images: imagesArray,
166 | preload: "none",
167 | }
168 | );
169 | // if preload: "none", it will trigger the load, and play after all the image loaded
170 | // if preload: "all", it will wait until full load and then start
171 | instance.play();
172 | ```
173 | In general, the plugin will load all the frames before any action, but you can preload a part of the
174 | images in some cases, for example, when the user is scrolling to the section in which the animation will
175 | take place.
176 |
177 | ### Loading errors
178 | All images that have been loaded with errors will be removed from the array of frames. Duration
179 | of the animation will be recalculated.
180 |
181 | New frames count could be obtained in preload callback:
182 | ```javascript
183 | new AnimateImages(element, {
184 | ...
185 | onPreloadFinished: function (instance){
186 | if ( instance.isLoadedWithErrors() ) {
187 | let newFramesCount = instance.getTotalImages();
188 | }
189 | },
190 | });
191 | ```
192 |
193 | ## Sizes and responsive behavior
194 | **TL;DR**
195 | use your image dimensions as width and height canvas properties, add ```width: 100%``` to canvas,
196 | add more css if you need:
197 | ```html
198 |
199 | ```
200 |
201 | ---
202 |
203 | Size calculation is controlled by ```responsiveAspect```. Default is ```width``` which means that canvas **width**
204 | should be defined or restricted by CSS, and **height** will be calculated by the plugin. With ```responsiveAspect: "height"```
205 | you should control **height** and leave **width** auto.
206 |
207 | Canvas itself should have ```width: 100%``` (```height: 100%``` if responsiveAspect is "height"). Size restrictions
208 | could be set on canvas or on wrapper element:
209 | ```html
210 |
211 | ```
212 |
213 | responsive height example
214 |
215 | ```html
216 |
217 |
218 |
219 | ```
220 |
221 |
222 |
223 | :information_source: Secondary side of the canvas should be ```auto``` and not fixed by CSS. If it's fixed,
224 | the ratio can't be used, and the canvas will use its natural CSS size.
225 |
226 | #### Ratio
227 | To calculate secondary side, plugin uses ratio of
228 | inline width and height canvas properties `` (if they're not set, default
229 | is 300x150). This ratio can be overwritten by `options.ratio`.
230 |
231 | #### Fill mode
232 | If **the canvas and images have the same ratio**, the full image will be displayed. If **the ratios are the same,
233 | but sizes are different**, the image will be scaled to fit the canvas. The dimensions of the images are taken from the
234 | image itself after load, and they do not need to be set anywhere in the settings.On the page the canvas
235 | with the image will be scaled to canvas CSS size.
236 |
237 | If **canvas and image ratios are different**, then image will use `options.fillMode`, which works like
238 | background-size `cover` and `contain`, and the image will be centered.
239 |
240 | To display the full image, check the image width and height, and set it as canvas inline `width` and `height`
241 | (or set `options.ratio`).
242 | Then set canvas width by CSS (width="500px" or width="100%" or max-width="800px" etc), and don't set
243 | canvas height (with default responsiveAspect).
244 |
245 | #### Other
246 | For example, <canvas width="800" height="400">, image 1200x600, canvas has css max-width="500px".
247 | Image will be scaled to 800x400 inside canvas and fully visible, canvas on the page will be displayed
248 | 500px x 250px.
249 |
250 | After page resize, the sizes will be recalculated automatically, but if canvas was resized **by a script**, call
251 | `instance.updateCanvas()`
252 |
253 |
254 | ## Options
255 |
256 | ```javascript
257 | new AnimateImages(element, options);
258 | ```
259 | element : HTMLCanvasElement - canvas DOM element (required)
260 |
261 | options:
262 |
263 | | Parameter | Type | Default | Description |
264 | | :--- | :---: | :---:| :--- |
265 | | **images** | Array<string> | | **(Required)** Array with images URLs |
266 | | **preload** | string | 'all' | Preload mode ("`all`", "`none`", "`partial`") |
267 | | **preloadNumber** | number | 0 | Number of images to preload when `preload: "partial"` (0 for all images) |
268 | | **poster** | string | | URL of the poster image, works like poster in `````` |
269 | | **fps** | number | 30 | FPS when playing. Determines the duration of the animation (e.g. 90 images and 60 fps = 1.5s, 90 images and 30fps = 3s) |
270 | | **loop** | boolean | false | Loop the animation |
271 | | **autoplay** | boolean | false | Autoplay |
272 | | **reverse** | boolean | false | Reverse direction |
273 | | **ratio** | number | false | Canvas width/height ratio, it has higher priority than inline canvas width and height |
274 | | **fillMode** | string | 'cover' | Fill mode to use **if canvas and image aspect ratios are different** ("`cover`" or "`contain`") |
275 | | **draggable** | boolean | false | Draggable by mouse or touch |
276 | | **inversion** | boolean | false | Inversion changes drag direction. Use it if animation direction doesn't match swipe direction |
277 | | **dragModifier** | number | 1 | Sensitivity factor for user interaction. Only positive numbers are allowed |
278 | | **touchScrollMode** | string | 'pageScrollTimer' | Page scroll behavior with touch events _(only for events that fire in the plugin area)_. Available modes: `preventPageScroll` - touch scroll is always disabled. `allowPageScroll` - touch scroll is always enabled. `pageScrollTimer` - after the first interaction the scroll is not disabled; if the time between the end of the previous interaction and the start of a new one is less than _pageScrollTimerDelay_, then scroll will be disabled; if more time has passed, then scroll will be enabled again |
279 | | **pageScrollTimerDelay** | number | 1500 | Time in ms when touch scroll will be disabled after the last user interaction, if `touchScrollMode: "pageScrollTimer"` |
280 | | **responsiveAspect** | string | 'width' | This option sets the side on which the sizes will be calculated. `width`: width should be controlled by css, height will be calculated by the plugin; `height`: use css to restrict height, width will be set by plugin. See [responsive behavior](#responsive) |
281 | | **fastPreview** | Object | false | false | Special mode when you want interactivity as quickly as possible, but you have a lot of pictures. It will only load a small set of images, after which it will be possible to interact with the plugin, and then full set of the images will be loaded. If enabled, ```preload```, ```preloadNumber``` and ```fps``` options will be applied to **fastPreview** images. See [examples below](#fast-preview) |
282 | | **fastPreview.images** | Array<string> | | Required if ```fastPreview``` is enabled. Array with urls of preview mode images. You could use a part of **options.images** array or completely different pictures, they will be replaced when full sequence is loaded |
283 | | **fastPreview.fpsAfter** | number | | fps value that will be applied after the full list of images is loaded |
284 | | **fastPreview.matchFrame** | function(number):number | | A function that takes the frame number of the short set and returns the frame number of the full set. The function is called when the plugin switches to the full set of images, so that the animation doesn't jump after full load. Frame numbers start from 1. If not specified, first frame will be set |
285 | | **onPreloadFinished** | function(AnimateImages) | | Callback, occurs when all image files have been loaded, receives plugin instance as a parameter |
286 | | **onFastPreloadFinished** | function(AnimateImages) | | Callback, occurs when all ```fastPreview``` mode images have been loaded, receives plugin instance as a parameter |
287 | | **onPosterLoaded** | function(AnimateImages) | | Callback, occurs when poster image is fully loaded, receives plugin instance as a parameter |
288 | | **onAnimationEnd** | function(AnimateImages) | | Callback, occurs when animation has ended, receives plugin instance as a parameter |
289 | | **onBeforeFrame** | function(AnimateImages, {context, width, height}) | | Callback, occurs before new frame, receives plugin and canvas info as parameters. Can be used to change settings, for example ```imageSmoothingEnabled``` |
290 | | **onAfterFrame** | function(AnimateImages, {context, width, height}) | | Callback, occurs after the frame was drawn, receives plugin and canvas info as parameters. Can be used to modify the canvas appearance. |
291 |
292 | ##### Callback example:
293 | ```javascript
294 | let instance1 = new AnimateImages(element, {
295 | images: imagesArray,
296 | poster: imagesArray[0],
297 | preload: "none",
298 | ...
299 | onPosterLoaded(plugin){
300 | plugin.preloadImages();// load all
301 | },
302 | onBeforeFrame(plugin, {context, width, height}){
303 | context.imageSmoothingEnabled = false;
304 | },
305 | onAfterFrame(plugin, {context, width, height}){
306 | context.fillStyle = "green";
307 | context.fillRect(10, 10, 100, 100);
308 | },
309 | });
310 | ```
311 | > ```width``` and ```height``` are internal canvas dimensions, they
312 | > depend on ```devicePixelRatio```
313 |
314 | ## Methods
315 | > Most methods can be chained (```instance.setReverse(true).play()```)
316 |
317 | > Methods that involve a frame change can be called before full load or even without any preload.
318 | > Plugin will add this action to the queue and start downloading the frames. Only one last action is saved in the queue
319 |
320 | ### play
321 | Start animation
322 |
323 | `returns` {AnimateImages} - plugin instance
324 |
325 | ---
326 |
327 | ### stop
328 | Stop animation
329 |
330 | `returns` {AnimateImages} - plugin instance
331 |
332 | ---
333 |
334 | ### toggle
335 | Toggle between start and stop
336 |
337 | `returns` {AnimateImages} - plugin instance
338 |
339 | ---
340 |
341 | ### next
342 | Show next frame
343 |
344 | `returns` {AnimateImages} - plugin instance
345 |
346 | ---
347 |
348 | ### prev
349 | Show previous frame
350 |
351 | `returns` {AnimateImages} - plugin instance
352 |
353 | ---
354 |
355 | ### setFrame
356 | Show a frame with a specified number (without animation)
357 |
358 | `parameters`
359 | - frameNumber {number} - Number of the frame to show
360 | ```javascript
361 | instance.setFrame(35);
362 | ```
363 | `returns` {AnimateImages} - plugin instance
364 |
365 | ---
366 |
367 | ### playTo
368 | Start animation, that plays until the specified frame number
369 |
370 | `parameters`
371 | - frameNumber {number} - Target frame number
372 | - options {Object}
373 | - options.shortestPath {boolean} - If set to true and loop is enabled, function will use the shortest path to the target frame,
374 | even if the path crosses edge frames. Default is **false**.
375 | ```javascript
376 | // if current frame is 30 of 100, it will play from 30 to 85,
377 | // if current frame is 95, it will play from 95 to 85
378 | instance.playTo(85);
379 |
380 | // shortestPath
381 | // if current frame is 2, it will play 1, 100, 99, 98
382 | instance.playTo(98, {
383 | shortestPath: true
384 | });
385 | // (default) if current frame is 2, it will play 3, 4, 5 ... 97, 98
386 | instance.playTo(98);
387 | ```
388 | `returns` {AnimateImages} - plugin instance
389 |
390 | ---
391 |
392 | ### playFrames
393 | Start animation in the current direction with the specified number of frames in the queue.
394 | If `loop: false` animation will stop when it reaches the first or the last frame.
395 |
396 | `parameters`
397 | - numberOfFrames {number} - Number of frames to play
398 | ```javascript
399 | instance.playFrames(200);
400 | ```
401 | `returns` {AnimateImages} - plugin instance
402 |
403 | ---
404 |
405 | ### setReverse
406 | Change the direction of the animation. Alias to ```setOption('reverse', true)```
407 |
408 | `parameters`
409 | - reverse {boolean} - true for backward animation, false for forward, default ```true```
410 | ```javascript
411 | instance.setReverse(true);
412 | ```
413 | `returns` {AnimateImages} - plugin instance
414 |
415 | ---
416 |
417 |
418 | ### getReverse
419 | Get current reverse option. Alias to ```getOption('reverse')```
420 |
421 | `returns` {boolean} - reverse or not
422 |
423 | ---
424 |
425 | ### setForward
426 | Change the direction of the animation. It does the opposite effect
427 | of ```setReverse()```
428 |
429 | `parameters`
430 | - forward {boolean} - true for forward animation, false for backward, default ```true```
431 |
432 | `returns` {AnimateImages} - plugin instance
433 |
434 | ---
435 |
436 | ### preloadImages
437 | Start preload specified number of images, can be called multiple times.
438 | If all the images are already loaded, then nothing will happen
439 |
440 | `parameters`
441 | - number {number} - (optional) Number of images to load. If not specified, all remaining images will be loaded.
442 | ```javascript
443 | instance.preloadImages(15);
444 | ```
445 | `returns` {AnimateImages} - plugin instance
446 |
447 | ---
448 |
449 | ### updateCanvas
450 | Calculate new canvas dimensions. Should be called after the canvas size was changed in
451 | the browser. It's called automatically after window ```resize``` event
452 |
453 | `returns` {AnimateImages} - plugin instance
454 |
455 | ---
456 |
457 | ### getOption
458 | Returns option value
459 |
460 | `parameters`
461 | - option {string} - Option name. All options are allowed
462 | ```javascript
463 | let reverse = instance.getOption('reverse');
464 | ```
465 | `returns` {*} - current option value
466 |
467 | ---
468 |
469 | ### setOption
470 | Set new option value
471 |
472 | `parameters`
473 | - option {string} - Option name. Allowed options: `fps`, `loop`, `reverse`, `inversion`, `ratio`, `fillMode`,
474 | `draggable`, `dragModifier`, `touchScrollMode`, `pageScrollTimerDelay`, `onPreloadFinished`, `onFastPreloadFinished`,
475 | `onPosterLoaded`, `onAnimationEnd`, `onBeforeFrame`, `onAfterFrame`
476 | - value {*} - New value
477 |
478 | `returns` {AnimateImages} - plugin instance
479 | ```javascript
480 | instance.setOption('fps', 40);
481 | instance.setOption('ratio', 2.56);
482 | ```
483 |
484 | ---
485 |
486 | ### getCurrentFrame
487 | Returns the current frame number. Frames start from 1
488 |
489 | `returns` {number} - Frame number
490 |
491 | ---
492 |
493 | ### getTotalImages
494 | Returns the total images count (considering loading errors)
495 |
496 | `returns` {number}
497 |
498 | ---
499 |
500 | ### getRatio
501 | Returns the current canvas ratio. It may differ from the
502 | value in the `options.ratio`
503 |
504 | `returns` {number}
505 |
506 | ---
507 |
508 | ### isAnimating
509 | Returns true if the animation is running, and false if not
510 |
511 | `returns` {boolean}
512 |
513 | ---
514 |
515 | ### isDragging
516 | Returns true if a drag action is in progress
517 |
518 | `returns` {boolean}
519 |
520 | ---
521 |
522 | ### isPreloadFinished
523 | Returns true if all the images are loaded and plugin is ready to
524 | change frames
525 |
526 | `returns` {boolean}
527 |
528 | ---
529 |
530 | ### isFastPreloadFinished
531 | Returns true if ```fastPreview``` mode preload finished and plugin is ready to
532 | change frames
533 |
534 | `returns` {boolean}
535 |
536 | ---
537 |
538 | ### isLoadedWithErrors
539 | Returns true if at least one image wasn't loaded because of error
540 |
541 | `returns` {boolean}
542 |
543 | ---
544 |
545 | ### reset
546 | Stop the animation and return to the first frame
547 |
548 | `returns` {AnimateImages} - plugin instance
549 |
550 | ---
551 |
552 | ### destroy
553 | Stop animation, remove event listeners and clear the canvas.
554 | Method doesn't remove canvas element from the DOM
555 |
556 | ---
557 |
558 |
559 | ## Events
560 | Plugin fires all the events on the canvas element.
561 |
562 | **animate-images:loading-progress** -
563 | Fires after every image load. Progress amount is in `event.detail.progress`
564 |
565 | **animate-images:loading-error** -
566 | Fires after every image.onerror
567 |
568 | **animate-images:preload-finished** -
569 | Fires when all the images have been loaded, and the plugin is ready to play
570 |
571 | **animate-images:fast-preload-finished** -
572 | Fires when all ```fastPreview``` images have been loaded, and the plugin is
573 | ready to play
574 |
575 | **animate-images:poster-loaded** -
576 | Fires when poster has been loaded
577 |
578 | **animate-images:animation-end** -
579 | Fires after the animation end. If the second animation was started
580 | while the first was active, this event will be fired only after the
581 | second animation end.
582 |
583 | **animate-images:drag-start** -
584 | Fires when user starts dragging. Frame number is in `event.detail.frame`
585 |
586 | **animate-images:drag-change** -
587 | Fires on every frame change while dragging. Frame number is in `event.detail.frame`, direction
588 | (`left` or `right`) is in `event.detail.direction`.
589 |
590 | **animate-images:drag-end** -
591 | Fires when user stops dragging. Frame number is in `event.detail.frame`, direction
592 | (`left` or `right`) is in `event.detail.direction`.
593 |
594 | Example:
595 | ```javascript
596 | let element = document.querySelector('canvas.canvas_el');
597 | let instance = new AnimateImages(element, options);
598 | element.addEventListener('animate-images:loading-progress', function (e){
599 | console.log(Math.floor(e.detail.progress * 100) + '%');
600 | });
601 | ```
602 |
603 | ## fastPreview mode
604 |
605 | Examples
606 |
607 | ```javascript
608 | // load only fastPreview images, start playing at 5 fps, then load all the images,
609 | // replace small set with full as soon as it loads, and continue playing at 30fps
610 | new AnimateImages(element, {
611 | images: imagesArray,
612 | autoplay: true,
613 | fps: 5, // fps for fastPreview
614 | ...
615 | fastPreview: {
616 | images: imagesArray.filter( (val, i) => i % 5 === 0 ),// use every 5th image (imagesArray[0], imagesArray[5], ...)
617 | fpsAfter: 30, // continue with 30fps
618 | matchFrame: function (currentFrame){
619 | return ((currentFrame-1) * 5) + 1; // 1 => 1, 2 => 6, 3 => 11, ...
620 | },
621 | }
622 | }
623 |
624 | // call play(), it will load and start fastPreview, then switch to full sequence
625 | let instance1 = new AnimateImages(element, {
626 | images: imagesArray,
627 | ...
628 | fastPreview: {
629 | images: imagesArray.filter( (val, i) => i % 10 === 0 ),
630 | }
631 | }
632 | button.addEventListener("click", () => { instance1.play() });
633 |
634 | // preload only 3 images, wait for user interaction, load the rest of fastPreview images, start playing,
635 | // then load full sequence
636 | let instance2 = new AnimateImages(element, {
637 | images: imagesArray,
638 | preload: "partial", // preload is applied to fastPreview only
639 | preloadNumber: 3,
640 | ...
641 | fastPreview: {
642 | images: imagesArray.filter( (val, i) => i % 10 === 0 ),// use every 10th image (imagesArray[0], imagesArray[10], etc)
643 | }
644 | }
645 | button.addEventListener("click", () => { instance2.play() }); // play() always loads the rest before playing
646 |
647 | // start loading only after some event, play after user interaction, then load the rest
648 | let instance3 = new AnimateImages(element, {
649 | images: imagesArray,
650 | preload: "none",
651 | fastPreview: {
652 | images: imagesArray.filter( (val, i) => i % 10 === 0 ),
653 | }
654 | }
655 | ...
656 | someModalOpenCallback(){ instance3.preloadImages() } // will load only fastPreview images
657 | buttonInsideModal.addEventListener("click", () => { instance3.play() }); // it's safe to call even without any preload
658 |
659 | // preload all fastPreview images, start loading full sequnce after that, but wait for interaction to play
660 | let instance4 = new AnimateImages(element, {
661 | images: imagesArray,
662 | preload: "all", // will load only fastPreview.images
663 | fastPreview: {
664 | images: imagesArray.filter( (val, i) => i % 10 === 0 ),
665 | },
666 | onFastPreloadFinished: (plugin) => {
667 | plugin.preloadImages();
668 | }
669 | }
670 | // initially will start short sequnce if full in not ready, otherwise will start the full
671 | buttonInsideModal.addEventListener("click", () => { instance4.play() });
672 | ```
673 |
674 |
675 | ## Browser support
676 |
677 | * latest versions of Chrome, android Chrome, Edge, Firefox
678 | * Safari 13.1+,
679 | * iOS Safari 13.4+
680 |
681 | ## License
682 | AnimateImages is provided under the [MIT License](https://opensource.org/licenses/MIT)
683 |
684 |
685 |
686 |
--------------------------------------------------------------------------------
/src/AnimateImages.js:
--------------------------------------------------------------------------------
1 | import { normalizeFrameNumber, uppercaseFirstChar } from "./utils";
2 | import { validateInitParameters, defaultSettings } from "./settings";
3 | import ImagePreloader from "./ImagePreloader";
4 | import Render from "./Render";
5 | import Animation from "./Animation";
6 | import Poster from "./Poster";
7 | import DragInput from "./DragInput";
8 |
9 | /**
10 | * Animate Images {@link https://github.com/its2easy/animate-images/}
11 | * @example
12 | * let pluginInstance = new AnimateImages(document.querySelector('canvas'), {
13 | * images: ['img1.jpg', 'img2.jpg', 'img3.jpg'],
14 | * loop: true,
15 | * draggable: true,
16 | * fps: 60,
17 | * });
18 | */
19 | export default class AnimateImages{
20 | #settings;
21 | #data = {
22 | currentFrame: 1,
23 | totalImages: null,
24 | loadedImagesArray: [], // images objects [0 - (images.length-1)]
25 | deferredAction: null, // call after full preload
26 | isAnyFrameChanged: false,
27 | /** @type AnimateImages */
28 | pluginApi: undefined,
29 | canvas: {
30 | element: null,
31 | ratio: null,
32 | },
33 | }
34 | #boundUpdateCanvasSizes;
35 | //Classes
36 | #preloader;
37 | #render;
38 | #animation;
39 | #poster;
40 | #dragInput;
41 |
42 | /**
43 | * Creates plugin instance
44 | * @param {HTMLCanvasElement} node - canvas element
45 | * @param {PluginOptions} options
46 | */
47 | constructor(node, options){
48 | validateInitParameters(node, options);
49 | this.#settings = {...defaultSettings, ...options};
50 | this.#data.totalImages = this.#settings.images.length;
51 | this.#data.canvas.element = node;
52 | this.#data.pluginApi = this;
53 | this.#boundUpdateCanvasSizes = this.#updateCanvasSizes.bind(this)
54 | this.#initPlugin();
55 | }
56 |
57 | #initPlugin(){
58 | this.#render = new Render( {settings: this.#settings, data: this.#data} );
59 | this.#animation = new Animation(
60 | {settings: this.#settings, data: this.#data, changeFrame: this.#changeFrame.bind(this)} );
61 | this.#updateCanvasSizes();
62 | if ( this.#settings.poster ) this.#setupPoster();
63 | this.#toggleResizeHandler(true);
64 | this.#preloader = new ImagePreloader({
65 | settings: this.#settings,
66 | data: this.#data,
67 | updateImagesCount: this.#updateImagesCount.bind(this),
68 | getFramesLeft: this.#getFramesLeft.bind(this),
69 | });
70 | if (this.#settings.preload === 'all' || this.#settings.preload === "partial"){
71 | let preloadNumber = (this.#settings.preload === 'all') ? this.#data.totalImages : this.#settings.preloadNumber;
72 | if (preloadNumber === 0) preloadNumber = this.#data.totalImages;
73 | this.#preloader._startLoading(preloadNumber);
74 | }
75 | if (this.#settings.autoplay) this.play();
76 | if ( this.#settings.draggable ) this.#toggleDrag(true);
77 | }
78 |
79 | #changeFrame(frameNumber){
80 | if (frameNumber === this.#data.currentFrame && this.#data.isAnyFrameChanged) return;//skip same frame, except first drawing
81 | if ( !this.#data.isAnyFrameChanged ) this.#data.isAnyFrameChanged = true;
82 |
83 | this.#animateCanvas(frameNumber);
84 | this.#data.currentFrame = frameNumber;
85 | }
86 |
87 | #animateCanvas(frameNumber){
88 | this.#render._clearCanvas();
89 | this.#render._drawFrame( this.#data.loadedImagesArray[frameNumber - 1] );
90 | }
91 |
92 |
93 | #updateCanvasSizes(){
94 | const canvas = this.#data.canvas;
95 | /**
96 | * +++RATIO SECTION+++
97 | * If no options.ratio, inline canvas width/height will be used (2:1 if not set)
98 | * Real canvas size is controlled by CSS, inner size will be set based on CSS width and ratio (height should be "auto")
99 | * If height if fixed in CSS, ratio can't be used and inner height will be equal to CSS-defined height
100 | */
101 | if ( this.#settings.ratio ) canvas.ratio = this.#settings.ratio;
102 | // Initial ratio shouldn't be changed. Ratio will only modified after setOption("ratio", newRatio),
103 | // or after setting css height and plugin.updateCanvas()
104 | else if ( !canvas.ratio ) {
105 | canvas.ratio = canvas.element.width / canvas.element.height;
106 | }
107 |
108 |
109 | // +++SIZE SECTION+++
110 | // mainSide is the side from responsiveAspect, it should be controlled by CSS, secondarySide value will be
111 | // controlled by script
112 | const dpr = (window.devicePixelRatio).toFixed(2) || 1; // sometimes dpr is like 2.00000000234
113 | let mainSide = this.#settings.responsiveAspect;// width or height
114 | let clientMainSide = "client" + uppercaseFirstChar(mainSide); // clientWidth or clientHeight
115 | let secondarySide = (mainSide === "width") ? "height" : "width";
116 | let clientSecondarySide = "client" + uppercaseFirstChar(secondarySide);// clientWidth or clientHeight
117 |
118 | // changing width and height won't change real clientWidth and clientHeight if size is fixed by CSS
119 | const initialClientMainSide = canvas.element[clientMainSide];
120 | canvas.element[mainSide] = canvas.element[clientMainSide] * dpr;
121 |
122 | // !!! ONLY if dpr != 1 and canvas css mainSide was not defined => changed width will change clientWidth
123 | // so we need to recalculate width based on new clientWidth
124 | if (initialClientMainSide !== canvas.element[clientMainSide]) {
125 | canvas.element[mainSide] = canvas.element[clientMainSide] * dpr;
126 | }
127 |
128 | let rawNewValue = (mainSide === "width") ? canvas.element.clientWidth / canvas.ratio : canvas.element.clientHeight * canvas.ratio;
129 | canvas.element[secondarySide] = Math.round(rawNewValue) * dpr; // "round" for partial fix to rounding pixels error
130 |
131 |
132 | // +++CORRECTION SECTION+++
133 | const secondaryValueDifference = Math.abs(canvas.element[secondarySide] - canvas.element[clientSecondarySide] * dpr);// diff in pixels
134 | // previously I compared with 1px to check subpixel errors, but error is somehow related to dpr, so we compare with "1px * dpr" or just "dpr"
135 | if ( secondaryValueDifference > dpr) { // if secondarySide is locked by CSS
136 | let newRatio = canvas.element.clientWidth / canvas.element.clientHeight; // ratio from "real" canvas element
137 | // <1% change => calculation error; >1% change => secondarySide size is locked with css
138 | if ( Math.abs(canvas.ratio - newRatio) / canvas.ratio > 0.01 ) {
139 | canvas.element[secondarySide] = canvas.element[clientSecondarySide] * dpr;
140 | canvas.ratio = newRatio;
141 | } else { // small diff between inner and real values, adjust to prevent errors accumulation
142 | canvas.element[secondarySide] = (mainSide === "width") ? canvas.element.width / canvas.ratio : canvas.element.height * canvas.ratio;
143 | }
144 | } else if (secondaryValueDifference > 0 && secondaryValueDifference <= dpr ) { // rare case, pixels are fractional
145 | // so just update inner canvas size baser on main side and ratio
146 | canvas.element[secondarySide] = (mainSide === "width") ? canvas.element.width / canvas.ratio : canvas.element.height * canvas.ratio;
147 | }
148 |
149 | if ( this.#dragInput ) this.#dragInput._updateThreshold()
150 | this.#maybeRedrawFrame(); // canvas is clear after resize
151 | }
152 |
153 | #updateImagesCount(){
154 | if ( this.#dragInput ) this.#dragInput._updateThreshold();
155 | this.#animation._updateDuration();
156 | }
157 | #maybeRedrawFrame(){
158 | if ( this.#data.isAnyFrameChanged ) { // frames were drawn
159 | this.#animateCanvas(this.#data.currentFrame);
160 | } else if ( this.#poster ) { // poster exists
161 | this.#poster._redrawPoster();
162 | }
163 | // don't redraw in initial state, or if poster onLoad is not finished yet
164 | }
165 |
166 | #toggleDrag(enable){
167 | if (enable) {
168 | if ( !this.#dragInput ) this.#dragInput = new DragInput({
169 | data: this.#data,
170 | settings: this.#settings,
171 | changeFrame: this.#changeFrame.bind(this),
172 | getNextFrame: this.#animation._getNextFrame.bind(this.#animation)
173 | });
174 | this.#dragInput._enableDrag();
175 | } else {
176 | if (this.#dragInput) this.#dragInput._disableDrag();
177 | }
178 | }
179 |
180 | #setupPoster(){
181 | if (!this.#poster) this.#poster = new Poster(
182 | {
183 | settings: this.#settings,
184 | data: this.#data,
185 | drawFrame: this.#render._drawFrame.bind(this.#render)
186 | });
187 | this.#poster._loadAndShowPoster();
188 | }
189 |
190 | #toggleResizeHandler(add = true) {
191 | if ( add ) window.addEventListener("resize", this.#boundUpdateCanvasSizes);
192 | else window.removeEventListener("resize", this.#boundUpdateCanvasSizes);
193 | }
194 |
195 | #getFramesLeft(){
196 | return this.#animation._framesLeftToPlay;
197 | }
198 |
199 | // Pubic API
200 |
201 | /**
202 | * Start animation
203 | * @returns {AnimateImages} - plugin instance
204 | */
205 | play(){
206 | if ( this.#animation._isAnimating ) return this;
207 | if ( this.#preloader._isAnyPreloadFinished ) {
208 | this.#animation._play();
209 | this.#preloader._maybePreloadAll();
210 | } else {
211 | this.#data.deferredAction = this.play.bind(this);
212 | this.#preloader._startLoading();
213 | }
214 | return this;
215 | }
216 | /**
217 | * Stop animation
218 | * @returns {AnimateImages} - plugin instance
219 | */
220 | stop(){
221 | this.#animation._stop();
222 | return this;
223 | }
224 | /**
225 | * Toggle between start and stop
226 | * @returns {AnimateImages} - plugin instance
227 | */
228 | toggle(){
229 | if ( !this.#animation._isAnimating ) this.play();
230 | else this.stop();
231 | return this;
232 | }
233 | /**
234 | * Show next frame
235 | * @returns {AnimateImages} - plugin instance
236 | */
237 | next(){
238 | if ( this.#preloader._isAnyPreloadFinished ) {
239 | this.stop();
240 | this.#changeFrame( this.#animation._getNextFrame(1) );
241 | this.#preloader._maybePreloadAll();
242 | } else {
243 | this.#data.deferredAction = this.next.bind(this);
244 | this.#preloader._startLoading();
245 | }
246 | return this;
247 | }
248 | /**
249 | * Show previous frame
250 | * @returns {AnimateImages} - plugin instance
251 | */
252 | prev(){
253 | if ( this.#preloader._isAnyPreloadFinished ) {
254 | this.stop();
255 | this.#changeFrame( this.#animation._getNextFrame(1, !this.#settings.reverse) );
256 | this.#preloader._maybePreloadAll();
257 | } else {
258 | this.#data.deferredAction = this.prev.bind(this);
259 | this.#preloader._startLoading();
260 | }
261 | return this;
262 | }
263 | /**
264 | * Show a frame with a specified number (without animation)
265 | * @param {number} frameNumber - Number of the frame to show
266 | * @returns {AnimateImages} - plugin instance
267 | */
268 | setFrame(frameNumber){
269 | if ( this.#preloader._isAnyPreloadFinished ) {
270 | this.stop();
271 | this.#changeFrame(normalizeFrameNumber(frameNumber, this.#data.totalImages));
272 | this.#preloader._maybePreloadAll();
273 | } else {
274 | this.#data.deferredAction = this.setFrame.bind(this, frameNumber);
275 | this.#preloader._startLoading();
276 | }
277 | return this;
278 | }
279 | /**
280 | * Start animation, that plays until the specified frame number
281 | * @param {number} frameNumber - Target frame number
282 | * @param {Object} [options] - Options
283 | * @param {boolean} [options.shortestPath=false] - If set to true and loop enabled, will use the shortest path
284 | * @returns {AnimateImages} - plugin instance
285 | */
286 | playTo(frameNumber, options){
287 | frameNumber = normalizeFrameNumber(frameNumber, this.#data.totalImages);
288 |
289 | const innerPathDistance = Math.abs(frameNumber - this.#data.currentFrame), // not crossing edge frames
290 | outerPathDistance = this.#data.totalImages - innerPathDistance, // crossing edges frames
291 | shouldUseOuterPath = this.#settings.loop && options?.shortestPath && (outerPathDistance < innerPathDistance);
292 |
293 | if ( !shouldUseOuterPath ) { // Inner path (default)
294 | // long conditions to make them more readable
295 | if (frameNumber > this.#data.currentFrame) this.setReverse(false); // move forward
296 | else this.setReverse(true); // move backward
297 | } else { // Outer path
298 | if (frameNumber < this.#data.currentFrame) this.setReverse(false); // move forward
299 | else this.setReverse(true); // move backward
300 | }
301 |
302 | return this.playFrames( (shouldUseOuterPath) ? outerPathDistance : innerPathDistance );
303 | }
304 | /**
305 | * Start animation in the current direction with the specified number of frames in the queue
306 | * @param {number} [numberOfFrames=0] - Number of frames to play
307 | * @returns {AnimateImages} - plugin instance
308 | */
309 | playFrames(numberOfFrames = 0){
310 | if ( this.#preloader._isAnyPreloadFinished ) {
311 | numberOfFrames = Math.floor(numberOfFrames);
312 | if (numberOfFrames < 0) { // first frame should be rendered to replace poster or transparent bg, so allow 0 for the first time
313 | return this.stop(); //empty animation, stop() to trigger events and callbacks
314 | }
315 |
316 | // if this is the 1st animation, we should add 1 frame to the queue to draw the 1st initial frame
317 | // because 1st frame is not drawn by default (1 frame will replace poster or transparent bg)
318 | if (!this.#data.isAnyFrameChanged) numberOfFrames += 1;
319 | if (numberOfFrames <= 0) { // with playFrames(0) before any actions numberOfFrames=1, after any frame change numberOfFrames=0
320 | return this.stop(); //empty animation
321 | }
322 |
323 | this.#animation._framesLeftToPlay = numberOfFrames;
324 | this.play();
325 | this.#preloader._maybePreloadAll();
326 | } else {
327 | this.#data.deferredAction = this.playFrames.bind(this, numberOfFrames);
328 | this.#preloader._startLoading();
329 | }
330 | return this;
331 | }
332 | /**
333 | * Change the direction of the animation. Alias to setOption('reverse', true)
334 | * @param {boolean} [reverse=true] - true for backward animation, false for forward, default "true"
335 | * @returns {AnimateImages} - plugin instance
336 | */
337 | setReverse(reverse = true){
338 | this.#settings.reverse = !!reverse;
339 | return this;
340 | }
341 | /**
342 | * Get current reverse option. Alias to getOption('reverse')
343 | * @returns {boolean} - reverse or not
344 | */
345 | getReverse() { return this.#settings.reverse; }
346 | /**
347 | * Change the direction of the animation. It does the opposite effect of setReverse()
348 | * @param {boolean} [forward=true] - true for forward animation, false for backward, default "true"
349 | * @returns {AnimateImages} - plugin instance
350 | */
351 | setForward(forward = true){
352 | this.#settings.reverse = !forward;
353 | return this;
354 | }
355 | /**
356 | * Start preload specified number of images, can be called multiple times.
357 | * If all the images are already loaded, then nothing will happen
358 | * @param {number} number - Number of images to load. If not specified, all remaining images will be loaded.
359 | * @returns {AnimateImages} - plugin instance
360 | */
361 | preloadImages(number= undefined){
362 | number = number ?? this.#settings.images.length;
363 | this.#preloader._startLoading(number);
364 | return this;
365 | }
366 | /**
367 | * Calculate new canvas dimensions. Should be called after the canvas size was changed manually
368 | * Called automatically after page resize
369 | * @returns {AnimateImages} - plugin instance
370 | */
371 | updateCanvas(){
372 | this.#updateCanvasSizes();
373 | return this;
374 | }
375 | /**
376 | * Returns option value
377 | * @param {string} option - Option name. All options are allowed
378 | * @returns {*} - Current option value
379 | */
380 | getOption(option){
381 | if ( option in this.#settings ) {
382 | return this.#settings[option];
383 | } else {
384 | console.warn(`${option} is not a valid option`);
385 | }
386 | }
387 | /**
388 | * Set new option value
389 | * @param {string} option - Option name. Allowed options: fps, loop, reverse, inversion, ratio, fillMode, draggable, dragModifier,
390 | * touchScrollMode, pageScrollTimerDelay, onPreloadFinished, onPosterLoaded, onAnimationEnd, onBeforeFrame, onAfterFrame
391 | * @param {*} value - New value
392 | * @returns {AnimateImages} - plugin instance
393 | */
394 | setOption(option, value) {
395 | const allowedOptions = ['fps', 'loop', 'reverse', 'inversion', 'ratio', 'fillMode', 'draggable', 'dragModifier', 'touchScrollMode',
396 | 'pageScrollTimerDelay', 'onPreloadFinished', 'onFastPreloadFinished', 'onPosterLoaded', 'onAnimationEnd', 'onBeforeFrame', 'onAfterFrame'];
397 | if (allowedOptions.includes(option)) {
398 | this.#settings[option] = value;
399 | if (option === 'fps') this.#animation._updateDuration();
400 | if (option === 'ratio') this.#updateCanvasSizes();
401 | if (option === 'fillMode') this.#updateCanvasSizes();
402 | if (option === 'draggable') this.#toggleDrag(value);
403 | if (option === 'dragModifier') this.#settings.dragModifier = Math.abs(+value);
404 | } else {
405 | console.warn(`${option} is not allowed in setOption`);
406 | }
407 | return this;
408 | }
409 | /** @returns {number} - current frame number */
410 | getCurrentFrame() { return this.#data.currentFrame }
411 | /** @returns {number} - total frames (considering loading errors) */
412 | getTotalImages() { return this.#data.totalImages }
413 | /** @returns {number} - current canvas ratio */
414 | getRatio() { return this.#data.canvas.ratio }
415 | /** @returns {boolean} - animating or not */
416 | isAnimating() { return this.#animation._isAnimating }
417 | /** @returns {boolean} - returns true if a drag action is in progress */
418 | isDragging() {
419 | if ( this.#dragInput ) return this.#dragInput._isSwiping;
420 | return false
421 | }
422 | /** @returns {boolean} - is preload finished */
423 | isPreloadFinished() { return this.#preloader._isPreloadFinished }
424 | /** @returns {boolean} - is fast preview mode preload finished */
425 | isFastPreloadFinished() { return this.#preloader._isFastPreloadFinished }
426 | /** @returns {boolean} - is loaded with errors */
427 | isLoadedWithErrors() { return this.#preloader._isLoadedWithErrors }
428 |
429 | /**
430 | * Stop the animation and return to the first frame
431 | * @returns {AnimateImages} - plugin instance
432 | */
433 | reset(){
434 | if ( this.#preloader._isAnyPreloadFinished ) {
435 | this.stop();
436 | this.#changeFrame(normalizeFrameNumber(1, this.#data.totalImages));
437 | this.#preloader._maybePreloadAll();
438 | } else {
439 | this.#data.deferredAction = this.reset.bind(this);
440 | this.#preloader._startLoading();
441 | }
442 | return this;
443 | }
444 | /**
445 | * Stop animation, remove event listeners and clear the canvas. Method doesn't remove canvas element from the DOM
446 | */
447 | destroy(){
448 | this.stop();
449 | this.#render._clearCanvas();
450 | this.#toggleDrag(false);
451 | this.#toggleResizeHandler(false);
452 | }
453 | }
454 | /**
455 | * NOTE
456 | * All internal classes have public methods and properties start with _, that's for terser plugin that can mangle internal names
457 | * by regexp. It's reducing size by about 20%. Private (#) properties are not used in internal classes because babel use wrapper
458 | * functions for these properties, which increases the size even though private names are minified
459 | */
460 |
461 | /**
462 | * @typedef {Object} PluginOptions
463 | * @property {Array} images - Array with images URLs (required)
464 | * @property {'all'|'partial'|'none'} [preload="all"] - Preload mode ("all", "none", "partial")
465 | * @property {number} [preloadNumber=0] - Number of preloaded images when preload: "partial" , 0 for all
466 | * @property {string} [poster] - Url of a poster image, to show before load
467 | * @property {number} [fps=30] - FPS when playing. Determines the duration of the animation (for example 90 images and 60
468 | * fps = 1.5s, 90 images and 30fps = 3s)
469 | * @property {boolean} [loop=false] - Loop the animation
470 | * @property {boolean} [autoplay=false] - Autoplay
471 | * @property {boolean} [reverse=false] - Reverse direction
472 | * reverse means forward or backward, and inversion determines which direction is forward. Affects animation and drag
473 | * @property {number} [ratio] - Canvas width/height ratio, it has higher priority than inline canvas width and height
474 | * @property {'cover'|'contain'} [fillMode="cover"] - Fill mode to use if canvas and image aspect ratios are different
475 | * ("cover" or "contain")
476 | * @property {boolean} [draggable=false] - Draggable by mouse or touch
477 | * @property {boolean} [inversion=false] - Inversion changes drag direction
478 | * @property {number} [dragModifier=1] - Sensitivity factor for user interaction. Only positive numbers are allowed
479 | * @property {'pageScrollTimer' | 'preventPageScroll' | 'allowPageScroll'} [touchScrollMode = "pageScrollTimer"] - Page
480 | * scroll behavior with touch events (preventPageScroll,allowPageScroll, pageScrollTimer)
481 | * @property {number} [pageScrollTimerDelay=1500] - Time in ms when touch scroll will be disabled during interaction
482 | * if touchScrollMode: "pageScrollTimer"
483 | * @property {'width'|'height'} [responsiveAspect="width"] - Which side will be responsive (controlled by css)
484 | * @property {Object|false} [fastPreview=false] - Special mode for interactivity after loading only a part of the pictures
485 | * @property {Array} [fastPreview.images] - images urls for fastPreview mode (Required if fastPreview is enabled)
486 | * @property {number} [fastPreview.fpsAfter] - fps value that will be applied after the full list of images is loaded
487 | * @property {function(number):number} [fastPreview.matchFrame] - A function that takes the frame number of the short set
488 | * and returns the frame number of the full set, to prevent jump after full load.
489 | * @property {function(AnimateImages):void} [onPreloadFinished] - Occurs when all image files have been loaded
490 | * @property {function(AnimateImages):void} [onFastPreloadFinished] - Occurs when all fastPreview mode images have been loaded
491 | * @property {function(AnimateImages):void} [onPosterLoaded] - Occurs when poster image is fully loaded
492 | * @property {function(AnimateImages):void} [onAnimationEnd] - Occurs when animation has ended
493 | * @property {function(AnimateImages, FrameInfo):void} [onBeforeFrame] - Occurs before new frame
494 | * @property {function(AnimateImages, FrameInfo):void} [onAfterFrame] - Occurs after the frame was drawn
495 | */
496 |
497 | /**
498 | * @typedef {Object} FrameInfo
499 | * @property {CanvasRenderingContext2D} context - canvas context
500 | * @property {number} width - internal canvas width
501 | * @property {number} height - internal canvas height
502 | * */
503 |
--------------------------------------------------------------------------------
/src/Animation.js:
--------------------------------------------------------------------------------
1 | import { eventPrefix } from "./settings";
2 |
3 | export default class Animation{
4 | // Public
5 | _isAnimating;
6 | _framesLeftToPlay; // frames from playTo() and playFrames()
7 |
8 | // Internal
9 | _lastUpdate; // time from RAF
10 | _duration; // time of the full animation sequence
11 | _stopRequested;
12 | _framesQueue; // save decimal part if deltaFrames is not round, to prevent rounding errors
13 | _progressThreshold; // >35% mea`ns that there was a long task in callstack
14 |
15 | constructor( {settings, data, changeFrame} ) {
16 | this._settings = settings;
17 | this._data = data;
18 | this._changeFrame = changeFrame;
19 |
20 | this._stopRequested = false;
21 | this._isAnimating = false;
22 | this._framesQueue = 0;
23 | this._progressThreshold = 0.35;
24 |
25 | this._updateDuration();
26 | }
27 |
28 | _play(){
29 | this._isAnimating = true;
30 | this._stopRequested = false; // fix for the case when stopRequested was set inside getNextFrame that was called outside #animate
31 | if ( !this._data.isAnyFrameChanged ) { // 1st paint, direct call because 1st frame wasn't drawn
32 | this._changeFrame(1);
33 | // subtract 1 manually, because changeFrame is calling not from animate(), but directly
34 | if ( Number.isFinite(this._framesLeftToPlay) ) this._framesLeftToPlay--; // undefined-- = NaN
35 | }
36 |
37 | this._lastUpdate = null;// first 'lastUpdate' should be always set in the first raf of the current animation
38 | requestAnimationFrame(this.#animate.bind(this));
39 | }
40 | _stop(){
41 | const wasAnimating = this._isAnimating;
42 | this._isAnimating = false;
43 | this._framesLeftToPlay = undefined;
44 | if ( wasAnimating ){ // !!! callbacks and events should be called after all the values are reset
45 | this._data.canvas.element.dispatchEvent( new Event(eventPrefix + 'animation-end') );
46 | this._settings.onAnimationEnd(this._data.pluginApi);
47 | }
48 | }
49 |
50 | /**
51 | * Get next frame number, based on current state and settings
52 | * @param {Number} deltaFrames -
53 | * @param {Boolean} reverse
54 | * @returns {number|*}
55 | */
56 | _getNextFrame(deltaFrames, reverse = undefined){
57 | deltaFrames = Math.floor(deltaFrames); //just to be safe
58 | // Handle reverse
59 | if ( reverse === undefined ) reverse = this._settings.reverse;
60 | let newFrameNumber = reverse ? this._data.currentFrame - deltaFrames : this._data.currentFrame + deltaFrames
61 |
62 | // Handle loop
63 | if (this._settings.loop) { // loop and outside of the frames
64 | if (newFrameNumber <= 0) {
65 | // for example newFrame = -2, total = 50, newFrame = 50 - abs(-2) = 48
66 | newFrameNumber = this._data.totalImages - Math.abs(newFrameNumber);
67 | }
68 | else if (newFrameNumber > this._data.totalImages) {
69 | // for example newFrame = 53, total 50, newFrame = newFrame - totalFrames = 53 - 50 = 3
70 | newFrameNumber = newFrameNumber - this._data.totalImages;
71 | }
72 | } else { // no loop and outside of the frames
73 | if (newFrameNumber <= 0) {
74 | newFrameNumber = 1;
75 | this._stopRequested = true;
76 | }
77 | else if (newFrameNumber > this._data.totalImages) {
78 | newFrameNumber = this._data.totalImages;
79 | this._stopRequested = true;
80 | }
81 | }
82 | return newFrameNumber;
83 | }
84 |
85 | // RAF callback
86 | // (chrome) 'timestamp' is timestamp from the moment the RAF callback was queued
87 | // (firefox) 'timestamp' is timestamp from the moment the RAF callback was called
88 | // the difference is equal to the time that the main thread was executing after raf callback was queued
89 | #animate(timestamp){
90 | if ( !this._isAnimating ) return;
91 |
92 | // lastUpdate is setting here because the time between play() and #animate() is unpredictable, and
93 | // lastUpdate = performance.now instead of timestamp because timestamp is unpredictable and depends on the browser.
94 | // Possible frame change in the first raf will always be skipped, because time <= performance.now
95 | if ( ! this._lastUpdate) this._lastUpdate = performance.now();
96 |
97 | let deltaFrames;
98 | // Check if there was a long task between this and the last frame, if so move 1 fixed frame and change lastUpdate to now
99 | // to prevent animation jump. (1,2,3,long task,75,76,77, ... => 1,2,3,long task,4,5,6,...)
100 | // In this case the duration will be longer
101 | let isLongTaskBeforeRaf = (Math.abs(timestamp - performance.now()) / this._duration) > this._progressThreshold; //chrome check
102 | let progress = ( timestamp - this._lastUpdate ) / this._duration; // e.g. 0.01
103 | if ( progress > this._progressThreshold ) isLongTaskBeforeRaf = true; // firefox check
104 |
105 | if (isLongTaskBeforeRaf) deltaFrames = 1; // raf after long task, just move to the next frame
106 | else { // normal execution, calculate progress after the last frame change
107 | if (progress < 0) progress = 0; //it happens sometimes, when raf timestamp is from the past for some reason
108 | deltaFrames = progress * this._data.totalImages; // Frame change step, e.g. 0.45 or 1.25
109 | // e.g. progress is 0.8 frames, queue is 0.25 frames, so now deltaFrames is 1.05 frames and we need to update canvas,
110 | // without this raf intervals will cause cumulative rounding errors, and actual fps will decrease
111 | deltaFrames = deltaFrames + this._framesQueue;
112 | }
113 |
114 | // calculate next frame only when we want to render
115 | // if the getNextFrame check was outside, getNextFrame would be called at screen fps rate, not animation fps
116 | // if screen fps 144 and animation fps 30, getNextFrame is calling now 30/s instead of 144/s.
117 | // After the last frame, raf is repeating until the next frame calculation,
118 | // between the last frame drawing and new frame time, reverse or loop could be changed, and animation won't stop
119 | if ( deltaFrames >= 1) { // Calculate only if we need to update 1 frame or more
120 | const newLastUpdate = isLongTaskBeforeRaf ? performance.now() : timestamp;
121 |
122 | this._framesQueue = deltaFrames % 1; // save decimal part for the next RAFs
123 | deltaFrames = Math.floor(deltaFrames) % this._data.totalImages;
124 | if ( deltaFrames > this._framesLeftToPlay ) deltaFrames = this._framesLeftToPlay;// case when animation fps > device fps
125 | const newFrame = this._getNextFrame( deltaFrames );
126 | if ( this._stopRequested ) { // animation ended from check in getNextFrame()
127 | this._data.pluginApi.stop();
128 | this._stopRequested = false;
129 | if (this._data.pluginApi.getCurrentFrame() !== newFrame ) this._changeFrame(newFrame); //last frame fix if fps > device fps
130 | } else { // animation is on
131 | this._lastUpdate = newLastUpdate;
132 | this._changeFrame(newFrame);
133 | if (typeof this._framesLeftToPlay !== 'undefined') {
134 | this._framesLeftToPlay = this._framesLeftToPlay - deltaFrames;
135 | // if 0 frames left, stop immediately, don't wait for the next frame calculation
136 | // because if isAnimating become true, this will be a new animation
137 | if ( this._framesLeftToPlay <= 0 ) this._data.pluginApi.stop();
138 | }
139 | }
140 | }
141 | if ( this._isAnimating ) requestAnimationFrame(this.#animate.bind(this));
142 | }
143 |
144 | /**
145 | * Recalculate animation duration after fps or totalImages change
146 | */
147 | _updateDuration(){
148 | this._duration = this._data.totalImages / this._settings.fps * 1000;
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/DragInput.js:
--------------------------------------------------------------------------------
1 | import { eventPrefix } from "./settings";
2 |
3 | export default class DragInput{
4 | // Public
5 | _isSwiping = false;
6 |
7 | // Internal
8 | _curX;
9 | _curY;
10 | _prevX;
11 | _prevY;
12 | _threshold;
13 | _pixelsCorrection;
14 | _lastInteractionTime;
15 | _prevDirection;
16 |
17 | constructor({ data, settings, changeFrame, getNextFrame }) {
18 | this._data = data;
19 | this._settings = settings;
20 | this._changeFrame = changeFrame;
21 | this._getNextFrame = getNextFrame;
22 |
23 | this._SWIPE_EVENTS = ['mousedown', 'mousemove', 'mouseup', 'touchstart', 'touchmove', 'touchend', 'touchcancel'];
24 | this._isSwiping = false;
25 | this._boundSwipeHandler = this.#swipeHandler.bind(this);
26 | this._pixelsCorrection = 0;
27 |
28 | this._updateThreshold();
29 | }
30 |
31 | /**
32 | * Enable rotating by mouse or touch drag
33 | */
34 | _enableDrag(){
35 | this._SWIPE_EVENTS.forEach( (value) => {
36 | this._data.canvas.element.addEventListener(value, this._boundSwipeHandler);
37 | })
38 | }
39 |
40 | /**
41 | * Disable rotating by mouse or touch drag
42 | */
43 | _disableDrag(){
44 | this._SWIPE_EVENTS.forEach( (value) => {
45 | this._data.canvas.element.removeEventListener(value, this._boundSwipeHandler);
46 | })
47 | // if disabling while swipeMove is running
48 | document.removeEventListener('mouseup', this._boundSwipeHandler);
49 | document.removeEventListener('mousemove', this._boundSwipeHandler);
50 | this._data.canvas.element.style.cursor = null;
51 | }
52 |
53 | /**
54 | * Update one frame threshold in pixels
55 | * @param newValue
56 | */
57 | _updateThreshold(newValue = null){
58 | if (newValue) {
59 | this._threshold = newValue;
60 | }
61 | else {
62 | this._threshold = this._data.canvas.element.clientWidth / this._data.totalImages;
63 | }
64 | }
65 |
66 |
67 | #swipeHandler(event) {
68 | // get current click/touch point
69 | let touches;
70 | if ( event.touches !== undefined && event.touches.length ) touches = event.touches;
71 | this._curX = (touches) ? touches[0].pageX : event.clientX;
72 | this._curY = (touches) ? touches[0].pageY : event.clientY;
73 |
74 | switch (event.type){
75 | case 'mousedown': // start
76 | case 'touchstart':
77 | if ( event.type === 'touchstart' && event.cancelable ) {
78 | //event.preventDefault();
79 | this.#maybeDisableScroll(event);
80 | }
81 | document.addEventListener('mouseup', this._boundSwipeHandler); // move outside of the canvas
82 | document.addEventListener('mousemove', this._boundSwipeHandler);
83 | this.#swipeStart();
84 | break;
85 | case 'mousemove':
86 | case 'touchmove': //move
87 | // ignore mousemove without move (to prevent fake "left" movement)
88 | const wasMoved = (this._prevX !== this._curX && this._prevY !== this._curX);
89 | if ( this._isSwiping && wasMoved) {
90 | //if ( event.type === 'touchmove' && event.cancelable) event.preventDefault();
91 | this.#swipeMove();
92 | }
93 | break;
94 | case 'mouseup':
95 | case 'touchend':
96 | case 'touchcancel': // end
97 | //if ( (event.type === 'touchend' || event.type === 'touchcancel') && event.cancelable) event.preventDefault();
98 | if ( this._isSwiping ) {
99 | document.removeEventListener('mouseup', this._boundSwipeHandler);
100 | document.removeEventListener('mousemove', this._boundSwipeHandler);
101 | this.#swipeEnd();
102 | }
103 | break;
104 | }
105 | }
106 | #swipeStart(){
107 | const plugin = this._data.pluginApi;
108 | if ( !(plugin.isFastPreloadFinished() || plugin.isPreloadFinished()) ) return;
109 | // trigger full load after user interaction after fast preload finished
110 | if (this._settings.fastPreview && !plugin.isPreloadFinished() && plugin.isFastPreloadFinished()) {
111 | plugin.preloadImages();
112 | }
113 | plugin.stop();
114 | this._isSwiping = true;
115 | this._data.canvas.element.style.cursor = 'grabbing';
116 | this._prevX = this._curX;
117 | this._prevY = this._curY;
118 | this._data.canvas.element.dispatchEvent( new CustomEvent(eventPrefix + 'drag-start',
119 | { detail: {frame: this._data.currentFrame} })
120 | );
121 | }
122 | #swipeMove(){
123 | const direction = this.#swipeDirection();
124 | if (this._prevDirection && this._prevDirection !== direction) { // reset after direction change
125 | this._pixelsCorrection = 0;
126 | }
127 | this._prevDirection = direction;
128 |
129 | const pixelDiffX = Math.abs(this._curX - this._prevX ); // save x diff before update
130 | const swipeLength = (pixelDiffX + this._pixelsCorrection) * this._settings.dragModifier ;
131 |
132 | this._prevX = this._curX; // update before any returns
133 | this._prevY = this._curY; // update Y to prevent wrong angle after many vertical moves
134 |
135 |
136 | if ( (direction !== 'left' && direction !== 'right') || // Ignore vertical directions
137 | (swipeLength < this._threshold) ) { // Ignore if less than 1 frame
138 | this._pixelsCorrection += pixelDiffX; // skip this mousemove, but save horizontal movement
139 | return;
140 | }
141 |
142 |
143 | const progress = swipeLength / this._data.canvas.element.clientWidth; // full width swipe means full length animation
144 | let deltaFrames = Math.floor(progress * this._data.totalImages);
145 | deltaFrames = deltaFrames % this._data.totalImages;
146 | // Add pixels to the next swipeMove if frames equivalent of swipe is not an integer number,
147 | // e.g one frame is 10px, swipeLength is 13px, we change 1 frame and add 3px to the next swipe,
148 | // so fullwidth swipe is always rotate sprite for 1 turn (with 'dragModifier' = 1).
149 | // I divide the whole value by dragModifier because it seems to work as it should
150 | this._pixelsCorrection = (swipeLength - (this._threshold * deltaFrames)) / this._settings.dragModifier;
151 | let isReverse = (direction === 'left'); // left means backward (reverse: true)
152 | if (this._settings.inversion) isReverse = !isReverse;// invert direction
153 | this._changeFrame(this._getNextFrame( deltaFrames, isReverse )); // left means backward (reverse: true)
154 | this._data.canvas.element.dispatchEvent( new CustomEvent(eventPrefix + 'drag-change',
155 | { detail: {
156 | frame: this._data.currentFrame,
157 | direction,
158 | } })
159 | );
160 | }
161 | #swipeEnd(){
162 | //if ( swipeObject.curX === undefined ) return; // there is no x coord on touch end
163 | this._curX = this._curY = this._prevX = this._prevY = null;
164 | this._isSwiping = false;
165 | this._data.canvas.element.style.cursor = null;
166 | this._lastInteractionTime = new Date().getTime();
167 | this._data.canvas.element.dispatchEvent( new CustomEvent(eventPrefix + 'drag-end',
168 | { detail: {
169 | frame: this._data.currentFrame,
170 | direction: this._prevDirection,
171 | } })
172 | );
173 | }
174 | #swipeDirection(){
175 | let r, swipeAngle,
176 | xDist = this._prevX - this._curX,
177 | yDist = this._prevY - this._curY;
178 |
179 | // taken from slick.js
180 | r = Math.atan2(yDist, xDist);
181 | swipeAngle = Math.round(r * 180 / Math.PI);
182 | if (swipeAngle < 0) swipeAngle = 360 - Math.abs(swipeAngle);
183 |
184 | if ( (swipeAngle >= 0 && swipeAngle <= 60) || (swipeAngle <= 360 && swipeAngle >= 300 )) return 'left';
185 | else if ( swipeAngle >= 120 && swipeAngle <= 240 ) return 'right';
186 | else if ( swipeAngle >= 241 && swipeAngle <= 299 ) return 'bottom';
187 | else return 'up';
188 | }
189 |
190 | /**
191 | * Idea from https://github.com/giniedp/spritespin/blob/master/src/plugins/input-drag.ts#L45
192 | * @param {Event} event
193 | */
194 | #maybeDisableScroll(event){
195 | // always prevent
196 | if (this._settings.touchScrollMode === "preventPageScroll") event.preventDefault();
197 | // check timer
198 | if (this._settings.touchScrollMode === "pageScrollTimer") {
199 | const now = new Date().getTime();
200 | // less time than delay => prevent page scroll
201 | if (this._lastInteractionTime && (now - this._lastInteractionTime < this._settings.pageScrollTimerDelay) ){
202 | event.preventDefault();
203 | } else { // more time than delay or first interaction => clear timer
204 | this._lastInteractionTime = null;
205 | }
206 | }
207 | // if touchScrollMode="allowPageScroll" => don't prevent scroll
208 | }
209 | }
210 |
--------------------------------------------------------------------------------
/src/ImagePreloader.js:
--------------------------------------------------------------------------------
1 | import { eventPrefix } from "./settings";
2 |
3 | export default class ImagePreloader{
4 |
5 | constructor( {settings, data, updateImagesCount, getFramesLeft} ) {
6 | this._settings = settings;
7 | this._data = data;
8 | this._updateImagesCount = updateImagesCount;
9 | this._getFramesLeft = getFramesLeft;
10 |
11 | // Public
12 | this._isPreloadFinished = false;// onload on all the images
13 | this._isFastPreloadFinished = false;// images from fastPreload mode
14 | this._isAnyPreloadFinished = false;
15 | this._isLoadedWithErrors = false;
16 |
17 | // Internal
18 | this._preloadOffset = 0;// images already in queue
19 | this._preloadedCount = 0;// count of loaded images
20 | this._tempImagesArray = []; // store images before they are fully loaded
21 | this._failedImages = [];
22 | this._currentMode = "default";// "default" or "fast"
23 | this._modes = {
24 | default: {
25 | images: this._settings.images,
26 | event: eventPrefix + "preload-finished",
27 | callback: this._settings.onPreloadFinished,
28 | },
29 | fast: {
30 | images: this._settings?.fastPreview.images,
31 | event: eventPrefix + "fast-preload-finished",
32 | callback: this._settings.onFastPreloadFinished,
33 | }
34 | }
35 |
36 | // set mode if fast preview
37 | if (this._settings.fastPreview) {
38 | if ( !this._settings.fastPreview.images ) {
39 | throw new TypeError('fastPreview.images is required when fastPreview is enabled');
40 | }
41 | this._currentMode = "fast";
42 | this._data.totalImages = this._settings.fastPreview.images.length;
43 | }
44 | this._totalImages = this._data.totalImages; // get initial value for the first time, update when fast => default mode
45 | }
46 |
47 | /**
48 | * Add number of images to loading queue
49 | * @param {number} [preloadNumber] - number of images to load
50 | */
51 | _startLoading(preloadNumber){
52 | if (this._isPreloadFinished) return;
53 | if ( !preloadNumber ) preloadNumber = this._totalImages;
54 | preloadNumber = Math.round(preloadNumber);
55 |
56 | // if too many, load just the rest
57 | const unloadedCount = this._totalImages - this._preloadOffset;
58 | if (preloadNumber > unloadedCount){
59 | preloadNumber = unloadedCount;
60 | }
61 |
62 | // true when all the images are in queue but not loaded yet, (unloadedCount = preloadNumber = 0)
63 | if (preloadNumber <= 0) return;
64 |
65 | //console.log(`start loop, preloadNumber=${preloadNumber}, offset=${this._preloadOffset}`);
66 | for (let i = this._preloadOffset; i < (preloadNumber + this._preloadOffset); i++){
67 | let img = new Image();
68 | img.onload = img.onerror = this.#onImageLoad.bind(this);
69 | img.src = this._modes[this._currentMode].images[i]
70 | this._tempImagesArray[i] = img;
71 | }
72 | this._preloadOffset = this._preloadOffset + preloadNumber;
73 | }
74 |
75 | #onImageLoad(e){
76 | this._preloadedCount++;
77 | const progress = Math.floor((this._preloadedCount/this._totalImages) * 1000) / 1000 ;
78 | this._data.canvas.element.dispatchEvent( new CustomEvent(eventPrefix + 'loading-progress', {detail: {progress}}) );
79 | if (e.type === "error") {
80 | this._isLoadedWithErrors = true;
81 | const path = e.path || (e.composedPath && e.composedPath());
82 | this._failedImages.push(path[0]);
83 | this._data.canvas.element.dispatchEvent( new Event(eventPrefix + 'loading-error') );
84 | }
85 | if (this._preloadedCount >= this._totalImages) {
86 | if ( this._isLoadedWithErrors ) this.#clearImagesArray();
87 | this.#afterPreloadFinishes();
88 | }
89 | }
90 |
91 | /**
92 | * Remove failed images from array
93 | */
94 | #clearImagesArray(){
95 | if ( this._failedImages.length < 1) return;
96 | this._tempImagesArray = this._tempImagesArray.filter((el) => {
97 | return !this._failedImages.includes(el);
98 | });
99 | }
100 |
101 | #afterPreloadFinishes(){ // check what to do next
102 | if (this._currentMode === "default"){
103 | this._isPreloadFinished = true;
104 | } else {
105 | this._isFastPreloadFinished = true;
106 | }
107 | this._isAnyPreloadFinished = true; // variable for checks from main plugin
108 | this._data.loadedImagesArray = [...this._tempImagesArray];
109 | this._data.totalImages = this._tempImagesArray.length;
110 | this._updateImagesCount();
111 |
112 | // we should call deferredAction and callback after "setFrame" inside next "if", because setFrame will replace
113 | // these actions, so save current mode before it will be changed inside "if", and use for deferredAction and callback
114 | const savedMode = this._currentMode;
115 | const plugin = this._data.pluginApi;
116 | // code below executes only if fastPreview is set
117 | if ( this._currentMode === "fast" ) { // fast preload has ended
118 | this._currentMode = "default";
119 | this._tempImagesArray = [];
120 | this._preloadOffset = this._preloadedCount = 0;
121 | this._totalImages = this._settings.images.length; // update for default preload mode
122 | // start preload full list if we have action, that started after fast preload end
123 | if ( this._data.deferredAction ) this._startLoading();
124 | } else if ( this._currentMode === "default" && this._settings.fastPreview ) { // default preload has ended (only after fast),
125 | // replace small sequence with full and change frame
126 | if (this._settings?.fastPreview.fpsAfter) plugin.setOption("fps", this._settings?.fastPreview.fpsAfter)
127 | const wasAnimating = plugin.isAnimating();
128 | const framesAreInQueue = typeof this._getFramesLeft() !== 'undefined'; // true if playTo or playFrames is active
129 | const matchFrame = this._settings?.fastPreview.matchFrame;
130 | plugin.setFrame( matchFrame ? matchFrame(this._data.currentFrame) : 1 );
131 | // play() => continue, playTo() or playFrames() => stop, because it is impossible
132 | // to calculate new target frame from _framesLeftToPlay
133 | //https://github.com/its2easy/animate-images/issues/7#issuecomment-1210624687
134 | if ( wasAnimating && !framesAreInQueue ) plugin.play();
135 | }
136 |
137 | // actions and callbacks
138 | if (this._data.deferredAction) {
139 | this._data.deferredAction();
140 | // clear to prevent from being called twice when action was queued before the end of fastPreview preload
141 | this._data.deferredAction = null;
142 | }
143 | this._data.canvas.element.dispatchEvent( new Event(this._modes[savedMode].event) );
144 | this._modes[savedMode].callback(plugin);
145 |
146 | }
147 |
148 | // Case when fast preload had ended, but we don't have deferred action, because action started with preview frames,
149 | // this is possible only with preload="all"; or with any preload after plugin.preloadImages() before any action,
150 | // and we have to start full preload here.
151 | // This function is called only after frame change was requested.
152 | _maybePreloadAll(){
153 | if (this._settings.fastPreview && !this._isPreloadFinished) this._startLoading();
154 | }
155 |
156 | }
157 |
--------------------------------------------------------------------------------
/src/Poster.js:
--------------------------------------------------------------------------------
1 | import { eventPrefix } from "./settings";
2 |
3 | export default class Poster{
4 | // Internal
5 | _imageObject;
6 | _isPosterLoaded;
7 |
8 | constructor({settings, data, drawFrame}) {
9 | this._settings = settings;
10 | this._data = data;
11 | this._drawFrame = drawFrame;
12 |
13 | this._isPosterLoaded = false;
14 | }
15 |
16 | /**
17 | * Start loading poster, then show if needed
18 | */
19 | _loadAndShowPoster(){
20 | if (this._settings.poster && !this._data.isAnyFrameChanged) {
21 | this._imageObject = new Image();
22 | this._imageObject.onload = this._imageObject.onerror = this.#onPosterLoaded.bind(this);
23 | this._imageObject.src = this._settings.poster;
24 | }
25 | }
26 |
27 | /**
28 | * Redraw poster after canvas change if the poster was loaded
29 | */
30 | _redrawPoster(){
31 | if ( this._data.isAnyFrameChanged || !this._isPosterLoaded ) return;
32 | this.#drawPoster();
33 | }
34 |
35 | #onPosterLoaded(e){
36 | if (e.type === "error") return;
37 | this._isPosterLoaded = true;
38 | this._data.canvas.element.dispatchEvent( new Event(eventPrefix + 'poster-loaded') );
39 | this._settings.onPosterLoaded(this._data.pluginApi);
40 | // show only if there wasn't any frame change from initial
41 | // if poster loaded after all the images and any action, it won't be shown
42 | if ( !this._data.isAnyFrameChanged ) {
43 | this.#drawPoster();
44 | }
45 | }
46 |
47 | #drawPoster(){
48 | this._drawFrame(this._imageObject);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Render.js:
--------------------------------------------------------------------------------
1 | export default class Render{
2 |
3 | constructor( {settings, data} ) {
4 | this._settings = settings;
5 | this._data = data;
6 | /** @type CanvasRenderingContext2D */
7 | this._context = this._data.canvas.element.getContext("2d");
8 | }
9 |
10 | /**
11 | * @param {HTMLImageElement} imageObject - image object
12 | */
13 | _drawFrame(imageObject){
14 | //this._context.imageSmoothingEnabled = false; // may reduce blurriness, but could make the image worse (resets to true after resize)
15 |
16 | let sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight;
17 | if (this._settings.fillMode === "cover") {
18 | ( {sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight} = this.#getDrawImageCoverProps(imageObject) )
19 | } else if ( this._settings.fillMode === "contain" ) {
20 | ( {sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight} = this.#getDrawImageContainProps(imageObject) )
21 | }
22 |
23 | //console.log(`sx= ${sx}, sy=${sy}, sWidth=${sWidth}, sHeight=${sHeight}, dx=${dx}, dy=${dy}, dWidth=${dWidth}, dHeight=${dHeight}`);
24 | const canvasEl = this._data.canvas.element;
25 | this._settings.onBeforeFrame(this._data.pluginApi,
26 | {context: this._context, width: canvasEl.width, height: canvasEl.height});
27 |
28 | this._context.drawImage(imageObject, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
29 |
30 | this._settings.onAfterFrame(this._data.pluginApi,
31 | {context: this._context, width: canvasEl.width, height: canvasEl.height});
32 | }
33 |
34 | _clearCanvas(){
35 | const canvasEl = this._data.canvas.element;
36 | this._context.clearRect(0, 0, canvasEl.width, canvasEl.height);
37 | }
38 |
39 | #getDrawImageCoverProps(image){
40 | //https://stackoverflow.com/questions/21961839/simulation-background-size-cover-in-canvas
41 | let dx = 0,
42 | dy = 0,
43 | canvasWidth = this._data.canvas.element.width,
44 | canvasHeight = this._data.canvas.element.height,
45 | imageWidth = image.naturalWidth,
46 | imageHeight = image.naturalHeight,
47 | offsetX = 0.5,
48 | offsetY = 0.5,
49 | minRatio = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight),
50 | newWidth = imageWidth * minRatio, // new prop. width
51 | newHeight = imageHeight * minRatio, // new prop. height
52 | sx, sy, sWidth, sHeight, ar = 1;
53 |
54 | // decide which gap to fill
55 | if (newWidth < canvasWidth) ar = canvasWidth / newWidth;
56 | if (Math.abs(ar - 1) < 1e-14 && newHeight < canvasHeight) ar = canvasHeight / newHeight; // updated
57 | newWidth *= ar;
58 | newHeight *= ar;
59 |
60 | // calc source rectangle
61 | sWidth = imageWidth / (newWidth / canvasWidth);
62 | sHeight = imageHeight / (newHeight / canvasHeight);
63 |
64 | sx = (imageWidth - sWidth) * offsetX;
65 | sy = (imageHeight - sHeight) * offsetY;
66 |
67 | // make sure source rectangle is valid
68 | if (sx < 0) sx = 0;
69 | if (sy < 0) sy = 0;
70 | if (sWidth > imageWidth) sWidth = imageWidth;
71 | if (sHeight > imageHeight) sHeight = imageHeight;
72 |
73 | return { sx, sy, sWidth, sHeight, dx, dy, dWidth: canvasWidth, dHeight: canvasHeight };
74 | }
75 | #getDrawImageContainProps(image){
76 | let canvasWidth = this._data.canvas.element.width,
77 | canvasHeight = this._data.canvas.element.height,
78 | imageWidth = image.naturalWidth,
79 | imageHeight = image.naturalHeight,
80 | sx = 0,
81 | sy = 0,
82 | sWidth = imageWidth,
83 | sHeight = imageHeight,
84 | dx,
85 | dy,
86 | offsetX = 0.5,
87 | offsetY = 0.5,
88 | ratioX = canvasWidth / imageWidth,
89 | ratioY = canvasHeight / imageHeight,
90 | minRation = Math.min(ratioX, ratioY),
91 | newWidth = imageWidth * minRation,
92 | newHeight = imageHeight * minRation;
93 |
94 | dx = (canvasWidth - newWidth) * offsetX;
95 | dy = (canvasHeight - newHeight) * offsetY;
96 |
97 | return { sx, sy, sWidth, sHeight, dx, dy, dWidth: newWidth, dHeight: newHeight};
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './AnimateImages';
2 |
--------------------------------------------------------------------------------
/src/settings.js:
--------------------------------------------------------------------------------
1 | export function validateInitParameters(node, options){
2 | if ( !(node instanceof HTMLCanvasElement) ) { // Check dom node
3 | throw new TypeError('node is required and should be canvas element');
4 | }
5 | if (!options.images || !Array.isArray(options.images) || options.images.length <= 1 ) { // Check images list
6 | throw new TypeError('options.images is required and must be an array with more than 1 element');
7 | }
8 | // if ( ("preload" in options) && // Check preload type
9 | // (
10 | // !(typeof options.preload === "string")
11 | // || !(options.preload === "all" || options.preload === "none" || options.preload === "partial")
12 | // )
13 | // ) {
14 | // throw new TypeError('options.preload must be one of these: all, none, partial');
15 | // }
16 | // if ( ("preloadNumber" in options)
17 | // && !( Number.isInteger(Number.parseInt(options.preloadNumber)) && Number.parseInt(options.preloadNumber) >= 0 )
18 | // ) {
19 | // throw new TypeError('options.preloadNumber must be number >= 0');
20 | // }
21 | if ('preloadNumber' in options) options.preloadNumber = Number.parseInt(options.preloadNumber); // Allow number as a string
22 | if ("fillMode" in options && !['cover', 'contain'].includes(options.fillMode)) delete options['fillMode'];
23 | if ('dragModifier' in options) options.dragModifier = Math.abs(+options.dragModifier);
24 | }
25 |
26 | export const defaultSettings = {
27 | preload: "all",
28 | preloadNumber: 0,
29 | poster: false,
30 | fps: 30,
31 | loop: false,
32 | autoplay: false,
33 | reverse: false,
34 | ratio: undefined,
35 | fillMode: "cover",
36 |
37 | draggable: false,
38 | inversion: false,
39 | dragModifier: 1,
40 | touchScrollMode: "pageScrollTimer",
41 | pageScrollTimerDelay: 1500,
42 | responsiveAspect: "width",
43 |
44 | fastPreview: false,
45 |
46 | onFastPreloadFinished: noOp,
47 | onPreloadFinished: noOp,
48 | onPosterLoaded: noOp,
49 | onAnimationEnd: noOp,
50 | onBeforeFrame: noOp,
51 | onAfterFrame: noOp,
52 | }
53 |
54 | export const eventPrefix = "animate-images:";
55 |
56 | function noOp(){}
57 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | export function normalizeFrameNumber(frameNumber, totalImages){
2 | frameNumber = Math.floor(frameNumber);
3 | if (frameNumber <= 0) {
4 | return 1;
5 | } else if (frameNumber > totalImages) {
6 | return totalImages;
7 | }
8 | return frameNumber;
9 | }
10 |
11 | export function calculateFullAnimationDuration(imagesNumber, fps){
12 | return imagesNumber / fps * 1000;
13 | }
14 |
15 | export function uppercaseFirstChar(word){
16 | return word.charAt(0).toUpperCase() + word.slice(1);
17 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // Entrypoint, will check all the imports
3 | "files": ["src/index.js"],
4 | "compilerOptions": {
5 | // Tells TypeScript to read JS files, as
6 | // normally they are ignored as source files
7 | "allowJs": true,
8 | // Generate d.ts files
9 | "declaration": true,
10 | // This compiler run should
11 | // only output d.ts files
12 | "emitDeclarationOnly": true,
13 | // Types should go into this directory.
14 | // Removing this would place the .d.ts files
15 | // next to the .js files
16 | "outDir": "types",
17 | // go to js file when using IDE functions like
18 | // "Go to Definition" in VSCode (won't work in npm package without source)
19 | "declarationMap": false,
20 | // tsc doesn't work without it
21 | "skipLibCheck": true,
22 | "module": "ES6",
23 | "moduleResolution": "Node",
24 | "target": "ES6"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------