├── .npmignore ├── .babelrc ├── .gitignore ├── .travis.yml ├── rollup.config.js ├── src ├── index.js ├── util.js ├── run-frames.js ├── call-fn.js ├── transform-helpers.js └── Transformer.js ├── package.json ├── test ├── transform.spec.js ├── index.html ├── transformObj.spec.js └── transformer.spec.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | *.log 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '7' 4 | - '6' -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | 3 | export default { 4 | entry: 'src/index.js', 5 | dest: 'dist/transform-when.js', 6 | sourceMap: true, 7 | plugins: [ 8 | babel({ 9 | presets: ['es2015-rollup'], 10 | babelrc: false 11 | }) 12 | ], 13 | format: 'umd', 14 | moduleName: 'Transformer' 15 | }; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Hello! Best place to start is probably run-frames.js and Transformer.js. 3 | */ 4 | 5 | import Transformer from './Transformer'; 6 | import * as helpers from './transform-helpers'; 7 | 8 | import './run-frames'; 9 | 10 | Transformer.transform = helpers.transform; 11 | Transformer.easings = helpers.easings; 12 | Transformer.transformObj = helpers.transformObj; 13 | 14 | export default Transformer; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "transform-when", 3 | "version": "1.2.0", 4 | "description": "A library for handing animations combining page position and time", 5 | "main": "dist/transform-when.js", 6 | "esnext:main": "src/index.js", 7 | "scripts": { 8 | "prepublish": "npm run build", 9 | "build": "rollup -c", 10 | "test": "npm run build && mocha-phantomjs /test/index.html" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/samknows/transform-when.git" 15 | }, 16 | "author": "Callum Macrae ", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/samknows/transform-when/issues" 20 | }, 21 | "homepage": "https://github.com/samknows/transform-when#readme", 22 | "devDependencies": { 23 | "babel-cli": "^6.18.0", 24 | "babel-polyfill": "^6.20.0", 25 | "babel-preset-es2015": "^6.18.0", 26 | "babel-preset-es2015-rollup": "^3.0.0", 27 | "mocha": "^3.2.0", 28 | "mocha-phantomjs": "^4.1.0", 29 | "rollup": "^0.40.0", 30 | "rollup-plugin-babel": "^2.7.1", 31 | "should": "^11.1.2" 32 | }, 33 | "dependencies": { 34 | "phantomjs": "^2.1.7" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/transform.spec.js: -------------------------------------------------------------------------------- 1 | describe('Transformer.transform()', function () { 2 | it('should exist', function () { 3 | Should(Transformer.transform).be.a.Function(); 4 | }); 5 | 6 | it('should transform numbers', function () { 7 | Transformer.transform([400, 600], [1, 0], 400).should.equal(1); 8 | Transformer.transform([400, 600], [1, 0], 500).should.equal(0.5); 9 | Transformer.transform([400, 600], [1, 0], 600).should.equal(0); 10 | }); 11 | 12 | it('should transform numbers out of the bounds', function () { 13 | Transformer.transform([400, 600], [1, 0], 300).should.equal(1); 14 | Transformer.transform([400, 600], [1, 0], 700).should.equal(0); 15 | }); 16 | 17 | it('should transform numbers out of the bounds when fixed=false', function () { 18 | Transformer.transform([400, 600], [1, 0], 300, false).should.equal(1.5); 19 | Transformer.transform([400, 600], [1, 0], 400, false).should.equal(1); 20 | Transformer.transform([400, 600], [1, 0], 500, false).should.equal(0.5); 21 | Transformer.transform([400, 600], [1, 0], 600, false).should.equal(0); 22 | Transformer.transform([400, 600], [1, 0], 700, false).should.equal(-0.5); 23 | }); 24 | 25 | it('should return a function when value not specified', function () { 26 | var transformFn = Transformer.transform([400, 600], [1, 0]); 27 | 28 | Should(transformFn).be.a.Function(); 29 | 30 | transformFn(400).should.equal(1); 31 | transformFn(500).should.equal(0.5); 32 | transformFn(600).should.equal(0); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | transform-when tests 6 | 7 | 8 | 9 | 19 | 20 | 21 | 22 |
23 | 24 |
25 |
26 |
27 | 28 | 29 | 30 | 31 | 32 |
33 |
34 |
35 |
36 | 37 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Loops through one or more elements. 3 | * 4 | * @param {Element|NodeList} els An element or a NodeList. 5 | * @param {function} cb Function to call with the element. 6 | */ 7 | export function each(els, cb) { 8 | if (els instanceof Element) { 9 | cb(els); 10 | } else { 11 | [].slice.call(els).forEach(cb); 12 | } 13 | } 14 | 15 | /** 16 | * Returns true if transform attribute should be used instead of CSS. 17 | * 18 | * @param {Element} el Element to test. 19 | * @returns {boolean} True if transform attribute should be used. 20 | */ 21 | export function useTransformAttr(el) { 22 | return (el instanceof SVGElement && el.tagName.toUpperCase() !== 'SVG'); 23 | } 24 | 25 | // Used by setData and getData. 26 | const dataStore = []; 27 | 28 | /** 29 | * Store data against a given element. IE11 doesn't support dataset on SVG 30 | * elements, so this is required. It's probably also more performant. 31 | * 32 | * @param {Element} el Element to store data against. 33 | * @param {string} key Key to store data against. 34 | * @param {*} value Value to store. 35 | */ 36 | export function setData(el, key, value) { 37 | let elStore = dataStore.find((store) => store.el === el); 38 | 39 | if (!elStore) { 40 | dataStore.push({ 41 | el, 42 | data: {}, 43 | }); 44 | 45 | elStore = dataStore[dataStore.length - 1]; 46 | } 47 | 48 | elStore.data[key] = value; 49 | } 50 | 51 | /** 52 | * Get data previously stored against a given element. 53 | * 54 | * @param {Element} el Element to store data against. 55 | * @param {string} key Key to store data against. 56 | * @returns {*} The stored data. 57 | */ 58 | export function getData(el, key) { 59 | let elStore = dataStore.find((store) => store.el === el); 60 | 61 | if (!elStore) { 62 | return undefined; 63 | } 64 | 65 | return elStore.data[key]; 66 | } 67 | 68 | // Used internally by the smart arguments logic 69 | export const UNCHANGED = Symbol('unchanged'); -------------------------------------------------------------------------------- /src/run-frames.js: -------------------------------------------------------------------------------- 1 | import { transformers } from './Transformer'; 2 | 3 | // Cache element lookups here so that we don't have to look them up at 60 fps 4 | const elements = {}; 5 | 6 | /** 7 | * This function powers transform-when. It is called on requestAnimationFrame, 8 | * and calls the transform functions. 9 | */ 10 | function runFrames() { 11 | const scrollPositions = { 12 | window: { 13 | x: typeof window.scrollX === 'number' ? window.scrollX : window.pageXOffset, 14 | y: typeof window.scrollY === 'number' ? window.scrollY : window.pageYOffset, 15 | }, 16 | }; 17 | 18 | // Two loops: the first runs "setup", which calculates all the values to set 19 | for (let transform of transformers) { 20 | if (!scrollPositions[transform.scrollElement]) { 21 | if (!elements[transform.scrollElement]) { 22 | elements[transform.scrollElement] = document.querySelector(transform.scrollElement); 23 | } 24 | 25 | scrollPositions[transform.scrollElement] = { 26 | x: elements[transform.scrollElement].scrollLeft, 27 | y: elements[transform.scrollElement].scrollTop, 28 | }; 29 | } 30 | 31 | const position = scrollPositions[transform.scrollElement]; 32 | 33 | const vars = { x: position.x, y: position.y }; 34 | Object.keys(transform._customVariables).forEach((varName) => { 35 | vars[varName] = transform._customVariables[varName].call(transform); 36 | }); 37 | 38 | // This is ugly and I feel bad 39 | transform._tmpVarCache = vars; 40 | 41 | try { 42 | transform._setup(vars); 43 | } catch(e) { 44 | console.error('Problem during setup', e); 45 | } 46 | } 47 | 48 | // The second loop calls "frame", which sets all the previously calculated values 49 | // It's done in two loops to avoid layout thrashing 50 | for (let transform of transformers) { 51 | try { 52 | transform._frame(transform._tmpVarCache); 53 | } catch (e) { 54 | console.error('Problem during frame', e); 55 | } 56 | } 57 | 58 | requestAnimationFrame(runFrames); 59 | } 60 | requestAnimationFrame(runFrames); -------------------------------------------------------------------------------- /src/call-fn.js: -------------------------------------------------------------------------------- 1 | import { UNCHANGED } from './util'; 2 | 3 | // An object containing common useful roundings 4 | export const dps = { 5 | 'transforms:rotate': 1, 6 | 'transforms:scale': 3, 7 | 'transforms:translate': 1, 8 | 'styles:opacity': 2 9 | }; 10 | 11 | /** 12 | * A utility function to call the transform function of a transformer. Handles 13 | * stuff like smart arguments, and calculating whether to call the function in 14 | * the first place. 15 | * 16 | * This is a utility function, but it's long enough for its own file. 17 | */ 18 | export default function callFn(type, name, fn, transform, unit, args) { 19 | let isFirstCall = !fn.args; 20 | 21 | if (isFirstCall) { 22 | // Smart arguments: calculate the arguments to be passed to the transform 23 | // function. Either an array (`['x', 'y', ()=>{}}`) or a fn (`(x, y)=>{}`). 24 | if (typeof fn === 'function') { 25 | fn.args = fn.toString().match(/\((.*?)\)/)[1].split(',').map((str) => str.trim()); 26 | } else { 27 | fn.args = fn.slice(0, -1); 28 | } 29 | } 30 | 31 | let changed = isFirstCall; 32 | 33 | if (fn.args.includes('i')) { 34 | changed = true; 35 | } 36 | 37 | if (fn.args.includes('actions') && (Object.keys(args.actions).length || args.actionEnded)) { 38 | changed = true; 39 | } 40 | 41 | if (!changed) { 42 | changed = Object.keys(args.last).some((arg) => { 43 | return fn.args.includes(arg) && args[arg] !== args.last[arg]; 44 | }); 45 | } 46 | 47 | // If the arguments haven't changed, don't call the function because the 48 | // value won't have changed. This assumes that functions are pure: request 49 | // the `i` argument if it isn't. 50 | if (!changed) { 51 | return UNCHANGED; 52 | } 53 | 54 | const argsForFn = fn.args.map((arg) => args[arg]); 55 | const callableFn = typeof fn === 'function' ? fn : fn[fn.length - 1]; 56 | let val = callableFn.apply(transform, argsForFn); 57 | 58 | // Round returned value for extra performance 59 | if (typeof val === 'number') { 60 | let roundTo = dps[type + ':' + (Array.isArray(name) ? name[0] : name)]; 61 | if (typeof roundTo !== 'number') { 62 | roundTo = 3; 63 | } 64 | 65 | val = Math.round(val * Math.pow(10, roundTo)) / Math.pow(10, roundTo); 66 | } 67 | 68 | return val + unit; 69 | } -------------------------------------------------------------------------------- /test/transformObj.spec.js: -------------------------------------------------------------------------------- 1 | describe('Transformer.transformObj()', function () { 2 | it('should exist', function () { 3 | Should(Transformer.transformObj).be.a.Function(); 4 | }); 5 | 6 | it('should transform numbers', function () { 7 | var transformFn = Transformer.transformObj({ 8 | 4: 0, 9 | 6: 0.5, 10 | 8: 0.5, 11 | 10: 1 12 | }); 13 | 14 | Should(transformFn).be.a.Function(); 15 | 16 | transformFn(3).should.equal(0); 17 | transformFn(4).should.equal(0); 18 | transformFn(5).should.equal(0.25); 19 | transformFn(6).should.equal(0.5); 20 | transformFn(7).should.equal(0.5); 21 | transformFn(8).should.equal(0.5); 22 | transformFn(9).should.equal(0.75); 23 | transformFn(10).should.equal(1); 24 | transformFn(11).should.equal(1); 25 | }); 26 | 27 | it('should loopBy', function () { 28 | var transformFn = Transformer.transformObj({ 29 | 0: 0, 30 | 1: 50, 31 | 2: 50, 32 | 3: 100 33 | }, 4); 34 | 35 | Should(transformFn).be.a.Function(); 36 | 37 | transformFn(0).should.equal(0); 38 | transformFn(1).should.equal(50); 39 | transformFn(2).should.equal(50); 40 | transformFn(3).should.equal(100); 41 | transformFn(3.5).should.equal(50); 42 | transformFn(4).should.equal(0); 43 | transformFn(5.5).should.equal(50); 44 | transformFn(6.75).should.equal(87.5); 45 | }); 46 | 47 | it('should support easing functions', function () { 48 | var ease = function (x) { 49 | return x * x; 50 | }; 51 | 52 | var transformFn = Transformer.transformObj({ 53 | 0: 0, 54 | 1: 50, 55 | 2: 50, 56 | 3: 100 57 | }, 4, ease); 58 | 59 | Should(transformFn).be.a.Function(); 60 | 61 | transformFn(0).should.equal(0); 62 | transformFn(0.5).should.equal(3.125); 63 | transformFn(1).should.equal(12.5); 64 | transformFn(2).should.equal(50); 65 | transformFn(2.5).should.equal(50); 66 | transformFn(3).should.equal(62.5); 67 | transformFn(3.5).should.equal(93.75); 68 | transformFn(3.75).should.equal(48.4375); 69 | transformFn(4).should.equal(0); 70 | }); 71 | 72 | it('should work with non-integers', function () { 73 | var transformFn = Transformer.transformObj({ 74 | 0: 0, 75 | 0.5: 100, 76 | 1: 150, 77 | 2.0001: 200 78 | }); 79 | 80 | Should(transformFn).be.a.Function(); 81 | 82 | transformFn(0).should.be.approximately(0, 0.1); 83 | transformFn(0.25).should.be.approximately(50, 0.1); 84 | transformFn(0.5).should.be.approximately(100, 0.1); 85 | transformFn(0.75).should.be.approximately(125, 0.1); 86 | transformFn(1).should.be.approximately(150, 0.1); 87 | transformFn(1.5).should.be.approximately(175, 0.1); 88 | transformFn(2).should.be.approximately(200, 0.1); 89 | }); 90 | 91 | it('should work with out of order numbers', function () { 92 | var transformFn = Transformer.transformObj({ 93 | 4665: 1, 94 | 5287: 0, 95 | 4540.599999999999: 0, 96 | 5162.6: 1 97 | }); 98 | 99 | Should(transformFn).be.a.Function(); 100 | 101 | transformFn(4500).should.equal(0); 102 | transformFn(4700).should.equal(1); 103 | transformFn(5100).should.equal(1); 104 | transformFn(5300).should.equal(0); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/transform-helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Given a domain and a range, transforms a number into another number. 3 | * 4 | * See the README. 5 | * 6 | * @param {[number, number]} domain 2-dimensional domain. 7 | * @param {[number, number]} range 2-dimensional range. 8 | * @param {number} [val] The number to transform. 9 | * @param {boolean} [fixed=true] Restrict the number to the range. 10 | * @returns {number|function} Transformed number. 11 | */ 12 | export function transform(domain, range, val, fixed) { 13 | if (typeof val === 'undefined') { 14 | return (val2) => transform(domain, range, val2, fixed); 15 | } 16 | 17 | let normalised = (val - domain[0]) / (domain[1] - domain[0]); 18 | 19 | if (fixed !== false) { 20 | normalised = Math.min(Math.max(normalised, 0), 1); 21 | } 22 | 23 | return (range[1] - range[0]) * normalised + range[0]; 24 | } 25 | 26 | // Easings that can be used by transformObj. Each function should take a 27 | // number between 0 and 1 (a percentage) and return a different number between 28 | // 0 and 1. Feel free to send PRs. 29 | export const easings = { 30 | linear: (x) => x, 31 | easeInQuad: (x) => Math.pow(x, 2), 32 | easeOutQuad: (x) => 1 - Math.pow(1 - x, 2), 33 | easeInOutQuad: (x) => (x) => x < 0.5 ? Math.pow(x * 2, 2) / 2 : 1 - Math.pow(2 * (1 - x), 2) / 2, 34 | easeInCubic: (x) => Math.pow(x, 3), 35 | easeOutCubic: (x) => 1 - Math.pow(1 - x, 3), 36 | easeInOutCubic: (x) => (x) => x < 0.5 ? Math.pow(x * 2, 3) / 2 : 1 - Math.pow(2 * (1 - x), 3) / 2, 37 | easeInQuart: (x) => Math.pow(x, 4), 38 | easeOutQuart: (x) => 1 - Math.pow(1 - x, 4), 39 | easeInOutQuart: (x) => (x) => x < 0.5 ? Math.pow(x * 2, 4) / 2 : 1 - Math.pow(2 * (1 - x), 4) / 2, 40 | easeInQuint: (x) => Math.pow(x, 5), 41 | easeOutQuint: (x) => 1 - Math.pow(1 - x, 5), 42 | easeInOutQuint: (x) => (x) => x < 0.5 ? Math.pow(x * 2, 5) / 2 : 1 - Math.pow(2 * (1 - x), 5) / 2, 43 | }; 44 | 45 | /** 46 | * Given an object to transform by, transforms a number into another number. 47 | * 48 | * See the README. 49 | * 50 | * @param {object} obj The object containing the values to transform around. 51 | * @param {number} [loopBy] The number to restart the transform at. 52 | * @param {string|function} [easing] Easing function. 53 | * @returns {Function} Function that takes a number to transform. 54 | */ 55 | export function transformObj(obj, loopBy, easing) { 56 | const keys = Object.keys(obj).sort((a, b) => a - b); 57 | const keysBackwards = keys.slice().reverse(); 58 | 59 | if (typeof easing === 'string') { 60 | easing = easings[easing]; 61 | } 62 | 63 | return function (val) { 64 | if (loopBy && typeof easing === 'function') { 65 | val = easing((val % loopBy) / loopBy) * loopBy; 66 | } else if (loopBy) { 67 | val %= loopBy; 68 | } 69 | 70 | if (val <= keys[0]) { 71 | return obj[keys[0]]; 72 | } 73 | 74 | if (val >= keysBackwards[0] && !loopBy) { 75 | return obj[keysBackwards[0]]; 76 | } 77 | 78 | const fromIndex = keys.length - 1 - keysBackwards.findIndex((key) => (val >= key)); 79 | 80 | const from = keys[fromIndex]; 81 | const to = keys[fromIndex + 1] || loopBy; 82 | 83 | const toVal = to === loopBy ? obj[keys[0]] : obj[to]; 84 | return transform([Number(from), Number(to)], [obj[from], toVal], val); 85 | }; 86 | } -------------------------------------------------------------------------------- /src/Transformer.js: -------------------------------------------------------------------------------- 1 | import { each, useTransformAttr, setData, getData, UNCHANGED } from './util'; 2 | import callFn from './call-fn'; 3 | 4 | // This is where active transformers are stored 5 | export const transformers = []; 6 | 7 | /** 8 | * This is the Transformer constructor, called by the user when creating a new 9 | * transformer object. It is given an array of objects containing transforms. 10 | * 11 | * See the README.md file for more. 12 | * 13 | * @param {Array} transforms Array of transforms. 14 | * @constructor 15 | */ 16 | export default function Transformer(transforms) { 17 | this.i = 0; 18 | this.transforms = Array.isArray(transforms) ? transforms : [transforms]; 19 | this.visible = undefined; 20 | 21 | this._last = {}; 22 | this._actions = {}; 23 | this._customVariables = {}; 24 | 25 | this.start(); 26 | } 27 | 28 | // Selector to get scroll position from. On the prototype so we can set globally 29 | Transformer.prototype.scrollElement = 'window'; 30 | Transformer.prototype.iIncrease = { 31 | belowOptimal: 'count', 32 | aboveOptimal: 'time', 33 | optimalFps: 60, 34 | }; 35 | 36 | /** 37 | * Stop the transformer from running 38 | */ 39 | Transformer.prototype.stop = function stopTransforms() { 40 | this.active = false; 41 | if (transformers.includes(this)) { 42 | transformers.splice(transformers.indexOf(this), 1); 43 | } 44 | }; 45 | 46 | /** 47 | * Start the transformer again if it was stopped. 48 | */ 49 | Transformer.prototype.start = function startTransforms() { 50 | this.active = true; 51 | if (!transformers.includes(this)) { 52 | transformers.push(this); 53 | } 54 | }; 55 | 56 | /** 57 | * Stop the transformer and reset some values like visibility and transforms to 58 | * their original values. 59 | */ 60 | Transformer.prototype.reset = function resetTransforms() { 61 | this.stop(); 62 | 63 | for (let transform of this.transforms) { 64 | if (transform.transforms) { 65 | each(transform.el, (el) => { 66 | if (useTransformAttr(el)) { 67 | el.setAttribute('transform', getData(el, 'originalTransform')); 68 | } else { 69 | el.style.transform = getData(el, 'originalTransform'); 70 | } 71 | }); 72 | } 73 | 74 | if (transform.visible || this.visible) { 75 | each(transform.el, (el) => { 76 | el.style.display = getData(el, 'originalDisplay'); 77 | }); 78 | } 79 | 80 | // @todo: should styles be unset? 81 | // if (transform.styles) { 82 | // for (let [ style, fn, unit = '' ] of transform.styles) { 83 | // each(transform.el, (el) => { 84 | // el.style[style] = ''; 85 | // }); 86 | // } 87 | // } 88 | 89 | // @todo: should attrs be unset? 90 | // if (transform.attrs) { 91 | // for (let [ attr, fn, unit = '' ] of transform.attrs) { 92 | // each(transform.el, (el) => el.removeAttribute(attr)); 93 | // } 94 | // } 95 | } 96 | }; 97 | 98 | /** 99 | * Set the y scroll positions the transforms are active between. Similar to the 100 | * visible property of a transform, but on the entire transformer. 101 | * 102 | * @param {[number, number]} visible Min and max y scroll value active at. 103 | */ 104 | Transformer.prototype.setVisible = function setGlobalVisible(visible) { 105 | this.visible = visible; 106 | }; 107 | 108 | Transformer.prototype.trigger = function triggerAction(name, duration) { 109 | let resolveFn; 110 | let promise; 111 | if (typeof window.Promise === 'function') { 112 | promise = new Promise((resolve) => { 113 | resolveFn = resolve; 114 | }); 115 | } 116 | 117 | this._actions[name] = { 118 | triggered: Date.now(), 119 | resolveFn, 120 | duration 121 | }; 122 | 123 | return promise; 124 | }; 125 | 126 | /** 127 | * Adds a custom variable that can be used in transformer functions. 128 | * 129 | * @param {string} name The name to use for the variables 130 | * @param {function} getter The function to call to get the variable value. 131 | */ 132 | Transformer.prototype.addVariable = function addCustomVar(name, getter) { 133 | this._customVariables[name] = getter; 134 | }; 135 | 136 | /** 137 | * The first function called on requestAnimationFrame. This one calculates the 138 | * properties to change and what to change them to, but doesn't apply the 139 | * changes to the DOM. Do not do anything to invalidate the DOM in this 140 | * function! 141 | * 142 | * Separating this process into two parts means that we can call functions that 143 | * forces styles to be recalculated / a layout in our transform calculation 144 | * functions without layout thrashing. 145 | * 146 | * @private 147 | */ 148 | Transformer.prototype._setup = function setupFrame(vars) { 149 | if (!this.active) { 150 | return; 151 | } 152 | 153 | const actions = {}; 154 | 155 | for (const name of Object.keys(this._actions)) { 156 | const action = this._actions[name]; 157 | const percent = 1 / action.duration * (Date.now() - action.triggered); 158 | actions[name] = Math.min(percent, 1); 159 | } 160 | 161 | for (let transform of this.transforms) { 162 | if (this.i === 0) { 163 | // This is where data to be put into the DOM is stored until ._frame() 164 | transform._stagedData = { styles: {}, attrs: {} }; 165 | 166 | // This is where data from the last run is kept so it can be compared for changes 167 | transform._lastData = { styles: {}, attrs: {} }; 168 | 169 | // Has to run before visible check 170 | if (transform.transforms) { 171 | each(transform.el, (el) => { 172 | if (useTransformAttr(el)) { 173 | setData(el, 'originalTransform', (el.getAttribute('transform') || '') + ' '); 174 | } else { 175 | const original = el.style.transform; 176 | setData(el, 'originalTransform', !original || original === 'none' ? '' : `${original} `); 177 | } 178 | }); 179 | } 180 | } 181 | 182 | if (transform.visible || this.visible) { 183 | let isHidden = true; 184 | if (this.visible) { 185 | isHidden = vars.y < this.visible[0] || vars.y > this.visible[1]; 186 | } 187 | 188 | if (isHidden && transform.visible) { 189 | isHidden = vars.y < transform.visible[0] || vars.y > transform.visible[1]; 190 | } 191 | 192 | transform._stagedData.isHidden = isHidden; 193 | 194 | if (isHidden) { 195 | continue; 196 | } 197 | } else { 198 | transform._stagedData.isHidden = undefined; 199 | } 200 | 201 | const args = Object.assign({ 202 | actions, actionEnded: this._actionEnded, i: this.i, last: this._last 203 | }, vars); 204 | 205 | if (transform.transforms) { 206 | let transforms = transform.transforms 207 | .map(([ prop, fn, unit = '' ]) => [prop, callFn('transforms', prop, fn, transform, unit, args)]); 208 | 209 | let changed = transforms.some((transform) => transform[1] !== UNCHANGED); 210 | 211 | if (changed && transform._lastData.transforms) { 212 | changed = transforms.some((innerTransform, i) => { 213 | return innerTransform[1] !== transform._lastData.transforms[i][1]; 214 | }); 215 | } 216 | 217 | if (changed) { 218 | transforms.forEach((innerTransform, i) => { 219 | if (innerTransform[1] === UNCHANGED) { 220 | innerTransform[1] = transform._lastData.transforms[i][1]; 221 | } 222 | }); 223 | } else { 224 | transforms = UNCHANGED; 225 | } 226 | 227 | transform._stagedData.transforms = transforms; 228 | 229 | if (transforms !== UNCHANGED) { 230 | transform._lastData.transforms = transforms; 231 | } 232 | } 233 | 234 | if (transform.styles) { 235 | for (let [ style, fn, unit = '' ] of transform.styles) { 236 | let value = callFn('styles', style, fn, transform, unit, args); 237 | 238 | if (value === transform._lastData.styles[style]) { 239 | value = UNCHANGED; 240 | } 241 | 242 | transform._stagedData.styles[style] = value; 243 | 244 | if (value !== UNCHANGED) { 245 | transform._lastData.styles[style] = value; 246 | } 247 | } 248 | } 249 | 250 | if (transform.attrs) { 251 | for (let [ attr, fn, unit = '' ] of transform.attrs) { 252 | let value = callFn('attrs', attr, fn, transform, unit, args); 253 | 254 | if (value === transform._lastData.attrs[attr]) { 255 | value = UNCHANGED; 256 | } 257 | 258 | transform._stagedData.attrs[attr] = value; 259 | 260 | if (value !== UNCHANGED) { 261 | transform._lastData.attrs[attr] = value; 262 | } 263 | } 264 | } 265 | } 266 | 267 | if (this._actionEnded) { 268 | this._actionEnded = false; 269 | } 270 | 271 | // Delete afterwards to ensure that callFn is called once when action === 1 272 | for (const name of Object.keys(this._actions)) { 273 | if (actions[name] === 1) { 274 | if (this._actions[name].resolveFn) { 275 | this._actions[name].resolveFn(); 276 | } 277 | 278 | this._actionEnded = true; 279 | 280 | delete this._actions[name]; 281 | } 282 | } 283 | }; 284 | 285 | /** 286 | * The second function called on requestAnimationFrame. By now, all the 287 | * properties to be changed should have been called, so this function just 288 | * iterates through and sets them. Don't call the `callFn` function here or do 289 | * anything that could cause the style to be recalculated. 290 | * 291 | * Here's a list of things not to do: https://gist.github.com/paulirish/5d52fb081b3570c81e3a 292 | * 293 | * @private 294 | */ 295 | Transformer.prototype._frame = function transformFrame(vars) { 296 | if (!this.active) { 297 | return; 298 | } 299 | 300 | for (let transform of this.transforms) { 301 | if (transform._stagedData.isHidden) { 302 | each(transform.el, (el) => { 303 | if (typeof getData(el, 'originalDisplay') === 'undefined') { 304 | setData(el, 'originalDisplay', el.style.display || ''); 305 | } 306 | 307 | el.style.display = 'none'; 308 | }); 309 | 310 | continue; 311 | } else { 312 | each(transform.el, (el) => { 313 | el.style.display = getData(el, 'originalDisplay'); 314 | }); 315 | } 316 | 317 | if (transform.transforms) { 318 | let transforms = transform._stagedData.transforms; 319 | 320 | if (transforms !== UNCHANGED) { 321 | transforms = transforms.map(([ key, value ]) => `${key}(${value})`).join(' '); 322 | 323 | each(transform.el, (el) => { 324 | if (useTransformAttr(el)) { 325 | el.setAttribute('transform', getData(el, 'originalTransform') + transforms); 326 | } else { 327 | el.style.transform = getData(el, 'originalTransform') + transforms; 328 | } 329 | }); 330 | } 331 | } 332 | 333 | if (transform.styles) { 334 | for (let [ style ] of transform.styles) { 335 | const computed = transform._stagedData.styles[style]; 336 | 337 | if (computed === UNCHANGED) { 338 | continue; 339 | } 340 | 341 | each(transform.el, (el) => { 342 | if (Array.isArray(style)) { 343 | style.forEach((style) => { 344 | el.style[style] = computed; 345 | }); 346 | } else { 347 | el.style[style] = computed; 348 | } 349 | }); 350 | } 351 | } 352 | 353 | if (transform.attrs) { 354 | for (let [ attr ] of transform.attrs) { 355 | const computed = transform._stagedData.attrs[attr]; 356 | 357 | if (computed === UNCHANGED) { 358 | continue; 359 | } 360 | 361 | each(transform.el, (el) => { 362 | if (Array.isArray(attr)) { 363 | attr.forEach((attr) => { 364 | el.setAttribute(attr, computed); 365 | }); 366 | } else { 367 | el.setAttribute(attr, computed); 368 | } 369 | }); 370 | } 371 | } 372 | } 373 | 374 | const currentTime = window.performance ? performance.now() : Date.now(); 375 | const deltaTime = currentTime - this._lastTime; 376 | 377 | const increaseBy = { 378 | count: 1, 379 | time: deltaTime / (1000 / this.iIncrease.optimalFps), 380 | }; 381 | 382 | if (!this._lastTime) { 383 | this.i++; 384 | } else if (deltaTime > 1000 / this.iIncrease.optimalFps) { 385 | this.i += increaseBy[this.iIncrease.belowOptimal]; 386 | } else { 387 | this.i += increaseBy[this.iIncrease.aboveOptimal]; 388 | } 389 | 390 | this._last = vars; 391 | this._lastTime = currentTime; 392 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # transform-when [![Build Status](https://travis-ci.org/SamKnows/transform-when.svg?branch=master)](https://travis-ci.org/SamKnows/transform-when) 2 | 3 | > A library for handing animations combining page position and time, written at [SamKnows]. 4 | 5 | ![](https://giant.gfycat.com/ScarceImaginativeLamprey.gif) 6 | 7 | For a full demo at 60fps, see: [samknows.com (archive.org)], 8 | or for a simpler demo, check out [this article I wrote]. 9 | 10 | ## Installation 11 | 12 | ``` 13 | $ npm install --save transform-when 14 | ``` 15 | 16 | ## Features 17 | 18 | - Blurs the line the between **reactive and time-based animations**, allowing 19 | you to combine variables such as page position, time, and user actions. 20 | - Uses a number of techniques to ensure **extremely high performance**: both on 21 | desktop and on mobile. 22 | - Uses **pure functions** to intelligently know when a property is going to 23 | change without having to call the transform function first. 24 | - Calculates every value to set, and then sets them all in one go, effectively 25 | making **layout thrashing impossible**. 26 | - Stores property values and compares changes against the old values to 27 | ensure that it actually is a change before setting it: sort of like a virtual 28 | DOM, but without a virtual DOM. 29 | - Uses `requestAnimationFrame` to ensure that it is only ran when necessary. 30 | - It is **powerful**. You can make complicated animations with this library. 31 | - Because it is low-level and doesn't contain any knowledge of the stuff it is 32 | animating (that bit is left up to you), it's extremely lightweight: minified 33 | and gzipped, the whole library is **under 10KB** 34 | - Works with both HTML elements and SVG elements. 35 | - Tested in IE11+. 36 | 37 | ## Usage 38 | 39 | ```js 40 | const Transformer = require('transform-when'); 41 | 42 | new Transformer([ 43 | { 44 | el: document.querySelector('.my-element'), 45 | styles: [ 46 | ['opacity', function (y) { 47 | if (y > 600) { 48 | return 0; 49 | } 50 | 51 | return 1; 52 | }] 53 | ] 54 | } 55 | ]); 56 | ``` 57 | 58 | The above code sets up a fairly simple transformer: it sets the opacity of the 59 | element to 0 if `window.scrollY` reaches more than 600, and back to 1 if the 60 | user scrolls back up above 600px again. 61 | 62 | In addition to **styles**, transform-when can animate **attrs**, and 63 | **transforms**. transforms is a helper function, and will set the `transform` 64 | style on HTML elements and the `transform` property on SVG elements. 65 | 66 | Let's take a look at a longer example that uses all three: 67 | 68 | ```js 69 | const Transformer = require('transform-when'); 70 | 71 | const transforms = new Transformer([ 72 | { 73 | el: document.querySelector('.my-element'), 74 | styles: [ 75 | ['opacity', function (y) { 76 | // This function animates the opacity from 1 to 0 between 500px and 600px: 77 | // we'll explore it some more later. 78 | return Transformer.transform([500, 600], [1, 0], y); 79 | }] 80 | ], 81 | attrs: [ 82 | ['class', function (y) { 83 | return 'my-element' + (y > 500 && y < 600 ? ' animating' : ''); 84 | }] 85 | ], 86 | transforms: [ 87 | ['scale', function (y) { 88 | return Transformer.transform([500, 600], [1, 0.5], y); 89 | }] 90 | ] 91 | } 92 | ]); 93 | ``` 94 | 95 | That code would take the element (or elements) matching `.my-element`, and then 96 | when the user scrolls between 500px and 600px, it would animate the opacity 97 | from 1 to 0, animate the scale from 1 to 0.5, and apply the `animating` class. 98 | 99 | ### Terminology 100 | 101 | - The `Transformer` function takes either a **transform object** or an array of 102 | them. 103 | - Each transform object should have an `el` property containing an element (or 104 | NodeList) and some **properties** to animate: styles, attrs, and transforms. 105 | - Properties use **transform functions** to calculate what values should be 106 | changed. Transform functions should be pure functions (without side effects), 107 | and request only the arguments requested so they can be heavily optimised. 108 | 109 | That's it. 110 | 111 | ### Transform function arguments (smart arguments) 112 | 113 | The transform functions above only have one argument, `y`, but if you were to 114 | change that to `x` or `i`, you would get a different number. This is because 115 | transform-when uses the arguments to detect when a property needs to be changed 116 | before actually calling the transform function: if the only argument is `y` and 117 | the y position of the page hasn't changed since the function was last called, 118 | then it doesn't bother to call the transform function. 119 | 120 | This is what makes transform-when so performant - but it means that transform 121 | functions should be pure as much as possible (if you want something to be 122 | random, don't worry - read the section on `i` below). 123 | 124 | There are (currently) four different arguments you can request: `y`, `x`, `i` 125 | and `actions`. 126 | 127 | #### Scroll position (`x` and `y`) 128 | 129 | The `x` and `y` values are simply `window.scrollX` and `window.scrollY` (or the 130 | IE equivalents)—how far down or along the page the user has scrolled. 131 | 132 | #### Time (`i`) 133 | 134 | `i` starts at 0, and increases by 1 for each frame—effectively, it's the frame 135 | number. This is useful for animating by time. 136 | 137 | If you want the actual time or a duration, you can calculate that yourself 138 | using `Date.now()`. 139 | 140 | If you want an impure transform function—say, you want to change it a bit 141 | randomly—request the `i` argument and the transform function will be called 142 | every time. 143 | 144 | #### User actions (`actions`) 145 | 146 | Sometimes you want a break in the normal animation: say, if a user clicks on 147 | something, or if a certain position on the page is reached. transform-when has 148 | a concept of actions: these can be triggered, and then play for a given amount 149 | of time. 150 | 151 | You trigger them using the `.trigger()` method, and they're passed in using an 152 | `actions` argument: 153 | 154 | ```js 155 | const transforms = new Transformer([ 156 | { 157 | el: document.querySelector('.my-element'), 158 | transforms: [ 159 | ['rotate', function (actions) { 160 | // actions === { spin: x } where x is a number between 0 and 1 161 | 162 | if (actions.spin) { 163 | return 360 * actions.spin; 164 | } 165 | 166 | return 0; 167 | }, 'deg'] 168 | ] 169 | } 170 | ]); 171 | 172 | transforms.trigger('spin', 2000); 173 | ``` 174 | 175 | Multiple actions can be triggered at the same time. 176 | 177 | The `.trigger()` function returns a promise which resolves when the action 178 | completes. It uses native promises, and will return `undefined` when 179 | `window.Promise` is undefined. 180 | 181 | #### Custom variables 182 | 183 | It's possible to add your own variables. 184 | 185 | ```js 186 | const transforms = new Transformer([ 187 | { 188 | el: document.querySelector('.my-element'), 189 | styles: [ 190 | ['opacity', function (myCustomVariable) { 191 | 192 | }] 193 | ] 194 | } 195 | ]); 196 | 197 | transforms.addVariable('myCustomVariable', function () { 198 | // Return what you want `myCustomVariable` to equal 199 | }); 200 | ``` 201 | 202 | The transform function is still only called when the variable is changed - 203 | except for the way it is generated, custom variables are treated exactly the 204 | same as scroll position, time and user actions. 205 | 206 | #### Minifiers 207 | 208 | Minifiers will, by default, break transform-when if they rename variables. The 209 | way around this is to wrap the function in an array saying what variables you 210 | need: 211 | 212 | ```js 213 | const transforms = new Transformer([ 214 | { 215 | el: document.querySelector('.my-element'), 216 | transforms: [ 217 | ['rotate', ['actions', function (actions) { 218 | if (actions.spin) { 219 | return 360 * actions.spin; 220 | } 221 | 222 | return 0; 223 | }], 'deg'] 224 | ] 225 | } 226 | ]); 227 | ``` 228 | 229 | The minifier won't touch the string, and transform-when will look at that 230 | instead. 231 | 232 | ### `this` 233 | 234 | In a transform function, `this` refers to the transform object. This allows 235 | you to store stuff like scales on the transform object: 236 | 237 | ```js 238 | const transforms = new Transformer([ 239 | { 240 | el: document.querySelector('.my-element'), 241 | colorScale: chroma.scale(['red', 'blue']).domain([500, 600]), 242 | styles: [ 243 | ['color', function (y) { 244 | return this.colorScale(y); 245 | }] 246 | ] 247 | } 248 | ]); 249 | ``` 250 | 251 | ### Types of properties 252 | 253 | There are three types of properties, `styles`, `attrs` and `transforms`. The 254 | first two are both pretty simple: they just set styles and attributes of an 255 | element. Be careful animating attributes and styles that aren't the opacity: 256 | they are more expensive to animate than transforms and opacity, and might make 257 | your animation jerky. 258 | 259 | Each takes an array of three things: the property (style or attribute) to 260 | animate, the transform functions, and optionally the unit to use - it's better 261 | to let transform-when handle adding the unit, because it will also round the 262 | number for you. 263 | 264 | Let's take a look at an example: 265 | 266 | ```js 267 | const transforms = new Transformer([ 268 | { 269 | el: document.querySelector('.my-element'), 270 | styles: [ 271 | ['padding', function (y) { 272 | return Transformer.transform([500, 600], [20, 50], y); 273 | }, 'px'] 274 | ], 275 | attrs: [ 276 | ['class', function (y) { 277 | return 'my-element' + (y > 500 && y < 600 ? ' animating' : ''); 278 | }] 279 | ], 280 | } 281 | ]); 282 | ``` 283 | 284 | That animates the padding of an element from 20px to 50px, and adds the 285 | `animating` class. 286 | 287 | Transforms are a little trickier. 288 | 289 | #### Animating transforms 290 | 291 | CSS or SVG transforms are all set on one property. For example, a CSS transform 292 | could be `scaleY(0.5) translate(10px 20px)` and an SVG transform could be 293 | `scale(1 0.5) translate(10 20)`. Transforms are the reason for the slightly 294 | strange syntax using arrays for properties, not objects: order is important. 295 | Translating an element then scaling it is pretty different to scaling it and 296 | then translating it. 297 | 298 | transform-when looks at the array, turning each property into part of the 299 | transform attribute (for SVG) or style (for HTML elements). 300 | 301 | ```js 302 | const transforms = new Transformer([ 303 | { 304 | el: document.querySelector('.my-element'), 305 | transforms: [ 306 | ['scale', function (y) { 307 | return Transformer.transform([500, 600], [1, 1.5], y); 308 | }], 309 | ['translateX', function (y) { 310 | return Transformer.transform([500, 600], [0, 50], y); 311 | }, 'px'] 312 | ] 313 | } 314 | ]); 315 | ``` 316 | 317 | That would return `scale(1) translateX(0px)` when the y position of the page is 318 | 500px, `scale(1.5) translateX(50px)` when the y position of the page is 600px, 319 | and transition between the two. 320 | 321 | Because the library doesn't have any knowledge of the properties it is 322 | animating, remember to specify units when required for CSS transforms, and 323 | don't try to use `scaleY` on an SVG! 324 | 325 | #### Animating multiple properties at once 326 | 327 | Sometimes it's necessary to animate multiple properties at the same time with 328 | the same value—for example, for CSS vendor prefixes. It isn't necessary to 329 | specify two different properties with the same transform functions (and it 330 | would be pretty inefficient, too): you can just specify the property as an 331 | array: 332 | 333 | ```js 334 | const transformer = new Transformer([ 335 | { 336 | el: mock, 337 | styles: [ 338 | [['clip-path', 'webkit-clip-path'], function (i) { 339 | return 'circle(50px at 0% 100px)'; 340 | }] 341 | ] 342 | } 343 | ]); 344 | ``` 345 | 346 | ### Transform helpers 347 | 348 | transform-when provides a couple functions to help with animating values 349 | between two different points: `Transformer.transform()`, and 350 | `Transformer.transformObj()`. If you're familiar with d3, 351 | `Transformer.transform()` work pretty similar to d3's scale functions. 352 | 353 | Both functions map a domain to a range: for example, if you want to animate 354 | the scale of an element from 1 to 2 between the y positions of 500px and 600px, 355 | you could do it like this: 356 | 357 | ```js 358 | const scale = (x) => (2 - 1) * (y - 500) / (600 - 500) + 1; 359 | ``` 360 | 361 | That gets complicated. Instead, you can use one of the helpers: 362 | 363 | ```js 364 | Transformer.transform([500, 600], [1, 2], y); 365 | ``` 366 | 367 | #### `Transformer.transform()` 368 | 369 | A simple scale function with three arguments, domain, range, and value. Takes 370 | the value and converts it into a new number. 371 | 372 | ```js 373 | Transformer.transform([400, 600], [1, 0], 400); // 1 374 | Transformer.transform([400, 600], [1, 0], 500); // 0.5 375 | Transformer.transform([400, 600], [1, 0], 600); // 0 376 | ``` 377 | 378 | If only given two arguments, it'll return a function that can be called with 379 | the final value, but there is no performance advantage to doing this: 380 | 381 | ```js 382 | const myTransform = Transformer.transform([400, 600], [1, 0]); 383 | 384 | myTransform(400); // 1 385 | myTransform(500); // 0.5 386 | myTransform(600); // 0 387 | ``` 388 | 389 | #### `Transformer.transformObj()` 390 | 391 | A slightly more complicated, more powerful version of the previous function. It 392 | takes an object with input values and output values to allow scales with 393 | multiple stages: 394 | 395 | ```js 396 | const myTransform = Transformer.transformObj({ 397 | 400: 1, 398 | 600: 0, 399 | 1000: 0, 400 | 1200: 1 401 | }); 402 | 403 | myTransform(0); // 1 404 | myTransform(400); // 1 405 | myTransform(500); // 0.5 406 | myTransform(600); // 0 407 | ``` 408 | 409 | If the y position of the page were passed in and the result used as an opacity, 410 | the above code would make the element start visible, then fade it out between 411 | 400px and 600px, then fade it back in again between 1000px and 1200px. 412 | 413 | This function also takes two more arguments, `loopBy` and `easing`. 414 | 415 | ##### `loopBy` 416 | 417 | This argument allows you to specify a point after which the animation should 418 | repeat itself. For example, if you want to animate the scale from 0.5 to 1 and 419 | back again over time, you could do this: 420 | 421 | ```js 422 | const scaleTransform = Transformer.transformObj({ 423 | 0: 0.5, 424 | 30: 1 425 | }, 60); 426 | 427 | scaleTransform(i); // Animates from 0.5 to 1 and back repeatedly as i increases 428 | ``` 429 | 430 | ##### `easing` 431 | 432 | `Transformer.transformObj()` has basic support for easings. You can either pass 433 | in the name of the easing—you can find the built in ones [here][easings]—or you 434 | can pass in you own easing function. 435 | 436 | Unlike standard easing functions, they're given one argument and return one 437 | number: both percentages (number between 0 and 1). 438 | 439 | For example, a quadratic ease in (`easeInQuad`) looks like this: 440 | 441 | ```js 442 | const easeInQuad = (x) => x * x; 443 | ``` 444 | 445 | Pull requests adding other easings very welcome! 446 | 447 | ### visible & setVisible 448 | 449 | Transform objects also accept another property, `visible`. This should be two 450 | numbers where when the y position of the page is outside of these values, the 451 | element will not be animated. This helps ensure that if you have a lot of 452 | elements on the page, the ones that aren't being displayed aren't wasting 453 | resources. 454 | 455 | ```js 456 | const transforms = new Transformer([ 457 | { 458 | el: document.querySelector('.my-element'), 459 | visible: [0, 600], 460 | styles: [ 461 | ['opacity', function (y) { 462 | return Transformer.transform([500, 600], [1, 0], y); 463 | }] 464 | ] 465 | } 466 | ]); 467 | ``` 468 | 469 | You can also set the property on everything at once using the `setVisible()` 470 | method: 471 | 472 | ``` 473 | transforms.setVisible([500, 600]); 474 | ``` 475 | 476 | ### Pausing and cancelling an animation 477 | 478 | It's possible to stop and start the animation using the `stop()` and `start()` 479 | methods. Stopping the animation will leave the currently animated properties 480 | exactly where they are, and stop `i` from increasing. Starting it again will 481 | resume things from where they were when the animation was stopped. 482 | 483 | The following will pause the animation for a second: 484 | 485 | ```js 486 | transforms.stop(); 487 | 488 | setTimeout(function () { 489 | transforms.start(); 490 | }, 1000); 491 | ``` 492 | 493 | There's also a `reset()` method for when you want to stop an animation and 494 | restore the transform and element displays to what they were to start off with 495 | (styles and attributes will be left as they were). This is useful if you need 496 | to reinitialise the animate when the window is resized: 497 | 498 | ```js 499 | let transforms; 500 | 501 | function init() { 502 | if (transforms) { 503 | transforms.reset(); 504 | } 505 | 506 | transforms = new Transformer([ ... ]); 507 | } 508 | 509 | init(); 510 | window.addEventListener('resize', debounce(init)); 511 | ``` 512 | 513 | ### Changing the element to get the scroll position from 514 | 515 | By default, transform-when gets the scroll positions from the `window`, but 516 | this isn't always what you want. To change it, just change the `scrollElement` 517 | property to contain a selector for the element you want to get the scroll 518 | position of instead: 519 | 520 | ```js 521 | transforms.scrollElement = '.my-scroll-element'; 522 | ``` 523 | 524 | ### Configuring how `i` increases 525 | 526 | The default behaviour of `i` is to increase by 1 on each frame, up to a maximum 527 | of 60 times. On most monitors, this just means that `i` will be the number of 528 | the frame, because most monitors don't go above 60fps. On monitors that are 529 | capable of a higher fps such as gaming monitors, however, this means that `i` 530 | won't necessarily be a whole number. If the monitor runs at 120fps, `i` will 531 | increase by about 0.5 120 times a second. 532 | 533 | This is configurable! There are three options, `belowOptimal` and 534 | `aboveOptimal`, each of which can be set to "count" (to increase by 1 each 535 | frame) or "time" (to increase so that `i` increases by 60 per second). By 536 | default, `belowOptimal` is set to "count" and `aboveOptimal` is set to "time". 537 | 538 | You may want to change `belowOptimal` to "time". You probably don't want to 539 | change `aboveOptimal` to "count". 540 | 541 | ```js 542 | transforms.iIncrease.belowOptimal = 'time'; 543 | ``` 544 | 545 | You can also configure the optimal FPS. By default it's 60, but you can change 546 | it: 547 | 548 | ```js 549 | transforms.iIncrease.optimalFps = 120; 550 | ``` 551 | 552 | 553 | ## Happy animating :) 554 | 555 | 🎉 556 | 557 | ## License 558 | 559 | Released under the MIT license. 560 | 561 | [SamKnows]: https://samknows.com/ 562 | [samknows.com (archive.org)]: https://web.archive.org/web/20171203224250/https://samknows.com/ 563 | [easings]: https://github.com/SamKnows/transform-when/blob/master/src/transform-helpers.js#L26 564 | [this article I wrote]: http://macr.ae/article/transform-when.html 565 | -------------------------------------------------------------------------------- /test/transformer.spec.js: -------------------------------------------------------------------------------- 1 | var mock = document.querySelector('#mock'); 2 | var mock2 = document.querySelector('#mock2'); 3 | var mocks = document.querySelectorAll('.mock'); 4 | var svgMock = document.querySelector('#svg-mock'); 5 | var scrollableEl = document.querySelector('.scrollable-outer'); 6 | 7 | function afterNextFrame(fn) { 8 | requestAnimationFrame(function () { 9 | setTimeout(fn); 10 | }); 11 | } 12 | 13 | describe('Transformer', function () { 14 | var interval; 15 | var transformer; 16 | var transformer2; 17 | 18 | beforeEach(function () { 19 | mock.removeAttribute('transform'); 20 | mock.style.transform = 'none'; 21 | svgMock.removeAttribute('transform'); 22 | scroll(0, 0); 23 | scrollableEl.scrollTop = 0; 24 | }); 25 | 26 | afterEach(function () { 27 | clearInterval(interval); 28 | if (transformer) { 29 | transformer.reset(); 30 | } 31 | if (transformer2) { 32 | transformer2.reset(); 33 | } 34 | }); 35 | 36 | it('should exist', function () { 37 | Transformer.should.be.a.Function(); 38 | }); 39 | 40 | it('should init', function () { 41 | transformer = new Transformer([]); 42 | transformer.active.should.be.true(); 43 | }); 44 | 45 | it('should stop', function () { 46 | transformer.stop(); 47 | transformer.active.should.be.false(); 48 | }); 49 | 50 | it('should change elements by i', function (done) { 51 | transformer = new Transformer([ 52 | { 53 | el: mock, 54 | transforms: [ 55 | ['scale', function (x, y, i) { 56 | return (i < 3) ? 1 : 2; 57 | }] 58 | ] 59 | } 60 | ]); 61 | 62 | interval = setInterval(function () { 63 | if (mock.style.transform === 'scale(1)') { 64 | clearInterval(interval); 65 | 66 | interval = setInterval(function () { 67 | if (mock.style.transform === 'scale(2)') { 68 | clearInterval(interval); 69 | done(); 70 | } 71 | }, 20); 72 | } 73 | }, 20); 74 | }); 75 | 76 | it('should change elements by y', function (done) { 77 | transformer = new Transformer([ 78 | { 79 | el: mock, 80 | transforms: [ 81 | ['scale', function (x, y, i) { 82 | return (y < 5) ? 1 : 2; 83 | }] 84 | ] 85 | } 86 | ]); 87 | 88 | interval = setInterval(function () { 89 | if (mock.style.transform === 'scale(1)') { 90 | scroll(0, 10); 91 | clearInterval(interval); 92 | 93 | interval = setInterval(function () { 94 | if (mock.style.transform === 'scale(2)') { 95 | clearInterval(interval); 96 | done(); 97 | } 98 | }, 20); 99 | } 100 | }, 20); 101 | }); 102 | 103 | it('should accept just one object as well as an array', function (done) { 104 | transformer = new Transformer({ 105 | el: mock, 106 | transforms: [ 107 | ['scale', function (x, y, i) { 108 | return (i < 3) ? 1 : 2; 109 | }] 110 | ] 111 | }); 112 | 113 | interval = setInterval(function () { 114 | if (mock.style.transform === 'scale(1)') { 115 | clearInterval(interval); 116 | done(); 117 | } 118 | }, 20); 119 | }); 120 | 121 | it('should support visible property', function (done) { 122 | transformer = new Transformer([ 123 | { 124 | el: mock, 125 | visible: [0, 10] 126 | } 127 | ]); 128 | 129 | scroll(0, 20); 130 | 131 | interval = setInterval(function () { 132 | if (getComputedStyle(mock).display === 'none') { 133 | scroll(0, 0); 134 | clearInterval(interval); 135 | 136 | interval = setInterval(function () { 137 | if (getComputedStyle(mock).display === 'block') { 138 | clearInterval(interval); 139 | done(); 140 | } 141 | }, 20); 142 | } 143 | }, 20); 144 | }); 145 | 146 | it('should support global visible method', function (done) { 147 | transformer = new Transformer([ 148 | { 149 | el: mock, 150 | } 151 | ]); 152 | 153 | transformer.setVisible([0, 10]); 154 | 155 | scroll(0, 20); 156 | 157 | interval = setInterval(function () { 158 | if (getComputedStyle(mock).display === 'none') { 159 | scroll(0, 0); 160 | clearInterval(interval); 161 | 162 | interval = setInterval(function () { 163 | if (getComputedStyle(mock).display === 'block') { 164 | clearInterval(interval); 165 | done(); 166 | } 167 | }, 20); 168 | } 169 | }, 20); 170 | }); 171 | 172 | it('should not move display set using css to style attr when toggling visibility', function (done) { 173 | transformer = new Transformer([ 174 | { 175 | el: mock, 176 | visible: [0, 10] 177 | } 178 | ]); 179 | 180 | scroll(0, 20); 181 | 182 | interval = setInterval(function () { 183 | if (getComputedStyle(mock).display === 'none') { 184 | scroll(0, 0); 185 | clearInterval(interval); 186 | 187 | interval = setInterval(function () { 188 | if (getComputedStyle(mock).display === 'block') { 189 | Should(mock.style.display).not.be.ok; 190 | clearInterval(interval); 191 | done(); 192 | } 193 | }, 20); 194 | } 195 | }, 20); 196 | }); 197 | 198 | it('should not call transform functions if element hidden', function (done) { 199 | var called = 0; 200 | 201 | transformer = new Transformer([ 202 | { 203 | el: mock, 204 | visible: [0, 10], 205 | styles: [['opacity', function (i) { 206 | called++; 207 | }]], 208 | attrs: [['opacity', function (i) { 209 | called++; 210 | }]], 211 | } 212 | ]); 213 | 214 | scroll(0, 20); 215 | 216 | interval = setInterval(function () { 217 | if (getComputedStyle(mock).display === 'none') { 218 | called.should.equal(0); 219 | 220 | clearInterval(interval); 221 | done(); 222 | } 223 | }, 20); 224 | }); 225 | 226 | it('should leave original transforms alone', function (done) { 227 | transformer = new Transformer([ 228 | { 229 | el: svgMock, 230 | transforms: [ 231 | ['scale', function (x, y, i) { 232 | return (y < 5) ? 1 : 2; 233 | }] 234 | ] 235 | } 236 | ]); 237 | 238 | svgMock.setAttribute('transform', 'translate(100 200)'); 239 | 240 | interval = setInterval(function () { 241 | if (svgMock.getAttribute('transform') === 'translate(100 200) scale(1)') { 242 | scroll(0, 10); 243 | 244 | clearInterval(interval); 245 | 246 | interval = setInterval(function () { 247 | if (svgMock.getAttribute('transform') === 'translate(100 200) scale(2)') { 248 | clearInterval(interval); 249 | done(); 250 | } 251 | }, 20); 252 | } 253 | }, 20); 254 | }); 255 | 256 | it('should unset transforms on reset', function (done) { 257 | transformer = new Transformer([ 258 | { 259 | el: svgMock, 260 | transforms: [ 261 | ['scale', function (x, y, i) { 262 | return (y < 5) ? 1 : 2; 263 | }] 264 | ] 265 | } 266 | ]); 267 | 268 | svgMock.setAttribute('transform', 'translate(100 200)'); 269 | 270 | interval = setInterval(function () { 271 | if (svgMock.getAttribute('transform').indexOf('scale(') !== -1) { 272 | svgMock.getAttribute('transform').should.containEql('translate(100 200)'); 273 | 274 | transformer.reset(); 275 | 276 | clearInterval(interval); 277 | 278 | interval = setInterval(function () { 279 | if (svgMock.getAttribute('transform').indexOf('scale(') === -1) { 280 | svgMock.getAttribute('transform').should.containEql('translate(100 200)'); 281 | clearInterval(interval); 282 | done(); 283 | } 284 | }, 20); 285 | } 286 | }, 20); 287 | }); 288 | 289 | it('should support NodeLists', function (done) { 290 | transformer = new Transformer([ 291 | { 292 | el: mocks, 293 | styles: [ 294 | ['opacity', function (x, y, i) { 295 | return (y < 5) ? 1 : 0; 296 | }] 297 | ] 298 | } 299 | ]); 300 | 301 | 302 | scroll(0, 20); 303 | 304 | interval = setInterval(function () { 305 | if (getComputedStyle(mock).opacity === '0') { 306 | getComputedStyle(mock2).opacity.should.equal('0'); 307 | 308 | scroll(0, 0); 309 | 310 | clearInterval(interval); 311 | 312 | interval = setInterval(function () { 313 | if (getComputedStyle(mock).opacity === '1') { 314 | getComputedStyle(mock2).opacity.should.equal('1'); 315 | clearInterval(interval); 316 | done(); 317 | } 318 | }, 20); 319 | } 320 | }, 20); 321 | }); 322 | 323 | it('should use attr for SVG transforms', function (done) { 324 | transformer = new Transformer([ 325 | { 326 | el: svgMock, 327 | transforms: [ 328 | ['scale', function (x, y, i) { 329 | return (i < 3) ? 1 : 2; 330 | }] 331 | ] 332 | } 333 | ]); 334 | 335 | interval = setInterval(function () { 336 | if (svgMock.getAttribute('transform').trim() === 'scale(1)') { 337 | 338 | clearInterval(interval); 339 | 340 | interval = setInterval(function () { 341 | if (svgMock.getAttribute('transform').trim() === 'scale(2)') { 342 | clearInterval(interval); 343 | done(); 344 | } 345 | }, 20); 346 | } 347 | }, 20); 348 | }); 349 | 350 | it('should pass in requested arguments only', function (done) { 351 | var lastY = -1; 352 | 353 | transformer = new Transformer([ 354 | { 355 | el: mock, 356 | transforms: [ 357 | ['scale', function (y) { 358 | lastY = y; 359 | return 1; 360 | }] 361 | ] 362 | } 363 | ]); 364 | 365 | scroll(0, 14); 366 | 367 | interval = setInterval(function () { 368 | if (lastY === 14) { 369 | clearInterval(interval); 370 | done(); 371 | } 372 | }, 20); 373 | }); 374 | 375 | it('should pass in requested arguments only (minified)', function (done) { 376 | var lastY = -1; 377 | 378 | transformer = new Transformer([ 379 | { 380 | el: mock, 381 | transforms: [ 382 | ['scale', ['y', function (a) { 383 | lastY = a; 384 | return 1; 385 | }]] 386 | ] 387 | } 388 | ]); 389 | 390 | scroll(0, 14); 391 | 392 | interval = setInterval(function () { 393 | if (lastY === 14) { 394 | clearInterval(interval); 395 | done(); 396 | } 397 | }, 20); 398 | }); 399 | 400 | it('should not call fn if request args unchanged', function (done) { 401 | var called = 0; 402 | 403 | transformer = new Transformer([ 404 | { 405 | el: mock, 406 | attrs: [ 407 | ['data-test', function (y) { 408 | called++; 409 | }] 410 | ] 411 | } 412 | ]); 413 | 414 | scroll(0, 10); 415 | 416 | interval = setInterval(function () { 417 | if (called === 1) { 418 | clearInterval(interval); 419 | scroll(0, 0); 420 | 421 | interval = setInterval(function () { 422 | if (called === 2) { 423 | clearInterval(interval); 424 | 425 | setTimeout(function () { 426 | called.should.equal(2); 427 | done(); 428 | }, 50); 429 | } 430 | }, 20); 431 | } 432 | }, 20); 433 | }); 434 | 435 | it('should call fn once if no arguments', function (done) { 436 | var called = 0; 437 | 438 | transformer = new Transformer([ 439 | { 440 | el: mock, 441 | attrs: [ 442 | ['data-test', function () { 443 | called++; 444 | }] 445 | ] 446 | } 447 | ]); 448 | 449 | scroll(0, 10); 450 | 451 | interval = setInterval(function () { 452 | if (called === 1) { 453 | clearInterval(interval); 454 | scroll(0, 0); 455 | 456 | setTimeout(function () { 457 | called.should.equal(1); 458 | done(); 459 | }, 50); 460 | } 461 | }, 20); 462 | }); 463 | 464 | it('should support getting the scroll position of other elements', function (done) { 465 | transformer = new Transformer([ 466 | { 467 | el: mock, 468 | transforms: [ 469 | ['scale', function (x, y, i) { 470 | return (y < 5) ? 1 : 2; 471 | }] 472 | ] 473 | } 474 | ]); 475 | 476 | transformer.scrollElement = '.scrollable-outer'; 477 | 478 | interval = setInterval(function () { 479 | if (mock.style.transform === 'scale(1)') { 480 | scrollableEl.scrollTop = 10; 481 | clearInterval(interval); 482 | 483 | interval = setInterval(function () { 484 | if (mock.style.transform === 'scale(2)') { 485 | clearInterval(interval); 486 | done(); 487 | } 488 | }, 20); 489 | } 490 | }, 20); 491 | }); 492 | 493 | it('should not call fn if request args unchanged when using scroll position of other element', function (done) { 494 | var called = 0; 495 | 496 | transformer = new Transformer([ 497 | { 498 | el: mock, 499 | attrs: [ 500 | ['data-test', function (y) { 501 | called++; 502 | }] 503 | ] 504 | } 505 | ]); 506 | 507 | transformer.scrollElement = '.scrollable-outer'; 508 | 509 | scrollableEl.scrollTop = 10; 510 | 511 | interval = setInterval(function () { 512 | if (called === 1) { 513 | clearInterval(interval); 514 | scrollableEl.scrollTop = 0; 515 | 516 | interval = setInterval(function () { 517 | if (called === 2) { 518 | clearInterval(interval); 519 | 520 | setTimeout(function () { 521 | called.should.equal(2); 522 | done(); 523 | }, 50); 524 | } 525 | }, 20); 526 | } 527 | }, 20); 528 | }); 529 | 530 | it('should support changing multiple css styles at once', function (done) { 531 | transformer = new Transformer([ 532 | { 533 | el: mock, 534 | styles: [ 535 | [['clip-path', 'webkit-clip-path'], function (i) { 536 | return 'circle(50px at 0% 100px)'; 537 | }] 538 | ] 539 | } 540 | ]); 541 | 542 | interval = setInterval(function () { 543 | if (mock.style.clipPath.startsWith('circle(50px at') || 544 | (mock.style['webkit-clip-path'] && mock.style['webkit-clip-path'].startsWith('circle(50px at'))) { 545 | clearInterval(interval); 546 | done(); 547 | } 548 | }, 20); 549 | }); 550 | 551 | describe('actions and triggers', function () { 552 | it('should support triggering actions', function (done) { 553 | var lastActions; 554 | var called = 0; 555 | 556 | transformer = new Transformer([ 557 | { 558 | el: mock, 559 | styles: [ 560 | ['opacity', function (actions) { 561 | lastActions = actions; 562 | called++; 563 | }] 564 | ] 565 | } 566 | ]); 567 | 568 | interval = setInterval(function () { 569 | if (called === 1) { 570 | clearInterval(interval); 571 | 572 | lastActions.should.not.have.property('test'); 573 | 574 | transformer.trigger('test', 100); 575 | 576 | var lastVal = 0; 577 | 578 | interval = setInterval(function () { 579 | if (lastActions.test === undefined) { 580 | done(); 581 | clearInterval(interval); 582 | return; 583 | } 584 | 585 | lastActions.test.should.be.a.Number(); 586 | lastActions.test.should.not.be.below(lastVal); 587 | lastActions.test.should.be.within(0, 1); 588 | 589 | lastVal = lastActions.test; 590 | }, 5); 591 | } 592 | }, 5); 593 | }); 594 | 595 | it('should support smart arguments and not be called when not needed', function (done) { 596 | var lastActions; 597 | var called = 0; 598 | 599 | transformer = new Transformer([ 600 | { 601 | el: mock, 602 | styles: [ 603 | ['opacity', function (actions) { 604 | lastActions = actions; 605 | called++; 606 | }] 607 | ] 608 | } 609 | ]); 610 | 611 | interval = setInterval(function () { 612 | if (called === 1) { 613 | clearInterval(interval); 614 | 615 | transformer.trigger('test', 30); 616 | 617 | interval = setInterval(function () { 618 | if (lastActions.test === 1) { 619 | clearInterval(interval); 620 | 621 | called.should.be.above(2); 622 | var calledNow = called; 623 | 624 | setTimeout(function () { 625 | // Called should have increased by only one 626 | called.should.equal(calledNow + 1); 627 | done(); 628 | }, 30); 629 | } 630 | }, 5); 631 | } 632 | }, 1); 633 | }); 634 | 635 | it('should allow multiple actions to be called at once', function (done) { 636 | var lastActions; 637 | 638 | transformer = new Transformer([ 639 | { 640 | el: mock, 641 | styles: [ 642 | ['opacity', function (actions) { 643 | lastActions = actions; 644 | }] 645 | ] 646 | } 647 | ]); 648 | 649 | transformer.trigger('test', 60); 650 | transformer.trigger('test2', 120); 651 | 652 | interval = setInterval(function () { 653 | lastActions.should.have.property('test2'); 654 | 655 | if (lastActions.test < 1) { 656 | lastActions.test.should.be.approximately(lastActions.test2 * 2, 0.05); 657 | } else { 658 | lastActions.test2.should.be.above(0.4999); 659 | clearInterval(interval); 660 | done(); 661 | } 662 | }, 20); 663 | }); 664 | 665 | it('shouldn\'t fist fite if multiple actions on multiple transformers called', function (done) { 666 | transformer = new Transformer([ 667 | { 668 | el: mock, 669 | transforms: [ 670 | ['scale', function (actions) { 671 | return actions.test ? 0.5 : 0; 672 | }] 673 | ] 674 | } 675 | ]); 676 | 677 | transformer2 = new Transformer([ 678 | { 679 | el: mock, 680 | transforms: [ 681 | ['scale', function (actions) { 682 | return actions.test ? 0.6 : 0; 683 | }] 684 | ] 685 | } 686 | ]); 687 | 688 | afterNextFrame(function () { 689 | transformer.trigger('test', 60); 690 | 691 | setTimeout(function () { 692 | mock.style.transform.should.equal('scale(0.5)'); 693 | 694 | afterNextFrame(function () { 695 | transformer2.trigger('test', 60); 696 | 697 | setTimeout(function () { 698 | mock.style.transform.should.equal('scale(0.6)'); 699 | 700 | done(); 701 | }, 50); 702 | }); 703 | }, 50); 704 | }); 705 | }); 706 | 707 | it('shouldn\'t fist fite if multiple actions on multiple transformers called (and they change)', function (done) { 708 | transformer = new Transformer([ 709 | { 710 | el: mock, 711 | transforms: [ 712 | ['scale', function (actions) { 713 | return 0.5 + Math.random() / 100; 714 | }] 715 | ] 716 | } 717 | ]); 718 | 719 | transformer2 = new Transformer([ 720 | { 721 | el: mock, 722 | transforms: [ 723 | ['scale', function (actions) { 724 | return 0.6 + Math.random() / 100; 725 | }] 726 | ] 727 | } 728 | ]); 729 | 730 | afterNextFrame(function () { 731 | transformer.trigger('test', 60); 732 | 733 | setTimeout(function () { 734 | mock.style.transform.should.startWith('scale(0.5'); 735 | 736 | afterNextFrame(function () { 737 | transformer2.trigger('test', 60); 738 | 739 | setTimeout(function () { 740 | mock.style.transform.should.startWith('scale(0.6'); 741 | 742 | done(); 743 | }, 50); 744 | }); 745 | }, 50); 746 | }); 747 | }); 748 | 749 | it('should return a promise that resolves when action complete', function () { 750 | transformer = new Transformer([]); 751 | 752 | var start = Date.now(); 753 | 754 | return transformer.trigger('test', 60) 755 | .then(function () { 756 | (Date.now() - start).should.be.approximately(60, 30); 757 | }); 758 | }); 759 | 760 | it('should return nothing if window.Promise undefined', function () { 761 | var Promise = window.Promise; 762 | window.Promise = undefined; 763 | 764 | // This so that if the test fails, it doesn't break anything else 765 | setTimeout(function () { 766 | window.Promise = Promise; 767 | }); 768 | 769 | transformer = new Transformer([]); 770 | 771 | Should(transformer.trigger('test', 60)).equal(undefined); 772 | 773 | window.Promise = Promise; 774 | }); 775 | 776 | it('should call action functions once more after action has finished', function (done) { 777 | const actionVals = []; 778 | 779 | transformer = new Transformer([ 780 | { 781 | el: mock, 782 | styles: [ 783 | ['opacity', function (actions) { 784 | actionVals.push(actions.test); 785 | }] 786 | ] 787 | } 788 | ]); 789 | 790 | transformer.trigger('test', 40); 791 | 792 | setTimeout(function () { 793 | actionVals[0].should.be.within(0, 1); 794 | actionVals[actionVals.length - 2].should.be.within(0, 1); 795 | Should(actionVals[actionVals.length - 1]).equal(undefined); 796 | done(); 797 | }, 80); 798 | }); 799 | }); 800 | 801 | describe('change detection', function () { 802 | it('should not write transform changes to DOM if transforms haven\'t changed', function (done) { 803 | var called = 0; 804 | 805 | var transformPart = { 806 | el: mock, 807 | transforms: [ 808 | ['scale', function (i) { 809 | called++; 810 | return 1; 811 | }] 812 | ] 813 | }; 814 | 815 | transformer = new Transformer([ transformPart ]); 816 | 817 | interval = setInterval(function () { 818 | if (called === 1) { 819 | transformPart._stagedData.transforms.should.deepEqual([['scale', '1']]); 820 | } 821 | 822 | if (called > 1) { 823 | // These need to be startsWith or phantomjs will fail 824 | transformPart._stagedData.transforms.toString().should.startWith('Symbol(unchanged)'); 825 | clearInterval(interval); 826 | done(); 827 | } 828 | }, 5); 829 | }); 830 | 831 | it('should not write style changes to DOM if style hasn\'t changed', function (done) { 832 | var called = 0; 833 | 834 | var transformPart = { 835 | el: mock, 836 | styles: [ 837 | ['opacity', function (i) { 838 | called++; 839 | return 1; 840 | }] 841 | ] 842 | }; 843 | 844 | transformer = new Transformer([ transformPart ]); 845 | 846 | interval = setInterval(function () { 847 | if (called === 1) { 848 | transformPart._stagedData.styles.opacity.should.equal('1'); 849 | } 850 | 851 | if (called > 1) { 852 | transformPart._stagedData.styles.opacity.toString().should.startWith('Symbol(unchanged)'); 853 | clearInterval(interval); 854 | done(); 855 | } 856 | }, 5); 857 | }); 858 | 859 | it('should not write attr changes to DOM if they haven\'t changed', function (done) { 860 | var called = 0; 861 | 862 | var transformPart = { 863 | el: mock, 864 | attrs: [ 865 | ['data-test', function (i) { 866 | called++; 867 | return 'foo'; 868 | }] 869 | ] 870 | }; 871 | 872 | transformer = new Transformer([ transformPart ]); 873 | 874 | interval = setInterval(function () { 875 | if (called === 1) { 876 | transformPart._stagedData.attrs['data-test'].should.equal('foo'); 877 | } 878 | 879 | if (called > 1) { 880 | transformPart._stagedData.attrs['data-test'].toString().should.startWith('Symbol(unchanged)'); 881 | clearInterval(interval); 882 | done(); 883 | } 884 | }, 5); 885 | }); 886 | 887 | it('should handle partial transform UNCHANGEDs', function (done) { 888 | var called = 0; 889 | 890 | transformer = new Transformer([ 891 | { 892 | el: mock, 893 | transforms: [ 894 | ['scale', function (actions) { 895 | return 2; 896 | }], 897 | ['translate', function (i) { 898 | called++; 899 | return i; 900 | }, 'px'] 901 | ] 902 | } 903 | ]); 904 | 905 | var startTransform; 906 | 907 | interval = setInterval(function () { 908 | if (called === 1) { 909 | startTransform = mock.style.transform; 910 | startTransform.should.equal('scale(2) translate(0px)'); 911 | } 912 | 913 | if (called > 1) { 914 | clearInterval(interval); 915 | mock.style.transform.should.startWith('scale(2) translate('); 916 | mock.style.transform.should.not.equal(startTransform); 917 | done(); 918 | } 919 | }, 5); 920 | }); 921 | }); 922 | 923 | describe('Custom variables', function () { 924 | it('should allow custom variables', function (done) { 925 | var lastI = -1; 926 | var called = 0; 927 | 928 | transformer = new Transformer([ 929 | { 930 | el: mock, 931 | styles: [ 932 | ['opacity', function (customI) { 933 | called++; 934 | lastI = customI; 935 | }] 936 | ] 937 | } 938 | ]); 939 | 940 | var i = 0; 941 | transformer.addVariable('customI', function () { 942 | return i++; 943 | }); 944 | 945 | interval = setInterval(function () { 946 | (lastI + 1).should.equal(called); 947 | 948 | if (called > 3) { 949 | clearInterval(interval); 950 | done(); 951 | } 952 | }, 5); 953 | }); 954 | 955 | it('should only call transform function when changed', function (done) { 956 | var called = 0; 957 | var lastMock; 958 | 959 | transformer = new Transformer([ 960 | { 961 | el: mock, 962 | styles: [ 963 | ['opacity', function (mock) { 964 | called++; 965 | lastMock = mock; 966 | }] 967 | ] 968 | } 969 | ]); 970 | 971 | var mockVar = 10; 972 | transformer.addVariable('mock', function () { 973 | return mockVar; 974 | }); 975 | 976 | interval = setInterval(function () { 977 | if (called === 1) { 978 | clearInterval(interval); 979 | 980 | lastMock.should.equal(10); 981 | 982 | setTimeout(function () { 983 | called.should.equal(1); 984 | 985 | mockVar = 25; 986 | 987 | interval = setInterval(function () { 988 | if (called === 2) { 989 | clearInterval(interval); 990 | 991 | lastMock.should.equal(25); 992 | 993 | setTimeout(function () { 994 | called.should.equal(2); 995 | 996 | done(); 997 | }, 35); 998 | } 999 | }, 5); 1000 | }, 35); 1001 | } 1002 | }, 5); 1003 | }); 1004 | }); 1005 | 1006 | describe('i increase rate', function () { 1007 | it('should increase by 1 when fps is less than optimal and mode is "count"', function (done) { 1008 | var called = 0; 1009 | var lastI; 1010 | 1011 | transformer = new Transformer([ 1012 | { 1013 | el: mock, 1014 | styles: [ 1015 | ['opacity', function (i) { 1016 | called++; 1017 | lastI = i; 1018 | }] 1019 | ] 1020 | } 1021 | ]); 1022 | 1023 | transformer.iIncrease.optimalFps = 120; 1024 | 1025 | interval = setInterval(function () { 1026 | if (called === 3) { 1027 | clearInterval(interval); 1028 | 1029 | lastI.should.equal(2); 1030 | 1031 | done(); 1032 | } 1033 | }, 5); 1034 | }); 1035 | 1036 | it('should increase by < 1 when fps is more than optimal and mode is "time"', function (done) { 1037 | var called = 0; 1038 | var lastI; 1039 | 1040 | transformer = new Transformer([ 1041 | { 1042 | el: mock, 1043 | styles: [ 1044 | ['opacity', function (i) { 1045 | called++; 1046 | lastI = i; 1047 | }] 1048 | ] 1049 | } 1050 | ]); 1051 | 1052 | transformer.iIncrease.optimalFps = 20; 1053 | 1054 | interval = setInterval(function () { 1055 | if (called === 3) { 1056 | clearInterval(interval); 1057 | 1058 | lastI.should.be.within(1.2, 1.6); 1059 | 1060 | done(); 1061 | } 1062 | }, 5); 1063 | }); 1064 | }); 1065 | }); --------------------------------------------------------------------------------