UNPKG

malevic

Version:

Malevič.js - minimalistic reactive UI library

472 lines (457 loc) 14.2 kB
/* malevic@0.20.2 - Aug 10, 2024 */ import * as malevicDOM from 'malevic/dom'; import * as malevicString from 'malevic/string'; import { escapeHTML } from 'malevic/string'; function isObject(value) { return value != null && typeof value === 'object'; } function isPlainObject(value) { return isObject(value) && Object.getPrototypeOf(value) === Object.prototype; } function last(items, index = 0) { const target = items.length - 1 - index; return items[target]; } function interpolate(t, from, to) { return from * (1 - t) + to * t; } const interpolateNumbers = function (from, to) { return (t) => interpolate(t, from, to); }; function createNumRegExp() { return /[-+]?(?:\d+\.?\d*|\.?\d+)(?:[e][-+]?\d+)?/gim; } function getNumPositions(line) { const positions = []; const regexp = createNumRegExp(); let match; while ((match = regexp.exec(line))) { positions.push({ index: match.index, length: match[0].length }); } return positions; } const interpolateNumbersInString = function (from, to) { const posFrom = getNumPositions(from); const posTo = getNumPositions(to); return (t) => { let result = ''; let na, nb, n; let last = 0; for (let i = 0; i < posTo.length; i++) { result += to.substring(last, posTo[i].index); na = parseFloat(from.substr(posFrom[i].index, posFrom[i].length)); nb = parseFloat(to.substr(posTo[i].index, posTo[i].length)); n = interpolate(t, na, nb); result += n.toString(); last = posTo[i].index + posTo[i].length; } result += to.substring(last); return result; }; }; function identity(x) { return x; } const defaultTiming = { delay: 0, duration: 250, easing: 'ease', }; class AnimationDeclaration { constructor() { this.$spec = { initial: null, timeline: [], interpolate: null, output: identity, done: null, }; } initial(value) { this.$spec.initial = value; return this; } from(from) { if (this.$spec.timeline.length > 0) { throw new Error('Starting keyframe was already declared'); } this.$spec.timeline.push({ from, to: null, timing: Object.assign({}, defaultTiming), }); return this; } to(to, timing) { if (!this.$spec.interpolate) { if (typeof to === 'number') { this.$spec.interpolate = interpolateNumbers; } else if (typeof to === 'string') { this.$spec.interpolate = interpolateNumbersInString; } } const last$1 = last(this.$spec.timeline); if (last$1 && last$1.to == null) { last$1.to = to; if (timing) { last$1.timing = Object.assign(Object.assign({}, last$1.timing), timing); } } else { this.$spec.timeline.push({ from: last$1 ? last$1.to : null, to, timing: Object.assign(Object.assign({}, defaultTiming), (timing ? timing : {})), }); } return this; } interpolate(interpolate) { this.$spec.interpolate = interpolate; return this; } output(output) { this.$spec.output = output; return this; } done(callback) { this.$spec.done = callback; return this; } spec() { return this.$spec; } } function styles(declarations) { return Object.keys(declarations) .filter((cssProp) => declarations[cssProp] != null) .map((cssProp) => `${cssProp}: ${declarations[cssProp]};`) .join(' '); } function setInlineCSSPropertyValue(element, prop, $value) { if ($value != null && $value !== '') { let value = String($value); let important = ''; if (value.endsWith('!important')) { value = value.substring(0, value.length - 10); important = 'important'; } element.style.setProperty(prop, value, important); } else { element.style.removeProperty(prop); } } function clamp(x, min, max) { return Math.min(max, Math.max(min, x)); } const easings = { linear: (t) => t, ease: (t) => { const t0 = (1 - Math.cos(t * Math.PI)) / 2; const t1 = Math.sqrt(1 - Math.pow(t - 1, 2)); const t2 = Math.sin((t * Math.PI) / 2); return t0 * (1 - t2) + t1 * t2; }, 'ease-in': (t) => { const r = 1 - Math.cos((t * Math.PI) / 2); return r > 1 - 1e-15 ? 1 : r; }, 'ease-out': (t) => Math.sin((t * Math.PI) / 2), 'ease-in-out': (t) => (1 - Math.cos(t * Math.PI)) / 2, }; class Animation { constructor(spec, callback) { if (!spec.interpolate) { throw new Error('No interpolator provided'); } this.interpolate = spec.interpolate; this.output = spec.output; this.callback = callback; this.doneCallback = spec.done; let total = 0; this.timeline = spec.timeline.map((spec) => { const standby = total; const start = standby + spec.timing.delay; const end = start + spec.timing.duration; total = end; return { standby, start, end, spec, interpolate: null }; }); this.isComplete = false; } tick(time) { if (this.startTime == null) { this.startTime = time; } const duration = time - this.startTime; const { timeline } = this; let interval; for (let i = timeline.length - 1; i >= 0; i--) { const { standby, end } = timeline[i]; if (duration >= end || (duration >= standby && duration <= end)) { interval = timeline[i]; break; } } const { start, end, spec } = interval; if (interval === last(timeline) && duration >= end) { this.isComplete = true; } if (!interval.interpolate) { interval.interpolate = this.interpolate(spec.from, spec.to); } const ease = typeof spec.timing.easing === 'string' ? easings[spec.timing.easing] : spec.timing.easing; const t = duration < start ? 0 : start === end ? 1 : clamp((duration - start) / (end - start), 0, 1); const eased = ease(t); const value = interval.interpolate.call(null, eased); this.lastValue = value; const output = this.output.call(null, value); this.callback.call(null, output); } value() { return this.lastValue; } complete() { return this.isComplete; } finalize() { if (this.doneCallback) { this.doneCallback.call(null); } } } function createTimer() { let currentTime = null; let frameId = null; let isRunning = false; const callbacks = []; function work() { currentTime = performance.now(); callbacks.forEach((cb) => cb(currentTime)); if (isRunning) { frameId = requestAnimationFrame(work); } } function run() { isRunning = true; currentTime = performance.now(); frameId = requestAnimationFrame(work); } function stop() { cancelAnimationFrame(frameId); frameId = null; currentTime = null; isRunning = false; } function tick(callback) { callbacks.push(callback); } function time() { return currentTime; } function running() { return isRunning; } return { run, stop, tick, time, running, }; } const timer = createTimer(); timer.tick((time) => Array.from(scheduledAnimations.values()).forEach((animation) => animationTick(animation, time))); function animationTick(animation, time) { animation.tick(time); if (animation.complete()) { cancelAnimation(animation); animation.finalize(); } } const animationsByDeclaration = new WeakMap(); const scheduledAnimations = new Set(); function scheduleAnimation(declaration, tick) { const animation = new Animation(declaration.spec(), tick); scheduledAnimations.add(animation); animationsByDeclaration.set(declaration, animation); !timer.running() && timer.run(); animationTick(animation, timer.time()); } function cancelAnimation(animation) { scheduledAnimations.delete(animation); if (scheduledAnimations.size === 0) { timer.stop(); } } function getScheduledAnimation(declaration) { const animation = animationsByDeclaration.get(declaration); if (animation && scheduledAnimations.has(animation)) { return animation; } return null; } function isAnimatedStyleObj(value) { return (isPlainObject(value) && Object.values(value).some((v) => v instanceof AnimationDeclaration)); } function handleAnimationDeclaration(value, prev, callback) { const spec = value.spec(); const specStartValue = spec.timeline[0].from; const specEndValue = last(spec.timeline).to; let prevEndValue; if (prev instanceof AnimationDeclaration) { const prevAnimation = getScheduledAnimation(prev); if (prevAnimation) { cancelAnimation(prevAnimation); prevEndValue = prevAnimation.value(); } else { const prevSpec = prev.spec(); prevEndValue = last(prevSpec.timeline).to; } } else if (typeof prev === typeof specEndValue && prev != null && specEndValue != null && prev.constructor === specEndValue.constructor) { prevEndValue = prev; } let startFrom; if (specStartValue != null) { startFrom = specStartValue; } else if (prevEndValue != null) { startFrom = prevEndValue; } else if (spec.initial != null) { startFrom = spec.initial; } if (startFrom == null) { const endValue = spec.output(specEndValue); callback(endValue); } else { spec.timeline[0].from = startFrom; scheduleAnimation(value, callback); } } function tryCancelAnimation(value) { if (value instanceof AnimationDeclaration) { const animation = getScheduledAnimation(value); if (animation) { cancelAnimation(animation); } } } const setAttributePlugin = ({ element, attr, value, prev, }) => { if (value instanceof AnimationDeclaration) { handleAnimationDeclaration(value, prev, (output) => element.setAttribute(attr, output)); return true; } else if (isAnimatedStyleObj(prev)) { Object.values(prev).forEach((v) => tryCancelAnimation(v)); } else { tryCancelAnimation(prev); } return null; }; const setStyleAttributePlugin = ({ element, attr, value, prev, }) => { if (attr === 'style') { if (isAnimatedStyleObj(value)) { const newStyle = value; let prevStyle; if (isPlainObject(prev)) { prevStyle = prev; Object.keys(prevStyle) .filter((prop) => !newStyle.hasOwnProperty(prop)) .forEach((prop) => { tryCancelAnimation(prevStyle[prop]); setInlineCSSPropertyValue(element, prop, null); }); } else { prevStyle = {}; element.removeAttribute('style'); tryCancelAnimation(prev); } Object.entries(newStyle).forEach(([prop, v]) => { const prevValue = prevStyle[prop]; if (v instanceof AnimationDeclaration) { handleAnimationDeclaration(v, prevValue, (output) => { setInlineCSSPropertyValue(element, prop, output); }); } else { tryCancelAnimation(prevValue); setInlineCSSPropertyValue(element, prop, v); } }); return true; } else if (isAnimatedStyleObj(prev)) { Object.values(prev).forEach((v) => tryCancelAnimation(v)); } } return null; }; function getStartOutput(spec) { return spec.output(spec.timeline[0].from != null ? spec.timeline[0].from : spec.initial != null ? spec.initial : last(spec.timeline).to); } const stringifyAttributePlugin = ({ value, }) => { if (value instanceof AnimationDeclaration) { const spec = value.spec(); return escapeHTML(String(getStartOutput(spec))); } return null; }; const stringifyStyleAttrPlugin = ({ attr, value }) => { if (attr === 'style' && isAnimatedStyleObj(value)) { const style = {}; Object.keys(value).forEach((prop) => { const v = value[prop]; if (v instanceof AnimationDeclaration) { const spec = v.spec(); style[prop] = getStartOutput(spec); } else { style[prop] = v; } }); return escapeHTML(styles(style)); } return null; }; function animate(to, timing) { const declaration = new AnimationDeclaration(); if (to != null) { declaration.to(to, timing); } return declaration; } function withAnimation(type) { if (malevicDOM) { const domPlugins = malevicDOM.plugins; domPlugins.setAttribute.add(type, setAttributePlugin); domPlugins.setAttribute.add(type, setStyleAttributePlugin); } if (malevicString) { const stringPlugins = malevicString.plugins; stringPlugins.stringifyAttribute.add(type, stringifyAttributePlugin); stringPlugins.stringifyAttribute.add(type, stringifyStyleAttrPlugin); } return type; } export { animate, withAnimation };