diff --git a/src/uix/eidos/components/palabras/continuar-palabras.md b/src/uix/eidos/components/palabras/continuar-palabras.md index d6d19daf2..da8393b1a 100644 --- a/src/uix/eidos/components/palabras/continuar-palabras.md +++ b/src/uix/eidos/components/palabras/continuar-palabras.md @@ -7,6 +7,90 @@ --- +## ESTADO 2026-06-07 — Menú de acciones del grip + parpadeo RESUELTO + +El grip dejó de ser un botón que solo abría el panel: ahora es un **menú de +acciones** (`DropdownMenu`) — **Propiedades · Mover arriba · Mover abajo · +Insertar bloque · Eliminar bloque**. "Propiedades" abre el panel de inspección; +el resto opera sobre el bloque por comandos del motor. Sigue siendo el +drag-source del reorder (HTML5 drag). Nuevo archivo: `palabras-block-actions.ts` +(`moveBlock`, `insertParagraphBelow`, `deleteBlockAndReanchor`). + +### El "parpadeo" del menú — eran CUATRO bugs distintos (todos resueltos) + +El usuario reportó que el menú "parpadeaba / temblaba / abría y cerraba". No era +uno, eran cuatro fallos COMPUESTOS — por eso cada fix individual "no arreglaba el +parpadeo". Diagnóstico y solución de cada uno: + +1. **Churn autónomo por `disabled` reactivo.** Los flags `disabled` de los items + (`Mover arriba/abajo`) derivaban de `blockIndex`/`blockCount`, que leían + `api.html` reactivo. Al abrir el menú el foco sale del contenteditable → el + provider re-renderiza → `api.html` cambia → se re-crea el item enfocado → + pierde foco → el menú lo lee como "foco fuera" → cierra/reabre en bucle. + **Fix:** `index`/`count` se CONGELAN en el objeto `grip` al posicionarlo (no + reactivos), y el grip se congela mientras el menú está abierto (guard + `menuOpen` en el `$effect` de anclaje y en el hover-follow). Espejo del + `index` estable del gutter de Words. + +2. **Cerrar-y-reabrir en un solo click (doble-toggle).** Al pulsar el grip con el + menú abierto, el `pointerdown` llega a la capa `Dismissal` del menú, que ve el + grip (el trigger, FUERA de `[data-palabras-handle-menu]`) como "pointerdown + fuera" → cierra; y el `click` que sigue → el trigger reabre. Cierra+reabre = + parpadeo. **Fix:** `onpointerdown` en el trigger con `e.stopPropagation()` + SOLO con el menú abierto (cerrado propaga normal para no romper la apertura ni + el drag-reorder). Reproducido y verificado de forma determinista. + +3. **Salto del scrollbar (ScrollLock).** `DropdownMenu.Content` tiene + `preventScroll` con default `true` → al abrir bloquea el scroll del body + (`overflow:hidden` + padding compensatorio) → **el scrollbar desaparece** y la + página salta; al cerrar vuelve. Un menú de acciones NO debe bloquear el scroll. + **Fix:** `preventScroll={false}` en el `Content`. (Pista del usuario: "aparece + el scroll cuando le doy".) + +4. **Doble animación de entrada** (lo que el usuario describió al final: "el fondo + de los bloques aparece y desaparece superpuesto al menú"). El contenido del + menú recibe DOS animaciones de entrada que chocan: `present-rise` (de + `eidos/lib/motion/presets`, aplicada por la capa sema/motion MIENTRAS el hold + `data-event` está activo ~240ms, porque el evento `open` del menú es familia + `emerge`) y `dropdown-menu-enter` (del recipe `dropdown-menu.css`, sobre + `[data-state=open]`). Cuando el hold sema termina, el `animation-name` cambia → + el navegador REINICIA la animación → la opacidad salta a 0 un frame. Como este + menú **no está portalizado** (es `position:fixed` pero DOM-anidado en el + carril), ese frame transparente deja ver los bloques de detrás. **Fix:** fijar + UNA sola animación con `!important` en `palabras.css`: + `[data-palabras-handle-menu][data-state='open'] { animation: dropdown-menu-enter … !important }` + → el nombre no cambia, la opacidad nunca se reinicia. Verificado por timeline de + opacidad: sube 0→1 una vez y se queda (sin caída a 0). + +Prerrequisito (un 5º foco-churn, ya resuelto antes): el grip y el menú llevan +`data-words-external-tool`, registrado en `isInsideWordsTool` +(`words-provider.svelte.ts`), para que abrir el menú (que roba el foco al +contenteditable) NO se lea como blur externo → re-foco → bucle editor↔menú. + +### Método (por qué costó tanto, para la próxima vez) + +- **Los eventos sintéticos NO reproducen el ratón real.** `grip.click()` / + `dispatchEvent` daban siempre "estable"; varios de estos bugs solo aparecen con + el ratón del usuario. Para este tipo de fallo: instrumentar y que el USUARIO + interactúe, no confiar en clicks sintéticos. +- Instrumento clave: listeners `focus`/`pointer` + `MutationObserver` del + `data-state` del menú + muestreo de opacidad/posición por frame, **persistido a + `sessionStorage`** (sobrevive a los reloads de HMR, que borraban los logs en + `window`). El log real del usuario reveló cada mecanismo. +- **Las pistas precisas del usuario cortaron días de adivinar:** "aparece el + scroll" (→ bug 3) y "es el fondo de los bloques superpuesto al menú" (→ bug 4). +- Bugs 2, 3 y 4 se verifican de forma DETERMINISTA sin el ratón (reproducir el + doble-toggle de estados; leer `body{overflow}`+scrollbar; leer el timeline de + opacidad). Bug 1 se confirmó por la estabilidad del log real tras el fix. + +Archivos tocados: `palabras-handle.svelte` (grip+index/count congelados, +`onpointerdown` stopPropagation, `preventScroll={false}`), `palabras.css` (pin de +animación), `palabras-block-actions.ts` (nuevo), `palabras-panel.svelte` + +`palabras.svelte` (prop `content` para `deleteBlockAndReanchor`), +`words-provider.svelte.ts` (`data-words-external-tool` en `isInsideWordsTool`). + +--- + ## 0. REGLAS DURAS (no negociables — el usuario fue tajante) 1. **Solo componentes del framework (eidos). PROHIBIDO usar elementos nativos @@ -47,7 +131,8 @@ | `palabras-panel.svelte` | Panel de propiedades (flotante, draggable). Cabecera (dot+título+papelera), secciones del schema (colapsable propio con chevron), pie "Apariencia · heredada/personalizada" + botón "Editar". | Funciona. La sección usa un ` + + + + {#snippet icon()} + + {/snippet} + Acciones del bloque + + + + Propiedades + + + Mover arriba + + = blockCount - 1} + > + Mover abajo + + Insertar bloque + + Eliminar bloque + + {/if} diff --git a/src/uix/eidos/components/palabras/palabras-panel.svelte b/src/uix/eidos/components/palabras/palabras-panel.svelte index 7c214891a..66c30a6d1 100644 --- a/src/uix/eidos/components/palabras/palabras-panel.svelte +++ b/src/uix/eidos/components/palabras/palabras-panel.svelte @@ -12,13 +12,20 @@ import { Trash2, ChevronUp, ChevronDown } from '$uix/eidos/components/icon'; import { getPanelSchema } from './panel-schema'; import { createDrag } from './palabras-drag.svelte'; + import { deleteBlockAndReanchor } from './palabras-block-actions'; import type { PalabrasFieldDef } from './types'; let { api, + content, onEditAppearance, onClose - }: { api: ProviderSnippetProps; onEditAppearance: () => void; onClose: () => void } = $props(); + }: { + api: ProviderSnippetProps; + content: HTMLElement | null; + onEditAppearance: () => void; + onClose: () => void; + } = $props(); const loc = $derived(api.activeBlockLocation); const schema = $derived(getPanelSchema(loc?.node.type)); @@ -54,18 +61,8 @@ } function deleteActive() { const path = loc?.path; - if (!path || path.length !== 1) return; - const index = path[0]; - api.applyCommand({ type: 'deleteBlock', blockIndex: index }); - // Re-point the active block by reading the UPDATED document: the block that - // shifted into this index, else the previous one, else whatever block the - // engine kept. Reading AFTER the delete is what fixes the "deleted the last - // block" case — there's no neighbour to capture beforehand, and the engine - // never leaves the document empty (it keeps a blank paragraph). Without - // this, activeBlockId would be null and the grip would hang over the gap. - const kids = api.document.children; - const survivor = kids[index] ?? kids[index - 1] ?? kids[0] ?? null; - api.setActiveBlock(survivor?.id ?? null); + if (!path || path.length !== 1 || !content) return; + void deleteBlockAndReanchor(api, content, path[0]); // Close the panel on delete — the block it was inspecting is gone. onClose(); } diff --git a/src/uix/eidos/components/palabras/palabras.css b/src/uix/eidos/components/palabras/palabras.css index 57908874e..2e9e2cb83 100644 --- a/src/uix/eidos/components/palabras/palabras.css +++ b/src/uix/eidos/components/palabras/palabras.css @@ -69,6 +69,19 @@ z-index: 5; transition: top var(--duration-fast, 0.12s) var(--ease-default, ease); } +/* Action-menu entrance — pin ONE animation. The menu gets TWO competing entrance + animations on open: the sema/motion `present-rise` (applied WHILE the + `data-event` hold is up, ~240ms, because the `open` event is family `emerge`) + and the dropdown recipe's `dropdown-menu-enter` (on `data-state=open`). When + the hold ends the `animation-name` switches → the browser RESTARTS the + animation, snapping opacity back to 0 for a frame. This menu is rendered + in-place (position:fixed but DOM-nested in the rail, NOT portaled), so that + transparent frame reveals the editor blocks behind it — the "block background + flashing over the menu" flicker. Pin the recipe animation with `!important` + so the name never switches and opacity never resets to 0. */ +[data-palabras-handle-menu][data-state='open'] { + animation: dropdown-menu-enter var(--duration-fast, 0.12s) var(--ease-out, ease) !important; +} /* Block grip — the framework