TinyMCE popups desplazados con AdminKit: 9 soluciones que no funcionaron y la que sí
Cuando los menús desplegables de TinyMCE aparecen cada vez más abajo cuanto más scroll haces, el problema no es TinyMCE ni tu CSS. Es un conflicto de sistemas de coordenadas. Esto es todo lo que probamos, por qué falló, y la solución real.
Si estás leyendo esto, probablemente tienes el mismo bug que nosotros: los popups de TinyMCE (menús de toolbar, tooltips, selectores de fuente, color, bloques) aparecen desplazados hacia abajo, y el desplazamiento es proporcional a cuánto scroll has hecho en la página. Cuanto más bajas, más lejos aparece el popup de donde debería estar.
Nos costó 9 intentos resolverlo. Este artículo documenta cada uno para que no tengas que repetir el mismo camino.
El bug: popups de TinyMCE desplazados proporcionalmente al scroll
Elementos afectados: cualquier popup o tooltip de TinyMCE. Los menús desplegables de la toolbar, tooltips de los botones, selectores de fuente, bloques, color, y plugins custom (en nuestro caso, un plugin de AI Writer). Todos se desplazan hacia abajo exactamente la misma cantidad de píxeles que has scrolleado.
Si estás en el top de la página: todo perfecto. Si has scrolleado 500px: el popup aparece 500px más abajo de donde debería. El patrón es lineal y predecible, lo que apunta directamente a un problema de cálculo de coordenadas, no a un bug de renderizado aleatorio.
Cómo verificar rápido en consola:
document.querySelector('.content').scrollTop // → Valor del scroll real
window.scrollY // → Siempre 0 (este es el problema)
La causa raíz (por qué getBoundingClientRect miente)
El bug está en la intersección de tres sistemas que no conocen la existencia del otro.
La arquitectura de AdminKit
AdminKit (y muchos frameworks CSS de admin panels) usa un patrón SPA donde el body no scrollea. El scroll lo hace un contenedor interno:
body ← overflow: hidden (NO scrollea → window.scrollY = 0)
└── .wrapper ← flex horizontal
├── #sidebar ← sidebar fija
└── .main ← overflow: hidden
└── .content ← overflow-y: auto → ESTE scrollea
Cómo calcula TinyMCE la posición de sus popups
TinyMCE hace lo que haría cualquier librería razonable:
- Llama a
getBoundingClientRect()del botón para obtener sus coordenadas relativas al viewport. - Suma
window.scrollYpara convertir a coordenadas absolutas del documento. - Coloca el popup con
position: absoluteen elbody.
El conflicto
Como window.scrollY es siempre 0 (el body no scrollea), TinyMCE no compensa nada por el scroll. Pero el popup está posicionado con position: absolute relativo al body, mientras que el botón que lo dispara está dentro de .content que sí ha scrolleado.
Resultado: getBoundingClientRect() devuelve la posición correcta del botón en el viewport, pero cuando TinyMCE convierte eso a posición absoluta, la ecuación rect.top + window.scrollY da un resultado incorrecto porque window.scrollY = 0 cuando el scroll real está en otro sitio.
TinyMCE y AdminKit hablan idiomas de coordenadas diferentes. El popup se coloca donde estaría el botón si no hubiese scroll, no donde está realmente.
Las 9 soluciones que probamos (y por qué no funcionaron)
1. ui_mode: 'split' + ui_container: 'body'
Renderiza los popups como hijos directos del body en vez de dentro del iframe del editor. En teoría, esto debería dar a TinyMCE el control total del posicionamiento.
Por qué falla: el popup se posiciona absolute en el body usando coordenadas de viewport, pero window.scrollY = 0. Las coordenadas no compensan el scroll de .content.
No funciona
2. CSS position: fixed en .tox-tinymce-aux
Forzar position: fixed !important en el contenedor de popups para que se posicionen relativo al viewport en vez del documento.
Por qué falla: TinyMCE decide internamente entre fixed y absolute según su función useFixed(). Calcula las coordenadas asumiendo absolute, pero el CSS las interpreta como fixed. Inconsistencia total.
No funciona
3. JS translateY(-window.scrollY)
Compensar el desplazamiento aplicando transform: translateY(-window.scrollY) al contenedor de popups.
Por qué falla: window.scrollY es siempre 0. El scroll está en .content, no en la ventana. Estás compensando un valor que no existe.
No funciona
4. JS updateTinyAuxOffset (compensar scroll del contenedor)
Calcular containerScroll - windowScroll y aplicarlo como CSS variable al contenedor de popups.
Por qué falla: funciona parcialmente durante un frame. Pero TinyMCE recalcula posiciones internamente en cada apertura de menú, sobreescribiendo el parche. Resultado: parpadeo constante (el popup aparece bien un frame, se desplaza, vuelve a corregirse).
Parcial, con parpadeo
5. Forzar overflow: visible en .wrapper y .main
Quitar el scroll interno forzando overflow: visible en los contenedores padre.
Por qué falla: AdminKit re-establece los estilos, o el layout se rompe completamente y el scroll deja de funcionar.
Rompe el layout
6. MutationObserver para mover .tox-tinymce-aux al body
Observar el DOM y mover el contenedor de popups al body si no está ahí.
Por qué falla: ya estaba en el body. El problema no es dónde está el elemento, es cómo TinyMCE calcula las coordenadas.
No funciona (el elemento ya estaba en body)
7. Monkey-patch JS complejo (170 líneas)
Un script que observaba mutaciones del DOM, rastreaba rawTop vs appliedDelta, y corregía el top de cada popup en tiempo real.
Por qué falla: demasiado frágil. Competía con TinyMCE que también actualiza los estilos, causando parpadeos y edge cases. 170 líneas de código peleando contra la librería que intentas usar.
Demasiado frágil
8. position: fixed !important con width: 0; height: 0
Hacer el sink invisible (0x0) y dejar que los hijos se muestren con overflow.
Por qué falla: TinyMCE necesita las dimensiones del sink para calcular los bounds internos. Con tamaño 0, el editor no carga.
Rompe TinyMCE
9. Quitar ui_mode: 'split'
Sin split, los popups se renderizan dentro del iframe del editor, no en un contenedor externo.
Por qué falla: TinyMCE 7 siempre renderiza ciertos popups (tooltips, menús de toolbar) en el aux container externo, con o sin split. El bug sigue ahí para esos elementos.
Incompleto
La solución que funcionó: cambiar quién scrollea
Después de 9 intentos, la conclusión fue clara: no se puede parchear un conflicto de sistemas de coordenadas con JavaScript. Si dos sistemas (AdminKit y TinyMCE) asumen cosas diferentes sobre quién scrollea, la única solución limpia es alinear las asunciones.
La solución: hacer que el body sea el que scrollee, no .content.
Antes (AdminKit default, causa el bug)
html, body { overflow: hidden; }
.main { overflow: hidden; }
.content { overflow-y: auto; } /* ← ESTE scrollea */
/* window.scrollY = 0 siempre → TinyMCE se confunde */
Después (fix, funciona)
html, body { height: 100%; margin: 0; }
.main { /* sin overflow forzado */ }
.content { /* sin overflow-y: auto */ }
/* El BODY scrollea → window.scrollY refleja el scroll real */
Y el sidebar con position: sticky; height: 100vh; para que se quede fijo mientras el body scrollea.
Por qué funciona
Cuando el body es el que scrollea, todos los cálculos de TinyMCE son correctos:
window.scrollYtiene el valor real del scroll.getBoundingClientRect().topdevuelve la posición relativa al viewport.rect.top + window.scrollY= posición absoluta correcta en el documento.- El popup con
position: absoluteen el body se coloca exactamente donde debe.
TinyMCE y el body hablan el mismo idioma de coordenadas. No hay parche, no hay hack, no hay monkey-patch. Solo dos sistemas que por fin están de acuerdo en cómo funciona el scroll.
Cómo implementar el fix paso a paso
Los cambios necesarios en un proyecto Laravel con AdminKit y TinyMCE:
1. CSS del layout (app.blade.php)
/* Quitar overflow: hidden del body y de .main */
html, body {
height: 100%;
margin: 0;
/* NO poner overflow: hidden */
}
.main {
/* quitar overflow: hidden si lo tiene */
}
.content {
/* quitar overflow-y: auto */
/* el body hará el scroll */
}
/* Sidebar fijo */
#sidebar, .sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
2. TinyMCE config (parciales _tinymce.blade.php)
// QUITAR estas opciones que ya no necesitas: // ui_mode: 'split', ← quitar // ui_container: 'body', ← quitar // QUITAR todo el JS de monkey-patch: // updateTinyAuxOffset ← quitar // ensureTinyAuxLayerOnBody ← quitar // startTinyAuxObserver ← quitar // fixTinyMCEPopups ← quitar // translateY hacks ← quitar // MutationObserver hacks ← quitar
3. Testing
Verificar después del cambio:
- Scroll de la página funciona correctamente.
- Sidebar se mantiene fija al scrollear.
- Popups de TinyMCE aparecen debajo del botón que los dispara, sin importar la posición de scroll.
- Tooltips de la toolbar aparecen correctamente.
- El resto del panel de admin funciona (formularios, tablas, navegación).
La lección para otros desarrolladores
Si tienes un layout tipo SPA donde un contenedor interno scrollea (no el body) y usas una librería de terceros que asume que window.scrollY refleja el scroll real, vas a tener este tipo de bug. No solo con TinyMCE: cualquier librería que posicione popups, tooltips o dropdowns con position: absolute en el body y use getBoundingClientRect() + window.scrollY se va a confundir.
La tentación es parchear con JavaScript. No funciona a largo plazo. La librería tiene sus propios ciclos de renderizado que van a competir con tu parche. El resultado son parpadeos, edge cases y código frágil que se rompe con cada actualización.
La solución limpia es siempre la misma: alinear las asunciones. Si la librería asume que el body scrollea, haz que el body scrollee. Si no puedes cambiar el layout, entonces necesitas una librería que soporte tu patrón de scroll (o contribuir un fix upstream para que lo soporte).
Regla general: no se puede parchear un conflicto de sistemas de coordenadas con JavaScript. Si dos sistemas asumen cosas diferentes sobre quién scrollea, la única solución limpia es alinear las asunciones.
Comments (0)
No comments yet.
Leave a Comment