Files
whatsapp-api-mockup/js/editor.js
T
urieljarethandClaude Opus 5.5 3509a77d62 Editor y toolbar: jerarquía, tarjetas legibles, foco seguro y autoPulsar estable
- Toolbar: «Nueva demo» primaria junto a la marca (N); Tema como icono;
  «Volver al ejemplo Café Aurora» (peligro) solo dentro del menú «⋯»;
  etiquetas de Nueva demo y Presentación visibles entre 900 y 1100 px.
- Tarjetas: flecha de dirección en el número (↙/↗), badge con icono
  (solo icono en editor estrecho), resumen en 2.ª línea al abrir, hueco
  de ↑/↓ reservado (sin saltos al pasar el ratón), container queries.
- Borrar (mensaje, botón, fila, tarjeta, respuesta) nunca deja el foco
  en otro botón ✕.
- autoPulsar sigue al elemento al mover/borrar/arrastrar; opción
  inexistente visible y aviso; casilla «Se pulsa sola» por elemento,
  chip «▶ auto» y select arriba del formulario.
- Deshacer/rehacer conservan el tema; el tema marca «Guardando…».
- Escenarios: siempre punto de deshacer, un único aviso con acción y
  foco al nombre de la cuenta; chips de tipos en el diálogo Demos,
  rejilla de 4 columnas en >= 1280 y sin nombre precargado.
- Paleta sin estado seleccionado, «+» en hover/foco, chip de destino
  y «+ Otro» solo tras el primer alta.
- Avisos abajo al centro sobre la vista previa; tel: válido en
  plantillas (máx. 1); interruptor «Mostrar cabecera» fijo (role=switch).
- Teclado: enlace «Saltar a la vista previa» y G / F6; atajos con ⌘ en
  Mac; etiqueta «Velocidad»; «Repetir desde el inicio».
- Demo vacía: herramientas ocultas y pista dentro del chat.

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

3018 lines
133 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/editor.js — Dueño C (SPEC §2/§4): panel editor.
Formulario de cuenta, alta por paleta de tarjetas de tipo (estilo Meta),
tarjetas con TODOS los campos del modelo (§1), contadores de caracteres,
barra de formato WhatsApp, variables de plantilla, toggle de cabecera,
origen de media por radios + dropzone (clic o arrastrar-soltar),
reordenar por drag & drop (mensajes y elementos internos),
duplicar/borrar, exportar/importar JSON.
Listeners SOLO dentro de #panel-editor e inputs propios.
Contratos usados de otros dueños: WAM.estado/guardar/normalizar/
generarId/nuevoMensaje/horaAhora/svgPlaceholder (store, D),
WAM.render/setTema (render, B). Toolbar (#btn-exportar, #btn-importar,
#btn-reset, #btn-tema, #importar-archivo) la enlaza main.js (D).
===================================================================== */
(function () {
"use strict";
window.WAM = window.WAM || {};
/* ------------------------------------------------------------------
Constantes del editor
------------------------------------------------------------------ */
// Los 13 tipos del modelo (SPEC §1)
var TIPOS = [
["text", "Texto"],
["image", "Imagen"],
["video", "Vídeo"],
["audio", "Audio"],
["document", "Documento"],
["sticker", "Sticker"],
["location", "Ubicación"],
["contact", "Contacto"],
["template", "Plantilla"],
["buttons", "Botones rápidos"],
["list", "Lista"],
["cta_url", "Botón URL"],
["carousel", "Carrusel"]
];
// Agrupación de la paleta de alta (Básicos / Medios / Interactivos)
var GRUPOS_PALETA = [
["Básicos", ["text", "location", "contact"]],
["Medios", ["image", "video", "audio", "document", "sticker"]],
["Interactivos", ["template", "buttons", "list", "cta_url", "carousel"]]
];
// Nombre corto para la paleta (el largo va en el tooltip)
var CORTO = { buttons: "Botones", cta_url: "Botón URL" };
// Tipos de cabecera de template/buttons (SPEC §1.9/§1.10)
var TIPOS_HEADER = [
["texto", "Texto"],
["imagen", "Imagen"],
["video", "Vídeo"],
["documento", "Documento"]
];
var ESTADOS = [
["sent", "Enviado ✓"],
["delivered", "Entregado ✓✓"],
["read", "Leído ✓✓"]
];
// Límites de docs/research-api.md (§8) que aplica el editor
var LIMITES = {
botonesRapidos: 3, // quick replies por mensaje (SPEC §1.10)
botonesPlantilla: 10, // botones totales por plantilla [20]
secciones: 10,
filas: 10,
tarjetasMax: 5, // carousel 2–5 cards (SPEC §1.13)
tarjetasMin: 2
};
// Iconos SVG (trazo) de la paleta de tipos, viewBox 0 0 24 24
var ICONOS_TIPO = {
text: '<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4h13A1.5 1.5 0 0 1 20 5.5v9a1.5 1.5 0 0 1-1.5 1.5H10l-4.3 3.6c-.5.4-1.2.1-1.2-.6z"/><path d="M8 9h8M8 12.2h5"/>',
image: '<rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="9" cy="10" r="1.7"/><path d="M3.8 16.8l4.4-3.9 3.4 3 3.6-3.6 5 4.4"/>',
video: '<rect x="3" y="6" width="12.5" height="12" rx="2"/><path d="M15.5 10.6l5.5-3.1v9l-5.5-3.1z"/>',
audio: '<path d="M4 10.2v3.6M8 7.2v9.6M12 4.4v15.2M16 7.2v9.6M20 10.2v3.6"/>',
document: '<path d="M7 3.5h6.8L18.5 8v11a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 19V5A1.5 1.5 0 0 1 7 3.5z"/><path d="M13.5 3.8V8h4.6M9 12.2h6M9 15.4h6"/>',
sticker: '<path d="M5.5 4h13v9.3L14 18H5.5z"/><path d="M18.2 13.5h-4v4.3"/><path d="M9.2 10.4h.01M14.8 10.4h.01"/>',
location: '<path d="M12 20.6S5.6 15 5.6 10.4a6.4 6.4 0 0 1 12.8 0C18.4 15 12 20.6 12 20.6z"/><circle cx="12" cy="10.4" r="2.2"/>',
contact: '<rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="9.4" cy="10.4" r="1.9"/><path d="M6.4 15.6c.8-1.6 1.9-2.1 3-2.1s2.2.5 3 2.1M15.3 9.6h2.5M15.3 13h2.5"/>',
template: '<rect x="3.5" y="4" width="17" height="16" rx="2"/><path d="M3.5 8.4h17M7 12.4h10M7 15.6h6.5"/>',
buttons: '<rect x="4" y="4.4" width="16" height="4.3" rx="2.15"/><rect x="4" y="9.9" width="16" height="4.3" rx="2.15"/><rect x="4" y="15.4" width="11.5" height="4.3" rx="2.15"/>',
list: '<path d="M8.5 6h11.5M8.5 12h11.5M8.5 18h11.5"/><circle cx="4.6" cy="6" r="1" fill="currentColor" stroke="none"/><circle cx="4.6" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="4.6" cy="18" r="1" fill="currentColor" stroke="none"/>',
cta_url: '<path d="M10.2 13.8l3.6-3.6"/><path d="M9.3 11.9l-2.1 2.1a3.1 3.1 0 0 0 4.4 4.4l2.1-2.1"/><path d="M14.7 12.1l2.1-2.1a3.1 3.1 0 0 0-4.4-4.4l-2.1 2.1"/>',
carousel: '<rect x="3" y="6" width="12.6" height="12" rx="1.8"/><path d="M18.6 8.1c1 .3 1.6 1.3 1.4 2.3l-1.7 7.6a1.8 1.8 0 0 1-2.1 1.4l-2-.4"/>'
};
var GRIP = '<circle cx="9" cy="5.5" r="1.15" fill="currentColor" stroke="none"/><circle cx="15" cy="5.5" r="1.15" fill="currentColor" stroke="none"/><circle cx="9" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="9" cy="18.5" r="1.15" fill="currentColor" stroke="none"/><circle cx="15" cy="18.5" r="1.15" fill="currentColor" stroke="none"/>';
var ICONO_SUBIR = '<path d="M12 15.2V4.8M8.3 8.2 12 4.5l3.7 3.7"/><path d="M4.8 14.4v3a2 2 0 0 0 2 2h10.4a2 2 0 0 0 2-2v-3"/>';
// Formato WhatsApp de la barra (marcador, clase, descripción)
var FORMATOS = [
["*", "negrita", "Negrita · *texto*"],
["_", "cursiva", "Cursiva · _texto_"],
["~", "tachado", "Tachado · ~texto~"],
["`", "mono", "Monoespaciado · `texto`"]
];
/* ------------------------------------------------------------------
Estado interno de UI (qué tarjetas están abiertas, nodos cacheados)
------------------------------------------------------------------ */
var abiertas = Object.create(null);
var panel = null;
var panelEnlazado = false;
var cuentaDecidida = false; // el <details> de Cuenta se abre/cierra solo la 1.ª vez
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>
// Render diferido mientras se escribe (una vez por frame)
var filasPendientes = Object.create(null);
var renderTotalPendiente = false;
var cabeceraPendiente = false;
var rafPendiente = 0;
// Agrupa las teclas seguidas de un mismo campo en un solo paso de deshacer
var rutaUndo = "";
var tUndo = 0;
// Drag & drop activo (reordenar mensajes y elementos internos)
var dragItem = null;
var dropObjetivo = null;
var dropTras = false;
var zonaArchivo = null;
/* ------------------------------------------------------------------
Utilidades
------------------------------------------------------------------ */
function predeterminado(v, d) { return (v === undefined || v === null) ? d : v; }
// Constructor de nodos: attrs especiales class/text/value/checked/disabled
function h(etiqueta, attrs, hijos) {
var el = document.createElement(etiqueta);
if (attrs) {
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 if (k === "checked") el.checked = !!v;
else if (k === "disabled") el.disabled = true;
else el.setAttribute(k, v === true ? "" : v);
});
}
// hijos: array de nodos, un solo nodo (incluido DocumentFragment) o nada
var listaHijos = (hijos === null || hijos === undefined) ? [] : (Array.isArray(hijos) ? hijos : [hijos]);
listaHijos.forEach(function (x) { if (x !== null && x !== undefined) el.appendChild(x); });
return el;
}
// Icono SVG inline (marcado propio y estático; nunca recibe input del usuario)
function svgIcono(marcado) {
var span = document.createElement("span");
span.className = "ed-ico";
span.setAttribute("aria-hidden", "true");
span.innerHTML =
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"' +
' stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' + (marcado || "") + "</svg>";
return span;
}
function generarIdLocal() {
if (typeof WAM.generarId === "function") { try { return WAM.generarId(); } catch (e) { /* sigue */ } }
return "m" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
// Recolecta los id de mensajes (incluidas respuestas anidadas) de un objeto
function recolectarIdsLocal(o, conjunto) {
if (!o || typeof o !== "object") return;
if (Array.isArray(o)) { o.forEach(function (x) { recolectarIdsLocal(x, conjunto); }); return; }
if (typeof o.id === "string" && o.id) conjunto[o.id] = true;
["mensajes", "respuestas", "botones", "secciones", "filas", "cards"].forEach(function (k) {
if (o[k] && typeof o[k] === "object") recolectarIdsLocal(o[k], conjunto);
});
}
// Alocador por lotes: cada id que entrega queda reservado para la
// siguiente llamada (evita el mismo id en un clon y sus respuestas).
function alocadorIds() {
if (typeof WAM.crearAlocador === "function") {
try { return WAM.crearAlocador(); } catch (e) { /* reserva local */ }
}
var usados = Object.create(null);
recolectarIdsLocal(WAM.estado, usados);
return function () {
var n = 1;
while (usados["m" + n]) n++;
usados["m" + n] = true;
return "m" + n;
};
}
function nuevoIdCampo() { contadorIds++; return "ed-c" + contadorIds; }
function cssEsc(s) {
s = String(s);
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(s);
return s.replace(/["\\]/g, "\\$&");
}
// Punto de deshacer antes de una mutación (lo gestiona js/ux.js)
function snap() {
if (typeof WAM.puntoDeshacer === "function") { try { WAM.puntoDeshacer(); } catch (e) { /* opcional */ } }
}
function marcarEditado() {
if (typeof WAM.marcarEditado === "function") { try { WAM.marcarEditado(); } catch (e) { /* opcional */ } }
}
function presentacionActiva() { return document.body.classList.contains("presentacion"); }
function reducirMovimiento() {
return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}
function placeholderSvg(texto) {
try {
if (typeof WAM.svgPlaceholder === "function") return WAM.svgPlaceholder(texto || "");
} catch (e) { /* sin placeholder */ }
return "";
}
function rutaGet(ruta) {
var partes = String(ruta).split(".");
return partes.reduce(function (o, k) { return (o === null || o === undefined) ? undefined : o[k]; }, WAM.estado);
}
function etiquetaTipo(tipo) {
for (var i = 0; i < TIPOS.length; i++) if (TIPOS[i][0] === tipo) return TIPOS[i][1];
return String(tipo || "?");
}
function esTipoValido(tipo) {
for (var i = 0; i < TIPOS.length; i++) if (TIPOS[i][0] === tipo) return true;
return false;
}
/* 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) {
c = h("div", { id: "ed-toasts", class: "ed-toasts" });
document.body.appendChild(c);
}
return c;
}
WAM.toast = function (texto, opts) {
opts = opts || {};
var c = contenedorToasts();
var esError = opts.tipo === "error";
var t = h("div", {
class: "ed-toast" + (esError ? " ed-toast-error" : ""),
role: esError ? "alert" : "status"
}, [h("span", { class: "ed-toast-txt", text: String(texto || "") })]);
var timer = 0;
var cerrar = function () {
clearTimeout(timer);
if (t.parentNode) t.parentNode.removeChild(t);
};
if (opts.accion && typeof opts.accion.fn === "function") {
var b = h("button", { type: "button", class: "ed-toast-accion", text: opts.accion.texto || "Deshacer" });
b.addEventListener("click", function () {
cerrar();
try { opts.accion.fn(); } catch (e) { /* la acción es opcional */ }
});
t.appendChild(b);
}
var x = h("button", { type: "button", class: "ed-toast-cerrar", "aria-label": "Cerrar aviso", text: "×" });
x.addEventListener("click", cerrar);
t.appendChild(x);
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;
};
function aviso(texto, tipo) { WAM.toast(texto, { tipo: tipo }); }
/* ------------------------------------------------------------------
Contadores de caracteres (límites oficiales, sin bloquear escritura)
------------------------------------------------------------------ */
function claseContador(n, max) {
if (n > max) return "ed-contador-over";
if (n >= Math.max(1, Math.round(max * 0.9))) return "ed-contador-alerta";
return "";
}
function edContador(valor, maximo) {
var n = String(predeterminado(valor, "")).length;
var clase = "ed-contador" + (claseContador(n, maximo) ? " " + claseContador(n, maximo) : "");
return h("span", { class: clase, text: n + " / " + maximo, "data-max": maximo });
}
// Refresca el contador del input/textarea que acaba de cambiar
function actualizarContador(input) {
if (!input || !input.closest) return;
var campo = input.closest(".ed-campo");
var cont = campo ? campo.querySelector(".ed-contador") : null;
if (!cont) return;
var max = Number(cont.getAttribute("data-max")) || 0;
if (!max) return;
var n = String(predeterminado(input.value, "")).length;
cont.textContent = n + " / " + max;
cont.classList.remove("ed-contador-alerta");
cont.classList.remove("ed-contador-over");
var extra = claseContador(n, max);
if (extra) cont.classList.add(extra);
}
/* ------------------------------------------------------------------
Edición en cursor: barra de formato WhatsApp y variables {{n}}
------------------------------------------------------------------ */
// Cambios programáticos del valor no disparan "input": lo lanzamos burbujeando
// para reutilizar la delegación (setCampo + contador).
function dispararInput(el) {
if (!el) return;
if (typeof Event === "function") {
el.dispatchEvent(new Event("input", { bubbles: true }));
} else {
var ruta = el.getAttribute ? el.getAttribute("data-ruta") : null;
if (ruta) WAM.setCampo(ruta, el.value);
actualizarContador(el);
}
}
function posSeleccion(input) {
var ini = (typeof input.selectionStart === "number") ? input.selectionStart : input.value.length;
var fin = (typeof input.selectionEnd === "number") ? input.selectionEnd : ini;
return [ini, fin];
}
// Envuelve la selección con el marcador (*, _, ~, `) y recoloca el cursor
function envolverSeleccion(input, marcador) {
if (!input || !marcador) return;
var pos = posSeleccion(input);
var ini = pos[0], fin = pos[1];
var sel = input.value.slice(ini, fin);
var nuevo = marcador + sel + marcador;
input.value = input.value.slice(0, ini) + nuevo + input.value.slice(fin);
input.focus();
var a = ini + marcador.length;
input.setSelectionRange(a, sel.length ? a + sel.length : a);
dispararInput(input);
}
// Inserta texto en la posición del cursor (variables {{n}})
function insertarTexto(input, texto) {
if (!input || !texto) return;
var pos = posSeleccion(input);
var ini = pos[0], fin = pos[1];
input.value = input.value.slice(0, ini) + texto + input.value.slice(fin);
input.focus();
var p = ini + texto.length;
input.setSelectionRange(p, p);
dispararInput(input);
}
// Siguiente variable libre: cuenta las {{n}} ya presentes y devuelve max + 1
function siguienteVariable(input) {
var re = /\{\{\s*(\d+)\s*\}\}/g;
var m, max = 0;
while ((m = re.exec(String(input.value || ""))) !== null) {
var n = parseInt(m[1], 10);
if (n > max) max = n;
}
return max + 1;
}
// El input/textarea asociado a un botón de la barra (mismo .ed-campo)
function inputDeCampo(el) {
var campo = el && el.closest ? el.closest(".ed-campo") : null;
return campo ? campo.querySelector("textarea, input.ed-input") : null;
}
function edBarra(opts) {
var bar = h("div", { class: "ed-fmtbar", role: "toolbar", "aria-label": "Formato de WhatsApp" });
if (opts.formato) {
FORMATOS.forEach(function (f) {
var txt = f[0] === "*" ? "B" : (f[0] === "_" ? "I" : (f[0] === "~" ? "S" : "</>"));
bar.appendChild(h("button", {
type: "button",
class: "ed-fmt-btn ed-fmt-" + f[1],
text: txt,
"data-acc": "fmt",
"data-fmt": f[0],
title: f[2],
"aria-label": f[2]
}));
});
}
if (opts.variables) {
bar.appendChild(h("button", {
type: "button",
class: "ed-fmt-btn ed-fmt-var",
text: "+ Añadir variable",
"data-acc": "var",
title: "Inserta {{1}}, {{2}}… en la posición del cursor (cuenta las existentes)",
"aria-label": "Añadir variable"
}));
}
return bar;
}
/* ------------------------------------------------------------------
Contrato: WAM.setCampo(ruta, valor) — muta + guardar + render (SPEC §2)
ruta con puntos: "mensajes.3.body", "cuenta.nombre", "mensajes.0.botones"
------------------------------------------------------------------ */
/* opts.diferido (edición de texto tecla a tecla): muta y guarda igual,
pero el chat se repinta en el siguiente frame y SOLO la fila afectada
(sin scroll al fondo, sin recrear el resto de burbujas ni sus medios).
Sin opts se mantiene el contrato original: guardar + render total. */
WAM.setCampo = function (ruta, valor, opts) {
if (!ruta || !WAM.estado) return;
var partes = String(ruta).split(".");
var obj = WAM.estado;
for (var i = 0; i < partes.length - 1; i++) {
var k = partes[i];
if (obj[k] === null || obj[k] === undefined) obj[k] = /^\d+$/.test(partes[i + 1]) ? [] : {};
obj = obj[k];
}
obj[partes[partes.length - 1]] = valor;
if (typeof WAM.guardar === "function") WAM.guardar();
marcarEditado();
if (opts && opts.diferido) { programarRender(String(ruta)); return; }
cancelarRenderPendiente();
if (typeof WAM.render === "function") WAM.render();
};
function cancelarRenderPendiente() {
if (rafPendiente) { cancelAnimationFrame(rafPendiente); rafPendiente = 0; }
filasPendientes = Object.create(null);
renderTotalPendiente = false;
cabeceraPendiente = false;
}
function programarRender(ruta) {
var m = /^mensajes\.(\d+)(\.|$)/.exec(ruta);
if (m && ruta !== "mensajes") filasPendientes[m[1]] = true;
else if (ruta.indexOf("cuenta.") === 0) cabeceraPendiente = true;
else renderTotalPendiente = true;
if (!rafPendiente) rafPendiente = requestAnimationFrame(ejecutarRenderPendiente);
}
function ejecutarRenderPendiente() {
rafPendiente = 0;
var total = renderTotalPendiente, cabecera = cabeceraPendiente, filas = Object.keys(filasPendientes);
filasPendientes = Object.create(null);
renderTotalPendiente = false;
cabeceraPendiente = false;
if (total) { if (typeof WAM.render === "function") WAM.render(); return; }
if (cabecera && typeof WAM.renderHeader === "function") WAM.renderHeader();
filas.forEach(function (i) { renderFilaChat(Number(i)); });
}
function demoEnCurso() {
return typeof WAM.demoActiva === "function" && !!WAM.demoActiva();
}
// Sustituye solo li.wa-row[data-id] del mensaje i (re-render parcial)
function renderFilaChat(i) {
var mensajes = (WAM.estado && WAM.estado.mensajes) || [];
var msg = mensajes[i];
if (!msg) return;
if (typeof WAM.renderUno === "function") { WAM.renderUno(i); return; }
var cuerpo = document.getElementById("chat-cuerpo");
if (!cuerpo || typeof WAM.renderMensaje !== "function") return;
var viejo = cuerpo.querySelector('.wa-row[data-id="' + cssEsc(msg.id) + '"]');
if (!viejo) {
// La fila aún no está (p. ej. durante «Reproducir demo»): nada que tocar
if (!demoEnCurso() && typeof WAM.render === "function") WAM.render();
return;
}
var nuevo = WAM.renderMensaje(msg, i);
if (viejo.classList.contains("ed-resaltado")) nuevo.classList.add("ed-resaltado");
var car = viejo.querySelector(".carousel");
var sl = car ? car.scrollLeft : 0;
viejo.parentNode.replaceChild(nuevo, viejo);
var car2 = sl ? nuevo.querySelector(".carousel") : null;
if (car2) car2.scrollLeft = sl;
}
/* --- 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;
}
/* ------------------------------------------------------------------
Fábricas de datos (usadas solo al CREAR; no mutan lo importado)
------------------------------------------------------------------ */
function aplicarCamposTipo(m) {
switch (m.tipo) {
case "text": m.texto = predeterminado(m.texto, ""); break;
case "image":
case "video": m.src = predeterminado(m.src, ""); m.caption = predeterminado(m.caption, ""); break;
case "audio": m.src = predeterminado(m.src, ""); m.voz = !!m.voz; m.duracion = predeterminado(m.duracion, "0:12"); break;
case "document": m.src = predeterminado(m.src, ""); m.caption = predeterminado(m.caption, ""); m.filename = predeterminado(m.filename, "documento.pdf"); break;
case "sticker": m.src = predeterminado(m.src, ""); break;
case "location":
m.latitud = predeterminado(m.latitud, ""); m.longitud = predeterminado(m.longitud, "");
m.nombre = predeterminado(m.nombre, ""); m.direccion = predeterminado(m.direccion, ""); break;
case "contact":
m.nombre = predeterminado(m.nombre, ""); m.telefono = predeterminado(m.telefono, "");
m.email = predeterminado(m.email, ""); m.empresa = predeterminado(m.empresa, ""); break;
case "template":
m.headerTipo = predeterminado(m.headerTipo, ""); m.headerTexto = predeterminado(m.headerTexto, "");
m.headerMedia = predeterminado(m.headerMedia, ""); m.body = predeterminado(m.body, "");
m.footer = predeterminado(m.footer, "");
if (!Array.isArray(m.botones)) m.botones = [];
m.botones.forEach(function (b) {
b.texto = predeterminado(b.texto, "");
if (!Array.isArray(b.respuestas)) b.respuestas = [];
});
break;
case "buttons":
m.headerTipo = predeterminado(m.headerTipo, ""); m.headerTexto = predeterminado(m.headerTexto, "");
m.headerMedia = predeterminado(m.headerMedia, "");
m.body = predeterminado(m.body, ""); m.footer = predeterminado(m.footer, "");
if (!Array.isArray(m.botones)) m.botones = [];
m.botones.forEach(function (b) {
b.texto = predeterminado(b.texto, "");
if (!Array.isArray(b.respuestas)) b.respuestas = [];
});
break;
case "list":
m.header = predeterminado(m.header, ""); m.body = predeterminado(m.body, "");
m.footer = predeterminado(m.footer, ""); m.boton = predeterminado(m.boton, "Ver opciones");
if (!Array.isArray(m.secciones)) m.secciones = [];
m.secciones.forEach(function (s) {
s.titulo = predeterminado(s.titulo, "");
if (!Array.isArray(s.filas)) s.filas = [];
s.filas.forEach(function (f) {
f.titulo = predeterminado(f.titulo, ""); f.descripcion = predeterminado(f.descripcion, "");
if (!Array.isArray(f.respuestas)) f.respuestas = [];
});
});
break;
case "cta_url":
m.headerMedia = predeterminado(m.headerMedia, ""); m.body = predeterminado(m.body, "");
m.footer = predeterminado(m.footer, ""); m.textoBoton = predeterminado(m.textoBoton, "Abrir");
m.url = predeterminado(m.url, "");
break;
case "carousel":
m.body = predeterminado(m.body, "");
if (!Array.isArray(m.cards)) m.cards = [];
m.cards.forEach(function (c) {
c.media = predeterminado(c.media, ""); c.texto = predeterminado(c.texto, "");
c.textoBoton = predeterminado(c.textoBoton, "Abrir"); c.url = predeterminado(c.url, "");
if (!Array.isArray(c.respuestas)) c.respuestas = [];
});
break;
default: break;
}
return m;
}
// Hora "HH:MM" + n minutos (texto libre inválido → null)
function sumarMinutos(hora, n) {
var m = /^(\d{1,2}):(\d{2})$/.exec(String(hora || "").trim());
if (!m) return null;
var total = (Number(m[1]) * 60 + Number(m[2]) + n) % 1440;
if (total < 0) total += 1440;
var hh = Math.floor(total / 60), mm = total % 60;
return (hh < 10 ? "0" : "") + hh + ":" + (mm < 10 ? "0" : "") + mm;
}
// Hora por defecto de un mensaje nuevo: la del mensaje anterior + 1 min
// (coherente con la conversación en vez de la hora real del reloj)
function horaSiguiente(despuesDe) {
var ms = (WAM.estado && WAM.estado.mensajes) || [];
var ref = (typeof despuesDe === "number" && despuesDe >= 0 && despuesDe < ms.length) ? ms[despuesDe] : ms[ms.length - 1];
var h1 = ref ? sumarMinutos(ref.hora, 1) : null;
if (h1) return h1;
return (typeof WAM.horaAhora === "function") ? WAM.horaAhora() : "10:24";
}
// Contenido de ejemplo para que un mensaje nuevo nunca salga vacío en el teléfono
function rellenarEjemplo(m) {
var vacioS = function (v) { return !String(v === undefined || v === null ? "" : v).trim(); };
switch (m.tipo) {
case "text": if (vacioS(m.texto)) m.texto = "Hola, ¿en qué te puedo ayudar?"; break;
case "audio": if (!m.duracion || m.duracion === "0:00") m.duracion = "0:12"; break;
case "location":
if (vacioS(m.latitud)) m.latitud = "19.4326";
if (vacioS(m.longitud)) m.longitud = "-99.1332";
if (vacioS(m.nombre)) m.nombre = "Nuestra sucursal";
if (vacioS(m.direccion)) m.direccion = "Av. Principal 123, Centro";
break;
case "contact":
if (vacioS(m.nombre)) m.nombre = "Nombre del contacto";
if (vacioS(m.telefono)) m.telefono = "+52 55 0000 0000";
break;
case "template":
if (vacioS(m.body)) m.body = "Hola {{1}}, tu solicitud quedó registrada. ¿Quieres confirmarla?";
break;
case "buttons":
if (vacioS(m.body)) m.body = "¿En qué te puedo ayudar?";
break;
case "list":
if (vacioS(m.body)) m.body = "Elige una opción de la lista";
if (vacioS(m.boton)) m.boton = "Ver opciones";
(m.secciones || []).forEach(function (s) {
if (vacioS(s.titulo)) s.titulo = "Opciones";
(s.filas || []).forEach(function (f, fi) { if (vacioS(f.titulo)) f.titulo = "Opción " + (fi + 1); });
});
break;
case "cta_url":
if (vacioS(m.body)) m.body = "Consulta todos los detalles en nuestro sitio.";
if (vacioS(m.textoBoton) || m.textoBoton === "Abrir") m.textoBoton = "Visitar sitio";
if (vacioS(m.url)) m.url = "https://example.com";
break;
case "carousel":
if (vacioS(m.body)) m.body = "Te pueden interesar:";
(m.cards || []).forEach(function (c, ci) {
if (vacioS(c.texto)) c.texto = "Producto " + (ci + 1) + " · descripción breve";
if (vacioS(c.textoBoton) || c.textoBoton === "Abrir") c.textoBoton = "Me interesa";
});
break;
default: break;
}
return m;
}
function crearMensaje(tipo, hora) {
var base = null;
if (typeof WAM.nuevoMensaje === "function") {
try { base = WAM.nuevoMensaje(tipo); } catch (e) { base = null; }
}
var m = (base && typeof base === "object") ? base : { id: "", dir: "in", tipo: tipo, hora: "10:24", estado: "sent" };
m.tipo = tipo;
if (!m.id) m.id = generarIdLocal();
if (m.dir !== "in" && m.dir !== "out") m.dir = "in";
if (hora && /^\d{1,2}:\d{2}$/.test(hora)) m.hora = hora;
if (!/^\d{1,2}:\d{2}$/.test(m.hora || "")) {
m.hora = (typeof WAM.horaAhora === "function") ? WAM.horaAhora() : "10:24";
}
if (ESTADOS.every(function (e) { return e[0] !== m.estado; })) m.estado = "sent";
aplicarCamposTipo(m);
// Arranques amables para tipos compuestos (solo mensajes nuevos)
if (m.tipo === "list" && m.secciones.length === 0) m.secciones.push(nuevaSeccion());
if (m.tipo === "carousel" && m.cards.length < LIMITES.tarjetasMin) {
while (m.cards.length < LIMITES.tarjetasMin) m.cards.push(nuevaCard());
}
if ((m.tipo === "buttons" || m.tipo === "template") && m.botones.length === 0) {
m.botones.push(nuevoBoton(m.tipo === "buttons" ? "Opción 1" : "Responder"));
}
rellenarEjemplo(m);
return m;
}
function mensajeAnidado(hora) {
var m = crearMensaje("text", hora);
m.dir = "in"; // las respuestas SIEMPRE son entrantes (SPEC §1)
m.texto = "¡Listo! Aquí tienes la información.";
return m;
}
function nuevoBoton(texto, conUrl) {
var b = { texto: texto || "" };
if (conUrl) b.url = "";
b.respuestas = [];
return b;
}
function nuevaFila(n) { return { titulo: n ? "Opción " + n : "", descripcion: "", respuestas: [] }; }
function nuevaSeccion(n) { return { titulo: n ? "Sección " + n : "", filas: [nuevaFila(n ? 1 : 0)] }; }
function nuevaCard(n) { return { media: "", texto: n ? "Tarjeta " + n + " · descripción breve" : "", textoBoton: n ? "Me interesa" : "Abrir", url: "", respuestas: [] }; }
// Regenera ids del clon y de todas sus respuestas anidadas (solo nodos Mensaje)
function reasignarIds(m) {
var tomar = alocadorIds();
var paseMensaje = function (x) {
if (!x || typeof x !== "object") return;
x.id = tomar();
if (Array.isArray(x.respuestas)) x.respuestas.forEach(paseMensaje);
if (Array.isArray(x.botones)) x.botones.forEach(function (b) {
if (Array.isArray(b.respuestas)) b.respuestas.forEach(paseMensaje);
});
if (Array.isArray(x.secciones)) x.secciones.forEach(function (s) {
if (!Array.isArray(s.filas)) return;
s.filas.forEach(function (f) { if (Array.isArray(f.respuestas)) f.respuestas.forEach(paseMensaje); });
});
if (Array.isArray(x.cards)) x.cards.forEach(function (c) {
if (Array.isArray(c.respuestas)) c.respuestas.forEach(paseMensaje);
});
};
paseMensaje(m);
return m;
}
/* ------------------------------------------------------------------
Acciones de contrato sobre mensajes
------------------------------------------------------------------ */
function terminarCambios() {
if (typeof WAM.guardar === "function") WAM.guardar();
if (typeof WAM.render === "function") WAM.render();
WAM.renderTarjetas();
}
// Nodo de la tarjeta top-level de un id
function tarjetaDe(id) {
var lista = listaNodo();
return (lista && id) ? lista.querySelector('.ed-tarjeta[data-id="' + cssEsc(id) + '"]') : null;
}
// Lleva el foco a la cabecera de la tarjeta (o a su primer campo)
function enfocarTarjeta(id, alCampo) {
var t = tarjetaDe(id);
if (!t) return;
// El primer campo de contenido (texto, cuerpo…), no la hora
// Solo campos visibles: los de media en modo «Subir archivo» ocultan su
// input de URL y focus() sobre un nodo oculto no hace nada (el foco se
// quedaba en la tarjeta anterior).
var visible = function (el) { return !!(el && el.getClientRects && el.getClientRects().length); };
var primeroVisible = function (sel) {
var cands = t.querySelectorAll(sel);
for (var k = 0; k < cands.length; k++) if (visible(cands[k])) return cands[k];
return null;
};
var objetivo = alCampo ? (primeroVisible(".ed-form textarea, .ed-form input.ed-input") ||
primeroVisible(".ed-cuerpo textarea, .ed-cuerpo input.ed-input") ||
primeroVisible(".ed-form .ed-dropzona")) : null;
if (!objetivo) objetivo = t.querySelector(".ed-cab-principal");
if (t.scrollIntoView) t.scrollIntoView({ block: "nearest", behavior: reducirMovimiento() ? "auto" : "smooth" });
if (objetivo) { try { objetivo.focus({ preventScroll: true }); } catch (e) { objetivo.focus(); } }
}
/* anadirMensaje(tipo, indice?): inserta en indice (por defecto, al final),
abre su tarjeta, enfoca su primer campo y la enlaza con la vista previa. */
WAM.anadirMensaje = function (tipo, indice) {
if (!WAM.estado) return;
if (!Array.isArray(WAM.estado.mensajes)) WAM.estado.mensajes = [];
var mensajes = WAM.estado.mensajes;
var pos = (typeof indice === "number" && indice >= 0 && indice <= mensajes.length) ? indice : mensajes.length;
var m = crearMensaje(esTipoValido(tipo) ? tipo : "text", horaSiguiente(pos - 1));
var copia = mensajes.slice();
copia.splice(pos, 0, m);
snap();
// Alta contractual: setCampo sobre el array completo
WAM.setCampo("mensajes", copia);
abiertas[m.id] = true;
ultimaAbierta = m.id;
WAM.renderTarjetas();
enfocarTarjeta(m.id, true);
enlazarVista(m.id);
};
WAM.moverMensaje = function (i, delta) {
var menssajes = (WAM.estado && WAM.estado.mensajes) || [];
i = Number(i);
var j = i + Number(delta);
if (i < 0 || i >= menssajes.length || j < 0 || j >= menssajes.length) return;
var copia = menssajes.slice();
var tmp = copia[i]; copia[i] = copia[j]; copia[j] = tmp;
snap();
WAM.setCampo("mensajes", copia);
WAM.renderTarjetas();
};
WAM.duplicarMensaje = function (i) {
var mensajes = (WAM.estado && WAM.estado.mensajes) || [];
i = Number(i);
if (i < 0 || i >= mensajes.length) return;
var clon;
try { clon = JSON.parse(JSON.stringify(mensajes[i])); } catch (e) { return; }
reasignarIds(clon); // un solo alocador: clon y respuestas con ids distintos
var copia = mensajes.slice();
copia.splice(i + 1, 0, clon);
snap();
WAM.setCampo("mensajes", copia);
abiertas[clon.id] = true;
ultimaAbierta = clon.id;
WAM.renderTarjetas();
enfocarTarjeta(clon.id, false);
enlazarVista(clon.id);
aviso("Mensaje duplicado");
};
// Sin confirm(): se borra al momento y el aviso ofrece «Deshacer» durante 6 s
WAM.borrarMensaje = function (i) {
var mensajes = (WAM.estado && WAM.estado.mensajes) || [];
i = Number(i);
if (i < 0 || i >= mensajes.length) return;
var tipo = etiquetaTipo(mensajes[i].tipo);
var copia = mensajes.slice();
copia.splice(i, 1);
snap();
WAM.setCampo("mensajes", copia);
WAM.renderTarjetas();
// 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(); }
var puede = typeof WAM.deshacer === "function";
WAM.toast("Mensaje borrado (" + tipo + ")", puede ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 6000 } : null);
};
/* ------------------------------------------------------------------
Contrato: WAM.aDataUrl(archivo) → Promise<dataURL> (FileReader)
------------------------------------------------------------------ */
WAM.aDataUrl = function (archivo) {
return new Promise(function (resolve, reject) {
if (!archivo || typeof FileReader === "undefined") { reject(new Error("sin archivo o sin FileReader")); return; }
var lector = new FileReader();
lector.onload = function () { resolve(String(lector.result)); };
lector.onerror = function () { reject(lector.error || new Error("lectura fallida")); };
lector.readAsDataURL(archivo);
});
};
/* ------------------------------------------------------------------
Contrato: WAM.exportarJSON() — Blob + <a download="wam-demo.json">
------------------------------------------------------------------ */
// "Restaurante Luna" → "restaurante-luna" (sin acentos ni símbolos)
function slug(texto) {
var s = String(texto || "").toLowerCase();
if (s.normalize) s = s.normalize("NFD").replace(/[̀-ͯ]/g, "");
s = s.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
return s;
}
// Nombre del archivo exportado: demo guardada activa › nombre de la cuenta › wam-demo
function nombreExportacion() {
var base = "";
if (typeof WAM.nombreDemoActiva === "function") { try { base = WAM.nombreDemoActiva(); } catch (e) { base = ""; } }
if (!base && WAM.estado && WAM.estado.cuenta) base = WAM.estado.cuenta.nombre;
return (slug(base) || "wam-demo") + ".json";
}
WAM.exportarJSON = function () {
try {
if (typeof WAM.guardarYa === "function") WAM.guardarYa();
var nombre = nombreExportacion();
var datos = JSON.stringify(WAM.estado, null, 2);
var blob = new Blob([datos], { type: "application/json" });
var url = URL.createObjectURL(blob);
var a = h("a", { href: url, download: nombre });
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
aviso("Demo exportada como " + nombre);
} catch (e) {
aviso("No se pudo exportar el JSON", "error");
}
};
// Acepta File, <input type="file"> o el evento change (robustez de integración con D)
function archivoDe(arg) {
if (!arg) return null;
if (arg instanceof File) return arg;
if (arg.files && arg.files[0]) return arg.files[0];
var t = arg.target || arg.currentTarget || arg.srcElement;
if (t && t.files && t.files[0]) return t.files[0];
return null;
}
// Convierte un archivo a dataURL (contrato WAM.aDataUrl) y lo guarda en ruta
function procesarArchivo(ruta, archivo, origenEl) {
if (!ruta || !archivo) return;
WAM.aDataUrl(archivo).then(function (dataURL) {
snap();
WAM.setCampo(ruta, dataURL);
if (ruta === "cuenta.avatar") refrescarAvatarVista();
else repintarDesde(origenEl);
aviso("Archivo cargado como dataURL");
}).catch(function () {
aviso("No se pudo leer el archivo", "error");
});
}
function gestionarArchivo(el) {
var ruta = el.getAttribute("data-media-ruta");
var f = el.files && el.files[0];
if (!ruta || !f) return;
procesarArchivo(ruta, f, el);
el.value = "";
}
/* ------------------------------------------------------------------
Contrato: WAM.importarJSON(archivo) → Promise<void>
parse + normalizar + guardar + re-render total.
Los errores se muestran en el aviso del panel y resuelven igualmente
(sin rechazos sin gestionar → consola limpia, criterio §5.1).
------------------------------------------------------------------ */
WAM.importarJSON = function (archivo) {
return new Promise(function (resolve) {
var f = archivoDe(archivo);
if (!f) { aviso("Selecciona un archivo JSON para importar", "error"); resolve(); return; }
var lector = new FileReader();
lector.onload = function () {
try {
var datos = JSON.parse(String(lector.result));
if (!datos || typeof datos !== "object") throw new Error("no es un objeto");
var previo = WAM.estado;
// El estado se sustituye entero: no se reservan los IDs del vivo
WAM.estado = null;
try {
if (typeof WAM.normalizar === "function") datos = WAM.normalizar(datos);
} finally {
WAM.estado = previo;
}
if (!datos || !Array.isArray(datos.mensajes)) throw new Error("sin mensajes");
snap();
WAM.estado = datos;
if (typeof WAM.guardarYa === "function") WAM.guardarYa();
else if (typeof WAM.guardar === "function") WAM.guardar();
marcarEditado();
try { window.localStorage.removeItem("wam-demo-activa"); } catch (e2) { /* sin LS */ }
if (typeof WAM.setTema === "function") WAM.setTema(WAM.estado.tema);
abiertas = Object.create(null);
ultimaAbierta = null;
insertarTras = null;
if (typeof WAM.render === "function") WAM.render();
WAM.renderEditor();
var n = WAM.estado.mensajes.length;
WAM.toast("Demo importada: " + n + " mensaje" + (n === 1 ? "" : "s"),
typeof WAM.deshacer === "function" ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
var primero = listaNodo() && listaNodo().querySelector(".ed-cab-principal");
if (primero) primero.focus();
} catch (e) {
aviso("El archivo no contiene una demo válida", "error");
}
resolve();
};
lector.onerror = function () { aviso("No se pudo leer el archivo", "error"); resolve(); };
lector.readAsText(f);
});
};
/* ------------------------------------------------------------------
Resumen por tipo para la cabecera de cada tarjeta
------------------------------------------------------------------ */
function resumenDe(m) {
var t = "";
switch (m.tipo) {
case "text": t = m.texto; break;
case "image":
case "video": t = m.caption || (m.src ? "(sin caption)" : "(placeholder, sin caption)"); break;
case "audio": t = (m.voz ? "Nota de voz" : "Audio") + (m.duracion ? " · " + m.duracion : ""); break;
case "document": t = m.filename || "Documento"; break;
case "sticker": t = "Sticker" + (m.src ? "" : " (placeholder)"); break;
case "location": t = m.nombre || m.direccion || "Ubicación"; break;
case "contact": t = m.nombre || "Contacto"; break;
case "template": t = (m.headerTipo === "texto" && m.headerTexto ? m.headerTexto + " · " : "") + (m.body || ""); break;
case "buttons": t = m.body; break;
case "list": t = m.header || m.body; break;
case "cta_url": t = m.body || m.textoBoton; break;
case "carousel": t = ((m.cards || []).length) + " tarjetas" + (m.body ? " · " + m.body : ""); break;
default: t = m.tipo || "";
}
t = String(t === undefined || t === null ? "" : t).replace(/\s+/g, " ").trim();
if (t.length > 44) t = t.slice(0, 43) + "…";
return t || "(vacío)";
}
/* ------------------------------------------------------------------
Validación: lo que la Cloud API rechazaría (docs/research-api.md)
validar(m) → [avisos]. Recorre también las respuestas encadenadas.
------------------------------------------------------------------ */
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 = [];
if (!m || typeof m !== "object") return av;
var p = prefijo || "";
var add = function (t) { av.push(p + t); };
var lim = function (v, max, nombre) { if (largo(v) > max) add(nombre + " supera " + max + " caracteres (" + largo(v) + ")"); };
var cabecera = function () {
if (m.headerTipo === "texto") {
if (vacioTxt(m.headerTexto)) add("La cabecera de texto está vacía");
lim(m.headerTexto, 60, "La cabecera");
}
};
switch (m.tipo) {
case "text":
if (vacioTxt(m.texto)) add("El texto está vacío");
lim(m.texto, 4096, "El texto");
break;
case "image":
case "video":
case "document":
lim(m.caption, 1024, "El caption");
if (m.tipo === "document" && !/\.[a-z0-9]{1,5}$/i.test(String(m.filename || "").trim())) {
add("El nombre de archivo necesita extensión (p. ej. .pdf)");
}
break;
case "location": {
var la = Number(m.latitud), lo = Number(m.longitud);
if (vacioTxt(m.latitud) || isNaN(la) || la < -90 || la > 90) add("Latitud no válida (entre -90 y 90)");
if (vacioTxt(m.longitud) || isNaN(lo) || lo < -180 || lo > 180) add("Longitud no válida (entre -180 y 180)");
break;
}
case "contact":
if (vacioTxt(m.nombre)) add("El contacto necesita un nombre");
break;
case "template": {
cabecera();
if (vacioTxt(m.body)) add("El cuerpo es obligatorio");
lim(m.body, 1024, "El cuerpo");
lim(m.footer, 60, "El pie");
var bt = m.botones || [];
if (bt.length > LIMITES.botonesPlantilla) add("Máximo " + LIMITES.botonesPlantilla + " botones");
// 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) && !telValido(b.url)) add("Botón " + (i + 1) + ": URL no válida (usa https://… o tel:+34…)");
});
break;
}
case "buttons": {
cabecera();
if (vacioTxt(m.body)) add("El cuerpo es obligatorio");
lim(m.body, 1024, "El cuerpo");
lim(m.footer, 60, "El pie");
var bq = m.botones || [];
if (!bq.length) add("Necesita al menos 1 botón");
if (bq.length > LIMITES.botonesRapidos) add("Máximo " + LIMITES.botonesRapidos + " botones rápidos");
var vistos = Object.create(null);
bq.forEach(function (b, i) {
if (vacioTxt(b.texto)) { add("Botón " + (i + 1) + ": sin texto"); return; }
lim(b.texto, 20, "Botón " + (i + 1));
var k = String(b.texto).trim().toLowerCase();
if (vistos[k]) add("Botón " + (i + 1) + ": texto repetido («" + b.texto + "»)");
vistos[k] = true;
});
break;
}
case "list": {
if (vacioTxt(m.body)) add("El cuerpo es obligatorio");
lim(m.body, 4096, "El cuerpo");
lim(m.header, 60, "La cabecera");
lim(m.footer, 60, "El pie");
if (vacioTxt(m.boton)) add("La etiqueta del botón es obligatoria");
lim(m.boton, 20, "La etiqueta del botón");
var secs = m.secciones || [];
var filas = totalFilas(secs);
if (!secs.length) add("Necesita al menos 1 sección");
if (secs.length > LIMITES.secciones) add("Máximo " + LIMITES.secciones + " secciones");
if (!filas) add("La lista no tiene filas");
if (filas > LIMITES.filas) add("Máximo " + LIMITES.filas + " filas en total (" + filas + ")");
secs.forEach(function (s, si) {
if (secs.length > 1 && vacioTxt(s.titulo)) add("Sección " + (si + 1) + ": el título es obligatorio con varias secciones");
lim(s.titulo, 24, "Título de la sección " + (si + 1));
(s.filas || []).forEach(function (f, fi) {
var n = "Fila " + (si + 1) + "." + (fi + 1);
if (vacioTxt(f.titulo)) add(n + ": sin título");
lim(f.titulo, 24, n);
lim(f.descripcion, 72, n + " (descripción)");
});
});
break;
}
case "cta_url":
if (vacioTxt(m.body)) add("El cuerpo es obligatorio");
lim(m.body, 1024, "El cuerpo");
lim(m.footer, 60, "El pie");
if (vacioTxt(m.textoBoton)) add("El texto del botón es obligatorio");
lim(m.textoBoton, 20, "El texto del botón");
if (vacioTxt(m.url)) add("Falta la URL del botón");
else if (!urlValida(m.url)) add("URL no válida (usa https://…)");
break;
case "carousel": {
var cs = m.cards || [];
if (cs.length < LIMITES.tarjetasMin) add("El carrusel necesita al menos " + LIMITES.tarjetasMin + " tarjetas");
if (cs.length > LIMITES.tarjetasMax) add("Máximo " + LIMITES.tarjetasMax + " tarjetas");
lim(m.body, 1024, "El cuerpo");
cs.forEach(function (c, ci) {
var n = "Tarjeta " + (ci + 1);
lim(c.texto, 160, n);
if ((String(c.texto || "").match(/\n/g) || []).length > 2) add(n + ": máximo 2 saltos de línea");
if (vacioTxt(c.textoBoton)) add(n + ": el botón necesita texto");
lim(c.textoBoton, 20, n + " (botón)");
if (!vacioTxt(c.url) && !urlValida(c.url)) add(n + ": URL no válida (usa https://…)");
});
break;
}
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) {
av = av.concat(validar(r, p + "Respuesta de «" + o.etiqueta + "»: ", (profundidad || 0) + 1));
});
});
}
return av;
}
// Elementos pulsables de un mensaje que pueden tener respuestas (sin URL)
function origenesRespuesta(m) {
var out = [];
var agregar = function (etiqueta, x) {
if (!x || !vacioTxt(x.url)) return; // con URL abre el enlace: no hay flujo
out.push({ etiqueta: String(etiqueta || "sin texto"), respuestas: Array.isArray(x.respuestas) ? x.respuestas : [] });
};
if (m.tipo === "template" || m.tipo === "buttons") (m.botones || []).forEach(function (b) { agregar(b.texto, b); });
if (m.tipo === "list") (m.secciones || []).forEach(function (s) { (s.filas || []).forEach(function (f) { agregar(f.titulo, f); }); });
if (m.tipo === "carousel") (m.cards || []).forEach(function (c) { agregar(c.textoBoton, c); });
return out;
}
function contarFlujos(m) {
var o = origenesRespuesta(m);
var con = o.filter(function (x) { return x.respuestas.length > 0; }).length;
return { con: con, sin: o.length - con, total: o.length };
}
// Chips de la cabecera de la tarjeta: ⚠ avisos y ↳ flujos
function chipsDe(m) {
var frag = document.createDocumentFragment();
var av = validar(m);
if (av.length) {
frag.appendChild(h("span", {
class: "ed-chip ed-chip-aviso",
title: av.join("\n"),
"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 : "");
var titulo = f.con + (f.con === 1 ? " flujo con respuesta" : " flujos con respuesta") +
(f.sin ? " · " + f.sin + " sin respuesta" : "");
frag.appendChild(h("span", {
class: "ed-chip ed-chip-flujo" + (f.sin ? " ed-chip-flujo-incompleto" : ""),
title: titulo,
"aria-label": titulo
}, [h("span", { "aria-hidden": "true", text: txt })]));
}
return frag;
}
// Caja de avisos dentro del cuerpo de la tarjeta abierta
function cajaValidacion(m) {
var av = validar(m);
var caja = h("div", { class: "ed-validacion", hidden: av.length ? null : true });
if (av.length) {
caja.appendChild(h("p", { class: "ed-validacion-titulo", text: "La Cloud API rechazaría este mensaje:" }));
var ul = h("ul");
av.slice(0, 8).forEach(function (t) { ul.appendChild(h("li", { text: t })); });
if (av.length > 8) ul.appendChild(h("li", { text: "… y " + (av.length - 8) + " más" }));
caja.appendChild(ul);
}
return caja;
}
// Refresca resumen, chips y avisos de una tarjeta ya pintada (sin repintarla)
function refrescarTarjetaInfo(t) {
if (!t) return;
var ruta = t.getAttribute("data-ruta-msg");
var m = ruta ? rutaGet(ruta) : null;
if (!m) return;
var cab = t.querySelector(":scope > .ed-tarjeta-cab");
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);
if (!t.classList.contains("ed-anidada")) chips.appendChild(chipsDe(m));
}
if (!t.classList.contains("ed-anidada")) {
var vieja = t.querySelector(":scope > .ed-cuerpo > .ed-validacion");
if (vieja) vieja.parentNode.replaceChild(cajaValidacion(m), vieja);
}
}
// Contador global de avisos en la sección Mensajes
function actualizarAvisosGlobal() {
var el = document.getElementById("ed-avisos-global");
if (!el) return;
var mensajes = (WAM.estado && WAM.estado.mensajes) || [];
var total = 0, primero = -1;
mensajes.forEach(function (m, i) {
var n = validar(m).length;
if (n && primero < 0) primero = i;
total += n;
});
el.hidden = !total;
el.setAttribute("data-primero", primero >= 0 ? mensajes[primero].id : "");
el.textContent = "⚠ " + total + (total === 1 ? " aviso" : " avisos");
el.title = total ? "Ir al primer mensaje con avisos (n.º " + (primero + 1) + ")" : "";
}
var tAvisosGlobal = 0;
function programarAvisosGlobal() {
clearTimeout(tAvisosGlobal);
tAvisosGlobal = setTimeout(actualizarAvisosGlobal, 250);
}
/* ------------------------------------------------------------------
Constructores de formulario (nodos ed-*)
------------------------------------------------------------------ */
// opts.max = límite duro (maxlength) · opts.limite = límite oficial SOFT
// (contador ámbar/rojo, no bloquea la escritura) · opts.contar = contador
// también para límites duros.
function edCampo(etiqueta, ruta, valor, opts) {
opts = opts || {};
var maxContador = opts.limite || (opts.contar ? opts.max : 0);
var id = opts.id || nuevoIdCampo();
var input = h("input", {
type: opts.tipo || "text",
class: "ed-input",
value: predeterminado(valor, ""),
id: id,
"data-ruta": ruta,
placeholder: opts.placeholder || "",
maxlength: opts.max || null,
inputmode: opts.inputmode || null,
autocomplete: "off",
spellcheck: opts.spell === false ? "false" : null,
"aria-describedby": maxContador ? id + "-cont" : null
});
return campoEnvuelto(etiqueta, id, input, valor, maxContador, opts);
}
/* El contenedor es un <div>: un <label> envolvente asociaría el clic de
la etiqueta, el contador o cualquier hueco con el PRIMER descendiente
etiquetable (el botón «B» de la barra de formato o «+ Añadir variable»).
La etiqueta es un <label for> que solo apunta al input/textarea. */
function campoEnvuelto(etiqueta, id, control, valor, maxContador, opts) {
var hijos = [h("label", { class: "ed-campo-e", for: id, text: etiqueta })];
if (opts.formato || opts.variables) hijos.push(edBarra(opts));
hijos.push(control);
if (maxContador) {
var cont = edContador(valor, maxContador);
cont.id = id + "-cont";
hijos.push(cont);
}
return h("div", { class: "ed-campo" + (opts.clase ? " " + opts.clase : "") }, hijos);
}
// Nota: textarea no admite value como atributo; se asigna texto al construirlo:
function edTextarea(etiqueta, ruta, valor, opts) {
opts = opts || {};
var maxContador = opts.limite || (opts.contar ? opts.max : 0);
var id = opts.id || nuevoIdCampo();
var ta = h("textarea", {
class: "ed-input",
id: id,
rows: opts.rows || 2,
maxlength: opts.max || null,
"data-ruta": ruta,
placeholder: opts.placeholder || "",
"aria-describedby": maxContador ? id + "-cont" : null
});
ta.value = predeterminado(valor, "");
return campoEnvuelto(etiqueta, id, ta, valor, maxContador, opts);
}
function edCheck(etiqueta, ruta, marcado, id) {
return h("label", { class: "ed-check" }, [
h("input", { type: "checkbox", id: id || null, "data-ruta": ruta, checked: !!marcado }),
h("span", { text: etiqueta })
]);
}
function edSelect(etiqueta, ruta, valor, opciones, repintar, rol) {
var id = nuevoIdCampo();
var sel = h("select", { class: "ed-input ed-select", id: id, "data-ruta": ruta, "data-repintar": repintar ? "1" : null, "data-rol": rol || null });
opciones.forEach(function (op) {
sel.appendChild(h("option", { value: op[0], text: op[1], selected: op[0] === valor ? true : null }));
});
return h("div", { class: "ed-campo" }, [
h("label", { class: "ed-campo-e", for: id, text: etiqueta }),
sel
]);
}
// Control segmentado Entrada / Salida (sustituye al select binario)
function edSegmentoDir(ruta, valor) {
var nombre = "dir-" + nuevoIdCampo();
var idEt = nombre + "-e";
var op = function (v, texto) {
return h("label", { class: "ed-seg-op" }, [
h("input", { type: "radio", name: nombre, value: v, checked: valor === v, "data-rol": "dir", "data-ruta": ruta }),
h("span", { text: texto })
]);
};
return h("div", { class: "ed-campo ed-campo-seg" }, [
h("span", { class: "ed-campo-e", id: idEt, text: "Dirección" }),
h("div", { class: "ed-seg", role: "radiogroup", "aria-labelledby": idEt }, [
op("in", "Entrada"),
op("out", "Salida")
])
]);
}
function btnMini(texto, titulo, acc, extras, peligro, claseExtra) {
var attrs = {
type: "button",
class: "ed-btn ed-btn-mini" + (peligro ? " ed-btn-peligro" : "") + (claseExtra ? " " + claseExtra : ""),
text: texto,
title: titulo,
"aria-label": titulo,
"data-acc": acc
};
Object.keys(extras || {}).forEach(function (k) { attrs[k] = extras[k]; });
return h("button", attrs);
}
// Asa de arrastre para reordenar (mensajes, botones, filas, secciones, cards)
function edAsa(titulo) {
return h("span", {
class: "ed-asa",
title: titulo || "Arrastra para reordenar",
"aria-hidden": "true"
}, [svgIcono(GRIP)]);
}
/* --- Origen de media: radios URL / Subir archivo + dropzone --- */
function modoMediaDe(valor) {
var v = String(predeterminado(valor, ""));
if (!v) return "archivo";
return v.slice(0, 5) === "data:" ? "archivo" : "url";
}
function edRadiosMedia(ruta, modo) {
var nombre = "media-" + String(ruta).replace(/[^a-zA-Z0-9]+/g, "-");
return h("div", { class: "ed-media-modos", role: "radiogroup", "aria-label": "Origen del archivo" }, [
h("label", { class: "ed-radio" }, [
h("input", { type: "radio", name: nombre, value: "url", checked: modo === "url", "data-rol": "media-modo" }),
h("span", { text: "URL" })
]),
h("label", { class: "ed-radio" }, [
h("input", { type: "radio", name: nombre, value: "archivo", checked: modo === "archivo", "data-rol": "media-modo" }),
h("span", { text: "Subir archivo" })
])
]);
}
// Zona de soltado: clic abre el input de archivo y acepta arrastrar-soltar
function edDropzona(ruta, texto) {
return h("button", {
type: "button",
class: "ed-dropzona",
"data-acc": "drop-clic",
"data-media-ruta": ruta,
title: "Clic para elegir archivo, o arrastra y suelta aquí"
}, [
svgIcono(ICONO_SUBIR),
h("span", { class: "ed-dropzona-texto", text: texto || "Clic o arrastra un archivo aquí" })
]);
}
// Campo de media: radios de origen, dropzone (archivo) y entrada por URL
function edMedia(etiqueta, ruta, valor, opts) {
opts = opts || {};
var modo = modoMediaDe(valor);
var clasesMedia = "ed-media-vista" + (opts.redonda ? " ed-avatar-vista" : "");
var vista;
if (valor) {
if (opts.tipoMedia === "video") {
vista = h("video", { class: clasesMedia, src: valor, muted: true, preload: "metadata", playsinline: true });
} else if (opts.tipoMedia === "audio" || opts.tipoMedia === "doc") {
vista = h("span", { class: clasesMedia + " ed-media-icono", text: opts.tipoMedia === "audio" ? "🎧" : "📄" });
} else {
vista = h("img", { class: clasesMedia, src: valor, alt: "Vista previa" });
}
} else {
var ph = placeholderSvg(etiqueta);
vista = ph
? h("img", { class: clasesMedia + " ed-media-placeholder", src: ph, alt: "" })
: h("span", { class: clasesMedia + " ed-media-vacio", text: "—" });
}
if (opts.idVista) vista.setAttribute("id", opts.idVista);
var inputUrl = h("input", {
type: "text",
class: "ed-input",
value: predeterminado(valor, ""),
"data-ruta": ruta,
id: opts.idUrl || null,
"aria-label": "URL de " + String(etiqueta).toLowerCase(),
placeholder: "Pega una URL / dataURL",
autocomplete: "off",
spellcheck: "false"
});
var inputArchivo = h("input", {
type: "file",
class: "ed-archivo",
accept: opts.aceptar || "",
"data-media-ruta": ruta,
id: opts.idArchivo || null,
"aria-label": "Subir " + etiqueta.toLowerCase()
});
return h("div", {
class: "ed-media " + (modo === "url" ? "ed-modo-url" : "ed-modo-archivo") + (opts.redonda ? " ed-avatar" : "")
}, [
vista,
h("div", { class: "ed-media-datos" }, [
h("span", { class: "ed-campo-e", text: etiqueta }),
edRadiosMedia(ruta, modo),
h("div", { class: "ed-media-panel ed-panel-url" }, [inputUrl]),
h("div", { class: "ed-media-panel ed-panel-archivo" }, [
inputArchivo,
edDropzona(ruta, opts.textoDrop)
])
])
]);
}
/* --- Sub-editor de respuestas encadenadas (array de Mensaje dir:"in") --- */
// Botones ↑ / ↓ de un elemento de array (botón, fila, sección, card, respuesta)
function movItem(ruta, idx, total, nombre) {
var frag = document.createDocumentFragment();
frag.appendChild(btnMini("↑", "Subir " + nombre, "mover-item",
{ "data-ruta": ruta, "data-idx": idx, "data-delta": -1, disabled: idx <= 0 || null }));
frag.appendChild(btnMini("↓", "Bajar " + nombre, "mover-item",
{ "data-ruta": ruta, "data-idx": idx, "data-delta": 1, disabled: idx >= total - 1 || null }));
return frag;
}
function edRespuestas(ruta, arr, raiz) {
var lista = Array.isArray(arr) ? arr : [];
var hijos = lista.map(function (m, j) {
return edTarjeta(m, { anidado: true, raiz: raiz, rutaMsg: ruta + "." + j, rutaArr: ruta, indice: j, total: lista.length });
});
return h("div", { class: "ed-sub ed-resps" + (lista.length ? "" : " ed-resps-vacias") }, [
h("div", { class: "ed-sub-cab" }, [
h("span", { class: "ed-sub-titulo", text: "↳ Respuestas (" + lista.length + ")" }),
h("button", {
type: "button", class: "ed-btn ed-btn-mini ed-add", text: "+ Respuesta",
"data-acc": "add-resp", "data-ruta": ruta,
title: "Mensaje entrante que llega al pulsar"
})
]),
hijos.length ? h("div", { class: "ed-anidadas" }, hijos)
: h("p", { class: "ed-nota", text: "Sin respuesta: al pulsarlo en el teléfono solo sale la burbuja con su texto." })
]);
}
/* --- Sub-editor de botones (template con URL / buttons sin URL) --- */
function edListaBotones(ruta, botones, o) {
var lista = Array.isArray(botones) ? botones : [];
var hijos = lista.map(function (b, i) {
return h("div", { class: "ed-item", "data-drag-ruta": ruta, "data-idx": i }, [
h("div", { class: "ed-sub-cab" }, [
edAsa("Arrastra para reordenar " + o.etiqueta.toLowerCase() + "s"),
h("span", { class: "ed-sub-titulo", text: o.etiqueta + " " + (i + 1) }),
movItem(ruta, i, lista.length, o.etiqueta.toLowerCase()),
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; tel:+34… llama)", ruta + "." + i + ".url", b.url, { placeholder: "https://…" }) : null,
edCheckAuto(rutaMensajeDe(ruta), i, b),
edRespuestas(ruta + "." + i + ".respuestas", b.respuestas, o.raiz)
]);
});
var lleno = lista.length >= o.max;
return h("div", { class: "ed-sub" }, [
h("div", { class: "ed-sub-cab" }, [
h("span", { class: "ed-sub-titulo", text: "Botones (" + lista.length + "/" + o.max + ")" })
]),
hijos.length ? h("div", { class: "ed-anidadas" }, hijos) : null,
h("button", {
type: "button", class: "ed-btn ed-btn-mini ed-add",
text: "+ Añadir " + o.etiqueta.toLowerCase(),
"data-acc": "add-boton", "data-ruta": ruta, "data-max": o.max, "data-conurl": o.conUrl ? "1" : "0",
disabled: lleno || null, title: lleno ? "Máximo " + o.max : null
})
]);
}
/* --- Sub-editor de secciones/filas de la lista --- */
function totalFilas(secciones) {
return (secciones || []).reduce(function (n, s) { return n + (Array.isArray(s.filas) ? s.filas.length : 0); }, 0);
}
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" }, [
edAsa("Arrastra para reordenar la fila"),
h("span", { class: "ed-sub-titulo", text: "Fila " + (fi + 1) }),
movItem(rutaFilas, fi, filas.length, "fila"),
btnMini("✕", "Quitar fila", "del-fila", { "data-ruta": rutaFilas, "data-idx": fi, "data-min": 1 }, true)
]),
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)
]);
});
return h("div", { class: "ed-item", "data-drag-ruta": ruta, "data-idx": si }, [
h("div", { class: "ed-sub-cab" }, [
edAsa("Arrastra para reordenar la sección"),
h("span", { class: "ed-sub-titulo", text: "Sección " + (si + 1) }),
movItem(ruta, si, lista.length, "sección"),
btnMini("✕", "Quitar sección", "del-sec", { "data-ruta": ruta, "data-idx": si, "data-min": 1 }, true)
]),
edCampo("Título de sección", ruta + "." + si + ".titulo", s.titulo, { limite: 24 }),
h("div", { class: "ed-sub" }, [
h("div", { class: "ed-sub-cab" }, [
h("span", { class: "ed-sub-titulo", text: "Filas (" + totalFilas(lista) + "/" + LIMITES.filas + ")" })
]),
filaEls.length ? h("div", { class: "ed-anidadas" }, filaEls) : h("p", { class: "ed-nota", text: "Sin filas." }),
h("button", {
type: "button", class: "ed-btn ed-btn-mini ed-add", text: "+ Fila",
"data-acc": "add-fila", "data-ruta": rutaFilas, "data-max": LIMITES.filas,
disabled: totalFilas(lista) >= LIMITES.filas || null,
title: totalFilas(lista) >= LIMITES.filas ? "Máximo " + LIMITES.filas + " filas en total" : null
})
])
]);
});
return h("div", { class: "ed-sub" }, [
h("div", { class: "ed-sub-cab" }, [
h("span", { class: "ed-sub-titulo", text: "Secciones (" + lista.length + "/" + LIMITES.secciones + ")" })
]),
hijos.length ? h("div", { class: "ed-anidadas" }, hijos) : h("p", { class: "ed-nota", text: "Sin secciones." }),
h("button", {
type: "button", class: "ed-btn ed-btn-mini ed-add", text: "+ Sección",
"data-acc": "add-sec", "data-ruta": ruta, "data-max": LIMITES.secciones,
disabled: lista.length >= LIMITES.secciones || null,
title: lista.length >= LIMITES.secciones ? "Máximo " + LIMITES.secciones : null
})
]);
}
/* --- Sub-editor de tarjetas del carrusel --- */
function edCardsEd(ruta, cards, raiz) {
var lista = Array.isArray(cards) ? cards : [];
var hijos = lista.map(function (c, ci) {
return h("div", { class: "ed-item", "data-drag-ruta": ruta, "data-idx": ci }, [
h("div", { class: "ed-sub-cab" }, [
edAsa("Arrastra para reordenar la tarjeta"),
h("span", { class: "ed-sub-titulo", text: "Tarjeta " + (ci + 1) }),
movItem(ruta, ci, lista.length, "tarjeta"),
btnMini("✕", "Quitar tarjeta", "del-card", { "data-ruta": ruta, "data-idx": ci, "data-min": LIMITES.tarjetasMin }, true)
]),
edMedia("Imagen", ruta + "." + ci + ".media", c.media, { aceptar: "image/*", textoDrop: "Clic o arrastra una imagen aquí" }),
edTextarea("Texto", ruta + "." + ci + ".texto", c.texto, { limite: 160, rows: 2 }),
h("div", { class: "ed-fila" }, [
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)
]);
});
return h("div", { class: "ed-sub" }, [
h("div", { class: "ed-sub-cab" }, [
h("span", { class: "ed-sub-titulo", text: "Tarjetas (" + lista.length + "/" + LIMITES.tarjetasMax + ", mín " + LIMITES.tarjetasMin + ")" })
]),
hijos.length ? h("div", { class: "ed-anidadas" }, hijos) : h("p", { class: "ed-nota", text: "Sin tarjetas." }),
h("button", {
type: "button", class: "ed-btn ed-btn-mini ed-add", text: "+ Tarjeta",
"data-acc": "add-card", "data-ruta": ruta, "data-max": LIMITES.tarjetasMax,
disabled: lista.length >= LIMITES.tarjetasMax || null,
title: lista.length >= LIMITES.tarjetasMax ? "Máximo " + LIMITES.tarjetasMax : null
})
]);
}
/* --- Cabecera común de template y buttons (research-api §6.1/§7.2) ---
Interruptor "Mostrar cabecera": headerTipo "" = oculta. Al activarla
aparecen las mini-tarjetas de tipo (Texto/Imagen/Vídeo/Documento) y
los campos que dependen de cada una (headerTexto o headerMedia). */
function edHeaderBloque(ruta, m, conVariables) {
var tipo = predeterminado(m.headerTipo, "");
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", 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: "Mostrar cabecera" })
])
]));
if (tipo !== "") {
var pal = h("div", { class: "ed-header-tipos", role: "group", "aria-label": "Tipo de cabecera" });
TIPOS_HEADER.forEach(function (t) {
pal.appendChild(h("button", {
type: "button",
class: "ed-tipo-card ed-tipo-card-mini" + (t[0] === tipo ? " ed-seleccionada" : ""),
"data-acc": "header-tipo",
"data-ruta": ruta,
"data-valor": t[0],
"aria-pressed": t[0] === tipo ? "true" : "false",
title: "Cabecera de tipo " + t[1]
}, [svgIcono(ICONOS_TIPO[{ texto: "text", imagen: "image", video: "video", documento: "document" }[t[0]]] || ""), h("span", { class: "ed-tipo-card-e", text: t[1] })]));
});
caja.appendChild(pal);
if (tipo === "texto") {
caja.appendChild(edCampo("Texto de cabecera", ruta + ".headerTexto", m.headerTexto, {
limite: 60, variables: !!conVariables, placeholder: "Nueva promoción"
}));
} else {
if (tipo === "documento") {
// render.js muestra headerTexto como nombre del documento de la cabecera
caja.appendChild(edCampo("Nombre del documento (con extensión; define el icono)", ruta + ".headerTexto", m.headerTexto, {
max: 80, contar: true, placeholder: "catalogo.pdf", spell: false
}));
}
caja.appendChild(edMedia("Media de cabecera", ruta + ".headerMedia", m.headerMedia, {
aceptar: tipo === "imagen" ? "image/*" : (tipo === "video" ? "video/*" : ""),
tipoMedia: tipo === "video" ? "video" : (tipo === "documento" ? "doc" : "img"),
textoDrop: tipo === "video" ? "Clic o arrastra un vídeo aquí"
: (tipo === "documento" ? "Clic o arrastra un documento aquí" : "Clic o arrastra una imagen aquí")
}));
}
} else {
caja.appendChild(h("p", { class: "ed-nota", text: "Sin cabecera. Actívala para añadir texto o media." }));
}
return caja;
}
/* --- Formulario completo por tipo (TODOS los campos del SPEC §1) --- */
/* autoPulsar (núcleo D): opción que «Reproducir demo» pulsa sola tras 1 s. */
function edAutoPulsar(ruta, m) {
var ops = [["", "No: la pulso yo"]];
if (m.tipo === "list") {
var n = 0;
(m.secciones || []).forEach(function (sec) {
((sec && sec.filas) || []).forEach(function (f) {
ops.push([String(n), "Fila " + (n + 1) + (f && f.titulo ? ": " + f.titulo : "")]);
n++;
});
});
} else if (m.tipo === "carousel") {
(m.cards || []).forEach(function (c, j) {
if (c && c.url) return;
ops.push([String(j), "Tarjeta " + (j + 1) + (c && c.textoBoton ? ": " + c.textoBoton : "")]);
});
} else {
(m.botones || []).forEach(function (b, j) {
if (b && b.url) return;
ops.push([String(j), "Botón " + (j + 1) + (b && b.texto ? ": " + b.texto : "")]);
});
}
var v = (m.autoPulsar === undefined || m.autoPulsar === null) ? "" : String(m.autoPulsar);
// 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 👋" }));
break;
case "image":
ap(edMedia("Imagen", ruta + ".src", m.src, { aceptar: "image/*", textoDrop: "Clic o arrastra una imagen aquí" }));
ap(edTextarea("Caption (opcional)", ruta + ".caption", m.caption, { limite: 1024 }));
break;
case "video":
ap(edMedia("Vídeo", ruta + ".src", m.src, { aceptar: "video/*", tipoMedia: "video", textoDrop: "Clic o arrastra un vídeo aquí" }));
ap(edTextarea("Caption (opcional)", ruta + ".caption", m.caption, { limite: 1024 }));
break;
case "audio":
ap(edMedia("Audio (opcional)", ruta + ".src", m.src, { aceptar: "audio/*", tipoMedia: "audio", textoDrop: "Clic o arrastra un audio aquí" }));
ap(edCheck("Es nota de voz (waveform)", ruta + ".voz", !!m.voz));
ap(edCampo("Duración", ruta + ".duracion", m.duracion, { max: 8, placeholder: "0:12" }));
break;
case "document":
ap(edCampo("Nombre de archivo (con extensión; define el icono)", ruta + ".filename", m.filename, { max: 80, contar: true, placeholder: "menu.pdf" }));
ap(edTextarea("Caption (opcional)", ruta + ".caption", m.caption, { limite: 1024 }));
ap(edMedia("Archivo (opcional, no se muestra)", ruta + ".src", m.src, { tipoMedia: "doc", textoDrop: "Clic o arrastra un documento aquí" }));
break;
case "sticker":
ap(edMedia("Sticker (WebP o imagen; sin burbuja de texto)", ruta + ".src", m.src, { aceptar: "image/webp,image/*", textoDrop: "Clic o arrastra una imagen o WebP aquí" }));
break;
case "location":
// Grid de 2 columnas con las 4 coordenadas del lugar
ap(h("div", { class: "ed-grid2" }, [
edCampo("Latitud", ruta + ".latitud", m.latitud, { placeholder: "37.44216" }),
edCampo("Longitud", ruta + ".longitud", m.longitud, { placeholder: "-122.16153" }),
edCampo("Nombre", ruta + ".nombre", m.nombre, { placeholder: "Café Aurora" }),
edCampo("Dirección", ruta + ".direccion", m.direccion, { placeholder: "C/ Mayor 1, Madrid" })
]));
break;
case "contact":
ap(h("div", { class: "ed-fila" }, [
edCampo("Nombre", ruta + ".nombre", m.nombre, { placeholder: "Ana García" }),
edCampo("Teléfono", ruta + ".telefono", m.telefono, { placeholder: "+34 600 123 456" })
]));
ap(h("div", { class: "ed-fila" }, [
edCampo("Email", ruta + ".email", m.email, { placeholder: "[email protected]" }),
edCampo("Empresa", ruta + ".empresa", m.empresa, { placeholder: "Café Aurora" })
]));
break;
case "template": {
ap(edHeaderBloque(ruta, m, true));
ap(edTextarea("Cuerpo", ruta + ".body", m.body, { limite: 1024, rows: 3, formato: true, variables: true }));
ap(edCampo("Pie (footer)", ruta + ".footer", m.footer, { limite: 60 }));
ap(edListaBotones(ruta + ".botones", m.botones, {
max: LIMITES.botonesPlantilla, maxTexto: 25, conUrl: true,
etiqueta: "Botón", ejemplo: "Confirmar", raiz: raiz
}));
break;
}
case "buttons":
ap(edHeaderBloque(ruta, m, false));
ap(edTextarea("Cuerpo", ruta + ".body", m.body, { limite: 1024, rows: 2 }));
ap(edCampo("Pie (footer)", ruta + ".footer", m.footer, { limite: 60 }));
ap(edListaBotones(ruta + ".botones", m.botones, {
max: LIMITES.botonesRapidos, maxTexto: 20, conUrl: false,
etiqueta: "Botón rápido", ejemplo: "Horarios", raiz: raiz
}));
break;
case "list":
ap(edCampo("Cabecera", ruta + ".header", m.header, { limite: 60 }));
ap(edTextarea("Cuerpo", ruta + ".body", m.body, { limite: 4096, rows: 2, formato: true }));
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));
break;
case "cta_url":
ap(edMedia("Cabecera («» = sin cabecera)", ruta + ".headerMedia", m.headerMedia, { aceptar: "image/*", textoDrop: "Clic o arrastra una imagen aquí" }));
ap(edTextarea("Cuerpo", ruta + ".body", m.body, { limite: 1024, rows: 2 }));
ap(edCampo("Pie (footer)", ruta + ".footer", m.footer, { limite: 60 }));
ap(h("div", { class: "ed-fila" }, [
edCampo("Texto del botón", ruta + ".textoBoton", m.textoBoton, { limite: 20 }),
edCampo("URL", ruta + ".url", m.url, { placeholder: "https://…" })
]));
break;
case "carousel":
ap(edTextarea("Cuerpo (opcional)", ruta + ".body", m.body, { limite: 1024, rows: 2 }));
ap(edCardsEd(ruta + ".cards", m.cards, raiz));
break;
default:
ap(h("p", { class: "ed-nota", text: "Tipo desconocido: " + m.tipo }));
}
return frag;
}
/* --- Una tarjeta colapsable (mensaje top-level o respuesta anidada) --- */
function edTarjeta(m, o) {
o = o || {};
var abierta = !!abiertas[m.id];
var rutaMsg = o.rutaMsg || ("mensajes." + o.i);
var esOut = m.dir === "out";
var acciones = [];
var total = o.anidado ? (o.total || 1) : ((WAM.estado && WAM.estado.mensajes) || []).length;
var idx = o.anidado ? o.indice : o.i;
if (!o.anidado) {
// ↑ ↓ 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 {
acciones.push(btnMini("↑", "Subir respuesta", "mover-item", { "data-ruta": o.rutaArr, "data-idx": idx, "data-delta": -1, disabled: idx <= 0 || null }, false, "ed-mov"));
acciones.push(btnMini("↓", "Bajar respuesta", "mover-item", { "data-ruta": o.rutaArr, "data-idx": idx, "data-delta": 1, disabled: idx >= total - 1 || null }, false, "ed-mov"));
acciones.push(btnMini("✕", "Quitar respuesta", "del-resp", { "data-ruta": o.rutaArr, "data-idx": o.indice }, true));
}
var comunes = [];
if (!o.anidado) {
comunes.push(edSegmentoDir(rutaMsg + ".dir", esOut ? "out" : "in"));
comunes.push(edCampo("Hora", rutaMsg + ".hora", m.hora, { max: 5, placeholder: "10:24", inputmode: "numeric", clase: "ed-campo-hora" }));
// El estado (ticks) solo aplica a los salientes (SPEC §1)
if (esOut) {
comunes.push(edSelect("Estado (ticks)", rutaMsg + ".estado",
ESTADOS.some(function (e) { return e[0] === m.estado; }) ? m.estado : "sent",
ESTADOS, true));
}
} else {
// Una respuesta puede ser de cualquier tipo (texto, imagen, lista…)
comunes.push(edSelect("Tipo de respuesta", rutaMsg + ".tipo", m.tipo, TIPOS, false, "tipo-resp"));
}
var cab = h("button", {
type: "button", class: "ed-cab-principal", "data-acc": "toggle",
"aria-expanded": abierta ? "true" : "false",
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)" }, [
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))
]);
return h(o.etiqueta || (o.anidado ? "div" : "li"), {
class: "ed-tarjeta" + (o.anidado ? " ed-anidada" : "") + (abierta ? " ed-abierta" : ""),
"data-id": m.id,
"data-raiz": o.raiz || m.id,
"data-ruta-msg": rutaMsg,
"data-drag-ruta": o.anidado ? null : "mensajes",
"data-mov-ruta": o.anidado ? o.rutaArr : null,
"data-idx": idx
}, [
h("div", { class: "ed-tarjeta-cab" }, [
o.anidado ? null : edAsa("Arrastra para reordenar el mensaje"),
cab,
h("span", { class: "ed-acciones" }, acciones)
]),
h("div", { class: "ed-cuerpo" }, [
o.anidado ? null : cajaValidacion(m),
comunes.length ? h("div", { class: "ed-fila ed-comunes" }, comunes) : null,
h("div", { class: "ed-form" }, edFormTipo(m, rutaMsg, o.raiz || m.id))
])
]);
}
/* ------------------------------------------------------------------
Contrato: WAM.renderTarjetas() — una tarjeta por mensaje en #lista-mensajes
------------------------------------------------------------------ */
function listaNodo() { return document.getElementById("lista-mensajes"); }
WAM.renderTarjetas = function () {
var lista = listaNodo();
if (!lista) return;
lista.classList.add("ed-lista");
var etiquetaItem = (lista.tagName === "OL" || lista.tagName === "UL") ? "li" : "div";
var firma = firmaFoco(lista);
while (lista.firstChild) lista.removeChild(lista.firstChild);
var mensajes = (WAM.estado && Array.isArray(WAM.estado.mensajes)) ? WAM.estado.mensajes : [];
if (insertarTras && !mensajes.some(function (m) { return m.id === insertarTras; })) insertarTras = null;
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" }),
h("p", { text: "Haz clic en un tipo de la paleta para añadirlo, o parte de un escenario ya armado." }),
h("div", { class: "ed-vacio-acciones" }, [
h("button", { type: "button", class: "ed-btn ed-btn-primario", "data-acc": "abrir-escenarios", text: "Empezar con un escenario" }),
h("button", { type: "button", class: "ed-btn", "data-acc": "importar", text: "Importar JSON" })
])
]));
restaurarFoco(lista, firma);
return;
}
mensajes.forEach(function (m, i) {
lista.appendChild(edTarjeta(m, { i: i, raiz: m.id, rutaMsg: "mensajes." + i, etiqueta: etiquetaItem }));
});
restaurarFoco(lista, firma);
};
/* Conserva el foco a través de un repintado: se identifica el control por
sus data-* (ruta, acción, índice) y se vuelve a enfocar su gemelo. */
function firmaFoco(cont) {
var a = document.activeElement;
if (!a || !cont || !cont.contains(a) || a === cont) return null;
var f = {
ruta: a.getAttribute("data-ruta"), acc: a.getAttribute("data-acc"),
idx: a.getAttribute("data-idx"), delta: a.getAttribute("data-delta"),
i: a.getAttribute("data-i"), valor: a.type === "radio" ? a.value : null,
id: (a.closest(".ed-tarjeta") || {}).getAttribute ? a.closest(".ed-tarjeta").getAttribute("data-id") : null,
selIni: typeof a.selectionStart === "number" ? a.selectionStart : null,
selFin: typeof a.selectionEnd === "number" ? a.selectionEnd : null
};
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) + '"]';
if (f.idx !== null) sel += '[data-idx="' + cssEsc(f.idx) + '"]';
if (f.delta !== null) sel += '[data-delta="' + cssEsc(f.delta) + '"]';
if (f.i !== null) sel += '[data-i="' + cssEsc(f.i) + '"]';
if (f.valor !== null) sel += '[value="' + cssEsc(f.valor) + '"]';
var el = sel ? cont.querySelector(sel) : null;
if ((!el || el.disabled) && f.id) {
var t = cont.querySelector('.ed-tarjeta[data-id="' + cssEsc(f.id) + '"]');
el = t ? t.querySelector(".ed-cab-principal") : null;
}
if (!el) return;
try { el.focus({ preventScroll: true }); } catch (e) { el.focus(); }
if (f.selIni !== null && typeof el.setSelectionRange === "function") {
try { el.setSelectionRange(f.selIni, f.selFin); } catch (e) { /* no aplica */ }
}
}
// Repinta solo la tarjeta top-level afectada (cambios estructurales locales)
function repintarTarjeta(raizId) {
var lista = listaNodo();
if (!lista) return;
var mensajes = (WAM.estado && WAM.estado.mensajes) || [];
var idx = -1;
for (var i = 0; i < mensajes.length; i++) if (mensajes[i].id === raizId) { idx = i; break; }
var viejo = lista.querySelector(':scope > [data-id="' + cssEsc(raizId) + '"]');
if (idx < 0 || !viejo) { WAM.renderTarjetas(); return; }
var firma = firmaFoco(viejo);
var etiquetaItem = (lista.tagName === "OL" || lista.tagName === "UL") ? "li" : "div";
var nuevo = edTarjeta(mensajes[idx], { i: idx, raiz: raizId, rutaMsg: "mensajes." + idx, etiqueta: etiquetaItem });
lista.replaceChild(nuevo, viejo);
restaurarFoco(nuevo, firma);
actualizarAvisosGlobal();
}
function repintarDesde(el) {
var t = el && el.closest ? el.closest(".ed-tarjeta") : null;
var raiz = t && (t.getAttribute("data-raiz") || t.getAttribute("data-id"));
if (raiz) repintarTarjeta(raiz);
}
/* ------------------------------------------------------------------
Sección identidad de la cuenta (#editor-cuenta)
------------------------------------------------------------------ */
// Resumen del <summary> de «Cuenta»: avatar + nombre + ✓ (se ve plegado)
function actualizarResumenCuenta() {
var res = document.getElementById("cuenta-resumen");
if (!res) return;
var c = (WAM.estado && WAM.estado.cuenta) || {};
while (res.firstChild) res.removeChild(res.firstChild);
var src = c.avatar || placeholderSvg(c.nombre || "");
if (src) res.appendChild(h("img", { class: "ed-cuenta-mini", src: src, alt: "" }));
res.appendChild(h("span", { class: "ed-cuenta-mini-nombre", text: c.nombre || "Sin nombre" }));
if (c.verificado) res.appendChild(h("span", { class: "ed-cuenta-mini-ok", title: "Cuenta verificada", "aria-label": "verificada", text: "✓" }));
}
function refrescarAvatarVista() {
var img = document.getElementById("cuenta-avatar-vista");
var url = document.getElementById("cuenta-avatar-url");
var avatar = WAM.estado && WAM.estado.cuenta ? predeterminado(WAM.estado.cuenta.avatar, "") : "";
if (img) {
var src = avatar || placeholderSvg("sin avatar");
if (src) { img.setAttribute("src", src); img.classList.remove("ed-media-vacio"); }
}
if (url && url.value !== avatar) url.value = avatar;
}
function renderCuenta(cont) {
// El cromado del bloque (padding, borde, título «Cuenta») lo aporta A
// (.wam-bloque); aquí solo la rejilla de campos.
cont.classList.add("ed-cuenta");
var c = (WAM.estado && WAM.estado.cuenta) || {};
while (cont.firstChild) cont.removeChild(cont.firstChild);
cont.appendChild(h("div", { class: "ed-fila" }, [
edCampo("Nombre", "cuenta.nombre", c.nombre, { id: "cuenta-nombre", max: 50, contar: true, placeholder: "Café Aurora" }),
edCampo("Subtítulo", "cuenta.subtitulo", c.subtitulo, { id: "cuenta-subtitulo", max: 60, contar: true, placeholder: "en línea" })
]));
var avatar = predeterminado(c.avatar, "");
var bloqueAvatar = edMedia("Avatar", "cuenta.avatar", avatar, {
aceptar: "image/*",
redonda: true,
idVista: "cuenta-avatar-vista",
idArchivo: "cuenta-avatar-archivo",
idUrl: "cuenta-avatar-url",
textoDrop: "Clic o arrastra una imagen aquí"
});
var datosAvatar = bloqueAvatar.querySelector(".ed-media-datos");
if (datosAvatar) {
datosAvatar.appendChild(h("button", {
type: "button", class: "ed-btn ed-btn-mini ed-btn-peligro",
text: "Quitar avatar", "data-acc": "avatar-quitar"
}));
}
cont.appendChild(bloqueAvatar);
cont.appendChild(edCheck("Cuenta verificada (badge)", "cuenta.verificado", !!c.verificado, "cuenta-verificado"));
// El tema se cambia con el botón «Tema» de la barra superior (atajo T)
actualizarResumenCuenta();
// <details> «Cuenta»: abierto si la demo está vacía, plegado si ya hay
// mensajes (se edita una vez por demo). Solo se decide la primera vez.
var det = document.getElementById("bloque-cuenta");
if (det && !cuentaDecidida) {
cuentaDecidida = true;
det.open = !((WAM.estado && WAM.estado.mensajes) || []).length;
}
}
/* ------------------------------------------------------------------
Sección alta de mensajes: paleta de tarjetas de tipo (#nuevo-tipo)
+ #nuevo-tipo-valor (hidden) + #btn-anadir
------------------------------------------------------------------ */
/* La paleta es una barra de herramientas: un clic en un tipo AÑADE el
mensaje (sin paso «Añadir»). Roving tabindex: una sola parada de Tab;
flechas, Inicio y Fin mueven el foco; Intro/Espacio añaden.
#nuevo-tipo-valor y data-valor guardan el último tipo usado (SPEC). */
function seleccionarTipo(tipo) {
if (!esTipoValido(tipo)) tipo = "text";
ultimoTipo = tipo;
var oculto = document.getElementById("nuevo-tipo-valor");
if (oculto) oculto.value = tipo;
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++) {
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 = "Repite el último tipo añadido: " + etiquetaTipo(tipo);
btn.hidden = !yaAnadido;
}
}
function construirPaleta(cont, inicial) {
if (cont.getAttribute("data-wam-ed") !== "paleta2" || !cont.firstElementChild) {
while (cont.firstChild) cont.removeChild(cont.firstChild);
cont.setAttribute("data-valor", inicial);
if (cont.getAttribute("role") === "radiogroup") cont.setAttribute("role", "toolbar");
GRUPOS_PALETA.forEach(function (g) {
var items = h("div", { class: "ed-paleta-items" });
var fila = h("div", { class: "ed-paleta-grupo", role: "group", "aria-label": g[0] }, [
h("span", { class: "ed-paleta-titulo", "aria-hidden": "true", text: g[0] }),
items
]);
g[1].forEach(function (tipo) {
items.appendChild(h("button", {
type: "button",
class: "ed-tipo-card",
"data-acc": "anadir-tipo",
"data-tipo": tipo,
tabindex: "-1",
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);
});
cont.setAttribute("data-wam-ed", "paleta2");
cont.addEventListener("keydown", teclaPaleta);
}
seleccionarTipo(inicial);
}
function teclaPaleta(e) {
var pal = e.currentTarget;
var botones = Array.prototype.slice.call(pal.querySelectorAll("[data-tipo]"));
var i = botones.indexOf(document.activeElement);
if (i < 0) return;
var j = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") j = (i + 1) % botones.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") j = (i - 1 + botones.length) % botones.length;
else if (e.key === "Home") j = 0;
else if (e.key === "End") j = botones.length - 1;
if (j < 0) return;
e.preventDefault();
seleccionarTipo(botones[j].getAttribute("data-tipo"));
botones[j].focus();
}
function paletaFoco() {
var pal = document.getElementById("nuevo-tipo");
return pal ? (pal.querySelector('[data-tipo][tabindex="0"]') || pal.querySelector("[data-tipo]")) : null;
}
function indiceDeId(id) {
var ms = (WAM.estado && WAM.estado.mensajes) || [];
for (var i = 0; i < ms.length; i++) if (ms[i].id === id) return i;
return -1;
}
// Dónde entra el siguiente mensaje: tras el «+» pulsado, tras la tarjeta
// abierta más reciente o, si no hay ninguna abierta, al final
function posicionAlta() {
var ms = (WAM.estado && WAM.estado.mensajes) || [];
var i = insertarTras ? indiceDeId(insertarTras) : -1;
if (i < 0 && ultimaAbierta && abiertas[ultimaAbierta]) i = indiceDeId(ultimaAbierta);
return i >= 0 ? i + 1 : ms.length;
}
function pintarDestinoAlta() {
var el = document.getElementById("ed-destino-alta");
if (!el) return;
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"));
} 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");
}
}
function anadirDesdePaleta(tipo) {
yaAnadido = true; // desde ahora se ofrece «+ Otro «X»» (repetir el último)
seleccionarTipo(tipo);
var pos = posicionAlta();
insertarTras = null;
WAM.anadirMensaje(tipo, pos);
}
function tipoSeleccionadoAlta() {
var oculto = document.getElementById("nuevo-tipo-valor");
if (oculto && esTipoValido(oculto.value)) return oculto.value;
var pal = document.getElementById("nuevo-tipo");
if (pal) {
if (pal.tagName === "SELECT") return pal.value;
var v = pal.getAttribute("data-valor");
if (esTipoValido(v)) return v;
}
return "text";
}
function prepararAlta(lista) {
var pal = document.getElementById("nuevo-tipo");
var btn = document.getElementById("btn-anadir");
if (!pal || !btn) {
// Reserva: si A no puso el bloque de alta, lo levanta C
var sec = h("div", { class: "ed-seccion ed-alta" }, [h("h2", { class: "ed-titulo", text: "Añadir mensaje" })]);
if (!pal) {
pal = h("div", { id: "nuevo-tipo", class: "ed-paleta", role: "toolbar", "aria-label": "Añadir un mensaje: elige el tipo" });
}
sec.appendChild(pal);
sec.appendChild(h("input", { type: "hidden", id: "nuevo-tipo-valor", value: "text" }));
if (!btn) btn = h("button", { id: "btn-anadir", type: "button", class: "ed-btn ed-btn-primario", text: "Añadir" });
sec.appendChild(btn);
lista.parentNode.insertBefore(sec, lista);
}
if (pal.tagName === "SELECT") {
// Compatibilidad: si el HTML antiguo dejó el select, se rellena como antes
if (!pal.options.length) {
TIPOS.forEach(function (t) { pal.appendChild(h("option", { value: t[0], text: t[1] })); });
}
} else {
var oculto = document.getElementById("nuevo-tipo-valor");
var inicial = (oculto && oculto.value) || (pal.getAttribute && pal.getAttribute("data-valor")) || "text";
construirPaleta(pal, esTipoValido(inicial) ? inicial : "text");
}
// El estilo de #btn-anadir lo aporta A (.wam-btn-anadir);
// solo queda el cableado del clic, una única vez.
if (!btn.getAttribute("data-wam-ed")) {
btn.setAttribute("data-wam-ed", "1");
btn.addEventListener("click", function () {
anadirDesdePaleta(tipoSeleccionadoAlta());
});
}
seleccionarTipo(tipoSeleccionadoAlta());
pintarDestinoAlta();
}
/* ------------------------------------------------------------------
Enlace editor ↔ vista previa
------------------------------------------------------------------ */
// Centra la burbuja del mensaje id en #chat-cuerpo y la resalta 1,2 s
WAM.enfocarEnChat = function (id) {
if (!id || presentacionActiva()) return false;
var cuerpo = document.getElementById("chat-cuerpo");
if (!cuerpo) return false;
var fila = cuerpo.querySelector('.wa-row[data-id="' + cssEsc(id) + '"]');
if (!fila) return false;
var rc = cuerpo.getBoundingClientRect(), rf = fila.getBoundingClientRect();
if (rc.height > 0 && cuerpo.clientHeight > 0) {
var escala = rc.height / cuerpo.clientHeight || 1; // el iPhone está escalado con transform
var destino = cuerpo.scrollTop + (rf.top - rc.top) / escala - (cuerpo.clientHeight - fila.offsetHeight) / 2;
destino = Math.max(0, destino);
if (typeof cuerpo.scrollTo === "function") cuerpo.scrollTo({ top: destino, behavior: reducirMovimiento() ? "auto" : "smooth" });
else cuerpo.scrollTop = destino;
}
var previos = cuerpo.querySelectorAll(".ed-resaltado");
for (var i = 0; i < previos.length; i++) previos[i].classList.remove("ed-resaltado");
fila.classList.add("ed-resaltado");
clearTimeout(tResalte);
tResalte = setTimeout(function () {
var f = cuerpo.querySelector(".ed-resaltado");
if (f) f.classList.remove("ed-resaltado");
}, 1200);
return true;
};
// Al abrir o enfocar una tarjeta: su burbuja se centra en el teléfono
function enlazarVista(id) {
if (!id) return;
ultimoEnlazado = id;
if (typeof WAM.recordarEnfoque === "function") WAM.recordarEnfoque(id);
if (demoEnCurso()) return;
// tras el repintado del chat (render total o parcial en el siguiente frame)
requestAnimationFrame(function () { WAM.enfocarEnChat(id); });
}
// Desde el chat (Alt+clic / doble clic en una burbuja): abre su tarjeta
WAM.abrirTarjeta = function (id) {
var t = tarjetaDe(id);
if (!t) return false;
abiertas[id] = true;
ultimaAbierta = id;
t.classList.add("ed-abierta");
var cab = t.querySelector(".ed-cab-principal");
if (cab) cab.setAttribute("aria-expanded", "true");
pintarDestinoAlta();
requestAnimationFrame(function () {
enfocarTarjeta(id, false);
t.classList.add("ed-destello");
setTimeout(function () { t.classList.remove("ed-destello"); }, 1200);
});
enlazarVista(id);
return true;
};
/* Reparte las horas en secuencia (+1 min por mensaje) desde la del primero */
function horasEnSecuencia() {
var ms = (WAM.estado && WAM.estado.mensajes) || [];
if (!ms.length) return;
var inicio = sumarMinutos(ms[0].hora, 0) || "10:00";
var copia = ms.map(function (m, i) {
var c = {};
Object.keys(m).forEach(function (k) { c[k] = m[k]; });
c.hora = sumarMinutos(inicio, i);
return c;
});
snap();
WAM.setCampo("mensajes", copia);
WAM.renderTarjetas();
WAM.toast("Horas repartidas desde " + inicio,
typeof WAM.deshacer === "function" ? { accion: { texto: "Deshacer", fn: WAM.deshacer } } : null);
}
/* ------------------------------------------------------------------
Drag & drop nativo: reordenar mensajes (#lista-mensajes) y elementos
internos (botones, secciones, filas, cards). Todo delegado en #panel-
editor; el arrastre solo arranca desde un asa (.ed-asa). Indicador de
inserción: línea azul (ed-drop-antes / ed-drop-despues).
------------------------------------------------------------------ */
function limpiarIndicador() {
if (dropObjetivo) {
dropObjetivo.classList.remove("ed-drop-antes");
dropObjetivo.classList.remove("ed-drop-despues");
dropObjetivo = null;
}
}
function marcarObjetivo(objetivo, tras) {
if (dropObjetivo !== objetivo) limpiarIndicador();
dropObjetivo = objetivo;
dropTras = tras;
objetivo.classList.toggle("ed-drop-antes", !tras);
objetivo.classList.toggle("ed-drop-despues", tras);
}
function marcarZona(zona) {
if (zonaArchivo === zona) return;
if (zonaArchivo) zonaArchivo.classList.remove("ed-dropzona-sobre");
zonaArchivo = zona;
if (zonaArchivo) zonaArchivo.classList.add("ed-dropzona-sobre");
}
function esArrastreArchivos(e) {
var tipos = e.dataTransfer && e.dataTransfer.types;
if (!tipos) return false;
for (var i = 0; i < tipos.length; i++) if (tipos[i] === "Files") return true;
return false;
}
function objetivoDrop(e) {
if (!dragItem || !dragItem.isConnected) return null;
var t = (e.target && e.target.closest) ? e.target.closest("[data-drag-ruta]") : null;
if (!t || t === dragItem) return null;
if (t.getAttribute("data-drag-ruta") !== dragItem.getAttribute("data-drag-ruta")) return null;
if (t.parentNode !== dragItem.parentNode) return null;
return t;
}
function hermanosMismaRuta(item) {
var ruta = item.getAttribute("data-drag-ruta");
var out = [];
var padre = item.parentNode;
for (var n = padre ? padre.firstElementChild : null; n; n = n.nextElementSibling) {
if (n.getAttribute && n.getAttribute("data-drag-ruta") === ruta) out.push(n);
}
return out;
}
function ejecutarReorden(objetivo) {
var ruta = dragItem.getAttribute("data-drag-ruta");
var hermanos = hermanosMismaRuta(dragItem);
var desde = hermanos.indexOf(dragItem);
var hasta = hermanos.indexOf(objetivo) + (dropTras ? 1 : 0);
if (desde < 0 || hasta === desde || hasta === desde + 1) return;
mutarArray(ruta, function (arr) {
if (desde >= arr.length) return null;
var movido = arr.splice(desde, 1)[0];
var destino = hasta > desde ? hasta - 1 : hasta;
if (destino < 0) destino = 0;
if (destino > arr.length) destino = arr.length;
arr.splice(destino, 0, movido);
return arr;
});
}
function soltarDrag() {
if (dragItem) {
dragItem.classList.remove("ed-arrastrando");
dragItem.classList.remove("ed-listo-drag");
dragItem.draggable = false;
}
dragItem = null;
limpiarIndicador();
marcarZona(null);
}
// El arrastre solo se habilita al pulsar sobre un asa (evita secuestrar
// la selección de texto de los inputs).
function alMouseDownPanel(e) {
if (e.button !== 0) return;
var asa = (e.target && e.target.closest) ? e.target.closest(".ed-asa") : null;
if (!asa || !panel.contains(asa)) return;
var item = asa.closest("[data-drag-ruta]");
if (item) {
item.draggable = true;
item.classList.add("ed-listo-drag");
}
}
// Si se suelta el asa sin arrastrar, se deshabilita de nuevo
function alMouseUpPanel() {
if (!panel) return;
var listos = panel.querySelectorAll(".ed-listo-drag");
for (var i = 0; i < listos.length; i++) {
listos[i].classList.remove("ed-listo-drag");
listos[i].draggable = false;
}
}
function alDragStartPanel(e) {
soltarDrag(); // limpia restos de un arrastre anterior
var t = e.target;
if (!t || !t.getAttribute || !t.classList) return;
if (!t.classList.contains("ed-listo-drag") || !t.getAttribute("data-drag-ruta")) {
e.preventDefault(); // solo se arrastra desde el asa
return;
}
dragItem = t;
t.classList.add("ed-arrastrando");
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", t.getAttribute("data-drag-ruta")); } catch (err) { /* sin data */ }
}
}
function alDragOverPanel(e) {
// 1) Arrastre de archivos del sistema: solo las dropzones lo aceptan
var archivos = esArrastreArchivos(e);
var zona = (e.target && e.target.closest) ? e.target.closest(".ed-dropzona") : null;
if (archivos) {
if (zona) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
marcarZona(zona);
} else {
// fuera de una dropzone: se anula para que el navegador no abra el archivo
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "none";
marcarZona(null);
}
return;
}
// 2) Reordenar elementos propios
var objetivo = objetivoDrop(e);
if (!objetivo) { limpiarIndicador(); return; }
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
var rect = objetivo.getBoundingClientRect();
marcarObjetivo(objetivo, e.clientY > rect.top + rect.height / 2);
}
function alDragLeavePanel(e) {
var zona = (e.target && e.target.closest) ? e.target.closest(".ed-dropzona") : null;
if (zona && zona === zonaArchivo) {
var dentro = e.relatedTarget && zona.contains(e.relatedTarget);
if (!dentro) marcarZona(null);
}
}
function alDropPanel(e) {
// 1) Archivos soltados en una dropzone → dataURL vía WAM.aDataUrl
var zona = (e.target && e.target.closest) ? e.target.closest(".ed-dropzona") : null;
if (zona) {
marcarZona(null);
var f = (e.dataTransfer && e.dataTransfer.files) ? e.dataTransfer.files[0] : null;
if (f) {
e.preventDefault();
procesarArchivo(zona.getAttribute("data-media-ruta"), f, zona);
}
return;
}
// 2) Archivos fuera de una dropzone: anular (que el navegador no los abra)
if (esArrastreArchivos(e)) { e.preventDefault(); return; }
// 3) Reordenar
var objetivo = objetivoDrop(e);
if (!objetivo) return;
e.preventDefault();
ejecutarReorden(objetivo);
soltarDrag();
}
function alDragEndPanel() {
soltarDrag();
}
/* ------------------------------------------------------------------
Delegación de eventos: TODO dentro de #panel-editor
------------------------------------------------------------------ */
function opAgregar(el) {
var ruta = el.getAttribute("data-ruta");
var max = Number(el.getAttribute("data-max")) || 0;
var acc = el.getAttribute("data-acc");
var raizMatch = /^mensajes\.(\d+)/.exec(ruta || "");
var raiz = raizMatch ? ((WAM.estado.mensajes || [])[Number(raizMatch[1])] || null) : null;
var nuevaResp = null;
var res = mutarArray(ruta, function (arr) {
if (max && arr.length >= max) { aviso("Máximo " + max, "error"); return null; }
if (acc === "add-resp") {
nuevaResp = mensajeAnidado(raiz ? raiz.hora : null);
abiertas[nuevaResp.id] = true; // se pinta ya desplegada
arr.push(nuevaResp);
}
else if (acc === "add-boton") arr.push(nuevoBoton("Opción " + (arr.length + 1), el.getAttribute("data-conurl") === "1"));
else if (acc === "add-sec") arr.push(nuevaSeccion(arr.length + 1));
else if (acc === "add-fila") arr.push(nuevaFila(arr.length + 1));
else if (acc === "add-card") arr.push(nuevaCard(arr.length + 1));
return arr;
});
if (!res) return;
var lista = listaNodo();
if (nuevaResp) {
// La respuesta nueva está abierta y recibe el foco: se escribe directamente
var t =lista && lista.querySelector('.ed-tarjeta[data-id="' + cssEsc(nuevaResp.id) + '"]');
var ta = t && t.querySelector("textarea");
if (ta) { ta.focus(); ta.select(); }
if (t && t.scrollIntoView) t.scrollIntoView({ block: "nearest" });
return;
}
// Foco al primer campo del elemento recién añadido (último de su lista)
var items = lista ? lista.querySelectorAll('.ed-item[data-drag-ruta="' + cssEsc(ruta) + '"]') : [];
var ultimo = items.length ? items[items.length - 1] : null;
var campo = ultimo && ultimo.querySelector("input.ed-input, textarea");
if (campo) { campo.focus(); if (campo.select) campo.select(); }
if (ultimo && ultimo.scrollIntoView) ultimo.scrollIntoView({ block: "nearest" });
}
/* Mueve el elemento idx de la lista ruta en delta (botones ↑/↓ y Alt+↑/↓).
El foco sigue al elemento movido. */
function opMoverItem(ruta, idx, delta, enfocar) {
var destino = idx + delta;
var res = mutarArray(ruta, function (arr) {
if (idx < 0 || idx >= arr.length || destino < 0 || destino >= arr.length) return null;
var tmp = arr[idx]; arr[idx] = arr[destino]; arr[destino] = tmp;
return arr;
});
if (!res || !enfocar) return;
var lista = listaNodo();
var sel = '[data-acc="mover-item"][data-ruta="' + cssEsc(ruta) + '"][data-idx="' + destino + '"][data-delta="' + delta + '"]';
var b = lista && lista.querySelector(sel);
if (b && b.disabled) b = lista.querySelector('[data-acc="mover-item"][data-ruta="' + cssEsc(ruta) + '"][data-idx="' + destino + '"][data-delta="' + (-delta) + '"]');
if (enfocar === "item") {
var it = lista && (lista.querySelector('[data-drag-ruta="' + cssEsc(ruta) + '"][data-idx="' + destino + '"]') ||
lista.querySelector('[data-mov-ruta="' + cssEsc(ruta) + '"][data-idx="' + destino + '"]'));
var foco = it && (it.querySelector(".ed-cab-principal") || it.querySelector("input.ed-input, textarea"));
if (foco) { foco.focus(); return; }
}
if (b) b.focus();
}
function moverMensajeConFoco(i, delta) {
var ms = (WAM.estado && WAM.estado.mensajes) || [];
var j = i + delta;
if (j < 0 || j >= ms.length) return;
var id = ms[i].id;
WAM.moverMensaje(i, delta);
var t = tarjetaDe(id);
if (!t) return;
var b = t.querySelector(':scope > .ed-tarjeta-cab [data-acc="mover"][data-delta="' + delta + '"]');
if (!b || b.disabled) b = t.querySelector(":scope > .ed-tarjeta-cab .ed-cab-principal");
if (b) b.focus();
enlazarVista(id);
}
function opEliminar(el) {
var ruta = el.getAttribute("data-ruta");
var idx = Number(el.getAttribute("data-idx"));
var min = Number(el.getAttribute("data-min")) || 0;
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) {
var media = el.closest ? el.closest(".ed-media") : null;
if (!media) return;
var esUrl = el.value === "url";
media.classList.toggle("ed-modo-url", esUrl);
media.classList.toggle("ed-modo-archivo", !esUrl);
}
function alClic(e) {
var el = e.target && e.target.closest ? e.target.closest("[data-acc]") : null;
if (!el || !panel.contains(el)) return;
switch (el.getAttribute("data-acc")) {
case "toggle": {
var t = el.closest(".ed-tarjeta");
if (!t) return;
var id = t.getAttribute("data-id");
abiertas[id] = !abiertas[id];
t.classList.toggle("ed-abierta", abiertas[id]);
el.setAttribute("aria-expanded", abiertas[id] ? "true" : "false");
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;
pintarDestinoAlta();
}
if (abiertas[id]) enlazarVista(raizT);
return;
}
case "mover": moverMensajeConFoco(Number(el.getAttribute("data-i")), Number(el.getAttribute("data-delta"))); return;
case "mover-item":
opMoverItem(el.getAttribute("data-ruta"), Number(el.getAttribute("data-idx")), Number(el.getAttribute("data-delta")), "boton");
return;
case "insertar-debajo": {
insertarTras = el.getAttribute("data-id");
pintarDestinoAlta();
var pf = paletaFoco();
var alta = document.querySelector(".wam-alta") || (pf && pf.parentNode);
if (alta && alta.scrollIntoView) alta.scrollIntoView({ block: "nearest", behavior: reducirMovimiento() ? "auto" : "smooth" });
if (pf) { try { pf.focus({ preventScroll: true }); } catch (err) { pf.focus(); } }
WAM.toast("Elige el tipo del mensaje que irá debajo del n.º " + (Number(el.getAttribute("data-i")) + 1));
return;
}
case "destino-final":
insertarTras = null;
if (ultimaAbierta && abiertas[ultimaAbierta]) {
abiertas[ultimaAbierta] = false;
var ta = tarjetaDe(ultimaAbierta);
if (ta) {
ta.classList.remove("ed-abierta");
var cp = ta.querySelector(".ed-cab-principal");
if (cp) cp.setAttribute("aria-expanded", "false");
}
}
pintarDestinoAlta();
return;
case "anadir-tipo":
anadirDesdePaleta(el.getAttribute("data-tipo"));
return;
case "abrir-escenarios":
if (typeof WAM.abrirDemos === "function") WAM.abrirDemos();
return;
case "importar": {
var bi = document.getElementById("btn-importar");
if (bi) bi.click();
return;
}
case "horas-secuencia": horasEnSecuencia(); return;
case "plegar-todo": {
var todas = (listaNodo() || document).querySelectorAll(".ed-tarjeta.ed-abierta");
for (var k = 0; k < todas.length; k++) {
abiertas[todas[k].getAttribute("data-id")] = false;
todas[k].classList.remove("ed-abierta");
var c2 = todas[k].querySelector(".ed-cab-principal");
if (c2) c2.setAttribute("aria-expanded", "false");
}
insertarTras = null;
pintarDestinoAlta();
return;
}
case "ir-aviso": {
var idA = el.getAttribute("data-primero");
if (idA && WAM.abrirTarjeta(idA)) {
var tA = tarjetaDe(idA);
var caja = tA && tA.querySelector(".ed-validacion");
if (caja && caja.scrollIntoView) setTimeout(function () { caja.scrollIntoView({ block: "nearest" }); }, 60);
}
return;
}
case "dup": WAM.duplicarMensaje(Number(el.getAttribute("data-i"))); return;
case "borrar": WAM.borrarMensaje(Number(el.getAttribute("data-i"))); return;
case "add-resp":
case "add-boton":
case "add-sec":
case "add-fila":
case "add-card":
opAgregar(el); return;
case "del-resp":
case "del-boton":
case "del-sec":
case "del-fila":
case "del-card":
opEliminar(el); return;
case "avatar-quitar":
snap();
WAM.setCampo("cuenta.avatar", "");
refrescarAvatarVista();
actualizarResumenCuenta();
return;
case "elegir-tipo":
seleccionarTipo(el.getAttribute("data-tipo"));
return;
case "header-tipo": {
var rh = el.getAttribute("data-ruta");
var nuevoTipo = el.getAttribute("data-valor") || "";
if (rh && rutaGet(rh + ".headerTipo") !== nuevoTipo) {
snap();
// Cabecera de documento: headerTexto es el nombre del archivo
var txt = String(rutaGet(rh + ".headerTexto") || "");
if (nuevoTipo === "documento" && !/\.[a-z0-9]{1,5}$/i.test(txt.trim())) {
WAM.setCampo(rh + ".headerTexto", "documento.pdf");
}
WAM.setCampo(rh + ".headerTipo", nuevoTipo);
repintarDesde(el);
}
return;
}
case "fmt": {
var inp = inputDeCampo(el);
if (inp) envolverSeleccion(inp, el.getAttribute("data-fmt") || "*");
return;
}
case "var": {
var inp2 = inputDeCampo(el);
if (inp2) insertarTexto(inp2, "{{" + siguienteVariable(inp2) + "}}");
return;
}
case "drop-clic": {
var media = el.closest(".ed-media");
var inp3 = media ? media.querySelector('input[type="file"]') : null;
if (inp3) inp3.click();
return;
}
default: return;
}
}
function alEscribir(e) {
var el = e.target;
if (!el || !el.matches) return;
if (el.tagName === "SELECT" || el.type === "checkbox" || el.type === "file" || el.type === "radio") return;
var ruta = el.getAttribute("data-ruta");
if (ruta) {
// Un paso de deshacer por ráfaga de escritura en un mismo campo
var ahora = Date.now();
if (ruta !== rutaUndo || ahora - tUndo > 1500) snap();
rutaUndo = ruta;
tUndo = ahora;
WAM.setCampo(ruta, el.value, { diferido: true });
actualizarContador(el);
// Cabecera de la tarjeta (resumen, ⚠, ↳) sin repintar el formulario
var propia = el.closest(".ed-tarjeta");
var raizT = el.closest(".ed-tarjeta:not(.ed-anidada)");
if (propia) refrescarTarjetaInfo(propia);
if (raizT && raizT !== propia) refrescarTarjetaInfo(raizT);
if (raizT) programarAvisosGlobal();
if (ruta.indexOf("cuenta.") === 0) actualizarResumenCuenta();
}
}
function alCambiar(e) {
var el = e.target;
if (!el || !el.matches) return;
if (el.type === "file") { gestionarArchivo(el); return; }
if (el.type === "radio") {
if (el.getAttribute("data-rol") === "media-modo") { alternarModoMedia(el); return; }
if (el.getAttribute("data-rol") === "dir" && el.checked) {
snap();
WAM.setCampo(el.getAttribute("data-ruta"), el.value === "out" ? "out" : "in");
repintarDesde(el); // aparece / desaparece «Estado (ticks)»
}
return;
}
if (el.tagName === "SELECT") {
var ruta = el.getAttribute("data-ruta");
if (el.getAttribute("data-rol") === "tipo-resp") { cambiarTipoRespuesta(ruta, el.value, el); return; }
snap();
if (ruta && /\.autoPulsar$/.test(ruta)) {
// SPEC §1: entero ≥ 0; vacío = sin pulsación automática (se omite)
var nAuto = el.value === "" ? NaN : Number(el.value);
if (isFinite(nAuto) && nAuto >= 0) WAM.setCampo(ruta, Math.floor(nAuto));
else {
var padreAuto = rutaGet(ruta.replace(/\.autoPulsar$/, ""));
if (padreAuto && typeof padreAuto === "object") delete padreAuto.autoPulsar;
WAM.setCampo(ruta.replace(/\.autoPulsar$/, ""), padreAuto);
}
} else if (ruta) WAM.setCampo(ruta, el.value);
if (el.hasAttribute("data-repintar")) repintarDesde(el);
else { var tc = el.closest(".ed-tarjeta:not(.ed-anidada)"); if (tc) refrescarTarjetaInfo(tc); }
return;
}
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" : "");
repintarDesde(el);
return;
}
var r = el.getAttribute("data-ruta");
if (r) WAM.setCampo(r, el.checked);
if (r && r.indexOf("cuenta.") === 0) actualizarResumenCuenta();
var tk = el.closest(".ed-tarjeta:not(.ed-anidada)");
if (tk) refrescarTarjetaInfo(tk);
return;
}
// Al salir de un input de texto: si era la URL del avatar, refresca la vista
if (el.getAttribute("data-ruta") === "cuenta.avatar") { refrescarAvatarVista(); actualizarResumenCuenta(); }
}
// Cambia el tipo de una respuesta encadenada conservando id, dir y hora
function cambiarTipoRespuesta(ruta, tipo, el) {
// El <select> lleva data-ruta="….respuestas.N.tipo": se sustituye el
// mensaje completo (….respuestas.N), no su campo «tipo»
ruta = String(ruta || "").replace(/\.tipo$/, "");
var viejo = rutaGet(ruta);
if (!viejo || !esTipoValido(tipo) || viejo.tipo === tipo) return;
var m = crearMensaje(tipo, viejo.hora);
m.id = viejo.id;
m.dir = "in";
if (tipo === "text" && viejo.texto) m.texto = viejo.texto;
snap();
abiertas[m.id] = true;
WAM.setCampo(ruta, m);
repintarDesde(el);
}
// Alt + ↑ / ↓ con el foco dentro de un mensaje o elemento: lo mueve
function alTeclaPanel(e) {
if (!e.altKey || e.ctrlKey || e.metaKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
var delta = e.key === "ArrowUp" ? -1 : 1;
var t = e.target;
var item = t && t.closest ? t.closest("[data-mov-ruta], .ed-item[data-drag-ruta], .ed-tarjeta[data-drag-ruta]") : null;
if (!item || !panel.contains(item)) return;
e.preventDefault();
if (item.getAttribute("data-drag-ruta") === "mensajes") {
moverMensajeConFoco(Number(item.getAttribute("data-idx")), delta);
return;
}
var ruta = item.getAttribute("data-mov-ruta") || item.getAttribute("data-drag-ruta");
opMoverItem(ruta, Number(item.getAttribute("data-idx")), delta, "item");
}
// Enfocar una tarjeta (o un campo dentro) también centra su burbuja
function alFocoPanel(e) {
var t = e.target && e.target.closest ? e.target.closest(".ed-tarjeta:not(.ed-anidada)") : null;
if (!t) return;
var id = t.getAttribute("data-id");
if (id && id !== ultimoEnlazado) enlazarVista(id);
}
function enlazarPanel() {
if (panelEnlazado || !panel) return;
panelEnlazado = true;
panel.addEventListener("click", alClic);
panel.addEventListener("input", alEscribir);
panel.addEventListener("change", alCambiar);
panel.addEventListener("keydown", alTeclaPanel);
panel.addEventListener("focusin", alFocoPanel);
// Drag & drop (reordenar + dropzones de archivos)
panel.addEventListener("mousedown", alMouseDownPanel);
panel.addEventListener("mouseup", alMouseUpPanel);
panel.addEventListener("dragstart", alDragStartPanel);
// dragenter también se cancela: Safari/WebKit (spec HTML) descarta el
// soltado si el último dragenter no se canceló, aunque sí lo fuera el dragover
panel.addEventListener("dragenter", alDragOverPanel);
panel.addEventListener("dragover", alDragOverPanel);
panel.addEventListener("dragleave", alDragLeavePanel);
panel.addEventListener("drop", alDropPanel);
panel.addEventListener("dragend", alDragEndPanel);
}
/* ------------------------------------------------------------------
Contrato: WAM.renderEditor() — formulario de cuenta + tarjetas
------------------------------------------------------------------ */
function asegurarPanel() {
var p = document.getElementById("panel-editor");
if (!p) {
p = document.createElement("aside");
p.id = "panel-editor";
(document.getElementById("app") || document.body).appendChild(p);
}
return p;
}
WAM.renderEditor = function () {
panel = asegurarPanel();
enlazarPanel();
var cuenta = document.getElementById("editor-cuenta");
if (!cuenta) {
// Reserva: si A no está, el cromado lo pone .ed-seccion
cuenta = document.createElement("section");
cuenta.id = "editor-cuenta";
cuenta.className = "ed-seccion";
panel.insertBefore(cuenta, panel.firstChild);
}
var lista = listaNodo();
if (!lista) {
lista = document.createElement("ol");
lista.id = "lista-mensajes";
lista.className = "ed-seccion ed-lista";
panel.appendChild(lista);
}
cancelarRenderPendiente();
renderCuenta(cuenta);
prepararAlta(lista);
WAM.renderTarjetas();
// Capa de UX de la app (js/ux.js): se inicializa una sola vez
if (typeof WAM.initUX === "function") {
try { WAM.initUX(); } catch (e) { /* la capa de UX es opcional */ }
}
};
})();