Integrar rama de editor y toolbar: jerarquía, foco seguro y autoPulsar estable

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
urieljareth
2026-09-27 21:33:16 -06:00
co-authored by Claude Opus 5.5
6 changed files with 762 additions and 134 deletions
+209 -33
View File
@@ -110,6 +110,7 @@
var ultimaAbierta = null; // id top-level abierto más reciente (posición de alta)
var insertarTras = null; // id tras el que insertar (botón «+» de una tarjeta)
var ultimoTipo = "text";
var yaAnadido = false; // ¿se añadió algo desde la paleta en esta sesión?
var ultimoEnlazado = null; // id resaltado en la vista previa
var tResalte = 0;
var contadorIds = 0; // ids únicos para <label for>
@@ -244,9 +245,9 @@
return false;
}
/* Aviso flotante (toast) abajo a la izquierda, fuera de la zona con
scroll del panel. opts: { tipo: "error", ms, accion: { texto, fn } }.
Devuelve una función que lo cierra. */
/* Aviso flotante (toast) abajo, centrado sobre la vista previa (fuera de
la lista de tarjetas). opts: { tipo: "error", ms, accion: { texto, fn } }.
Solo hay un aviso con acción a la vez. Devuelve una función que lo cierra. */
function contenedorToasts() {
var c = document.getElementById("ed-toasts");
if (!c) {
@@ -280,14 +281,24 @@
var x = h("button", { type: "button", class: "ed-toast-cerrar", "aria-label": "Cerrar aviso", text: "×" });
x.addEventListener("click", cerrar);
t.appendChild(x);
// Como mucho 2 a la vista: primero se retiran los que no tienen acción
var hijos = Array.prototype.slice.call(c.children);
// Un aviso con acción nuevo invalida los anteriores con acción: su
// «Deshacer» ya no desharía lo que dice (se deshace de uno en uno).
if (opts.accion && typeof opts.accion.fn === "function") {
hijos = hijos.filter(function (x) {
if (!x.querySelector(".ed-toast-accion")) return true;
if (typeof x.__cerrar === "function") x.__cerrar(); else if (x.parentNode) x.parentNode.removeChild(x);
return false;
});
}
// Como mucho 2 a la vista: primero se retiran los que no tienen acción
var sinAccion = hijos.filter(function (x) { return !x.querySelector(".ed-toast-accion"); });
while (hijos.length >= 2) {
var quitar = sinAccion.length ? sinAccion.shift() : hijos[0];
hijos.splice(hijos.indexOf(quitar), 1);
if (quitar.parentNode) quitar.parentNode.removeChild(quitar);
}
t.__cerrar = cerrar;
c.appendChild(t);
timer = setTimeout(cerrar, opts.ms || (esError ? 7000 : 3000));
return cerrar;
@@ -500,14 +511,51 @@
if (car2) car2.scrollLeft = sl;
}
// Reemplaza un array completo vía setCampo (mutación contractual) y repinta tarjetas
/* --- autoPulsar (SPEC §1): índice del elemento que «Reproducir demo»
pulsa solo. Botones (template/buttons) y tarjetas (carousel) usan su
índice en el array; las listas, el índice aplanado de todas sus filas. */
function pulsablesDe(m) {
if (!m || typeof m !== "object") return [];
if (m.tipo === "list") {
var out = [];
(m.secciones || []).forEach(function (s) { ((s && s.filas) || []).forEach(function (f) { out.push(f); }); });
return out;
}
if (m.tipo === "carousel") return Array.isArray(m.cards) ? m.cards : [];
if (m.tipo === "template" || m.tipo === "buttons") return Array.isArray(m.botones) ? m.botones : [];
return [];
}
function tieneAuto(m) { return !!m && typeof m.autoPulsar === "number" && isFinite(m.autoPulsar) && m.autoPulsar >= 0; }
// Ruta del mensaje dueño de una lista de botones / cards / secciones / filas
function rutaMensajeDe(rutaArr) {
var r = String(rutaArr || "");
var m = /^(.*)\.secciones\.\d+\.filas$/.exec(r) || /^(.*)\.(?:botones|cards|secciones)$/.exec(r);
return m ? m[1] : null;
}
// Reemplaza un array completo vía setCampo (mutación contractual) y repinta tarjetas.
// Si el array es de botones / cards / secciones / filas, autoPulsar sigue
// al elemento pulsado (se mueve con él; se quita si se borró).
function mutarArray(ruta, fn) {
var arr = rutaGet(ruta);
if (!Array.isArray(arr)) { aviso("Estructura no encontrada: " + ruta, "error"); return null; }
var copia = fn(arr.slice());
if (!copia) return null; // validación rechazada (fn ya avisó)
snap();
var rutaMsg = rutaMensajeDe(ruta);
var dueno = rutaMsg ? rutaGet(rutaMsg) : null;
var pulsado = tieneAuto(dueno) ? pulsablesDe(dueno)[dueno.autoPulsar] : undefined;
WAM.setCampo(ruta, copia);
if (pulsado !== undefined) {
var nuevo = pulsablesDe(rutaGet(rutaMsg)).indexOf(pulsado);
if (nuevo !== dueno.autoPulsar) {
var padre = rutaGet(rutaMsg);
if (nuevo < 0) delete padre.autoPulsar; else padre.autoPulsar = nuevo;
WAM.setCampo(rutaMsg, padre);
}
}
WAM.renderTarjetas();
return copia;
}
@@ -816,7 +864,8 @@
snap();
WAM.setCampo("mensajes", copia);
WAM.renderTarjetas();
// Foco a la tarjeta vecina (la siguiente o, si era la última, la anterior)
// Foco a la CABECERA de la tarjeta vecina (la siguiente o, si era la
// última, la anterior), nunca a su botón borrar; sin tarjetas, la paleta.
var vecino = copia[i] || copia[i - 1];
if (vecino) enfocarTarjeta(vecino.id, false);
else { var pal = paletaFoco(); if (pal) pal.focus(); }
@@ -990,6 +1039,11 @@
function largo(v) { return String(v === undefined || v === null ? "" : v).length; }
function vacioTxt(v) { return !String(v === undefined || v === null ? "" : v).trim(); }
function urlValida(v) { return /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(String(v || "").trim()); }
// Botón de llamada de plantilla: tel: con al menos 3 dígitos (espacios, guiones y paréntesis permitidos)
function telValido(v) {
var s = String(v || "").trim();
return /^tel:\+?[\d\s().-]+$/i.test(s) && (s.replace(/\D/g, "").length >= 3);
}
function validar(m, prefijo, profundidad) {
var av = [];
@@ -1032,12 +1086,16 @@
lim(m.footer, 60, "El pie");
var bt = m.botones || [];
if (bt.length > LIMITES.botonesPlantilla) add("Máximo " + LIMITES.botonesPlantilla + " botones");
var conUrl = bt.filter(function (b) { return b && !vacioTxt(b.url); }).length;
// tel:+… es el botón de llamada (PHONE_NUMBER de la API): máximo 1 y
// no cuenta entre los 2 de URL
var conTel = bt.filter(function (b) { return b && telValido(b.url); }).length;
var conUrl = bt.filter(function (b) { return b && !vacioTxt(b.url) && !telValido(b.url); }).length;
if (conUrl > 2) add("Máximo 2 botones con URL (" + conUrl + ")");
if (conTel > 1) add("Máximo 1 botón de llamada tel: (" + conTel + ")");
bt.forEach(function (b, i) {
if (vacioTxt(b.texto)) add("Botón " + (i + 1) + ": sin texto");
else lim(b.texto, 25, "Botón " + (i + 1));
if (!vacioTxt(b.url) && !urlValida(b.url)) add("Botón " + (i + 1) + ": URL no válida (usa https://…)");
if (!vacioTxt(b.url) && !urlValida(b.url) && !telValido(b.url)) add("Botón " + (i + 1) + ": URL no válida (usa https://… o tel:+34…)");
});
break;
}
@@ -1110,6 +1168,13 @@
}
default: break;
}
// «Se pulsa sola» apunta a una opción que ya no existe o que abre una URL
if (tieneAuto(m) && ["template", "buttons", "list", "carousel"].indexOf(m.tipo) >= 0) {
var objAuto = pulsablesDe(m)[m.autoPulsar];
var nAuto = "«Se pulsa sola en la demo»: la opción " + (m.autoPulsar + 1);
if (!objAuto) add(nAuto + " ya no existe (la demo no pulsará nada)");
else if (!vacioTxt(objAuto.url)) add(nAuto + " abre una URL y la demo no la pulsará");
}
if ((profundidad || 0) < 6) {
origenesRespuesta(m).forEach(function (o) {
o.respuestas.forEach(function (r) {
@@ -1150,6 +1215,16 @@
"aria-label": av.length + (av.length === 1 ? " aviso" : " avisos")
}, [h("span", { "aria-hidden": "true", text: "⚠ " + av.length })]));
}
// ▶ auto: la demo pulsa sola una opción de este mensaje
if (tieneAuto(m)) {
var objA = pulsablesDe(m)[m.autoPulsar];
if (objA && vacioTxt(objA.url)) {
var etA = objA.texto || objA.titulo || objA.textoBoton || ("opción " + (m.autoPulsar + 1));
var tA = "En «Reproducir demo» se pulsa sola: «" + etA + "»";
frag.appendChild(h("span", { class: "ed-chip ed-chip-auto", title: tA, "aria-label": tA },
[h("span", { "aria-hidden": "true", text: "▶ auto" })]));
}
}
var f = contarFlujos(m);
if (f.total) {
var txt = "↳ " + f.con + (f.sin ? "/" + f.total : "");
@@ -1188,6 +1263,8 @@
if (!cab) return;
var res = cab.querySelector(".ed-resumen");
if (res) res.textContent = resumenDe(m);
var cp = cab.querySelector(".ed-cab-principal");
if (cp) cp.title = (t.classList.contains("ed-abierta") ? "Plegar" : "Desplegar") + " · " + resumenDe(m);
var chips = cab.querySelector(".ed-chips");
if (chips) {
while (chips.firstChild) chips.removeChild(chips.firstChild);
@@ -1477,7 +1554,8 @@
btnMini("✕", "Quitar " + o.etiqueta.toLowerCase(), "del-boton", { "data-ruta": ruta, "data-idx": i }, true)
]),
edCampo("Texto", ruta + "." + i + ".texto", b.texto, { limite: o.maxTexto, placeholder: o.ejemplo || "" }),
o.conUrl ? edCampo("URL (si la pones, abre el enlace en vez de responder)", ruta + "." + i + ".url", b.url, { placeholder: "https://…" }) : null,
o.conUrl ? edCampo("URL (si la pones, abre el enlace en vez de responder; tel:+34… llama)", ruta + "." + i + ".url", b.url, { placeholder: "https://…" }) : null,
edCheckAuto(rutaMensajeDe(ruta), i, b),
edRespuestas(ruta + "." + i + ".respuestas", b.respuestas, o.raiz)
]);
});
@@ -1503,9 +1581,13 @@
function edSeccionesEd(ruta, secciones, raiz) {
var lista = Array.isArray(secciones) ? secciones : [];
var rutaMsgLista = rutaMensajeDe(ruta);
var base = 0; // índice aplanado de la primera fila de la sección (autoPulsar)
var hijos = lista.map(function (s, si) {
var filas = Array.isArray(s.filas) ? s.filas : [];
var rutaFilas = ruta + "." + si + ".filas";
var offset = base;
base += filas.length;
var filaEls = filas.map(function (f, fi) {
return h("div", { class: "ed-item", "data-drag-ruta": rutaFilas, "data-idx": fi }, [
h("div", { class: "ed-sub-cab" }, [
@@ -1516,6 +1598,7 @@
]),
edCampo("Título", rutaFilas + "." + fi + ".titulo", f.titulo, { limite: 24 }),
edCampo("Descripción", rutaFilas + "." + fi + ".descripcion", f.descripcion, { limite: 72 }),
edCheckAuto(rutaMsgLista, offset + fi, f),
edRespuestas(rutaFilas + "." + fi + ".respuestas", f.respuestas, raiz)
]);
});
@@ -1572,6 +1655,7 @@
edCampo("Texto del botón", ruta + "." + ci + ".textoBoton", c.textoBoton, { limite: 20 }),
edCampo("URL (opcional)", ruta + "." + ci + ".url", c.url, { placeholder: "https://…" })
]),
edCheckAuto(rutaMensajeDe(ruta), ci, c),
edRespuestas(ruta + "." + ci + ".respuestas", c.respuestas, raiz)
]);
});
@@ -1598,10 +1682,14 @@
var caja = h("div", { class: "ed-header-bloque" });
caja.appendChild(h("div", { class: "ed-sub-cab" }, [
h("span", { class: "ed-sub-titulo", text: "Cabecera" }),
// Etiqueta fija (no se invierte con el estado): el interruptor dice si se muestra
h("label", { class: "ed-switch" }, [
h("input", { type: "checkbox", "data-rol": "header-toggle", "data-ruta": ruta, checked: tipo !== "" }),
h("input", {
type: "checkbox", role: "switch", "aria-checked": tipo !== "" ? "true" : "false",
"data-rol": "header-toggle", "data-ruta": ruta, checked: tipo !== ""
}),
h("span", { class: "ed-switch-pista" }, [h("span", { class: "ed-switch-nudo" })]),
h("span", { class: "ed-switch-texto", text: tipo === "" ? "Mostrar cabecera" : "Ocultar cabecera" })
h("span", { class: "ed-switch-texto", text: "Mostrar cabecera" })
])
]));
if (tipo !== "") {
@@ -1666,12 +1754,34 @@
});
}
var v = (m.autoPulsar === undefined || m.autoPulsar === null) ? "" : String(m.autoPulsar);
return edSelect("En «Reproducir demo», pulsar sola", ruta + ".autoPulsar", v, ops, false);
// Valor guardado que ya no corresponde a ninguna opción (borrado, o con URL):
// se muestra tal cual para que no parezca «No» en silencio (y validar() avisa)
if (v !== "" && !ops.some(function (op) { return op[0] === v; })) {
ops.push([v, "⚠ Opción inexistente (" + (Number(v) + 1) + ")"]);
}
var campo = edSelect("En «Reproducir demo», se pulsa sola", ruta + ".autoPulsar", v, ops, true);
campo.classList.add("ed-campo-auto");
return campo;
}
// Casilla «Se pulsa sola en la demo» dentro de cada botón / fila / tarjeta
// (exclusiva dentro del mensaje: marcar una desmarca la anterior)
function edCheckAuto(rutaMsg, indice, elemento) {
var m = rutaGet(rutaMsg);
if (!m || !elemento || !vacioTxt(elemento.url)) return null; // con URL abre el enlace
var marcado = tieneAuto(m) && m.autoPulsar === indice;
return h("label", { class: "ed-check ed-check-auto", title: "En «Reproducir demo» se pulsa sola 1 s después de aparecer" }, [
h("input", { type: "checkbox", "data-rol": "auto-item", "data-ruta": rutaMsg + ".autoPulsar", "data-idx": indice, checked: marcado }),
h("span", { text: "Se pulsa sola en la demo" })
]);
}
function edFormTipo(m, ruta, raiz) {
var frag = document.createDocumentFragment();
var ap = function (n) { if (n) frag.appendChild(n); };
// Clave para las demos: arriba del todo, justo bajo Dirección / Hora
// (cada botón / fila / tarjeta tiene además su casilla «Se pulsa sola»)
if (["template", "buttons", "list", "carousel"].indexOf(m.tipo) >= 0) ap(edAutoPulsar(ruta, m));
switch (m.tipo) {
case "text":
ap(edTextarea("Texto", ruta + ".texto", m.texto, { limite: 4096, rows: 3, formato: true, placeholder: "Hola 👋" }));
@@ -1724,7 +1834,6 @@
max: LIMITES.botonesPlantilla, maxTexto: 25, conUrl: true,
etiqueta: "Botón", ejemplo: "Confirmar", raiz: raiz
}));
ap(edAutoPulsar(ruta, m));
break;
}
case "buttons":
@@ -1735,7 +1844,6 @@
max: LIMITES.botonesRapidos, maxTexto: 20, conUrl: false,
etiqueta: "Botón rápido", ejemplo: "Horarios", raiz: raiz
}));
ap(edAutoPulsar(ruta, m));
break;
case "list":
ap(edCampo("Cabecera", ruta + ".header", m.header, { limite: 60 }));
@@ -1743,7 +1851,6 @@
ap(edCampo("Pie (footer)", ruta + ".footer", m.footer, { limite: 60 }));
ap(edCampo("Etiqueta del botón", ruta + ".boton", m.boton, { limite: 20, placeholder: "Ver opciones" }));
ap(edSeccionesEd(ruta + ".secciones", m.secciones, raiz));
ap(edAutoPulsar(ruta, m));
break;
case "cta_url":
ap(edMedia("Cabecera («» = sin cabecera)", ruta + ".headerMedia", m.headerMedia, { aceptar: "image/*", textoDrop: "Clic o arrastra una imagen aquí" }));
@@ -1757,7 +1864,6 @@
case "carousel":
ap(edTextarea("Cuerpo (opcional)", ruta + ".body", m.body, { limite: 1024, rows: 2 }));
ap(edCardsEd(ruta + ".cards", m.cards, raiz));
ap(edAutoPulsar(ruta, m));
break;
default:
ap(h("p", { class: "ed-nota", text: "Tipo desconocido: " + m.tipo }));
@@ -1775,9 +1881,10 @@
var total = o.anidado ? (o.total || 1) : ((WAM.estado && WAM.estado.mensajes) || []).length;
var idx = o.anidado ? o.indice : o.i;
if (!o.anidado) {
acciones.push(btnMini("+", "Insertar un mensaje debajo", "insertar-debajo", { "data-i": o.i, "data-id": m.id }));
// ↑ ↓ primero: su hueco reservado (ocultas en reposo) queda como margen
acciones.push(btnMini("↑", "Subir", "mover", { "data-i": o.i, "data-delta": -1, disabled: idx <= 0 || null }, false, "ed-mov"));
acciones.push(btnMini("↓", "Bajar", "mover", { "data-i": o.i, "data-delta": 1, disabled: idx >= total - 1 || null }, false, "ed-mov"));
acciones.push(btnMini("+", "Insertar un mensaje debajo", "insertar-debajo", { "data-i": o.i, "data-id": m.id }));
acciones.push(btnMini("⧉", "Duplicar", "dup", { "data-i": o.i }));
acciones.push(btnMini("✕", "Borrar mensaje (se puede deshacer)", "borrar", { "data-i": o.i }, true));
} else {
@@ -1804,12 +1911,19 @@
var cab = h("button", {
type: "button", class: "ed-cab-principal", "data-acc": "toggle",
"aria-expanded": abierta ? "true" : "false",
title: abierta ? "Plegar" : "Desplegar"
title: (abierta ? "Plegar" : "Desplegar") + " · " + resumenDe(m)
}, [
// Número + flecha de dirección (↙ entrada / ↗ salida): no depende solo del color
o.anidado ? h("span", { class: "ed-num ed-num-resp", text: "↳" })
: h("span", { class: "ed-num ed-num-" + (esOut ? "out" : "in"), title: esOut ? "Salida (enviado)" : "Entrada (recibido)", text: String(o.i + 1) }),
o.anidado ? null : h("span", { class: "ed-dir ed-dir-" + (esOut ? "out" : "in"), text: esOut ? "Salida" : "Entrada" }),
h("span", { class: "ed-badge", text: CORTO[m.tipo] || etiquetaTipo(m.tipo) }),
: h("span", { class: "ed-num ed-num-" + (esOut ? "out" : "in"), title: esOut ? "Salida (enviado)" : "Entrada (recibido)" }, [
h("span", { class: "ed-num-dir", "aria-hidden": "true", text: esOut ? "↗" : "↙" }),
document.createTextNode(String(o.i + 1)),
h("span", { class: "wam-sr", text: esOut ? " · salida" : " · entrada" })
]),
h("span", { class: "ed-badge", title: etiquetaTipo(m.tipo) }, [
svgIcono(ICONOS_TIPO[m.tipo] || ""),
h("span", { class: "ed-badge-txt", text: CORTO[m.tipo] || etiquetaTipo(m.tipo) })
]),
h("span", { class: "ed-resumen", text: resumenDe(m) }),
h("span", { class: "ed-chips" }, o.anidado ? null : chipsDe(m))
]);
@@ -1853,6 +1967,11 @@
pintarDestinoAlta();
actualizarAvisosGlobal();
actualizarResumenCuenta();
// Demo vacía: sin herramientas de lista (no harían nada) y pista en el teléfono
var herr = document.getElementById("ed-lista-herr") || document.querySelector(".ed-lista-herr");
if (herr) herr.hidden = !mensajes.length;
if (mensajes.length) document.body.removeAttribute("data-wam-vacia");
else document.body.setAttribute("data-wam-vacia", "");
if (!mensajes.length) {
lista.appendChild(h(etiquetaItem, { class: "ed-vacio" }, [
h("p", { class: "ed-vacio-titulo", text: "Sin mensajes todavía" }),
@@ -1887,8 +2006,13 @@
return f;
}
// Botones que borran: tras usarlos el foco NUNCA vuelve a su gemelo (un
// segundo Intro/Espacio borraría otro elemento); lo recoloca quien borra.
function esAccionBorrar(acc) { return acc === "borrar" || /^del-/.test(String(acc || "")); }
function restaurarFoco(cont, f) {
if (!f || !cont) return;
if (esAccionBorrar(f.acc)) return;
var sel = "";
if (f.ruta) sel += '[data-ruta="' + cssEsc(f.ruta) + '"]';
if (f.acc) sel += '[data-acc="' + cssEsc(f.acc) + '"]';
@@ -2017,17 +2141,18 @@
var pal = document.getElementById("nuevo-tipo");
if (pal && pal.tagName !== "SELECT") {
pal.setAttribute("data-valor", tipo);
// Sin estado «seleccionado» visible: cada clic AÑADE (no es un selector).
// Solo se conserva la parada de Tab (roving tabindex) en el último usado.
var hijos = pal.querySelectorAll("[data-tipo]");
for (var i = 0; i < hijos.length; i++) {
var activo = hijos[i].getAttribute("data-tipo") === tipo;
hijos[i].classList.toggle("ed-seleccionada", activo);
hijos[i].tabIndex = activo ? 0 : -1;
hijos[i].tabIndex = hijos[i].getAttribute("data-tipo") === tipo ? 0 : -1;
}
}
var btn = document.getElementById("btn-anadir");
if (btn) {
btn.textContent = "+ Otro «" + etiquetaTipo(tipo) + "»";
btn.title = "Añade otro mensaje de tipo " + etiquetaTipo(tipo);
btn.title = "Repite el último tipo añadido: " + etiquetaTipo(tipo);
btn.hidden = !yaAnadido;
}
}
@@ -2049,8 +2174,13 @@
"data-acc": "anadir-tipo",
"data-tipo": tipo,
tabindex: "-1",
title: "Añadir un mensaje de tipo " + etiquetaTipo(tipo)
}, [svgIcono(ICONOS_TIPO[tipo] || ""), h("span", { class: "ed-tipo-card-e", text: CORTO[tipo] || etiquetaTipo(tipo) })]));
title: "Añadir un mensaje de tipo " + etiquetaTipo(tipo),
"aria-label": "Añadir " + etiquetaTipo(tipo)
}, [
svgIcono(ICONOS_TIPO[tipo] || ""),
h("span", { class: "ed-tipo-card-e", text: CORTO[tipo] || etiquetaTipo(tipo) }),
h("span", { class: "ed-tipo-mas", "aria-hidden": "true", text: "+" })
]));
});
cont.appendChild(fila);
});
@@ -2102,15 +2232,31 @@
while (el.firstChild) el.removeChild(el.firstChild);
var ms = (WAM.estado && WAM.estado.mensajes) || [];
var pos = posicionAlta();
var previo = el.getAttribute("data-pos");
var clave = ms.length ? String(pos >= ms.length ? "fin" : pos) : "vacio";
el.setAttribute("data-pos", clave);
// Chip visible del destino: «al final» o «debajo del n.º N ×» (× = al final)
el.classList.toggle("ed-destino-chip", !!ms.length);
el.classList.toggle("ed-destino-dentro", !!ms.length && pos < ms.length);
if (pos >= ms.length) {
el.appendChild(document.createTextNode(ms.length ? "Se añade al final" : "Elige un tipo para empezar"));
return;
} else {
el.appendChild(document.createTextNode("Se inserta debajo del n.º " + pos));
el.appendChild(h("button", {
type: "button", class: "ed-destino-quitar", "data-acc": "destino-final",
title: "Añadir al final", "aria-label": "Añadir al final", text: "×"
}));
}
// Aviso visual breve cuando el destino cambia solo (al abrir otra tarjeta)
if (previo !== null && previo !== clave && ms.length && !reducirMovimiento()) {
el.classList.remove("ed-destino-cambio");
void el.offsetWidth;
el.classList.add("ed-destino-cambio");
}
el.appendChild(document.createTextNode("Se inserta debajo del n.º " + pos + " "));
el.appendChild(h("button", { type: "button", class: "ed-enlace", "data-acc": "destino-final", text: "Al final" }));
}
function anadirDesdePaleta(tipo) {
yaAnadido = true; // desde ahora se ofrece «+ Otro «X»» (repetir el último)
seleccionarTipo(tipo);
var pos = posicionAlta();
insertarTras = null;
@@ -2239,7 +2385,7 @@
snap();
WAM.setCampo("mensajes", copia);
WAM.renderTarjetas();
WAM.toast("Horas repartidas desde las " + inicio + " (+1 min por mensaje)",
WAM.toast("Horas repartidas desde " + inicio,
typeof WAM.deshacer === "function" ? { accion: { texto: "Deshacer", fn: WAM.deshacer } } : null);
}
@@ -2509,12 +2655,29 @@
var ruta = el.getAttribute("data-ruta");
var idx = Number(el.getAttribute("data-idx"));
var min = Number(el.getAttribute("data-min")) || 0;
mutarArray(ruta, function (arr) {
var res = mutarArray(ruta, function (arr) {
if (arr.length <= min) { aviso("Debe quedar al menos " + min, "error"); return null; }
if (idx < 0 || idx >= arr.length) return null;
arr.splice(idx, 1);
return arr;
});
if (res) enfocarTrasBorrar(ruta, idx, res.length);
}
/* Tras quitar un elemento: foco a la cabecera / primer campo del que
ocupa su lugar (o del anterior); si la lista quedó vacía, a su botón
«+ Añadir». Nunca a otro botón ✕. */
function enfocarTrasBorrar(ruta, idx, n) {
var lista = listaNodo();
if (!lista) return;
var j = idx < n ? idx : n - 1;
var r = cssEsc(ruta);
var it = j >= 0 ? lista.querySelector('[data-drag-ruta="' + r + '"][data-idx="' + j + '"], [data-mov-ruta="' + r + '"][data-idx="' + j + '"]') : null;
var foco = it && (it.querySelector(".ed-cab-principal") || it.querySelector("input.ed-input, textarea"));
if (!foco) foco = lista.querySelector('[data-acc^="add-"][data-ruta="' + r + '"]');
if (!foco) return;
try { foco.focus({ preventScroll: true }); } catch (e) { foco.focus(); }
if (foco.scrollIntoView) foco.scrollIntoView({ block: "nearest" });
}
function alternarModoMedia(el) {
@@ -2536,7 +2699,8 @@
abiertas[id] = !abiertas[id];
t.classList.toggle("ed-abierta", abiertas[id]);
el.setAttribute("aria-expanded", abiertas[id] ? "true" : "false");
el.title = abiertas[id] ? "Plegar" : "Desplegar";
var resT = el.querySelector(".ed-resumen");
el.title = (abiertas[id] ? "Plegar" : "Desplegar") + (resT ? " · " + resT.textContent : "");
var raizT = t.getAttribute("data-raiz") || id;
if (!t.classList.contains("ed-anidada")) {
if (abiertas[id]) ultimaAbierta = id;
@@ -2719,6 +2883,18 @@
}
if (el.type === "checkbox") {
snap();
if (el.getAttribute("data-rol") === "auto-item") {
// Exclusiva dentro del mensaje: marcar fija autoPulsar; desmarcar lo quita
var rutaA = String(el.getAttribute("data-ruta") || "").replace(/\.autoPulsar$/, "");
var msgA = rutaGet(rutaA);
if (msgA && typeof msgA === "object") {
if (el.checked) msgA.autoPulsar = Number(el.getAttribute("data-idx")) || 0;
else delete msgA.autoPulsar;
WAM.setCampo(rutaA, msgA);
}
repintarDesde(el);
return;
}
if (el.getAttribute("data-rol") === "header-toggle") {
var rt = el.getAttribute("data-ruta");
WAM.setCampo(rt + ".headerTipo", el.checked ? "texto" : "");
+203 -34
View File
@@ -5,7 +5,8 @@
· 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 (?)
· 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
@@ -137,10 +138,17 @@
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(pilaDeshacer.pop());
sustituirEstado(conTemaActual(pilaDeshacer.pop()));
pintarBotonesHistorial();
toast("Cambio deshecho", { accion: { texto: "Rehacer", fn: WAM.rehacer } });
return true;
@@ -149,7 +157,7 @@
WAM.rehacer = function () {
if (!pilaRehacer.length) { toast("No hay nada que rehacer"); return false; }
pilaDeshacer.push(clonar(WAM.estado));
sustituirEstado(pilaRehacer.pop());
sustituirEstado(conTemaActual(pilaRehacer.pop()));
pintarBotonesHistorial();
toast("Cambio rehecho");
return true;
@@ -253,14 +261,17 @@
if (!dentro) dlg.close();
});
var foco = null;
var restaurar = true;
dlg.addEventListener("close", function () {
setTimeout(function () { dlg.remove(); }, 0);
if (foco && foco.isConnected && typeof foco.focus === "function") foco.focus();
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(); }
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; }
};
}
@@ -281,7 +292,7 @@
lsDel(CLAVE_ACTIVA);
ejecutar();
marcarEditado();
toast("Demo de ejemplo restablecida", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
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
}
@@ -290,11 +301,11 @@
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 ? "¿Restablecer la demo de ejemplo? Se sustituirá " + resumen + "."
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 ? "¿Restablecer la demo de ejemplo?" : "¿Importar una demo desde JSON?", "wam-dlg-chico");
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 + ").") }));
@@ -372,14 +383,59 @@
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;
var habiaAlgo = nMensajes() > 0;
if (habiaAlgo) WAM.puntoDeshacer();
sustituirEstado(normalizado(crudo));
lsDel(CLAVE_ACTIVA);
toast("Escenario «" + esc.nombre + "» cargado", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
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) {
@@ -397,29 +453,28 @@
// --- Escenarios ---
var gridEsc = h("div", { class: "wam-esc-grid" });
(WAM.ESCENARIOS || []).forEach(function (esc) {
var n = 0;
try { n = (esc.datos().mensajes || []).length; } catch (e) { n = 0; }
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 }),
h("span", { class: "wam-esc-meta", text: n ? n + " mensajes" : "Vacía" })
chipsTipos(ms),
h("span", { class: "wam-esc-meta", text: ms.length ? ms.length + " mensajes" : "Vacía" })
]);
b.addEventListener("click", function () { d.dlg.close(); cargarEscenario(esc); });
b.addEventListener("click", function () { d.sinRestaurar(); d.dlg.close(); cargarEscenario(esc); });
gridEsc.appendChild(b);
});
var bAurora = h("button", { type: "button", class: "wam-esc" }, [
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" })
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;
var habiaAlgo = nMensajes() > 0;
if (habiaAlgo) WAM.puntoDeshacer();
sustituirEstado(normalizado(WAM.datosDemo()));
lsDel(CLAVE_ACTIVA);
toast("Demo «Café Aurora» cargada", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
aplicarDemoCompleta(WAM.datosDemo(), "Demo «Café Aurora» cargada");
});
gridEsc.appendChild(bAurora);
@@ -432,13 +487,17 @@
// --- 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 : ((WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "Mi demo"),
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() || "Mi demo";
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(); }
@@ -539,27 +598,43 @@
/* ------------------------------------------------------------------
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"],
["N", "Demos y escenarios"],
["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 en una burbuja", "Abrir su tarjeta en el editor (también doble clic)"],
["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(function (k, i, arr) {
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(" + "));
@@ -591,12 +666,66 @@
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();
@@ -625,6 +754,7 @@
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
@@ -638,15 +768,28 @@
/* ------------------------------------------------------------------
Menú «⋯» de la toolbar (ventana estrecha) y pestañas Editor / Vista
------------------------------------------------------------------ */
function cerrarMenu() {
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 (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;
@@ -654,10 +797,27 @@
e.stopPropagation();
var abierto = tb.classList.toggle("tb-menu-abierto");
mas.setAttribute("aria-expanded", abierto ? "true" : "false");
if (abierto) { var b = menu.querySelector("button:not([disabled])"); if (b) b.focus(); }
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();
if (e.target.closest && e.target.closest("button")) cerrarMenu(false);
});
document.addEventListener("click", function (e) {
if (!tb.classList.contains("tb-menu-abierto")) return;
@@ -782,9 +942,17 @@
/* ------------------------------------------------------------------
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", document.body.getAttribute("data-tema") === "oscuro" ? "true" : "false");
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;
}
/* ------------------------------------------------------------------
@@ -809,6 +977,7 @@
enlazarPestanas();
observarToolbar();
enlazarChat();
enlazarSaltos();
envolverAnuncios();
sincronizarTema();