animal.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  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) => el instanceof SVGElement;
  13. const toArray = (targets) => {
  14. if (isArr(targets)) return targets;
  15. if (isStr(targets)) return Array.from(document.querySelectorAll(targets));
  16. if (targets instanceof NodeList) return Array.from(targets);
  17. if (targets instanceof Element || targets instanceof Window) return [targets];
  18. return [];
  19. };
  20. const $ = (targets) => toArray(targets);
  21. const UNITLESS_KEYS = ['opacity', 'scale', 'scaleX', 'scaleY', 'scaleZ', 'zIndex', 'fontWeight', 'strokeDashoffset', 'strokeDasharray', 'strokeWidth'];
  22. const getUnit = (val, prop) => {
  23. if (UNITLESS_KEYS.includes(prop)) return '';
  24. 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);
  25. return split ? split[1] : undefined;
  26. };
  27. // --- Spring Physics (Simplified) ---
  28. // Returns an array of [time, value] or just value for WAAPI linear easing
  29. function spring({ stiffness = 100, damping = 10, mass = 1, velocity = 0, precision = 0.01 } = {}) {
  30. const values = [];
  31. let t = 0;
  32. const timeStep = 1 / 60; // 60fps simulation
  33. let current = 0;
  34. let v = velocity;
  35. const target = 1;
  36. let running = true;
  37. while (running && t < 10) { // Safety break at 10s
  38. const fSpring = -stiffness * (current - target);
  39. const fDamper = -damping * v;
  40. const a = (fSpring + fDamper) / mass;
  41. v += a * timeStep;
  42. current += v * timeStep;
  43. values.push(current);
  44. t += timeStep;
  45. if (Math.abs(current - target) < precision && Math.abs(v) < precision) {
  46. running = false;
  47. }
  48. }
  49. if (values[values.length - 1] !== 1) values.push(1);
  50. return values;
  51. }
  52. // --- WAAPI Core ---
  53. const TRANSFORMS = ['translateX', 'translateY', 'translateZ', 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'scale', 'scaleX', 'scaleY', 'scaleZ', 'skew', 'skewX', 'skewY', 'perspective', 'x', 'y'];
  54. const ALIASES = {
  55. x: 'translateX',
  56. y: 'translateY',
  57. z: 'translateZ'
  58. };
  59. // Basic rAF loop for non-WAAPI props or fallback
  60. class RafEngine {
  61. constructor() {
  62. this.animations = [];
  63. this.tick = this.tick.bind(this);
  64. this.running = false;
  65. }
  66. add(anim) {
  67. this.animations.push(anim);
  68. if (!this.running) {
  69. this.running = true;
  70. requestAnimationFrame(this.tick);
  71. }
  72. }
  73. remove(anim) {
  74. this.animations = this.animations.filter(a => a !== anim);
  75. }
  76. tick(t) {
  77. const now = t;
  78. this.animations = this.animations.filter(anim => {
  79. return anim.tick(now); // return true to keep
  80. });
  81. if (this.animations.length) {
  82. requestAnimationFrame(this.tick);
  83. } else {
  84. this.running = false;
  85. }
  86. }
  87. }
  88. const rafEngine = new RafEngine();
  89. // --- Main Animation Logic ---
  90. function animate(targets, params) {
  91. const elements = toArray(targets);
  92. const {
  93. duration = 1000,
  94. delay = 0,
  95. easing = 'ease-out',
  96. direction = 'normal',
  97. fill = 'forwards',
  98. loop = 1,
  99. endDelay = 0,
  100. autoplay = true,
  101. update, // callback
  102. begin, // callback
  103. complete // callback
  104. } = params;
  105. const animatableProps = {};
  106. const waapiProps = {};
  107. const jsProps = {};
  108. let isSpring = false;
  109. let springValues = null;
  110. if (typeof easing === 'object' && !Array.isArray(easing)) {
  111. isSpring = true;
  112. springValues = spring(easing);
  113. }
  114. Object.keys(params).forEach(key => {
  115. if (['duration', 'delay', 'easing', 'direction', 'fill', 'loop', 'endDelay', 'autoplay', 'update', 'begin', 'complete'].includes(key)) return;
  116. let prop = ALIASES[key] || key;
  117. const isTransform = TRANSFORMS.includes(prop) || TRANSFORMS.includes(key);
  118. const isCss = !isTransform && (key === 'opacity' || key === 'filter'); // Simplified check
  119. const val = params[key];
  120. if (isTransform || isCss) {
  121. waapiProps[key] = val;
  122. } else {
  123. jsProps[key] = val;
  124. }
  125. });
  126. // Create animations but don't play if autoplay is false
  127. const animations = [];
  128. const promises = elements.map(el => {
  129. // 1. WAAPI Animation
  130. let waapiAnim = null;
  131. let waapiPromise = Promise.resolve();
  132. if (Object.keys(waapiProps).length > 0) {
  133. const generateFrames = (propValues) => {
  134. if (isSpring && springValues) {
  135. return springValues.map(v => {
  136. const frame = {};
  137. Object.keys(propValues).forEach(k => {
  138. const targetVal = parseFloat(propValues[k]);
  139. const unit = getUnit(propValues[k], k) || (k.startsWith('rotate') ? 'deg' : (UNITLESS_KEYS.includes(k) ? '' : 'px'));
  140. let startVal = 0;
  141. if (k.startsWith('scale')) startVal = 1;
  142. if (k === 'opacity') startVal = parseFloat(getComputedStyle(el).opacity);
  143. if (Array.isArray(propValues[k])) {
  144. startVal = parseFloat(propValues[k][0]);
  145. const endVal = parseFloat(propValues[k][1]);
  146. const current = startVal + (endVal - startVal) * v;
  147. frame[k] = current + unit;
  148. } else {
  149. const endVal = targetVal;
  150. const current = startVal + (endVal - startVal) * v;
  151. frame[k] = current + unit;
  152. }
  153. });
  154. return frame;
  155. });
  156. } else {
  157. const frame0 = {};
  158. const frame1 = {};
  159. Object.keys(propValues).forEach(k => {
  160. let val = propValues[k];
  161. if (Array.isArray(val)) {
  162. frame0[k] = val[0];
  163. frame1[k] = val[1];
  164. } else {
  165. frame1[k] = val;
  166. }
  167. });
  168. return [frame0, frame1];
  169. }
  170. };
  171. let rawFrames = generateFrames(waapiProps);
  172. const finalFrames = rawFrames.map(f => {
  173. const final = {};
  174. let transformStr = '';
  175. Object.keys(f).forEach(k => {
  176. const isT = TRANSFORMS.includes(ALIASES[k] || k);
  177. if (isT) {
  178. let key = ALIASES[k] || k;
  179. if (key === 'x') key = 'translateX';
  180. if (key === 'y') key = 'translateY';
  181. if (key === 'z') key = 'translateZ';
  182. let val = f[k];
  183. if (typeof val === 'number') {
  184. if (key.startsWith('scale')) {
  185. val = val + '';
  186. } else if (key.startsWith('rotate') || key.startsWith('skew')) {
  187. val = val + 'deg';
  188. } else {
  189. val = val + 'px';
  190. }
  191. }
  192. transformStr += `${key}(${val}) `;
  193. } else {
  194. final[k] = f[k];
  195. }
  196. });
  197. if (transformStr) final.transform = transformStr.trim();
  198. return final;
  199. });
  200. if (Object.keys(finalFrames[0]).length === 0) finalFrames.shift();
  201. const opts = {
  202. duration: isSpring ? (springValues.length * 1000 / 60) : duration,
  203. delay,
  204. fill,
  205. iterations: loop,
  206. easing: isSpring ? 'linear' : easing
  207. };
  208. const animation = el.animate(finalFrames, opts);
  209. if (!autoplay) animation.pause();
  210. waapiAnim = animation;
  211. waapiPromise = animation.finished;
  212. animations.push(waapiAnim);
  213. }
  214. // 2. JS Animation (Fallback / Attributes)
  215. let jsPromise = Promise.resolve();
  216. if (Object.keys(jsProps).length > 0) {
  217. jsPromise = new Promise(resolve => {
  218. // For JS engine to support manual seek/scroll, we need to structure it differently.
  219. // But for this lite version, we focus on WAAPI for scroll linking.
  220. // JS fallback will just run normally if autoplay.
  221. if (!autoplay) return; // JS engine simplified doesn't support pause/seek easily yet
  222. const startTime = performance.now() + delay;
  223. const initialValues = {};
  224. // Setup
  225. Object.keys(jsProps).forEach(k => {
  226. if (k === 'scrollTop' || k === 'scrollLeft') {
  227. initialValues[k] = el[k];
  228. } else if (k in el.style) {
  229. initialValues[k] = parseFloat(el.style[k]) || 0;
  230. } else if (isSVG(el) && el.hasAttribute(k)) {
  231. initialValues[k] = parseFloat(el.getAttribute(k)) || 0;
  232. } else {
  233. initialValues[k] = 0;
  234. }
  235. });
  236. const tick = (now) => {
  237. if (now < startTime) return true;
  238. const elapsed = now - startTime;
  239. let progress = Math.min(elapsed / duration, 1);
  240. Object.keys(jsProps).forEach(k => {
  241. const target = jsProps[k];
  242. let start = initialValues[k];
  243. let end = isArr(target) ? target[1] : target;
  244. if (isArr(target)) start = target[0];
  245. const val = start + (end - start) * progress;
  246. if (k === 'scrollTop' || k === 'scrollLeft') {
  247. el[k] = val;
  248. } else if (k in el.style) {
  249. el.style[k] = val;
  250. } else if (isSVG(el)) {
  251. const attr = k.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
  252. el.setAttribute(attr, val);
  253. } else {
  254. el[k] = val;
  255. }
  256. if (update) update({ target: el, progress, value: val });
  257. });
  258. if (progress < 1) return true;
  259. if (complete) complete(el);
  260. resolve();
  261. return false;
  262. };
  263. rafEngine.add({ tick });
  264. });
  265. }
  266. return Promise.all([waapiPromise, jsPromise]);
  267. });
  268. const masterPromise = Promise.all(promises);
  269. masterPromise.then = (fn) => Promise.all(promises).then(fn);
  270. // Attach animations to promise for external control (like scroll)
  271. masterPromise.animations = animations;
  272. return masterPromise;
  273. }
  274. // --- SVG Draw ---
  275. function svgDraw(targets, params = {}) {
  276. const elements = toArray(targets);
  277. elements.forEach(el => {
  278. if (!isSVG(el)) return;
  279. const len = el.getTotalLength ? el.getTotalLength() : 0;
  280. el.style.strokeDasharray = len;
  281. el.style.strokeDashoffset = len;
  282. animate(el, {
  283. strokeDashoffset: [len, 0],
  284. ...params
  285. });
  286. });
  287. }
  288. // --- In View ---
  289. function inViewAnimate(targets, params, options = {}) {
  290. const elements = toArray(targets);
  291. const observer = new IntersectionObserver((entries) => {
  292. entries.forEach(entry => {
  293. if (entry.isIntersecting) {
  294. animate(entry.target, params);
  295. if (options.once !== false) observer.unobserve(entry.target);
  296. }
  297. });
  298. }, { threshold: options.threshold || 0.1 });
  299. elements.forEach(el => observer.observe(el));
  300. }
  301. // --- Scroll Linked ---
  302. function scroll(animationPromise, options = {}) {
  303. // options: container (default window), range [start, end] (default viewport logic)
  304. const container = options.container || window;
  305. const target = options.target || document.body; // Element to track for progress
  306. // If passing an animation promise, we control its WAAPI animations
  307. const anims = animationPromise.animations || [];
  308. if (!anims.length) return;
  309. const updateScroll = () => {
  310. let progress = 0;
  311. if (container === window) {
  312. const scrollY = window.scrollY;
  313. const winH = window.innerHeight;
  314. const docH = document.body.scrollHeight;
  315. // Simple progress: how far down the page (0 to 1)
  316. // Or element based?
  317. // Motion One defaults to element entering view.
  318. if (options.target) {
  319. const rect = options.target.getBoundingClientRect();
  320. const start = winH;
  321. const end = -rect.height;
  322. // progress 0 when rect.top == start (just entering)
  323. // progress 1 when rect.top == end (just left)
  324. const totalDistance = start - end;
  325. const currentDistance = start - rect.top;
  326. progress = currentDistance / totalDistance;
  327. } else {
  328. // Whole page scroll
  329. progress = scrollY / (docH - winH);
  330. }
  331. }
  332. // Clamp
  333. progress = Math.max(0, Math.min(1, progress));
  334. anims.forEach(anim => {
  335. if (anim.effect) {
  336. const dur = anim.effect.getComputedTiming().duration;
  337. anim.currentTime = dur * progress;
  338. }
  339. });
  340. };
  341. window.addEventListener('scroll', updateScroll);
  342. updateScroll(); // Initial
  343. return () => window.removeEventListener('scroll', updateScroll);
  344. }
  345. // --- Timeline ---
  346. function timeline(defaults = {}) {
  347. const realTimeApi = {
  348. currentTime: 0,
  349. add: (targets, params, offset) => {
  350. const animParams = { ...defaults, ...params };
  351. let start = realTimeApi.currentTime;
  352. if (offset !== undefined) {
  353. if (isStr(offset) && offset.startsWith('-=')) start -= parseFloat(offset.slice(2));
  354. else if (isStr(offset) && offset.startsWith('+=')) start += parseFloat(offset.slice(2));
  355. else if (typeof offset === 'number') start = offset;
  356. }
  357. if (start <= 0) {
  358. animate(targets, animParams);
  359. } else {
  360. setTimeout(() => {
  361. animate(targets, animParams);
  362. }, start);
  363. }
  364. const dur = animParams.duration || 1000;
  365. realTimeApi.currentTime = Math.max(realTimeApi.currentTime, start + dur);
  366. return realTimeApi;
  367. }
  368. };
  369. return realTimeApi;
  370. }
  371. return {
  372. animate,
  373. timeline,
  374. svgDraw,
  375. inViewAnimate,
  376. spring,
  377. scroll,
  378. $
  379. };
  380. })));