/* ===================================================================== js/ux.js — Capa de experiencia de la app (editor, C). No toca el DOM al cargar: WAM.initUX() se ejecuta una sola vez desde WAM.renderEditor(). · Historial deshacer / rehacer (WAM.puntoDeshacer / deshacer / rehacer) · Indicador de guardado en la toolbar (#estado-guardado) · Confirmación de Restablecer / Importar (WAM.confirmarReemplazo) · Diálogo «Demos»: escenarios por industria + demos guardadas (wam-demos) · Atajos de teclado globales y diálogo de ayuda (?); G / F6 y el enlace «Saltar a la vista previa» mueven el foco entre editor y teléfono · Pestañas Editor / Vista previa en ventana estrecha y menú «⋯» · Enlace vista previa → tarjeta (Alt+clic o doble clic en una burbuja) · Región sr-only que anuncia los mensajes nuevos del chat ===================================================================== */ (function () { "use strict"; window.WAM = window.WAM || {}; var LIMITE_HISTORIAL = 30; var CLAVE_DEMOS = "wam-demos"; var PREFIJO_DEMO = "wam-demo-"; var CLAVE_ACTIVA = "wam-demo-activa"; var CLAVE_VISTA = "wam-ui-vista"; var MQ_ESTRECHA = "(max-width: 900px)"; var pilaDeshacer = []; var pilaRehacer = []; var iniciado = false; /* ------------------------------------------------------------------ Utilidades ------------------------------------------------------------------ */ function porId(id) { return document.getElementById(id); } function h(etiqueta, attrs, hijos) { var el = document.createElement(etiqueta); Object.keys(attrs || {}).forEach(function (k) { var v = attrs[k]; if (v === null || v === undefined || v === false) return; if (k === "class") el.className = v; else if (k === "text") el.textContent = v; else if (k === "value") el.value = v; else el.setAttribute(k, v === true ? "" : v); }); (Array.isArray(hijos) ? hijos : (hijos ? [hijos] : [])).forEach(function (x) { if (x) el.appendChild(typeof x === "string" ? document.createTextNode(x) : x); }); return el; } function toast(texto, opts) { if (typeof WAM.toast === "function") return WAM.toast(texto, opts); return function () {}; } function lsGet(k) { try { return window.localStorage.getItem(k); } catch (e) { return null; } } function lsSet(k, v) { try { window.localStorage.setItem(k, v); return true; } catch (e) { return false; } } function lsDel(k) { try { window.localStorage.removeItem(k); } catch (e) { /* nada */ } } function esEstrecha() { return !!(window.matchMedia && window.matchMedia(MQ_ESTRECHA).matches); } function enCampoTexto(t) { if (!t || !t.tagName) return false; if (t.isContentEditable) return true; if (t.tagName === "TEXTAREA" || t.tagName === "SELECT") return true; if (t.tagName === "INPUT") { var tipo = (t.type || "text").toLowerCase(); return ["checkbox", "radio", "button", "submit", "reset", "file", "range", "color"].indexOf(tipo) === -1; } return false; } function esInteractivo(t) { if (!t || !t.closest) return false; return !!t.closest("button, a[href], input, textarea, select, summary, [role='button'], [role='tab'], [tabindex]:not([tabindex='-1'])"); } function nMensajes() { return (WAM.estado && Array.isArray(WAM.estado.mensajes)) ? WAM.estado.mensajes.length : 0; } // Copia profunda estructural: los strings (dataURL incluidos) se comparten // por referencia, así 30 instantáneas no multiplican los MB en memoria. function clonar(v) { if (Array.isArray(v)) return v.map(clonar); if (v && typeof v === "object") { var o = {}; for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) o[k] = clonar(v[k]); return o; } return v; } function reRenderTotal() { if (typeof WAM.setTema === "function") WAM.setTema(WAM.estado.tema); if (typeof WAM.render === "function") WAM.render(); if (typeof WAM.renderEditor === "function") WAM.renderEditor(); if (typeof WAM.onDemoChange === "function") { try { WAM.onDemoChange(false); } catch (e) { /* opcional */ } } } function guardarYa() { if (typeof WAM.guardarYa === "function") return WAM.guardarYa(); if (typeof WAM.guardar === "function") WAM.guardar(); return true; } // Sustituye el estado entero (undo, escenario, demo guardada) function sustituirEstado(nuevo) { if (typeof WAM.demoActiva === "function" && WAM.demoActiva() && typeof WAM.detenerDemo === "function") WAM.detenerDemo(); if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet(); WAM.estado = nuevo; guardarYa(); reRenderTotal(); marcarEditado(); } function normalizado(crudo) { var previo = WAM.estado; WAM.estado = null; // los IDs empiezan en m1 (el estado se sustituye entero) try { return (typeof WAM.normalizar === "function") ? WAM.normalizar(crudo) : crudo; } finally { WAM.estado = previo; } } /* ------------------------------------------------------------------ Historial deshacer / rehacer ------------------------------------------------------------------ */ WAM.puntoDeshacer = function () { if (!WAM.estado) return; pilaDeshacer.push(clonar(WAM.estado)); if (pilaDeshacer.length > LIMITE_HISTORIAL) pilaDeshacer.shift(); pilaRehacer = []; pintarBotonesHistorial(); }; WAM.puedeDeshacer = function () { return pilaDeshacer.length > 0; }; // El tema es una preferencia de visualización, no contenido: deshacer y // rehacer conservan el tema actual en vez del de la instantánea. function conTemaActual(x) { if (x && WAM.estado) x.tema = WAM.estado.tema; return x; } WAM.deshacer = function () { if (!pilaDeshacer.length) { toast("No hay nada que deshacer"); return false; } pilaRehacer.push(clonar(WAM.estado)); sustituirEstado(conTemaActual(pilaDeshacer.pop())); pintarBotonesHistorial(); toast("Cambio deshecho", { accion: { texto: "Rehacer", fn: WAM.rehacer } }); return true; }; WAM.rehacer = function () { if (!pilaRehacer.length) { toast("No hay nada que rehacer"); return false; } pilaDeshacer.push(clonar(WAM.estado)); sustituirEstado(conTemaActual(pilaRehacer.pop())); pintarBotonesHistorial(); toast("Cambio rehecho"); return true; }; function pintarBotonesHistorial() { var d = porId("btn-deshacer"), r = porId("btn-rehacer"); if (d) d.disabled = !pilaDeshacer.length; if (r) r.disabled = !pilaRehacer.length; } /* ------------------------------------------------------------------ Indicador de guardado (#estado-guardado) ------------------------------------------------------------------ */ var ultimoGuardado = 0; var tComprobar = 0; var errorGuardado = ""; function pintarGuardado(estadoTxt, clase, titulo) { var el = porId("estado-guardado"); if (!el) return; el.setAttribute("data-estado", clase); var txt = el.querySelector(".tb-guardado-txt"); if (txt) txt.textContent = estadoTxt; el.title = titulo || estadoTxt; pintarRelativo(); } function formatoTamano(bytes) { if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + " KB"; return (bytes / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB"; } function pintarRelativo() { var el = porId("estado-guardado"); var rel = el && el.querySelector(".tb-guardado-rel"); if (!rel) return; if (el.getAttribute("data-estado") !== "ok" || !ultimoGuardado) { rel.textContent = ""; return; } var s = Math.max(0, Math.round((Date.now() - ultimoGuardado) / 1000)); rel.textContent = s < 5 ? "· ahora" : (s < 60 ? "· hace " + s + " s" : "· hace " + Math.round(s / 60) + " min"); } function comprobarGuardado() { tComprobar = 0; var ok = guardarYa(); if (ok === false) { pintarGuardado("No se pudo guardar", "error", errorGuardado || "No se pudo guardar en este navegador: exporta el JSON."); return; } errorGuardado = ""; ultimoGuardado = Date.now(); var bruto = lsGet(WAM.CLAVE || "wam-mockup-v1"); var bytes = bruto ? bruto.length * 2 : 0; // UTF-16 en memoria del navegador var extra = bytes > 3 * 1024 * 1024 ? " · " + formatoTamano(bytes) : ""; pintarGuardado("Guardado" + extra, "ok", "Guardado en este navegador" + (bytes ? " (" + formatoTamano(bytes) + ")" : "") + (bytes > 4 * 1024 * 1024 ? ". Te acercas al límite (~5 MB): usa URLs en vez de subir archivos pesados." : "")); } // El editor la llama tras cada mutación: «Guardando…» y comprobación diferida function marcarEditado() { if (errorGuardado) return; // mantener el aviso en rojo hasta que vuelva a funcionar pintarGuardado("Guardando…", "pendiente"); clearTimeout(tComprobar); tComprobar = setTimeout(comprobarGuardado, 700); } WAM.marcarEditado = marcarEditado; function alErrorGuardar(texto) { errorGuardado = texto || "No se pudo guardar"; pintarGuardado("No se pudo guardar", "error", errorGuardado); toast(errorGuardado, { tipo: "error", ms: 12000, accion: { texto: "Exportar JSON", fn: function () { if (typeof WAM.exportarJSON === "function") WAM.exportarJSON(); } } }); // Reintento: al siguiente guardado correcto el indicador vuelve a verde setTimeout(function () { errorGuardado = ""; }, 4000); } /* ------------------------------------------------------------------ Diálogos ( nativo; reserva con window.confirm) ------------------------------------------------------------------ */ var soportaDialog = typeof window.HTMLDialogElement === "function"; function crearDialogo(id, titulo, clase) { var viejo = porId(id); if (viejo) viejo.remove(); var cerrar = h("button", { type: "button", class: "wam-dlg-cerrar", "aria-label": "Cerrar", text: "×" }); var cuerpo = h("div", { class: "wam-dlg-cuerpo" }); var pie = h("div", { class: "wam-dlg-pie" }); var dlg = h("dialog", { id: id, class: "wam-dlg" + (clase ? " " + clase : ""), "aria-labelledby": id + "-titulo" }, [ h("div", { class: "wam-dlg-cab" }, [h("h2", { id: id + "-titulo", class: "wam-dlg-titulo", text: titulo }), cerrar]), cuerpo, pie ]); cerrar.addEventListener("click", function () { dlg.close(); }); // Clic en el fondo (fuera de la caja) cierra dlg.addEventListener("click", function (e) { if (e.target !== dlg) return; var r = dlg.getBoundingClientRect(); var dentro = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom; if (!dentro) dlg.close(); }); var foco = null; var restaurar = true; dlg.addEventListener("close", function () { setTimeout(function () { dlg.remove(); }, 0); if (restaurar && foco && foco.isConnected && typeof foco.focus === "function") foco.focus(); }); document.body.appendChild(dlg); return { dlg: dlg, cuerpo: cuerpo, pie: pie, abrir: function () { foco = document.activeElement; dlg.showModal(); }, // La acción elegida mueve el foco a otro sitio (p. ej. el nombre de la cuenta) sinRestaurar: function () { restaurar = false; } }; } function boton(texto, clase, fn) { var b = h("button", { type: "button", class: "wam-dlg-btn " + (clase || ""), text: texto }); if (fn) b.addEventListener("click", fn); return b; } /* Confirmación de acciones que sustituyen la demo entera. tipo: "reset" | "importar". ejecutar(): la acción real. */ WAM.confirmarReemplazo = function (tipo, ejecutar) { if (typeof ejecutar !== "function") return; var esReset = tipo === "reset"; var hacer = function () { if (esReset) { WAM.puntoDeshacer(); lsDel(CLAVE_ACTIVA); ejecutar(); marcarEditado(); toast("Demo de ejemplo «Café Aurora» restablecida", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 }); } else { ejecutar(); // el punto de deshacer lo toma importarJSON al aplicar el archivo } }; if (!nMensajes()) { hacer(); return; } var nombre = (WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "sin nombre"; var resumen = "«" + nombre + "», " + nMensajes() + " mensaje" + (nMensajes() === 1 ? "" : "s"); if (!soportaDialog) { if (window.confirm(esReset ? "¿Volver al ejemplo Café Aurora? Se sustituirá " + resumen + "." : "¿Importar un JSON? Se sustituirá " + resumen + ".")) hacer(); return; } var d = crearDialogo("dlg-confirmar", esReset ? "¿Volver al ejemplo Café Aurora?" : "¿Importar una demo desde JSON?", "wam-dlg-chico"); d.cuerpo.appendChild(h("p", { text: (esReset ? "La demo actual (" + resumen + ") se sustituirá por la demo de ejemplo «Café Aurora»." : "El archivo que elijas sustituirá la demo actual (" + resumen + ").") })); d.cuerpo.appendChild(h("p", { class: "wam-dlg-nota", text: "Puedes exportarla antes. Mientras no recargues la página, también puedes deshacer con Ctrl+Z." })); var bExp = boton("Exportar antes", "", function () { if (typeof WAM.exportarJSON === "function") WAM.exportarJSON(); bExp.textContent = "Exportada ✓"; bExp.disabled = true; }); var bOk = boton(esReset ? "Restablecer" : "Elegir archivo…", esReset ? "wam-dlg-peligro" : "wam-dlg-primario", function () { d.dlg.close(); hacer(); }); d.pie.appendChild(boton("Cancelar", "", function () { d.dlg.close(); })); d.pie.appendChild(bExp); d.pie.appendChild(bOk); d.abrir(); bOk.focus(); }; /* ------------------------------------------------------------------ Demos: escenarios + demos guardadas en localStorage ------------------------------------------------------------------ */ function indiceDemos() { var bruto = lsGet(CLAVE_DEMOS); try { var arr = JSON.parse(bruto || "[]"); return Array.isArray(arr) ? arr.filter(function (x) { return x && typeof x.id === "string"; }) : []; } catch (e) { return []; } } function guardarIndice(arr) { return lsSet(CLAVE_DEMOS, JSON.stringify(arr)); } function nuevoIdDemo() { return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } function demoActiva() { var id = lsGet(CLAVE_ACTIVA); if (!id) return null; var idx = indiceDemos(); for (var i = 0; i < idx.length; i++) if (idx[i].id === id) return idx[i]; return null; } WAM.nombreDemoActiva = function () { var d = demoActiva(); return d ? d.nombre : ""; }; function guardarDemoComo(nombre, id) { guardarYa(); var idx = indiceDemos(); var ahora = new Date().toISOString(); var nuevoId = id || nuevoIdDemo(); if (!lsSet(PREFIJO_DEMO + nuevoId, JSON.stringify(WAM.estado))) { toast("No se pudo guardar la demo: almacenamiento lleno. Exporta a JSON o borra demos guardadas.", { tipo: "error", ms: 9000 }); return null; } var entrada = { id: nuevoId, nombre: nombre || "Demo sin nombre", fecha: ahora, n: nMensajes() }; var pos = -1; for (var i = 0; i < idx.length; i++) if (idx[i].id === nuevoId) pos = i; if (pos >= 0) idx[pos] = entrada; else idx.unshift(entrada); guardarIndice(idx); lsSet(CLAVE_ACTIVA, nuevoId); return entrada; } function cargarDemo(id) { var bruto = lsGet(PREFIJO_DEMO + id); var datos = null; try { datos = JSON.parse(bruto || "null"); } catch (e) { datos = null; } if (!datos) { toast("No se encontró esa demo guardada", { tipo: "error" }); return false; } WAM.puntoDeshacer(); sustituirEstado(normalizado(datos)); lsSet(CLAVE_ACTIVA, id); return true; } /* Tras cargar un escenario lo primero es poner el nombre del cliente: se despliega «Cuenta» y se enfoca (y selecciona) su nombre. */ function enfocarNombreCuenta() { if (esEstrecha() && document.body.classList.contains("vista-movil")) return; if (document.body.classList.contains("presentacion")) return; var det = porId("bloque-cuenta"); if (det && !det.open) det.open = true; var inp = porId("cuenta-nombre"); if (!inp) return; try { inp.focus({ preventScroll: true }); } catch (e) { inp.focus(); } if (inp.scrollIntoView) inp.scrollIntoView({ block: "nearest" }); if (typeof inp.select === "function") inp.select(); } /* Siempre deja un punto de deshacer (también desde una demo vacía) y un único aviso con «Deshacer»: WAM.toast retira los avisos con acción anteriores, que ya no deshacen lo que dicen. */ function aplicarDemoCompleta(crudo, texto) { WAM.puntoDeshacer(); sustituirEstado(normalizado(crudo)); lsDel(CLAVE_ACTIVA); toast(texto, { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 }); } function cargarEscenario(esc) { var crudo = WAM.datosEscenario ? WAM.datosEscenario(esc.id) : null; if (!crudo) return; aplicarDemoCompleta(crudo, "Escenario «" + esc.nombre + "» cargado. Cambia el nombre del negocio en «Cuenta»."); enfocarNombreCuenta(); } // Tipos de mensaje que usa un escenario (chips del diálogo Demos) var NOMBRE_TIPO = { text: "Texto", image: "Imagen", video: "Vídeo", audio: "Audio", document: "Documento", sticker: "Sticker", location: "Ubicación", contact: "Contacto", template: "Plantilla", buttons: "Botones", list: "Lista", cta_url: "Botón URL", carousel: "Carrusel" }; // Los más distintivos primero (texto casi siempre está) var ORDEN_TIPOS = ["template", "list", "carousel", "buttons", "cta_url", "location", "document", "contact", "image", "video", "audio", "sticker", "text"]; function chipsTipos(mensajes) { var usados = Object.create(null); (mensajes || []).forEach(function (m) { if (m && m.tipo) usados[m.tipo] = true; }); var lista = ORDEN_TIPOS.filter(function (t) { return usados[t]; }); if (!lista.length) return null; var max = 4; var cont = h("span", { class: "wam-esc-tipos", "aria-label": "Incluye: " + lista.map(function (t) { return NOMBRE_TIPO[t]; }).join(", ") }); lista.slice(0, max).forEach(function (t) { cont.appendChild(h("span", { class: "wam-esc-tipo", "aria-hidden": "true", text: NOMBRE_TIPO[t] || t })); }); if (lista.length > max) cont.appendChild(h("span", { class: "wam-esc-tipo", "aria-hidden": "true", text: "+" + (lista.length - max) })); return cont; } function fechaCorta(iso) { var d = new Date(iso); if (isNaN(d.getTime())) return ""; try { return d.toLocaleString("es", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); } catch (e) { return d.toISOString().slice(0, 16).replace("T", " "); } } WAM.abrirDemos = function (seccion) { if (!soportaDialog) { toast("Tu navegador no admite diálogos nativos", { tipo: "error" }); return; } var d = crearDialogo("dlg-demos", "Demos", "wam-dlg-grande"); // --- Escenarios --- var gridEsc = h("div", { class: "wam-esc-grid" }); (WAM.ESCENARIOS || []).forEach(function (esc) { var ms = []; try { ms = esc.datos().mensajes || []; } catch (e) { ms = []; } var b = h("button", { type: "button", class: "wam-esc", "data-esc": esc.id }, [ h("span", { class: "wam-esc-nombre", text: esc.nombre }), h("span", { class: "wam-esc-desc", text: esc.descripcion }), chipsTipos(ms), h("span", { class: "wam-esc-meta", text: ms.length ? ms.length + " mensajes" : "Vacía" }) ]); b.addEventListener("click", function () { d.sinRestaurar(); d.dlg.close(); cargarEscenario(esc); }); gridEsc.appendChild(b); }); var msAurora = []; try { msAurora = (typeof WAM.datosDemo === "function" && WAM.datosDemo().mensajes) || []; } catch (e) { msAurora = []; } var bAurora = h("button", { type: "button", class: "wam-esc", "data-esc": "aurora" }, [ h("span", { class: "wam-esc-nombre", text: "Catálogo · Café Aurora" }), h("span", { class: "wam-esc-desc", text: "Los 13 tipos de mensaje de la Cloud API. Útil como referencia." }), h("span", { class: "wam-esc-meta", text: "Demo de ejemplo" + (msAurora.length ? " · " + msAurora.length + " mensajes" : "") }) ]); bAurora.addEventListener("click", function () { d.dlg.close(); if (typeof WAM.datosDemo !== "function") return; aplicarDemoCompleta(WAM.datosDemo(), "Demo «Café Aurora» cargada"); }); gridEsc.appendChild(bAurora); var secEsc = h("section", { class: "wam-dlg-sec", "aria-labelledby": "dlg-sec-esc" }, [ h("h3", { id: "dlg-sec-esc", class: "wam-dlg-subtitulo", text: "Empezar desde un escenario" }), h("p", { class: "wam-dlg-nota", text: "Sustituye la demo actual (puedes deshacer). Los textos son de ejemplo y ficticios: revísalos y adáptalos antes de enseñarlos a un cliente." }), gridEsc ]); // --- Demos guardadas --- var lista = h("ul", { class: "wam-demos-lista" }); var activa = demoActiva(); // Sin nombre precargado: el consultor escribe el del cliente (si lo deja // vacío se usa el nombre de la cuenta). var nombreCuenta = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || ""; var inputNombre = h("input", { type: "text", class: "wam-dlg-input", id: "dlg-demo-nombre", maxlength: "60", value: activa ? activa.nombre : "", placeholder: "Nombre de la demo (p. ej. el cliente)" + (nombreCuenta ? " · si lo dejas vacío: «" + nombreCuenta + "»" : ""), "aria-label": "Nombre de la demo" }); var bGuardar = boton(activa ? "Actualizar" : "Guardar la actual", "wam-dlg-primario", function () { var nombre = String(inputNombre.value || "").trim() || nombreCuenta || "Mi demo"; var misma = activa && activa.nombre === nombre; var e = guardarDemoComo(nombre, misma ? activa.id : null); if (e) { toast("Demo «" + e.nombre + "» guardada"); pintarLista(); activa = demoActiva(); } }); inputNombre.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); bGuardar.click(); } }); inputNombre.addEventListener("input", function () { bGuardar.textContent = (activa && activa.nombre === String(inputNombre.value).trim()) ? "Actualizar" : "Guardar como nueva"; }); function pintarLista() { while (lista.firstChild) lista.removeChild(lista.firstChild); var idx = indiceDemos(); var actId = lsGet(CLAVE_ACTIVA); if (!idx.length) { lista.appendChild(h("li", { class: "wam-demos-vacio", text: "Aún no has guardado ninguna demo. Guarda la actual para tener varias (una por cliente) sin exportar e importar." })); return; } idx.forEach(function (it) { var nombreEl = h("span", { class: "wam-demo-nombre", text: it.nombre }); var li = h("li", { class: "wam-demo" + (it.id === actId ? " wam-demo-activa" : "") }, [ h("div", { class: "wam-demo-info" }, [ nombreEl, h("span", { class: "wam-demo-meta", text: (it.n || 0) + " mensajes · " + fechaCorta(it.fecha) + (it.id === actId ? " · en uso" : "") }) ]) ]); var acc = h("div", { class: "wam-demo-acc" }); acc.appendChild(boton("Abrir", "wam-dlg-mini", function () { d.dlg.close(); if (cargarDemo(it.id)) toast("Demo «" + it.nombre + "» abierta", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 }); })); acc.appendChild(boton("Renombrar", "wam-dlg-mini", function () { var inp = h("input", { type: "text", class: "wam-dlg-input", value: it.nombre, maxlength: "60", "aria-label": "Nuevo nombre" }); nombreEl.replaceWith(inp); inp.focus(); inp.select(); var hecho = false; var fin = function (guardar) { if (hecho) return; hecho = true; var v = String(inp.value || "").trim(); if (guardar && v) { var arr = indiceDemos(); arr.forEach(function (x) { if (x.id === it.id) x.nombre = v; }); guardarIndice(arr); } pintarLista(); }; inp.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); fin(true); } if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); fin(false); } }); inp.addEventListener("blur", function () { fin(true); }); })); acc.appendChild(boton("Duplicar", "wam-dlg-mini", function () { var bruto = lsGet(PREFIJO_DEMO + it.id); var nid = nuevoIdDemo(); if (!bruto || !lsSet(PREFIJO_DEMO + nid, bruto)) { toast("No se pudo duplicar (almacenamiento lleno)", { tipo: "error" }); return; } var arr = indiceDemos(); arr.unshift({ id: nid, nombre: it.nombre + " (copia)", fecha: new Date().toISOString(), n: it.n || 0 }); guardarIndice(arr); pintarLista(); })); var bBorrar = boton("Borrar", "wam-dlg-mini wam-dlg-peligro", function () { if (bBorrar.getAttribute("data-confirmar") !== "1") { bBorrar.setAttribute("data-confirmar", "1"); bBorrar.textContent = "¿Seguro?"; setTimeout(function () { if (bBorrar.isConnected) { bBorrar.removeAttribute("data-confirmar"); bBorrar.textContent = "Borrar"; } }, 3500); return; } lsDel(PREFIJO_DEMO + it.id); guardarIndice(indiceDemos().filter(function (x) { return x.id !== it.id; })); if (lsGet(CLAVE_ACTIVA) === it.id) lsDel(CLAVE_ACTIVA); activa = demoActiva(); pintarLista(); }); acc.appendChild(bBorrar); li.appendChild(acc); lista.appendChild(li); }); } pintarLista(); var secGuardadas = h("section", { class: "wam-dlg-sec", "aria-labelledby": "dlg-sec-guardadas" }, [ h("h3", { id: "dlg-sec-guardadas", class: "wam-dlg-subtitulo", text: "Mis demos guardadas" }), h("div", { class: "wam-dlg-fila" }, [inputNombre, bGuardar]), lista, h("p", { class: "wam-dlg-nota", text: "Se guardan solo en este navegador. Para compartir una demo o hacer copia de seguridad, usa Exportar." }) ]); d.cuerpo.appendChild(seccion === "guardadas" ? secGuardadas : secEsc); d.cuerpo.appendChild(seccion === "guardadas" ? secEsc : secGuardadas); d.pie.appendChild(boton("Cerrar", "", function () { d.dlg.close(); })); d.abrir(); var primero = d.cuerpo.querySelector(".wam-esc, .wam-dlg-input"); if (primero) primero.focus(); }; /* ------------------------------------------------------------------ Atajos de teclado ------------------------------------------------------------------ */ // «Ctrl» se pinta como ⌘ en Mac (los atajos aceptan ctrlKey o metaKey) var ATAJOS = [ ["N", "Nueva demo: escenarios y demos guardadas"], ["P", "Modo presentación"], ["Espacio", "Reproducir / detener la demo"], ["G", "Saltar entre el editor y el teléfono (también F6)"], ["T", "Tema claro / oscuro"], ["F", "Pantalla completa"], ["Esc", "Cerrar la lista del chat o salir de la presentación"], ["Ctrl + S", "Exportar JSON"], ["Ctrl + Z", "Deshacer (fuera de un campo de texto)"], ["Ctrl + Mayús + Z", "Rehacer"], ["Alt + ↑ / ↓", "Mover el mensaje o elemento con el foco"], ["Alt + clic", "Sobre una burbuja: abre su tarjeta en el editor (también doble clic)"], ["?", "Esta ayuda"] ]; function esMac() { var nav = window.navigator || {}; var plat = (nav.userAgentData && nav.userAgentData.platform) || nav.platform || nav.userAgent || ""; return /mac|iphone|ipad|ipod/i.test(String(plat)); } function teclaVisible(k) { if (!esMac()) return k; if (k === "Ctrl") return "⌘"; if (k === "Alt") return "⌥"; if (k === "Mayús") return "⇧"; return k; } WAM.abrirAtajos = function () { if (!soportaDialog) return; var d = crearDialogo("dlg-atajos", "Atajos de teclado", "wam-dlg-chico"); var tabla = h("dl", { class: "wam-atajos" }); ATAJOS.forEach(function (a) { tabla.appendChild(h("dt", {}, a[0].split(" + ").map(teclaVisible).map(function (k, i, arr) { var frag = document.createDocumentFragment(); frag.appendChild(h("kbd", { text: k })); if (i < arr.length - 1) frag.appendChild(document.createTextNode(" + ")); return frag; }))); tabla.appendChild(h("dd", { text: a[1] })); }); d.cuerpo.appendChild(tabla); d.cuerpo.appendChild(h("p", { class: "wam-dlg-nota", text: "Los atajos de una letra no actúan mientras escribes en un campo." })); d.pie.appendChild(boton("Cerrar", "wam-dlg-primario", function () { d.dlg.close(); })); d.abrir(); }; function clicEn(id) { var b = porId(id); if (b && !b.disabled) b.click(); } function alternarPantallaCompleta() { var doc = document; try { if (doc.fullscreenElement) { var p = doc.exitFullscreen(); if (p && p.catch) p.catch(function () {}); } else if (doc.documentElement.requestFullscreen) { var q = doc.documentElement.requestFullscreen(); if (q && q.catch) q.catch(function () { toast("El navegador no permitió la pantalla completa", { tipo: "error" }); }); } } catch (e) { /* sin soporte */ } } function sheetAbierto() { var s = porId("sheet"); return !!(s && !s.hidden && s.offsetParent !== null); } /* ------------------------------------------------------------------ Saltar entre paneles: enlace «Saltar a la vista previa» y G / F6 ------------------------------------------------------------------ */ var ultimoFocoEditor = null; function enfocar(el) { if (!el) return false; try { el.focus({ preventScroll: true }); } catch (e) { el.focus(); } return document.activeElement === el; } function irAVistaPrevia() { if (esEstrecha() && !document.body.classList.contains("presentacion")) WAM.setVistaMovil("vista"); var cuerpo = porId("chat-cuerpo"); if (!cuerpo) return; // tras el cambio de pestaña (display) el nodo ya es enfocable requestAnimationFrame(function () { enfocar(cuerpo); }); } function irAlEditor() { if (document.body.classList.contains("presentacion")) return false; if (esEstrecha()) WAM.setVistaMovil("editor"); requestAnimationFrame(function () { var lista = porId("lista-mensajes"); var previo = ultimoFocoEditor && ultimoFocoEditor.isConnected && ultimoFocoEditor.getClientRects().length ? ultimoFocoEditor : null; if (previo && enfocar(previo)) return; var cab = lista && lista.querySelector(".ed-cab-principal"); if (cab && enfocar(cab)) { if (cab.scrollIntoView) cab.scrollIntoView({ block: "nearest" }); return; } var pal = document.querySelector('#nuevo-tipo [data-tipo][tabindex="0"]') || document.querySelector("#nuevo-tipo [data-tipo]"); enfocar(pal || lista); }); return true; } function alternarPanel() { var movil = porId("panel-movil"); var a = document.activeElement; if (movil && a && movil.contains(a)) irAlEditor(); else irAVistaPrevia(); } function enlazarSaltos() { var enlace = porId("saltar-vista"); if (enlace) enlace.addEventListener("click", function (e) { e.preventDefault(); irAVistaPrevia(); }); var ed = porId("panel-editor"); if (ed) ed.addEventListener("focusin", function (e) { ultimoFocoEditor = e.target; }); } function alTecla(e) { if (e.defaultPrevented || e.isComposing) return; var k = e.key; var mod = e.ctrlKey || e.metaKey; var hayDialogo = !!document.querySelector("dialog[open]"); if (k === "F6" && !mod && !e.altKey && !hayDialogo) { e.preventDefault(); alternarPanel(); return; } if (mod && !e.altKey && (k === "s" || k === "S")) { e.preventDefault(); if (!hayDialogo && typeof WAM.exportarJSON === "function") WAM.exportarJSON(); return; } if (hayDialogo) return; // el gestiona Esc y el foco if (mod && !e.altKey && (k === "z" || k === "Z") && !enCampoTexto(e.target)) { e.preventDefault(); if (e.shiftKey) WAM.rehacer(); else WAM.deshacer(); return; } if (mod && !e.altKey && (k === "y" || k === "Y") && !enCampoTexto(e.target)) { e.preventDefault(); WAM.rehacer(); return; } if (k === "Escape") { if (cerrarMenu()) return; if (sheetAbierto() && typeof WAM.cerrarSheet === "function") { WAM.cerrarSheet(); return; } if (document.body.classList.contains("presentacion") && !enCampoTexto(e.target)) clicEn("btn-presentacion"); return; } if (mod || e.altKey || enCampoTexto(e.target)) return; switch (k) { case "p": case "P": e.preventDefault(); clicEn("btn-presentacion"); break; case "t": case "T": e.preventDefault(); clicEn("btn-tema"); break; case "f": case "F": e.preventDefault(); alternarPantallaCompleta(); break; case "n": case "N": e.preventDefault(); WAM.abrirDemos(); break; case "g": case "G": e.preventDefault(); alternarPanel(); break; case "?": e.preventDefault(); WAM.abrirAtajos(); break; case " ": if (esInteractivo(e.target)) return; // Espacio activa el botón con foco e.preventDefault(); clicEn("btn-reproducir"); break; default: break; } } /* ------------------------------------------------------------------ Menú «⋯» de la toolbar (ventana estrecha) y pestañas Editor / Vista ------------------------------------------------------------------ */ function cerrarMenu(devolverFoco) { var tb = porId("toolbar"); var mas = porId("btn-mas"); if (!tb || !tb.classList.contains("tb-menu-abierto")) return false; var menu = porId("tb-menu"); var dentro = menu && menu.contains(document.activeElement); tb.classList.remove("tb-menu-abierto"); if (mas) { mas.setAttribute("aria-expanded", "false"); if (devolverFoco !== false && dentro) mas.focus(); } return true; } // Opciones visibles del menú «⋯» abierto (en escritorio solo «Volver al ejemplo») function opcionesMenu() { var menu = porId("tb-menu"); if (!menu) return []; var bs = esEstrecha() ? menu.querySelectorAll("button:not([disabled])") : menu.querySelectorAll("#btn-reset"); return Array.prototype.filter.call(bs, function (b) { return b.getClientRects().length > 0; }); } function enlazarMenu() { var tb = porId("toolbar"), mas = porId("btn-mas"), menu = porId("tb-menu"); if (!tb || !mas || !menu) return; mas.addEventListener("click", function (e) { e.stopPropagation(); var abierto = tb.classList.toggle("tb-menu-abierto"); mas.setAttribute("aria-expanded", abierto ? "true" : "false"); if (abierto) { var ops = opcionesMenu(); if (ops[0]) ops[0].focus(); } }); // Flechas dentro del menú abierto tb.addEventListener("keydown", function (e) { if (!tb.classList.contains("tb-menu-abierto") || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return; var ops = opcionesMenu(); var i = ops.indexOf(document.activeElement); if (i < 0 && document.activeElement !== mas) return; e.preventDefault(); var j = i < 0 ? 0 : (i + (e.key === "ArrowDown" ? 1 : -1) + ops.length) % ops.length; if (ops[j]) ops[j].focus(); }); // Al salir con Tab del menú abierto, se cierra tb.addEventListener("focusout", function (e) { if (!tb.classList.contains("tb-menu-abierto")) return; var sig = e.relatedTarget; if (sig && (menu.contains(sig) || sig === mas)) return; if (sig) cerrarMenu(false); }); menu.addEventListener("click", function (e) { if (e.target.closest && e.target.closest("button")) cerrarMenu(false); }); document.addEventListener("click", function (e) { if (!tb.classList.contains("tb-menu-abierto")) return; if (menu.contains(e.target) || mas.contains(e.target)) return; cerrarMenu(); }); } var ultimoIdEditado = null; WAM.recordarEnfoque = function (id) { ultimoIdEditado = id || null; }; WAM.setVistaMovil = function (vista) { var previa = vista === "vista"; document.body.classList.toggle("vista-movil", previa); var te = porId("tab-editor"), tv = porId("tab-vista"); if (te) { te.setAttribute("aria-selected", previa ? "false" : "true"); te.tabIndex = previa ? -1 : 0; } if (tv) { tv.setAttribute("aria-selected", previa ? "true" : "false"); tv.tabIndex = previa ? 0 : -1; } lsSet(CLAVE_VISTA, previa ? "vista" : "editor"); if (previa && ultimoIdEditado && typeof WAM.enfocarEnChat === "function") { var id = ultimoIdEditado; requestAnimationFrame(function () { requestAnimationFrame(function () { WAM.enfocarEnChat(id); }); }); } }; function enlazarPestanas() { var te = porId("tab-editor"), tv = porId("tab-vista"); if (te) te.addEventListener("click", function () { WAM.setVistaMovil("editor"); }); if (tv) tv.addEventListener("click", function () { WAM.setVistaMovil("vista"); }); [te, tv].forEach(function (t) { if (!t) return; t.addEventListener("keydown", function (e) { if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; e.preventDefault(); var otra = t === te ? tv : te; if (otra) { otra.click(); otra.focus(); } }); }); var ver = porId("btn-ver-telefono"); if (ver) ver.addEventListener("click", function () { WAM.setVistaMovil("vista"); }); var guardada = lsGet(CLAVE_VISTA); WAM.setVistaMovil(guardada === "editor" ? "editor" : "vista"); } // Altura real de la toolbar → variable CSS para el cálculo de escala function observarToolbar() { var tb = porId("toolbar"); if (!tb) return; var fijar = function () { document.documentElement.style.setProperty("--wam-toolbar-alto", Math.ceil(tb.getBoundingClientRect().height) + "px"); }; fijar(); if (typeof ResizeObserver === "function") new ResizeObserver(fijar).observe(tb); else window.addEventListener("resize", fijar); } /* ------------------------------------------------------------------ Vista previa → tarjeta del editor ------------------------------------------------------------------ */ function editorOculto() { return document.body.classList.contains("presentacion"); } function abrirDesdeChat(id) { if (!id || editorOculto() || typeof WAM.abrirTarjeta !== "function") return false; if (esEstrecha()) WAM.setVistaMovil("editor"); return WAM.abrirTarjeta(id); } function enlazarChat() { var pantalla = porId("pantalla"), cuerpo = porId("chat-cuerpo"); if (pantalla) { // Captura: Alt+clic no dispara el flujo del botón (lo gestiona main.js) pantalla.addEventListener("click", function (e) { if (!e.altKey) return; var fila = e.target.closest && e.target.closest("#chat-cuerpo .wa-row[data-id]"); if (!fila || editorOculto()) return; e.preventDefault(); e.stopPropagation(); abrirDesdeChat(fila.getAttribute("data-id")); }, true); } if (cuerpo) { cuerpo.addEventListener("dblclick", function (e) { if (!e.target.closest || e.target.closest("[data-flujo], a, button, video, audio, input")) return; var fila = e.target.closest(".wa-row[data-id]"); if (!fila || editorOculto()) return; if (window.getSelection) { try { window.getSelection().removeAllRanges(); } catch (err) { /* nada */ } } abrirDesdeChat(fila.getAttribute("data-id")); }); // Las altas y re-render parciales no deben releer todo el chat cuerpo.removeAttribute("aria-live"); } } /* ------------------------------------------------------------------ Anuncios para lectores de pantalla (mensajes nuevos del chat) ------------------------------------------------------------------ */ function anunciar(msg) { var reg = porId("wam-anuncios"); if (!reg || !msg) return; var nombre = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "el negocio"; var txt = msg.texto || msg.body || msg.caption || msg.filename || msg.nombre || ""; txt = String(txt).replace(/\s+/g, " ").slice(0, 140); reg.textContent = (msg.dir === "out" ? "Tú: " : "Nuevo mensaje de " + nombre + ": ") + (txt || "(" + (msg.tipo || "mensaje") + ")"); } function envolverAnuncios() { if (!porId("wam-anuncios")) { document.body.appendChild(h("div", { id: "wam-anuncios", class: "wam-sr", "aria-live": "polite", "aria-atomic": "true" })); } var orig = WAM.anadirMensajeDOM; if (typeof orig !== "function" || orig.__wamAnuncio) return; var envuelta = function (msg) { var r = orig.apply(this, arguments); try { anunciar(msg); } catch (e) { /* nunca rompe el flujo */ } return r; }; envuelta.__wamAnuncio = true; WAM.anadirMensajeDOM = envuelta; } /* ------------------------------------------------------------------ Estado de los botones de la toolbar ------------------------------------------------------------------ */ var temaPintado = null; function sincronizarTema() { var tema = document.body.getAttribute("data-tema"); var b = porId("btn-tema"); if (b) { b.setAttribute("aria-pressed", tema === "oscuro" ? "true" : "false"); b.setAttribute("aria-label", "Tema oscuro"); } // El cambio de tema (botón o T) también pasa por «Guardando…» if (temaPintado !== null && tema !== temaPintado) marcarEditado(); temaPintado = tema; } /* ------------------------------------------------------------------ Inicialización (una sola vez) ------------------------------------------------------------------ */ WAM.initUX = function () { if (iniciado) return; iniciado = true; // Sustituye al aviso genérico: indicador rojo en la toolbar + aviso con «Exportar JSON» WAM.onErrorGuardar = function (texto) { alErrorGuardar(texto); }; document.addEventListener("keydown", alTecla); var bd = porId("btn-deshacer"), br = porId("btn-rehacer"), bdemos = porId("btn-demos"), bat = porId("btn-atajos"); if (bd) bd.addEventListener("click", function () { WAM.deshacer(); }); if (br) br.addEventListener("click", function () { WAM.rehacer(); }); if (bdemos) bdemos.addEventListener("click", function () { WAM.abrirDemos(); }); if (bat) bat.addEventListener("click", function () { WAM.abrirAtajos(); }); pintarBotonesHistorial(); enlazarMenu(); enlazarPestanas(); observarToolbar(); enlazarChat(); enlazarSaltos(); envolverAnuncios(); sincronizarTema(); if (typeof MutationObserver === "function") { new MutationObserver(sincronizarTema).observe(document.body, { attributes: true, attributeFilter: ["data-tema"] }); } // Estado inicial del indicador y refresco del «hace N s» ultimoGuardado = Date.now(); pintarGuardado("Guardado", "ok", "Guardado en este navegador"); setInterval(pintarRelativo, 5000); }; })();