animal.js 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495
  1. (function (global, factory) {
  2. if (typeof exports === 'object' && typeof module !== 'undefined') {
  3. module.exports = factory();
  4. } else if (typeof define === 'function' && define.amd) {
  5. define(factory);
  6. } else {
  7. const api = factory();
  8. // Main global: $ (jQuery-like selector + utilities)
  9. global.$ = api;
  10. // Backward-compatible aliases
  11. global.xjs = api;
  12. global.animal = api;
  13. }
  14. }(this, (function () {
  15. 'use strict';
  16. // --- Utils ---
  17. const isArr = (a) => Array.isArray(a);
  18. const isStr = (s) => typeof s === 'string';
  19. const isFunc = (f) => typeof f === 'function';
  20. const isNil = (v) => v === undefined || v === null;
  21. const isSVG = (el) => (typeof SVGElement !== 'undefined' && el instanceof SVGElement) || ((typeof Element !== 'undefined') && (el instanceof Element) && el.namespaceURI === 'http://www.w3.org/2000/svg');
  22. const isEl = (v) => (typeof Element !== 'undefined') && (v instanceof Element);
  23. const clamp01 = (n) => Math.max(0, Math.min(1, n));
  24. const toKebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
  25. const toArray = (targets) => {
  26. if (isArr(targets)) return targets;
  27. if (isStr(targets)) {
  28. if (typeof document === 'undefined') return [];
  29. return Array.from(document.querySelectorAll(targets));
  30. }
  31. if ((typeof NodeList !== 'undefined') && (targets instanceof NodeList)) return Array.from(targets);
  32. if ((typeof Element !== 'undefined') && (targets instanceof Element)) return [targets];
  33. if ((typeof Window !== 'undefined') && (targets instanceof Window)) return [targets];
  34. // Plain objects (for anime.js-style object tweening)
  35. if (!isNil(targets) && (typeof targets === 'object' || isFunc(targets))) return [targets];
  36. return [];
  37. };
  38. // Selection helper (chainable, still Array-compatible)
  39. const _layerHandlerByEl = (typeof WeakMap !== 'undefined') ? new WeakMap() : null;
  40. class Selection extends Array {
  41. animate(params) { return animate(this, params); }
  42. draw(params = {}) { return svgDraw(this, params); }
  43. inViewAnimate(params, options = {}) { return inViewAnimate(this, params, options); }
  44. // Layer.js plugin-style helper:
  45. // const $ = animal.$;
  46. // $('.btn').layer({ title: 'Hi' });
  47. // Clicking the element will open Layer.
  48. layer(options) {
  49. const LayerCtor =
  50. (typeof window !== 'undefined' && window.Layer) ? window.Layer :
  51. (typeof globalThis !== 'undefined' && globalThis.Layer) ? globalThis.Layer :
  52. (typeof Layer !== 'undefined' ? Layer : null);
  53. if (!LayerCtor) return this;
  54. this.forEach((el) => {
  55. if (!isEl(el)) return;
  56. // De-dupe / replace previous binding
  57. if (_layerHandlerByEl) {
  58. const prev = _layerHandlerByEl.get(el);
  59. if (prev) el.removeEventListener('click', prev);
  60. }
  61. const handler = () => {
  62. let opts = options;
  63. if (isFunc(options)) {
  64. opts = options(el);
  65. } else if (isNil(options)) {
  66. opts = null;
  67. }
  68. // If no explicit options, allow data-* configuration
  69. if (!opts || (typeof opts === 'object' && Object.keys(opts).length === 0)) {
  70. const d = el.dataset || {};
  71. opts = {
  72. title: d.layerTitle || el.getAttribute('data-layer-title') || (el.textContent || '').trim(),
  73. text: d.layerText || el.getAttribute('data-layer-text') || '',
  74. icon: d.layerIcon || el.getAttribute('data-layer-icon') || null,
  75. showCancelButton: (d.layerCancel === 'true') || (el.getAttribute('data-layer-cancel') === 'true')
  76. };
  77. }
  78. // Prefer builder API if present, fallback to static fire.
  79. if (LayerCtor.$ && isFunc(LayerCtor.$)) {
  80. const inst = LayerCtor.$(opts);
  81. return (inst.run ? inst.run() : inst.fire());
  82. }
  83. if (LayerCtor.run && isFunc(LayerCtor.run)) return LayerCtor.run(opts);
  84. if (LayerCtor.fire && isFunc(LayerCtor.fire)) return LayerCtor.fire(opts);
  85. };
  86. if (_layerHandlerByEl) _layerHandlerByEl.set(el, handler);
  87. el.addEventListener('click', handler);
  88. });
  89. return this;
  90. }
  91. // Remove click bindings added by `.layer()`
  92. unlayer() {
  93. this.forEach((el) => {
  94. if (!isEl(el)) return;
  95. if (!_layerHandlerByEl) return;
  96. const prev = _layerHandlerByEl.get(el);
  97. if (prev) el.removeEventListener('click', prev);
  98. _layerHandlerByEl.delete(el);
  99. });
  100. return this;
  101. }
  102. // jQuery-style click handler
  103. // - click(fn): bind handler with `this` as element
  104. // - click(layerInstance): bind Layer instance to click (auto fire)
  105. // - click(options): open Layer with options on click
  106. click(handler) {
  107. const LayerCtor =
  108. (typeof window !== 'undefined' && window.Layer) ? window.Layer :
  109. (typeof globalThis !== 'undefined' && globalThis.Layer) ? globalThis.Layer :
  110. (typeof Layer !== 'undefined' ? Layer : null);
  111. this.forEach((el) => {
  112. if (!isEl(el)) return;
  113. let cb = handler;
  114. if (handler && (typeof handler.run === 'function' || typeof handler.fire === 'function')) {
  115. // Layer instance: defer auto-fire and trigger on click
  116. try { handler._deferAuto = true; } catch {}
  117. cb = (e) => {
  118. try { handler._triggerEl = el; handler._triggerEvent = e; } catch {}
  119. try { return handler.run ? handler.run() : handler.fire(); } catch {}
  120. };
  121. } else if (!isFunc(handler)) {
  122. // Options object: open Layer on click
  123. const opts = handler;
  124. cb = (e) => {
  125. if (!LayerCtor) {
  126. console.warn('Layer module not loaded.');
  127. return;
  128. }
  129. let finalOpts = opts;
  130. if (isFunc(opts)) {
  131. finalOpts = opts.call(el, e);
  132. }
  133. if (finalOpts && typeof finalOpts === 'object' && !('triggerEl' in finalOpts)) {
  134. finalOpts.triggerEl = el;
  135. }
  136. if (LayerCtor.$ && isFunc(LayerCtor.$)) {
  137. const inst = LayerCtor.$(finalOpts);
  138. return (inst.run ? inst.run() : inst.fire());
  139. }
  140. if (LayerCtor.run && isFunc(LayerCtor.run)) return LayerCtor.run(finalOpts);
  141. if (LayerCtor.fire && isFunc(LayerCtor.fire)) return LayerCtor.fire(finalOpts);
  142. };
  143. } else {
  144. cb = (e) => handler.call(el, e);
  145. }
  146. el.addEventListener('click', cb);
  147. });
  148. return this;
  149. }
  150. }
  151. const $ = (targets) => Selection.from(toArray(targets));
  152. const UNITLESS_KEYS = ['opacity', 'scale', 'scaleX', 'scaleY', 'scaleZ', 'zIndex', 'fontWeight', 'strokeDashoffset', 'strokeDasharray', 'strokeWidth'];
  153. const getUnit = (val, prop) => {
  154. if (UNITLESS_KEYS.includes(prop)) return '';
  155. const split = /[+-]?\d*\.?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?(%|px|pt|em|rem|in|cm|mm|ex|ch|pc|vw|vh|vmin|vmax|deg|rad|turn)?$/.exec(val);
  156. return split ? split[1] : undefined;
  157. };
  158. const isCssVar = (k) => isStr(k) && k.startsWith('--');
  159. // Keep this intentionally broad so "unknown" config keys don't accidentally become animated props.
  160. // Prefer putting non-anim-prop config under `params.options`.
  161. const isAnimOptionKey = (k) => [
  162. 'options',
  163. 'duration', 'delay', 'easing', 'direction', 'fill', 'loop', 'endDelay', 'autoplay',
  164. 'update', 'begin', 'complete',
  165. // WAAPI-ish common keys (ignored unless we explicitly support them)
  166. 'iterations', 'iterationStart', 'iterationComposite', 'composite', 'playbackRate',
  167. // Spring helpers
  168. 'springFrames'
  169. ].includes(k);
  170. const isEasingFn = (e) => typeof e === 'function';
  171. // Minimal cubic-bezier implementation (for JS engine easing)
  172. function cubicBezier(x1, y1, x2, y2) {
  173. // Inspired by https://github.com/gre/bezier-easing (simplified)
  174. const NEWTON_ITERATIONS = 4;
  175. const NEWTON_MIN_SLOPE = 0.001;
  176. const SUBDIVISION_PRECISION = 0.0000001;
  177. const SUBDIVISION_MAX_ITERATIONS = 10;
  178. const kSplineTableSize = 11;
  179. const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0);
  180. const float32ArraySupported = typeof Float32Array === 'function';
  181. function A(aA1, aA2) { return 1.0 - 3.0 * aA2 + 3.0 * aA1; }
  182. function B(aA1, aA2) { return 3.0 * aA2 - 6.0 * aA1; }
  183. function C(aA1) { return 3.0 * aA1; }
  184. function calcBezier(aT, aA1, aA2) {
  185. return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT;
  186. }
  187. function getSlope(aT, aA1, aA2) {
  188. return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1);
  189. }
  190. function binarySubdivide(aX, aA, aB) {
  191. let currentX, currentT, i = 0;
  192. do {
  193. currentT = aA + (aB - aA) / 2.0;
  194. currentX = calcBezier(currentT, x1, x2) - aX;
  195. if (currentX > 0.0) aB = currentT;
  196. else aA = currentT;
  197. } while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS);
  198. return currentT;
  199. }
  200. function newtonRaphsonIterate(aX, aGuessT) {
  201. for (let i = 0; i < NEWTON_ITERATIONS; ++i) {
  202. const currentSlope = getSlope(aGuessT, x1, x2);
  203. if (currentSlope === 0.0) return aGuessT;
  204. const currentX = calcBezier(aGuessT, x1, x2) - aX;
  205. aGuessT -= currentX / currentSlope;
  206. }
  207. return aGuessT;
  208. }
  209. const sampleValues = float32ArraySupported ? new Float32Array(kSplineTableSize) : new Array(kSplineTableSize);
  210. for (let i = 0; i < kSplineTableSize; ++i) {
  211. sampleValues[i] = calcBezier(i * kSampleStepSize, x1, x2);
  212. }
  213. function getTForX(aX) {
  214. let intervalStart = 0.0;
  215. let currentSample = 1;
  216. const lastSample = kSplineTableSize - 1;
  217. for (; currentSample !== lastSample && sampleValues[currentSample] <= aX; ++currentSample) {
  218. intervalStart += kSampleStepSize;
  219. }
  220. --currentSample;
  221. const dist = (aX - sampleValues[currentSample]) / (sampleValues[currentSample + 1] - sampleValues[currentSample]);
  222. const guessForT = intervalStart + dist * kSampleStepSize;
  223. const initialSlope = getSlope(guessForT, x1, x2);
  224. if (initialSlope >= NEWTON_MIN_SLOPE) return newtonRaphsonIterate(aX, guessForT);
  225. if (initialSlope === 0.0) return guessForT;
  226. return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize);
  227. }
  228. return (x) => {
  229. if (x === 0 || x === 1) return x;
  230. return calcBezier(getTForX(x), y1, y2);
  231. };
  232. }
  233. function resolveJsEasing(easing, springValues) {
  234. if (isEasingFn(easing)) return easing;
  235. if (typeof easing === 'object' && easing && !Array.isArray(easing)) {
  236. // Spring: map linear progress -> simulated spring curve (0..1)
  237. const values = springValues || getSpringValues(easing);
  238. const last = Math.max(0, values.length - 1);
  239. return (t) => {
  240. const p = clamp01(t);
  241. const idx = p * last;
  242. const i0 = Math.floor(idx);
  243. const i1 = Math.min(last, i0 + 1);
  244. const frac = idx - i0;
  245. const v0 = values[i0] ?? p;
  246. const v1 = values[i1] ?? p;
  247. return v0 + (v1 - v0) * frac;
  248. };
  249. }
  250. if (Array.isArray(easing) && easing.length === 4) {
  251. return cubicBezier(easing[0], easing[1], easing[2], easing[3]);
  252. }
  253. // Common named easings
  254. switch (easing) {
  255. case 'linear': return (t) => t;
  256. case 'ease': return cubicBezier(0.25, 0.1, 0.25, 1);
  257. case 'ease-in': return cubicBezier(0.42, 0, 1, 1);
  258. case 'ease-out': return cubicBezier(0, 0, 0.58, 1);
  259. case 'ease-in-out': return cubicBezier(0.42, 0, 0.58, 1);
  260. default: return (t) => t;
  261. }
  262. }
  263. // --- Spring Physics (Simplified) ---
  264. // Returns an array of [time, value] or just value for WAAPI linear easing
  265. function spring({ stiffness = 100, damping = 10, mass = 1, velocity = 0, precision = 0.01 } = {}) {
  266. const values = [];
  267. let t = 0;
  268. const timeStep = 1 / 60; // 60fps simulation
  269. let current = 0;
  270. let v = velocity;
  271. const target = 1;
  272. let running = true;
  273. while (running && t < 10) { // Safety break at 10s
  274. const fSpring = -stiffness * (current - target);
  275. const fDamper = -damping * v;
  276. const a = (fSpring + fDamper) / mass;
  277. v += a * timeStep;
  278. current += v * timeStep;
  279. values.push(current);
  280. t += timeStep;
  281. if (Math.abs(current - target) < precision && Math.abs(v) < precision) {
  282. running = false;
  283. }
  284. }
  285. if (values[values.length - 1] !== 1) values.push(1);
  286. return values;
  287. }
  288. // Cache spring curves by config (perf: avoid recomputing for many targets)
  289. // LRU-ish capped cache to avoid unbounded growth in long-lived apps.
  290. const SPRING_CACHE_MAX = 50;
  291. const springCache = new Map();
  292. function springCacheGet(key) {
  293. const v = springCache.get(key);
  294. if (!v) return v;
  295. // refresh LRU
  296. springCache.delete(key);
  297. springCache.set(key, v);
  298. return v;
  299. }
  300. function springCacheSet(key, values) {
  301. if (springCache.has(key)) springCache.delete(key);
  302. springCache.set(key, values);
  303. if (springCache.size > SPRING_CACHE_MAX) {
  304. const firstKey = springCache.keys().next().value;
  305. if (firstKey !== undefined) springCache.delete(firstKey);
  306. }
  307. }
  308. function getSpringValues(config = {}) {
  309. const {
  310. stiffness = 100,
  311. damping = 10,
  312. mass = 1,
  313. velocity = 0,
  314. precision = 0.01
  315. } = config || {};
  316. const key = `${stiffness}|${damping}|${mass}|${velocity}|${precision}`;
  317. const cached = springCacheGet(key);
  318. if (cached) return cached;
  319. const values = spring({ stiffness, damping, mass, velocity, precision });
  320. springCacheSet(key, values);
  321. return values;
  322. }
  323. function downsample(values, maxFrames = 120) {
  324. if (!values || values.length <= maxFrames) return values || [];
  325. const out = [];
  326. const lastIndex = values.length - 1;
  327. const step = lastIndex / (maxFrames - 1);
  328. for (let i = 0; i < maxFrames; i++) {
  329. const idx = i * step;
  330. const i0 = Math.floor(idx);
  331. const i1 = Math.min(lastIndex, i0 + 1);
  332. const frac = idx - i0;
  333. const v0 = values[i0];
  334. const v1 = values[i1];
  335. out.push(v0 + (v1 - v0) * frac);
  336. }
  337. if (out[out.length - 1] !== 1) out[out.length - 1] = 1;
  338. return out;
  339. }
  340. // --- WAAPI Core ---
  341. const TRANSFORMS = ['translateX', 'translateY', 'translateZ', 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'scale', 'scaleX', 'scaleY', 'scaleZ', 'skew', 'skewX', 'skewY', 'perspective', 'x', 'y'];
  342. const ALIASES = {
  343. x: 'translateX',
  344. y: 'translateY',
  345. z: 'translateZ'
  346. };
  347. // Basic rAF loop for non-WAAPI props or fallback
  348. class RafEngine {
  349. constructor() {
  350. this.animations = [];
  351. this.tick = this.tick.bind(this);
  352. this.running = false;
  353. }
  354. add(anim) {
  355. this.animations.push(anim);
  356. if (!this.running) {
  357. this.running = true;
  358. requestAnimationFrame(this.tick);
  359. }
  360. }
  361. remove(anim) {
  362. this.animations = this.animations.filter(a => a !== anim);
  363. }
  364. tick(t) {
  365. const now = t;
  366. this.animations = this.animations.filter(anim => {
  367. return anim.tick(now); // return true to keep
  368. });
  369. if (this.animations.length) {
  370. requestAnimationFrame(this.tick);
  371. } else {
  372. this.running = false;
  373. }
  374. }
  375. }
  376. const rafEngine = new RafEngine();
  377. // --- WAAPI update sampler (only used when update callback is provided) ---
  378. class WaapiUpdateSampler {
  379. constructor() {
  380. this.items = new Set();
  381. this._running = false;
  382. this._tick = this._tick.bind(this);
  383. }
  384. add(item) {
  385. this.items.add(item);
  386. if (!this._running) {
  387. this._running = true;
  388. requestAnimationFrame(this._tick);
  389. }
  390. }
  391. remove(item) {
  392. this.items.delete(item);
  393. }
  394. _tick(t) {
  395. if (!this.items.size) {
  396. this._running = false;
  397. return;
  398. }
  399. let anyActive = false;
  400. let anyChanged = false;
  401. this.items.forEach((item) => {
  402. const { anim, target, update } = item;
  403. if (!anim || !anim.effect) return;
  404. let dur = item._dur || 0;
  405. if (!dur) {
  406. const timing = readWaapiEffectTiming(anim.effect);
  407. dur = timing.duration || 0;
  408. item._dur = dur;
  409. }
  410. if (!dur) return;
  411. const p = clamp01((anim.currentTime || 0) / dur);
  412. if (anim.playState === 'running') anyActive = true;
  413. if (item._lastP !== p) {
  414. anyChanged = true;
  415. item._lastP = p;
  416. update({ target, progress: p, time: t });
  417. }
  418. if (anim.playState === 'finished' || anim.playState === 'idle') {
  419. this.items.delete(item);
  420. }
  421. });
  422. if (this.items.size && (anyActive || anyChanged)) {
  423. requestAnimationFrame(this._tick);
  424. } else {
  425. this._running = false;
  426. }
  427. }
  428. }
  429. const waapiUpdateSampler = new WaapiUpdateSampler();
  430. function readWaapiEffectTiming(effect) {
  431. // We compute this once per animation and cache it to avoid per-frame getComputedTiming().
  432. // We primarily cache `duration` to preserve existing behavior (progress maps to one iteration).
  433. let duration = 0;
  434. let endTime = 0;
  435. if (!effect) return { duration, endTime };
  436. try {
  437. const ct = effect.getComputedTiming ? effect.getComputedTiming() : null;
  438. if (ct) {
  439. if (typeof ct.duration === 'number' && Number.isFinite(ct.duration)) duration = ct.duration;
  440. if (typeof ct.endTime === 'number' && Number.isFinite(ct.endTime)) endTime = ct.endTime;
  441. }
  442. } catch (e) {
  443. // ignore
  444. }
  445. if (!endTime) endTime = duration || 0;
  446. return { duration, endTime };
  447. }
  448. function getDefaultUnit(prop) {
  449. if (!prop) return '';
  450. if (UNITLESS_KEYS.includes(prop)) return '';
  451. if (prop.startsWith('scale')) return '';
  452. if (prop === 'opacity') return '';
  453. if (prop.startsWith('rotate') || prop.startsWith('skew')) return 'deg';
  454. return 'px';
  455. }
  456. function normalizeTransformPartValue(prop, v) {
  457. if (typeof v !== 'number') return v;
  458. if (prop && prop.startsWith('scale')) return '' + v;
  459. if (prop && (prop.startsWith('rotate') || prop.startsWith('skew'))) return v + 'deg';
  460. return v + 'px';
  461. }
  462. // String number template: interpolate numeric tokens inside a string.
  463. // Useful for SVG path `d`, `points`, and other attributes that contain many numbers.
  464. const _PURE_NUMBER_RE = /^[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?(?:%|px|pt|em|rem|in|cm|mm|ex|ch|pc|vw|vh|vmin|vmax|deg|rad|turn)?$/;
  465. function isPureNumberLike(str) {
  466. return _PURE_NUMBER_RE.test(String(str || '').trim());
  467. }
  468. function countDecimals(numStr) {
  469. const s = String(numStr || '');
  470. const dot = s.indexOf('.');
  471. if (dot < 0) return 0;
  472. // Strip exponent part for decimal count
  473. const e = s.search(/[eE]/);
  474. const end = e >= 0 ? e : s.length;
  475. const frac = s.slice(dot + 1, end);
  476. // Ignore trailing zeros for display
  477. const trimmed = frac.replace(/0+$/, '');
  478. return Math.min(6, trimmed.length);
  479. }
  480. function parseNumberTemplate(str) {
  481. const s = String(str ?? '');
  482. const nums = [];
  483. const parts = [];
  484. let last = 0;
  485. const re = /[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?/g;
  486. let m;
  487. while ((m = re.exec(s))) {
  488. const start = m.index;
  489. const end = start + m[0].length;
  490. parts.push(s.slice(last, start));
  491. nums.push(parseFloat(m[0]));
  492. last = end;
  493. }
  494. parts.push(s.slice(last));
  495. return { nums, parts };
  496. }
  497. function extractNumberStrings(str) {
  498. // Use a fresh regex to avoid any `lastIndex` surprises.
  499. return String(str ?? '').match(/[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?/g) || [];
  500. }
  501. function formatInterpolatedNumber(n, decimals) {
  502. let v = n;
  503. if (!Number.isFinite(v)) v = 0;
  504. if (Math.abs(v) < 1e-12) v = 0;
  505. if (!decimals) return String(Math.round(v));
  506. // Use fixed, then strip trailing zeros/dot to keep strings compact.
  507. return v.toFixed(decimals).replace(/\.?0+$/, '');
  508. }
  509. // --- JS Interpolator (anime.js-ish, simplified) ---
  510. class JsAnimation {
  511. constructor(target, propValues, opts = {}, callbacks = {}) {
  512. this.target = target;
  513. this.propValues = propValues;
  514. this.duration = opts.duration ?? 1000;
  515. this.delay = opts.delay ?? 0;
  516. this.direction = opts.direction ?? 'normal';
  517. this.loop = opts.loop ?? 1;
  518. this.endDelay = opts.endDelay ?? 0;
  519. this.easing = opts.easing ?? 'linear';
  520. this.autoplay = opts.autoplay !== false;
  521. this.update = callbacks.update;
  522. this.begin = callbacks.begin;
  523. this.complete = callbacks.complete;
  524. this._resolve = null;
  525. this.finished = new Promise((res) => (this._resolve = res));
  526. this._started = false;
  527. this._running = false;
  528. this._paused = false;
  529. this._cancelled = false;
  530. this._startTime = 0;
  531. this._progress = 0;
  532. this._didBegin = false;
  533. this._ease = resolveJsEasing(this.easing, opts.springValues);
  534. this._tween = this._buildTween();
  535. this.tick = this.tick.bind(this);
  536. if (this.autoplay) this.play();
  537. }
  538. _readCurrentValue(k) {
  539. const t = this.target;
  540. // JS object
  541. if (!isEl(t) && !isSVG(t)) return t[k];
  542. // scroll
  543. if (k === 'scrollTop' || k === 'scrollLeft') return t[k];
  544. // CSS var
  545. if (isEl(t) && isCssVar(k)) {
  546. const cs = getComputedStyle(t);
  547. return (cs.getPropertyValue(k) || t.style.getPropertyValue(k) || '').trim();
  548. }
  549. // style
  550. if (isEl(t)) {
  551. const cs = getComputedStyle(t);
  552. // Prefer computed style (kebab) because many props aren't direct keys on cs
  553. const v = cs.getPropertyValue(toKebab(k));
  554. if (v && v.trim()) return v.trim();
  555. // Fallback to inline style access
  556. if (k in t.style) return t.style[k];
  557. }
  558. // SVG
  559. // For many SVG presentation attributes (e.g. strokeDashoffset), style overrides attribute.
  560. // Prefer computed style / inline style when available, and fallback to attributes.
  561. if (isSVG(t)) {
  562. // Geometry attrs must remain attrs (not style)
  563. if (k === 'd' || k === 'points') {
  564. const attr = toKebab(k);
  565. if (t.hasAttribute(attr)) return t.getAttribute(attr);
  566. if (t.hasAttribute(k)) return t.getAttribute(k);
  567. return t.getAttribute(k);
  568. }
  569. try {
  570. const cs = getComputedStyle(t);
  571. const v = cs.getPropertyValue(toKebab(k));
  572. if (v && v.trim()) return v.trim();
  573. } catch (_) {}
  574. try {
  575. if (k in t.style) return t.style[k];
  576. } catch (_) {}
  577. const attr = toKebab(k);
  578. if (t.hasAttribute(attr)) return t.getAttribute(attr);
  579. if (t.hasAttribute(k)) return t.getAttribute(k);
  580. }
  581. // Generic property
  582. return t[k];
  583. }
  584. _writeValue(k, v) {
  585. const t = this.target;
  586. // JS object
  587. if (!isEl(t) && !isSVG(t)) {
  588. t[k] = v;
  589. return;
  590. }
  591. // scroll
  592. if (k === 'scrollTop' || k === 'scrollLeft') {
  593. t[k] = v;
  594. return;
  595. }
  596. // CSS var
  597. if (isEl(t) && isCssVar(k)) {
  598. t.style.setProperty(k, v);
  599. return;
  600. }
  601. // style
  602. if (isEl(t) && (k in t.style)) {
  603. t.style[k] = v;
  604. return;
  605. }
  606. // SVG
  607. // Prefer style for presentation attrs so it can animate when svgDraw initialized styles.
  608. if (isSVG(t)) {
  609. if (k === 'd' || k === 'points') {
  610. t.setAttribute(k, v);
  611. return;
  612. }
  613. try {
  614. if (k in t.style) {
  615. t.style[k] = v;
  616. return;
  617. }
  618. } catch (_) {}
  619. const attr = toKebab(k);
  620. t.setAttribute(attr, v);
  621. return;
  622. }
  623. // Fallback for path/d/points even if isSVG check somehow failed
  624. if ((k === 'd' || k === 'points') && isEl(t)) {
  625. t.setAttribute(k, v);
  626. return;
  627. }
  628. // Generic property
  629. t[k] = v;
  630. }
  631. _buildTween() {
  632. const tween = {};
  633. Object.keys(this.propValues).forEach((k) => {
  634. const raw = this.propValues[k];
  635. const fromRaw = isArr(raw) ? raw[0] : this._readCurrentValue(k);
  636. const toRaw = isArr(raw) ? raw[1] : raw;
  637. const fromStr = isNil(fromRaw) ? '0' : ('' + fromRaw).trim();
  638. const toStr = isNil(toRaw) ? '0' : ('' + toRaw).trim();
  639. const fromScalar = isPureNumberLike(fromStr);
  640. const toScalar = isPureNumberLike(toStr);
  641. // numeric tween (with unit preservation) — only when BOTH sides are pure scalars.
  642. if (fromScalar && toScalar) {
  643. const fromNum = parseFloat(fromStr);
  644. const toNum = parseFloat(toStr);
  645. const unit = getUnit(toStr, k) ?? getUnit(fromStr, k) ?? '';
  646. tween[k] = { type: 'number', from: fromNum, to: toNum, unit };
  647. return;
  648. }
  649. // string number-template tween (SVG path `d`, `points`, etc.)
  650. // Requires both strings to have the same count of numeric tokens (>= 2).
  651. const a = parseNumberTemplate(fromStr);
  652. const b = parseNumberTemplate(toStr);
  653. if (a.nums.length >= 2 && a.nums.length === b.nums.length && a.parts.length === b.parts.length) {
  654. const aStrs = extractNumberStrings(fromStr);
  655. const bStrs = extractNumberStrings(toStr);
  656. const decs = a.nums.map((_, i) => Math.max(countDecimals(aStrs[i]), countDecimals(bStrs[i])));
  657. tween[k] = { type: 'number-template', fromNums: a.nums, toNums: b.nums, parts: b.parts, decimals: decs };
  658. return;
  659. } else if ((k === 'd' || k === 'points') && (a.nums.length > 0 || b.nums.length > 0)) {
  660. console.warn(`[Animal.js] Morph mismatch for property "${k}".\nValues must have matching number count.`,
  661. { from: a.nums.length, to: b.nums.length, fromVal: fromStr, toVal: toStr }
  662. );
  663. }
  664. // Non-numeric: fall back to "switch" (still useful for seek endpoints)
  665. tween[k] = { type: 'discrete', from: fromStr, to: toStr };
  666. });
  667. return tween;
  668. }
  669. _apply(progress, time) {
  670. this._progress = clamp01(progress);
  671. Object.keys(this._tween).forEach((k) => {
  672. const t = this._tween[k];
  673. if (t.type === 'number') {
  674. const eased = this._ease ? this._ease(this._progress) : this._progress;
  675. const val = t.from + (t.to - t.from) * eased;
  676. if (!isEl(this.target) && !isSVG(this.target) && t.unit === '') {
  677. this._writeValue(k, val);
  678. } else {
  679. this._writeValue(k, (val + t.unit));
  680. }
  681. } else if (t.type === 'number-template') {
  682. const eased = this._ease ? this._ease(this._progress) : this._progress;
  683. const { fromNums, toNums, parts, decimals } = t;
  684. let out = parts[0] || '';
  685. for (let i = 0; i < fromNums.length; i++) {
  686. const v = fromNums[i] + (toNums[i] - fromNums[i]) * eased;
  687. out += formatInterpolatedNumber(v, decimals ? decimals[i] : 0);
  688. out += parts[i + 1] || '';
  689. }
  690. this._writeValue(k, out);
  691. } else {
  692. const val = this._progress >= 1 ? t.to : t.from;
  693. this._writeValue(k, val);
  694. }
  695. });
  696. if (this.update) this.update({ target: this.target, progress: this._progress, time });
  697. }
  698. seek(progress) {
  699. // Seek does not auto-play; it's intended for scroll-linked or manual control.
  700. const t = (typeof performance !== 'undefined' ? performance.now() : 0);
  701. this._apply(progress, t);
  702. }
  703. play() {
  704. if (this._cancelled) return;
  705. if (!this._started) {
  706. this._started = true;
  707. this._startTime = performance.now() + this.delay - (this._progress * this.duration);
  708. // begin fired on first active tick to avoid firing during delay.
  709. }
  710. this._paused = false;
  711. if (!this._running) {
  712. this._running = true;
  713. rafEngine.add(this);
  714. }
  715. }
  716. pause() {
  717. this._paused = true;
  718. }
  719. cancel() {
  720. this._cancelled = true;
  721. this._running = false;
  722. rafEngine.remove(this);
  723. // Resolve to avoid hanging awaits
  724. if (this._resolve) this._resolve();
  725. }
  726. finish() {
  727. this.seek(1);
  728. this._running = false;
  729. rafEngine.remove(this);
  730. if (this.complete) this.complete(this.target);
  731. if (this._resolve) this._resolve();
  732. }
  733. tick(now) {
  734. if (this._cancelled) return false;
  735. if (this._paused) return true;
  736. if (!this._started) {
  737. this._started = true;
  738. this._startTime = now + this.delay;
  739. }
  740. if (now < this._startTime) return true;
  741. if (!this._didBegin) {
  742. this._didBegin = true;
  743. if (this.begin) this.begin(this.target);
  744. }
  745. const totalDur = this.duration + (this.endDelay || 0);
  746. const elapsed = now - this._startTime;
  747. const iter = totalDur > 0 ? Math.floor(elapsed / totalDur) : 0;
  748. const inIter = totalDur > 0 ? (elapsed - iter * totalDur) : elapsed;
  749. const iterations = this.loop === true ? Infinity : this.loop;
  750. if (iterations !== Infinity && iter >= iterations) {
  751. this._apply(this._mapDirection(1, iterations - 1));
  752. this._running = false;
  753. if (this.complete) this.complete(this.target);
  754. if (this._resolve) this._resolve();
  755. return false;
  756. }
  757. // if we're in endDelay portion, hold the end state
  758. let p = clamp01(inIter / this.duration);
  759. if (this.duration <= 0) p = 1;
  760. if (this.endDelay && inIter > this.duration) p = 1;
  761. this._apply(this._mapDirection(p, iter), now);
  762. // Keep running until loops exhausted
  763. return true;
  764. }
  765. _mapDirection(p, iterIndex) {
  766. const dir = this.direction;
  767. const flip = (dir === 'reverse') || (dir === 'alternate-reverse');
  768. const isAlt = (dir === 'alternate') || (dir === 'alternate-reverse');
  769. let t = flip ? (1 - p) : p;
  770. if (isAlt && (iterIndex % 2 === 1)) t = 1 - t;
  771. return t;
  772. }
  773. }
  774. // --- Controls (Motion One-ish, chainable / thenable) ---
  775. class Controls {
  776. constructor({ waapi = [], js = [], finished }) {
  777. this.animations = waapi; // backward compat with old `.animations` usage
  778. this.jsAnimations = js;
  779. this.finished = finished || Promise.resolve();
  780. }
  781. then(onFulfilled, onRejected) { return this.finished.then(onFulfilled, onRejected); }
  782. catch(onRejected) { return this.finished.catch(onRejected); }
  783. finally(onFinally) { return this.finished.finally(onFinally); }
  784. play() {
  785. if (this._onPlay) this._onPlay.forEach((fn) => fn && fn());
  786. if (this._ensureWaapiUpdate) this._ensureWaapiUpdate();
  787. this.animations.forEach((a) => a && a.play && a.play());
  788. this.jsAnimations.forEach((a) => a && a.play && a.play());
  789. return this;
  790. }
  791. pause() {
  792. this.animations.forEach((a) => a && a.pause && a.pause());
  793. this.jsAnimations.forEach((a) => a && a.pause && a.pause());
  794. return this;
  795. }
  796. cancel() {
  797. this.animations.forEach((a) => a && a.cancel && a.cancel());
  798. this.jsAnimations.forEach((a) => a && a.cancel && a.cancel());
  799. return this;
  800. }
  801. finish() {
  802. this.animations.forEach((a) => a && a.finish && a.finish());
  803. this.jsAnimations.forEach((a) => a && a.finish && a.finish());
  804. return this;
  805. }
  806. seek(progress) {
  807. const p = clamp01(progress);
  808. const t = (typeof performance !== 'undefined' ? performance.now() : 0);
  809. this.animations.forEach((anim) => {
  810. if (anim && anim.effect) {
  811. let timing = this._waapiTimingByAnim && this._waapiTimingByAnim.get(anim);
  812. if (!timing) {
  813. timing = readWaapiEffectTiming(anim.effect);
  814. if (this._waapiTimingByAnim) this._waapiTimingByAnim.set(anim, timing);
  815. }
  816. const dur = timing.duration || 0;
  817. if (!dur) return;
  818. anim.currentTime = dur * p;
  819. const target = this._waapiTargetByAnim && this._waapiTargetByAnim.get(anim);
  820. if (this._fireUpdate && target) this._fireUpdate({ target, progress: p, time: t });
  821. }
  822. });
  823. this.jsAnimations.forEach((a) => a && a.seek && a.seek(p));
  824. return this;
  825. }
  826. }
  827. // --- Main Animation Logic ---
  828. function animate(targets, params) {
  829. const elements = toArray(targets);
  830. const safeParams = params || {};
  831. const optionsNamespace = (safeParams.options && typeof safeParams.options === 'object') ? safeParams.options : {};
  832. // `params.options` provides a safe namespace for config keys without risking them being treated as animated props.
  833. // Top-level keys still win for backward compatibility.
  834. const merged = { ...optionsNamespace, ...safeParams };
  835. const {
  836. duration = 1000,
  837. delay = 0,
  838. easing = 'ease-out',
  839. direction = 'normal',
  840. fill = 'forwards',
  841. loop = 1,
  842. endDelay = 0,
  843. autoplay = true,
  844. springFrames = 120,
  845. update, // callback
  846. begin, // callback
  847. complete // callback
  848. } = merged;
  849. let isSpring = false;
  850. let springValuesRaw = null;
  851. let springValuesSampled = null;
  852. let springDurationMs = null;
  853. if (typeof easing === 'object' && !Array.isArray(easing)) {
  854. isSpring = true;
  855. springValuesRaw = getSpringValues(easing);
  856. const frames = (typeof springFrames === 'number' && springFrames > 1) ? Math.floor(springFrames) : 120;
  857. springValuesSampled = downsample(springValuesRaw, frames);
  858. springDurationMs = springValuesRaw.length * 1000 / 60;
  859. }
  860. // Callback aggregation (avoid double-calling when WAAPI+JS both run)
  861. const cbState = typeof WeakMap !== 'undefined' ? new WeakMap() : null;
  862. const getState = (t) => {
  863. if (!cbState) return { begun: false, completed: false, lastUpdateBucket: -1 };
  864. let s = cbState.get(t);
  865. if (!s) {
  866. s = { begun: false, completed: false, lastUpdateBucket: -1 };
  867. cbState.set(t, s);
  868. }
  869. return s;
  870. };
  871. const fireBegin = (t) => {
  872. if (!begin) return;
  873. const s = getState(t);
  874. if (s.begun) return;
  875. s.begun = true;
  876. begin(t);
  877. };
  878. const fireComplete = (t) => {
  879. if (!complete) return;
  880. const s = getState(t);
  881. if (s.completed) return;
  882. s.completed = true;
  883. complete(t);
  884. };
  885. const fireUpdate = (payload) => {
  886. if (!update) return;
  887. const t = payload && payload.target;
  888. const time = payload && payload.time;
  889. if (!t) return update(payload);
  890. const s = getState(t);
  891. const bucket = Math.floor(((typeof time === 'number' ? time : (typeof performance !== 'undefined' ? performance.now() : 0))) / 16);
  892. if (bucket === s.lastUpdateBucket) return;
  893. s.lastUpdateBucket = bucket;
  894. update(payload);
  895. };
  896. const propEntries = [];
  897. Object.keys(safeParams).forEach((key) => {
  898. if (key === 'options') return;
  899. if (isAnimOptionKey(key)) return;
  900. propEntries.push({ key, canonical: ALIASES[key] || key, val: safeParams[key] });
  901. });
  902. // Create animations but don't play if autoplay is false
  903. const waapiAnimations = [];
  904. const jsAnimations = [];
  905. const engineInfo = {
  906. isSpring,
  907. waapiKeys: [],
  908. jsKeys: []
  909. };
  910. const waapiKeySet = new Set();
  911. const jsKeySet = new Set();
  912. const onPlayHooks = [];
  913. const waapiUpdateItems = [];
  914. const waapiTargetByAnim = (typeof WeakMap !== 'undefined') ? new WeakMap() : null;
  915. const waapiTimingByAnim = (typeof WeakMap !== 'undefined') ? new WeakMap() : null;
  916. let waapiUpdateStarted = false;
  917. const ensureWaapiUpdate = () => {
  918. if (waapiUpdateStarted) return;
  919. waapiUpdateStarted = true;
  920. waapiUpdateItems.forEach((item) => waapiUpdateSampler.add(item));
  921. };
  922. const promises = elements.map((el) => {
  923. // Route props per target (fix mixed HTML/SVG/object target arrays).
  924. const waapiProps = {};
  925. const jsProps = {};
  926. propEntries.forEach(({ key, canonical, val }) => {
  927. const isTransform = TRANSFORMS.includes(canonical) || TRANSFORMS.includes(key);
  928. if (!el || (!isEl(el) && !isSVG(el))) {
  929. jsProps[key] = val;
  930. jsKeySet.add(key);
  931. return;
  932. }
  933. const isSvgTarget = isSVG(el);
  934. if (isSvgTarget) {
  935. if (isTransform || key === 'opacity' || key === 'filter') {
  936. waapiProps[canonical] = val;
  937. waapiKeySet.add(canonical);
  938. } else {
  939. jsProps[key] = val;
  940. jsKeySet.add(key);
  941. }
  942. return;
  943. }
  944. // HTML element
  945. if (isCssVar(key)) {
  946. jsProps[key] = val;
  947. jsKeySet.add(key);
  948. return;
  949. }
  950. const isCssLike = isTransform || key === 'opacity' || key === 'filter' || (isEl(el) && (key in el.style));
  951. if (isCssLike) {
  952. waapiProps[canonical] = val;
  953. waapiKeySet.add(canonical);
  954. } else {
  955. jsProps[key] = val;
  956. jsKeySet.add(key);
  957. }
  958. });
  959. // 1. WAAPI Animation
  960. let waapiAnim = null;
  961. let waapiPromise = Promise.resolve();
  962. if (Object.keys(waapiProps).length > 0) {
  963. const buildFrames = (propValues) => {
  964. // Spring: share sampled progress; per-target we only compute start/end once per prop.
  965. if (isSpring && springValuesSampled && springValuesSampled.length) {
  966. const cs = (isEl(el) && typeof getComputedStyle !== 'undefined') ? getComputedStyle(el) : null;
  967. const metas = Object.keys(propValues).map((k) => {
  968. const raw = propValues[k];
  969. const rawFrom = Array.isArray(raw) ? raw[0] : undefined;
  970. const rawTo = Array.isArray(raw) ? raw[1] : raw;
  971. const toNum = parseFloat(('' + rawTo).trim());
  972. const fromNumExplicit = Array.isArray(raw) ? parseFloat(('' + rawFrom).trim()) : NaN;
  973. const isT = TRANSFORMS.includes(ALIASES[k] || k) || TRANSFORMS.includes(k);
  974. const unit = getUnit(('' + rawTo), k) ?? getUnit(('' + rawFrom), k) ?? getDefaultUnit(k);
  975. let fromNum = 0;
  976. if (Number.isFinite(fromNumExplicit)) {
  977. fromNum = fromNumExplicit;
  978. } else if (k.startsWith('scale')) {
  979. fromNum = 1;
  980. } else if (k === 'opacity' && cs) {
  981. const n = parseFloat(cs.opacity);
  982. if (!Number.isNaN(n)) fromNum = n;
  983. } else if (!isT && cs) {
  984. const cssVal = cs.getPropertyValue(toKebab(k));
  985. const n = parseFloat(cssVal);
  986. if (!Number.isNaN(n)) fromNum = n;
  987. }
  988. const to = Number.isFinite(toNum) ? toNum : 0;
  989. return { k, isT, unit: unit ?? '', from: fromNum, to };
  990. });
  991. const frames = new Array(springValuesSampled.length);
  992. for (let i = 0; i < springValuesSampled.length; i++) {
  993. const v = springValuesSampled[i];
  994. const frame = {};
  995. let transformStr = '';
  996. for (let j = 0; j < metas.length; j++) {
  997. const m = metas[j];
  998. const current = m.from + (m.to - m.from) * v;
  999. const outVal = (m.unit === '' ? ('' + current) : (current + m.unit));
  1000. if (m.isT) transformStr += `${m.k}(${outVal}) `;
  1001. else frame[m.k] = outVal;
  1002. }
  1003. if (transformStr) frame.transform = transformStr.trim();
  1004. frames[i] = frame;
  1005. }
  1006. if (frames[0] && Object.keys(frames[0]).length === 0) frames.shift();
  1007. return frames;
  1008. }
  1009. // Non-spring: 2-keyframe path
  1010. const frame0 = {};
  1011. const frame1 = {};
  1012. let transform0 = '';
  1013. let transform1 = '';
  1014. Object.keys(propValues).forEach((k) => {
  1015. const val = propValues[k];
  1016. const isT = TRANSFORMS.includes(ALIASES[k] || k) || TRANSFORMS.includes(k);
  1017. if (Array.isArray(val)) {
  1018. const from = isT ? normalizeTransformPartValue(k, val[0]) : val[0];
  1019. const to = isT ? normalizeTransformPartValue(k, val[1]) : val[1];
  1020. if (isT) {
  1021. transform0 += `${k}(${from}) `;
  1022. transform1 += `${k}(${to}) `;
  1023. } else {
  1024. frame0[k] = from;
  1025. frame1[k] = to;
  1026. }
  1027. } else {
  1028. if (isT) transform1 += `${k}(${normalizeTransformPartValue(k, val)}) `;
  1029. else frame1[k] = val;
  1030. }
  1031. });
  1032. if (transform0) frame0.transform = transform0.trim();
  1033. if (transform1) frame1.transform = transform1.trim();
  1034. const out = [frame0, frame1];
  1035. if (Object.keys(out[0]).length === 0) out.shift();
  1036. return out;
  1037. };
  1038. const finalFrames = buildFrames(waapiProps);
  1039. const opts = {
  1040. duration: isSpring ? springDurationMs : duration,
  1041. delay,
  1042. fill,
  1043. iterations: loop,
  1044. easing: isSpring ? 'linear' : easing,
  1045. direction,
  1046. endDelay
  1047. };
  1048. const animation = el.animate(finalFrames, opts);
  1049. if (!autoplay) animation.pause();
  1050. waapiAnim = animation;
  1051. waapiPromise = animation.finished;
  1052. waapiAnimations.push(waapiAnim);
  1053. if (waapiTargetByAnim) waapiTargetByAnim.set(waapiAnim, el);
  1054. if (waapiTimingByAnim) waapiTimingByAnim.set(waapiAnim, readWaapiEffectTiming(waapiAnim.effect));
  1055. if (begin) {
  1056. // Fire begin when play starts (and also for autoplay on next frame)
  1057. onPlayHooks.push(() => fireBegin(el));
  1058. if (autoplay && typeof requestAnimationFrame !== 'undefined') requestAnimationFrame(() => fireBegin(el));
  1059. }
  1060. if (complete) {
  1061. waapiAnim.addEventListener?.('finish', () => fireComplete(el));
  1062. }
  1063. if (update) {
  1064. const timing = (waapiTimingByAnim && waapiTimingByAnim.get(waapiAnim)) || readWaapiEffectTiming(waapiAnim.effect);
  1065. const item = { anim: waapiAnim, target: el, update: fireUpdate, _lastP: null, _dur: timing.duration || 0 };
  1066. waapiUpdateItems.push(item);
  1067. // Ensure removal on finish/cancel
  1068. waapiAnim.addEventListener?.('finish', () => waapiUpdateSampler.remove(item));
  1069. waapiAnim.addEventListener?.('cancel', () => waapiUpdateSampler.remove(item));
  1070. // Start sampler only when needed (autoplay or explicit play)
  1071. if (autoplay) ensureWaapiUpdate();
  1072. }
  1073. }
  1074. // 2. JS Animation (Fallback / Attributes)
  1075. let jsPromise = Promise.resolve();
  1076. if (Object.keys(jsProps).length > 0) {
  1077. const jsAnim = new JsAnimation(
  1078. el,
  1079. jsProps,
  1080. { duration, delay, easing, autoplay, direction, loop, endDelay, springValues: isSpring ? springValuesRaw : null },
  1081. { update: fireUpdate, begin: fireBegin, complete: fireComplete }
  1082. );
  1083. jsAnimations.push(jsAnim);
  1084. jsPromise = jsAnim.finished;
  1085. }
  1086. return Promise.all([waapiPromise, jsPromise]);
  1087. });
  1088. const finished = Promise.all(promises);
  1089. const controls = new Controls({ waapi: waapiAnimations, js: jsAnimations, finished });
  1090. controls.engine = engineInfo;
  1091. controls._onPlay = onPlayHooks;
  1092. controls._fireUpdate = fireUpdate;
  1093. controls._waapiTargetByAnim = waapiTargetByAnim;
  1094. controls._waapiTimingByAnim = waapiTimingByAnim;
  1095. controls._ensureWaapiUpdate = update ? ensureWaapiUpdate : null;
  1096. if (!autoplay) controls.pause();
  1097. engineInfo.waapiKeys = Array.from(waapiKeySet);
  1098. engineInfo.jsKeys = Array.from(jsKeySet);
  1099. return controls;
  1100. }
  1101. // --- SVG Draw ---
  1102. function svgDraw(targets, params = {}) {
  1103. const elements = toArray(targets);
  1104. elements.forEach(el => {
  1105. if (!isSVG(el)) return;
  1106. const len = el.getTotalLength ? el.getTotalLength() : 0;
  1107. el.style.strokeDasharray = len;
  1108. el.style.strokeDashoffset = len;
  1109. animate(el, {
  1110. strokeDashoffset: [len, 0],
  1111. ...params
  1112. });
  1113. });
  1114. }
  1115. // --- In View ---
  1116. function inViewAnimate(targets, params, options = {}) {
  1117. const elements = toArray(targets);
  1118. const observer = new IntersectionObserver((entries) => {
  1119. entries.forEach(entry => {
  1120. if (entry.isIntersecting) {
  1121. animate(entry.target, params);
  1122. if (options.once !== false) observer.unobserve(entry.target);
  1123. }
  1124. });
  1125. }, { threshold: options.threshold || 0.1 });
  1126. elements.forEach(el => observer.observe(el));
  1127. // Return cleanup so callers can disconnect observers in long-lived pages (esp. once:false).
  1128. return () => {
  1129. try {
  1130. elements.forEach((el) => observer.unobserve(el));
  1131. observer.disconnect();
  1132. } catch (e) {
  1133. // ignore
  1134. }
  1135. };
  1136. }
  1137. // --- Scroll Linked ---
  1138. function scroll(animationPromise, options = {}) {
  1139. // options: container (default window), range [start, end] (default viewport logic)
  1140. const container = options.container || window;
  1141. const target = options.target || document.body; // Element to track for progress
  1142. // If passing an animation promise, we control its WAAPI animations
  1143. const controls = animationPromise;
  1144. // Back-compat: old return value was a Promise with `.animations`
  1145. const hasSeek = controls && isFunc(controls.seek);
  1146. const anims = (controls && controls.animations) || (animationPromise && animationPromise.animations) || [];
  1147. const jsAnims = (controls && controls.jsAnimations) || [];
  1148. if (!hasSeek && !anims.length && !jsAnims.length) return;
  1149. // Cache WAAPI timing per animation to avoid repeated getComputedTiming() during scroll.
  1150. const timingCache = (typeof WeakMap !== 'undefined') ? new WeakMap() : null;
  1151. const getEndTime = (anim) => {
  1152. if (!anim || !anim.effect) return 0;
  1153. if (timingCache) {
  1154. const cached = timingCache.get(anim);
  1155. if (cached) return cached;
  1156. }
  1157. const timing = readWaapiEffectTiming(anim.effect);
  1158. const end = timing.duration || 0;
  1159. if (timingCache && end) timingCache.set(anim, end);
  1160. return end;
  1161. };
  1162. const updateScroll = () => {
  1163. let progress = 0;
  1164. if (container === window) {
  1165. const scrollY = window.scrollY;
  1166. const winH = window.innerHeight;
  1167. const docH = document.body.scrollHeight;
  1168. // Simple progress: how far down the page (0 to 1)
  1169. // Or element based?
  1170. // Motion One defaults to element entering view.
  1171. if (options.target) {
  1172. const rect = options.target.getBoundingClientRect();
  1173. const start = winH;
  1174. const end = -rect.height;
  1175. // progress 0 when rect.top == start (just entering)
  1176. // progress 1 when rect.top == end (just left)
  1177. const totalDistance = start - end;
  1178. const currentDistance = start - rect.top;
  1179. progress = currentDistance / totalDistance;
  1180. } else {
  1181. // Whole page scroll
  1182. progress = scrollY / (docH - winH);
  1183. }
  1184. } else if (container && (typeof Element !== 'undefined') && (container instanceof Element)) {
  1185. // Scroll container progress
  1186. const el = container;
  1187. const scrollTop = el.scrollTop;
  1188. const max = (el.scrollHeight - el.clientHeight) || 1;
  1189. if (options.target) {
  1190. const containerRect = el.getBoundingClientRect();
  1191. const rect = options.target.getBoundingClientRect();
  1192. const start = containerRect.height;
  1193. const end = -rect.height;
  1194. const totalDistance = start - end;
  1195. const currentDistance = start - (rect.top - containerRect.top);
  1196. progress = currentDistance / totalDistance;
  1197. } else {
  1198. progress = scrollTop / max;
  1199. }
  1200. }
  1201. // Clamp
  1202. progress = clamp01(progress);
  1203. if (hasSeek) {
  1204. controls.seek(progress);
  1205. return;
  1206. }
  1207. anims.forEach((anim) => {
  1208. if (anim.effect) {
  1209. const end = getEndTime(anim);
  1210. if (!end) return;
  1211. anim.currentTime = end * progress;
  1212. }
  1213. });
  1214. };
  1215. let rafId = 0;
  1216. const onScroll = () => {
  1217. if (rafId) return;
  1218. rafId = requestAnimationFrame(() => {
  1219. rafId = 0;
  1220. updateScroll();
  1221. });
  1222. };
  1223. const eventTarget = (container && container.addEventListener) ? container : window;
  1224. eventTarget.addEventListener('scroll', onScroll, { passive: true });
  1225. updateScroll(); // Initial
  1226. return () => {
  1227. if (rafId) cancelAnimationFrame(rafId);
  1228. eventTarget.removeEventListener('scroll', onScroll);
  1229. };
  1230. }
  1231. // --- Timeline ---
  1232. function timeline(defaults = {}) {
  1233. const steps = [];
  1234. const api = {
  1235. currentTime: 0,
  1236. add: (targets, params, offset) => {
  1237. const animParams = { ...defaults, ...params };
  1238. let start = api.currentTime;
  1239. if (offset !== undefined) {
  1240. if (isStr(offset) && offset.startsWith('-=')) start -= parseFloat(offset.slice(2));
  1241. else if (isStr(offset) && offset.startsWith('+=')) start += parseFloat(offset.slice(2));
  1242. else if (typeof offset === 'number') start = offset;
  1243. }
  1244. const dur = animParams.duration || 1000;
  1245. const step = { targets, animParams, start, _scheduled: false };
  1246. steps.push(step);
  1247. // Backward compatible: schedule immediately (existing docs rely on this)
  1248. if (start <= 0) {
  1249. animate(targets, animParams);
  1250. step._scheduled = true;
  1251. } else {
  1252. setTimeout(() => {
  1253. animate(targets, animParams);
  1254. }, start);
  1255. step._scheduled = true;
  1256. }
  1257. api.currentTime = Math.max(api.currentTime, start + dur);
  1258. return api;
  1259. },
  1260. // Optional: if you create a timeline and want to defer scheduling yourself
  1261. play: () => {
  1262. steps.forEach((s) => {
  1263. if (s._scheduled) return;
  1264. if (s.start <= 0) animate(s.targets, s.animParams);
  1265. else setTimeout(() => animate(s.targets, s.animParams), s.start);
  1266. s._scheduled = true;
  1267. });
  1268. return api;
  1269. }
  1270. };
  1271. return api;
  1272. }
  1273. // --- Export ---
  1274. // transform `$` to be the main export `animal`, with statics attached
  1275. const animal = $;
  1276. // Extend $ behavior to act as a global selector and property accessor
  1277. Object.assign(animal, {
  1278. animate,
  1279. timeline,
  1280. draw: svgDraw,
  1281. svgDraw,
  1282. inViewAnimate,
  1283. spring,
  1284. scroll,
  1285. $: animal // Self-reference for backward compatibility
  1286. });
  1287. // Expose Layer if available or allow lazy loading
  1288. Object.defineProperty(animal, 'Layer', {
  1289. get: () => {
  1290. return (typeof window !== 'undefined' && window.Layer) ? window.Layer :
  1291. (typeof globalThis !== 'undefined' && globalThis.Layer) ? globalThis.Layer :
  1292. (typeof Layer !== 'undefined' ? Layer : null);
  1293. }
  1294. });
  1295. // Shortcut for Layer.run or new Layer()
  1296. // Allows $.run({ title: 'Hi' })
  1297. animal.run = (options) => {
  1298. const L = animal.Layer;
  1299. if (L) return (L.run ? L.run(options) : (L.fire ? L.fire(options) : new L(options).run()));
  1300. console.warn('Layer module not loaded.');
  1301. return Promise.reject('Layer module not loaded');
  1302. };
  1303. // Backward-compatible alias
  1304. animal.fire = (options) => animal.run(options);
  1305. // $.layer(): returns a Layer instance with auto-fire for simple popups
  1306. animal.layer = (options) => {
  1307. const L = animal.Layer;
  1308. if (!L) {
  1309. console.warn('Layer module not loaded.');
  1310. return Promise.reject('Layer module not loaded');
  1311. }
  1312. const inst = (L.$ && isFunc(L.$)) ? L.$(options) : (new L()).config(options);
  1313. const rawRun = inst.run ? inst.run.bind(inst) : (inst.fire ? inst.fire.bind(inst) : null);
  1314. if (rawRun) {
  1315. inst.run = (opts) => {
  1316. const p = rawRun(opts);
  1317. inst._autoPromise = p;
  1318. return p;
  1319. };
  1320. }
  1321. inst._autoPromise = null;
  1322. // Auto-fire on next tick if no steps are added (unless deferred/bound)
  1323. setTimeout(() => {
  1324. if (!inst || inst._autoPromise) return;
  1325. if (inst._deferAuto) return;
  1326. if (inst._flowSteps && inst._flowSteps.length) return;
  1327. if (rawRun) inst.run();
  1328. }, 0);
  1329. // Promise-like convenience for $.layer(...).then(...)
  1330. inst.then = (...args) => {
  1331. if (!inst._autoPromise) {
  1332. if (inst._flowSteps && inst._flowSteps.length) {
  1333. if (rawRun) inst.run();
  1334. } else {
  1335. if (rawRun) inst.run();
  1336. }
  1337. }
  1338. return inst._autoPromise ? inst._autoPromise.then(...args) : Promise.resolve().then(...args);
  1339. };
  1340. inst.catch = (...args) => inst.then(null, ...args);
  1341. inst.finally = (...args) => {
  1342. if (!inst._autoPromise && rawRun) inst.run();
  1343. return inst._autoPromise ? inst._autoPromise.finally(...args) : Promise.resolve().finally(...args);
  1344. };
  1345. // Backward-compatible alias
  1346. if (!inst.fire && inst.run) inst.fire = inst.run.bind(inst);
  1347. return inst;
  1348. };
  1349. return animal;
  1350. })));