Compare commits
13
Commits
e4aab51955
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29d5f6dbdc | ||
|
|
46d47797b4 | ||
|
|
875ae9ad67 | ||
|
|
1176003b42 | ||
|
|
17e672e45f | ||
|
|
4fe8f73959 | ||
|
|
a645056c00 | ||
|
|
b042329f93 | ||
|
|
2f1c606129 | ||
|
|
13b11a0227 | ||
|
|
3509a77d62 | ||
|
|
50a64f21e3 | ||
|
|
c0b7d0c2ce |
@@ -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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
- 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).
|
||||
- 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
|
||||
|
||||
@@ -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) |
|
||||
| Imagen / Vídeo | archivo o URL + caption opcional |
|
||||
| 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) |
|
||||
| Ubicación | latitud, longitud, nombre y dirección (el mapa es un gráfico SVG con pin) |
|
||||
| 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)
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -51,10 +51,14 @@ con `dir:"in"`, anidados, NO se renderizan en el chat: los consume la interactiv
|
||||
Convenciones: `src`/`avatar`/`headerMedia`/`media` = dataURL o URL remota; `""` → placeholder SVG
|
||||
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`.
|
||||
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 `.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).
|
||||
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
|
||||
~1 s después de revelar el mensaje. `normalizar` lo omite si no es válido.
|
||||
~1 s después de revelar el mensaje. `normalizar` lo omite si no es válido. El editor lo mantiene al
|
||||
reordenar o borrar botones / filas / cards (sigue al elemento; se quita si se borró el pulsado).
|
||||
|
||||
**Guion frente a sesión.** `estado.mensajes` es el guion (persistente). Lo que se pulsa o escribe en el
|
||||
teléfono es la sesión: `WAM.sesion` (array en memoria, IDs `s1`, `s2`…), pintada con `anadirMensajeDOM`
|
||||
@@ -72,7 +76,12 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
|
||||
(main.js lo pinta con `WAM.avisar`) hasta el siguiente guardado correcto.
|
||||
- `WAM.resetear()` → void (borra LS, estado = demo con IDs desde `m1`)
|
||||
- `WAM.normalizar(o)` → estado (acepta un array de mensajes como raíz; IDs duplicados o inválidos se
|
||||
reasignan; vacía URLs con esquema no permitido; respuestas string → text; conserva campos extra primitivos)
|
||||
reasignan; vacía URLs con esquema no permitido; respuestas string → text; conserva campos extra primitivos).
|
||||
Lo recortado (más de 500 mensajes, 10 botones/secciones/filas/cards, 20 respuestas o 12 niveles) queda en
|
||||
`WAM.ultimosRecortes` y en `estado.__recortes` (no enumerable): `{ mensajes, mensajesOriginales, botones,
|
||||
secciones, filas, cards, respuestas, profundidad }`.
|
||||
- `WAM.describirRecortes(r?)` → string en español para el toast de importar («Se importaron 500 de 600
|
||||
mensajes; se recortaron 3 respuestas…») o `""` si no se recortó nada (por defecto `WAM.ultimosRecortes`)
|
||||
- `WAM.generarId()` → string (nunca repite un ID ya entregado aunque aún no esté en el estado)
|
||||
- `WAM.crearAlocador(extra?)` → `function tomar()` que reparte IDs únicos frente al estado, a los ya
|
||||
entregados y a los de `extra` (para clonar mensajes con respuestas anidadas)
|
||||
@@ -95,7 +104,18 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
|
||||
`#pantalla`: título = `boton`, filas `button.sheet-fila[data-sec][data-fila]`, botón `.sheet-cerrar`)
|
||||
- `WAM.abrirOpciones(i | Mensaje, li?)` → void: hoja "Todas las opciones" de un mensaje con >3 botones
|
||||
(la abre el propio B al pulsar `[data-ver-todas]`). Sus filas de respuesta llevan `data-opcion="<j>"`
|
||||
y B reenvía el clic al `[data-btn="<j>"]` oculto de la burbuja, que atiende la delegación normal de main.js.
|
||||
y B reenvía el clic al `[data-btn="<j>"]` oculto de la burbuja, que atiende la delegación normal de main.js
|
||||
(busca la fila viva en el momento del clic). `renderMensaje` de un mensaje cuya hoja está abierta la cierra
|
||||
(re-render parcial del editor), así la hoja nunca queda desfasada.
|
||||
- `scrollAbajo()` es instantáneo (ignora `scroll-behavior`) y deja el chat "pegado abajo": mientras el usuario
|
||||
no suba (>60 px del fondo), un ResizeObserver sobre `#chat-cuerpo` y sus filas vuelve a bajar cuando algo
|
||||
crece (imágenes que decodifican tarde, pestaña Vista previa que pasa a visible).
|
||||
- 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").
|
||||
- Hooks de CSS (css/iphone.css): `body[data-wam-zoom="100"|"125"]` fija la escala del teléfono (sin
|
||||
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`)
|
||||
- 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
|
||||
@@ -115,7 +135,8 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
|
||||
nombre = slug de la demo guardada activa o de `cuenta.nombre`, p. ej. `cafe-aurora.json`; `wam-demo.json` si vacío)
|
||||
- `WAM.importarJSON(archivo)` → Promise<void> (parse + `normalizar` sin reservar los IDs del estado vivo + guardar +
|
||||
re-render total; deja punto de deshacer)
|
||||
- `WAM.toast(texto, {tipo:"error", ms, accion:{texto, fn}})` → fn cerrar: aviso `#ed-toasts` fijo abajo a la izquierda
|
||||
- `WAM.toast(texto, {tipo:"error", ms, accion:{texto, fn}})` → fn cerrar: aviso `#ed-toasts` fijo abajo, centrado
|
||||
sobre la vista previa. Solo hay UN aviso con acción a la vista: uno nuevo con acción retira los anteriores con acción
|
||||
- `WAM.enfocarEnChat(id)` → bool: centra y resalta 1,2 s (`.ed-resaltado`) la burbuja en `#chat-cuerpo`; lo usa el
|
||||
editor al abrir/enfocar una tarjeta · `WAM.abrirTarjeta(id)` → bool: abre y enfoca la tarjeta (desde el chat)
|
||||
- Validación propia (no pública): chip ⚠ por tarjeta, caja «La Cloud API rechazaría…» y `#ed-avisos-global`;
|
||||
@@ -136,7 +157,10 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
|
||||
- `WAM.marcarEditado()` → indicador `#estado-guardado` («Guardando…» → «Guardado · hace N s»; rojo con
|
||||
`WAM.onErrorGuardar`, que ux.js sustituye por toast con «Exportar JSON»)
|
||||
- `WAM.setVistaMovil("editor"|"vista")` (≤ 900 px; `body.vista-movil`, `wam-ui-vista`) · `WAM.recordarEnfoque(id)`
|
||||
- Atajos globales (ignorados en campos): P, Espacio, T, F, N, Esc, Ctrl+S, Ctrl+Z, ?; Alt+↑/↓ mueve en el editor.
|
||||
- Deshacer / rehacer conservan el tema actual (el tema no forma parte del historial de contenido); cambiar el
|
||||
tema pasa por `WAM.marcarEditado()`. Cargar escenario / demo / Café Aurora deja SIEMPRE punto de deshacer.
|
||||
- Atajos globales (ignorados en campos): P, Espacio, T, F, N, G, Esc, Ctrl+S, Ctrl+Z, ?; F6 también en campos;
|
||||
G / F6 y el enlace `#saltar-vista` mueven el foco entre el editor y `#chat-cuerpo`; Alt+↑/↓ mueve en el editor.
|
||||
Alt+clic o doble clic en una burbuja abre su tarjeta. Envuelve `WAM.anadirMensajeDOM` para anunciar el mensaje
|
||||
en `#wam-anuncios` (sr-only) y quita `aria-live` de `#chat-cuerpo`.
|
||||
|
||||
@@ -154,9 +178,15 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
|
||||
" + descripcion`.
|
||||
- `WAM.mensajeDeElemento(el)` → Mensaje · `WAM.abrirLista(msg)` → abre la hoja (`abrirSheet(i | msg)`)
|
||||
- `WAM.marcarUsado(el)` → clase `.usado` (gris, sin más clics) · `WAM.reiniciarChat()` (= `render()`)
|
||||
- `WAM.sesionAGuion()` → n: añade la sesión al guion (acción explícita) + guardar + re-render
|
||||
- `WAM.sesionAGuion()` → n: añade la sesión al guion (acción explícita) + `puntoDeshacer` + guardar +
|
||||
`marcarEditado` + re-render
|
||||
- Hora de la sesión: las salientes de un flujo y sus respuestas usan la hora del último mensaje visible
|
||||
+ 1 min (línea temporal coherente con el guion); el composer libre usa `WAM.horaAhora()`. Los IDs de sesión
|
||||
(`s1`, `s2`…) saltan los que ya existen en el guion.
|
||||
- `WAM.getVelocidad()` / `WAM.setVelocidad(0.5|1|2)` (persistida en `wam-mockup-v1-velocidad`)
|
||||
- `WAM.demoActiva()` → bool · hook opcional `WAM.onDemoChange(activa)`
|
||||
- `WAM.demoActiva()` → bool · hook opcional `WAM.onDemoChange(activa)` (también se llama en cada paso)
|
||||
- `WAM.progresoDemo()` → `{ paso, total }` (mensajes del guion revelados / total; `{0, 0}` sin demo).
|
||||
main.js lo pinta en `#demo-paso` («Paso 3/12», `aria-live`) dentro de `#barra-movil`
|
||||
- `WAM.reproducirDemo()` / `WAM.detenerDemo()` → void: cancela flujos en curso, vacía `#chat-cuerpo` y revela
|
||||
`estado.mensajes` uno a uno (pulsando solas las opciones `autoPulsar`); detener vuelve al guion
|
||||
(~600 ms; mensaje `in` precedido de typing ~700 ms; cancelable)
|
||||
@@ -165,7 +195,7 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
|
||||
|
||||
### js/main.js (dueño D) — bootstrap
|
||||
- `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.
|
||||
`#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()`;
|
||||
@@ -180,7 +210,8 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
|
||||
| `editor-cuenta` | C | form identidad: `cuenta-nombre`, `cuenta-subtitulo`, `cuenta-verificado`, `cuenta-avatar-vista`, `cuenta-avatar-archivo`, `cuenta-avatar-url` |
|
||||
| `nuevo-tipo` (paleta `role=toolbar` de los 13 tipos: un clic AÑADE), `nuevo-tipo-valor` (hidden), `btn-anadir` («+ Otro …»), `ed-destino-alta`, `lista-mensajes` | C | alta por paleta (el último tipo usado vive en `#nuevo-tipo-valor` y `data-valor`) y tarjetas de mensajes |
|
||||
| `bloque-cuenta` (`<details>`), `cuenta-resumen`, `ed-avisos-global`, `btn-ver-telefono` | C | cuenta plegable, contador de avisos, salto a la vista previa (≤ 900 px) |
|
||||
| `btn-deshacer`, `btn-rehacer`, `btn-demos`, `btn-atajos`, `btn-mas` + `tb-menu`, `estado-guardado` | C (ux.js) | toolbar: historial, demos, atajos, menú «⋯» (≤ 900 px), indicador de guardado |
|
||||
| `btn-deshacer`, `btn-rehacer`, `btn-demos`, `btn-atajos`, `btn-mas` + `tb-menu`, `estado-guardado` | C (ux.js) | toolbar: historial, «Nueva demo» (primario, junto a la marca), atajos, menú «⋯» (siempre visible; `#btn-reset` «Volver al ejemplo Café Aurora» solo vive dentro del menú; ≤ 900 px agrupa además Exportar / Importar / Atajos), indicador de guardado |
|
||||
| `saltar-vista` (enlace), `ed-lista-herr`, `body[data-wam-vacia]` | C | salto de teclado a la vista previa; herramientas de lista (ocultas sin mensajes); demo vacía → pista en el chat |
|
||||
| `tabs-movil`, `tab-editor`, `tab-vista` | C (ux.js) | pestañas Editor / Vista previa (≤ 900 px) |
|
||||
| `barra-movil`, `btn-reproducir` | A | barra sobre el teléfono (Reproducir demo) |
|
||||
| `iphone`, `pantalla` (class `wa`), `isla`, `statusbar` ("9:41" + señal/wifi/batería SVG), `home-indicator` | A | marco iPhone 393×852 |
|
||||
|
||||
+146
-12
@@ -187,9 +187,43 @@ body {
|
||||
border-color: var(--wam-acento);
|
||||
color: var(--wam-acento-hover);
|
||||
}
|
||||
#btn-reset:hover { color: var(--wam-peligro); border-color: var(--wam-peligro); }
|
||||
#btn-mas { display: none; }
|
||||
/* «Nueva demo»: punto de partida (botón primario junto a la marca) */
|
||||
.tb-btn-primario {
|
||||
margin-left: 4px;
|
||||
background: var(--wam-primario);
|
||||
border-color: var(--wam-primario);
|
||||
color: #fff;
|
||||
}
|
||||
.tb-btn-primario:hover { background: var(--wam-primario-hover); border-color: var(--wam-primario-hover); }
|
||||
.tb-kbd {
|
||||
display: inline-block;
|
||||
min-width: 18px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid rgba(255, 255, 255, .45);
|
||||
border-radius: 5px;
|
||||
font: 600 11px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
text-align: center;
|
||||
opacity: .9;
|
||||
}
|
||||
/* Acción destructiva: siempre dentro del menú «⋯», al final */
|
||||
.tb-btn-peligro { color: var(--wam-peligro); }
|
||||
.tb-btn-peligro:hover { color: var(--wam-peligro); border-color: var(--wam-peligro); background: rgba(185, 58, 46, .08); }
|
||||
.tb-menu-sep { display: none; }
|
||||
.tb-solo-menu { display: none; }
|
||||
/* Escritorio: el grupo secundario va en línea y «⋯» despliega
|
||||
«Volver al ejemplo Café Aurora» como un menú de una opción. */
|
||||
#btn-reset { display: none; }
|
||||
.toolbar.tb-menu-abierto #btn-reset {
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 10px;
|
||||
z-index: 40;
|
||||
min-height: 40px;
|
||||
padding: 10px 14px;
|
||||
border-color: var(--wam-borde);
|
||||
box-shadow: var(--wam-sombra);
|
||||
}
|
||||
|
||||
/* --- Editor (columna izquierda · cascarón; contenido: editor.js) -------- */
|
||||
#panel-editor {
|
||||
@@ -375,6 +409,20 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
|
||||
.wam-esc-nombre { font-size: 14px; font-weight: 700; }
|
||||
.wam-esc-desc { font-size: 12.5px; line-height: 1.4; color: var(--wam-texto-suave); }
|
||||
.wam-esc-meta { font-size: 11.5px; font-weight: 600; color: var(--wam-acento-hover); }
|
||||
.wam-esc-tipos { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
|
||||
.wam-esc-tipo {
|
||||
padding: 1px 7px;
|
||||
border: 1px solid var(--wam-borde);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wam-texto-suave);
|
||||
background: var(--wam-panel-alt);
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.wam-dlg-grande { width: min(1040px, calc(100vw - 64px)); max-height: min(92vh, 860px); }
|
||||
.wam-dlg-grande .wam-esc-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* Demos guardadas */
|
||||
.wam-demos-lista { list-style: none; display: grid; gap: 6px; }
|
||||
@@ -429,8 +477,12 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
|
||||
.tb-guardado-rel { display: none; }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.toolbar-acciones .tb-btn > span:not(.tb-solo-menu) { display: none; }
|
||||
.toolbar-acciones .tb-btn { padding: 7px 9px; }
|
||||
/* Iconos sin texto, salvo en «Nueva demo» y «Presentación» */
|
||||
.toolbar-acciones .tb-grupo-sec .tb-btn > span:not(.tb-solo-menu) { display: none; }
|
||||
.toolbar-acciones .tb-grupo-sec .tb-btn { padding: 7px 9px; }
|
||||
.toolbar.tb-menu-abierto #btn-reset > span { display: inline; }
|
||||
.toolbar.tb-menu-abierto #btn-reset { padding: 10px 14px; }
|
||||
.tb-kbd { display: none; }
|
||||
}
|
||||
|
||||
/* --- Ventana estrecha (≤ 900 px): una columna con pestañas -------------- */
|
||||
@@ -489,9 +541,12 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
|
||||
.tab-movil:focus-visible { outline-offset: -4px; }
|
||||
|
||||
/* «Ver teléfono»: botón flotante dentro del editor */
|
||||
/* centrado: nunca tapa las acciones (derecha) ni el asa (izquierda) de
|
||||
las tarjetas; #lista-mensajes reserva su hueco al final (editor.css) */
|
||||
.ed-ver-telefono {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(var(--wam-tabs-alto) + 14px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 25;
|
||||
display: inline-flex;
|
||||
@@ -515,10 +570,15 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
|
||||
.toolbar-logo { width: 30px; height: 30px; }
|
||||
.toolbar h1 { font-size: 14px; }
|
||||
.tb-guardado { padding: 4px 8px; margin-left: 2px; }
|
||||
.tb-guardado-txt { display: none; }
|
||||
.tb-guardado[data-estado="error"] .tb-guardado-txt { display: inline; }
|
||||
#btn-mas { display: inline-flex; }
|
||||
/* el texto sigue disponible para lectores de pantalla (y en el title) */
|
||||
.tb-guardado-txt {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
.tb-guardado[data-estado="error"] .tb-guardado-txt { position: static; width: auto; height: auto; margin: 0; clip: auto; }
|
||||
.tb-sep-sec { display: none; }
|
||||
.toolbar-acciones #btn-presentacion > span { display: none; }
|
||||
#btn-presentacion { padding: 7px 9px; }
|
||||
.tb-grupo-sec {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@@ -536,15 +596,26 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
|
||||
box-shadow: var(--wam-sombra);
|
||||
}
|
||||
.toolbar.tb-menu-abierto .tb-grupo-sec { display: flex; }
|
||||
.tb-grupo-sec .tb-btn {
|
||||
.toolbar-acciones .tb-grupo-sec .tb-btn {
|
||||
justify-content: flex-start;
|
||||
border-color: transparent;
|
||||
padding: 10px 12px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.toolbar-acciones .tb-grupo-sec .tb-btn > span,
|
||||
.tb-grupo-sec .tb-solo-menu { display: inline; }
|
||||
.toolbar-acciones .tb-grupo-sec .tb-btn > span:not(.tb-solo-menu),
|
||||
.toolbar-acciones .tb-grupo-sec .tb-solo-menu { display: inline; }
|
||||
.tb-grupo-sec .tb-btn:hover { border-color: transparent; }
|
||||
.tb-grupo-sec .tb-btn-peligro:hover { background: rgba(185, 58, 46, .08); }
|
||||
.tb-menu-sep { display: block; height: 1px; margin: 4px 6px; background: var(--wam-borde); }
|
||||
/* dentro del menú desplegable «Volver al ejemplo» es una fila más */
|
||||
#btn-reset,
|
||||
.toolbar.tb-menu-abierto #btn-reset {
|
||||
display: inline-flex;
|
||||
position: static;
|
||||
padding: 10px 12px;
|
||||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Escala del teléfono con el ancho completo (sin restar el editor) y la
|
||||
altura real de la toolbar + barra de vista previa + pestañas. */
|
||||
@@ -566,10 +637,19 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
#btn-demos > span { display: none; }
|
||||
#btn-demos { padding: 7px 9px; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
/* la pestaña ya dice «Vista previa»: se gana una fila para el teléfono */
|
||||
#barra-movil .barra-titulo { display: none; }
|
||||
#barra-movil { justify-content: center; }
|
||||
#barra-movil .barra-etq-txt {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
@@ -577,6 +657,60 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
|
||||
.toolbar-marca { flex: 1; }
|
||||
}
|
||||
|
||||
/* --- Enlace «Saltar a la vista previa» (visible solo con el foco) ------ */
|
||||
.wam-saltar {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
top: 8px;
|
||||
z-index: 200;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--wam-primario);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transform: translateY(-160%);
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
.wam-saltar:focus { transform: none; outline: 2px solid #fff; outline-offset: -4px; }
|
||||
#chat-cuerpo:focus { outline: none; }
|
||||
#chat-cuerpo:focus-visible { outline: 2px solid var(--wam-acento); outline-offset: -3px; }
|
||||
#lista-mensajes:focus { outline: none; }
|
||||
|
||||
/* --- Etiqueta de la velocidad en la barra de la vista previa ---------- */
|
||||
#barra-movil .barra-etq {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wam-texto-suave);
|
||||
cursor: pointer;
|
||||
}
|
||||
#barra-movil #btn-reiniciar { display: inline-flex; align-items: center; gap: 6px; }
|
||||
#barra-movil #btn-reiniciar svg { flex: none; }
|
||||
|
||||
/* --- Chat vacío: pista dentro del teléfono -----------------------------
|
||||
body[data-wam-vacia] lo pone js/editor.js cuando la demo no tiene
|
||||
mensajes (así no parpadea al arrancar «Reproducir demo»). */
|
||||
body[data-wam-vacia] #chat-cuerpo:not(:has(> .wa-row))::after {
|
||||
content: "Añade mensajes desde el editor";
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: 80%;
|
||||
margin: 16px auto;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
color: #54656f;
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px .5px rgba(11, 20, 26, .13);
|
||||
}
|
||||
body[data-wam-vacia] .pantalla.oscuro #chat-cuerpo:not(:has(> .wa-row))::after { background: #182229; color: #8696a0; }
|
||||
body.presentacion[data-wam-vacia] #chat-cuerpo:not(:has(> .wa-row))::after { content: "Sin mensajes todavía"; }
|
||||
|
||||
/* Destinos táctiles más grandes */
|
||||
@media (pointer: coarse) {
|
||||
.tb-btn { min-height: 40px; }
|
||||
@@ -585,5 +719,5 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
|
||||
/* --- Accesibilidad: menos movimiento ------------------------------------ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#chat-cuerpo { scroll-behavior: auto; }
|
||||
.tb-btn, .wam-btn-anadir, .wam-esc { transition: none; }
|
||||
.tb-btn, .wam-btn-anadir, .wam-esc, .wam-saltar { transition: none; }
|
||||
}
|
||||
|
||||
+156
-28
@@ -276,6 +276,7 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
.ed-cab-principal {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -293,14 +294,22 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
flex: 0 0 auto;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
padding: 0 5px 0 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ed-acento-hondo);
|
||||
background: var(--ed-acento-suave);
|
||||
border-radius: 999px;
|
||||
}
|
||||
/* Dirección: flecha ↙ entrada / ↗ salida (además del color) */
|
||||
.ed-num-dir { font-size: 11px; line-height: 1; }
|
||||
.ed-num.ed-num-in { color: var(--ed-entrada); background: var(--ed-entrada-suave); }
|
||||
.ed-num.ed-num-resp { padding: 0 5px; }
|
||||
|
||||
.ed-dir {
|
||||
flex: 0 0 auto;
|
||||
@@ -321,19 +330,28 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
}
|
||||
|
||||
.ed-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 6px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 6px 1px 4px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--wam-texto, var(--ed-texto-suave));
|
||||
border: 1px solid var(--wam-borde, var(--ed-borde));
|
||||
border-radius: 999px;
|
||||
}
|
||||
.ed-badge .ed-ico { flex: none; display: inline-grid; place-items: center; color: var(--wam-texto-suave, var(--ed-texto-suave)); }
|
||||
.ed-badge .ed-ico svg { display: block; width: 13px; height: 13px; }
|
||||
.ed-badge-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.ed-resumen {
|
||||
flex: 1;
|
||||
flex: 1 1 60px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -341,6 +359,26 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
font-size: 12px;
|
||||
color: var(--wam-texto, var(--ed-texto-suave));
|
||||
}
|
||||
/* Tarjeta abierta: el resumen baja a su propia línea bajo la cabecera
|
||||
(así siempre se lee qué mensaje se está editando) */
|
||||
.ed-tarjeta { --ed-acc-w: 146px; } /* + ↑ ↓ ⧉ ✕ (26 px) + huecos */
|
||||
.ed-tarjeta.ed-anidada { --ed-acc-w: 88px; } /* ↑ ↓ ✕ */
|
||||
.ed-tarjeta.ed-abierta > .ed-tarjeta-cab { align-items: flex-start; }
|
||||
.ed-tarjeta.ed-abierta > .ed-tarjeta-cab > .ed-asa { margin-top: 2px; }
|
||||
.ed-tarjeta.ed-abierta > .ed-tarjeta-cab > .ed-cab-principal { flex-wrap: wrap; row-gap: var(--ed-acc-h, 8px); overflow: visible; }
|
||||
/* la 2.ª línea pasa por debajo de las acciones: usa todo el ancho de la tarjeta */
|
||||
.ed-tarjeta.ed-abierta > .ed-tarjeta-cab > .ed-cab-principal > .ed-resumen {
|
||||
order: 10;
|
||||
flex: 0 0 calc(100% + var(--ed-acc-w)); /* tamaño exterior = 100 %: fuerza la 2.ª línea */
|
||||
max-width: none;
|
||||
margin-right: calc(-1 * var(--ed-acc-w));
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.ed-acciones {
|
||||
flex: 0 0 auto;
|
||||
@@ -825,15 +863,22 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.ed-paleta .ed-tipo-card .ed-tipo-card-e { flex: none; overflow: visible; }
|
||||
.ed-paleta .ed-tipo-card.ed-seleccionada {
|
||||
/* último tipo usado: marca sutil (la paleta añade al hacer clic) */
|
||||
background: var(--wam-panel, var(--ed-panel));
|
||||
color: var(--wam-texto, var(--ed-texto));
|
||||
font-weight: 500;
|
||||
box-shadow: none;
|
||||
border-color: var(--ed-seleccion);
|
||||
}
|
||||
/* Sin estado «seleccionado»: cada clic añade. El «+» aparece con hover/foco. */
|
||||
.ed-paleta .ed-tipo-card:hover { background: var(--ed-seleccion-suave); }
|
||||
.ed-tipo-mas {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: width 0.12s ease, opacity 0.12s ease;
|
||||
}
|
||||
.ed-paleta .ed-tipo-card:hover .ed-tipo-mas,
|
||||
.ed-paleta .ed-tipo-card:focus-visible .ed-tipo-mas { width: 0.7em; opacity: 1; }
|
||||
@media (hover: none) {
|
||||
.ed-tipo-mas { width: 0.7em; opacity: 0.55; }
|
||||
}
|
||||
.ed-alta-pie {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -845,6 +890,52 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
font-size: 12px;
|
||||
color: var(--wam-texto-suave, var(--ed-texto-suave));
|
||||
}
|
||||
/* Chip visible del destino del próximo alta */
|
||||
.ed-destino-alta.ed-destino-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 26px;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--wam-borde, var(--ed-borde));
|
||||
border-radius: 999px;
|
||||
background: var(--ed-fondo-suave);
|
||||
font-weight: 600;
|
||||
color: var(--wam-texto, var(--ed-texto));
|
||||
}
|
||||
.ed-destino-alta.ed-destino-chip::before {
|
||||
content: "↧";
|
||||
font-weight: 700;
|
||||
color: var(--ed-seleccion);
|
||||
}
|
||||
.ed-destino-alta.ed-destino-dentro {
|
||||
border-color: var(--ed-seleccion);
|
||||
background: var(--ed-seleccion-suave);
|
||||
color: var(--ed-seleccion);
|
||||
padding-right: 3px;
|
||||
}
|
||||
.ed-destino-quitar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ed-destino-quitar:hover { background: rgba(43, 107, 228, 0.18); }
|
||||
.ed-destino-quitar:focus-visible { outline: 2px solid var(--ed-seleccion); outline-offset: 1px; }
|
||||
.ed-destino-cambio { animation: ed-destino-cambio 0.9s ease-out; }
|
||||
@keyframes ed-destino-cambio {
|
||||
0% { box-shadow: 0 0 0 3px var(--ed-seleccion-suave), 0 0 0 1px var(--ed-seleccion); }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
}
|
||||
.ed-enlace {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -955,20 +1046,43 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
}
|
||||
.ed-chip-aviso { color: var(--ed-ambar); background: rgba(180, 83, 9, 0.12); }
|
||||
.ed-chip-flujo { color: var(--ed-acento-hondo); background: var(--ed-acento-suave); }
|
||||
.ed-chip-auto { color: var(--ed-seleccion); background: var(--ed-seleccion-suave); }
|
||||
|
||||
/* «Se pulsa sola en la demo»: select arriba del formulario y casilla por elemento */
|
||||
.ed-campo-auto { max-width: 320px; }
|
||||
.ed-check-auto {
|
||||
justify-self: start;
|
||||
padding: 3px 8px 3px 6px;
|
||||
border: 1px dashed var(--ed-seleccion);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: var(--ed-seleccion);
|
||||
}
|
||||
.ed-check-auto input { accent-color: var(--ed-seleccion); }
|
||||
.ed-check-auto:has(input:checked) { border-style: solid; background: var(--ed-seleccion-suave); font-weight: 600; }
|
||||
.ed-chip-flujo-incompleto { color: var(--ed-entrada); background: var(--ed-entrada-suave); }
|
||||
.ed-num-resp { font-size: 12px; }
|
||||
|
||||
/* Las flechas ↑/↓ se muestran al abrir, al pasar el ratón o con el foco
|
||||
dentro (siguen en el orden de Tab: el foco las hace visibles) */
|
||||
dentro. Su hueco se reserva siempre (visibility, no display): el hover no
|
||||
provoca saltos de layout ni recorta el resumen. Con el foco en la
|
||||
cabecera se vuelven visibles y entran en el orden de Tab. */
|
||||
.ed-tarjeta { position: relative; }
|
||||
.ed-tarjeta:not(.ed-abierta):not(:hover):not(:focus-within) > .ed-tarjeta-cab .ed-mov {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Editor estrecho (contenido < 400 px, p. ej. ventanas de 1024 y 1280): en plegadas las
|
||||
flechas no ocupan sitio (se reordena con el asa o Alt+↑/↓) y aparecen
|
||||
al abrir la tarjeta, cuando el resumen ya va en su propia línea. */
|
||||
.ed-bloque-mensajes { container-type: inline-size; container-name: ed-mensajes; }
|
||||
@container ed-mensajes (max-width: 400px) {
|
||||
.ed-tarjeta:not(.ed-abierta) > .ed-tarjeta-cab .ed-mov { display: none; }
|
||||
}
|
||||
/* Muy estrecho (editor < 380 px, contenido ~330): la etiqueta de tipo queda solo como icono */
|
||||
@container ed-mensajes (max-width: 330px) {
|
||||
.ed-tarjeta-cab .ed-badge-txt { display: none; }
|
||||
.ed-tarjeta-cab .ed-badge { padding: 2px 4px; }
|
||||
}
|
||||
.ed-acciones .ed-btn-mini { min-width: 26px; min-height: 26px; padding: 2px 6px; }
|
||||
.ed-btn-mini:disabled { opacity: 0.35; }
|
||||
@@ -1033,16 +1147,23 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
.ed-vacio-acciones { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 4px; }
|
||||
|
||||
/* ---------- Toasts (fuera del scroll del panel) ---------- */
|
||||
/* Abajo, centrados sobre la columna de la vista previa: no tapan las
|
||||
tarjetas ni sus acciones mientras se trabaja en el editor. */
|
||||
.ed-toasts {
|
||||
--ed-col-editor: clamp(340px, 34vw, 480px); /* = columna del editor (base.css) */
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
left: calc(var(--ed-col-editor) + (100vw - var(--ed-col-editor)) / 2);
|
||||
bottom: 16px;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
max-width: min(440px, calc(100vw - 32px));
|
||||
width: max-content;
|
||||
max-width: min(440px, calc(100vw - var(--ed-col-editor) - 32px));
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
body.presentacion .ed-toasts { left: 50%; max-width: min(440px, calc(100vw - 32px)); }
|
||||
.ed-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1090,7 +1211,14 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.ed-toasts { bottom: calc(var(--wam-tabs-alto, 58px) + 70px); }
|
||||
.ed-toasts {
|
||||
left: 50%;
|
||||
max-width: min(440px, calc(100vw - 32px));
|
||||
bottom: calc(var(--wam-tabs-alto, 58px) + 64px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
body.vista-movil .ed-toasts { bottom: calc(var(--wam-tabs-alto, 58px) + 12px + env(safe-area-inset-bottom, 0px)); }
|
||||
/* hueco al final de la lista para el botón flotante «Ver teléfono» */
|
||||
#lista-mensajes { padding-bottom: 56px; }
|
||||
}
|
||||
|
||||
/* ---------- Enlace con la vista previa ---------- */
|
||||
@@ -1108,6 +1236,8 @@ body.presentacion #chat-cuerpo .wa-row.ed-resaltado { outline: 0; }
|
||||
|
||||
/* ---------- Destinos táctiles ---------- */
|
||||
@media (pointer: coarse) {
|
||||
.ed-tarjeta { --ed-acc-w: 176px; --ed-acc-h: 14px; }
|
||||
.ed-tarjeta.ed-anidada { --ed-acc-w: 106px; }
|
||||
.ed-btn-mini,
|
||||
.ed-acciones .ed-btn-mini { min-width: 32px; min-height: 32px; }
|
||||
.ed-fmt-btn { min-width: 32px; height: 32px; }
|
||||
@@ -1116,14 +1246,12 @@ body.presentacion #chat-cuerpo .wa-row.ed-resaltado { outline: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* cabecera de tarjeta: la dirección se lee en el color del número */
|
||||
.ed-tarjeta-cab .ed-dir { display: none; }
|
||||
.ed-num.ed-num-in { color: var(--ed-entrada); background: var(--ed-entrada-suave); }
|
||||
.ed-paleta-grupo { grid-template-columns: 1fr; gap: 4px; }
|
||||
.ed-paleta-titulo { padding-top: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ed-toast, .ed-tarjeta.ed-destello { animation: none; }
|
||||
.ed-toast, .ed-tarjeta.ed-destello, .ed-destino-cambio { animation: none; }
|
||||
.ed-tipo-mas { transition: none; }
|
||||
.ed-bloque-cuenta > .ed-cuenta-sumario::before, .ed-seg-op span { transition: none; }
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
margin-right: 4px;
|
||||
opacity: .8;
|
||||
}
|
||||
.wa .wa-cita-doc {
|
||||
display: inline-block;
|
||||
vertical-align: -2px;
|
||||
margin-right: 4px;
|
||||
color: var(--wa-text-meta);
|
||||
}
|
||||
.wa .wa-cita-mini {
|
||||
flex: none;
|
||||
width: 56px;
|
||||
@@ -100,6 +106,7 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
#btn-reiniciar,
|
||||
#sel-zoom,
|
||||
#sel-velocidad {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
@@ -112,8 +119,11 @@
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
#sel-zoom,
|
||||
#sel-velocidad { padding: 0 8px; }
|
||||
#btn-reiniciar:hover,
|
||||
#sel-zoom:hover,
|
||||
#sel-velocidad:hover { border-color: var(--wam-acento, #00a884); }
|
||||
#btn-reiniciar:focus-visible,
|
||||
#sel-zoom:focus-visible,
|
||||
#sel-velocidad:focus-visible { outline: 2px solid var(--wam-acento, #00a884); outline-offset: 2px; }
|
||||
|
||||
+89
-5
@@ -21,11 +21,11 @@
|
||||
|
||||
@supports (scale: calc(1vh / 1px)) {
|
||||
:root {
|
||||
/* 150 px = toolbar + barra-movil + aire; 352 px = editor mínimo + aire.
|
||||
/* toolbar + barra-movil (alturas reales, js/ux.js) + aire; 352 px = editor mínimo + aire.
|
||||
px/px → número (requisito de scale()); min/max solo comparan números. */
|
||||
--wam-escala: max(.3, min(
|
||||
1,
|
||||
(100vh - 150px) / 852px,
|
||||
(100vh - var(--wam-toolbar-alto, 57px) - var(--wam-barra-alto, 48px) - 44px) / 852px,
|
||||
(100vw - 352px) / 393px
|
||||
));
|
||||
}
|
||||
@@ -43,6 +43,45 @@
|
||||
que el teléfono crezca (en 1280×720 pasa de .67 a ~.73). */
|
||||
body.presentacion #barra-movil { display: none; }
|
||||
|
||||
/* Presentación en escritorio (ratón + teclado): captura limpia, sin toolbar.
|
||||
Se sale con Esc o P (js/ux.js); una pista flotante lo recuerda. */
|
||||
@media (min-width: 901px) and (hover: hover) and (pointer: fine) {
|
||||
body.presentacion #toolbar { display: none; }
|
||||
body.presentacion #panel-movil { position: relative; }
|
||||
body.presentacion #panel-movil::before {
|
||||
content: "Esc para salir";
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(11, 20, 26, .55);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
opacity: .75;
|
||||
z-index: 5;
|
||||
}
|
||||
@supports (scale: calc(1vh / 1px)) {
|
||||
body.presentacion {
|
||||
--wam-escala: max(.3, min(1, (100vh - 32px) / 852px, (100vw - 64px) / 393px));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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.
|
||||
Solo en escritorio y fuera de Presentación (ahí manda el ajuste). */
|
||||
@media (min-width: 901px) {
|
||||
html body[data-wam-zoom="100"]:not(.presentacion) { --wam-escala: 1; }
|
||||
html body[data-wam-zoom="125"]:not(.presentacion) { --wam-escala: 1.25; }
|
||||
body[data-wam-zoom="100"]:not(.presentacion) #panel-movil,
|
||||
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) ---------------------------- */
|
||||
#panel-movil {
|
||||
min-width: 0;
|
||||
@@ -61,18 +100,32 @@ body.presentacion #barra-movil { display: none; }
|
||||
#barra-movil {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-width: 820px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
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 {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--wam-texto-suave);
|
||||
}
|
||||
#btn-reproducir {
|
||||
@@ -185,7 +238,7 @@ body.presentacion #barra-movil { display: none; }
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: #fff; /* en oscuro */
|
||||
z-index: 45;
|
||||
z-index: 70; /* encima de la hoja inferior (.sheet z 60), como en iOS */
|
||||
pointer-events: none;
|
||||
}
|
||||
#pantalla:not(.oscuro) .home-indicator { background: #000; } /* en claro, negro */
|
||||
@@ -202,7 +255,7 @@ body.presentacion #barra-movil { display: none; }
|
||||
overflow-x: hidden;
|
||||
list-style: none;
|
||||
padding: 8px 0 14px;
|
||||
scroll-behavior: smooth;
|
||||
scroll-behavior: auto; /* el suave solo en enfocarEnChat (behavior explícito) */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@@ -241,6 +294,37 @@ body.presentacion #barra-movil { display: none; }
|
||||
/* --- Modo presentación: el iPhone se centra y respira más --------------- */
|
||||
body.presentacion #panel-movil { padding: 14px 20px 22px; }
|
||||
|
||||
/* --- Móvil real (≤ 520 px): sin marco ----------------------------------
|
||||
En el propio teléfono del consultor sobra el iPhone dibujado: la pantalla
|
||||
ocupa el ancho y el alto disponibles a escala 1 (sin isla, status bar ni
|
||||
indicador home duplicados). body.con-marco lo desactiva. */
|
||||
@media (max-width: 520px) {
|
||||
html body:not(.con-marco) { --wam-escala: 1; }
|
||||
body:not(.con-marco) #panel-movil { padding-left: 0; padding-right: 0; }
|
||||
body:not(.con-marco) #barra-movil { padding: 0 16px; margin-bottom: 8px; }
|
||||
body:not(.con-marco) .iphone-escena {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 420px;
|
||||
margin: 0;
|
||||
}
|
||||
body:not(.con-marco) .iphone {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
body:not(.con-marco) .pantalla { border-radius: 0; padding-top: 0; }
|
||||
body:not(.con-marco) .island,
|
||||
body:not(.con-marco) .statusbar,
|
||||
body:not(.con-marco) .home-indicator { display: none; }
|
||||
body:not(.con-marco) .wa #composer { padding-bottom: 8px; }
|
||||
body:not(.con-marco) .wa .sheet-panel { padding-bottom: 12px; }
|
||||
}
|
||||
|
||||
/* --- Accesibilidad: menos movimiento ------------------------------------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#btn-reproducir { transition: none; }
|
||||
|
||||
+28
-14
@@ -69,7 +69,7 @@
|
||||
--wa-chrome: #121313;
|
||||
--wa-chrome-text: #ffffff;
|
||||
--wa-header-icon: #ffffff;
|
||||
--wa-compose-bar: #121313;
|
||||
--wa-compose-bar: #232626; /* barra [medido]; el header sí es #121313 */
|
||||
--wa-compose-field: #393c3c; /* [medido] */
|
||||
--wa-compose-border: transparent;
|
||||
--wa-separator: rgba(255, 255, 255, .10);
|
||||
@@ -106,7 +106,6 @@
|
||||
|
||||
/* Reset de <button> dentro del chat (botones de flujo accesibles) */
|
||||
.wa button.qr-btn,
|
||||
.wa button.sheet-fila,
|
||||
.wa button.sheet-cerrar,
|
||||
.wa button.contact-accion {
|
||||
font: inherit;
|
||||
@@ -116,6 +115,16 @@
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
/* Las filas de la hoja conservan su separador (lista-hoja-inferior.png) */
|
||||
.wa button.sheet-fila {
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
border: 0;
|
||||
border-bottom: .5px solid var(--wa-sheet-sep);
|
||||
background: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* ---------- 4. Header del chat (iOS: chevron negro, avatar 36, iconos de línea) ---------- */
|
||||
.wa #chat-header {
|
||||
@@ -127,7 +136,7 @@
|
||||
background: var(--wa-chrome);
|
||||
color: var(--wa-chrome-text);
|
||||
border-bottom: .5px solid var(--wa-separator);
|
||||
transition: background-color .2s ease;
|
||||
transition: background-color .2s ease, color .2s ease;
|
||||
}
|
||||
.wa .wa-back { width: 22px; flex: none; display: flex; color: var(--wa-chrome-text); padding-left: 4px; }
|
||||
.wa .wa-avatar,
|
||||
@@ -139,6 +148,7 @@
|
||||
flex: none;
|
||||
background: var(--wa-separator);
|
||||
}
|
||||
.wa #chat-avatar { position: relative; z-index: 1; }
|
||||
.wa .wa-header-tit { flex: 1; min-width: 0; }
|
||||
.wa .header-title {
|
||||
font-size: 17px;
|
||||
@@ -199,14 +209,14 @@
|
||||
position: relative;
|
||||
z-index: 0; /* contexto: la cola queda bajo el contenido */
|
||||
max-width: 78%;
|
||||
padding: 6px 12px 7px;
|
||||
padding: 7px 12px 6px; /* 7 arriba / 6 abajo [medido, README §2.2] */
|
||||
border-radius: 18px;
|
||||
background: var(--wa-bubble-in);
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
transition: background-color .2s ease;
|
||||
transition: background-color .2s ease, color .2s ease;
|
||||
}
|
||||
.wa .out .bubble { background: var(--wa-bubble-out); }
|
||||
|
||||
@@ -615,7 +625,7 @@
|
||||
background: var(--wa-bubble-in);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background-color .2s ease;
|
||||
transition: background-color .2s ease, color .2s ease;
|
||||
}
|
||||
.wa .card-media {
|
||||
display: block;
|
||||
@@ -737,15 +747,17 @@
|
||||
margin-left: 16px;
|
||||
padding-left: 0;
|
||||
}
|
||||
.wa .sheet-opciones > .sheet-seccion:first-child { padding-top: 12px; }
|
||||
.wa .sheet-opciones > .sheet-seccion:first-child { padding-top: 30px; }
|
||||
.wa .sheet-grupo { margin: 0; }
|
||||
.wa .sheet-grupo + .sheet-grupo { margin-top: 18px; border-top: .5px solid var(--wa-sheet-sep); }
|
||||
/* "Todas las opciones": un único separador (con sangría) entre grupos */
|
||||
.wa .sheet-grupo + .sheet-grupo { margin: 0 0 0 16px; border-top: .5px solid var(--wa-sheet-sep); }
|
||||
.wa .sheet-grupo + .sheet-grupo > .sheet-fila { margin-left: 0; width: 100%; }
|
||||
.wa .sheet-fila {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 52px;
|
||||
padding: 12px 16px 12px 0;
|
||||
padding: 17px 16px 17px 0;
|
||||
margin: 0 0 0 16px;
|
||||
width: calc(100% - 16px);
|
||||
border-bottom: .5px solid var(--wa-sheet-sep);
|
||||
@@ -757,10 +769,12 @@
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.wa .sheet-fila:active { background: rgba(0, 0, 0, .05); }
|
||||
.wa .sheet-fila-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.wa .sheet-fila.qr-btn-inactiva:active { background: none; }
|
||||
.wa .sheet-fila-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0; }
|
||||
.wa .sheet-fila-titulo { font-size: 17px; line-height: 22px; }
|
||||
.wa .sheet-fila-desc { font-size: 15px; line-height: 20px; letter-spacing: 0; color: var(--wa-sheet-sub); }
|
||||
.wa .sheet-fila-btn { color: var(--wa-action); min-height: 48px; }
|
||||
.wa .sheet-fila-desc { font-size: 15px; line-height: 18px; letter-spacing: 0; color: var(--wa-sheet-sub); }
|
||||
.wa .sheet-fila-btn,
|
||||
.wa button.sheet-fila-btn { color: var(--wa-action); min-height: 48px; padding-top: 12px; padding-bottom: 12px; border-bottom: 0; }
|
||||
.wa .sheet-fila-btn .sheet-fila-titulo { color: var(--wa-action); }
|
||||
.wa .sheet-fila .qr-ico { flex: none; display: flex; color: var(--wa-action); }
|
||||
.wa .sheet-pie {
|
||||
@@ -780,8 +794,8 @@
|
||||
gap: 12px;
|
||||
padding: 6px 12px 34px 10px; /* 34 pt de safe area bajo el home indicator */
|
||||
background: var(--wa-compose-bar);
|
||||
border-top: 0;
|
||||
transition: background-color .2s ease;
|
||||
border-top: .5px solid var(--wa-separator); /* hairline como el del header */
|
||||
transition: background-color .2s ease, color .2s ease;
|
||||
}
|
||||
.wa #composer .attach,
|
||||
.wa #composer .cmp-icono {
|
||||
|
||||
+47
-23
@@ -16,6 +16,9 @@
|
||||
</head>
|
||||
<body data-tema="claro">
|
||||
|
||||
<!-- Teclado: salta las tarjetas del editor directo al teléfono (atajo G / F6) -->
|
||||
<a id="saltar-vista" class="wam-saltar" href="#chat-cuerpo">Saltar a la vista previa</a>
|
||||
|
||||
<div id="app">
|
||||
|
||||
<!-- ============ Barra superior de la app (shell A) ============ -->
|
||||
@@ -28,6 +31,12 @@
|
||||
<h1>WhatsApp Cloud API</h1>
|
||||
<span class="toolbar-subtitulo">Mockup visual · demo estática</span>
|
||||
</div>
|
||||
<!-- Punto de partida del consultor: escenarios por industria y demos guardadas -->
|
||||
<button id="btn-demos" class="tb-btn tb-btn-primario" type="button" title="Nueva demo: escenarios por industria y demos guardadas (N)" aria-keyshortcuts="N">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.6"/><path d="M17 14v6M14 17h6"/></svg>
|
||||
<span>Nueva demo</span>
|
||||
<kbd class="tb-kbd" aria-hidden="true">N</kbd>
|
||||
</button>
|
||||
<!-- Estado del guardado local (lo pinta js/ux.js) -->
|
||||
<span id="estado-guardado" class="tb-guardado" data-estado="ok" title="Guardado en este navegador">
|
||||
<span class="tb-guardado-punto" aria-hidden="true"></span>
|
||||
@@ -44,24 +53,18 @@
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 14 5-5-5-5"/><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"/></svg>
|
||||
</button>
|
||||
<span class="tb-separador" aria-hidden="true"></span>
|
||||
<button id="btn-tema" class="tb-btn" type="button" title="Alternar tema claro / oscuro (T)" aria-label="Tema oscuro" aria-pressed="false">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 2.6a9.4 9.4 0 1 0 0 18.8 9.4 9.4 0 0 0 0-18.8zm0 16.9V4.5a7.5 7.5 0 0 1 0 15z"/></svg>
|
||||
<span>Tema</span>
|
||||
</button>
|
||||
<button id="btn-presentacion" class="tb-btn" type="button" title="Modo presentación: oculta el editor (P · Esc para salir)" aria-pressed="false">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" fill-rule="evenodd" aria-hidden="true"><path d="M3 4.5h18a1 1 0 0 1 1 1V16a1 1 0 0 1-1 1h-6.4v2.1h1.9a.9.9 0 1 1 0 1.8H7.5a.9.9 0 1 1 0-1.8h1.9V17H3a1 1 0 0 1-1-1V5.5a1 1 0 0 1 1-1zm8 3.4v5.7l4.6-2.85z"/></svg>
|
||||
<span>Presentación</span>
|
||||
</button>
|
||||
<span class="tb-separador tb-sep-sec" aria-hidden="true"></span>
|
||||
<!-- «⋯»: en ventana estrecha agrupa las acciones secundarias -->
|
||||
<button id="btn-mas" class="tb-btn tb-btn-icono" type="button" aria-label="Más acciones" aria-expanded="false" aria-controls="tb-menu" title="Más acciones">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="19" cy="12" r="1.9"/></svg>
|
||||
<button id="btn-tema" class="tb-btn tb-btn-icono" type="button" title="Alternar tema claro / oscuro (T)" aria-label="Tema oscuro" aria-pressed="false">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 2.6a9.4 9.4 0 1 0 0 18.8 9.4 9.4 0 0 0 0-18.8zm0 16.9V4.5a7.5 7.5 0 0 1 0 15z"/></svg>
|
||||
</button>
|
||||
<span class="tb-separador tb-sep-sec" aria-hidden="true"></span>
|
||||
<!-- Acciones secundarias: en línea en escritorio; en ventana estrecha
|
||||
forman el menú «⋯». «Volver al ejemplo» (destructiva) vive siempre
|
||||
dentro del menú «⋯», al final y con estilo de peligro. -->
|
||||
<div id="tb-menu" class="tb-grupo-sec">
|
||||
<button id="btn-demos" class="tb-btn" type="button" title="Nueva demo: escenarios por industria y demos guardadas (N)">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.6"/><path d="M17 14v6M14 17h6"/></svg>
|
||||
<span>Demos</span>
|
||||
</button>
|
||||
<button id="btn-exportar" class="tb-btn" type="button" title="Exportar la demo a JSON (Ctrl+S)">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 3.2c.5 0 .9.4.9.9v8.2l2.7-2.7a.9.9 0 1 1 1.3 1.3l-4.2 4.2a.9.9 0 0 1-1.3 0L7.2 11a.9.9 0 1 1 1.3-1.3l2.7 2.7V4.1c0-.5.4-.9.9-.9zM4.9 18.5h14.2a.9.9 0 1 1 0 1.8H4.9a.9.9 0 1 1 0-1.8z"/></svg>
|
||||
<span>Exportar</span>
|
||||
@@ -71,15 +74,19 @@
|
||||
<span>Importar</span>
|
||||
</button>
|
||||
<input type="file" id="importar-archivo" accept="application/json,.json" hidden>
|
||||
<button id="btn-reset" class="tb-btn" type="button" title="Restablecer la demo de ejemplo (pide confirmación)">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 5V2.8a.7.7 0 0 0-1.13-.55L6.9 5.2a.7.7 0 0 0 0 1.1l3.97 2.95A.7.7 0 0 0 12 8.7V6.5a5.5 5.5 0 1 1-5.5 5.5H4.75A7.25 7.25 0 1 0 12 5z"/></svg>
|
||||
<span>Restablecer</span>
|
||||
</button>
|
||||
<button id="btn-atajos" class="tb-btn tb-btn-icono" type="button" title="Atajos de teclado (?)" aria-label="Atajos de teclado">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="6" width="19" height="12" rx="2.2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7.5 14h9"/></svg>
|
||||
<span class="tb-solo-menu">Atajos</span>
|
||||
</button>
|
||||
<span class="tb-menu-sep" aria-hidden="true"></span>
|
||||
<button id="btn-reset" class="tb-btn tb-btn-peligro" type="button" title="Sustituye la demo actual por el ejemplo Café Aurora (pide confirmación y se puede deshacer)">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 5V2.8a.7.7 0 0 0-1.13-.55L6.9 5.2a.7.7 0 0 0 0 1.1l3.97 2.95A.7.7 0 0 0 12 8.7V6.5a5.5 5.5 0 1 1-5.5 5.5H4.75A7.25 7.25 0 1 0 12 5z"/></svg>
|
||||
<span>Volver al ejemplo Café Aurora</span>
|
||||
</button>
|
||||
</div>
|
||||
<button id="btn-mas" class="tb-btn tb-btn-icono" type="button" aria-label="Más acciones" aria-expanded="false" aria-controls="tb-menu" title="Más acciones">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="19" cy="12" r="1.9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -103,19 +110,20 @@
|
||||
<div class="wam-alta">
|
||||
<!-- Paleta de los 13 tipos (la rellena js/editor.js): un clic AÑADE el mensaje.
|
||||
El último tipo usado queda en #nuevo-tipo-valor (hidden) y en data-valor. -->
|
||||
<div id="nuevo-tipo" class="ed-paleta" role="toolbar" aria-label="Añadir un mensaje: elige el tipo"></div>
|
||||
<div id="nuevo-tipo" class="ed-paleta" role="toolbar" aria-label="Añadir un mensaje: cada tipo añade uno"></div>
|
||||
<input type="hidden" id="nuevo-tipo-valor" value="text">
|
||||
<div class="ed-alta-pie">
|
||||
<span id="ed-destino-alta" class="ed-destino-alta" aria-live="polite"></span>
|
||||
<button id="btn-anadir" class="wam-btn-anadir" type="button">+ Otro «Texto»</button>
|
||||
<!-- «Repetir último»: aparece tras el primer alta de la sesión -->
|
||||
<button id="btn-anadir" class="wam-btn-anadir" type="button" hidden>+ Otro «Texto»</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-lista-herr">
|
||||
<div class="ed-lista-herr" id="ed-lista-herr">
|
||||
<button type="button" class="ed-btn ed-btn-mini" data-acc="horas-secuencia" title="Reparte las horas desde la del primer mensaje, +1 min por mensaje">Horas en secuencia</button>
|
||||
<button type="button" class="ed-btn ed-btn-mini" data-acc="plegar-todo">Plegar todo</button>
|
||||
</div>
|
||||
<!-- Una tarjeta colapsable por mensaje la pinta js/editor.js (C) -->
|
||||
<div id="lista-mensajes"></div>
|
||||
<div id="lista-mensajes" tabindex="-1" aria-label="Tarjetas de mensajes"></div>
|
||||
</section>
|
||||
|
||||
<!-- Solo en ventana estrecha: salta a la vista previa del mensaje en edición -->
|
||||
@@ -130,12 +138,28 @@
|
||||
<div id="barra-movil">
|
||||
<span class="barra-titulo">Vista previa</span>
|
||||
<div class="barra-acciones">
|
||||
<select id="sel-velocidad" title="Velocidad de la reproducción" aria-label="Velocidad de la reproducción">
|
||||
<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">
|
||||
<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>
|
||||
</label>
|
||||
<select id="sel-velocidad" title="Velocidad de la reproducción">
|
||||
<option value="0.5">0,5x</option>
|
||||
<option value="1" selected>1x</option>
|
||||
<option value="2">2x</option>
|
||||
</select>
|
||||
<button id="btn-reiniciar" type="button" title="Descartar lo pulsado y escrito en el teléfono y volver al guion">Reiniciar chat</button>
|
||||
<button id="btn-reiniciar" type="button" title="Descarta lo pulsado y escrito en el teléfono y vuelve al guion desde el primer mensaje">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/></svg>
|
||||
<span>Repetir desde el inicio</span>
|
||||
</button>
|
||||
<button id="btn-reproducir" type="button" title="Reproducir la demo paso a paso (clic de nuevo para detener)">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8.4 5.6v12.8c0 .8.9 1.3 1.6.9l10-6.4c.6-.4.6-1.4 0-1.8l-10-6.4c-.7-.4-1.6.1-1.6.9z"/></svg>
|
||||
<span>Reproducir demo</span>
|
||||
@@ -199,7 +223,7 @@
|
||||
</header>
|
||||
|
||||
<!-- Scroll de mensajes (estructura A · filas li.wa-row de B) -->
|
||||
<ul id="chat-cuerpo" aria-live="polite"></ul>
|
||||
<ul id="chat-cuerpo" aria-live="polite" tabindex="-1" aria-label="Conversación"></ul>
|
||||
|
||||
<!-- Indicador "escribiendo…" (estructura A · contenido y animación de B) -->
|
||||
<div id="chat-escribiendo"></div>
|
||||
|
||||
+227
-36
@@ -110,6 +110,7 @@
|
||||
var ultimaAbierta = null; // id top-level abierto más reciente (posición de alta)
|
||||
var insertarTras = null; // id tras el que insertar (botón «+» de una tarjeta)
|
||||
var ultimoTipo = "text";
|
||||
var yaAnadido = false; // ¿se añadió algo desde la paleta en esta sesión?
|
||||
var ultimoEnlazado = null; // id resaltado en la vista previa
|
||||
var tResalte = 0;
|
||||
var contadorIds = 0; // ids únicos para <label for>
|
||||
@@ -244,9 +245,9 @@
|
||||
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. */
|
||||
/* Aviso flotante (toast) abajo, centrado sobre la vista previa (fuera de
|
||||
la lista de tarjetas). opts: { tipo: "error", ms, accion: { texto, fn } }.
|
||||
Solo hay un aviso con acción a la vez. Devuelve una función que lo cierra. */
|
||||
function contenedorToasts() {
|
||||
var c = document.getElementById("ed-toasts");
|
||||
if (!c) {
|
||||
@@ -280,14 +281,24 @@
|
||||
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);
|
||||
// Un aviso con acción nuevo invalida los anteriores con acción: su
|
||||
// «Deshacer» ya no desharía lo que dice (se deshace de uno en uno).
|
||||
if (opts.accion && typeof opts.accion.fn === "function") {
|
||||
hijos = hijos.filter(function (x) {
|
||||
if (!x.querySelector(".ed-toast-accion")) return true;
|
||||
if (typeof x.__cerrar === "function") x.__cerrar(); else if (x.parentNode) x.parentNode.removeChild(x);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
// Como mucho 2 a la vista: primero se retiran los que no tienen acción
|
||||
var sinAccion = hijos.filter(function (x) { return !x.querySelector(".ed-toast-accion"); });
|
||||
while (hijos.length >= 2) {
|
||||
var quitar = sinAccion.length ? sinAccion.shift() : hijos[0];
|
||||
hijos.splice(hijos.indexOf(quitar), 1);
|
||||
if (quitar.parentNode) quitar.parentNode.removeChild(quitar);
|
||||
}
|
||||
t.__cerrar = cerrar;
|
||||
c.appendChild(t);
|
||||
timer = setTimeout(cerrar, opts.ms || (esError ? 7000 : 3000));
|
||||
return cerrar;
|
||||
@@ -439,7 +450,10 @@
|
||||
if (obj[k] === null || obj[k] === undefined) obj[k] = /^\d+$/.test(partes[i + 1]) ? [] : {};
|
||||
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();
|
||||
marcarEditado();
|
||||
if (opts && opts.diferido) { programarRender(String(ruta)); return; }
|
||||
@@ -500,14 +514,51 @@
|
||||
if (car2) car2.scrollLeft = sl;
|
||||
}
|
||||
|
||||
// Reemplaza un array completo vía setCampo (mutación contractual) y repinta tarjetas
|
||||
/* --- autoPulsar (SPEC §1): índice del elemento que «Reproducir demo»
|
||||
pulsa solo. Botones (template/buttons) y tarjetas (carousel) usan su
|
||||
índice en el array; las listas, el índice aplanado de todas sus filas. */
|
||||
function pulsablesDe(m) {
|
||||
if (!m || typeof m !== "object") return [];
|
||||
if (m.tipo === "list") {
|
||||
var out = [];
|
||||
(m.secciones || []).forEach(function (s) { ((s && s.filas) || []).forEach(function (f) { out.push(f); }); });
|
||||
return out;
|
||||
}
|
||||
if (m.tipo === "carousel") return Array.isArray(m.cards) ? m.cards : [];
|
||||
if (m.tipo === "template" || m.tipo === "buttons") return Array.isArray(m.botones) ? m.botones : [];
|
||||
return [];
|
||||
}
|
||||
|
||||
function tieneAuto(m) { return !!m && typeof m.autoPulsar === "number" && isFinite(m.autoPulsar) && m.autoPulsar >= 0; }
|
||||
|
||||
// Ruta del mensaje dueño de una lista de botones / cards / secciones / filas
|
||||
function rutaMensajeDe(rutaArr) {
|
||||
var r = String(rutaArr || "");
|
||||
var m = /^(.*)\.secciones\.\d+\.filas$/.exec(r) || /^(.*)\.(?:botones|cards|secciones)$/.exec(r);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// Reemplaza un array completo vía setCampo (mutación contractual) y repinta tarjetas.
|
||||
// Si el array es de botones / cards / secciones / filas, autoPulsar sigue
|
||||
// al elemento pulsado (se mueve con él; se quita si se borró).
|
||||
function mutarArray(ruta, fn) {
|
||||
var arr = rutaGet(ruta);
|
||||
if (!Array.isArray(arr)) { aviso("Estructura no encontrada: " + ruta, "error"); return null; }
|
||||
var copia = fn(arr.slice());
|
||||
if (!copia) return null; // validación rechazada (fn ya avisó)
|
||||
snap();
|
||||
var rutaMsg = rutaMensajeDe(ruta);
|
||||
var dueno = rutaMsg ? rutaGet(rutaMsg) : null;
|
||||
var pulsado = tieneAuto(dueno) ? pulsablesDe(dueno)[dueno.autoPulsar] : undefined;
|
||||
WAM.setCampo(ruta, copia);
|
||||
if (pulsado !== undefined) {
|
||||
var nuevo = pulsablesDe(rutaGet(rutaMsg)).indexOf(pulsado);
|
||||
if (nuevo !== dueno.autoPulsar) {
|
||||
var padre = rutaGet(rutaMsg);
|
||||
if (nuevo < 0) delete padre.autoPulsar; else padre.autoPulsar = nuevo;
|
||||
WAM.setCampo(rutaMsg, padre);
|
||||
}
|
||||
}
|
||||
WAM.renderTarjetas();
|
||||
return copia;
|
||||
}
|
||||
@@ -816,7 +867,8 @@
|
||||
snap();
|
||||
WAM.setCampo("mensajes", copia);
|
||||
WAM.renderTarjetas();
|
||||
// Foco a la tarjeta vecina (la siguiente o, si era la última, la anterior)
|
||||
// Foco a la CABECERA de la tarjeta vecina (la siguiente o, si era la
|
||||
// última, la anterior), nunca a su botón borrar; sin tarjetas, la paleta.
|
||||
var vecino = copia[i] || copia[i - 1];
|
||||
if (vecino) enfocarTarjeta(vecino.id, false);
|
||||
else { var pal = paletaFoco(); if (pal) pal.focus(); }
|
||||
@@ -929,6 +981,8 @@
|
||||
} finally {
|
||||
WAM.estado = previo;
|
||||
}
|
||||
// Se lee ya: cualquier normalización posterior sobrescribe el resumen
|
||||
var recortes = typeof WAM.describirRecortes === "function" && datos ? WAM.describirRecortes(datos.__recortes) : "";
|
||||
if (!datos || !Array.isArray(datos.mensajes)) throw new Error("sin mensajes");
|
||||
snap();
|
||||
WAM.estado = datos;
|
||||
@@ -943,7 +997,7 @@
|
||||
if (typeof WAM.render === "function") WAM.render();
|
||||
WAM.renderEditor();
|
||||
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);
|
||||
var primero = listaNodo() && listaNodo().querySelector(".ed-cab-principal");
|
||||
if (primero) primero.focus();
|
||||
@@ -989,7 +1043,18 @@
|
||||
------------------------------------------------------------------ */
|
||||
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()); }
|
||||
// Con esquema http(s) o con forma de dominio («www.cafe.com», «cafe.com/menu»):
|
||||
// render.js abre estas últimas con https://, igual que WhatsApp.
|
||||
function urlValida(v) {
|
||||
var s = String(v || "").trim();
|
||||
return /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(s) ||
|
||||
/^www\.[^\s]+$/i.test(s) || /^[\w-]+(\.[\w-]+)+(:\d+)?([\/?#][^\s]*)?$/.test(s);
|
||||
}
|
||||
// Botón de llamada de plantilla: tel: con al menos 3 dígitos (espacios, guiones y paréntesis permitidos)
|
||||
function telValido(v) {
|
||||
var s = String(v || "").trim();
|
||||
return /^tel:\+?[\d\s().-]+$/i.test(s) && (s.replace(/\D/g, "").length >= 3);
|
||||
}
|
||||
|
||||
function validar(m, prefijo, profundidad) {
|
||||
var av = [];
|
||||
@@ -1032,12 +1097,16 @@
|
||||
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;
|
||||
// tel:+… es el botón de llamada (PHONE_NUMBER de la API): máximo 1 y
|
||||
// no cuenta entre los 2 de URL
|
||||
var conTel = bt.filter(function (b) { return b && telValido(b.url); }).length;
|
||||
var conUrl = bt.filter(function (b) { return b && !vacioTxt(b.url) && !telValido(b.url); }).length;
|
||||
if (conUrl > 2) add("Máximo 2 botones con URL (" + conUrl + ")");
|
||||
if (conTel > 1) add("Máximo 1 botón de llamada tel: (" + conTel + ")");
|
||||
bt.forEach(function (b, i) {
|
||||
if (vacioTxt(b.texto)) add("Botón " + (i + 1) + ": sin texto");
|
||||
else lim(b.texto, 25, "Botón " + (i + 1));
|
||||
if (!vacioTxt(b.url) && !urlValida(b.url)) add("Botón " + (i + 1) + ": URL no válida (usa https://…)");
|
||||
if (!vacioTxt(b.url) && !urlValida(b.url) && !telValido(b.url)) add("Botón " + (i + 1) + ": URL no válida (usa https://… o tel:+34…)");
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -1110,6 +1179,13 @@
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
// «Se pulsa sola» apunta a una opción que ya no existe o que abre una URL
|
||||
if (tieneAuto(m) && ["template", "buttons", "list", "carousel"].indexOf(m.tipo) >= 0) {
|
||||
var objAuto = pulsablesDe(m)[m.autoPulsar];
|
||||
var nAuto = "«Se pulsa sola en la demo»: la opción " + (m.autoPulsar + 1);
|
||||
if (!objAuto) add(nAuto + " ya no existe (la demo no pulsará nada)");
|
||||
else if (!vacioTxt(objAuto.url)) add(nAuto + " abre una URL y la demo no la pulsará");
|
||||
}
|
||||
if ((profundidad || 0) < 6) {
|
||||
origenesRespuesta(m).forEach(function (o) {
|
||||
o.respuestas.forEach(function (r) {
|
||||
@@ -1150,6 +1226,16 @@
|
||||
"aria-label": av.length + (av.length === 1 ? " aviso" : " avisos")
|
||||
}, [h("span", { "aria-hidden": "true", text: "⚠ " + av.length })]));
|
||||
}
|
||||
// ▶ auto: la demo pulsa sola una opción de este mensaje
|
||||
if (tieneAuto(m)) {
|
||||
var objA = pulsablesDe(m)[m.autoPulsar];
|
||||
if (objA && vacioTxt(objA.url)) {
|
||||
var etA = objA.texto || objA.titulo || objA.textoBoton || ("opción " + (m.autoPulsar + 1));
|
||||
var tA = "En «Reproducir demo» se pulsa sola: «" + etA + "»";
|
||||
frag.appendChild(h("span", { class: "ed-chip ed-chip-auto", title: tA, "aria-label": tA },
|
||||
[h("span", { "aria-hidden": "true", text: "▶ auto" })]));
|
||||
}
|
||||
}
|
||||
var f = contarFlujos(m);
|
||||
if (f.total) {
|
||||
var txt = "↳ " + f.con + (f.sin ? "/" + f.total : "");
|
||||
@@ -1188,6 +1274,8 @@
|
||||
if (!cab) return;
|
||||
var res = cab.querySelector(".ed-resumen");
|
||||
if (res) res.textContent = resumenDe(m);
|
||||
var cp = cab.querySelector(".ed-cab-principal");
|
||||
if (cp) cp.title = (t.classList.contains("ed-abierta") ? "Plegar" : "Desplegar") + " · " + resumenDe(m);
|
||||
var chips = cab.querySelector(".ed-chips");
|
||||
if (chips) {
|
||||
while (chips.firstChild) chips.removeChild(chips.firstChild);
|
||||
@@ -1477,7 +1565,8 @@
|
||||
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,
|
||||
o.conUrl ? edCampo("URL (si la pones, abre el enlace en vez de responder; tel:+34… llama)", ruta + "." + i + ".url", b.url, { placeholder: "https://…" }) : null,
|
||||
edCheckAuto(rutaMensajeDe(ruta), i, b),
|
||||
edRespuestas(ruta + "." + i + ".respuestas", b.respuestas, o.raiz)
|
||||
]);
|
||||
});
|
||||
@@ -1503,9 +1592,13 @@
|
||||
|
||||
function edSeccionesEd(ruta, secciones, raiz) {
|
||||
var lista = Array.isArray(secciones) ? secciones : [];
|
||||
var rutaMsgLista = rutaMensajeDe(ruta);
|
||||
var base = 0; // índice aplanado de la primera fila de la sección (autoPulsar)
|
||||
var hijos = lista.map(function (s, si) {
|
||||
var filas = Array.isArray(s.filas) ? s.filas : [];
|
||||
var rutaFilas = ruta + "." + si + ".filas";
|
||||
var offset = base;
|
||||
base += filas.length;
|
||||
var filaEls = filas.map(function (f, fi) {
|
||||
return h("div", { class: "ed-item", "data-drag-ruta": rutaFilas, "data-idx": fi }, [
|
||||
h("div", { class: "ed-sub-cab" }, [
|
||||
@@ -1516,6 +1609,7 @@
|
||||
]),
|
||||
edCampo("Título", rutaFilas + "." + fi + ".titulo", f.titulo, { limite: 24 }),
|
||||
edCampo("Descripción", rutaFilas + "." + fi + ".descripcion", f.descripcion, { limite: 72 }),
|
||||
edCheckAuto(rutaMsgLista, offset + fi, f),
|
||||
edRespuestas(rutaFilas + "." + fi + ".respuestas", f.respuestas, raiz)
|
||||
]);
|
||||
});
|
||||
@@ -1572,6 +1666,7 @@
|
||||
edCampo("Texto del botón", ruta + "." + ci + ".textoBoton", c.textoBoton, { limite: 20 }),
|
||||
edCampo("URL (opcional)", ruta + "." + ci + ".url", c.url, { placeholder: "https://…" })
|
||||
]),
|
||||
edCheckAuto(rutaMensajeDe(ruta), ci, c),
|
||||
edRespuestas(ruta + "." + ci + ".respuestas", c.respuestas, raiz)
|
||||
]);
|
||||
});
|
||||
@@ -1598,10 +1693,14 @@
|
||||
var caja = h("div", { class: "ed-header-bloque" });
|
||||
caja.appendChild(h("div", { class: "ed-sub-cab" }, [
|
||||
h("span", { class: "ed-sub-titulo", text: "Cabecera" }),
|
||||
// Etiqueta fija (no se invierte con el estado): el interruptor dice si se muestra
|
||||
h("label", { class: "ed-switch" }, [
|
||||
h("input", { type: "checkbox", "data-rol": "header-toggle", "data-ruta": ruta, checked: tipo !== "" }),
|
||||
h("input", {
|
||||
type: "checkbox", role: "switch", "aria-checked": tipo !== "" ? "true" : "false",
|
||||
"data-rol": "header-toggle", "data-ruta": ruta, checked: tipo !== ""
|
||||
}),
|
||||
h("span", { class: "ed-switch-pista" }, [h("span", { class: "ed-switch-nudo" })]),
|
||||
h("span", { class: "ed-switch-texto", text: tipo === "" ? "Mostrar cabecera" : "Ocultar cabecera" })
|
||||
h("span", { class: "ed-switch-texto", text: "Mostrar cabecera" })
|
||||
])
|
||||
]));
|
||||
if (tipo !== "") {
|
||||
@@ -1628,6 +1727,9 @@
|
||||
caja.appendChild(edCampo("Nombre del documento (con extensión; define el icono)", ruta + ".headerTexto", m.headerTexto, {
|
||||
max: 80, contar: true, placeholder: "catalogo.pdf", spell: false
|
||||
}));
|
||||
caja.appendChild(edCampo("Tamaño (opcional, p. ej. 243 KB)", ruta + ".headerTamano", m.headerTamano, {
|
||||
max: 20, placeholder: "243 KB", spell: false
|
||||
}));
|
||||
}
|
||||
caja.appendChild(edMedia("Media de cabecera", ruta + ".headerMedia", m.headerMedia, {
|
||||
aceptar: tipo === "imagen" ? "image/*" : (tipo === "video" ? "video/*" : ""),
|
||||
@@ -1666,12 +1768,34 @@
|
||||
});
|
||||
}
|
||||
var v = (m.autoPulsar === undefined || m.autoPulsar === null) ? "" : String(m.autoPulsar);
|
||||
return edSelect("En «Reproducir demo», pulsar sola", ruta + ".autoPulsar", v, ops, false);
|
||||
// Valor guardado que ya no corresponde a ninguna opción (borrado, o con URL):
|
||||
// se muestra tal cual para que no parezca «No» en silencio (y validar() avisa)
|
||||
if (v !== "" && !ops.some(function (op) { return op[0] === v; })) {
|
||||
ops.push([v, "⚠ Opción inexistente (" + (Number(v) + 1) + ")"]);
|
||||
}
|
||||
var campo = edSelect("En «Reproducir demo», se pulsa sola", ruta + ".autoPulsar", v, ops, true);
|
||||
campo.classList.add("ed-campo-auto");
|
||||
return campo;
|
||||
}
|
||||
|
||||
// Casilla «Se pulsa sola en la demo» dentro de cada botón / fila / tarjeta
|
||||
// (exclusiva dentro del mensaje: marcar una desmarca la anterior)
|
||||
function edCheckAuto(rutaMsg, indice, elemento) {
|
||||
var m = rutaGet(rutaMsg);
|
||||
if (!m || !elemento || !vacioTxt(elemento.url)) return null; // con URL abre el enlace
|
||||
var marcado = tieneAuto(m) && m.autoPulsar === indice;
|
||||
return h("label", { class: "ed-check ed-check-auto", title: "En «Reproducir demo» se pulsa sola 1 s después de aparecer" }, [
|
||||
h("input", { type: "checkbox", "data-rol": "auto-item", "data-ruta": rutaMsg + ".autoPulsar", "data-idx": indice, checked: marcado }),
|
||||
h("span", { text: "Se pulsa sola en la demo" })
|
||||
]);
|
||||
}
|
||||
|
||||
function edFormTipo(m, ruta, raiz) {
|
||||
var frag = document.createDocumentFragment();
|
||||
var ap = function (n) { if (n) frag.appendChild(n); };
|
||||
// Clave para las demos: arriba del todo, justo bajo Dirección / Hora
|
||||
// (cada botón / fila / tarjeta tiene además su casilla «Se pulsa sola»)
|
||||
if (["template", "buttons", "list", "carousel"].indexOf(m.tipo) >= 0) ap(edAutoPulsar(ruta, m));
|
||||
switch (m.tipo) {
|
||||
case "text":
|
||||
ap(edTextarea("Texto", ruta + ".texto", m.texto, { limite: 4096, rows: 3, formato: true, placeholder: "Hola 👋" }));
|
||||
@@ -1691,6 +1815,7 @@
|
||||
break;
|
||||
case "document":
|
||||
ap(edCampo("Nombre de archivo (con extensión; define el icono)", ruta + ".filename", m.filename, { max: 80, contar: true, placeholder: "menu.pdf" }));
|
||||
ap(edCampo("Tamaño (opcional, p. ej. 243 KB)", ruta + ".tamano", m.tamano, { max: 20, placeholder: "243 KB", spell: false }));
|
||||
ap(edTextarea("Caption (opcional)", ruta + ".caption", m.caption, { limite: 1024 }));
|
||||
ap(edMedia("Archivo (opcional, no se muestra)", ruta + ".src", m.src, { tipoMedia: "doc", textoDrop: "Clic o arrastra un documento aquí" }));
|
||||
break;
|
||||
@@ -1724,7 +1849,6 @@
|
||||
max: LIMITES.botonesPlantilla, maxTexto: 25, conUrl: true,
|
||||
etiqueta: "Botón", ejemplo: "Confirmar", raiz: raiz
|
||||
}));
|
||||
ap(edAutoPulsar(ruta, m));
|
||||
break;
|
||||
}
|
||||
case "buttons":
|
||||
@@ -1735,7 +1859,6 @@
|
||||
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 }));
|
||||
@@ -1743,7 +1866,6 @@
|
||||
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í" }));
|
||||
@@ -1757,7 +1879,6 @@
|
||||
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 }));
|
||||
@@ -1775,9 +1896,10 @@
|
||||
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 }));
|
||||
// ↑ ↓ primero: su hueco reservado (ocultas en reposo) queda como margen
|
||||
acciones.push(btnMini("↑", "Subir", "mover", { "data-i": o.i, "data-delta": -1, disabled: idx <= 0 || null }, false, "ed-mov"));
|
||||
acciones.push(btnMini("↓", "Bajar", "mover", { "data-i": o.i, "data-delta": 1, disabled: idx >= total - 1 || null }, false, "ed-mov"));
|
||||
acciones.push(btnMini("+", "Insertar un mensaje debajo", "insertar-debajo", { "data-i": o.i, "data-id": m.id }));
|
||||
acciones.push(btnMini("⧉", "Duplicar", "dup", { "data-i": o.i }));
|
||||
acciones.push(btnMini("✕", "Borrar mensaje (se puede deshacer)", "borrar", { "data-i": o.i }, true));
|
||||
} else {
|
||||
@@ -1804,12 +1926,19 @@
|
||||
var cab = h("button", {
|
||||
type: "button", class: "ed-cab-principal", "data-acc": "toggle",
|
||||
"aria-expanded": abierta ? "true" : "false",
|
||||
title: abierta ? "Plegar" : "Desplegar"
|
||||
title: (abierta ? "Plegar" : "Desplegar") + " · " + resumenDe(m)
|
||||
}, [
|
||||
// Número + flecha de dirección (↙ entrada / ↗ salida): no depende solo del color
|
||||
o.anidado ? h("span", { class: "ed-num ed-num-resp", text: "↳" })
|
||||
: h("span", { class: "ed-num ed-num-" + (esOut ? "out" : "in"), title: esOut ? "Salida (enviado)" : "Entrada (recibido)", 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-num ed-num-" + (esOut ? "out" : "in"), title: esOut ? "Salida (enviado)" : "Entrada (recibido)" }, [
|
||||
h("span", { class: "ed-num-dir", "aria-hidden": "true", text: esOut ? "↗" : "↙" }),
|
||||
document.createTextNode(String(o.i + 1)),
|
||||
h("span", { class: "wam-sr", text: esOut ? " · salida" : " · entrada" })
|
||||
]),
|
||||
h("span", { class: "ed-badge", title: etiquetaTipo(m.tipo) }, [
|
||||
svgIcono(ICONOS_TIPO[m.tipo] || ""),
|
||||
h("span", { class: "ed-badge-txt", text: CORTO[m.tipo] || etiquetaTipo(m.tipo) })
|
||||
]),
|
||||
h("span", { class: "ed-resumen", text: resumenDe(m) }),
|
||||
h("span", { class: "ed-chips" }, o.anidado ? null : chipsDe(m))
|
||||
]);
|
||||
@@ -1853,6 +1982,11 @@
|
||||
pintarDestinoAlta();
|
||||
actualizarAvisosGlobal();
|
||||
actualizarResumenCuenta();
|
||||
// Demo vacía: sin herramientas de lista (no harían nada) y pista en el teléfono
|
||||
var herr = document.getElementById("ed-lista-herr") || document.querySelector(".ed-lista-herr");
|
||||
if (herr) herr.hidden = !mensajes.length;
|
||||
if (mensajes.length) document.body.removeAttribute("data-wam-vacia");
|
||||
else document.body.setAttribute("data-wam-vacia", "");
|
||||
if (!mensajes.length) {
|
||||
lista.appendChild(h(etiquetaItem, { class: "ed-vacio" }, [
|
||||
h("p", { class: "ed-vacio-titulo", text: "Sin mensajes todavía" }),
|
||||
@@ -1887,8 +2021,13 @@
|
||||
return f;
|
||||
}
|
||||
|
||||
// Botones que borran: tras usarlos el foco NUNCA vuelve a su gemelo (un
|
||||
// segundo Intro/Espacio borraría otro elemento); lo recoloca quien borra.
|
||||
function esAccionBorrar(acc) { return acc === "borrar" || /^del-/.test(String(acc || "")); }
|
||||
|
||||
function restaurarFoco(cont, f) {
|
||||
if (!f || !cont) return;
|
||||
if (esAccionBorrar(f.acc)) return;
|
||||
var sel = "";
|
||||
if (f.ruta) sel += '[data-ruta="' + cssEsc(f.ruta) + '"]';
|
||||
if (f.acc) sel += '[data-acc="' + cssEsc(f.acc) + '"]';
|
||||
@@ -2017,17 +2156,18 @@
|
||||
var pal = document.getElementById("nuevo-tipo");
|
||||
if (pal && pal.tagName !== "SELECT") {
|
||||
pal.setAttribute("data-valor", tipo);
|
||||
// Sin estado «seleccionado» visible: cada clic AÑADE (no es un selector).
|
||||
// Solo se conserva la parada de Tab (roving tabindex) en el último usado.
|
||||
var hijos = pal.querySelectorAll("[data-tipo]");
|
||||
for (var i = 0; i < hijos.length; i++) {
|
||||
var activo = hijos[i].getAttribute("data-tipo") === tipo;
|
||||
hijos[i].classList.toggle("ed-seleccionada", activo);
|
||||
hijos[i].tabIndex = activo ? 0 : -1;
|
||||
hijos[i].tabIndex = hijos[i].getAttribute("data-tipo") === tipo ? 0 : -1;
|
||||
}
|
||||
}
|
||||
var btn = document.getElementById("btn-anadir");
|
||||
if (btn) {
|
||||
btn.textContent = "+ Otro «" + etiquetaTipo(tipo) + "»";
|
||||
btn.title = "Añade otro mensaje de tipo " + etiquetaTipo(tipo);
|
||||
btn.title = "Repite el último tipo añadido: " + etiquetaTipo(tipo);
|
||||
btn.hidden = !yaAnadido;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2049,8 +2189,13 @@
|
||||
"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) })]));
|
||||
title: "Añadir un mensaje de tipo " + etiquetaTipo(tipo),
|
||||
"aria-label": "Añadir " + etiquetaTipo(tipo)
|
||||
}, [
|
||||
svgIcono(ICONOS_TIPO[tipo] || ""),
|
||||
h("span", { class: "ed-tipo-card-e", text: CORTO[tipo] || etiquetaTipo(tipo) }),
|
||||
h("span", { class: "ed-tipo-mas", "aria-hidden": "true", text: "+" })
|
||||
]));
|
||||
});
|
||||
cont.appendChild(fila);
|
||||
});
|
||||
@@ -2102,15 +2247,31 @@
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
var ms = (WAM.estado && WAM.estado.mensajes) || [];
|
||||
var pos = posicionAlta();
|
||||
var previo = el.getAttribute("data-pos");
|
||||
var clave = ms.length ? String(pos >= ms.length ? "fin" : pos) : "vacio";
|
||||
el.setAttribute("data-pos", clave);
|
||||
// Chip visible del destino: «al final» o «debajo del n.º N ×» (× = al final)
|
||||
el.classList.toggle("ed-destino-chip", !!ms.length);
|
||||
el.classList.toggle("ed-destino-dentro", !!ms.length && pos < ms.length);
|
||||
if (pos >= ms.length) {
|
||||
el.appendChild(document.createTextNode(ms.length ? "Se añade al final" : "Elige un tipo para empezar"));
|
||||
return;
|
||||
} else {
|
||||
el.appendChild(document.createTextNode("Se inserta debajo del n.º " + pos));
|
||||
el.appendChild(h("button", {
|
||||
type: "button", class: "ed-destino-quitar", "data-acc": "destino-final",
|
||||
title: "Añadir al final", "aria-label": "Añadir al final", text: "×"
|
||||
}));
|
||||
}
|
||||
// Aviso visual breve cuando el destino cambia solo (al abrir otra tarjeta)
|
||||
if (previo !== null && previo !== clave && ms.length && !reducirMovimiento()) {
|
||||
el.classList.remove("ed-destino-cambio");
|
||||
void el.offsetWidth;
|
||||
el.classList.add("ed-destino-cambio");
|
||||
}
|
||||
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) {
|
||||
yaAnadido = true; // desde ahora se ofrece «+ Otro «X»» (repetir el último)
|
||||
seleccionarTipo(tipo);
|
||||
var pos = posicionAlta();
|
||||
insertarTras = null;
|
||||
@@ -2239,7 +2400,7 @@
|
||||
snap();
|
||||
WAM.setCampo("mensajes", copia);
|
||||
WAM.renderTarjetas();
|
||||
WAM.toast("Horas repartidas desde las " + inicio + " (+1 min por mensaje)",
|
||||
WAM.toast("Horas repartidas desde " + inicio,
|
||||
typeof WAM.deshacer === "function" ? { accion: { texto: "Deshacer", fn: WAM.deshacer } } : null);
|
||||
}
|
||||
|
||||
@@ -2509,12 +2670,29 @@
|
||||
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) {
|
||||
var res = mutarArray(ruta, function (arr) {
|
||||
if (arr.length <= min) { aviso("Debe quedar al menos " + min, "error"); return null; }
|
||||
if (idx < 0 || idx >= arr.length) return null;
|
||||
arr.splice(idx, 1);
|
||||
return arr;
|
||||
});
|
||||
if (res) enfocarTrasBorrar(ruta, idx, res.length);
|
||||
}
|
||||
|
||||
/* Tras quitar un elemento: foco a la cabecera / primer campo del que
|
||||
ocupa su lugar (o del anterior); si la lista quedó vacía, a su botón
|
||||
«+ Añadir». Nunca a otro botón ✕. */
|
||||
function enfocarTrasBorrar(ruta, idx, n) {
|
||||
var lista = listaNodo();
|
||||
if (!lista) return;
|
||||
var j = idx < n ? idx : n - 1;
|
||||
var r = cssEsc(ruta);
|
||||
var it = j >= 0 ? lista.querySelector('[data-drag-ruta="' + r + '"][data-idx="' + j + '"], [data-mov-ruta="' + r + '"][data-idx="' + j + '"]') : null;
|
||||
var foco = it && (it.querySelector(".ed-cab-principal") || it.querySelector("input.ed-input, textarea"));
|
||||
if (!foco) foco = lista.querySelector('[data-acc^="add-"][data-ruta="' + r + '"]');
|
||||
if (!foco) return;
|
||||
try { foco.focus({ preventScroll: true }); } catch (e) { foco.focus(); }
|
||||
if (foco.scrollIntoView) foco.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function alternarModoMedia(el) {
|
||||
@@ -2536,7 +2714,8 @@
|
||||
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 resT = el.querySelector(".ed-resumen");
|
||||
el.title = (abiertas[id] ? "Plegar" : "Desplegar") + (resT ? " · " + resT.textContent : "");
|
||||
var raizT = t.getAttribute("data-raiz") || id;
|
||||
if (!t.classList.contains("ed-anidada")) {
|
||||
if (abiertas[id]) ultimaAbierta = id;
|
||||
@@ -2719,6 +2898,18 @@
|
||||
}
|
||||
if (el.type === "checkbox") {
|
||||
snap();
|
||||
if (el.getAttribute("data-rol") === "auto-item") {
|
||||
// Exclusiva dentro del mensaje: marcar fija autoPulsar; desmarcar lo quita
|
||||
var rutaA = String(el.getAttribute("data-ruta") || "").replace(/\.autoPulsar$/, "");
|
||||
var msgA = rutaGet(rutaA);
|
||||
if (msgA && typeof msgA === "object") {
|
||||
if (el.checked) msgA.autoPulsar = Number(el.getAttribute("data-idx")) || 0;
|
||||
else delete msgA.autoPulsar;
|
||||
WAM.setCampo(rutaA, msgA);
|
||||
}
|
||||
repintarDesde(el);
|
||||
return;
|
||||
}
|
||||
if (el.getAttribute("data-rol") === "header-toggle") {
|
||||
var rt = el.getAttribute("data-ruta");
|
||||
WAM.setCampo(rt + ".headerTipo", el.checked ? "texto" : "");
|
||||
|
||||
+79
-12
@@ -27,7 +27,42 @@ window.WAM = window.WAM || {};
|
||||
/* Mensajes de la sesión en curso (efímeros). */
|
||||
WAM.sesion = [];
|
||||
var contSesion = 0;
|
||||
function idSesion() { contSesion++; return "s" + contSesion; }
|
||||
|
||||
/* IDs del guion (mensajes, respuestas, botones, filas, cards) para que un
|
||||
ID de sesión nunca coincida con uno importado (p. ej. un guion con "s1"). */
|
||||
function idsDelGuion() {
|
||||
var usados = new Set();
|
||||
(function recorrer(o) {
|
||||
if (!o || typeof o !== "object") return;
|
||||
if (Array.isArray(o)) { for (var i = 0; i < o.length; i++) recorrer(o[i]); return; }
|
||||
if (typeof o.id === "string" && o.id) usados.add(o.id);
|
||||
["mensajes", "respuestas", "botones", "secciones", "filas", "cards"].forEach(function (k) {
|
||||
if (o[k] && typeof o[k] === "object") recorrer(o[k]);
|
||||
});
|
||||
})(WAM.estado);
|
||||
return usados;
|
||||
}
|
||||
|
||||
function idSesion() {
|
||||
var usados = WAM.estado ? idsDelGuion() : null;
|
||||
var id;
|
||||
do { contSesion++; id = "s" + contSesion; } while (usados && usados.has(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
/* Hora para los mensajes de un flujo: la del último mensaje visible + 1 min,
|
||||
para que la línea temporal de la demo siga siendo coherente (si no hay
|
||||
ninguna hora legible, el reloj real). */
|
||||
function horaTras() {
|
||||
var metas = document.querySelectorAll("#chat-cuerpo .wa-row .meta");
|
||||
var t = null;
|
||||
for (var i = metas.length - 1; i >= 0 && !t; i--) {
|
||||
t = String(metas[i].textContent || "").match(/(\d{1,2}):(\d{2})/);
|
||||
}
|
||||
if (!t) return WAM.horaAhora();
|
||||
var min = (Number(t[1]) * 60 + Number(t[2]) + 1) % 1440;
|
||||
return ("0" + Math.floor(min / 60)).slice(-2) + ":" + ("0" + (min % 60)).slice(-2);
|
||||
}
|
||||
|
||||
/* ————————————————— temporizadores cancelables por grupo ————————————————— */
|
||||
|
||||
@@ -215,12 +250,12 @@ window.WAM = window.WAM || {};
|
||||
return pintar(msg);
|
||||
}
|
||||
|
||||
function mensajeOut(texto, cita) {
|
||||
function mensajeOut(texto, cita, hora) {
|
||||
var msg = {
|
||||
id: idSesion(),
|
||||
dir: "out",
|
||||
tipo: "text",
|
||||
hora: WAM.horaAhora(),
|
||||
hora: hora || WAM.horaAhora(),
|
||||
estado: "sent",
|
||||
texto: String(texto == null ? "" : texto)
|
||||
};
|
||||
@@ -243,7 +278,7 @@ window.WAM = window.WAM || {};
|
||||
if (TIPOS_VALIDOS.indexOf(copia.tipo) === -1) copia.tipo = "text";
|
||||
copia.id = idSesion();
|
||||
copia.dir = "in";
|
||||
copia.hora = WAM.horaAhora();
|
||||
copia.hora = horaTras();
|
||||
copia.estado = "sent";
|
||||
delete copia.cita;
|
||||
return copia;
|
||||
@@ -257,12 +292,20 @@ window.WAM = window.WAM || {};
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/```([\s\S]*?)```/g, "$1")
|
||||
.replace(/`([^`\n]+)`/g, "$1")
|
||||
.replace(/\*([^*\n]+)\*/g, "$1")
|
||||
.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, "$1$2")
|
||||
.replace(/(^|[^\w])~([^~\n]+)~(?=[^\w]|$)/g, "$1$2")
|
||||
.replace(reMarca("*"), "$1$2")
|
||||
.replace(reMarca("_"), "$1$2")
|
||||
.replace(reMarca("~"), "$1$2")
|
||||
.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(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");
|
||||
}
|
||||
|
||||
function miniaturaSegura(u) {
|
||||
if (vacio(u)) return "";
|
||||
var s = String(u);
|
||||
@@ -305,6 +348,9 @@ window.WAM = window.WAM || {};
|
||||
cita.foto = true;
|
||||
} else if (msg.headerTipo === "video") {
|
||||
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");
|
||||
@@ -315,6 +361,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">' +
|
||||
'<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). */
|
||||
function pintarCita(fila, cita) {
|
||||
var burbuja = fila.querySelector(".bubble");
|
||||
@@ -330,7 +379,12 @@ window.WAM = window.WAM || {};
|
||||
if (cita.titulo) {
|
||||
var tit = document.createElement("div");
|
||||
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);
|
||||
}
|
||||
if (cita.extracto) {
|
||||
@@ -413,7 +467,7 @@ window.WAM = window.WAM || {};
|
||||
WAM.flujo = function (texto, respuestas, cita) {
|
||||
var lista = Array.isArray(respuestas) ? respuestas : [];
|
||||
var ctx = contexto("flujo");
|
||||
var out = mensajeOut(texto, cita || null);
|
||||
var out = mensajeOut(texto, cita || null, horaTras());
|
||||
anadirSesion(out);
|
||||
var tics = animarTics(out, ctx);
|
||||
tics.catch(nada);
|
||||
@@ -460,7 +514,8 @@ window.WAM = window.WAM || {};
|
||||
if (ds.btn !== undefined) {
|
||||
var n = parseInt(ds.btn, 10);
|
||||
var boton = (msg.botones || [])[n];
|
||||
if (!boton) return vacioR;
|
||||
/* botón de enlace con URL bloqueada: inerte, nunca un quick reply */
|
||||
if (!boton || boton.urlBloqueada) return vacioR;
|
||||
return {
|
||||
texto: vacio(boton.texto) ? "Botón " + (n + 1) : String(boton.texto),
|
||||
respuestas: boton.respuestas || [],
|
||||
@@ -472,7 +527,7 @@ window.WAM = window.WAM || {};
|
||||
if (ds.card !== undefined) {
|
||||
var c = parseInt(ds.card, 10);
|
||||
var card = (msg.cards || [])[c];
|
||||
if (!card) return vacioR;
|
||||
if (!card || card.urlBloqueada) return vacioR;
|
||||
return {
|
||||
texto: vacio(card.textoBoton) ? "Ver más" : String(card.textoBoton),
|
||||
respuestas: card.respuestas || [],
|
||||
@@ -509,11 +564,13 @@ window.WAM = window.WAM || {};
|
||||
return copia;
|
||||
});
|
||||
var n = nuevos.length;
|
||||
if (typeof WAM.puntoDeshacer === "function") { try { WAM.puntoDeshacer(); } catch (e) { /* opcional */ } }
|
||||
WAM.estado = WAM.normalizar ? WAM.normalizar({
|
||||
version: WAM.estado.version, tema: WAM.estado.tema, cuenta: WAM.estado.cuenta,
|
||||
mensajes: WAM.estado.mensajes.concat(nuevos)
|
||||
}) : WAM.estado;
|
||||
if (typeof WAM.guardar === "function") WAM.guardar();
|
||||
if (typeof WAM.marcarEditado === "function") { try { WAM.marcarEditado(); } catch (e) { /* opcional */ } }
|
||||
if (typeof WAM.render === "function") WAM.render();
|
||||
if (typeof WAM.renderEditor === "function") WAM.renderEditor();
|
||||
return n;
|
||||
@@ -594,7 +651,7 @@ window.WAM = window.WAM || {};
|
||||
|
||||
async function flujoEnDemo(sel, ctx) {
|
||||
if (!sel || !sel.msg) return;
|
||||
var out = mensajeOut(sel.texto, sel.cita);
|
||||
var out = mensajeOut(sel.texto, sel.cita, horaTras());
|
||||
anadirSesion(out);
|
||||
await animarTics(out, ctx);
|
||||
await responder(Array.isArray(sel.respuestas) ? sel.respuestas : [], ctx, true);
|
||||
@@ -609,6 +666,12 @@ window.WAM = window.WAM || {};
|
||||
}
|
||||
}
|
||||
|
||||
/* Progreso de la demo: mensajes del guion revelados / total. */
|
||||
var progreso = { paso: 0, total: 0 };
|
||||
WAM.progresoDemo = function () {
|
||||
return demoAndando ? { paso: progreso.paso, total: progreso.total } : { paso: 0, total: 0 };
|
||||
};
|
||||
|
||||
WAM.reproducirDemo = function () {
|
||||
if (demoAndando) return;
|
||||
/* aborta flujos, ticks y typing en curso: no se mezclan con la demo */
|
||||
@@ -621,6 +684,8 @@ window.WAM = window.WAM || {};
|
||||
vaciarChat();
|
||||
var ctx = contexto("demo");
|
||||
var secuencia = ((WAM.estado && WAM.estado.mensajes) || []).slice();
|
||||
progreso.paso = 0;
|
||||
progreso.total = secuencia.length;
|
||||
(async function () {
|
||||
try {
|
||||
for (var i = 0; i < secuencia.length; i++) {
|
||||
@@ -629,6 +694,8 @@ window.WAM = window.WAM || {};
|
||||
if (msg.dir === "in") await escribirDurante(ctx, 700);
|
||||
if (!ctx.vivo()) throw CANCELADO;
|
||||
pintar(msg);
|
||||
progreso.paso = i + 1;
|
||||
avisarDemo();
|
||||
await ctx.esperar(600);
|
||||
await autoPulsar(msg, ctx);
|
||||
}
|
||||
|
||||
+38
-3
@@ -74,15 +74,50 @@ window.WAM = window.WAM || {};
|
||||
|
||||
/* ————————————————— botón "Reproducir demo" ————————————————— */
|
||||
|
||||
var SVG_STOP = '<rect x="6.5" y="6.5" width="11" height="11" rx="2"/>';
|
||||
|
||||
/* Contador discreto «Paso 3/12» junto al botón (se crea una vez). */
|
||||
function contadorDemo(btn) {
|
||||
var c = porId("demo-paso");
|
||||
if (c) return c;
|
||||
c = document.createElement("span");
|
||||
c.id = "demo-paso";
|
||||
c.className = "demo-paso";
|
||||
c.setAttribute("aria-live", "polite");
|
||||
c.setAttribute("role", "status");
|
||||
c.hidden = true;
|
||||
btn.parentNode.insertBefore(c, btn);
|
||||
return c;
|
||||
}
|
||||
|
||||
function pintarBotonDemo() {
|
||||
var btn = porId("btn-reproducir");
|
||||
if (!btn) return;
|
||||
var activa = WAM.demoActiva();
|
||||
btn.setAttribute("aria-pressed", activa ? "true" : "false");
|
||||
var svg = btn.querySelector("svg");
|
||||
if (svg) {
|
||||
if (!svg.dataset.play) svg.dataset.play = svg.innerHTML;
|
||||
var deseado = activa ? SVG_STOP : svg.dataset.play;
|
||||
if (svg.innerHTML !== deseado) svg.innerHTML = deseado;
|
||||
}
|
||||
var span = btn.querySelector("span");
|
||||
if (!span) return;
|
||||
if (!span.dataset.etiqueta) span.dataset.etiqueta = span.textContent;
|
||||
span.textContent = activa ? "■ Detener" : span.dataset.etiqueta;
|
||||
if (span) {
|
||||
if (!span.dataset.etiqueta) span.dataset.etiqueta = span.textContent;
|
||||
span.textContent = activa ? "Detener" : span.dataset.etiqueta;
|
||||
}
|
||||
if (btn.parentNode) {
|
||||
var c = contadorDemo(btn);
|
||||
var p = typeof WAM.progresoDemo === "function" ? WAM.progresoDemo() : null;
|
||||
if (activa && p && p.total > 0 && p.paso > 0) {
|
||||
var txt = "Paso " + p.paso + "/" + p.total;
|
||||
if (c.textContent !== txt) c.textContent = txt;
|
||||
c.hidden = false;
|
||||
} else {
|
||||
c.hidden = true;
|
||||
c.textContent = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Evita que un doble clic arranque y detenga la demo en el mismo gesto. */
|
||||
|
||||
+177
-28
@@ -29,11 +29,17 @@
|
||||
function estado() { return WAM.estado || { cuenta: {}, mensajes: [] }; }
|
||||
|
||||
var ESCRIBIENDO = false;
|
||||
var HOJA_ID = ""; /* id del mensaje cuya hoja está abierta ("" = ninguna) */
|
||||
var ORIGEN_HOJA = null; /* elemento que tenía el foco al abrir la hoja */
|
||||
var FOCO_EN_HOJA = false; /* la hoja movió el foco a su interior al abrirse */
|
||||
|
||||
/* 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 +208,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 +362,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>";
|
||||
@@ -387,7 +429,7 @@
|
||||
return '<a class="qr-btn"' + h + ' href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-btn-url="' + j + '">' +
|
||||
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 '<button type="button" class="qr-btn"' + h + ' data-flujo="' + i + '" data-btn="' + j + '">' +
|
||||
@@ -436,7 +478,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>";
|
||||
@@ -500,7 +542,7 @@
|
||||
var url = urlSegura(c.url);
|
||||
var boton;
|
||||
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>";
|
||||
html += '<div class="card">' +
|
||||
'<img class="card-media" src="' + esc(media) + '" alt="" draggable="false">' +
|
||||
@@ -590,6 +632,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 +654,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,14 +733,61 @@
|
||||
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) {
|
||||
function montarHoja(titulo, contenido, pie, alClic, opts) {
|
||||
var pantalla = byId("pantalla");
|
||||
if (!pantalla) return null;
|
||||
WAM.cerrarSheet();
|
||||
@@ -706,6 +801,28 @@
|
||||
'<div class="sheet-opciones" id="sheet-opciones">' + contenido + "</div>" +
|
||||
(pie ? '<div class="sheet-pie">' + esc(pie) + "</div>" : "") + "</div>";
|
||||
pantalla.appendChild(hoja);
|
||||
/* Foco (diálogo modal): se guarda el origen, se enfoca la primera fila
|
||||
activa (o la X) y Tab / Mayús+Tab quedan dentro del panel. Durante
|
||||
«Reproducir demo» (o con opts.sinFoco) no se roba el foco. */
|
||||
ORIGEN_HOJA = document.activeElement && document.activeElement !== document.body ? document.activeElement : null;
|
||||
var enDemo = typeof WAM.demoActiva === "function" && WAM.demoActiva();
|
||||
if (!enDemo && !(opts && opts.sinFoco)) {
|
||||
var primera = hoja.querySelector('.sheet-fila:not([aria-disabled="true"]):not(span)') || hoja.querySelector(".sheet-cerrar");
|
||||
if (primera) { try { primera.focus({ preventScroll: true }); } catch (e) { primera.focus(); } }
|
||||
FOCO_EN_HOJA = !!primera;
|
||||
}
|
||||
hoja.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "Tab") return;
|
||||
var panel = hoja.querySelector(".sheet-panel");
|
||||
var focos = panel ? Array.prototype.filter.call(panel.querySelectorAll("a[href], button:not([disabled])"), function (x) {
|
||||
return !x.hidden;
|
||||
}) : [];
|
||||
if (!focos.length) return;
|
||||
var ini = focos[0], fin = focos[focos.length - 1], act = document.activeElement;
|
||||
var dentro = panel.contains(act);
|
||||
if (e.shiftKey && (act === ini || !dentro)) { e.preventDefault(); fin.focus(); }
|
||||
else if (!e.shiftKey && (act === fin || !dentro)) { e.preventDefault(); ini.focus(); }
|
||||
});
|
||||
hoja.addEventListener("click", function (e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) return;
|
||||
@@ -741,20 +858,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++) {
|
||||
@@ -765,7 +888,11 @@
|
||||
if (url) {
|
||||
enlaces += '<a class="sheet-fila sheet-fila-btn" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' +
|
||||
icono(tipoIconoUrl(url)) + etiqueta + "</a>";
|
||||
} else if (vacio(b.url)) {
|
||||
} else if (!vacio(b.url) || b.urlBloqueada) {
|
||||
/* Igual que botonAccion: URL no permitida o bloqueada → fila inerte */
|
||||
enlaces += '<span class="sheet-fila sheet-fila-btn qr-btn-inactiva" aria-disabled="true">' +
|
||||
icono("url") + etiqueta + "</span>";
|
||||
} else {
|
||||
var usado = li && li.querySelector('[data-btn="' + j + '"].usado');
|
||||
respuestas += '<button type="button" class="sheet-fila sheet-fila-btn' + (usado ? " usado" : "") + '" data-opcion="' + j + '"' +
|
||||
(usado ? ' aria-disabled="true"' : "") + ">" + icono("responder") + etiqueta + "</button>";
|
||||
@@ -773,20 +900,42 @@
|
||||
}
|
||||
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");
|
||||
var idHoja = HOJA_ID;
|
||||
var origen = ORIGEN_HOJA;
|
||||
var movido = FOCO_EN_HOJA;
|
||||
ORIGEN_HOJA = null;
|
||||
FOCO_EN_HOJA = false;
|
||||
var act = document.activeElement;
|
||||
/* Safari no enfoca los botones al hacer clic: el foco cae en body */
|
||||
var focoDentro = !!hoja && (hoja.contains(act) || (movido && (!act || act === document.body)));
|
||||
if (hoja && hoja.parentNode) hoja.parentNode.removeChild(hoja);
|
||||
HOJA_ID = "";
|
||||
if (!focoDentro) return;
|
||||
/* Devolver el foco: al origen si sigue en el DOM; si hubo re-render, al
|
||||
mismo control en la fila nueva del mensaje; si no, a la conversación. */
|
||||
var destino = origen && origen.isConnected ? origen : null;
|
||||
if (!destino && idHoja) {
|
||||
var filaNueva = filaDe({ id: idHoja });
|
||||
if (filaNueva) destino = filaNueva.querySelector("[data-ver-todas], [data-flujo]:not([data-btn]):not([data-card])");
|
||||
}
|
||||
if (!destino) destino = byId("chat-cuerpo");
|
||||
if (destino) { try { destino.focus({ preventScroll: true }); } catch (e) { destino.focus(); } }
|
||||
};
|
||||
})();
|
||||
|
||||
+79
-12
@@ -221,10 +221,54 @@ window.WAM = window.WAM || {};
|
||||
return n;
|
||||
}
|
||||
|
||||
function lista(v, max) {
|
||||
return (Array.isArray(v) ? v : []).slice(0, max || MAX_ITEMS);
|
||||
/* Contadores de lo que recorta la normalización en curso (ver
|
||||
WAM.ultimosRecortes). null fuera de WAM.normalizar. */
|
||||
var recortes = null;
|
||||
|
||||
function lista(v, max, clave) {
|
||||
var arr = Array.isArray(v) ? v : [];
|
||||
var tope = max || MAX_ITEMS;
|
||||
if (recortes && clave && arr.length > tope) recortes[clave] += arr.length - tope;
|
||||
return arr.slice(0, tope);
|
||||
}
|
||||
|
||||
/* URL de un botón/card de enlace: si venía escrita pero es insegura se
|
||||
vacía y se marca urlBloqueada, para que siga siendo un botón de enlace
|
||||
(inerte) y no se convierta en una respuesta rápida con flujo. */
|
||||
function urlEnlace(destino, origen) {
|
||||
var bruta = cad(origen.url).trim();
|
||||
destino.url = urlLimpia(bruta, "enlace");
|
||||
if (!destino.url && (bruta || origen.urlBloqueada === true || origen.urlBloqueada === "true")) {
|
||||
destino.urlBloqueada = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* Texto en español con lo recortado, o "" si no se recortó nada. */
|
||||
WAM.describirRecortes = function (r) {
|
||||
r = r || WAM.ultimosRecortes;
|
||||
if (!r) return "";
|
||||
var partes = [];
|
||||
function n(c, uno, varios) { if (r[c] > 0) partes.push(r[c] + " " + (r[c] === 1 ? uno : varios)); }
|
||||
n("botones", "botón", "botones");
|
||||
n("secciones", "sección", "secciones");
|
||||
n("filas", "fila", "filas");
|
||||
n("cards", "tarjeta", "tarjetas");
|
||||
n("respuestas", "respuesta", "respuestas");
|
||||
n("profundidad", "respuesta anidada (más de " + MAX_PROFUNDIDAD + " niveles)",
|
||||
"respuestas anidadas (más de " + MAX_PROFUNDIDAD + " niveles)");
|
||||
var txt = "";
|
||||
if (r.mensajesOriginales > r.mensajes) {
|
||||
txt = "Se importaron " + r.mensajes + " de " + r.mensajesOriginales + " mensajes";
|
||||
}
|
||||
if (partes.length) {
|
||||
var total = 0;
|
||||
["botones", "secciones", "filas", "cards", "respuestas", "profundidad"].forEach(function (c) { total += r[c] || 0; });
|
||||
var verbo = total === 1 ? "se recortó " : "se recortaron ";
|
||||
txt += (txt ? "; " + verbo : verbo.charAt(0).toUpperCase() + verbo.slice(1)) + partes.join(", ");
|
||||
}
|
||||
return txt;
|
||||
};
|
||||
|
||||
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
|
||||
Usado por cargar(), resetear() y editor.importarJSON (import / upgrade).
|
||||
- Acepta también un array de mensajes suelto como raíz.
|
||||
@@ -239,6 +283,11 @@ window.WAM = window.WAM || {};
|
||||
: ((o && typeof o === "object") ? o : {});
|
||||
|
||||
entregados = new Set();
|
||||
var mensajesSrc = Array.isArray(src.mensajes) ? src.mensajes.length : 0;
|
||||
recortes = {
|
||||
mensajes: Math.min(mensajesSrc, 500), mensajesOriginales: mensajesSrc,
|
||||
botones: 0, secciones: 0, filas: 0, cards: 0, respuestas: 0, profundidad: 0
|
||||
};
|
||||
var conjunto = new Set();
|
||||
recolectarIds(src, conjunto);
|
||||
var tomar = crearAlocador(conjunto);
|
||||
@@ -270,16 +319,19 @@ window.WAM = window.WAM || {};
|
||||
};
|
||||
|
||||
function respuestas(valor) {
|
||||
if (profundidad >= MAX_PROFUNDIDAD) return [];
|
||||
return lista(valor, 20).map(function (r) { return normalizarMensaje(r, true, profundidad + 1); });
|
||||
if (profundidad >= MAX_PROFUNDIDAD) {
|
||||
if (Array.isArray(valor) && valor.length) recortes.profundidad += valor.length;
|
||||
return [];
|
||||
}
|
||||
return lista(valor, 20, "respuestas").map(function (r) { return normalizarMensaje(r, true, profundidad + 1); });
|
||||
}
|
||||
|
||||
function botones(valor) {
|
||||
return lista(valor).map(function (b) {
|
||||
return lista(valor, MAX_ITEMS, "botones").map(function (b) {
|
||||
var boton = (b && typeof b === "object") ? b : { texto: typeof b === "string" ? b : "" };
|
||||
var out = { texto: cad(boton.texto), respuestas: respuestas(boton.respuestas) };
|
||||
/* Botón URL de plantilla (research-api §7.2): se conserva si viene */
|
||||
if (typeof boton.url === "string") out.url = urlLimpia(boton.url, "enlace");
|
||||
if (typeof boton.url === "string" || boton.urlBloqueada === true) urlEnlace(out, boton);
|
||||
return out;
|
||||
});
|
||||
}
|
||||
@@ -307,6 +359,8 @@ window.WAM = window.WAM || {};
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
msg.caption = cad(m.caption);
|
||||
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;
|
||||
case "sticker":
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
@@ -328,6 +382,9 @@ window.WAM = window.WAM || {};
|
||||
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
|
||||
msg.headerTexto = cad(m.headerTexto);
|
||||
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.footer = cad(m.footer);
|
||||
msg.botones = botones(m.botones);
|
||||
@@ -338,11 +395,11 @@ window.WAM = window.WAM || {};
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.boton = cad(m.boton);
|
||||
msg.secciones = lista(m.secciones).map(function (s) {
|
||||
msg.secciones = lista(m.secciones, MAX_ITEMS, "secciones").map(function (s) {
|
||||
var seccion = (s && typeof s === "object") ? s : {};
|
||||
return {
|
||||
titulo: cad(seccion.titulo),
|
||||
filas: lista(seccion.filas).map(function (f) {
|
||||
filas: lista(seccion.filas, MAX_ITEMS, "filas").map(function (f) {
|
||||
var fila = (f && typeof f === "object") ? f : { titulo: typeof f === "string" ? f : "" };
|
||||
return {
|
||||
titulo: cad(fila.titulo),
|
||||
@@ -363,15 +420,17 @@ window.WAM = window.WAM || {};
|
||||
break;
|
||||
case "carousel":
|
||||
msg.body = cad(m.body);
|
||||
msg.cards = lista(m.cards).map(function (c) {
|
||||
msg.cards = lista(m.cards, MAX_ITEMS, "cards").map(function (c) {
|
||||
var card = (c && typeof c === "object") ? c : {};
|
||||
return {
|
||||
var salida = {
|
||||
media: urlLimpia(card.media, "media"),
|
||||
texto: cad(card.texto),
|
||||
textoBoton: cad(card.textoBoton),
|
||||
url: urlLimpia(card.url, "enlace"),
|
||||
url: "",
|
||||
respuestas: respuestas(card.respuestas)
|
||||
};
|
||||
urlEnlace(salida, card);
|
||||
return salida;
|
||||
});
|
||||
autoPulsar();
|
||||
break;
|
||||
@@ -389,7 +448,7 @@ window.WAM = window.WAM || {};
|
||||
}
|
||||
|
||||
var cuentaSrc = (src.cuenta && typeof src.cuenta === "object") ? src.cuenta : {};
|
||||
return {
|
||||
var resultado = {
|
||||
version: 1,
|
||||
tema: src.tema === "oscuro" ? "oscuro" : "claro",
|
||||
cuenta: {
|
||||
@@ -404,6 +463,14 @@ window.WAM = window.WAM || {};
|
||||
return normalizarMensaje(x, false, 0);
|
||||
})
|
||||
};
|
||||
/* Lo recortado queda en WAM.ultimosRecortes y, no enumerable (no se
|
||||
guarda ni se exporta), en resultado.__recortes. */
|
||||
WAM.ultimosRecortes = recortes;
|
||||
try {
|
||||
Object.defineProperty(resultado, "__recortes", { value: recortes, enumerable: false, configurable: true });
|
||||
} catch (e) { /* entorno sin defineProperty */ }
|
||||
recortes = null;
|
||||
return resultado;
|
||||
};
|
||||
|
||||
/* ————————————————— persistencia ————————————————— */
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
· Indicador de guardado en la toolbar (#estado-guardado)
|
||||
· Confirmación de Restablecer / Importar (WAM.confirmarReemplazo)
|
||||
· Diálogo «Demos»: escenarios por industria + demos guardadas (wam-demos)
|
||||
· Atajos de teclado globales y diálogo de ayuda (?)
|
||||
· Atajos de teclado globales y diálogo de ayuda (?); G / F6 y el enlace
|
||||
«Saltar a la vista previa» mueven el foco entre editor y teléfono
|
||||
· Pestañas Editor / Vista previa en ventana estrecha y menú «⋯»
|
||||
· Enlace vista previa → tarjeta (Alt+clic o doble clic en una burbuja)
|
||||
· Región sr-only que anuncia los mensajes nuevos del chat
|
||||
@@ -127,9 +128,32 @@
|
||||
/* ------------------------------------------------------------------
|
||||
Historial deshacer / rehacer
|
||||
------------------------------------------------------------------ */
|
||||
/* Cada instantánea guarda también qué demo guardada estaba en uso
|
||||
(«wam-demo-activa»): al deshacer o rehacer se repone, para que
|
||||
«Actualizar» nunca escriba el contenido de otra demo en su hueco. */
|
||||
function instantanea() {
|
||||
return { estado: clonar(WAM.estado), activa: lsGet(CLAVE_ACTIVA) };
|
||||
}
|
||||
|
||||
function reponerActiva(id) {
|
||||
if (id) lsSet(CLAVE_ACTIVA, id); else lsDel(CLAVE_ACTIVA);
|
||||
}
|
||||
|
||||
/* Un cambio nuevo deja obsoletos los avisos con acción («Deshacer» /
|
||||
«Rehacer»): su botón ya no desharía lo que anuncian. */
|
||||
function cerrarAvisosConAccion() {
|
||||
var ts = document.querySelectorAll("#ed-toasts .ed-toast");
|
||||
for (var i = 0; i < ts.length; i++) {
|
||||
if (!ts[i].querySelector(".ed-toast-accion")) continue;
|
||||
if (typeof ts[i].__cerrar === "function") ts[i].__cerrar();
|
||||
else if (ts[i].parentNode) ts[i].parentNode.removeChild(ts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
WAM.puntoDeshacer = function () {
|
||||
if (!WAM.estado) return;
|
||||
pilaDeshacer.push(clonar(WAM.estado));
|
||||
cerrarAvisosConAccion();
|
||||
pilaDeshacer.push(instantanea());
|
||||
if (pilaDeshacer.length > LIMITE_HISTORIAL) pilaDeshacer.shift();
|
||||
pilaRehacer = [];
|
||||
pintarBotonesHistorial();
|
||||
@@ -137,10 +161,19 @@
|
||||
|
||||
WAM.puedeDeshacer = function () { return pilaDeshacer.length > 0; };
|
||||
|
||||
// El tema es una preferencia de visualización, no contenido: deshacer y
|
||||
// rehacer conservan el tema actual en vez del de la instantánea.
|
||||
function conTemaActual(x) {
|
||||
if (x && WAM.estado) x.tema = WAM.estado.tema;
|
||||
return x;
|
||||
}
|
||||
|
||||
WAM.deshacer = function () {
|
||||
if (!pilaDeshacer.length) { toast("No hay nada que deshacer"); return false; }
|
||||
pilaRehacer.push(clonar(WAM.estado));
|
||||
sustituirEstado(pilaDeshacer.pop());
|
||||
var previo = pilaDeshacer.pop();
|
||||
pilaRehacer.push(instantanea());
|
||||
sustituirEstado(conTemaActual(previo.estado));
|
||||
reponerActiva(previo.activa);
|
||||
pintarBotonesHistorial();
|
||||
toast("Cambio deshecho", { accion: { texto: "Rehacer", fn: WAM.rehacer } });
|
||||
return true;
|
||||
@@ -148,8 +181,10 @@
|
||||
|
||||
WAM.rehacer = function () {
|
||||
if (!pilaRehacer.length) { toast("No hay nada que rehacer"); return false; }
|
||||
pilaDeshacer.push(clonar(WAM.estado));
|
||||
sustituirEstado(pilaRehacer.pop());
|
||||
var siguiente = pilaRehacer.pop();
|
||||
pilaDeshacer.push(instantanea());
|
||||
sustituirEstado(conTemaActual(siguiente.estado));
|
||||
reponerActiva(siguiente.activa);
|
||||
pintarBotonesHistorial();
|
||||
toast("Cambio rehecho");
|
||||
return true;
|
||||
@@ -253,14 +288,17 @@
|
||||
if (!dentro) dlg.close();
|
||||
});
|
||||
var foco = null;
|
||||
var restaurar = true;
|
||||
dlg.addEventListener("close", function () {
|
||||
setTimeout(function () { dlg.remove(); }, 0);
|
||||
if (foco && foco.isConnected && typeof foco.focus === "function") foco.focus();
|
||||
if (restaurar && foco && foco.isConnected && typeof foco.focus === "function") foco.focus();
|
||||
});
|
||||
document.body.appendChild(dlg);
|
||||
return {
|
||||
dlg: dlg, cuerpo: cuerpo, pie: pie,
|
||||
abrir: function () { foco = document.activeElement; dlg.showModal(); }
|
||||
abrir: function () { foco = document.activeElement; dlg.showModal(); },
|
||||
// La acción elegida mueve el foco a otro sitio (p. ej. el nombre de la cuenta)
|
||||
sinRestaurar: function () { restaurar = false; }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,7 +319,7 @@
|
||||
lsDel(CLAVE_ACTIVA);
|
||||
ejecutar();
|
||||
marcarEditado();
|
||||
toast("Demo de ejemplo restablecida", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
|
||||
toast("Demo de ejemplo «Café Aurora» restablecida", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
|
||||
} else {
|
||||
ejecutar(); // el punto de deshacer lo toma importarJSON al aplicar el archivo
|
||||
}
|
||||
@@ -290,11 +328,11 @@
|
||||
var nombre = (WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "sin nombre";
|
||||
var resumen = "«" + nombre + "», " + nMensajes() + " mensaje" + (nMensajes() === 1 ? "" : "s");
|
||||
if (!soportaDialog) {
|
||||
if (window.confirm(esReset ? "¿Restablecer la demo de ejemplo? Se sustituirá " + resumen + "."
|
||||
if (window.confirm(esReset ? "¿Volver al ejemplo Café Aurora? Se sustituirá " + resumen + "."
|
||||
: "¿Importar un JSON? Se sustituirá " + resumen + ".")) hacer();
|
||||
return;
|
||||
}
|
||||
var d = crearDialogo("dlg-confirmar", esReset ? "¿Restablecer la demo de ejemplo?" : "¿Importar una demo desde JSON?", "wam-dlg-chico");
|
||||
var d = crearDialogo("dlg-confirmar", esReset ? "¿Volver al ejemplo Café Aurora?" : "¿Importar una demo desde JSON?", "wam-dlg-chico");
|
||||
d.cuerpo.appendChild(h("p", { text: (esReset
|
||||
? "La demo actual (" + resumen + ") se sustituirá por la demo de ejemplo «Café Aurora»."
|
||||
: "El archivo que elijas sustituirá la demo actual (" + resumen + ").") }));
|
||||
@@ -361,6 +399,18 @@
|
||||
return entrada;
|
||||
}
|
||||
|
||||
/* ¿El estado actual cambió de cuenta o de número de mensajes respecto a
|
||||
lo guardado en el hueco «id»? */
|
||||
function cambioSustancial(id) {
|
||||
var guardada = null;
|
||||
try { guardada = JSON.parse(lsGet(PREFIJO_DEMO + id) || "null"); } catch (e) { guardada = null; }
|
||||
if (!guardada || typeof guardada !== "object") return false;
|
||||
var nomG = (guardada.cuenta && guardada.cuenta.nombre) || "";
|
||||
var nomA = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "";
|
||||
var nG = Array.isArray(guardada.mensajes) ? guardada.mensajes.length : 0;
|
||||
return nomG !== nomA || nG !== nMensajes();
|
||||
}
|
||||
|
||||
function cargarDemo(id) {
|
||||
var bruto = lsGet(PREFIJO_DEMO + id);
|
||||
var datos = null;
|
||||
@@ -372,14 +422,59 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Tras cargar un escenario lo primero es poner el nombre del cliente:
|
||||
se despliega «Cuenta» y se enfoca (y selecciona) su nombre. */
|
||||
function enfocarNombreCuenta() {
|
||||
if (esEstrecha() && document.body.classList.contains("vista-movil")) return;
|
||||
if (document.body.classList.contains("presentacion")) return;
|
||||
var det = porId("bloque-cuenta");
|
||||
if (det && !det.open) det.open = true;
|
||||
var inp = porId("cuenta-nombre");
|
||||
if (!inp) return;
|
||||
try { inp.focus({ preventScroll: true }); } catch (e) { inp.focus(); }
|
||||
if (inp.scrollIntoView) inp.scrollIntoView({ block: "nearest" });
|
||||
if (typeof inp.select === "function") inp.select();
|
||||
}
|
||||
|
||||
/* Siempre deja un punto de deshacer (también desde una demo vacía) y un
|
||||
único aviso con «Deshacer»: WAM.toast retira los avisos con acción
|
||||
anteriores, que ya no deshacen lo que dicen. */
|
||||
function aplicarDemoCompleta(crudo, texto) {
|
||||
WAM.puntoDeshacer();
|
||||
sustituirEstado(normalizado(crudo));
|
||||
lsDel(CLAVE_ACTIVA);
|
||||
toast(texto, { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
|
||||
}
|
||||
|
||||
function cargarEscenario(esc) {
|
||||
var crudo = WAM.datosEscenario ? WAM.datosEscenario(esc.id) : null;
|
||||
if (!crudo) return;
|
||||
var habiaAlgo = nMensajes() > 0;
|
||||
if (habiaAlgo) WAM.puntoDeshacer();
|
||||
sustituirEstado(normalizado(crudo));
|
||||
lsDel(CLAVE_ACTIVA);
|
||||
toast("Escenario «" + esc.nombre + "» cargado", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
|
||||
aplicarDemoCompleta(crudo, "Escenario «" + esc.nombre + "» cargado. Cambia el nombre del negocio en «Cuenta».");
|
||||
enfocarNombreCuenta();
|
||||
}
|
||||
|
||||
// Tipos de mensaje que usa un escenario (chips del diálogo Demos)
|
||||
var NOMBRE_TIPO = {
|
||||
text: "Texto", image: "Imagen", video: "Vídeo", audio: "Audio", document: "Documento",
|
||||
sticker: "Sticker", location: "Ubicación", contact: "Contacto", template: "Plantilla",
|
||||
buttons: "Botones", list: "Lista", cta_url: "Botón URL", carousel: "Carrusel"
|
||||
};
|
||||
// Los más distintivos primero (texto casi siempre está)
|
||||
var ORDEN_TIPOS = ["template", "list", "carousel", "buttons", "cta_url", "location", "document",
|
||||
"contact", "image", "video", "audio", "sticker", "text"];
|
||||
|
||||
function chipsTipos(mensajes) {
|
||||
var usados = Object.create(null);
|
||||
(mensajes || []).forEach(function (m) { if (m && m.tipo) usados[m.tipo] = true; });
|
||||
var lista = ORDEN_TIPOS.filter(function (t) { return usados[t]; });
|
||||
if (!lista.length) return null;
|
||||
var max = 4;
|
||||
var cont = h("span", { class: "wam-esc-tipos", "aria-label": "Incluye: " + lista.map(function (t) { return NOMBRE_TIPO[t]; }).join(", ") });
|
||||
lista.slice(0, max).forEach(function (t) {
|
||||
cont.appendChild(h("span", { class: "wam-esc-tipo", "aria-hidden": "true", text: NOMBRE_TIPO[t] || t }));
|
||||
});
|
||||
if (lista.length > max) cont.appendChild(h("span", { class: "wam-esc-tipo", "aria-hidden": "true", text: "+" + (lista.length - max) }));
|
||||
return cont;
|
||||
}
|
||||
|
||||
function fechaCorta(iso) {
|
||||
@@ -397,29 +492,28 @@
|
||||
// --- Escenarios ---
|
||||
var gridEsc = h("div", { class: "wam-esc-grid" });
|
||||
(WAM.ESCENARIOS || []).forEach(function (esc) {
|
||||
var n = 0;
|
||||
try { n = (esc.datos().mensajes || []).length; } catch (e) { n = 0; }
|
||||
var ms = [];
|
||||
try { ms = esc.datos().mensajes || []; } catch (e) { ms = []; }
|
||||
var b = h("button", { type: "button", class: "wam-esc", "data-esc": esc.id }, [
|
||||
h("span", { class: "wam-esc-nombre", text: esc.nombre }),
|
||||
h("span", { class: "wam-esc-desc", text: esc.descripcion }),
|
||||
h("span", { class: "wam-esc-meta", text: n ? n + " mensajes" : "Vacía" })
|
||||
chipsTipos(ms),
|
||||
h("span", { class: "wam-esc-meta", text: ms.length ? ms.length + " mensajes" : "Vacía" })
|
||||
]);
|
||||
b.addEventListener("click", function () { d.dlg.close(); cargarEscenario(esc); });
|
||||
b.addEventListener("click", function () { d.sinRestaurar(); d.dlg.close(); cargarEscenario(esc); });
|
||||
gridEsc.appendChild(b);
|
||||
});
|
||||
var bAurora = h("button", { type: "button", class: "wam-esc" }, [
|
||||
var msAurora = [];
|
||||
try { msAurora = (typeof WAM.datosDemo === "function" && WAM.datosDemo().mensajes) || []; } catch (e) { msAurora = []; }
|
||||
var bAurora = h("button", { type: "button", class: "wam-esc", "data-esc": "aurora" }, [
|
||||
h("span", { class: "wam-esc-nombre", text: "Catálogo · Café Aurora" }),
|
||||
h("span", { class: "wam-esc-desc", text: "Los 13 tipos de mensaje de la Cloud API. Útil como referencia." }),
|
||||
h("span", { class: "wam-esc-meta", text: "Demo de ejemplo" })
|
||||
h("span", { class: "wam-esc-meta", text: "Demo de ejemplo" + (msAurora.length ? " · " + msAurora.length + " mensajes" : "") })
|
||||
]);
|
||||
bAurora.addEventListener("click", function () {
|
||||
d.dlg.close();
|
||||
if (typeof WAM.datosDemo !== "function") return;
|
||||
var habiaAlgo = nMensajes() > 0;
|
||||
if (habiaAlgo) WAM.puntoDeshacer();
|
||||
sustituirEstado(normalizado(WAM.datosDemo()));
|
||||
lsDel(CLAVE_ACTIVA);
|
||||
toast("Demo «Café Aurora» cargada", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
|
||||
aplicarDemoCompleta(WAM.datosDemo(), "Demo «Café Aurora» cargada");
|
||||
});
|
||||
gridEsc.appendChild(bAurora);
|
||||
|
||||
@@ -432,19 +526,33 @@
|
||||
// --- Demos guardadas ---
|
||||
var lista = h("ul", { class: "wam-demos-lista" });
|
||||
var activa = demoActiva();
|
||||
// Sin nombre precargado: el consultor escribe el del cliente (si lo deja
|
||||
// vacío se usa el nombre de la cuenta).
|
||||
var nombreCuenta = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "";
|
||||
var inputNombre = h("input", {
|
||||
type: "text", class: "wam-dlg-input", id: "dlg-demo-nombre", maxlength: "60",
|
||||
value: activa ? activa.nombre : ((WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "Mi demo"),
|
||||
value: activa ? activa.nombre : "",
|
||||
placeholder: "Nombre de la demo (p. ej. el cliente)" + (nombreCuenta ? " · si lo dejas vacío: «" + nombreCuenta + "»" : ""),
|
||||
"aria-label": "Nombre de la demo"
|
||||
});
|
||||
var bGuardar = boton(activa ? "Actualizar" : "Guardar la actual", "wam-dlg-primario", function () {
|
||||
var nombre = String(inputNombre.value || "").trim() || "Mi demo";
|
||||
var nombre = String(inputNombre.value || "").trim() || nombreCuenta || "Mi demo";
|
||||
var misma = activa && activa.nombre === nombre;
|
||||
// Defensa: si lo que se va a escribir sobre una demo existente tiene
|
||||
// otra cuenta u otro número de mensajes, se pide un segundo clic.
|
||||
if (misma && bGuardar.getAttribute("data-confirmar") !== "1" && cambioSustancial(activa.id)) {
|
||||
bGuardar.setAttribute("data-confirmar", "1");
|
||||
bGuardar.textContent = "¿Sobrescribir «" + activa.nombre + "»?";
|
||||
toast("El contenido actual no se parece al de «" + activa.nombre + "» (otra cuenta u otro número de mensajes). Pulsa otra vez para sobrescribirla o cambia el nombre para guardarla como nueva.", { ms: 7000 });
|
||||
return;
|
||||
}
|
||||
bGuardar.removeAttribute("data-confirmar");
|
||||
var e = guardarDemoComo(nombre, misma ? activa.id : null);
|
||||
if (e) { toast("Demo «" + e.nombre + "» guardada"); pintarLista(); activa = demoActiva(); }
|
||||
});
|
||||
inputNombre.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); bGuardar.click(); } });
|
||||
inputNombre.addEventListener("input", function () {
|
||||
bGuardar.removeAttribute("data-confirmar");
|
||||
bGuardar.textContent = (activa && activa.nombre === String(inputNombre.value).trim()) ? "Actualizar" : "Guardar como nueva";
|
||||
});
|
||||
|
||||
@@ -539,27 +647,43 @@
|
||||
/* ------------------------------------------------------------------
|
||||
Atajos de teclado
|
||||
------------------------------------------------------------------ */
|
||||
// «Ctrl» se pinta como ⌘ en Mac (los atajos aceptan ctrlKey o metaKey)
|
||||
var ATAJOS = [
|
||||
["N", "Nueva demo: escenarios y demos guardadas"],
|
||||
["P", "Modo presentación"],
|
||||
["Espacio", "Reproducir / detener la demo"],
|
||||
["G", "Saltar entre el editor y el teléfono (también F6)"],
|
||||
["T", "Tema claro / oscuro"],
|
||||
["F", "Pantalla completa"],
|
||||
["N", "Demos y escenarios"],
|
||||
["Esc", "Cerrar la lista del chat o salir de la presentación"],
|
||||
["Ctrl + S", "Exportar JSON"],
|
||||
["Ctrl + Z", "Deshacer (fuera de un campo de texto)"],
|
||||
["Ctrl + Mayús + Z", "Rehacer"],
|
||||
["Alt + ↑ / ↓", "Mover el mensaje o elemento con el foco"],
|
||||
["Alt + clic en una burbuja", "Abrir su tarjeta en el editor (también doble clic)"],
|
||||
["Alt + clic", "Sobre una burbuja: abre su tarjeta en el editor (también doble clic)"],
|
||||
["?", "Esta ayuda"]
|
||||
];
|
||||
|
||||
function esMac() {
|
||||
var nav = window.navigator || {};
|
||||
var plat = (nav.userAgentData && nav.userAgentData.platform) || nav.platform || nav.userAgent || "";
|
||||
return /mac|iphone|ipad|ipod/i.test(String(plat));
|
||||
}
|
||||
|
||||
function teclaVisible(k) {
|
||||
if (!esMac()) return k;
|
||||
if (k === "Ctrl") return "⌘";
|
||||
if (k === "Alt") return "⌥";
|
||||
if (k === "Mayús") return "⇧";
|
||||
return k;
|
||||
}
|
||||
|
||||
WAM.abrirAtajos = function () {
|
||||
if (!soportaDialog) return;
|
||||
var d = crearDialogo("dlg-atajos", "Atajos de teclado", "wam-dlg-chico");
|
||||
var tabla = h("dl", { class: "wam-atajos" });
|
||||
ATAJOS.forEach(function (a) {
|
||||
tabla.appendChild(h("dt", {}, a[0].split(" + ").map(function (k, i, arr) {
|
||||
tabla.appendChild(h("dt", {}, a[0].split(" + ").map(teclaVisible).map(function (k, i, arr) {
|
||||
var frag = document.createDocumentFragment();
|
||||
frag.appendChild(h("kbd", { text: k }));
|
||||
if (i < arr.length - 1) frag.appendChild(document.createTextNode(" + "));
|
||||
@@ -591,12 +715,66 @@
|
||||
return !!(s && !s.hidden && s.offsetParent !== null);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Saltar entre paneles: enlace «Saltar a la vista previa» y G / F6
|
||||
------------------------------------------------------------------ */
|
||||
var ultimoFocoEditor = null;
|
||||
|
||||
function enfocar(el) {
|
||||
if (!el) return false;
|
||||
try { el.focus({ preventScroll: true }); } catch (e) { el.focus(); }
|
||||
return document.activeElement === el;
|
||||
}
|
||||
|
||||
function irAVistaPrevia() {
|
||||
if (esEstrecha() && !document.body.classList.contains("presentacion")) WAM.setVistaMovil("vista");
|
||||
var cuerpo = porId("chat-cuerpo");
|
||||
if (!cuerpo) return;
|
||||
// tras el cambio de pestaña (display) el nodo ya es enfocable
|
||||
requestAnimationFrame(function () { enfocar(cuerpo); });
|
||||
}
|
||||
|
||||
function irAlEditor() {
|
||||
if (document.body.classList.contains("presentacion")) return false;
|
||||
if (esEstrecha()) WAM.setVistaMovil("editor");
|
||||
requestAnimationFrame(function () {
|
||||
var lista = porId("lista-mensajes");
|
||||
var previo = ultimoFocoEditor && ultimoFocoEditor.isConnected && ultimoFocoEditor.getClientRects().length ? ultimoFocoEditor : null;
|
||||
if (previo && enfocar(previo)) return;
|
||||
var cab = lista && lista.querySelector(".ed-cab-principal");
|
||||
if (cab && enfocar(cab)) { if (cab.scrollIntoView) cab.scrollIntoView({ block: "nearest" }); return; }
|
||||
var pal = document.querySelector('#nuevo-tipo [data-tipo][tabindex="0"]') || document.querySelector("#nuevo-tipo [data-tipo]");
|
||||
enfocar(pal || lista);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function alternarPanel() {
|
||||
var movil = porId("panel-movil");
|
||||
var a = document.activeElement;
|
||||
if (movil && a && movil.contains(a)) irAlEditor();
|
||||
else irAVistaPrevia();
|
||||
}
|
||||
|
||||
function enlazarSaltos() {
|
||||
var enlace = porId("saltar-vista");
|
||||
if (enlace) enlace.addEventListener("click", function (e) { e.preventDefault(); irAVistaPrevia(); });
|
||||
var ed = porId("panel-editor");
|
||||
if (ed) ed.addEventListener("focusin", function (e) { ultimoFocoEditor = e.target; });
|
||||
}
|
||||
|
||||
function alTecla(e) {
|
||||
if (e.defaultPrevented || e.isComposing) return;
|
||||
var k = e.key;
|
||||
var mod = e.ctrlKey || e.metaKey;
|
||||
var hayDialogo = !!document.querySelector("dialog[open]");
|
||||
|
||||
if (k === "F6" && !mod && !e.altKey && !hayDialogo) {
|
||||
e.preventDefault();
|
||||
alternarPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mod && !e.altKey && (k === "s" || k === "S")) {
|
||||
e.preventDefault();
|
||||
if (!hayDialogo && typeof WAM.exportarJSON === "function") WAM.exportarJSON();
|
||||
@@ -625,6 +803,7 @@
|
||||
case "t": case "T": e.preventDefault(); clicEn("btn-tema"); break;
|
||||
case "f": case "F": e.preventDefault(); alternarPantallaCompleta(); break;
|
||||
case "n": case "N": e.preventDefault(); WAM.abrirDemos(); break;
|
||||
case "g": case "G": e.preventDefault(); alternarPanel(); break;
|
||||
case "?": e.preventDefault(); WAM.abrirAtajos(); break;
|
||||
case " ":
|
||||
if (esInteractivo(e.target)) return; // Espacio activa el botón con foco
|
||||
@@ -638,15 +817,28 @@
|
||||
/* ------------------------------------------------------------------
|
||||
Menú «⋯» de la toolbar (ventana estrecha) y pestañas Editor / Vista
|
||||
------------------------------------------------------------------ */
|
||||
function cerrarMenu() {
|
||||
function cerrarMenu(devolverFoco) {
|
||||
var tb = porId("toolbar");
|
||||
var mas = porId("btn-mas");
|
||||
if (!tb || !tb.classList.contains("tb-menu-abierto")) return false;
|
||||
var menu = porId("tb-menu");
|
||||
var dentro = menu && menu.contains(document.activeElement);
|
||||
tb.classList.remove("tb-menu-abierto");
|
||||
if (mas) mas.setAttribute("aria-expanded", "false");
|
||||
if (mas) {
|
||||
mas.setAttribute("aria-expanded", "false");
|
||||
if (devolverFoco !== false && dentro) mas.focus();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Opciones visibles del menú «⋯» abierto (en escritorio solo «Volver al ejemplo»)
|
||||
function opcionesMenu() {
|
||||
var menu = porId("tb-menu");
|
||||
if (!menu) return [];
|
||||
var bs = esEstrecha() ? menu.querySelectorAll("button:not([disabled])") : menu.querySelectorAll("#btn-reset");
|
||||
return Array.prototype.filter.call(bs, function (b) { return b.getClientRects().length > 0; });
|
||||
}
|
||||
|
||||
function enlazarMenu() {
|
||||
var tb = porId("toolbar"), mas = porId("btn-mas"), menu = porId("tb-menu");
|
||||
if (!tb || !mas || !menu) return;
|
||||
@@ -654,10 +846,27 @@
|
||||
e.stopPropagation();
|
||||
var abierto = tb.classList.toggle("tb-menu-abierto");
|
||||
mas.setAttribute("aria-expanded", abierto ? "true" : "false");
|
||||
if (abierto) { var b = menu.querySelector("button:not([disabled])"); if (b) b.focus(); }
|
||||
if (abierto) { var ops = opcionesMenu(); if (ops[0]) ops[0].focus(); }
|
||||
});
|
||||
// Flechas dentro del menú abierto
|
||||
tb.addEventListener("keydown", function (e) {
|
||||
if (!tb.classList.contains("tb-menu-abierto") || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return;
|
||||
var ops = opcionesMenu();
|
||||
var i = ops.indexOf(document.activeElement);
|
||||
if (i < 0 && document.activeElement !== mas) return;
|
||||
e.preventDefault();
|
||||
var j = i < 0 ? 0 : (i + (e.key === "ArrowDown" ? 1 : -1) + ops.length) % ops.length;
|
||||
if (ops[j]) ops[j].focus();
|
||||
});
|
||||
// Al salir con Tab del menú abierto, se cierra
|
||||
tb.addEventListener("focusout", function (e) {
|
||||
if (!tb.classList.contains("tb-menu-abierto")) return;
|
||||
var sig = e.relatedTarget;
|
||||
if (sig && (menu.contains(sig) || sig === mas)) return;
|
||||
if (sig) cerrarMenu(false);
|
||||
});
|
||||
menu.addEventListener("click", function (e) {
|
||||
if (e.target.closest && e.target.closest("button")) cerrarMenu();
|
||||
if (e.target.closest && e.target.closest("button")) cerrarMenu(false);
|
||||
});
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!tb.classList.contains("tb-menu-abierto")) return;
|
||||
@@ -708,9 +917,18 @@
|
||||
var fijar = function () {
|
||||
document.documentElement.style.setProperty("--wam-toolbar-alto", Math.ceil(tb.getBoundingClientRect().height) + "px");
|
||||
};
|
||||
var barra = porId("barra-movil");
|
||||
var fijarBarra = function () {
|
||||
if (!barra) return;
|
||||
var r = barra.getBoundingClientRect();
|
||||
if (r.height) document.documentElement.style.setProperty("--wam-barra-alto", Math.ceil(r.height + 14) + "px");
|
||||
};
|
||||
fijar();
|
||||
if (typeof ResizeObserver === "function") new ResizeObserver(fijar).observe(tb);
|
||||
else window.addEventListener("resize", fijar);
|
||||
fijarBarra();
|
||||
if (typeof ResizeObserver === "function") {
|
||||
new ResizeObserver(fijar).observe(tb);
|
||||
if (barra) new ResizeObserver(fijarBarra).observe(barra);
|
||||
} else window.addEventListener("resize", function () { fijar(); fijarBarra(); });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
@@ -782,9 +1000,41 @@
|
||||
/* ------------------------------------------------------------------
|
||||
Estado de los botones de la toolbar
|
||||
------------------------------------------------------------------ */
|
||||
var temaPintado = null;
|
||||
function sincronizarTema() {
|
||||
var tema = document.body.getAttribute("data-tema");
|
||||
var b = porId("btn-tema");
|
||||
if (b) b.setAttribute("aria-pressed", document.body.getAttribute("data-tema") === "oscuro" ? "true" : "false");
|
||||
if (b) {
|
||||
b.setAttribute("aria-pressed", tema === "oscuro" ? "true" : "false");
|
||||
b.setAttribute("aria-label", "Tema oscuro");
|
||||
}
|
||||
// El cambio de tema (botón o T) también pasa por «Guardando…»
|
||||
if (temaPintado !== null && tema !== temaPintado) marcarEditado();
|
||||
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); });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
@@ -809,6 +1059,8 @@
|
||||
enlazarPestanas();
|
||||
observarToolbar();
|
||||
enlazarChat();
|
||||
enlazarSaltos();
|
||||
enlazarZoom();
|
||||
envolverAnuncios();
|
||||
|
||||
sincronizarTema();
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// Cobertura de lo integrado tras los parches de render, editor/toolbar y lógica:
|
||||
// scroll inicial, zoom manual, JSON malicioso, recortes al importar, contador
|
||||
// de pasos, horas e IDs de sesión, formato WhatsApp, citas y documentos.
|
||||
import {
|
||||
test, expect, estado, filasChat, filaChat, cargarEstado, rapido, irA, URL_FILE, tarjetas,
|
||||
} from "./fixtures.mjs";
|
||||
|
||||
async function importar(page, datos, nombre = "demo.json") {
|
||||
const buffer = Buffer.from(typeof datos === "string" ? datos : JSON.stringify(datos));
|
||||
await page.locator("#btn-importar").click();
|
||||
const confirmar = page.locator("#dlg-confirmar");
|
||||
const [chooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser"),
|
||||
(async () => {
|
||||
if (await confirmar.isVisible().catch(() => false)) {
|
||||
await confirmar.getByRole("button", { name: "Elegir archivo…" }).click();
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
await chooser.setFiles({ name: nombre, mimeType: "application/json", buffer });
|
||||
}
|
||||
|
||||
const alFondo = (page) => page.evaluate(() => {
|
||||
const c = document.getElementById("chat-cuerpo");
|
||||
return Math.abs(c.scrollHeight - c.clientHeight - c.scrollTop) <= 2;
|
||||
});
|
||||
|
||||
function estadoCon(mensajes, extra = {}) {
|
||||
return { version: 1, tema: "claro", cuenta: { nombre: "Prueba", verificado: false }, mensajes, ...extra };
|
||||
}
|
||||
|
||||
test.describe("Scroll inicial del chat", () => {
|
||||
test("al cargar, el chat queda pegado al último mensaje", async ({ app }) => {
|
||||
await expect.poll(() => alFondo(app), { timeout: 5000 }).toBe(true);
|
||||
// y sigue abajo tras recargar (imágenes data:SVG que decodifican tarde)
|
||||
await app.reload();
|
||||
await expect.poll(() => alFondo(app), { timeout: 5000 }).toBe(true);
|
||||
});
|
||||
|
||||
test("por file:// también arranca abajo", async ({ page }) => {
|
||||
await irA(page, URL_FILE);
|
||||
await expect.poll(() => alFondo(page), { timeout: 5000 }).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Zoom manual", () => {
|
||||
const ancho = (page) => page.locator("#iphone").evaluate((el) => el.getBoundingClientRect().width);
|
||||
|
||||
test("Ajustar / 100 % / 125 % cambian la escala y se recuerda", async ({ app }) => {
|
||||
const sel = app.locator("#sel-zoom");
|
||||
await expect(sel).toBeVisible();
|
||||
await expect(sel).toHaveValue("");
|
||||
const ajustado = await ancho(app);
|
||||
await sel.selectOption("125");
|
||||
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "125");
|
||||
await expect.poll(() => ancho(app)).toBeGreaterThan(ajustado);
|
||||
const grande = await ancho(app);
|
||||
await sel.selectOption("100");
|
||||
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "100");
|
||||
await expect.poll(() => ancho(app)).toBeLessThan(grande);
|
||||
await app.reload();
|
||||
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "100");
|
||||
await expect(app.locator("#sel-zoom")).toHaveValue("100");
|
||||
await app.locator("#sel-zoom").selectOption("");
|
||||
await expect(app.locator("body")).not.toHaveAttribute("data-wam-zoom", /.*/);
|
||||
await expect.poll(() => ancho(app)).toBeCloseTo(ajustado, 0);
|
||||
});
|
||||
|
||||
test("en Presentación manda el ajuste automático", async ({ app }) => {
|
||||
await app.locator("#sel-zoom").selectOption("125");
|
||||
await app.locator("#btn-presentacion").click();
|
||||
await expect(app.locator("body")).toHaveClass(/presentacion/);
|
||||
const caja = await app.locator("#iphone").boundingBox();
|
||||
const vh = app.viewportSize().height;
|
||||
expect(caja.y + caja.height).toBeLessThanOrEqual(vh + 1);
|
||||
await app.keyboard.press("Escape");
|
||||
await expect(app.locator("body")).not.toHaveClass(/presentacion/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("JSON malicioso", () => {
|
||||
test("importar HTML y URLs peligrosas no ejecuta nada ni inyecta nodos", async ({ app }) => {
|
||||
const img = '<img src=x onerror="window.__xss=1">';
|
||||
await importar(app, estadoCon([
|
||||
{ id: "m1", dir: "in", tipo: "text", hora: "10:00", texto: img + " *hola* <script>window.__xss=2</script>" },
|
||||
{
|
||||
id: "m2", dir: "in", tipo: "template", hora: "10:01", headerTipo: "texto", headerTexto: img, body: img,
|
||||
botones: [
|
||||
{ texto: img, url: "javascript:window.__xss=3" },
|
||||
{ texto: "Datos", url: "data:text/html,<script>window.__xss=4</script>" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "m3", dir: "in", tipo: "carousel", hora: "10:02", body: "",
|
||||
cards: [
|
||||
{ media: "javascript:alert(1)", texto: img, textoBoton: "Ver", url: "vbscript:msgbox(1)" },
|
||||
{ media: "", texto: "ok", textoBoton: "Ver" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "m4", dir: "in", tipo: "list", hora: "10:03", body: "Menú", boton: img,
|
||||
secciones: [{ titulo: img, filas: [{ id: "f1", titulo: img, descripcion: img }] }],
|
||||
},
|
||||
{ id: "m5", dir: "in", tipo: "image", hora: "10:04", src: "javascript:window.__xss=5", caption: img },
|
||||
], { cuenta: { nombre: img, avatar: "javascript:window.__xss=6", verificado: false } }));
|
||||
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(5);
|
||||
const e = await estado(app);
|
||||
// URLs peligrosas bloqueadas y marcadas
|
||||
expect(e.mensajes[1].botones[0]).toMatchObject({ url: "", urlBloqueada: true });
|
||||
expect(e.mensajes[1].botones[1]).toMatchObject({ url: "", urlBloqueada: true });
|
||||
expect(e.mensajes[2].cards[0]).toMatchObject({ url: "", urlBloqueada: true });
|
||||
expect(e.mensajes[4].src).toBe("");
|
||||
// nada ejecutable en el DOM del chat ni del editor
|
||||
const sospechosos = await app.evaluate(() => ({
|
||||
scripts: document.querySelectorAll("#pantalla script, #panel-editor script").length,
|
||||
onerror: document.querySelectorAll("[onerror]").length,
|
||||
jsHref: document.querySelectorAll('a[href^="javascript"], a[href^="data:text"], a[href^="vbscript"]').length,
|
||||
jsSrc: Array.from(document.querySelectorAll("img, video")).filter((el) => /^javascript:/i.test(el.getAttribute("src") || "")).length,
|
||||
}));
|
||||
expect(sospechosos).toEqual({ scripts: 0, onerror: 0, jsHref: 0, jsSrc: 0 });
|
||||
// el texto se ve literal
|
||||
await expect(filaChat(app, "m1")).toContainText("<img src=x");
|
||||
await expect(app.locator("#chat-nombre")).toContainText("<img src=x");
|
||||
// botones bloqueados: inertes, sin flujo
|
||||
await expect(filaChat(app, "m2").locator(".qr-btn-inactiva")).toHaveCount(2);
|
||||
await expect(filaChat(app, "m2").locator("[data-flujo]")).toHaveCount(0);
|
||||
await expect(filaChat(app, "m3").locator(".card").first().locator(".qr-btn-inactiva")).toHaveCount(1);
|
||||
const n = await filasChat(app).count();
|
||||
await filaChat(app, "m2").locator(".qr-btn-inactiva").first().click({ force: true });
|
||||
await app.waitForTimeout(600);
|
||||
await expect(filasChat(app)).toHaveCount(n);
|
||||
// la hoja de lista también pinta texto literal
|
||||
await filaChat(app, "m4").locator("[data-flujo]").click();
|
||||
await expect(app.locator(".sheet-panel").first()).toContainText("<img src=x");
|
||||
expect(await app.evaluate(() => window.__xss)).toBeUndefined();
|
||||
expect(await app.evaluate(() => document.querySelectorAll("[onerror]").length)).toBe(0);
|
||||
});
|
||||
|
||||
test("editar a mano la URL de un botón bloqueado retira la marca", async ({ app }) => {
|
||||
await cargarEstado(app, estadoCon([
|
||||
{ id: "m1", dir: "in", tipo: "template", hora: "10:00", body: "b", botones: [{ texto: "Ir", url: "javascript:1" }] },
|
||||
]));
|
||||
expect((await estado(app)).mensajes[0].botones[0].urlBloqueada).toBe(true);
|
||||
await app.evaluate(() => WAM.setCampo("mensajes.0.botones.0.url", "https://example.com"));
|
||||
const b = (await estado(app)).mensajes[0].botones[0];
|
||||
expect(b.urlBloqueada).toBeUndefined();
|
||||
await expect(filaChat(app, "m1").locator('a.qr-btn[href="https://example.com"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("un JSON enorme se recorta y el aviso lo cuenta", async ({ app }) => {
|
||||
const mensajes = Array.from({ length: 505 }, (_, i) => ({ id: "m" + (i + 1), dir: "in", tipo: "text", hora: "10:00", texto: "n" + i }));
|
||||
await importar(app, estadoCon(mensajes));
|
||||
await expect.poll(async () => (await estado(app)).mensajes.length, { timeout: 15000 }).toBe(500);
|
||||
await expect(app.getByText("Se importaron 500 de 505 mensajes")).toBeVisible();
|
||||
// el resumen no se guarda ni se exporta
|
||||
const guardado = await app.evaluate(() => { WAM.guardarYa(); return localStorage.getItem("wam-mockup-v1"); });
|
||||
expect(guardado).not.toContain("__recortes");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Demo: contador, horas e IDs de sesión", () => {
|
||||
const GUION = estadoCon([
|
||||
{ id: "m1", dir: "in", tipo: "text", hora: "10:30", texto: "Hola" },
|
||||
{ id: "m2", dir: "out", tipo: "text", hora: "10:31", texto: "Buenas", estado: "read" },
|
||||
{ id: "m3", dir: "in", tipo: "text", hora: "10:33", texto: "¿Qué tal?" },
|
||||
]);
|
||||
|
||||
test("mientras corre se ve «Paso N/M» y el botón muestra Detener", async ({ app }) => {
|
||||
await cargarEstado(app, GUION);
|
||||
await rapido(app);
|
||||
const paso = app.locator("#demo-paso");
|
||||
await expect(paso).toBeHidden();
|
||||
await app.locator("#btn-reproducir").click();
|
||||
await expect(paso).toBeVisible();
|
||||
await expect(paso).toHaveText(/Paso \d+\/3/);
|
||||
await expect(app.locator("#btn-reproducir")).toContainText("Detener");
|
||||
await expect.poll(() => app.evaluate(() => WAM.demoActiva()), { timeout: 10000 }).toBe(false);
|
||||
await expect(paso).toBeHidden();
|
||||
await expect(app.locator("#btn-reproducir")).toContainText("Reproducir demo");
|
||||
});
|
||||
|
||||
test("la saliente de un botón toma la hora del último mensaje + 1 min", async ({ app }) => {
|
||||
await cargarEstado(app, estadoCon([
|
||||
{ id: "m1", dir: "in", tipo: "text", hora: "10:30", texto: "Hola" },
|
||||
{
|
||||
id: "m2", dir: "in", tipo: "buttons", hora: "10:33", body: "¿Qué quieres?",
|
||||
botones: [{ texto: "Precios", respuestas: [{ id: "m3", dir: "in", tipo: "text", hora: "", texto: "Barato" }] }],
|
||||
},
|
||||
]));
|
||||
await rapido(app);
|
||||
await filaChat(app, "m2").locator("[data-flujo]").first().click();
|
||||
const out = app.locator('#chat-cuerpo li.out[data-id^="s"]').last();
|
||||
await expect(out).toContainText("Precios");
|
||||
await expect(out.locator(".meta")).toContainText("10:34");
|
||||
const resp = app.locator('#chat-cuerpo li.in[data-id^="s"]').last();
|
||||
await expect(resp).toContainText("Barato", { timeout: 6000 });
|
||||
await expect(resp.locator(".meta")).toContainText("10:35");
|
||||
});
|
||||
|
||||
test("los IDs de sesión no chocan con los del guion", async ({ app }) => {
|
||||
await cargarEstado(app, estadoCon([
|
||||
{ id: "s1", dir: "in", tipo: "text", hora: "10:00", texto: "uno" },
|
||||
{ id: "s2", dir: "in", tipo: "text", hora: "10:01", texto: "dos" },
|
||||
]));
|
||||
await app.locator("#composer-input").fill("tres");
|
||||
await app.locator("#composer-input").press("Enter");
|
||||
const ids = await app.locator("#chat-cuerpo > li.wa-row").evaluateAll((els) => els.map((e) => e.getAttribute("data-id")));
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids[2]).toMatch(/^s/);
|
||||
expect(["s1", "s2"]).not.toContain(ids[2]);
|
||||
});
|
||||
|
||||
test("«Repetir desde el inicio» vuelve al guion tras pulsar un botón", async ({ app }) => {
|
||||
const n = (await estado(app)).mensajes.length;
|
||||
const btn = app.locator("#btn-reiniciar");
|
||||
await expect(btn).toContainText("Repetir desde el inicio");
|
||||
await rapido(app);
|
||||
await app.locator("#chat-cuerpo [data-flujo][data-btn]").first().click();
|
||||
await expect.poll(() => filasChat(app).count()).toBeGreaterThan(n);
|
||||
await btn.click();
|
||||
await expect(filasChat(app)).toHaveCount(n);
|
||||
await expect(app.locator("#chat-cuerpo .qr-btn.usado")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Formato, citas y documentos", () => {
|
||||
test("negrita solo con marcas pegadas al texto; «2 * 3 * 4» queda literal", async ({ app }) => {
|
||||
await cargarEstado(app, estadoCon([
|
||||
{ id: "m1", dir: "in", tipo: "text", hora: "10:00", texto: "*negrita* y _cursiva_ y ~tachado~" },
|
||||
{ id: "m2", dir: "in", tipo: "text", hora: "10:01", texto: "2 * 3 * 4 y *negrita *" },
|
||||
]));
|
||||
await expect(filaChat(app, "m1").locator("strong, b")).toHaveText("negrita");
|
||||
await expect(filaChat(app, "m1").locator("em, i")).toHaveText("cursiva");
|
||||
await expect(filaChat(app, "m1").locator("s, del, strike")).toHaveText("tachado");
|
||||
await expect(filaChat(app, "m2").locator("strong, b")).toHaveCount(0);
|
||||
await expect(filaChat(app, "m2")).toContainText("2 * 3 * 4 y *negrita *");
|
||||
});
|
||||
|
||||
test("la cita de la respuesta quita el formato con la misma regla", async ({ app }) => {
|
||||
await cargarEstado(app, estadoCon([
|
||||
{
|
||||
id: "m1", dir: "in", tipo: "buttons", hora: "10:00", body: "*Hola* 2 * 3",
|
||||
botones: [{ texto: "Vale", respuestas: [] }],
|
||||
},
|
||||
]));
|
||||
await rapido(app);
|
||||
await filaChat(app, "m1").locator("[data-flujo]").click();
|
||||
const cita = app.locator('#chat-cuerpo li.out[data-id^="s"] .wa-cita-extracto').last();
|
||||
await expect(cita).toHaveText("Hola 2 * 3");
|
||||
});
|
||||
|
||||
test("citar una plantilla con cabecera de documento muestra icono y nombre", async ({ app }) => {
|
||||
await cargarEstado(app, estadoCon([
|
||||
{
|
||||
id: "m1", dir: "in", tipo: "template", hora: "10:00", headerTipo: "documento", headerTexto: "carta.pdf",
|
||||
body: "Te enviamos la carta", botones: [{ texto: "Gracias", respuestas: [] }],
|
||||
},
|
||||
]));
|
||||
await rapido(app);
|
||||
await filaChat(app, "m1").locator("[data-flujo]").click();
|
||||
const tit = app.locator('#chat-cuerpo li.out[data-id^="s"] .wa-cita-titulo').last();
|
||||
await expect(tit).toHaveText("carta.pdf");
|
||||
await expect(tit.locator("svg.wa-cita-doc")).toHaveCount(1);
|
||||
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"] .wa-cita-mini')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("documento: «tamaño • ext» con tamano; si no, la extensión en mayúsculas", async ({ app }) => {
|
||||
await cargarEstado(app, estadoCon([
|
||||
{ id: "m1", dir: "in", tipo: "document", hora: "10:00", filename: "menu.pdf", tamano: "243 KB" },
|
||||
{ id: "m2", dir: "in", tipo: "document", hora: "10:01", filename: "hoja.xlsx" },
|
||||
{
|
||||
id: "m3", dir: "in", tipo: "template", hora: "10:02", headerTipo: "documento", headerTexto: "cat.pdf",
|
||||
headerTamano: "1,2 MB", body: "b", botones: [],
|
||||
},
|
||||
]));
|
||||
await expect(filaChat(app, "m1").locator(".doc-sub")).toHaveText("243 KB • pdf");
|
||||
await expect(filaChat(app, "m2").locator(".doc-sub")).toHaveText("XLSX");
|
||||
await expect(filaChat(app, "m3").locator(".doc-sub")).toHaveText("1,2 MB • pdf");
|
||||
const e = await estado(app);
|
||||
expect(e.mensajes[1].tamano).toBeUndefined();
|
||||
expect(e.mensajes[2].headerTamano).toBe("1,2 MB");
|
||||
});
|
||||
|
||||
test("botón con URL sin esquema abre con https:// y no da aviso de validación", async ({ app }) => {
|
||||
await cargarEstado(app, estadoCon([
|
||||
{ id: "m1", dir: "in", tipo: "template", hora: "10:00", body: "b", botones: [{ texto: "Web", url: "www.cafe-aurora.mx" }] },
|
||||
]));
|
||||
await expect(filaChat(app, "m1").locator('a.qr-btn[href="https://www.cafe-aurora.mx"]')).toHaveCount(1);
|
||||
await expect(tarjetas(app).first().locator(".ed-validacion")).toBeHidden();
|
||||
await expect(tarjetas(app).first()).not.toContainText("URL no válida");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Móvil sin marco @movil", () => {
|
||||
test("pantalla a todo el ancho, sin isla ni zoom, y el chat arranca abajo @movil", async ({ app }) => {
|
||||
await expect(app.locator("#tab-vista")).toHaveAttribute("aria-selected", "true");
|
||||
await expect(app.locator("#sel-zoom")).toBeHidden();
|
||||
await expect(app.locator("#isla")).toBeHidden();
|
||||
const caja = await app.locator("#pantalla").boundingBox();
|
||||
expect(Math.round(caja.width)).toBeGreaterThanOrEqual(app.viewportSize().width - 1);
|
||||
await expect.poll(() => alFondo(app), { timeout: 5000 }).toBe(true);
|
||||
});
|
||||
|
||||
test("un zoom guardado en escritorio no afecta al móvil @movil", async ({ app }) => {
|
||||
await app.evaluate(() => localStorage.setItem("wam-zoom", "125"));
|
||||
await app.reload();
|
||||
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "125");
|
||||
const caja = await app.locator("#pantalla").boundingBox();
|
||||
expect(caja.width).toBeLessThanOrEqual(app.viewportSize().width + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// Regresiones: deshacer/rehacer con demos guardadas, avisos «Deshacer»
|
||||
// obsoletos, hoja «Todas las opciones» con URLs bloqueadas y foco en las hojas.
|
||||
import { test, expect, estado, filaChat, cargarEstado, rapido } from "./fixtures.mjs";
|
||||
|
||||
async function nombreCuenta(page, v) {
|
||||
await page.evaluate(() => { const d = document.getElementById("bloque-cuenta"); if (d) d.open = true; });
|
||||
await page.locator("#cuenta-nombre").fill(v);
|
||||
await expect.poll(async () => (await estado(page)).cuenta.nombre).toBe(v);
|
||||
}
|
||||
|
||||
const activaLS = (page) => page.evaluate(() => localStorage.getItem("wam-demo-activa"));
|
||||
|
||||
async function guardarComo(page, nombre) {
|
||||
await page.locator("#btn-demos").click();
|
||||
const dlg = page.locator("#dlg-demos");
|
||||
await dlg.locator("#dlg-demo-nombre").fill(nombre);
|
||||
await dlg.getByRole("button", { name: /Guardar/ }).click();
|
||||
await expect(dlg.locator(".wam-demo-nombre", { hasText: new RegExp("^" + nombre + "$") })).toBeVisible();
|
||||
await dlg.locator(".wam-dlg-pie").getByRole("button", { name: "Cerrar" }).click();
|
||||
}
|
||||
|
||||
test.describe("Deshacer y demos guardadas", () => {
|
||||
test("deshacer / rehacer reponen la demo activa y «Actualizar» no pisa otra demo", async ({ app }) => {
|
||||
await guardarComo(app, "A");
|
||||
const idA = await activaLS(app);
|
||||
// cargar un escenario y guardarlo como B
|
||||
await app.locator("#btn-demos").click();
|
||||
await app.locator("#dlg-demos .wam-esc").first().click();
|
||||
await nombreCuenta(app, "Cuenta B");
|
||||
await guardarComo(app, "B");
|
||||
const idB = await activaLS(app);
|
||||
expect(idB).not.toBe(idA);
|
||||
// abrir A
|
||||
await app.locator("#btn-demos").click();
|
||||
await app.locator("#dlg-demos .wam-demo").filter({ has: app.locator(".wam-demo-nombre", { hasText: /^A$/ }) })
|
||||
.getByRole("button", { name: "Abrir" }).click();
|
||||
expect(await activaLS(app)).toBe(idA);
|
||||
// deshacer: vuelve el contenido de B y también la demo activa B
|
||||
await app.locator("#btn-deshacer").click();
|
||||
expect((await estado(app)).cuenta.nombre).toBe("Cuenta B");
|
||||
expect(await activaLS(app)).toBe(idB);
|
||||
await app.locator("#btn-demos").click();
|
||||
await expect(app.locator("#dlg-demo-nombre")).toHaveValue("B");
|
||||
await app.locator("#dlg-demos .wam-dlg-pie").getByRole("button", { name: "Cerrar" }).click();
|
||||
// rehacer: vuelve A
|
||||
await app.locator("#btn-rehacer").click();
|
||||
expect(await activaLS(app)).toBe(idA);
|
||||
// deshacer un escenario (que borra la clave activa) la repone
|
||||
await app.locator("#btn-demos").click();
|
||||
await app.locator("#dlg-demos .wam-esc").first().click();
|
||||
expect(await activaLS(app)).toBeNull();
|
||||
await app.locator("#btn-deshacer").click();
|
||||
expect(await activaLS(app)).toBe(idA);
|
||||
// el hueco de A sigue siendo A
|
||||
const slotA = await app.evaluate((id) => JSON.parse(localStorage.getItem("wam-demo-" + id) || "null"), idA);
|
||||
expect(slotA && slotA.cuenta.nombre).not.toBe("Cuenta B");
|
||||
});
|
||||
|
||||
test("«Actualizar» pide un segundo clic si cambió la cuenta", async ({ app }) => {
|
||||
await guardarComo(app, "A");
|
||||
const idA = await activaLS(app);
|
||||
const nombreOriginal = (await estado(app)).cuenta.nombre;
|
||||
await nombreCuenta(app, "Otra cuenta");
|
||||
await app.locator("#btn-demos").click();
|
||||
const bGuardar = app.locator("#dlg-demos .wam-dlg-primario");
|
||||
await expect(bGuardar).toHaveText("Actualizar");
|
||||
await bGuardar.click();
|
||||
await expect(bGuardar).toHaveText(/Sobrescribir/);
|
||||
let slot = await app.evaluate((id) => JSON.parse(localStorage.getItem("wam-demo-" + id)), idA);
|
||||
expect(slot.cuenta.nombre).toBe(nombreOriginal);
|
||||
await bGuardar.click();
|
||||
slot = await app.evaluate((id) => JSON.parse(localStorage.getItem("wam-demo-" + id)), idA);
|
||||
expect(slot.cuenta.nombre).toBe("Otra cuenta");
|
||||
});
|
||||
|
||||
test("el «Deshacer» de un aviso desaparece cuando hay otro cambio", async ({ app }) => {
|
||||
const n = (await estado(app)).mensajes.length;
|
||||
await app.evaluate(() => WAM.borrarMensaje(0));
|
||||
const accion = app.locator("#ed-toasts .ed-toast-accion");
|
||||
await expect(accion).toHaveCount(1);
|
||||
await nombreCuenta(app, "Nombre nuevo");
|
||||
// sin esperar: el aviso caduca solo a los 6 s y enmascararía el fallo
|
||||
expect(await accion.count()).toBe(0);
|
||||
expect((await estado(app)).mensajes.length).toBe(n - 1);
|
||||
});
|
||||
});
|
||||
|
||||
const PLANTILLA = [
|
||||
{
|
||||
id: "m1", dir: "in", tipo: "template", hora: "10:00", body: "Elige",
|
||||
botones: [
|
||||
{ texto: "Uno" }, { texto: "Dos" },
|
||||
{ texto: "Bloqueado", url: "javascript:alert(1)" },
|
||||
{ texto: "Relativa", url: "/menu" },
|
||||
{ texto: "Cinco" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "m2", dir: "in", tipo: "list", hora: "10:01", body: "Menú", boton: "Ver menú",
|
||||
secciones: [{ titulo: "S", filas: [{ id: "f1", titulo: "Fila 1" }, { id: "f2", titulo: "Fila 2" }] }],
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Hojas inferiores", () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await rapido(app);
|
||||
await cargarEstado(app, { mensajes: PLANTILLA });
|
||||
await expect(filaChat(app, "m1")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("«Todas las opciones» pinta inertes los botones con URL bloqueada o no válida", async ({ app }) => {
|
||||
const fila = filaChat(app, "m1");
|
||||
const inertesBurbuja = await fila.locator(".qr-btn-inactiva").allTextContents();
|
||||
expect(inertesBurbuja.map((t) => t.trim())).toEqual(["Bloqueado", "Relativa"]);
|
||||
await fila.locator("[data-ver-todas]").click();
|
||||
const hoja = app.locator("#sheet");
|
||||
const inertesHoja = await hoja.locator('.sheet-fila.qr-btn-inactiva[aria-disabled="true"]').allTextContents();
|
||||
expect(inertesHoja.map((t) => t.trim())).toEqual(inertesBurbuja.map((t) => t.trim()));
|
||||
await expect(hoja.locator("[data-opcion]")).toHaveCount(3);
|
||||
await expect(hoja.locator('[data-opcion="2"], [data-opcion="3"]')).toHaveCount(0);
|
||||
await expect(hoja.locator(".sheet-fila")).toHaveCount(5);
|
||||
});
|
||||
|
||||
test("foco: la hoja lo recibe, lo retiene con Tab y lo devuelve al cerrar", async ({ app }) => {
|
||||
const toggle = filaChat(app, "m1").locator("[data-ver-todas]");
|
||||
await toggle.focus();
|
||||
await app.keyboard.press("Enter");
|
||||
await expect(app.locator("#sheet")).toHaveCount(1);
|
||||
const enHoja = () => app.evaluate(() => !!document.activeElement.closest("#sheet"));
|
||||
expect(await enHoja()).toBe(true);
|
||||
for (let k = 0; k < 6; k++) {
|
||||
await app.keyboard.press("Tab");
|
||||
expect(await enHoja()).toBe(true);
|
||||
}
|
||||
await app.keyboard.press("Shift+Tab");
|
||||
expect(await enHoja()).toBe(true);
|
||||
await app.keyboard.press("Escape");
|
||||
await expect(app.locator("#sheet")).toHaveCount(0);
|
||||
expect(await app.evaluate(() => document.activeElement.hasAttribute("data-ver-todas"))).toBe(true);
|
||||
});
|
||||
|
||||
test("foco: la lista «Ver opciones» enfoca su primera fila", async ({ app }) => {
|
||||
const abridor = filaChat(app, "m2").locator("[data-flujo]:not([data-btn]):not([data-card])").first();
|
||||
await abridor.focus();
|
||||
await app.keyboard.press("Enter");
|
||||
await expect(app.locator("#sheet")).toHaveCount(1);
|
||||
expect(await app.evaluate(() => document.activeElement.getAttribute("data-fila"))).toBe("0");
|
||||
await app.locator("#sheet .sheet-cerrar").click();
|
||||
await expect(app.locator("#sheet")).toHaveCount(0);
|
||||
expect(await app.evaluate(() => document.activeElement.closest('[data-id="m2"]') !== null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -153,6 +153,7 @@ test.describe("Restablecer, deshacer y rehacer", () => {
|
||||
await anadirTipo(app, "text");
|
||||
await campo(app, "mensajes.0.texto").fill("Mi cambio");
|
||||
const mio = await estado(app);
|
||||
await app.locator("#btn-mas").click();
|
||||
await app.locator("#btn-reset").click();
|
||||
const dlg = app.locator("#dlg-confirmar");
|
||||
await expect(dlg).toBeVisible();
|
||||
@@ -168,6 +169,7 @@ test.describe("Restablecer, deshacer y rehacer", () => {
|
||||
});
|
||||
|
||||
test("«Exportar antes» del diálogo descarga la demo actual", async ({ app }) => {
|
||||
await app.locator("#btn-mas").click();
|
||||
await app.locator("#btn-reset").click();
|
||||
const { nombre } = await exportar(app, () => app.locator("#dlg-confirmar").getByRole("button", { name: "Exportar antes" }).click());
|
||||
expect(nombre).toBe("cafe-aurora.json");
|
||||
@@ -177,6 +179,7 @@ test.describe("Restablecer, deshacer y rehacer", () => {
|
||||
|
||||
test("sin mensajes restablece sin preguntar", async ({ app }) => {
|
||||
await vaciar(app);
|
||||
await app.locator("#btn-mas").click();
|
||||
await app.locator("#btn-reset").click();
|
||||
await expect(app.locator("#dlg-confirmar")).toHaveCount(0);
|
||||
await expect(tarjetas(app)).toHaveCount(14);
|
||||
|
||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
expect: { timeout: 7_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
retries: 1, // tolera algún fallo intermitente por carga con muchos workers
|
||||
// WebKit en Windows se vuelve inestable con muchos workers en paralelo
|
||||
workers: process.env.CI ? 2 : Number(process.env.WAM_WORKERS || 6),
|
||||
reporter: [["list"], ["html", { outputFolder: "playwright-report", open: "never" }]],
|
||||
|
||||
Reference in New Issue
Block a user