/** * 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: false, }; /** * 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; } /** * Ajusta el contenido del curso para ocupar el máximo de pantalla. * @function scaleWrapCourseContent */ function scaleWrapCourseContent() { const content = document.querySelector('.wrap-course-content'); const mainContent = document.querySelector('#coursenav-main-content'); if (!content) return; const header = document.querySelector('header'); const footer = document.querySelector('footer'); const headerHeight = header ? header.offsetHeight : 0; const footerHeight = footer ? footer.offsetHeight : 0; const ww = window.innerWidth; const wh = window.innerHeight - headerHeight; if (ww < 1366) { const scale = Math.min(ww / 1366, wh / 768); content.style.transform = `scale(${scale})`; content.style.transformOrigin = 'top left'; content.style.width = '1366px'; content.style.height = '768px'; content.style.position = 'absolute'; content.style.left = (ww - 1366 * scale) / 2 + 'px'; content.style.top = headerHeight + (wh - 768 * scale) / 2 + 'px'; content.style.overflow = 'hidden'; if (mainContent) { const availableHeight = 768 - 80; mainContent.style.height = availableHeight + 'px'; } } else { content.style.transform = ''; content.style.transformOrigin = ''; content.style.width = '100%'; content.style.height = '100vh'; content.style.position = 'relative'; content.style.left = ''; content.style.top = ''; content.style.overflow = 'hidden'; if (mainContent) { mainContent.style.height = `calc(100vh - ${headerHeight + footerHeight}px)`; } } 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', () => { // Ajustar contenido al cargar y redimensionar setTimeout(scaleWrapCourseContent, 100); window.addEventListener('resize', () => 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());