Render: chat pegado abajo, hojas sin desfase, separadores y fidelidad iOS
- scrollAbajo instantáneo + ResizeObserver 'pegado abajo' (imágenes tardías, pestaña oculta) - Hoja de opciones busca la fila viva; re-render de su mensaje cierra la hoja - Separadores de filas de la hoja, proporciones de lista, un solo separador entre grupos - Home indicator sobre la hoja, composer oscuro #232626 con hairline, padding 7/12/6 - URLs sin puntuación final, www. -> https://, negrita/cursiva/tachado con reglas de WhatsApp - Documento: 'tamaño • ext' o 'PDF'; transición de color al cambiar tema - Presentación sin toolbar en escritorio (Esc para salir), hooks de zoom, móvil sin marco Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9a5f21badf
commit
50a64f21e3
+127
-24
@@ -29,11 +29,15 @@
|
||||
function estado() { return WAM.estado || { cuenta: {}, mensajes: [] }; }
|
||||
|
||||
var ESCRIBIENDO = false;
|
||||
var HOJA_ID = ""; /* id del mensaje cuya hoja está abierta ("" = ninguna) */
|
||||
|
||||
/* Solo esquemas seguros en href (defensa en profundidad para JSON importados). */
|
||||
function urlSegura(u) {
|
||||
var s = String(u == null ? "" : u).trim();
|
||||
return /^(https?:|mailto:|tel:)/i.test(s) ? s : "";
|
||||
if (/^(https?:|mailto:|tel:)/i.test(s)) return s;
|
||||
/* Sin esquema ("www.cafe.com", "cafe.com/menu"): WhatsApp lo abre como https */
|
||||
if (/^www\.[^\s]+$/i.test(s) || /^[\w-]+(\.[\w-]+)+(:\d+)?([\/?#][^\s]*)?$/.test(s)) return "https://" + s;
|
||||
return "";
|
||||
}
|
||||
|
||||
/* src de <img>/<video>: bloquea javascript:/vbscript: y data: que no sea media. */
|
||||
@@ -202,27 +206,61 @@
|
||||
/* Formato WhatsApp: *negrita* _cursiva_ ~tachado~ ```bloque``` `en línea`
|
||||
+ URLs hipervinculadas. Dentro del código no se aplica ningún otro formato. */
|
||||
|
||||
/* Marcadores de WhatsApp: el marcador va pegado a texto (sin espacio por
|
||||
dentro) y por fuera no puede haber letra/número ("2 * 3 * 4" no es negrita). */
|
||||
function reMarca(m) {
|
||||
var e = m === "*" ? "\\*" : m; /* con flag u, "\_" y "\~" son escapes inválidos */
|
||||
return new RegExp("(^|[^\\p{L}\\p{N}_" + e + "])" + e + "(?=\\S)([^" + e + "\\n]*?\\S)" + e +
|
||||
"(?=[^\\p{L}\\p{N}_" + e + "]|$)", "gu");
|
||||
}
|
||||
var RE_NEGRITA = reMarca("*"), RE_CURSIVA = reMarca("_"), RE_TACHADO = reMarca("~");
|
||||
|
||||
/* Deja fuera del enlace la puntuación final y los ')' sin su '(' */
|
||||
function separarUrl(u) {
|
||||
var url = u, resto = "";
|
||||
for (;;) {
|
||||
var m = /[.,;:!?'"\]}]+$/.exec(url);
|
||||
if (m) { resto = m[0] + resto; url = url.slice(0, -m[0].length); continue; }
|
||||
if (/\)$/.test(url) && (url.split(")").length > url.split("(").length)) {
|
||||
resto = ")" + resto; url = url.slice(0, -1); continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return [url, resto];
|
||||
}
|
||||
|
||||
function formatoTexto(p) {
|
||||
var codigos = [];
|
||||
var guardar = function (_, c) { codigos.push(c); return "\u0000" + (codigos.length - 1) + "\u0000"; };
|
||||
var t = esc(p.replace(/\u0000/g, ""));
|
||||
t = t.replace(/```([\s\S]+?)```/g, guardar);
|
||||
t = t.replace(/`([^`\n]+)`/g, guardar);
|
||||
t = t.replace(RE_NEGRITA, "$1<strong>$2</strong>");
|
||||
t = t.replace(RE_CURSIVA, "$1<em>$2</em>");
|
||||
t = t.replace(RE_TACHADO, "$1<del>$2</del>");
|
||||
return t.replace(/\u0000(\d+)\u0000/g, function (_, n) { return "<code>" + codigos[+n] + "</code>"; });
|
||||
}
|
||||
|
||||
function formatear(txt) {
|
||||
var partes = String(txt == null ? "" : txt).split(/(https?:\/\/[^\s<>"]+)/g);
|
||||
var html = "";
|
||||
var html = "", pendiente = "";
|
||||
for (var i = 0; i < partes.length; i++) {
|
||||
var p = partes[i];
|
||||
if (p === "") continue;
|
||||
if (/^https?:\/\//.test(p)) {
|
||||
html += '<a class="wa-link" href="' + esc(p) + '" target="_blank" rel="noopener noreferrer">' + esc(p) + "</a>";
|
||||
var par = separarUrl(p);
|
||||
if (pendiente) { html += formatoTexto(pendiente); pendiente = ""; }
|
||||
if (par[0].length > p.indexOf("//") + 2) {
|
||||
html += '<a class="wa-link" href="' + esc(par[0]) + '" target="_blank" rel="noopener noreferrer">' + esc(par[0]) + "</a>";
|
||||
pendiente = par[1];
|
||||
} else {
|
||||
pendiente = p;
|
||||
}
|
||||
} else {
|
||||
var codigos = [];
|
||||
var guardar = function (_, c) { codigos.push(c); return "\u0000" + (codigos.length - 1) + "\u0000"; };
|
||||
var t = esc(p.replace(/\u0000/g, ""));
|
||||
t = t.replace(/```([\s\S]+?)```/g, guardar);
|
||||
t = t.replace(/`([^`\n]+)`/g, guardar);
|
||||
t = t.replace(/\*([^*\n]+)\*/g, "<strong>$1</strong>");
|
||||
t = t.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, "$1<em>$2</em>");
|
||||
t = t.replace(/(^|[^\w])~([^~\n]+)~(?=[^\w]|$)/g, "$1<del>$2</del>");
|
||||
t = t.replace(/\u0000(\d+)\u0000/g, function (_, n) { return "<code>" + codigos[+n] + "</code>"; });
|
||||
html += t;
|
||||
pendiente += p;
|
||||
}
|
||||
}
|
||||
if (pendiente) html += formatoTexto(pendiente);
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -322,18 +360,20 @@
|
||||
derecha + "</div></div>";
|
||||
}
|
||||
|
||||
function panelDocumento(nombre) {
|
||||
/* Subtítulo iOS "tamaño • extensión" (243 KB • pdf); sin tamaño, solo "PDF" */
|
||||
function panelDocumento(nombre, tamano) {
|
||||
var ext = extDe(nombre);
|
||||
var sub = vacio(tamano) ? ext.toUpperCase() : String(tamano) + " • " + ext;
|
||||
return '<div class="doc-panel">' +
|
||||
'<span class="doc-icono">' + docIcon(ext) + "</span>" +
|
||||
'<span class="doc-datos"><span class="doc-nombre">' + esc(nombre) + "</span>" +
|
||||
'<span class="doc-sub">Documento • ' + esc(ext) + "</span></span>" +
|
||||
'<span class="doc-sub">' + esc(sub) + "</span></span>" +
|
||||
'<span class="doc-descarga">' + svgDescarga() + "</span></div>";
|
||||
}
|
||||
|
||||
function burbujaDocumento(m) {
|
||||
var nombre = vacio(m.filename) ? "documento.pdf" : String(m.filename);
|
||||
var html = '<div class="bubble wa-doc">' + panelDocumento(nombre);
|
||||
var html = '<div class="bubble wa-doc">' + panelDocumento(nombre, m.tamano);
|
||||
if (!vacio(m.caption)) html += '<div class="wa-caption">' + formatear(m.caption) + metaHTML(m) + "</div>";
|
||||
else html += '<div class="wa-pie">' + metaHTML(m) + "</div>";
|
||||
return html + "</div>";
|
||||
@@ -436,7 +476,7 @@
|
||||
}
|
||||
if (ht === "documento") {
|
||||
var nom = vacio(m.headerTexto) ? "documento.pdf" : String(m.headerTexto);
|
||||
return '<div class="tpl-header-doc">' + panelDocumento(nom) + "</div>";
|
||||
return '<div class="tpl-header-doc">' + panelDocumento(nom, m.headerTamano) + "</div>";
|
||||
}
|
||||
if (ht === "texto") {
|
||||
return vacio(m.headerTexto) ? "" : '<div class="tpl-header-txt">' + esc(m.headerTexto) + "</div>";
|
||||
@@ -590,6 +630,10 @@
|
||||
if (!vacio(m.id)) li.setAttribute("data-id", String(m.id));
|
||||
li.innerHTML = cuerpo;
|
||||
activarPlegado(li, m);
|
||||
/* Re-render parcial (editor) del mensaje cuya hoja está abierta: la hoja
|
||||
quedaría desfasada (títulos viejos, fila destino fuera del DOM). */
|
||||
if (HOJA_ID && !vacio(m.id) && String(m.id) === HOJA_ID) WAM.cerrarSheet();
|
||||
observarAltura(li);
|
||||
return li;
|
||||
};
|
||||
|
||||
@@ -608,8 +652,10 @@
|
||||
for (var s = 0; s < sis.length; s++) frag.appendChild(sis[s]);
|
||||
for (var i = 0; i < mensajes.length; i++) frag.appendChild(WAM.renderMensaje(mensajes[i], i));
|
||||
cuerpo.innerHTML = "";
|
||||
if (RO) { RO.disconnect(); RO.observe(cuerpo); }
|
||||
if (indEnCuerpo && ind) frag.appendChild(ind); /* conserva el indicador */
|
||||
cuerpo.appendChild(frag);
|
||||
if (RO) for (var k = 0; k < cuerpo.children.length; k++) RO.observe(cuerpo.children[k]);
|
||||
WAM.scrollAbajo();
|
||||
};
|
||||
|
||||
@@ -685,11 +731,58 @@
|
||||
if (ESCRIBIENDO) WAM.scrollAbajo();
|
||||
};
|
||||
|
||||
/* Desplazamiento INSTANTÁNEO al final (un scroll suave se quedaría en el
|
||||
máximo del momento si el contenido crece durante la animación). */
|
||||
WAM.scrollAbajo = function () {
|
||||
var cuerpo = byId("chat-cuerpo");
|
||||
if (cuerpo) cuerpo.scrollTop = cuerpo.scrollHeight;
|
||||
if (!cuerpo) return;
|
||||
vigilarCuerpo(cuerpo);
|
||||
var prev = cuerpo.style.scrollBehavior;
|
||||
cuerpo.style.scrollBehavior = "auto";
|
||||
cuerpo.scrollTop = cuerpo.scrollHeight;
|
||||
cuerpo.style.scrollBehavior = prev;
|
||||
PEGADO = true;
|
||||
};
|
||||
|
||||
/* "Pegado abajo" (como WhatsApp): mientras el usuario no suba, cualquier
|
||||
crecimiento del chat (imágenes/SVG que decodifican tarde, vídeo, la
|
||||
pestaña Vista previa que pasa de oculta a visible, una fila repintada)
|
||||
vuelve a bajar. Si el usuario sube, se respeta su posición. */
|
||||
var PEGADO = true;
|
||||
var RO = null;
|
||||
|
||||
function distanciaFondo(c) { return c.scrollHeight - c.scrollTop - c.clientHeight; }
|
||||
|
||||
function reajustar() {
|
||||
var c = byId("chat-cuerpo");
|
||||
if (c && PEGADO && distanciaFondo(c) > 1) WAM.scrollAbajo();
|
||||
}
|
||||
|
||||
function vigilarCuerpo(cuerpo) {
|
||||
if (cuerpo.__wamVigilado) return;
|
||||
cuerpo.__wamVigilado = true;
|
||||
cuerpo.addEventListener("scroll", function () {
|
||||
if (cuerpo.clientHeight > 0) PEGADO = distanciaFondo(cuerpo) < 60;
|
||||
}, { passive: true });
|
||||
if (typeof ResizeObserver === "function") {
|
||||
RO = new ResizeObserver(reajustar);
|
||||
RO.observe(cuerpo);
|
||||
for (var k = 0; k < cuerpo.children.length; k++) RO.observe(cuerpo.children[k]);
|
||||
}
|
||||
}
|
||||
|
||||
/* Observa la altura de una fila (aunque aún no esté en el DOM). Sin
|
||||
ResizeObserver, recurre al load de sus imágenes/vídeos. */
|
||||
function observarAltura(li) {
|
||||
if (RO) { RO.observe(li); return; }
|
||||
if (typeof ResizeObserver === "function") return; /* aún sin vigilar: vigilarCuerpo lo hará */
|
||||
var medios = li.querySelectorAll("img, video");
|
||||
for (var k = 0; k < medios.length; k++) {
|
||||
medios[k].addEventListener("load", reajustar, { once: true });
|
||||
medios[k].addEventListener("loadedmetadata", reajustar, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* Hoja inferior iOS genérica (B la crea/destruye) en #pantalla.
|
||||
alClic: manejador opcional de presentación para las filas. */
|
||||
function montarHoja(titulo, contenido, pie, alClic) {
|
||||
@@ -741,20 +834,26 @@
|
||||
}
|
||||
if (!total) html = '<div class="sheet-vacio">Sin opciones</div>';
|
||||
montarHoja(titulo, html, total ? "Toca un elemento para seleccionarlo" : "");
|
||||
HOJA_ID = vacio(m.id) ? "" : String(m.id);
|
||||
};
|
||||
|
||||
/* Hoja "Todas las opciones" de un mensaje con >3 botones (m = Mensaje o
|
||||
índice; fila = su li.wa-row). URL/llamada primero, separador, respuestas.
|
||||
Pulsar una respuesta reenvía el clic al botón [data-btn] (oculto) de la
|
||||
burbuja, así main.js/interactivity.js lo atienden igual que en línea. */
|
||||
/* Última li.wa-row pintada de ese mensaje (o null) */
|
||||
function filaDe(msg) {
|
||||
if (!msg || vacio(msg.id)) return null;
|
||||
var filas = document.querySelectorAll("#chat-cuerpo .wa-row");
|
||||
for (var k = filas.length - 1; k >= 0; k--) if (filas[k].getAttribute("data-id") === String(msg.id)) return filas[k];
|
||||
return null;
|
||||
}
|
||||
|
||||
WAM.abrirOpciones = function (m, fila) {
|
||||
var mensajes = estado().mensajes || [];
|
||||
var msg = (m && typeof m === "object" ? m : (typeof m === "number" ? mensajes[m] : null)) || {};
|
||||
var li = fila || null;
|
||||
if (!li && msg.id) {
|
||||
var filas = document.querySelectorAll("#chat-cuerpo .wa-row");
|
||||
for (var k = filas.length - 1; k >= 0 && !li; k--) if (filas[k].getAttribute("data-id") === String(msg.id)) li = filas[k];
|
||||
}
|
||||
if (!li) li = filaDe(msg);
|
||||
var bs = Array.isArray(msg.botones) ? msg.botones.slice(0, 10) : [];
|
||||
var enlaces = "", respuestas = "";
|
||||
for (var j = 0; j < bs.length; j++) {
|
||||
@@ -773,20 +872,24 @@
|
||||
}
|
||||
var html = (enlaces ? '<div class="sheet-grupo">' + enlaces + "</div>" : "") +
|
||||
(respuestas ? '<div class="sheet-grupo">' + respuestas + "</div>" : "");
|
||||
montarHoja("Todas las opciones", html || '<div class="sheet-vacio">Sin opciones</div>', "", function (e, t) {
|
||||
var hojaOp = montarHoja("Todas las opciones", html || '<div class="sheet-vacio">Sin opciones</div>', "", function (e, t) {
|
||||
if (t.closest("a.sheet-fila")) { WAM.cerrarSheet(); return; }
|
||||
var op = t.closest("[data-opcion]");
|
||||
if (!op) return;
|
||||
e.preventDefault();
|
||||
if (op.getAttribute("aria-disabled") === "true") return;
|
||||
var destino = li ? li.querySelector('[data-btn="' + op.getAttribute("data-opcion") + '"]') : null;
|
||||
/* la fila viva AHORA (el editor puede haberla repintado con la hoja abierta) */
|
||||
var viva = li && li.isConnected ? li : filaDe(msg);
|
||||
var destino = viva ? viva.querySelector('[data-btn="' + op.getAttribute("data-opcion") + '"]') : null;
|
||||
WAM.cerrarSheet();
|
||||
if (destino) destino.click(); /* burbujea a #chat-cuerpo (delegación de main.js) */
|
||||
});
|
||||
if (hojaOp) HOJA_ID = vacio(msg.id) ? "" : String(msg.id);
|
||||
};
|
||||
|
||||
WAM.cerrarSheet = function () {
|
||||
var hoja = byId("sheet");
|
||||
if (hoja && hoja.parentNode) hoja.parentNode.removeChild(hoja);
|
||||
HOJA_ID = "";
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user