malevic
Version:
Malevič.js - minimalistic reactive UI library
472 lines (457 loc) • 14.2 kB
JavaScript
/* 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 };