Files
whatsapp-api-mockup/js/ux.js
T
urieljarethandClaude Opus 5.5 875ae9ad67 Deshacer con demo activa, avisos obsoletos, hoja de opciones y foco en hojas
- Deshacer/rehacer guardan y reponen «wam-demo-activa» con cada instantánea
  ({estado, activa}); «Actualizar» pide un segundo clic si el contenido cambió
  de cuenta o de número de mensajes respecto a la demo guardada.
- WAM.puntoDeshacer cierra los avisos con acción: su «Deshacer» ya no
  desharía el cambio que anuncian.
- «Todas las opciones» replica botonAccion: URL bloqueada o no válida se
  pinta como fila inerte (aria-disabled, sin data-opcion) en el grupo de
  enlaces.
- Hojas inferiores: enfocan la primera fila (o la X), retienen Tab dentro del
  panel y devuelven el foco al cerrar (origen, fila repintada o
  #chat-cuerpo); no roban el foco durante «Reproducir demo».
- Tests e2e de regresión en tests/e2e/regresiones.spec.mjs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 22:08:46 -06:00

1068 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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
------------------------------------------------------------------ */
/* Cada instantánea guarda también qué demo guardada estaba en uso
(«wam-demo-activa»): al deshacer o rehacer se repone, para que
«Actualizar» nunca escriba el contenido de otra demo en su hueco. */
function instantanea() {
return { estado: clonar(WAM.estado), activa: lsGet(CLAVE_ACTIVA) };
}
function reponerActiva(id) {
if (id) lsSet(CLAVE_ACTIVA, id); else lsDel(CLAVE_ACTIVA);
}
/* Un cambio nuevo deja obsoletos los avisos con acción («Deshacer» /
«Rehacer»): su botón ya no desharía lo que anuncian. */
function cerrarAvisosConAccion() {
var ts = document.querySelectorAll("#ed-toasts .ed-toast");
for (var i = 0; i < ts.length; i++) {
if (!ts[i].querySelector(".ed-toast-accion")) continue;
if (typeof ts[i].__cerrar === "function") ts[i].__cerrar();
else if (ts[i].parentNode) ts[i].parentNode.removeChild(ts[i]);
}
}
WAM.puntoDeshacer = function () {
if (!WAM.estado) return;
cerrarAvisosConAccion();
pilaDeshacer.push(instantanea());
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; }
var previo = pilaDeshacer.pop();
pilaRehacer.push(instantanea());
sustituirEstado(conTemaActual(previo.estado));
reponerActiva(previo.activa);
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; }
var siguiente = pilaRehacer.pop();
pilaDeshacer.push(instantanea());
sustituirEstado(conTemaActual(siguiente.estado));
reponerActiva(siguiente.activa);
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 (<dialog> 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;
}
/* ¿El estado actual cambió de cuenta o de número de mensajes respecto a
lo guardado en el hueco «id»? */
function cambioSustancial(id) {
var guardada = null;
try { guardada = JSON.parse(lsGet(PREFIJO_DEMO + id) || "null"); } catch (e) { guardada = null; }
if (!guardada || typeof guardada !== "object") return false;
var nomG = (guardada.cuenta && guardada.cuenta.nombre) || "";
var nomA = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "";
var nG = Array.isArray(guardada.mensajes) ? guardada.mensajes.length : 0;
return nomG !== nomA || nG !== nMensajes();
}
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;
// Defensa: si lo que se va a escribir sobre una demo existente tiene
// otra cuenta u otro número de mensajes, se pide un segundo clic.
if (misma && bGuardar.getAttribute("data-confirmar") !== "1" && cambioSustancial(activa.id)) {
bGuardar.setAttribute("data-confirmar", "1");
bGuardar.textContent = "¿Sobrescribir «" + activa.nombre + "»?";
toast("El contenido actual no se parece al de «" + activa.nombre + "» (otra cuenta u otro número de mensajes). Pulsa otra vez para sobrescribirla o cambia el nombre para guardarla como nueva.", { ms: 7000 });
return;
}
bGuardar.removeAttribute("data-confirmar");
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.removeAttribute("data-confirmar");
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 <dialog> 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;
}
/* ------------------------------------------------------------------
Zoom manual del teléfono (Ajustar / 100 % / 125 %). El CSS vive en
iphone.css (body[data-wam-zoom]); sin atributo = ajustar al hueco.
------------------------------------------------------------------ */
var CLAVE_ZOOM = "wam-zoom";
function aplicarZoom(v) {
v = v === "100" || v === "125" ? v : "";
if (v) document.body.setAttribute("data-wam-zoom", v);
else document.body.removeAttribute("data-wam-zoom");
var sel = porId("sel-zoom");
if (sel && sel.value !== v) sel.value = v;
return v;
}
WAM.setZoom = function (v) {
v = aplicarZoom(String(v == null ? "" : v));
if (v) lsSet(CLAVE_ZOOM, v); else lsDel(CLAVE_ZOOM);
return v;
};
function enlazarZoom() {
aplicarZoom(lsGet(CLAVE_ZOOM) || "");
var sel = porId("sel-zoom");
if (sel) sel.addEventListener("change", function () { WAM.setZoom(sel.value); });
}
/* ------------------------------------------------------------------
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();
enlazarZoom();
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);
};
})();