animal.js 49 KB

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