diff --git a/CHANGELOG.md b/CHANGELOG.md index e3946f6..456a8f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project are documented in this file. This project follows Semantic Versioning. +## [0.1.6] - 2026-02-16 + +### Added + +- Added viewport-based start with `startOnView` using `IntersectionObserver`. +- Added observer options: `once`, `root`, `rootMargin`, and `threshold`. + ## [0.1.4] - 2026-02-16 ### Fixed diff --git a/README.md b/README.md index 6e6979b..db87f22 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,19 @@ const counters = counterUp(".metric", { counters.update([100, 250, 999]); ``` +### ESM (iniciar ao entrar na tela) + +```js +import { counterUp } from "@nullsablex/counter-up"; + +counterUp(".metric", { + end: 1500, + startOnView: true, + once: true, + threshold: 0.2, +}); +``` + ### Navegador (UMD) ```html @@ -94,6 +107,11 @@ counters.update([100, 250, 999]); - `easing` (`"linear"` | `"easeInOutQuad"` | `"easeOutCubic"` | function) - `formatter` (function) - `autostart` (boolean, padrão `true`) +- `startOnView` (boolean, padrão `false`): inicia quando o elemento entra na viewport +- `once` (boolean, padrão `true`): com `startOnView`, anima apenas uma vez +- `root` (Element|null, padrão `null`): root do `IntersectionObserver` +- `rootMargin` (string, padrão `"0px"`): margem do `IntersectionObserver` +- `threshold` (number|number[], padrão `0.1`): threshold do `IntersectionObserver` - `onUpdate` (function) - `onComplete` (function) diff --git a/dist/counterup.esm.js b/dist/counterup.esm.js index 428c6b1..5e45ed1 100644 --- a/dist/counterup.esm.js +++ b/dist/counterup.esm.js @@ -1,4 +1,4 @@ -/* @nullsablex/counter-up v0.1.5 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ +/* @nullsablex/counter-up v0.1.7 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ const easings = { linear: (t) => t, easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2), @@ -17,6 +17,11 @@ const defaultOptions = { easing: "easeOutCubic", formatter: null, autostart: true, + startOnView: false, + once: true, + root: null, + rootMargin: "0px", + threshold: 0.1, onUpdate: null, onComplete: null, }; @@ -107,6 +112,8 @@ function createCounterInstance(element, userOptions = {}, index = 0) { isRunning: false, isPaused: false, destroyed: false, + hasPlayed: false, + observer: null, }; function render(value, notify = true) { @@ -124,6 +131,13 @@ function createCounterInstance(element, userOptions = {}, index = 0) { } } + function disconnectObserver() { + if (state.observer) { + state.observer.disconnect(); + state.observer = null; + } + } + function animate(timestamp) { if (!state.isRunning || state.destroyed) return; @@ -163,6 +177,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { state.startTime = null; state.isPaused = false; state.isRunning = true; + state.hasPlayed = true; state.rafId = requestAnimationFrame(animate); return api; } @@ -177,6 +192,10 @@ function createCounterInstance(element, userOptions = {}, index = 0) { } function start() { + if (state.destroyed) return api; + if (options.startOnView && options.once) { + disconnectObserver(); + } if (state.isPaused) { return resume(); } @@ -223,6 +242,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { function update(nextEnd, nextOptions = {}) { if (state.destroyed) return api; + disconnectObserver(); options = normalizeOptions({ ...options, ...nextOptions, @@ -237,9 +257,52 @@ function createCounterInstance(element, userOptions = {}, index = 0) { function destroy() { stop(); + disconnectObserver(); state.destroyed = true; } + function setupObserver() { + if ( + !options.startOnView || + !options.autostart || + typeof IntersectionObserver === "undefined" + ) { + return; + } + + disconnectObserver(); + state.observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry) return; + + if (entry.isIntersecting) { + if (options.once && state.hasPlayed) { + return; + } + render(options.start, false); + play(options.start, options.end); + if (options.once) { + disconnectObserver(); + } + return; + } + + if (!options.once) { + stop(); + render(options.start, false); + } + }, + { + root: options.root, + rootMargin: options.rootMargin, + threshold: options.threshold, + } + ); + + state.observer.observe(element); + } + const api = { start, stop, @@ -261,7 +324,9 @@ function createCounterInstance(element, userOptions = {}, index = 0) { }; render(options.start, false); - if (options.autostart) { + if (options.startOnView) { + setupObserver(); + } else if (options.autostart) { start(); } diff --git a/dist/counterup.esm.min.js b/dist/counterup.esm.min.js index adea5f4..af702bd 100644 --- a/dist/counterup.esm.min.js +++ b/dist/counterup.esm.min.js @@ -1,4 +1,4 @@ -/* @nullsablex/counter-up v0.1.5 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ -const easings={linear:(t)=>t,easeInOutQuad:(t)=>(t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2),easeOutCubic:(t)=>1-Math.pow(1-t,3),};const defaultOptions={start:0,end:100,duration:2000,decimals:0,prefix:"",suffix:"",locale:"pt-BR",useGrouping:true,easing:"easeOutCubic",formatter:null,autostart:true,onUpdate:null,onComplete:null,};function toNumber(value,fallback=0){const n=Number(value);return Number.isFinite(n)?n:fallback;}function normalizeOptions(options){const normalized={...options,start:toNumber(options.start,0),end:toNumber(options.end,100),duration:Math.max(0,toNumber(options.duration,2000)),decimals:Math.max(0,Math.floor(toNumber(options.decimals,0))),};normalized.easingFn=typeof normalized.easing==="function"?normalized.easing:easings[normalized.easing]||easings.easeOutCubic;return normalized;}function formatNumber(value,options){if(typeof options.formatter==="function"){return options.formatter(value,options.element,options.index);}const formatted=new Intl.NumberFormat(options.locale,{minimumFractionDigits:options.decimals,maximumFractionDigits:options.decimals,useGrouping:options.useGrouping,}).format(value);return`${options.prefix}${formatted}${options.suffix}`;}function isElement(target){return typeof Element!=="undefined"&&target instanceof Element;}function resolveElements(target){if(typeof target==="string"){return Array.from(document.querySelectorAll(target));}if(isElement(target)){return[target];}if(Array.isArray(target)){return target.filter(isElement);}if(typeof NodeList!=="undefined"&&(target instanceof NodeList||(typeof HTMLCollection!=="undefined"&&target instanceof HTMLCollection))){return Array.from(target).filter(isElement);}return[];}function createCounterInstance(element,userOptions={},index=0){if(!element){throw new Error("counterUp: target element not found.");}let options=normalizeOptions({...defaultOptions,...userOptions,element,index,});const state={value:options.start,from:options.start,to:options.end,elapsed:0,startTime:null,rafId:null,isRunning:false,isPaused:false,destroyed:false,};function render(value,notify=true){state.value=value;element.textContent=formatNumber(value,options);if(notify&&typeof options.onUpdate==="function"){options.onUpdate(value,element,index);}}function cancelFrame(){if(state.rafId!==null){cancelAnimationFrame(state.rafId);state.rafId=null;}}function animate(timestamp){if(!state.isRunning||state.destroyed)return;if(state.startTime===null){state.startTime=timestamp-state.elapsed;}state.elapsed=timestamp-state.startTime;const progress=options.duration===0?1:Math.min(state.elapsed/options.duration,1);const eased=options.easingFn(progress);const nextValue=state.from+(state.to-state.from)*eased;render(nextValue);if(progress<1){state.rafId=requestAnimationFrame(animate);return;}state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;render(state.to);if(typeof options.onComplete==="function"){options.onComplete(state.to,element,index);}}function play(from,to){if(state.destroyed)return api;cancelFrame();state.from=toNumber(from,state.value);state.to=toNumber(to,state.to);state.elapsed=0;state.startTime=null;state.isPaused=false;state.isRunning=true;state.rafId=requestAnimationFrame(animate);return api;}function stop(){cancelFrame();state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;return api;}function start(){if(state.isPaused){return resume();}if(state.isRunning){return api;}return play(options.start,options.end);}function pause(){if(!state.isRunning)return api;cancelFrame();state.isRunning=false;state.isPaused=true; +/* @nullsablex/counter-up v0.1.7 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ +const easings={linear:(t)=>t,easeInOutQuad:(t)=>(t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2),easeOutCubic:(t)=>1-Math.pow(1-t,3),};const defaultOptions={start:0,end:100,duration:2000,decimals:0,prefix:"",suffix:"",locale:"pt-BR",useGrouping:true,easing:"easeOutCubic",formatter:null,autostart:true,startOnView:false,once:true,root:null,rootMargin:"0px",threshold:0.1,onUpdate:null,onComplete:null,};function toNumber(value,fallback=0){const n=Number(value);return Number.isFinite(n)?n:fallback;}function normalizeOptions(options){const normalized={...options,start:toNumber(options.start,0),end:toNumber(options.end,100),duration:Math.max(0,toNumber(options.duration,2000)),decimals:Math.max(0,Math.floor(toNumber(options.decimals,0))),};normalized.easingFn=typeof normalized.easing==="function"?normalized.easing:easings[normalized.easing]||easings.easeOutCubic;return normalized;}function formatNumber(value,options){if(typeof options.formatter==="function"){return options.formatter(value,options.element,options.index);}const formatted=new Intl.NumberFormat(options.locale,{minimumFractionDigits:options.decimals,maximumFractionDigits:options.decimals,useGrouping:options.useGrouping,}).format(value);return`${options.prefix}${formatted}${options.suffix}`;}function isElement(target){return typeof Element!=="undefined"&&target instanceof Element;}function resolveElements(target){if(typeof target==="string"){return Array.from(document.querySelectorAll(target));}if(isElement(target)){return[target];}if(Array.isArray(target)){return target.filter(isElement);}if(typeof NodeList!=="undefined"&&(target instanceof NodeList||(typeof HTMLCollection!=="undefined"&&target instanceof HTMLCollection))){return Array.from(target).filter(isElement);}return[];}function createCounterInstance(element,userOptions={},index=0){if(!element){throw new Error("counterUp: target element not found.");}let options=normalizeOptions({...defaultOptions,...userOptions,element,index,});const state={value:options.start,from:options.start,to:options.end,elapsed:0,startTime:null,rafId:null,isRunning:false,isPaused:false,destroyed:false,hasPlayed:false,observer:null,};function render(value,notify=true){state.value=value;element.textContent=formatNumber(value,options);if(notify&&typeof options.onUpdate==="function"){options.onUpdate(value,element,index);}}function cancelFrame(){if(state.rafId!==null){cancelAnimationFrame(state.rafId);state.rafId=null;}}function disconnectObserver(){if(state.observer){state.observer.disconnect();state.observer=null;}}function animate(timestamp){if(!state.isRunning||state.destroyed)return;if(state.startTime===null){state.startTime=timestamp-state.elapsed;}state.elapsed=timestamp-state.startTime;const progress=options.duration===0?1:Math.min(state.elapsed/options.duration,1);const eased=options.easingFn(progress);const nextValue=state.from+(state.to-state.from)*eased;render(nextValue);if(progress<1){state.rafId=requestAnimationFrame(animate);return;}state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;render(state.to);if(typeof options.onComplete==="function"){options.onComplete(state.to,element,index);}}function play(from,to){if(state.destroyed)return api;cancelFrame();state.from=toNumber(from,state.value);state.to=toNumber(to,state.to);state.elapsed=0;state.startTime=null;state.isPaused=false;state.isRunning=true;state.hasPlayed=true;state.rafId=requestAnimationFrame(animate);return api;}function stop(){cancelFrame();state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;return api;}function start(){if(state.destroyed)return api;if(options.startOnView&&options.once){disconnectObserver();}if(state.isPaused){return resume();}if(state.isRunning){return api;}return play(options.start,options.end);}function pause(){if(!state.isRunning)return api;cancelFrame();state.isRunning=false;state.isPaused=true; state.startTime=null;return api;}function resume(){if(!state.isPaused||state.destroyed)return api;state.isRunning=true;state.isPaused=false; -state.startTime=null;state.rafId=requestAnimationFrame(animate);return api;}function reset(){stop();render(options.start,false);return api;}function set(value){const nextValue=toNumber(value,state.value);stop();state.from=nextValue;state.to=nextValue;render(nextValue);return api;}function update(nextEnd,nextOptions={}){if(state.destroyed)return api;options=normalizeOptions({...options,...nextOptions,element,index,start:nextOptions.start===undefined?state.value:toNumber(nextOptions.start),end:toNumber(nextEnd,options.end),});return play(options.start,options.end);}function destroy(){stop();state.destroyed=true;}const api={start,stop,pause,resume,reset,set,update,destroy,get value(){return state.value;},get running(){return state.isRunning;},get paused(){return state.isPaused;},};render(options.start,false);if(options.autostart){start();}return api;}function createGroupInstance(elements,userOptions){const instances=elements.map((element,index)=>createCounterInstance(element,userOptions,index));const api={start(){instances.forEach((instance)=>instance.start());return api;},stop(){instances.forEach((instance)=>instance.stop());return api;},pause(){instances.forEach((instance)=>instance.pause());return api;},resume(){instances.forEach((instance)=>instance.resume());return api;},reset(){instances.forEach((instance)=>instance.reset());return api;},set(value){if(Array.isArray(value)){instances.forEach((instance,index)=>{instance.set(value[index]??value[value.length-1]??0);});return api;}instances.forEach((instance)=>instance.set(value));return api;},update(nextEnd,nextOptions={}){if(Array.isArray(nextEnd)){instances.forEach((instance,index)=>{instance.update(nextEnd[index]??nextEnd[nextEnd.length-1]??0,nextOptions);});return api;}instances.forEach((instance)=>instance.update(nextEnd,nextOptions));return api;},destroy(){instances.forEach((instance)=>instance.destroy());return api;},get values(){return instances.map((instance)=>instance.value);},get running(){return instances.some((instance)=>instance.running);},get paused(){return instances.some((instance)=>instance.paused);},get count(){return instances.length;},};return api;}export function counterUp(target,userOptions={}){const elements=resolveElements(target);if(elements.length===0){throw new Error("counterUp: target element not found.");}if(elements.length===1){return createCounterInstance(elements[0],userOptions,0);}return createGroupInstance(elements,userOptions);}export default counterUp; \ No newline at end of file +state.startTime=null;state.rafId=requestAnimationFrame(animate);return api;}function reset(){stop();render(options.start,false);return api;}function set(value){const nextValue=toNumber(value,state.value);stop();state.from=nextValue;state.to=nextValue;render(nextValue);return api;}function update(nextEnd,nextOptions={}){if(state.destroyed)return api;disconnectObserver();options=normalizeOptions({...options,...nextOptions,element,index,start:nextOptions.start===undefined?state.value:toNumber(nextOptions.start),end:toNumber(nextEnd,options.end),});return play(options.start,options.end);}function destroy(){stop();disconnectObserver();state.destroyed=true;}function setupObserver(){if(!options.startOnView||!options.autostart||typeof IntersectionObserver==="undefined"){return;}disconnectObserver();state.observer=new IntersectionObserver((entries)=>{const entry=entries[0];if(!entry)return;if(entry.isIntersecting){if(options.once&&state.hasPlayed){return;}render(options.start,false);play(options.start,options.end);if(options.once){disconnectObserver();}return;}if(!options.once){stop();render(options.start,false);}},{root:options.root,rootMargin:options.rootMargin,threshold:options.threshold,});state.observer.observe(element);}const api={start,stop,pause,resume,reset,set,update,destroy,get value(){return state.value;},get running(){return state.isRunning;},get paused(){return state.isPaused;},};render(options.start,false);if(options.startOnView){setupObserver();}else if(options.autostart){start();}return api;}function createGroupInstance(elements,userOptions){const instances=elements.map((element,index)=>createCounterInstance(element,userOptions,index));const api={start(){instances.forEach((instance)=>instance.start());return api;},stop(){instances.forEach((instance)=>instance.stop());return api;},pause(){instances.forEach((instance)=>instance.pause());return api;},resume(){instances.forEach((instance)=>instance.resume());return api;},reset(){instances.forEach((instance)=>instance.reset());return api;},set(value){if(Array.isArray(value)){instances.forEach((instance,index)=>{instance.set(value[index]??value[value.length-1]??0);});return api;}instances.forEach((instance)=>instance.set(value));return api;},update(nextEnd,nextOptions={}){if(Array.isArray(nextEnd)){instances.forEach((instance,index)=>{instance.update(nextEnd[index]??nextEnd[nextEnd.length-1]??0,nextOptions);});return api;}instances.forEach((instance)=>instance.update(nextEnd,nextOptions));return api;},destroy(){instances.forEach((instance)=>instance.destroy());return api;},get values(){return instances.map((instance)=>instance.value);},get running(){return instances.some((instance)=>instance.running);},get paused(){return instances.some((instance)=>instance.paused);},get count(){return instances.length;},};return api;}export function counterUp(target,userOptions={}){const elements=resolveElements(target);if(elements.length===0){throw new Error("counterUp: target element not found.");}if(elements.length===1){return createCounterInstance(elements[0],userOptions,0);}return createGroupInstance(elements,userOptions);}export default counterUp; \ No newline at end of file diff --git a/dist/counterup.umd.js b/dist/counterup.umd.js index 85adfd6..bf2db7d 100644 --- a/dist/counterup.umd.js +++ b/dist/counterup.umd.js @@ -1,4 +1,4 @@ -/* @nullsablex/counter-up v0.1.5 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ +/* @nullsablex/counter-up v0.1.7 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ (function (global, factory) { if (typeof module === "object" && typeof module.exports === "object") { module.exports = factory(); @@ -25,6 +25,11 @@ easing: "easeOutCubic", formatter: null, autostart: true, + startOnView: false, + once: true, + root: null, + rootMargin: "0px", + threshold: 0.1, onUpdate: null, onComplete: null, }; @@ -115,6 +120,8 @@ isRunning: false, isPaused: false, destroyed: false, + hasPlayed: false, + observer: null, }; function render(value, notify = true) { @@ -132,6 +139,13 @@ } } + function disconnectObserver() { + if (state.observer) { + state.observer.disconnect(); + state.observer = null; + } + } + function animate(timestamp) { if (!state.isRunning || state.destroyed) return; @@ -171,6 +185,7 @@ state.startTime = null; state.isPaused = false; state.isRunning = true; + state.hasPlayed = true; state.rafId = requestAnimationFrame(animate); return api; } @@ -185,6 +200,10 @@ } function start() { + if (state.destroyed) return api; + if (options.startOnView && options.once) { + disconnectObserver(); + } if (state.isPaused) { return resume(); } @@ -231,6 +250,7 @@ function update(nextEnd, nextOptions = {}) { if (state.destroyed) return api; + disconnectObserver(); options = normalizeOptions({ ...options, ...nextOptions, @@ -245,9 +265,52 @@ function destroy() { stop(); + disconnectObserver(); state.destroyed = true; } + function setupObserver() { + if ( + !options.startOnView || + !options.autostart || + typeof IntersectionObserver === "undefined" + ) { + return; + } + + disconnectObserver(); + state.observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry) return; + + if (entry.isIntersecting) { + if (options.once && state.hasPlayed) { + return; + } + render(options.start, false); + play(options.start, options.end); + if (options.once) { + disconnectObserver(); + } + return; + } + + if (!options.once) { + stop(); + render(options.start, false); + } + }, + { + root: options.root, + rootMargin: options.rootMargin, + threshold: options.threshold, + } + ); + + state.observer.observe(element); + } + const api = { start, stop, @@ -269,7 +332,9 @@ }; render(options.start, false); - if (options.autostart) { + if (options.startOnView) { + setupObserver(); + } else if (options.autostart) { start(); } diff --git a/dist/counterup.umd.min.js b/dist/counterup.umd.min.js index 540daf5..a280cc7 100644 --- a/dist/counterup.umd.min.js +++ b/dist/counterup.umd.min.js @@ -1,4 +1,4 @@ -/* @nullsablex/counter-up v0.1.5 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ -(function(global,factory){if(typeof module==="object"&&typeof module.exports==="object"){module.exports=factory();}else{global.CounterUp=factory();}})(typeof window!=="undefined"?window:this,function(){"use strict";const easings={linear:(t)=>t,easeInOutQuad:(t)=>(t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2),easeOutCubic:(t)=>1-Math.pow(1-t,3),};const defaultOptions={start:0,end:100,duration:2000,decimals:0,prefix:"",suffix:"",locale:"pt-BR",useGrouping:true,easing:"easeOutCubic",formatter:null,autostart:true,onUpdate:null,onComplete:null,};function toNumber(value,fallback=0){const n=Number(value);return Number.isFinite(n)?n:fallback;}function normalizeOptions(options){const normalized={...options,start:toNumber(options.start,0),end:toNumber(options.end,100),duration:Math.max(0,toNumber(options.duration,2000)),decimals:Math.max(0,Math.floor(toNumber(options.decimals,0))),};normalized.easingFn=typeof normalized.easing==="function"?normalized.easing:easings[normalized.easing]||easings.easeOutCubic;return normalized;}function formatNumber(value,options){if(typeof options.formatter==="function"){return options.formatter(value,options.element,options.index);}const formatted=new Intl.NumberFormat(options.locale,{minimumFractionDigits:options.decimals,maximumFractionDigits:options.decimals,useGrouping:options.useGrouping,}).format(value);return`${options.prefix}${formatted}${options.suffix}`;}function isElement(target){return typeof Element!=="undefined"&&target instanceof Element;}function resolveElements(target){if(typeof target==="string"){return Array.from(document.querySelectorAll(target));}if(isElement(target)){return[target];}if(Array.isArray(target)){return target.filter(isElement);}if(typeof NodeList!=="undefined"&&(target instanceof NodeList||(typeof HTMLCollection!=="undefined"&&target instanceof HTMLCollection))){return Array.from(target).filter(isElement);}return[];}function createCounterInstance(element,userOptions={},index=0){if(!element){throw new Error("counterUp: target element not found.");}let options=normalizeOptions({...defaultOptions,...userOptions,element,index,});const state={value:options.start,from:options.start,to:options.end,elapsed:0,startTime:null,rafId:null,isRunning:false,isPaused:false,destroyed:false,};function render(value,notify=true){state.value=value;element.textContent=formatNumber(value,options);if(notify&&typeof options.onUpdate==="function"){options.onUpdate(value,element,index);}}function cancelFrame(){if(state.rafId!==null){cancelAnimationFrame(state.rafId);state.rafId=null;}}function animate(timestamp){if(!state.isRunning||state.destroyed)return;if(state.startTime===null){state.startTime=timestamp-state.elapsed;}state.elapsed=timestamp-state.startTime;const progress=options.duration===0?1:Math.min(state.elapsed/options.duration,1);const eased=options.easingFn(progress);const nextValue=state.from+(state.to-state.from)*eased;render(nextValue);if(progress<1){state.rafId=requestAnimationFrame(animate);return;}state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;render(state.to);if(typeof options.onComplete==="function"){options.onComplete(state.to,element,index);}}function play(from,to){if(state.destroyed)return api;cancelFrame();state.from=toNumber(from,state.value);state.to=toNumber(to,state.to);state.elapsed=0;state.startTime=null;state.isPaused=false;state.isRunning=true;state.rafId=requestAnimationFrame(animate);return api;}function stop(){cancelFrame();state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;return api;}function start(){if(state.isPaused){return resume();}if(state.isRunning){return api;}return play(options.start,options.end);}function pause(){if(!state.isRunning)return api;cancelFrame();state.isRunning=false;state.isPaused=true; +/* @nullsablex/counter-up v0.1.7 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ +(function(global,factory){if(typeof module==="object"&&typeof module.exports==="object"){module.exports=factory();}else{global.CounterUp=factory();}})(typeof window!=="undefined"?window:this,function(){"use strict";const easings={linear:(t)=>t,easeInOutQuad:(t)=>(t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2),easeOutCubic:(t)=>1-Math.pow(1-t,3),};const defaultOptions={start:0,end:100,duration:2000,decimals:0,prefix:"",suffix:"",locale:"pt-BR",useGrouping:true,easing:"easeOutCubic",formatter:null,autostart:true,startOnView:false,once:true,root:null,rootMargin:"0px",threshold:0.1,onUpdate:null,onComplete:null,};function toNumber(value,fallback=0){const n=Number(value);return Number.isFinite(n)?n:fallback;}function normalizeOptions(options){const normalized={...options,start:toNumber(options.start,0),end:toNumber(options.end,100),duration:Math.max(0,toNumber(options.duration,2000)),decimals:Math.max(0,Math.floor(toNumber(options.decimals,0))),};normalized.easingFn=typeof normalized.easing==="function"?normalized.easing:easings[normalized.easing]||easings.easeOutCubic;return normalized;}function formatNumber(value,options){if(typeof options.formatter==="function"){return options.formatter(value,options.element,options.index);}const formatted=new Intl.NumberFormat(options.locale,{minimumFractionDigits:options.decimals,maximumFractionDigits:options.decimals,useGrouping:options.useGrouping,}).format(value);return`${options.prefix}${formatted}${options.suffix}`;}function isElement(target){return typeof Element!=="undefined"&&target instanceof Element;}function resolveElements(target){if(typeof target==="string"){return Array.from(document.querySelectorAll(target));}if(isElement(target)){return[target];}if(Array.isArray(target)){return target.filter(isElement);}if(typeof NodeList!=="undefined"&&(target instanceof NodeList||(typeof HTMLCollection!=="undefined"&&target instanceof HTMLCollection))){return Array.from(target).filter(isElement);}return[];}function createCounterInstance(element,userOptions={},index=0){if(!element){throw new Error("counterUp: target element not found.");}let options=normalizeOptions({...defaultOptions,...userOptions,element,index,});const state={value:options.start,from:options.start,to:options.end,elapsed:0,startTime:null,rafId:null,isRunning:false,isPaused:false,destroyed:false,hasPlayed:false,observer:null,};function render(value,notify=true){state.value=value;element.textContent=formatNumber(value,options);if(notify&&typeof options.onUpdate==="function"){options.onUpdate(value,element,index);}}function cancelFrame(){if(state.rafId!==null){cancelAnimationFrame(state.rafId);state.rafId=null;}}function disconnectObserver(){if(state.observer){state.observer.disconnect();state.observer=null;}}function animate(timestamp){if(!state.isRunning||state.destroyed)return;if(state.startTime===null){state.startTime=timestamp-state.elapsed;}state.elapsed=timestamp-state.startTime;const progress=options.duration===0?1:Math.min(state.elapsed/options.duration,1);const eased=options.easingFn(progress);const nextValue=state.from+(state.to-state.from)*eased;render(nextValue);if(progress<1){state.rafId=requestAnimationFrame(animate);return;}state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;render(state.to);if(typeof options.onComplete==="function"){options.onComplete(state.to,element,index);}}function play(from,to){if(state.destroyed)return api;cancelFrame();state.from=toNumber(from,state.value);state.to=toNumber(to,state.to);state.elapsed=0;state.startTime=null;state.isPaused=false;state.isRunning=true;state.hasPlayed=true;state.rafId=requestAnimationFrame(animate);return api;}function stop(){cancelFrame();state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;return api;}function start(){if(state.destroyed)return api;if(options.startOnView&&options.once){disconnectObserver();}if(state.isPaused){return resume();}if(state.isRunning){return api;}return play(options.start,options.end);}function pause(){if(!state.isRunning)return api;cancelFrame();state.isRunning=false;state.isPaused=true; state.startTime=null;return api;}function resume(){if(!state.isPaused||state.destroyed)return api;state.isRunning=true;state.isPaused=false; -state.startTime=null;state.rafId=requestAnimationFrame(animate);return api;}function reset(){stop();render(options.start,false);return api;}function set(value){const nextValue=toNumber(value,state.value);stop();state.from=nextValue;state.to=nextValue;render(nextValue);return api;}function update(nextEnd,nextOptions={}){if(state.destroyed)return api;options=normalizeOptions({...options,...nextOptions,element,index,start:nextOptions.start===undefined?state.value:toNumber(nextOptions.start),end:toNumber(nextEnd,options.end),});return play(options.start,options.end);}function destroy(){stop();state.destroyed=true;}const api={start,stop,pause,resume,reset,set,update,destroy,get value(){return state.value;},get running(){return state.isRunning;},get paused(){return state.isPaused;},};render(options.start,false);if(options.autostart){start();}return api;}function createGroupInstance(elements,userOptions){const instances=elements.map((element,index)=>createCounterInstance(element,userOptions,index));const api={start(){instances.forEach((instance)=>instance.start());return api;},stop(){instances.forEach((instance)=>instance.stop());return api;},pause(){instances.forEach((instance)=>instance.pause());return api;},resume(){instances.forEach((instance)=>instance.resume());return api;},reset(){instances.forEach((instance)=>instance.reset());return api;},set(value){if(Array.isArray(value)){instances.forEach((instance,index)=>{instance.set(value[index]??value[value.length-1]??0);});return api;}instances.forEach((instance)=>instance.set(value));return api;},update(nextEnd,nextOptions={}){if(Array.isArray(nextEnd)){instances.forEach((instance,index)=>{instance.update(nextEnd[index]??nextEnd[nextEnd.length-1]??0,nextOptions);});return api;}instances.forEach((instance)=>instance.update(nextEnd,nextOptions));return api;},destroy(){instances.forEach((instance)=>instance.destroy());return api;},get values(){return instances.map((instance)=>instance.value);},get running(){return instances.some((instance)=>instance.running);},get paused(){return instances.some((instance)=>instance.paused);},get count(){return instances.length;},};return api;}function counterUp(target,userOptions={}){const elements=resolveElements(target);if(elements.length===0){throw new Error("counterUp: target element not found.");}if(elements.length===1){return createCounterInstance(elements[0],userOptions,0);}return createGroupInstance(elements,userOptions);}return{counterUp:counterUp,default:counterUp};}); \ No newline at end of file +state.startTime=null;state.rafId=requestAnimationFrame(animate);return api;}function reset(){stop();render(options.start,false);return api;}function set(value){const nextValue=toNumber(value,state.value);stop();state.from=nextValue;state.to=nextValue;render(nextValue);return api;}function update(nextEnd,nextOptions={}){if(state.destroyed)return api;disconnectObserver();options=normalizeOptions({...options,...nextOptions,element,index,start:nextOptions.start===undefined?state.value:toNumber(nextOptions.start),end:toNumber(nextEnd,options.end),});return play(options.start,options.end);}function destroy(){stop();disconnectObserver();state.destroyed=true;}function setupObserver(){if(!options.startOnView||!options.autostart||typeof IntersectionObserver==="undefined"){return;}disconnectObserver();state.observer=new IntersectionObserver((entries)=>{const entry=entries[0];if(!entry)return;if(entry.isIntersecting){if(options.once&&state.hasPlayed){return;}render(options.start,false);play(options.start,options.end);if(options.once){disconnectObserver();}return;}if(!options.once){stop();render(options.start,false);}},{root:options.root,rootMargin:options.rootMargin,threshold:options.threshold,});state.observer.observe(element);}const api={start,stop,pause,resume,reset,set,update,destroy,get value(){return state.value;},get running(){return state.isRunning;},get paused(){return state.isPaused;},};render(options.start,false);if(options.startOnView){setupObserver();}else if(options.autostart){start();}return api;}function createGroupInstance(elements,userOptions){const instances=elements.map((element,index)=>createCounterInstance(element,userOptions,index));const api={start(){instances.forEach((instance)=>instance.start());return api;},stop(){instances.forEach((instance)=>instance.stop());return api;},pause(){instances.forEach((instance)=>instance.pause());return api;},resume(){instances.forEach((instance)=>instance.resume());return api;},reset(){instances.forEach((instance)=>instance.reset());return api;},set(value){if(Array.isArray(value)){instances.forEach((instance,index)=>{instance.set(value[index]??value[value.length-1]??0);});return api;}instances.forEach((instance)=>instance.set(value));return api;},update(nextEnd,nextOptions={}){if(Array.isArray(nextEnd)){instances.forEach((instance,index)=>{instance.update(nextEnd[index]??nextEnd[nextEnd.length-1]??0,nextOptions);});return api;}instances.forEach((instance)=>instance.update(nextEnd,nextOptions));return api;},destroy(){instances.forEach((instance)=>instance.destroy());return api;},get values(){return instances.map((instance)=>instance.value);},get running(){return instances.some((instance)=>instance.running);},get paused(){return instances.some((instance)=>instance.paused);},get count(){return instances.length;},};return api;}function counterUp(target,userOptions={}){const elements=resolveElements(target);if(elements.length===0){throw new Error("counterUp: target element not found.");}if(elements.length===1){return createCounterInstance(elements[0],userOptions,0);}return createGroupInstance(elements,userOptions);}return{counterUp:counterUp,default:counterUp};}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 817aaaa..1c6f775 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nullsablex/counter-up", - "version": "0.1.5", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nullsablex/counter-up", - "version": "0.1.5", + "version": "0.1.7", "license": "MIT" } } diff --git a/package.json b/package.json index 002f735..ca20214 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nullsablex/counter-up", - "version": "0.1.5", + "version": "0.1.7", "description": "Biblioteca JS pura para animação de contadores numéricos", "author": "NullSablex", "repository": { diff --git a/src/counterup.js b/src/counterup.js index 90e6e42..5959e02 100644 --- a/src/counterup.js +++ b/src/counterup.js @@ -16,6 +16,11 @@ const defaultOptions = { easing: "easeOutCubic", formatter: null, autostart: true, + startOnView: false, + once: true, + root: null, + rootMargin: "0px", + threshold: 0.1, onUpdate: null, onComplete: null, }; @@ -106,6 +111,8 @@ function createCounterInstance(element, userOptions = {}, index = 0) { isRunning: false, isPaused: false, destroyed: false, + hasPlayed: false, + observer: null, }; function render(value, notify = true) { @@ -123,6 +130,13 @@ function createCounterInstance(element, userOptions = {}, index = 0) { } } + function disconnectObserver() { + if (state.observer) { + state.observer.disconnect(); + state.observer = null; + } + } + function animate(timestamp) { if (!state.isRunning || state.destroyed) return; @@ -162,6 +176,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { state.startTime = null; state.isPaused = false; state.isRunning = true; + state.hasPlayed = true; state.rafId = requestAnimationFrame(animate); return api; } @@ -176,6 +191,10 @@ function createCounterInstance(element, userOptions = {}, index = 0) { } function start() { + if (state.destroyed) return api; + if (options.startOnView && options.once) { + disconnectObserver(); + } if (state.isPaused) { return resume(); } @@ -222,6 +241,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { function update(nextEnd, nextOptions = {}) { if (state.destroyed) return api; + disconnectObserver(); options = normalizeOptions({ ...options, ...nextOptions, @@ -236,9 +256,52 @@ function createCounterInstance(element, userOptions = {}, index = 0) { function destroy() { stop(); + disconnectObserver(); state.destroyed = true; } + function setupObserver() { + if ( + !options.startOnView || + !options.autostart || + typeof IntersectionObserver === "undefined" + ) { + return; + } + + disconnectObserver(); + state.observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry) return; + + if (entry.isIntersecting) { + if (options.once && state.hasPlayed) { + return; + } + render(options.start, false); + play(options.start, options.end); + if (options.once) { + disconnectObserver(); + } + return; + } + + if (!options.once) { + stop(); + render(options.start, false); + } + }, + { + root: options.root, + rootMargin: options.rootMargin, + threshold: options.threshold, + } + ); + + state.observer.observe(element); + } + const api = { start, stop, @@ -260,7 +323,9 @@ function createCounterInstance(element, userOptions = {}, index = 0) { }; render(options.start, false); - if (options.autostart) { + if (options.startOnView) { + setupObserver(); + } else if (options.autostart) { start(); }