/** * Configuración global del curso. * @namespace * @property {string} COURSE_CONFIG_URL - Ruta al archivo de configuración JSON del curso. * @property {boolean} DEBUG - Habilita/deshabilita el modo de depuración. */ window.COURSE_CONFIG = { COURSE_CONFIG_URL: 'config.json', DEBUG: true, }; /** * Renderiza la paginación mostrando la posición actual dentro del módulo. * @function renderPagination * @param {number} currentIndex - Índice del slide actual en el array completo. * @param {Array} contentArray - Array completo de slides del curso. * @example * // Ejemplo de uso: * renderPagination(2, courseData.contentArray); */ function renderPagination(currentIndex, contentArray) { const pageNumber = document.getElementById('coursenav-page-number'); const totalPages = document.getElementById('coursenav-total-pages'); if (!Array.isArray(contentArray)) return; // Gestionar visibilidad de menús primero manageMenuVisibility(currentIndex, contentArray); // Obtener el módulo actual basado en el slide actual const currentSlide = contentArray[currentIndex]; if (!currentSlide) return; // Filtrar todos los slides del mismo módulo const moduleSlides = contentArray.filter((slide) => slide.moduleTitle === currentSlide.moduleTitle); // Encontrar la posición del slide actual dentro del módulo const moduleSlideIndex = moduleSlides.findIndex((slide) => slide.content === currentSlide.content); if (pageNumber) pageNumber.textContent = moduleSlideIndex + 1; if (totalPages) totalPages.textContent = ' / ' + moduleSlides.length; //Navegación personalizada updateNavButtons(moduleSlideIndex, moduleSlides); } /** * Actualiza el estado (habilitado/deshabilitado) de los botones de navegación * del módulo (anterior/siguiente). * * Esta función deshabilita el botón "siguiente" si el usuario está en la última * diapositiva del módulo y el botón "anterior" si está en la primera. * Sin embargo, si el curso está en modo de depuración (`CourseNav.isDebug()` es true), * los botones permanecerán habilitados, permitiendo la navegación libre. * * @function updateNavButtons * @param {number} moduleSlideIndex - El índice de base cero de la diapositiva actual dentro de su módulo. * @param {Array} moduleSlides - Un array de los objetos de diapositiva que pertenecen al módulo actual. */ function updateNavButtons(moduleSlideIndex, moduleSlides) { const nextBtn = document.getElementById('coursenav-next-btn'); const prevBtn = document.getElementById('coursenav-prev-btn'); if (!nextBtn || !prevBtn) return; const isLastSlide = moduleSlideIndex + 1 === moduleSlides.length; const isFirstSlide = moduleSlideIndex === 0; const isDebugMode = CourseNav.isDebug(); nextBtn.classList.toggle('disabled', isLastSlide && !isDebugMode); prevBtn.classList.toggle('disabled', isFirstSlide && !isDebugMode); } /** * Gestiona la visibilidad de los menús del curso en la barra de navegación. * * Esta función muestra u oculta dinámicamente los menús secundarios * (`ul.course-menu`) basándose en el módulo del slide actual. Se asume que * el primer menú es el principal y siempre debe estar visible, mientras que * los menús subsecuentes son específicos de cada módulo y solo se muestran * cuando el usuario está navegando en dicho módulo. * * @function manageMenuVisibility * @param {number} currentIndex - El índice del slide actual en el array global de contenido. * @param {Array} contentArray - El array completo de objetos de slide del curso. */ function manageMenuVisibility(currentIndex, contentArray) { const courseMenus = document.querySelectorAll('#coursenav-main-menu > ul.course-menu'); if (!courseMenus.length) return; // El primer menú siempre está visible. courseMenus[0].style.display = 'block'; // Si no hay contenido, ocultar los demás menús. if (!Array.isArray(contentArray) || !contentArray.length) { for (let i = 1; i < courseMenus.length; i++) { courseMenus[i].style.display = 'none'; } return; } const currentSlide = contentArray[currentIndex]; // Si el slide actual no existe o no tiene un título de módulo, ocultar los demás. if (!currentSlide?.moduleTitle) { for (let i = 1; i < courseMenus.length; i++) { courseMenus[i].style.display = 'none'; } return; } const currentModuleTitle = currentSlide.moduleTitle; for (let i = 1; i < courseMenus.length; i++) { const menuItems = Array.from(courseMenus[i].querySelectorAll('.coursenav-link')); const shouldShow = menuItems.some((item) => { const itemIndex = parseInt(item.dataset.coursenavindex); return itemIndex >= 0 && contentArray[itemIndex]?.moduleTitle === currentModuleTitle; }); courseMenus[i].style.display = shouldShow ? 'block' : 'none'; } } /** * Desplaza la ventana hasta la parte superior de un elemento. * @function scrollToElementTop * @param {string} selector - Selector CSS del elemento objetivo. * @param {Object} [options={}] - Opciones de scroll. * @param {string} [options.behavior="smooth"] - Comportamiento del scroll ('auto' o 'smooth'). * @param {string} [options.block="start"] - Alineación vertical ('start', 'center', 'end' o 'nearest'). * @param {string} [options.inline="nearest"] - Alineación horizontal ('start', 'center', 'end' o 'nearest'). * @example * // Ejemplo de uso: * scrollToElementTop('#main-content', { behavior: 'smooth', block: 'start' }); */ function scrollToElementTop(selector, options = {}) { const defaults = { behavior: 'smooth', block: 'start', inline: 'nearest' }; const opts = Object.assign(defaults, options); const el = document.querySelector(selector); if (el) el.scrollIntoView(opts); } /** * Observador de intersección para animar elementos cuando son visibles en el viewport. * @function animateOnScroll * @param {string} selector - Selector de los elementos a observar. * @param {string} animationClass - Clase de animación de Animate.css (ej. 'animate__fadeInUp'). * @param {Object} [options={}] - Opciones de configuración. * @param {number} [options.threshold=0.1] - Umbral de visibilidad (0-1). * @param {boolean} [options.animateOnce=true] - Si es true, la animación solo se ejecuta una vez. * @param {string} [options.prefix='animate__animated'] - Prefijo para clases de animación. * @returns {IntersectionObserver} Instancia del observador. * @example * // Ejemplo de uso: * animateOnScroll('.animar', 'animate__fadeIn', { threshold: 0.2 }); */ function animateOnScroll(selector, animationClass, options = {}) { const { threshold = 0.1, animateOnce = true, prefix = 'animate__animated' } = options; const cb = (entries, observer) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add(prefix, animationClass); if (animateOnce) observer.unobserve(entry.target); } else if (!animateOnce) { entry.target.classList.remove(prefix, animationClass); } }); }; const observer = new IntersectionObserver(cb, { threshold }); document.querySelectorAll(selector).forEach((el) => observer.observe(el)); return observer; } function scaleWrapCourseContent() { const content = document.querySelector('.wrap-course-content'); if (!content) return; const ww = window.innerWidth; const wh = window.innerHeight; // Breakpoint a 1500px let baseWidth, baseHeight; if (ww < 1500) { // Menor a 1500px: usar 1366x768 baseWidth = 1366; baseHeight = 768; } else { // 1500px o más: usar 1920x1080 baseWidth = 1920; baseHeight = 1080; } // Escala simple const scale = Math.min(ww / baseWidth, wh / baseHeight); content.style.transform = `scale(${scale})`; content.style.transformOrigin = 'top left'; content.style.width = baseWidth + 'px'; content.style.height = baseHeight + 'px'; content.style.position = 'absolute'; content.style.left = (ww - baseWidth * scale) / 2 + 'px'; content.style.top = (wh - baseHeight * scale) / 2 + 'px'; content.style.overflow = 'hidden'; content.style.zIndex = '1'; } /** * Renderiza un stepper visual que muestra el progreso del curso. * @function renderStepper * @param {HTMLElement|string} stepperEl - Elemento o selector del contenedor del stepper. * @param {number} progressPercent - Porcentaje de progreso (0-100). * @param {HTMLElement|string} mobileEl - Elemento o selector del indicador móvil. * @param {number} [stepsCount=5] - Número de pasos visibles en el stepper. * @throws {Error} Si los elementos no son válidos. * @example * // Ejemplo de uso: * renderStepper('#stepper', 75, '#step-movil', 4); */ function renderStepper(stepperEl, progressPercent, mobileEl, stepsCount = 5) { // Validación y obtención de elementos if (typeof stepperEl === 'string') stepperEl = document.querySelector(stepperEl); if (typeof mobileEl === 'string') mobileEl = document.querySelector(mobileEl); if (!(stepperEl instanceof HTMLElement) || !(mobileEl instanceof HTMLElement)) { throw new Error('renderStepper: elementos inválidos.'); } // Limpiar contenido previo stepperEl.querySelectorAll('.step').forEach((el) => el.remove()); // Calcular posiciones de los pasos const stepPercents = Array.from({ length: stepsCount }, (_, i) => (i / (stepsCount - 1)) * 100); // Determinar paso actual const currentIndex = stepPercents .map((p, i) => ({ p, i })) .filter(({ p }) => p <= progressPercent) .pop().i; // Crear elementos de los pasos stepPercents.forEach((pct, i) => { const step = document.createElement('div'); step.classList.add('step'); if (i < currentIndex) step.classList.add('completed'); if (i === currentIndex) { step.classList.add('completed'); step.setAttribute('data-label', Math.round(pct) + '%'); } stepperEl.appendChild(step); }); // Actualizar estilos y posición stepperEl.style.setProperty('--pct', progressPercent + '%'); const halfMobile = mobileEl.offsetWidth; mobileEl.style.left = `calc(${progressPercent}% - ${halfMobile}px)`; mobileEl.setAttribute('data-label', progressPercent + '%'); } /** * Navega al primer slide que tenga el título "Menús de la herramienta". * * Esta función busca en todo el contenido del curso y se desplaza a la primera * diapositiva que coincida con ese título específico y modulo actual de donde es invocada la funcion. Es útil para crear * accesos directos a secciones clave. * @function gotoFirstMenuToolSlide * @example * // Se puede vincular a un botón: * // document.getElementById('mi-boton').addEventListener('click', gotoFirstMenuToolSlide); */ function gotoFirstMenuToolSlide() { const contentArray = CourseNav.getCourseContentArray(); const targetTitle = 'Menús de la herramienta'; const currentSlide = CourseNav.getCurrentSlide(); // Filtrar todos los slides del mismo módulo const targetIndex = contentArray.findIndex( (slide) => slide.title === targetTitle && slide.moduleTitle === currentSlide.moduleTitle ); if (targetIndex !== -1) { CourseNav.gotoSlide(targetIndex); } else { console.warn(`No se encontró ningún slide con el título '${targetTitle}'.`); } } /** * Configura los event listeners cuando el DOM está completamente cargado. * @event DOMContentLoaded */ document.addEventListener('DOMContentLoaded', () => { // Escalado inicial y continuo scaleWrapCourseContent(); setTimeout(scaleWrapCourseContent, 100); // Escalado en redimensionamiento con debounce let resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(scaleWrapCourseContent, 50); }); // Escalado en cambio de orientación (móviles/tablets) window.addEventListener('orientationchange', () => { setTimeout(scaleWrapCourseContent, 200); }); // Escalado cuando cambia la visibilidad (cambio de pestaña) document.addEventListener('visibilitychange', () => { if (!document.hidden) { setTimeout(scaleWrapCourseContent, 100); } }); /** * Evento antes de cambiar de slide. * @event beforeSlideChange * @property {Object} detail - Detalles del evento. * @property {number} detail.currentIndex - Índice del slide actual. * @property {Array} detail.contentArray - Array completo de contenido. */ document.body.addEventListener('beforeSlideChange', (e) => { console.log('Antes de cambiar de slide:', e.detail); }); /** * Evento al cambiar de slide. * @event slideChange * @property {Object} detail - Detalles del evento. * @property {number} detail.slideIndex - Índice del nuevo slide. * @property {Array} detail.contentArray - Array completo de contenido. */ document.body.addEventListener('slideChange', (e) => { if (e.detail && typeof e.detail.slideIndex === 'number' && Array.isArray(e.detail.contentArray)) { renderPagination(e.detail.slideIndex, e.detail.contentArray); console.log(e.detail.contentArray[e.detail.slideIndex].content); const contentArray = e.detail.contentArray; const targetTitle = 'Menús de la herramienta'; const currentSlide = CourseNav.getCurrentSlide(); // Filtrar todos los slides del mismo módulo const targetIndex = contentArray.findIndex( (slide) => slide.title === targetTitle && slide.moduleTitle === currentSlide.moduleTitle ); const btn = document.getElementById('coursenav-other-btn'); if (!btn) return; if (targetIndex !== -1) { btn.classList.remove('disabled'); } else { btn.classList.add('disabled'); } } const titleSlide = document.getElementById('coursenav-course-title'); if (titleSlide) { const slide = e.detail.contentArray[e.detail.slideIndex]; const moduleTitle = slide.moduleTitle ? slide.moduleTitle + ' | ' : ''; const title = slide.title || 'Sin título'; titleSlide.textContent = moduleTitle + title; } const stepper = document.getElementById('stepper'); const movil = document.getElementById('step-movil'); const progreso = CourseNav.getProgressPercent(true); renderStepper(stepper, progreso, movil); }); /** * Evento al completar un slide. * @event slideCompleted * @property {Object} detail - Detalles del evento. */ document.body.addEventListener('slideCompleted', (e) => { console.log('Slide completado:', e.detail); const stepper = document.getElementById('stepper'); const movil = document.getElementById('step-movil'); const progreso = CourseNav.getProgressPercent(true); renderStepper(stepper, progreso, movil); renderPagination(e.detail.slideIndex, CourseNav.getCourseContentArray()); }); // Event listener para botón personalizado const customButtonEvent = document.getElementById('coursenav-other-btn'); if (customButtonEvent) { customButtonEvent.addEventListener('click', () => { console.log('Botón personalizado clickeado'); gotoFirstMenuToolSlide(); }); } }); // Escalar contenido al cargar y redimensionar window.addEventListener('DOMContentLoaded', () => scaleWrapCourseContent()); window.addEventListener('resize', () => scaleWrapCourseContent());