Aplicar pendientes tras la integración

- render.js: botones y cards con urlBloqueada se pintan inertes.
- interactivity.js: sinFormato con la misma regla de marcadores que render.js; cita de documento con icono y nombre.
- Zoom manual (Ajustar / 100 % / 125 %) en #barra-movil, persistido en localStorage.
- Contador «Paso N/M» con estilo en iphone.css en lugar de estilos en línea.
- store/editor: campos opcionales tamano y headerTamano; editar la URL retira urlBloqueada;
  urlValida acepta URLs con forma de dominio; importar avisa de lo recortado.
- README y SPEC actualizados.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
urieljareth
2026-09-27 21:39:09 -06:00
co-authored by Claude Opus 5.5
parent a645056c00
commit 4fe8f73959
11 changed files with 136 additions and 28 deletions
+12 -4
View File
@@ -34,7 +34,8 @@ En el bloque **Cuenta** configuras lo que se ve en la cabecera del chat:
- **Subtítulo** (p. ej. «en línea»); cuando el chat «está escribiendo» se cambia temporalmente por *escribiendo…* en verde. - **Subtítulo** (p. ej. «en línea»); cuando el chat «está escribiendo» se cambia temporalmente por *escribiendo…* en verde.
- **Avatar**: súbelo desde un archivo (se convierte a dataURL) o pega una URL/dataURL; también puedes quitarlo (se usa un placeholder SVG). - **Avatar**: súbelo desde un archivo (se convierte a dataURL) o pega una URL/dataURL; también puedes quitarlo (se usa un placeholder SVG).
- **Cuenta verificada**: activa o desactiva la insignia azul junto al nombre. - **Cuenta verificada**: activa o desactiva la insignia azul junto al nombre.
- **Tema**: claro u oscuro (también con el botón *Tema* de la barra superior; se guarda con el resto del estado).
El **tema** (claro u oscuro) ya no está en Cuenta: se cambia con el botón *Tema* de la barra superior o con la tecla **T**, y se guarda con el resto del estado.
### Añadir, editar y reordenar mensajes ### Añadir, editar y reordenar mensajes
@@ -42,7 +43,8 @@ En el bloque **Cuenta** configuras lo que se ve en la cabecera del chat:
- Cada mensaje es una **tarjeta colapsable** con un resumen; haz clic para desplegar sus campos. - Cada mensaje es una **tarjeta colapsable** con un resumen; haz clic para desplegar sus campos.
- En cada tarjeta puedes cambiar los campos comunes: **Dirección** (Entrada/Salida), **Hora** (texto literal `HH:MM`, se muestra tal cual) y, solo en los salientes, **Estado (ticks)**: Enviado ✓ / Entregado ✓✓ / Leído ✓✓ (celeste). - En cada tarjeta puedes cambiar los campos comunes: **Dirección** (Entrada/Salida), **Hora** (texto literal `HH:MM`, se muestra tal cual) y, solo en los salientes, **Estado (ticks)**: Enviado ✓ / Entregado ✓✓ / Leído ✓✓ (celeste).
- Acciones por tarjeta: **↑ / ↓** reordenar, **⧉** duplicar (con IDs regenerados) y **✕** borrar (pide confirmación). - Acciones por tarjeta: **↑ / ↓** reordenar, **⧉** duplicar (con IDs regenerados) y **✕** borrar (pide confirmación).
- Todo cambio se guarda al instante y se refleja en el teléfono. El botón **Restablecer** de la barra superior borra `localStorage` y restaura la demo «Café Aurora». - Todo cambio se guarda al instante y se refleja en el teléfono. En el menú **⋯** de la barra superior, **Volver al ejemplo Café Aurora** restaura la demo de ejemplo (pide confirmación y se puede deshacer).
- Los botones, filas y tarjetas sin URL tienen la casilla **Se pulsa sola en la demo**: durante *Reproducir demo* se pulsa automáticamente (la cabecera plegada muestra el chip «▶ auto»).
### Campos por tipo de mensaje ### Campos por tipo de mensaje
@@ -51,7 +53,7 @@ En el bloque **Cuenta** configuras lo que se ve en la cabecera del chat:
| Texto | texto (admite formato WhatsApp: `*negrita*`, `_cursiva_`, `~tachado~`, `` ` ```monoespaciado``` ` ` y URLs clicables) | | Texto | texto (admite formato WhatsApp: `*negrita*`, `_cursiva_`, `~tachado~`, `` ` ```monoespaciado``` ` ` y URLs clicables) |
| Imagen / Vídeo | archivo o URL + caption opcional | | Imagen / Vídeo | archivo o URL + caption opcional |
| Audio | archivo opcional, casilla **nota de voz** (waveform + micro) y duración (`0:12`) | | Audio | archivo opcional, casilla **nota de voz** (waveform + micro) y duración (`0:12`) |
| Documento | nombre de archivo con extensión (define el color del icono: pdf, docx, xlsx…), caption y archivo opcional | | Documento | nombre de archivo con extensión (define el color del icono: pdf, docx, xlsx…), tamaño opcional («243 KB • pdf»), caption y archivo opcional |
| Sticker | imagen/WebP (se muestra sin burbuja ni hora) | | Sticker | imagen/WebP (se muestra sin burbuja ni hora) |
| Ubicación | latitud, longitud, nombre y dirección (el mapa es un gráfico SVG con pin) | | Ubicación | latitud, longitud, nombre y dirección (el mapa es un gráfico SVG con pin) |
| Contacto | nombre, teléfono, email y empresa | | Contacto | nombre, teléfono, email y empresa |
@@ -91,11 +93,17 @@ Si un botón no tiene respuestas, al pulsarlo solo sale la burbuja con su texto.
## Modo demo (columna derecha) ## Modo demo (columna derecha)
- **Reproducir demo**: vacía el chat y revela los mensajes uno a uno (~600 ms por mensaje, con «escribiendo…» antes de cada mensaje entrante), como si la conversación ocurriera en directo. Pulsar de nuevo (**■ Detener**) lo cancela. - **Reproducir demo**: vacía el chat y revela los mensajes uno a uno (~600 ms por mensaje, con «escribiendo…» antes de cada mensaje entrante), como si la conversación ocurriera en directo. Mientras corre se ve el contador «Paso N/M»; pulsar de nuevo (**■ Detener**) lo cancela.
- **Velocidad**: el selector junto al botón ajusta la reproducción (0,5x, 1x, 2x).
- **Zoom** (solo escritorio): *Ajustar* calcula el tamaño del teléfono según el hueco; *100 %* y *125 %* lo fijan y el panel hace scroll. Se recuerda en este navegador.
- **Repetir desde el inicio** (antes «Reiniciar chat»): descarta lo pulsado y escrito en el teléfono y vuelve al guion desde el primer mensaje.
- **Nueva demo** (botón de la barra superior o tecla **N**): abre los escenarios de ejemplo y tus demos guardadas.
- **Deshacer / Rehacer**: botones de la barra superior o **Ctrl/⌘ + Z** y **Ctrl/⌘ + Shift + Z**. Cargar un escenario, una demo o el ejemplo deja siempre un punto de deshacer.
- **Mensajes libres**: escribe en el campo *Mensaje* del composer y envía (Enter o botón): sale una burbuja con la **hora actual** y ticks automáticos ✓ → ✓✓ → ✓✓ azul. - **Mensajes libres**: escribe en el campo *Mensaje* del composer y envía (Enter o botón): sale una burbuja con la **hora actual** y ticks automáticos ✓ → ✓✓ → ✓✓ azul.
- **Hoja de lista (sheet)**: al pulsar el botón de un mensaje de tipo *Lista* se abre la hoja inferior con las secciones y filas; toca una fila para disparar su flujo, o toca el fondo oscuro para cerrarla. - **Hoja de lista (sheet)**: al pulsar el botón de un mensaje de tipo *Lista* se abre la hoja inferior con las secciones y filas; toca una fila para disparar su flujo, o toca el fondo oscuro para cerrarla.
- **Modo presentación**: oculta el editor y deja solo el iPhone centrado, perfecto para pantallazos o para proyectar. Se alterna con el botón *Presentación* de la barra superior. - **Modo presentación**: oculta el editor y deja solo el iPhone centrado, perfecto para pantallazos o para proyectar. Se alterna con el botón *Presentación* de la barra superior.
- **Tema**: alterna claro/oscuro en toda la app (editor y chat). - **Tema**: alterna claro/oscuro en toda la app (editor y chat).
- **Teclado**: el enlace **Saltar a la vista previa** (visible al tabular) y las teclas **G** / **F6** alternan el foco entre editor y teléfono. El diálogo *Atajos* lista el resto.
--- ---
+6 -3
View File
@@ -52,7 +52,8 @@ Convenciones: `src`/`avatar`/`headerMedia`/`media` = dataURL o URL remota; `""`
de `WAM.svgPlaceholder(texto)`. Botones con `url` no vacía se renderizan como `<a target="_blank">` de `WAM.svgPlaceholder(texto)`. Botones con `url` no vacía se renderizan como `<a target="_blank">`
(cta_url, template URL, card de carousel); sin `url` → flujo interactivo con `respuestas`. (cta_url, template URL, card de carousel); sin `url` → flujo interactivo con `respuestas`.
Botón de plantilla o card con URL no permitida: `normalizar` deja `url: ""` y añade `urlBloqueada: true` Botón de plantilla o card con URL no permitida: `normalizar` deja `url: ""` y añade `urlBloqueada: true`
(sigue siendo un botón de enlace, inerte; nunca un quick reply con flujo; `resolverFlujo` lo ignora). (sigue siendo un botón de enlace, inerte `.qr-btn-inactiva` en render.js; nunca un quick reply con flujo;
`resolverFlujo` lo ignora). Editar a mano el campo URL en el editor retira la marca.
`WAM.normalizar(o)` rellena campos faltantes por tipo y devuelve el estado válido (import/upgrade). `WAM.normalizar(o)` rellena campos faltantes por tipo y devuelve el estado válido (import/upgrade).
Campo opcional `autoPulsar` (entero ≥ 0) en `template`/`buttons`/`list`/`carousel` (también en respuestas Campo opcional `autoPulsar` (entero ≥ 0) en `template`/`buttons`/`list`/`carousel` (también en respuestas
anidadas): índice del botón, de la fila aplanada de la lista o de la card que «Reproducir demo» pulsa sola anidadas): índice del botón, de la fila aplanada de la lista o de la card que «Reproducir demo» pulsa sola
@@ -112,7 +113,9 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
- Documento: campo opcional `tamano` (y `headerTamano` en la cabecera de plantilla) → subtítulo - Documento: campo opcional `tamano` (y `headerTamano` en la cabecera de plantilla) → subtítulo
"243 KB • pdf"; sin él, solo la extensión en mayúsculas ("PDF"). "243 KB • pdf"; sin él, solo la extensión en mayúsculas ("PDF").
- Hooks de CSS (css/iphone.css): `body[data-wam-zoom="100"|"125"]` fija la escala del teléfono (sin - Hooks de CSS (css/iphone.css): `body[data-wam-zoom="100"|"125"]` fija la escala del teléfono (sin
atributo = ajuste automático); en ≤ 520 px el teléfono va sin marco salvo con `body.con-marco`. atributo = ajuste automático), solo ≥ 901 px y fuera de Presentación. Lo pone `#sel-zoom` (ux.js,
`WAM.setZoom(v)`, persistido en `localStorage["wam-zoom"]`); en ≤ 520 px el teléfono va sin marco salvo
con `body.con-marco`.
- `WAM.tick(estado)` → string SVG inline (✓ / ✓✓ del color de la hora; ✓✓ leído azul iOS `#027bfc`) - `WAM.tick(estado)` → string SVG inline (✓ / ✓✓ del color de la hora; ✓✓ leído azul iOS `#027bfc`)
- Botones del teléfono: `<button type="button" class="qr-btn">` (Enter/Espacio nativos); URLs solo con - Botones del teléfono: `<button type="button" class="qr-btn">` (Enter/Espacio nativos); URLs solo con
esquema `http(s):`, `mailto:` o `tel:` (otro esquema → botón inerte `.qr-btn-inactiva`); `tel:` pinta esquema `http(s):`, `mailto:` o `tel:` (otro esquema → botón inerte `.qr-btn-inactiva`); `tel:` pinta
@@ -192,7 +195,7 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
### js/main.js (dueño D) — bootstrap ### js/main.js (dueño D) — bootstrap
- `WAM.avisar(texto, {tipo:"info"|"error", duracion?})` → aviso flotante `#wam-aviso` (estilos por CSSOM) - `WAM.avisar(texto, {tipo:"info"|"error", duracion?})` → aviso flotante `#wam-aviso` (estilos por CSSOM)
- Controles de `#barra-movil`: `#sel-velocidad`, `#btn-reiniciar`, `#btn-reproducir` (estilos en `css/interactividad.css`, D). - Controles de `#barra-movil`: `#sel-zoom` (ux.js), `#sel-velocidad`, `#btn-reiniciar`, `#btn-reproducir` (estilos en `css/interactividad.css`, D).
Teclado en `#pantalla`: Enter/Espacio activan `[data-flujo]`/`[data-ver-todas]`/`.sheet-cerrar`; Escape cierra la hoja. Teclado en `#pantalla`: Enter/Espacio activan `[data-flujo]`/`[data-ver-todas]`/`.sheet-cerrar`; Escape cierra la hoja.
`#btn-reset` pide confirmación. Enlaces del chat con esquema no permitido no se abren. `#btn-reset` pide confirmación. Enlaces del chat con esquema no permitido no se abren.
- `WAM.init()` → void (se ejecuta al final del body): `estado = cargar()`; `setTema`; `render()`; `renderEditor()`; - `WAM.init()` → void (se ejecuta al final del body): `estado = cargar()`; `setTema`; `render()`; `renderEditor()`;
+10
View File
@@ -69,6 +69,12 @@
margin-right: 4px; margin-right: 4px;
opacity: .8; opacity: .8;
} }
.wa .wa-cita-doc {
display: inline-block;
vertical-align: -2px;
margin-right: 4px;
color: var(--wa-text-meta);
}
.wa .wa-cita-mini { .wa .wa-cita-mini {
flex: none; flex: none;
width: 56px; width: 56px;
@@ -100,6 +106,7 @@
justify-content: flex-end; justify-content: flex-end;
} }
#btn-reiniciar, #btn-reiniciar,
#sel-zoom,
#sel-velocidad { #sel-velocidad {
height: 34px; height: 34px;
padding: 0 12px; padding: 0 12px;
@@ -112,8 +119,11 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
#sel-zoom,
#sel-velocidad { padding: 0 8px; } #sel-velocidad { padding: 0 8px; }
#btn-reiniciar:hover, #btn-reiniciar:hover,
#sel-zoom:hover,
#sel-velocidad:hover { border-color: var(--wam-acento, #00a884); } #sel-velocidad:hover { border-color: var(--wam-acento, #00a884); }
#btn-reiniciar:focus-visible, #btn-reiniciar:focus-visible,
#sel-zoom:focus-visible,
#sel-velocidad:focus-visible { outline: 2px solid var(--wam-acento, #00a884); outline-offset: 2px; } #sel-velocidad:focus-visible { outline: 2px solid var(--wam-acento, #00a884); outline-offset: 2px; }
+23 -7
View File
@@ -71,13 +71,16 @@ body.presentacion #barra-movil { display: none; }
} }
/* Zoom manual (lo fija js/ux.js con body[data-wam-zoom]; sin atributo = /* Zoom manual (lo fija js/ux.js con body[data-wam-zoom]; sin atributo =
"Ajustar", el cálculo automático). Con zoom fijo el panel hace scroll. */ "Ajustar", el cálculo automático). Con zoom fijo el panel hace scroll.
html body[data-wam-zoom="100"] { --wam-escala: 1; } Solo en escritorio y fuera de Presentación (ahí manda el ajuste). */
html body[data-wam-zoom="125"] { --wam-escala: 1.25; } @media (min-width: 901px) {
body[data-wam-zoom="100"] #panel-movil, html body[data-wam-zoom="100"]:not(.presentacion) { --wam-escala: 1; }
body[data-wam-zoom="125"] #panel-movil { overflow: auto; } html body[data-wam-zoom="125"]:not(.presentacion) { --wam-escala: 1.25; }
body[data-wam-zoom="100"] .iphone-escena, body[data-wam-zoom="100"]:not(.presentacion) #panel-movil,
body[data-wam-zoom="125"] .iphone-escena { margin: 0 auto; } body[data-wam-zoom="125"]:not(.presentacion) #panel-movil { overflow: auto; }
body[data-wam-zoom="100"]:not(.presentacion) .iphone-escena,
body[data-wam-zoom="125"]:not(.presentacion) .iphone-escena { margin: 0 auto; }
}
/* --- Columna de vista previa (#panel-movil) ---------------------------- */ /* --- Columna de vista previa (#panel-movil) ---------------------------- */
#panel-movil { #panel-movil {
@@ -104,6 +107,19 @@ body[data-wam-zoom="125"] .iphone-escena { margin: 0 auto; }
gap: 12px; gap: 12px;
margin-bottom: 14px; margin-bottom: 14px;
} }
/* Contador «Paso 3/12» de la demo (lo crea js/main.js junto al botón) */
#barra-movil .demo-paso {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--wam-texto-suave);
white-space: nowrap;
}
#barra-movil .demo-paso[hidden] { display: none; }
/* El zoom manual solo tiene sentido con marco y ventana de escritorio */
@media (max-width: 900px) {
#barra-movil .barra-zoom { display: none; }
}
#barra-movil .barra-titulo { #barra-movil .barra-titulo {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
+9
View File
@@ -138,6 +138,15 @@
<div id="barra-movil"> <div id="barra-movil">
<span class="barra-titulo">Vista previa</span> <span class="barra-titulo">Vista previa</span>
<div class="barra-acciones"> <div class="barra-acciones">
<label class="barra-etq barra-zoom" for="sel-zoom" title="Tamaño del teléfono en pantalla">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.2-4.2M11 8.5v5M8.5 11h5"/></svg>
<span class="barra-etq-txt">Zoom</span>
</label>
<select id="sel-zoom" class="barra-zoom" title="Tamaño del teléfono en pantalla">
<option value="" selected>Ajustar</option>
<option value="100">100 %</option>
<option value="125">125 %</option>
</select>
<label class="barra-etq" for="sel-velocidad" title="Velocidad de la reproducción"> <label class="barra-etq" for="sel-velocidad" title="Velocidad de la reproducción">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 13V9M10 2.8h4"/></svg> <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 13V9M10 2.8h4"/></svg>
<span class="barra-etq-txt">Velocidad</span> <span class="barra-etq-txt">Velocidad</span>
+18 -3
View File
@@ -450,7 +450,10 @@
if (obj[k] === null || obj[k] === undefined) obj[k] = /^\d+$/.test(partes[i + 1]) ? [] : {}; if (obj[k] === null || obj[k] === undefined) obj[k] = /^\d+$/.test(partes[i + 1]) ? [] : {};
obj = obj[k]; obj = obj[k];
} }
obj[partes[partes.length - 1]] = valor; var clave = partes[partes.length - 1];
obj[clave] = valor;
// Editar la URL a mano retira la marca de enlace bloqueado (store.js)
if (clave === "url" && obj.urlBloqueada) delete obj.urlBloqueada;
if (typeof WAM.guardar === "function") WAM.guardar(); if (typeof WAM.guardar === "function") WAM.guardar();
marcarEditado(); marcarEditado();
if (opts && opts.diferido) { programarRender(String(ruta)); return; } if (opts && opts.diferido) { programarRender(String(ruta)); return; }
@@ -978,6 +981,8 @@
} finally { } finally {
WAM.estado = previo; WAM.estado = previo;
} }
// Se lee ya: cualquier normalización posterior sobrescribe el resumen
var recortes = typeof WAM.describirRecortes === "function" && datos ? WAM.describirRecortes(datos.__recortes) : "";
if (!datos || !Array.isArray(datos.mensajes)) throw new Error("sin mensajes"); if (!datos || !Array.isArray(datos.mensajes)) throw new Error("sin mensajes");
snap(); snap();
WAM.estado = datos; WAM.estado = datos;
@@ -992,7 +997,7 @@
if (typeof WAM.render === "function") WAM.render(); if (typeof WAM.render === "function") WAM.render();
WAM.renderEditor(); WAM.renderEditor();
var n = WAM.estado.mensajes.length; var n = WAM.estado.mensajes.length;
WAM.toast("Demo importada: " + n + " mensaje" + (n === 1 ? "" : "s"), WAM.toast(recortes ? recortes + "." : "Demo importada: " + n + " mensaje" + (n === 1 ? "" : "s"),
typeof WAM.deshacer === "function" ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null); typeof WAM.deshacer === "function" ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
var primero = listaNodo() && listaNodo().querySelector(".ed-cab-principal"); var primero = listaNodo() && listaNodo().querySelector(".ed-cab-principal");
if (primero) primero.focus(); if (primero) primero.focus();
@@ -1038,7 +1043,13 @@
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
function largo(v) { return String(v === undefined || v === null ? "" : v).length; } function largo(v) { return String(v === undefined || v === null ? "" : v).length; }
function vacioTxt(v) { return !String(v === undefined || v === null ? "" : v).trim(); } function vacioTxt(v) { return !String(v === undefined || v === null ? "" : v).trim(); }
function urlValida(v) { return /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(String(v || "").trim()); } // Con esquema http(s) o con forma de dominio («www.cafe.com», «cafe.com/menu»):
// render.js abre estas últimas con https://, igual que WhatsApp.
function urlValida(v) {
var s = String(v || "").trim();
return /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(s) ||
/^www\.[^\s]+$/i.test(s) || /^[\w-]+(\.[\w-]+)+(:\d+)?([\/?#][^\s]*)?$/.test(s);
}
// Botón de llamada de plantilla: tel: con al menos 3 dígitos (espacios, guiones y paréntesis permitidos) // Botón de llamada de plantilla: tel: con al menos 3 dígitos (espacios, guiones y paréntesis permitidos)
function telValido(v) { function telValido(v) {
var s = String(v || "").trim(); var s = String(v || "").trim();
@@ -1716,6 +1727,9 @@
caja.appendChild(edCampo("Nombre del documento (con extensión; define el icono)", ruta + ".headerTexto", m.headerTexto, { 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 max: 80, contar: true, placeholder: "catalogo.pdf", spell: false
})); }));
caja.appendChild(edCampo("Tamaño (opcional, p. ej. 243 KB)", ruta + ".headerTamano", m.headerTamano, {
max: 20, placeholder: "243 KB", spell: false
}));
} }
caja.appendChild(edMedia("Media de cabecera", ruta + ".headerMedia", m.headerMedia, { caja.appendChild(edMedia("Media de cabecera", ruta + ".headerMedia", m.headerMedia, {
aceptar: tipo === "imagen" ? "image/*" : (tipo === "video" ? "video/*" : ""), aceptar: tipo === "imagen" ? "image/*" : (tipo === "video" ? "video/*" : ""),
@@ -1801,6 +1815,7 @@
break; break;
case "document": case "document":
ap(edCampo("Nombre de archivo (con extensión; define el icono)", ruta + ".filename", m.filename, { max: 80, contar: true, placeholder: "menu.pdf" })); ap(edCampo("Nombre de archivo (con extensión; define el icono)", ruta + ".filename", m.filename, { max: 80, contar: true, placeholder: "menu.pdf" }));
ap(edCampo("Tamaño (opcional, p. ej. 243 KB)", ruta + ".tamano", m.tamano, { max: 20, placeholder: "243 KB", spell: false }));
ap(edTextarea("Caption (opcional)", ruta + ".caption", m.caption, { limite: 1024 })); ap(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í" })); ap(edMedia("Archivo (opcional, no se muestra)", ruta + ".src", m.src, { tipoMedia: "doc", textoDrop: "Clic o arrastra un documento aquí" }));
break; break;
+26 -4
View File
@@ -292,12 +292,23 @@ window.WAM = window.WAM || {};
return String(s == null ? "" : s) return String(s == null ? "" : s)
.replace(/```([\s\S]*?)```/g, "$1") .replace(/```([\s\S]*?)```/g, "$1")
.replace(/`([^`\n]+)`/g, "$1") .replace(/`([^`\n]+)`/g, "$1")
.replace(/\*([^*\n]+)\*/g, "$1") .replace(reMarca("\*"), "$1$2")
.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, "$1$2") .replace(reMarca("_"), "$1$2")
.replace(/(^|[^\w])~([^~\n]+)~(?=[^\w]|$)/g, "$1$2") .replace(reMarca("~"), "$1$2")
.trim(); .trim();
} }
/* Misma regla que render.js: marca pegada a un carácter no blanco por dentro
y sin letra, número ni _ por fuera («2 * 3 * 4» queda literal). */
function reMarca(e) {
try {
return new RegExp("(^|[^\p{L}\p{N}_" + e + "])" + e + "(?=\S)([^" + e + "\n]*?\S)" + e +
"(?=[^\p{L}\p{N}_" + e + "]|$)", "gu");
} catch (err) {
return new RegExp("(^|[^\w" + e + "])" + e + "(?=\S)([^" + e + "\n]*?\S)" + e + "(?=[^\w" + e + "]|$)", "g");
}
}
function miniaturaSegura(u) { function miniaturaSegura(u) {
if (vacio(u)) return ""; if (vacio(u)) return "";
var s = String(u); var s = String(u);
@@ -340,6 +351,9 @@ window.WAM = window.WAM || {};
cita.foto = true; cita.foto = true;
} else if (msg.headerTipo === "video") { } else if (msg.headerTipo === "video") {
cita.foto = true; cita.foto = true;
} else if (msg.headerTipo === "documento") {
cita.titulo = vacio(msg.headerTexto) ? "documento.pdf" : String(msg.headerTexto);
cita.doc = true;
} }
} }
cita.extracto = partes.filter(function (p) { return !vacio(p); }).map(sinFormato).join("\n"); cita.extracto = partes.filter(function (p) { return !vacio(p); }).map(sinFormato).join("\n");
@@ -350,6 +364,9 @@ window.WAM = window.WAM || {};
var SVG_CAMARA = '<svg class="wa-cita-cam" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">' + var SVG_CAMARA = '<svg class="wa-cita-cam" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">' +
'<path fill="currentColor" d="M5.6 2.5h4.8l1 1.5H13a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 13 12.5H3A1.5 1.5 0 0 1 1.5 11V5.5A1.5 1.5 0 0 1 3 4h1.6l1-1.5Zm2.4 3a2.75 2.75 0 1 0 0 5.5 2.75 2.75 0 0 0 0-5.5Z"/></svg>'; '<path fill="currentColor" d="M5.6 2.5h4.8l1 1.5H13a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 13 12.5H3A1.5 1.5 0 0 1 1.5 11V5.5A1.5 1.5 0 0 1 3 4h1.6l1-1.5Zm2.4 3a2.75 2.75 0 1 0 0 5.5 2.75 2.75 0 0 0 0-5.5Z"/></svg>';
var SVG_DOC = '<svg class="wa-cita-doc" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">' +
'<path fill="currentColor" d="M4 1.5h5.3L13 5.2V13a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 13V3a1.5 1.5 0 0 1 1-1.5Zm5 .9V5.5h3.1L9 2.4ZM5 8h6v1H5V8Zm0 2.2h6v1H5v-1Z"/></svg>';
/* Bloque de cita dentro de la burbuja OUT; todo por textContent (sin XSS). */ /* Bloque de cita dentro de la burbuja OUT; todo por textContent (sin XSS). */
function pintarCita(fila, cita) { function pintarCita(fila, cita) {
var burbuja = fila.querySelector(".bubble"); var burbuja = fila.querySelector(".bubble");
@@ -365,7 +382,12 @@ window.WAM = window.WAM || {};
if (cita.titulo) { if (cita.titulo) {
var tit = document.createElement("div"); var tit = document.createElement("div");
tit.className = "wa-cita-titulo"; tit.className = "wa-cita-titulo";
tit.textContent = cita.titulo; if (cita.doc) {
var doc = document.createElement("span");
doc.innerHTML = SVG_DOC; /* constante propia, sin datos del usuario */
if (doc.firstChild) tit.appendChild(doc.firstChild);
}
tit.appendChild(document.createTextNode(cita.titulo));
txt.appendChild(tit); txt.appendChild(tit);
} }
if (cita.extracto) { if (cita.extracto) {
-5
View File
@@ -86,11 +86,6 @@ window.WAM = window.WAM || {};
c.setAttribute("aria-live", "polite"); c.setAttribute("aria-live", "polite");
c.setAttribute("role", "status"); c.setAttribute("role", "status");
c.hidden = true; c.hidden = true;
/* estilo mínimo en línea (tabular) mientras no haya regla en iphone.css */
c.style.fontSize = "12px";
c.style.fontVariantNumeric = "tabular-nums";
c.style.opacity = "0.75";
c.style.whiteSpace = "nowrap";
btn.parentNode.insertBefore(c, btn); btn.parentNode.insertBefore(c, btn);
return c; return c;
} }
+2 -2
View File
@@ -427,7 +427,7 @@
return '<a class="qr-btn"' + h + ' href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-btn-url="' + j + '">' + return '<a class="qr-btn"' + h + ' href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-btn-url="' + j + '">' +
icono(tipoIconoUrl(url)) + lbl + "</a>"; icono(tipoIconoUrl(url)) + lbl + "</a>";
} }
if (!vacio(b.url)) { if (!vacio(b.url) || b.urlBloqueada) {
return '<span class="qr-btn qr-btn-inactiva"' + h + ' aria-disabled="true">' + icono("url") + lbl + "</span>"; return '<span class="qr-btn qr-btn-inactiva"' + h + ' aria-disabled="true">' + icono("url") + lbl + "</span>";
} }
return '<button type="button" class="qr-btn"' + h + ' data-flujo="' + i + '" data-btn="' + j + '">' + return '<button type="button" class="qr-btn"' + h + ' data-flujo="' + i + '" data-btn="' + j + '">' +
@@ -540,7 +540,7 @@
var url = urlSegura(c.url); var url = urlSegura(c.url);
var boton; var boton;
if (url) boton = '<a class="qr-btn" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + icono(tipoIconoUrl(url)) + lbl + "</a>"; if (url) boton = '<a class="qr-btn" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + icono(tipoIconoUrl(url)) + lbl + "</a>";
else if (!vacio(c.url)) boton = '<span class="qr-btn qr-btn-inactiva" aria-disabled="true">' + icono("url") + lbl + "</span>"; else if (!vacio(c.url) || c.urlBloqueada) boton = '<span class="qr-btn qr-btn-inactiva" aria-disabled="true">' + icono("url") + lbl + "</span>";
else boton = '<button type="button" class="qr-btn" data-flujo="' + i + '" data-card="' + j + '">' + icono("responder") + lbl + "</button>"; else boton = '<button type="button" class="qr-btn" data-flujo="' + i + '" data-card="' + j + '">' + icono("responder") + lbl + "</button>";
html += '<div class="card">' + html += '<div class="card">' +
'<img class="card-media" src="' + esc(media) + '" alt="" draggable="false">' + '<img class="card-media" src="' + esc(media) + '" alt="" draggable="false">' +
+5
View File
@@ -359,6 +359,8 @@ window.WAM = window.WAM || {};
msg.src = urlLimpia(m.src, "media"); msg.src = urlLimpia(m.src, "media");
msg.caption = cad(m.caption); msg.caption = cad(m.caption);
msg.filename = cad(m.filename, "documento.pdf") || "documento.pdf"; msg.filename = cad(m.filename, "documento.pdf") || "documento.pdf";
/* opcional: «243 KB» para el subtítulo «243 KB • pdf» (render.js) */
if (cad(m.tamano).trim()) msg.tamano = cad(m.tamano).trim().slice(0, 20);
break; break;
case "sticker": case "sticker":
msg.src = urlLimpia(m.src, "media"); msg.src = urlLimpia(m.src, "media");
@@ -380,6 +382,9 @@ window.WAM = window.WAM || {};
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : ""; msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
msg.headerTexto = cad(m.headerTexto); msg.headerTexto = cad(m.headerTexto);
msg.headerMedia = urlLimpia(m.headerMedia, "media"); msg.headerMedia = urlLimpia(m.headerMedia, "media");
if (msg.headerTipo === "documento" && cad(m.headerTamano).trim()) {
msg.headerTamano = cad(m.headerTamano).trim().slice(0, 20);
}
msg.body = cad(m.body); msg.body = cad(m.body);
msg.footer = cad(m.footer); msg.footer = cad(m.footer);
msg.botones = botones(m.botones); msg.botones = botones(m.botones);
+25
View File
@@ -955,6 +955,30 @@
temaPintado = tema; temaPintado = tema;
} }
/* ------------------------------------------------------------------
Zoom manual del teléfono (Ajustar / 100 % / 125 %). El CSS vive en
iphone.css (body[data-wam-zoom]); sin atributo = ajustar al hueco.
------------------------------------------------------------------ */
var CLAVE_ZOOM = "wam-zoom";
function aplicarZoom(v) {
v = v === "100" || v === "125" ? v : "";
if (v) document.body.setAttribute("data-wam-zoom", v);
else document.body.removeAttribute("data-wam-zoom");
var sel = porId("sel-zoom");
if (sel && sel.value !== v) sel.value = v;
return v;
}
WAM.setZoom = function (v) {
v = aplicarZoom(String(v == null ? "" : v));
if (v) lsSet(CLAVE_ZOOM, v); else lsDel(CLAVE_ZOOM);
return v;
};
function enlazarZoom() {
aplicarZoom(lsGet(CLAVE_ZOOM) || "");
var sel = porId("sel-zoom");
if (sel) sel.addEventListener("change", function () { WAM.setZoom(sel.value); });
}
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
Inicialización (una sola vez) Inicialización (una sola vez)
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
@@ -978,6 +1002,7 @@
observarToolbar(); observarToolbar();
enlazarChat(); enlazarChat();
enlazarSaltos(); enlazarSaltos();
enlazarZoom();
envolverAnuncios(); envolverAnuncios();
sincronizarTema(); sincronizarTema();