390 lines
15 KiB
JavaScript
390 lines
15 KiB
JavaScript
/**
|
|
* 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<Object>} 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<Object>} 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<Object>} 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());
|