/** * 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"); if (!content) return; const vw = window.innerWidth; const vh = window.innerHeight; const minWidth = 768; if (vw < minWidth) { // Escalar en móviles const scale = vw / minWidth; content.style.transform = `scale(${scale})`; content.style.transformOrigin = "top left"; content.style.width = minWidth + "px"; content.style.height = (vh / scale) + "px"; content.style.position = "fixed"; content.style.left = "0"; content.style.top = "0"; content.style.overflow = "hidden"; } else { // Pantalla completa en desktop content.style.transform = ""; content.style.transformOrigin = ""; content.style.width = "100vw"; content.style.height = "100vh"; content.style.position = "fixed"; content.style.left = "0"; content.style.top = "0"; content.style.overflow = "auto"; } 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", () => { /** * 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());