animal.js 15 KB

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