PageSourceSearch

https://www.um.es/o/um-lr-74-principal-theme/js/components/um-scro…=js&languageId=es_ES&t=1789636316000

js um.es collected 2026-09-24 08:06:48 UTC 12,115 bytes, 323 lines download raw bytes

1/*1789636316000*/
2/**
3 * Módulo para detectar cuando el usuario ha hecho scroll más allá de un umbral.
4 * Dispara callbacks personalizados al cruzar los umbrales en ambas direcciones.
5 * Comportamiento configurable por instancia: una sola vez o continuo.
6 * Umbrales configurables por instancia.
7 * Permite activación manual de estados además de detección por scroll.
8 * 
9 * @module ScrollThresholdDetector
10 */
11(() => {
12  const ScrollThresholdDetector = {
13    // Configuración global por defecto (puede ser sobrescrita por instancia)
14    config: {
15      thresholdEnter: 120,
16      thresholdExit: 50,
17      debounceDelay: 300, // Milisegundos de espera antes de permitir otro cambio
18    },
19
20    // Estado interno para evitar múltiples instancias
21    instances: new Map(),
22    scrollListener: null,
23    isScrollListenerActive: false,
24    lastChangeTime: 0, // Timestamp del último cambio de estado
25
26    /**
27     * Inicializa una nueva instancia del detector de scroll.
28     * 
29     * @param {string} instanceId - Identificador único para la instancia
30     * @param {Object} options - Configuración de la instancia
31     * @param {Function} options.onEnter - Callback cuando se cruza thresholdEnter (descendente)
32     * @param {Function} options.onExit - Callback cuando se cruza thresholdExit (ascendente)
33     * @param {HTMLElement} [options.element] - Elemento sobre el que aplicar cambios (opcional, para contexto)
34     * @param {Boolean} [options.oneTimeOnly] - Si true, una vez scrolled permanece así. Si false, comportamiento bidireccional (default: false)
35     * @param {Number} [options.thresholdEnter] - Umbral para entrar en estado scrolled (default: 120)
36     * @param {Number} [options.thresholdExit] - Umbral para salir del estado scrolled (default: 50)
37     * @returns {Object} Objeto con métodos de control de la instancia
38     */
39    init(instanceId, options = {}) {
40      // Valida que la instancia no exista ya
41      if (this.instances.has(instanceId)) {
42        console.warn(`[ScrollThresholdDetector] La instancia "${instanceId}" ya existe.`);
43        return this.instances.get(instanceId);
44      }
45
46      // Crea la nueva instancia con umbrales configurables
47      const instance = {
48        id: instanceId,
49        onEnter: typeof options.onEnter === 'function' ? options.onEnter : null,
50        onExit: typeof options.onExit === 'function' ? options.onExit : null,
51        element: options.element || null,
52        lastState: null, // null = inicial, true = scrolled, false = no scrolled
53        oneTimeOnly: options.oneTimeOnly === true, // Comportamiento de una sola vez
54        hasScrolledOnce: false, // Bandera por instancia
55        // Umbrales por instancia (usa valores globales por defecto si no se especifican)
56        thresholdEnter: typeof options.thresholdEnter === 'number' ? options.thresholdEnter : this.config.thresholdEnter,
57        thresholdExit: typeof options.thresholdExit === 'number' ? options.thresholdExit : this.config.thresholdExit,
58      };
59
60      // Almacena la instancia
61      this.instances.set(instanceId, instance);
62
63      // Activa el listener global si no lo está
64      this._activateGlobalListener();
65
66      // Comprueba la posición inicial del scroll
67      this._checkInitialScroll(instance);
68
69      // Retorna objeto con métodos de control
70      return {
71        destroy: () => this._destroyInstance(instanceId),
72        getState: () => instance.lastState,
73        setScrolledState: (isScrolled) => this._setScrolledState(instanceId, isScrolled),
74      };
75    },
76
77    /**
78     * Activa el listener global de scroll si no está ya activo.
79     * Usa requestAnimationFrame para optimizar el rendimiento.
80     * @private
81     */
82    _activateGlobalListener() {
83      if (this.isScrollListenerActive) return;
84
85      this.scrollListener = () => {
86        window.requestAnimationFrame(() => {
87          this._checkAllInstances();
88        });
89      };
90
91      window.addEventListener('scroll', this.scrollListener, { passive: true });
92      this.isScrollListenerActive = true;
93    },
94
95    /**
96     * Desactiva el listener global cuando no hay instancias activas.
97     * @private
98     */
99    _deactivateGlobalListener() {
100      if (this.instances.size === 0 && this.isScrollListenerActive && this.scrollListener) {
101        window.removeEventListener('scroll', this.scrollListener);
102        this.scrollListener = null;
103        this.isScrollListenerActive = false;
104      }
105    },
106
107    /**
108     * Comprueba si está dentro del período de debounce.
109     * @private
110     */
111    _isDebounced() {
112      const now = Date.now();
113      const timeSinceLastChange = now - this.lastChangeTime;
114      return timeSinceLastChange < this.config.debounceDelay;
115    },
116
117    /**
118     * Marca el momento del último cambio de estado.
119     * @private
120     */
121    _recordStateChange() {
122      this.lastChangeTime = Date.now();
123    },
124
125    /**
126     * Comprueba la posición de scroll inicial para cada instancia.
127     * @private
128     */
129    _checkInitialScroll(instance) {
130      const y = window.scrollY;
131
132      if (y > instance.thresholdEnter) {
133        // El usuario comienza con scroll pasado el umbral
134        instance.lastState = true;
135        if (instance.oneTimeOnly) {
136          instance.hasScrolledOnce = true;
137        }
138        this._executeCallback(instance, 'onEnter', { scrollY: y, direction: 'initial' });
139        this._recordStateChange();
140      } else {
141        // El usuario comienza sin scroll o bajo el umbral
142        instance.lastState = false;
143        // No dispara callback en este caso, solo establece el estado
144      }
145    },
146
147    /**
148     * Comprueba el scroll para todas las instancias activas.
149     * @private
150     */
151    _checkAllInstances() {
152      const y = window.scrollY;
153
154      this.instances.forEach((instance) => {
155        this._checkInstance(instance, y);
156      });
157    },
158
159    /**
160     * Comprueba si se ha cruzado un umbral para una instancia específica.
161     * Comportamiento depende de la configuración oneTimeOnly de cada instancia.
162     * Usa los umbrales configurados por instancia.
163     * @private
164     */
165    _checkInstance(instance, scrollY) {
166      const thresholdEnter = instance.thresholdEnter;
167      const thresholdExit = instance.thresholdExit;
168
169      // Si está en debounce, no procesa cambios de estado
170      if (this._isDebounced()) {
171        return;
172      }
173
174      // Comportamiento si oneTimeOnly está activado
175      if (instance.oneTimeOnly) {
176        // Si ya se ha hecho scroll una vez, solo permite pasar a scrolled, nunca volver atrás
177        if (instance.hasScrolledOnce) {
178          // Solo permite transición de no-scrolled a scrolled
179          if (instance.lastState === false && scrollY > thresholdEnter) {
180            instance.lastState = true;
181            this._executeCallback(instance, 'onEnter', { scrollY, direction: 'down' });
182            this._recordStateChange();
183          }
184          // Ignora intentos de volver a no-scrolled
185          return;
186        }
187
188        // Si aún no se ha scrolleado, comportamiento normal (bidireccional)
189        // Transición: de no-scrolled a scrolled (cruzar thresholdEnter hacia abajo)
190        if (instance.lastState === false && scrollY > thresholdEnter) {
191          instance.lastState = true;
192          instance.hasScrolledOnce = true; // Marca que se ha scrolleado por primera vez
193          this._executeCallback(instance, 'onEnter', { scrollY, direction: 'down' });
194          this._recordStateChange();
195        }
196        // Transición: de scrolled a no-scrolled (cruzar thresholdExit hacia arriba)
197        // Solo se permite si aún no se ha scrolleado (before hasScrolledOnce)
198        else if (instance.lastState === true && scrollY < thresholdExit) {
199          instance.lastState = false;
200          this._executeCallback(instance, 'onExit', { scrollY, direction: 'up' });
201          this._recordStateChange();
202        }
203      } else {
204        // Comportamiento continuo (bidireccional) - sin restricciones de una sola vez
205        // Transición: de no-scrolled a scrolled (cruzar thresholdEnter hacia abajo)
206        if (instance.lastState === false && scrollY > thresholdEnter) {
207          instance.lastState = true;
208          this._executeCallback(instance, 'onEnter', { scrollY, direction: 'down' });
209          this._recordStateChange();
210        }
211        // Transición: de scrolled a no-scrolled (cruzar thresholdExit hacia arriba)
212        else if (instance.lastState === true && scrollY < thresholdExit) {
213          instance.lastState = false;
214          this._executeCallback(instance, 'onExit', { scrollY, direction: 'up' });
215          this._recordStateChange();
216        }
217      }
218    },
219
220    /**
221     * Establece manualmente el estado scrolled de una instancia.
222     * Permite activación manual sin depender del scroll real.
223     * Respeta la configuración oneTimeOnly: si está activada, no permite desactivar scrolled.
224     * Retorna una Promise que se resuelve cuando la transición está completa.
225     * 
226     * @param {string} instanceId - ID de la instancia
227     * @param {boolean} isScrolled - true para activar scrolled, false para desactivar
228     * @returns {Promise} Se resuelve cuando el callback se ha ejecutado
229     */
230    _setScrolledState(instanceId, isScrolled) {
231      return new Promise((resolve) => {
232        const instance = this.instances.get(instanceId);
233        if (!instance) {
234          console.warn(`[ScrollThresholdDetector] La instancia "${instanceId}" no existe.`);
235          resolve();
236          return;
237        }
238
239        const targetState = isScrolled === true;
240
241        // Si está en debounce, espera a que termine
242        if (this._isDebounced()) {
243          const waitTime = this.config.debounceDelay - (Date.now() - this.lastChangeTime);
244          setTimeout(() => {
245            this._setScrolledState(instanceId, isScrolled).then(resolve);
246          }, waitTime);
247          return;
248        }
249
250        // Si oneTimeOnly está activo y hasScrolledOnce es true, NO permite desactivar
251        if (instance.oneTimeOnly && instance.hasScrolledOnce && !target
251State) {
252          resolve();
253          return;
254        }
255
256        // Si ya está en el estado deseado, no hace nada
257        if (instance.lastState === targetState) {
258          resolve();
259          return;
260        }
261
262        // Ejecuta la transición
263        instance.lastState = targetState;
264
265        // Si es activación y oneTimeOnly está activo, marca como scrolleado
266        if (targetState && instance.oneTimeOnly) {
267          instance.hasScrolledOnce = true;
268        }
269
270        if (targetState) {
271          // Activar scrolled (onEnter)
272          this._executeCallback(instance, 'onEnter', { scrollY: window.scrollY, direction: 'manual' });
273        } else {
274          // Desactivar scrolled (onExit)
275          this._executeCallback(instance, 'onExit', { scrollY: window.scrollY, direction: 'manual' });
276        }
277
278        this._recordStateChange();
279
280        // Resuelve después de que el callback se ejecute (asíncrono)
281        Promise.resolve().then(resolve);
282      });
283    },
284
285    /**
286     * Ejecuta un callback de forma asíncrona si existe.
287     * @private
288     */
289    _executeCallback(instance, callbackName, data) {
290      const callback = instance[callbackName];
291      if (!callback) return;
292
293      // Ejecuta de forma asíncrona usando Promise
294      Promise.resolve().then(() => {
295        try {
296          callback.call(instance, data);
297        } catch (error) {
298          console.error(
299            `[ScrollThresholdDetector] Error en callback "${callbackName}" de la instancia "${instance.id}":`,
300            error
301          );
302        }
303      });
304    },
305
306    /**
307     * Destruye una instancia y limpia sus recursos.
308     * @private
309     */
310    _destroyInstance(instanceId) {
311      if (!this.instances.has(instanceId)) {
312        console.warn(`[ScrollThresholdDetector] La instancia "${instanceId}" no existe.`);
313        return;
314      }
315
316      this.instances.delete(instanceId);
317      this._deactivateGlobalListener();
318    },
319  };
320
321  // Expone el módulo globalmente
322  window.ScrollThresholdDetector = ScrollThresholdDetector;
323})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.