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;
}
// Se lee ya: cualquier normalización posterior sobrescribe el resumen
var recortes = typeof WAM.describirRecortes === "function" && datos ? WAM.describirRecortes(datos.__recortes) : "";
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(recortes ? recortes + "." : "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(); }
// Con esquema http(s) o con forma de dominio («www.cafe.com», «cafe.com/menu»):
// render.js abre estas últimas con https://, igual que WhatsApp.
function urlValida(v) {
var s = String(v || "").trim();
return /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(s) ||
/^www\.[^\s]+$/i.test(s) || /^[\w-]+(\.[\w-]+)+(:\d+)?([\/?#][^\s]*)?$/.test(s);
}
// 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 : un 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 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(edCampo("Tamaño (opcional, p. ej. 243 KB)", ruta + ".headerTamano", m.headerTamano, {
max: 20, placeholder: "243 KB", 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(edCampo("Tamaño (opcional, p. ej. 243 KB)", ruta + ".tamano", m.tamano, { max: 20, placeholder: "243 KB", spell: false }));
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: "ana@cafeaurora.es" }),
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 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();
// «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 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 */ }
}
};
})();