2842 lines
124 KiB
JavaScript
2842 lines
124 KiB
JavaScript
/* =====================================================================
|
||
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 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 a la izquierda, fuera de la zona con
|
||
scroll del panel. opts: { tipo: "error", ms, accion: { texto, fn } }.
|
||
Devuelve una función que lo cierra. */
|
||
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);
|
||
// Como mucho 2 a la vista: primero se retiran los que no tienen acción
|
||
var hijos = Array.prototype.slice.call(c.children);
|
||
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);
|
||
}
|
||
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;
|
||
}
|
||
|
||
// Reemplaza un array completo vía setCampo (mutación contractual) y repinta tarjetas
|
||
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();
|
||
WAM.setCampo(ruta, copia);
|
||
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 tarjeta vecina (la siguiente o, si era la última, la anterior)
|
||
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()); }
|
||
|
||
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");
|
||
var conUrl = bt.filter(function (b) { return b && !vacioTxt(b.url); }).length;
|
||
if (conUrl > 2) add("Máximo 2 botones con URL (" + conUrl + ")");
|
||
bt.forEach(function (b, i) {
|
||
if (vacioTxt(b.texto)) add("Botón " + (i + 1) + ": sin texto");
|
||
else lim(b.texto, 25, "Botón " + (i + 1));
|
||
if (!vacioTxt(b.url) && !urlValida(b.url)) add("Botón " + (i + 1) + ": URL no válida (usa https://…)");
|
||
});
|
||
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;
|
||
}
|
||
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 })]));
|
||
}
|
||
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 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)", ruta + "." + i + ".url", b.url, { placeholder: "https://…" }) : null,
|
||
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 hijos = lista.map(function (s, si) {
|
||
var filas = Array.isArray(s.filas) ? s.filas : [];
|
||
var rutaFilas = ruta + "." + si + ".filas";
|
||
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 }),
|
||
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://…" })
|
||
]),
|
||
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" }),
|
||
h("label", { class: "ed-switch" }, [
|
||
h("input", { type: "checkbox", "data-rol": "header-toggle", "data-ruta": ruta, checked: tipo !== "" }),
|
||
h("span", { class: "ed-switch-pista" }, [h("span", { class: "ed-switch-nudo" })]),
|
||
h("span", { class: "ed-switch-texto", text: tipo === "" ? "Mostrar cabecera" : "Ocultar cabecera" })
|
||
])
|
||
]));
|
||
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);
|
||
return edSelect("En «Reproducir demo», pulsar sola", ruta + ".autoPulsar", v, ops, false);
|
||
}
|
||
|
||
function edFormTipo(m, ruta, raiz) {
|
||
var frag = document.createDocumentFragment();
|
||
var ap = function (n) { if (n) frag.appendChild(n); };
|
||
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
|
||
}));
|
||
ap(edAutoPulsar(ruta, m));
|
||
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
|
||
}));
|
||
ap(edAutoPulsar(ruta, m));
|
||
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));
|
||
ap(edAutoPulsar(ruta, m));
|
||
break;
|
||
case "cta_url":
|
||
ap(edMedia("Cabecera («» = sin cabecera)", ruta + ".headerMedia", m.headerMedia, { aceptar: "image/*", textoDrop: "Clic o arrastra una imagen aquí" }));
|
||
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));
|
||
ap(edAutoPulsar(ruta, m));
|
||
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) {
|
||
acciones.push(btnMini("+", "Insertar un mensaje debajo", "insertar-debajo", { "data-i": o.i, "data-id": m.id }));
|
||
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("⧉", "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"
|
||
}, [
|
||
o.anidado ? h("span", { class: "ed-num ed-num-resp", text: "↳" })
|
||
: h("span", { class: "ed-num ed-num-" + (esOut ? "out" : "in"), title: esOut ? "Salida (enviado)" : "Entrada (recibido)", text: String(o.i + 1) }),
|
||
o.anidado ? null : h("span", { class: "ed-dir ed-dir-" + (esOut ? "out" : "in"), text: esOut ? "Salida" : "Entrada" }),
|
||
h("span", { class: "ed-badge", text: CORTO[m.tipo] || etiquetaTipo(m.tipo) }),
|
||
h("span", { class: "ed-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();
|
||
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;
|
||
}
|
||
|
||
function restaurarFoco(cont, f) {
|
||
if (!f || !cont) 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);
|
||
var hijos = pal.querySelectorAll("[data-tipo]");
|
||
for (var i = 0; i < hijos.length; i++) {
|
||
var activo = hijos[i].getAttribute("data-tipo") === tipo;
|
||
hijos[i].classList.toggle("ed-seleccionada", activo);
|
||
hijos[i].tabIndex = activo ? 0 : -1;
|
||
}
|
||
}
|
||
var btn = document.getElementById("btn-anadir");
|
||
if (btn) {
|
||
btn.textContent = "+ Otro «" + etiquetaTipo(tipo) + "»";
|
||
btn.title = "Añade otro mensaje de tipo " + etiquetaTipo(tipo);
|
||
}
|
||
}
|
||
|
||
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)
|
||
}, [svgIcono(ICONOS_TIPO[tipo] || ""), h("span", { class: "ed-tipo-card-e", text: CORTO[tipo] || etiquetaTipo(tipo) })]));
|
||
});
|
||
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();
|
||
if (pos >= ms.length) {
|
||
el.appendChild(document.createTextNode(ms.length ? "Se añade al final" : "Elige un tipo para empezar"));
|
||
return;
|
||
}
|
||
el.appendChild(document.createTextNode("Se inserta debajo del n.º " + pos + " "));
|
||
el.appendChild(h("button", { type: "button", class: "ed-enlace", "data-acc": "destino-final", text: "Al final" }));
|
||
}
|
||
|
||
function anadirDesdePaleta(tipo) {
|
||
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 las " + inicio + " (+1 min por mensaje)",
|
||
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;
|
||
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;
|
||
});
|
||
}
|
||
|
||
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");
|
||
el.title = abiertas[id] ? "Plegar" : "Desplegar";
|
||
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") === "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 */ }
|
||
}
|
||
};
|
||
})();
|