UNPKG

animateplus

Version:

A+ animation module for the modern web

437 lines (363 loc) 11.5 kB
/* * Animate Plus v2.1.1 * Copyright (c) 2017-2018 Benjamin De Cock * http://animateplus.com/license */ // logic // ===== const first = ([item]) => item; const computeValue = (value, index) => typeof value == "function" ? value(index) : value; // dom // === const getElements = elements => { if (Array.isArray(elements)) return elements; if (!elements || elements.nodeType) return [elements]; return Array.from(typeof elements == "string" ? document.querySelectorAll(elements) : elements); }; const accelerate = ({style}, keyframes) => style.willChange = keyframes ? keyframes.map(({property}) => property).join() : "auto"; const createSVG = (element, attributes) => Object.entries(attributes).reduce((node, [attribute, value]) => { node.setAttribute(attribute, value); return node; }, document.createElementNS("http://www.w3.org/2000/svg", element)); // motion blur // =========== const blurs = { axes: ["x", "y"], count: 0, add({element, blur}) { const id = `motion-blur-${this.count++}`; const svg = createSVG("svg", { style: "position: absolute; width: 0; height: 0" }); const filter = createSVG("filter", this.axes.reduce((attributes, axis) => { const offset = blur[axis] * 2; attributes[axis] = `-${offset}%`; attributes[axis == "x" ? "width" : "height"] = `${100 + offset * 2}%`; return attributes; },{ id, "color-interpolation-filters": "sRGB" })); const gaussian = createSVG("feGaussianBlur", { in: "SourceGraphic" }); filter.append(gaussian); svg.append(filter); element.style.filter = `url("#${id}")`; document.body.prepend(svg); return gaussian; } }; const getDeviation = (blur, {easing}, curve) => { const progress = blur * curve; const out = blur - progress; const deviation = (() => { if (easing == "linear") return blur; if (easing.startsWith("in-out")) return (curve < .5 ? progress : out) * 2; if (easing.startsWith("in")) return progress; return out; })(); return Math.max(0, deviation); }; const setDeviation = ({blur, gaussian, easing}, curve) => { const values = blurs.axes.map(axis => getDeviation(blur[axis], easing, curve)); gaussian.setAttribute("stdDeviation", values.join()); }; const normalizeBlur = blur => { const defaults = blurs.axes.reduce((object, axis) => { object[axis] = 0; return object; }, {}); return Object.assign(defaults, blur); }; const clearBlur = ({style}, {parentNode: {parentNode: svg}}) => { style.filter = "none"; svg.remove(); }; // color conversion // ================ const hexPairs = color => { const split = color.split(""); const pairs = color.length < 5 ? split.map(string => string + string) : split.reduce((array, string, index) => { if (index % 2) array.push(split[index - 1] + string); return array; }, []); if (pairs.length < 4) pairs.push("ff"); return pairs; }; const convert = color => hexPairs(color).map(string => parseInt(string, 16)); const rgba = hex => { const color = hex.slice(1); const [r, g, b, a] = convert(color); return `rgba(${r}, ${g}, ${b}, ${a / 255})`; }; // easing equations // ================ const pi2 = Math.PI * 2; const getOffset = (strength, period) => period / pi2 * Math.asin(1 / strength); const easings = { "linear": progress => progress, "in-cubic": progress => progress ** 3, "in-quartic": progress => progress ** 4, "in-quintic": progress => progress ** 5, "in-exponential": progress => 1024 ** (progress - 1), "in-circular": progress => 1 - Math.sqrt(1 - progress ** 2), "in-elastic": (progress, amplitude, period) => { const strength = Math.max(amplitude, 1); const offset = getOffset(strength, period); return -(strength * 2 ** (10 * (progress -= 1)) * Math.sin((progress - offset) * pi2 / period)); }, "out-cubic": progress => --progress ** 3 + 1, "out-quartic": progress => 1 - --progress ** 4, "out-quintic": progress => --progress ** 5 + 1, "out-exponential": progress => 1 - 2 ** (-10 * progress), "out-circular": progress => Math.sqrt(1 - --progress ** 2), "out-elastic": (progress, amplitude, period) => { const strength = Math.max(amplitude, 1); const offset = getOffset(strength, period); return strength * 2 ** (-10 * progress) * Math.sin((progress - offset) * pi2 / period) + 1; }, "in-out-cubic": progress => (progress *= 2) < 1 ? .5 * progress ** 3 : .5 * ((progress -= 2) * progress ** 2 + 2), "in-out-quartic": progress => (progress *= 2) < 1 ? .5 * progress ** 4 : -.5 * ((progress -= 2) * progress ** 3 - 2), "in-out-quintic": progress => (progress *= 2) < 1 ? .5 * progress ** 5 : .5 * ((progress -= 2) * progress ** 4 + 2), "in-out-exponential": progress => (progress *= 2) < 1 ? .5 * 1024 ** (progress - 1) : .5 * (-(2 ** (-10 * (progress - 1))) + 2), "in-out-circular": progress => (progress *= 2) < 1 ? -.5 * (Math.sqrt(1 - progress ** 2) - 1) : .5 * (Math.sqrt(1 - (progress -= 2) * progress) + 1), "in-out-elastic": (progress, amplitude, period) => { const strength = Math.max(amplitude, 1); const offset = getOffset(strength, period); return (progress *= 2) < 1 ? -.5 * (strength * 2 ** (10 * (progress -= 1)) * Math.sin((progress - offset) * pi2 / period)) : strength * 2 ** (-10 * (progress -= 1)) * Math.sin((progress - offset) * pi2 / period) * .5 + 1; } }; const decomposeEasing = string => { const [easing, amplitude = 1, period = .4] = string.trim().split(" "); return {easing, amplitude, period}; }; const ease = ({easing, amplitude, period}, progress) => easings[easing](progress, amplitude, period); // keyframes composition // ===================== const extractRegExp = /-?\d*\.?\d+/g; const extractStrings = value => value.split(extractRegExp); const extractNumbers = value => value.match(extractRegExp).map(Number); const sanitize = values => values.map(value => { const string = String(value); return string.startsWith("#") ? rgba(string) : string; }); const addPropertyKeyframes = (property, values) => { const animatable = sanitize(values); const strings = extractStrings(first(animatable)); const numbers = animatable.map(extractNumbers); const round = first(strings).startsWith("rgb"); return {property, strings, numbers, round}; }; const createAnimationKeyframes = (keyframes, index) => Object.entries(keyframes).map(([property, values]) => addPropertyKeyframes(property, computeValue(values, index))); const getCurrentValue = (from, to, easing) => from + (to - from) * easing; const recomposeValue = ([from, to], strings, round, easing) => strings.reduce((style, string, index) => { const previous = index - 1; const value = getCurrentValue(from[previous], to[previous], easing); return style + (round && index < 4 ? Math.round(value) : value) + string; }); const createStyles = (keyframes, easing) => keyframes.reduce((styles, {property, numbers, strings, round}) => { styles[property] = recomposeValue(numbers, strings, round, easing); return styles; }, {}); const reverseKeyframes = keyframes => keyframes.forEach(({numbers}) => numbers.reverse()); // animation tracking // ================== const rAF = { all: new Set, add(object) { if (this.all.add(object).size < 2) requestAnimationFrame(tick); } }; const paused = {}; const trackTime = (timing, now) => { if (!timing.startTime) timing.startTime = now; timing.elapsed = now - timing.startTime; }; const resetTime = object => object.startTime = 0; const getProgress = ({elapsed, duration}) => duration > 0 ? Math.min(elapsed / duration, 1) : 1; const setSpeed = (speed, value, index) => speed > 0 ? computeValue(value, index) / speed : 0; const addAnimations = (options, resolve) => { const { elements = null, easing = "out-elastic", duration = 1000, delay: timeout = 0, speed = 1, loop = false, optimize = false, direction = "normal", blur = null, change = null, ...rest } = options; const last = { totalDuration: -1 }; getElements(elements).forEach(async (element, index) => { const keyframes = createAnimationKeyframes(rest, index); const animation = { element, keyframes, loop, optimize, direction, change, easing: decomposeEasing(easing), duration: setSpeed(speed, duration, index) }; const animationTimeout = setSpeed(speed, timeout, index); const totalDuration = animationTimeout + animation.duration; if (direction != "normal") reverseKeyframes(keyframes); if (element) { if (optimize) accelerate(element, keyframes); if (blur) { animation.blur = normalizeBlur(computeValue(blur, index)); animation.gaussian = blurs.add(animation); } } if (totalDuration > last.totalDuration) { last.animation = animation; last.totalDuration = totalDuration; } if (animationTimeout) await delay(animationTimeout); rAF.add(animation); }); const {animation} = last; if (!animation) return; animation.end = resolve; animation.options = options; }; const tick = now => { const {all} = rAF; all.forEach(object => { trackTime(object, now); const progress = getProgress(object); const { element, keyframes, loop, optimize, direction, change, easing, duration, gaussian, end, options } = object; // object is an animation if (direction) { let curve = progress; switch (progress) { case 0: if (direction == "alternate") reverseKeyframes(keyframes); break; case 1: if (loop) resetTime(object); else { all.delete(object); if (optimize && element) accelerate(element); if (gaussian) clearBlur(element, gaussian); } if (end) end(options); break; default: curve = ease(easing, progress); } if (gaussian) setDeviation(object, curve); if (change && end) change(curve); if (element) Object.assign(element.style, createStyles(keyframes, curve)); return; } // object is a delay if (progress < 1) return; all.delete(object); end(duration); }); if (all.size) requestAnimationFrame(tick); }; document.addEventListener("visibilitychange", () => { const now = performance.now(); if (document.hidden) { const {all} = rAF; paused.time = now; paused.all = new Set(all); all.clear(); return; } const {all, time} = paused; if (!all) return; const elapsed = now - time; requestAnimationFrame(() => all.forEach(object => { object.startTime += elapsed; rAF.add(object); })); }); // exports // ======= export default options => new Promise(resolve => addAnimations(options, resolve)); export const delay = duration => new Promise(resolve => rAF.add({ duration, end: resolve })); export const stop = elements => { const {all} = rAF; const nodes = getElements(elements); all.forEach(object => { if (nodes.includes(object.element)) all.delete(object); }); return nodes; };