palabras: menú de acciones en el grip + arreglo del parpadeo (4 bugs)

El grip pasa a ser un DropdownMenu con acciones (Propiedades, Mover
arriba/abajo, Insertar, Eliminar bloque). Arreglado el "parpadeo" del menú,
que eran CUATRO fallos compuestos (por eso cada fix suelto no lo resolvía):

- disabled reactivo (leía api.html) re-creaba el item enfocado al abrir ->
  cierre/reapertura en bucle. Fix: index/count congelados en el objeto grip +
  grip congelado mientras el menú está abierto.
- pointerdown sobre el grip llegaba a la capa Dismissal del menú, que lo veía
  "fuera del contenido" -> cerraba, y el click reabría. Fix: stopPropagation en
  el pointerdown del trigger SOLO con el menú abierto.
- preventScroll (default true) bloqueaba el scroll del body al abrir -> el
  scrollbar desaparecía y la página saltaba. Fix: preventScroll={false}.
- doble animación de entrada (present-rise de sema/motion durante el hold +
  dropdown-menu-enter del recipe) reiniciaba la opacidad a 0 al acabar el hold;
  como el menú no está portalizado, se veían los bloques detrás. Fix: fijar una
  sola animación con !important en palabras.css.

Nuevo palabras-block-actions.ts (moveBlock / insertParagraphBelow /
deleteBlockAndReanchor). words-provider: marcador genérico
data-words-external-tool en isInsideWordsTool para que abrir el menú no dispare
el bucle blur->refoco del editor. Diagnóstico completo en continuar-palabras.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 6c8d911d39
commit ec6fbd784b

@ -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) ## 0. REGLAS DURAS (no negociables — el usuario fue tajante)
1. **Solo componentes del framework (eidos). PROHIBIDO usar elementos nativos 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 `<button>`+`<section>` NATIVO colapsable (no Accordion/Tabs) — ver punto 4. Título/labels en `<span>` nativo → deuda regla #1. | | `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 `<button>`+`<section>` NATIVO colapsable (no Accordion/Tabs) — ver punto 4. Título/labels en `<span>` nativo → deuda regla #1. |
| `palabras-appearance.svelte` | 2º popover (botón "Editar"): edita `block.visual.*`. Color de texto/Fondo (`PalabrasColorRow`), Tamaño/Interlineado (`NumberField`), Alineación (`ToggleGroup`, labels de texto). Título "Apariencia". | Funciona. Contenedores `<aside>`/`<div>`/`<span>` nativos → deuda regla #1. Muestra el valor EFECTIVO (override del bloque, o el computado/heredado). | | `palabras-appearance.svelte` | 2º popover (botón "Editar"): edita `block.visual.*`. Color de texto/Fondo (`PalabrasColorRow`), Tamaño/Interlineado (`NumberField`), Alineación (`ToggleGroup`, labels de texto). Título "Apariencia". | Funciona. Contenedores `<aside>`/`<div>`/`<span>` nativos → deuda regla #1. Muestra el valor EFECTIVO (override del bloque, o el computado/heredado). |
| `palabras-color-row.svelte` | Fila de color con nuestro `<ColorPicker>` (Control>Trigger UN swatch, Portal>Content con Area + ChannelSlider hue/alpha + ChannelInput hex/rgb + SwatchGroup de `presets`). Patrón draft+`onValueChangeEnd` como `words-color-row`. | Funciona. `presets` llega vacío hoy (la paleta del esquema se revirtió — ver punto 5). | | `palabras-color-row.svelte` | Fila de color con nuestro `<ColorPicker>` (Control>Trigger UN swatch, Portal>Content con Area + ChannelSlider hue/alpha + ChannelInput hex/rgb + SwatchGroup de `presets`). Patrón draft+`onValueChangeEnd` como `words-color-row`. | Funciona. `presets` llega vacío hoy (la paleta del esquema se revirtió — ver punto 5). |
| `palabras-handle.svelte` | El **tirador (grip)**. Ahora es un `<Button>` del framework (re-aplicado por el usuario). Aparece en el bloque ACTIVO (touch, sin hover) + sigue al hover (desktop); `gripFor` devuelve el CENTRO vertical. Escucha en `[data-palabras-doc]` (no en content) para que no desaparezca al ir a pulsarlo. | **VERIFICAR EL PRIMERO** (ver punto 3). | | `palabras-handle.svelte` | El **tirador (grip)** = `DropdownMenu` cuyo Trigger es un `<Button>` (ghost/xs/iconOnly) draggable. Click → **menú de acciones** (Propiedades · Mover arriba/abajo · Insertar · Eliminar). Sigue al bloque hover/activo (`gripFor` → centro vertical + `index`/`count` congelados); escucha en `[data-palabras-doc]`. Drag → reorder. | Funciona (parpadeo resuelto — ver sección 2026-06-07). |
| `palabras-block-actions.ts` | Acciones de bloque compartidas por el grip y el panel: `moveBlock`, `insertParagraphBelow`, `deleteBlockAndReanchor` (borra y re-ancla el activo leyendo el superviviente del DOM, no de `api.document` que es snapshot). | Funciona. |
| `palabras-nav.svelte` | Descarte del panel: `Esc` o pointerdown FUERA → `onClose`. Ignora clics dentro de `[data-palabras-panel-card]`, `[data-palabras-appearance]`, `[data-palabras-handle]` y **`[data-color-picker-content]`** (el picker portaliza fuera). | Funciona. | | `palabras-nav.svelte` | Descarte del panel: `Esc` o pointerdown FUERA → `onClose`. Ignora clics dentro de `[data-palabras-panel-card]`, `[data-palabras-appearance]`, `[data-palabras-handle]` y **`[data-color-picker-content]`** (el picker portaliza fuera). | Funciona. |
| `palabras-breadcrumb.svelte` | Breadcrumb desde `ancestry`. Delegación de clic por `data-idpath` (funciona sea `<a>` o `<span>`): clic en miga = activar nodo + abrir panel; crumb "documento" = solo limpia. | Funciona. | | `palabras-breadcrumb.svelte` | Breadcrumb desde `ancestry`. Delegación de clic por `data-idpath` (funciona sea `<a>` o `<span>`): clic en miga = activar nodo + abrir panel; crumb "documento" = solo limpia. | Funciona. |
| `palabras-drag.svelte.ts` | `createDrag()` reactivo: `transform` + `down/move/up`. Lo usan panel y appearance (cada uno su estado). Botones internos no arrastran. | Funciona. | | `palabras-drag.svelte.ts` | `createDrag()` reactivo: `transform` + `down/move/up`. Lo usan panel y appearance (cada uno su estado). Botones internos no arrastran. | Funciona. |
@ -57,7 +142,12 @@
| `index.ts` | Exports (`Palabras` default + tipos). | OK | | `index.ts` | Exports (`Palabras` default + tipos). | OK |
| Demo: `web/routes/uix/components/palabras/+page.svelte` | Doc inicial (heading/paragraph), `<Palabras bind:value>`. | OK. Ruta: `/uix/components/palabras`. | | Demo: `web/routes/uix/components/palabras/+page.svelte` | Doc inicial (heading/paragraph), `<Palabras bind:value>`. | OK. Ruta: `/uix/components/palabras`. |
## 3. PRIMER pendiente — coherencia del tirador (grip) ## 3. ~~PRIMER pendiente — coherencia del tirador (grip)~~ (SUPERADO)
> Resuelto: el grip ya es coherente (DropdownMenu con Trigger `<Button>`,
> posicionado por el slot en el carril) y evolucionó a menú de acciones. Ver la
> sección fechada 2026-06-07 arriba. Lo de abajo se conserva como referencia
> histórica del CSS del slot.
El usuario re-aplicó `palabras-handle.svelte` como `<Button>` del framework (regla #1), El usuario re-aplicó `palabras-handle.svelte` como `<Button>` del framework (regla #1),
con comentario: "el placement CSS vive en palabras.css con especificidad que gana a con comentario: "el placement CSS vive en palabras.css con especificidad que gana a

@ -0,0 +1,53 @@
/**
* Shared top-level block actions for palabras.
*
* The grip's action menu (`palabras-handle`) and the inspector's trash button
* (`palabras-panel`) both mutate the document; this keeps the non-trivial bits
* (re-pointing the active block after a delete) in one place.
*/
import { tick } from 'svelte';
import type { ProviderSnippetProps } from '$soma/components/words';
/** Move a top-level block up or down by one position. */
export function moveBlock(api: ProviderSnippetProps, index: number, direction: 'up' | 'down'): void {
api.applyCommand({ type: 'moveBlock', blockIndex: index, direction });
}
/**
* Insert a fresh empty paragraph right below `index`. Provisional behaviour for
* the grip menu's "Insertar bloque" until the block-type grid (the shared popup,
* also opened by the `+` button) lands.
*/
export function insertParagraphBelow(api: ProviderSnippetProps, index: number): void {
api.applyCommand({
type: 'insertBlock',
blockIndex: index + 1,
block: { type: 'paragraph', children: [{ type: 'text', text: '' }] }
});
}
/**
* Delete a top-level block and re-point the active block to a surviving
* neighbour.
*
* IMPORTANT: the survivor is read from the DOM (`content.children`), NOT from
* `api.document`. The `api` snippet-prop is a snapshot the provider recreates
* each render; the one a handler holds keeps the PRE-delete document forever
* (even after a tick), so its survivor would be the block we just removed. The
* DOM, by contrast, re-renders from the new document — so after the flush we read
* the survivor there. Land on the block now at `index`, else the previous, else
* the first (the engine always keeps ≥1 block — a blank paragraph).
*/
export async function deleteBlockAndReanchor(
api: ProviderSnippetProps,
content: HTMLElement,
index: number
): Promise<void> {
api.applyCommand({ type: 'deleteBlock', blockIndex: index });
await tick();
const kids = Array.from(content.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-words-id')
);
const survivor = kids[index] ?? kids[index - 1] ?? kids[0] ?? null;
api.setActiveBlock(survivor?.getAttribute('data-words-id') ?? null);
}

@ -1,48 +1,45 @@
<script lang="ts"> <script lang="ts">
/** /**
* Block grip — the trigger for the properties panel + drag-handle. * Block grip — trigger for the block ACTION MENU + drag-handle.
* *
* The grip is a LITERAL COPY of Words' gutter grip * Structure mirrors Words' gutter grip (`words-block-gutter.svelte`): a
* (`words-block-gutter.svelte`): the framework `<Button>` * `DropdownMenu` whose Trigger is the framework `<Button>` (ghost/xs/iconOnly)
* (`variant="ghost" size="xs" iconOnly`), the SAME element Words' * made a drag source with `draggable` + `ondragstart`. Words' block-reorder
* `DropdownMenu.Trigger` renders, made a drag source with `draggable` + * drag works through exactly this trigger, so ours does too.
* `ondragstart`. Words' block-reorder drag works through exactly this Button,
* so ours does too. A block element (button) is a reliable HTML5 drag source;
* a bare inline `<svg>` is NOT — that was the earlier bug.
* *
* Two behaviours from the same Button, distinguished by the BROWSER's native * Two behaviours from the same trigger, by the browser's click-vs-drag:
* click-vs-drag detection (no custom press timer): * - CLICK → opens the ACTION MENU (Propiedades · Mover arriba · Mover abajo ·
* - CLICK (press + release, no movement) → fires the Button's `onPress`, * Insertar bloque · Eliminar bloque). A drag fires no trailing click, so a
* which opens / toggles the properties panel. Per the HTML spec a drag * reorder never opens it.
* fires NO trailing click, so a reorder never opens the panel. * - DRAG → reorders (HTML5 drag; the canvas `<WordsBlockDrag>` is the sink).
* - DRAG (press + move past the browser threshold) → reorders the block.
* `ondragstart` writes the block's top-level index on the canonical Words
* MIME (identical to Words' `onGripDragStart`); the canvas-level
* `<WordsBlockDrag>` controller (mounted by the parent) reads it on
* dragover/drop, paints the seam indicator and runs `moveBlockTo`.
* *
* Why nothing re-renders the Button mid-drag: Chrome CANCELS an HTML5 drag * TWO open/close loops had to be killed, both seen as the menu flickering:
* when the source element changes between mousedown and mousemove. The grip * 1. EDITOR ↔ menu — opening the menu steals focus from the contenteditable;
* carries no press-timer icon swap and no `data-dragging` toggle — exactly * without a marker the editor reads the blur as external, re-grabs focus,
* like Words' grip. The only state it reads (`grip`) is stable during a drag * and the menu closes → reopens. Fix: the trigger and the portaled menu
* (pointermove doesn't fire while dragging; the anchor effect's deps don't * BOTH carry `data-words-external-tool`, so the provider's
* change), so the Button element stays identical across the gesture. * `isInsideWordsTool` treats focus landing here as internal.
* 2. GRIP ↔ menu — the menu's `disabled` flags must NOT re-derive from
* `api.html`. The blur in (1) makes the provider re-render → `api.html`
* changes → a reactive `disabled` re-creates the focused item → focus
* drops to null → the DropdownMenu reads "focus left" → closes → reopens
* forever. Fix: index/count are frozen onto the grip at placement, and the
* grip is frozen while the menu is open. Mirrors Words' stable-`index`.
* *
* Anchoring: while the panel is OPEN, the grip is anchored to the active * Grip placement: anchors to the active block; follows the hovered block (and
* block — pointermove on other blocks does NOT pull the grip away. With the * the cursor along the rail, via `blockAtY`) when the panel/menu are closed.
* panel CLOSED, the grip follows the hovered block on desktop.
*
* DOM doctrine: cross-cutting DOM activity goes through `dom` (`dom.listen`
* for the rail's pointer events). LOCAL reads (`getBoundingClientRect`,
* `closest`, `contains`, iterating our own `content.children`) stay inline
* per `arts/adom/README.md`.
*/ */
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import type { ActiveDom } from '$adom'; import type { ActiveDom } from '$adom';
import type { ProviderSnippetProps } from '$soma/components/words'; import type { ProviderSnippetProps } from '$soma/components/words';
import { Box } from '$uix/eidos/components/box'; import { Box } from '$uix/eidos/components/box';
import { Button } from '$uix/eidos/components/button'; import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { WORDS_BLOCK_DRAG_TYPE } from '$uix/eidos/components/words/words-block-drag-type'; import { WORDS_BLOCK_DRAG_TYPE } from '$uix/eidos/components/words/words-block-drag-type';
import {
deleteBlockAndReanchor,
insertParagraphBelow,
moveBlock
} from './palabras-block-actions';
let { let {
api, api,
@ -60,7 +57,15 @@
onClose: () => void; onClose: () => void;
} = $props(); } = $props();
let grip = $state<{ top: number; id: string } | null>(null); // The grip carries its block's `index` + `count` CAPTURED at placement time
// (not reactively re-read from `api.html`). This is the churn fix: if the
// menu's `disabled` flags read `api.html`, opening the menu (focus leaves the
// contenteditable → provider re-renders → `api.html` changes) re-creates the
// focused menu item → focus drops to null → the DropdownMenu reads "focus
// left" → closes → reopens forever. Words avoids this with a stable `index`
// $state; we mirror it by freezing index/count onto the (frozen) grip.
let grip = $state<{ top: number; id: string; index: number; count: number } | null>(null);
let menuOpen = $state(false);
// LOCAL reads on elements we own — left inline per ADom doctrine. // LOCAL reads on elements we own — left inline per ADom doctrine.
function idPathOf(target: HTMLElement, host: HTMLElement): string { function idPathOf(target: HTMLElement, host: HTMLElement): string {
@ -76,7 +81,15 @@
function gripFor(block: HTMLElement, host: HTMLElement) { function gripFor(block: HTMLElement, host: HTMLElement) {
const hr = host.getBoundingClientRect(); const hr = host.getBoundingClientRect();
const br = block.getBoundingClientRect(); const br = block.getBoundingClientRect();
return { top: br.top - hr.top + br.height / 2, id: idPathOf(block, host) }; const id = idPathOf(block, host);
// Capture index + count NOW (DOM read at placement) so the menu's disabled
// flags never re-derive from `api.html` while the menu is open.
return {
top: br.top - hr.top + br.height / 2,
id,
index: indexOfId(id),
count: topLevelBlocks().length
};
} }
function host(): HTMLElement | null { function host(): HTMLElement | null {
return (content?.closest('[data-palabras-doc]') as HTMLElement | null) ?? null; return (content?.closest('[data-palabras-doc]') as HTMLElement | null) ?? null;
@ -90,12 +103,8 @@
const top = id.split('/')[0]; const top = id.split('/')[0];
return topLevelBlocks().findIndex((el) => el.getAttribute('data-words-id') === top); return topLevelBlocks().findIndex((el) => el.getAttribute('data-words-id') === top);
} }
// Resolve the active TOP-LEVEL block's HTMLElement by its id, by iterating // Resolve the active TOP-LEVEL block's HTMLElement by its id, iterating
// `content.children` (Words.Content renders top-level blocks 1:1 as direct // `content.children` (top-level blocks render 1:1 as direct children).
// children, in document order). LOCAL read on an element we own; no
// `querySelector` + string interpolation, no dependency on
// `[data-words-active]` (stamped on a separate render pass, can drift behind
// `activeBlockId`).
function findActiveTopLevelEl(activeId: string): HTMLElement | null { function findActiveTopLevelEl(activeId: string): HTMLElement | null {
const topId = activeId.split('/')[0]; const topId = activeId.split('/')[0];
for (const child of content.children) { for (const child of content.children) {
@ -105,10 +114,9 @@
} }
return null; return null;
} }
// Top-level block whose vertical band contains `clientY` (Voronoi-on-Y: the // Top-level block whose vertical band contains `clientY` (Voronoi-on-Y). Lets
// boundary between two blocks is the midpoint of the inter-block gap). Lets the // the grip follow the cursor while it's over the RAIL (gutter), where
// grip follow the cursor while it's over the RAIL (gutter), where `closest()` // `closest()` finds no block.
// finds no block.
function blockAtY(clientY: number): HTMLElement | null { function blockAtY(clientY: number): HTMLElement | null {
const blocks = topLevelBlocks(); const blocks = topLevelBlocks();
if (blocks.length === 0) return null; if (blocks.length === 0) return null;
@ -122,46 +130,44 @@
return null; return null;
} }
// Anchor to the active block. Reads `panelOpen` so the effect re-runs when // Index + count of the grip's block — read from the FROZEN grip, NOT from
// the panel opens (the click sets active THEN opens the panel; the anchor // `api.html`. The grip is re-anchored (with fresh index/count) whenever it
// must re-fire on panelOpen so the grip lands on the new active block). // moves, but stays put while the menu is open, so the menu's disabled flags
// (first can't move up, last can't move down) are stable for the menu's whole
// lifetime. Deriving these from `api.html` is what looped the menu open/close.
const blockIndex = $derived(grip?.index ?? -1);
const blockCount = $derived(grip?.count ?? 0);
// Anchor to the active block. FROZEN while the menu is open: re-anchoring
// would replace the `grip` object (and its captured index/count) mid-menu,
// re-rendering the trigger/items and dropping focus — the open/close loop.
// Words freezes its gutter the same way (every writer guards on `menuOpen`).
$effect(() => { $effect(() => {
const h = host(); const h = host();
const activeId = api.activeBlockId; const activeId = api.activeBlockId;
void api.html; void api.html;
const po = panelOpen; const po = panelOpen;
if (!h) return; const mo = menuOpen;
if (!h || mo) return;
const activeEl = activeId ? findActiveTopLevelEl(activeId) : null; const activeEl = activeId ? findActiveTopLevelEl(activeId) : null;
untrack(() => { untrack(() => {
if (!activeEl) { if (!activeEl) {
// No valid active block (nothing matched activeBlockId). If the grip
// points at a block that no longer exists, clear it so it doesn't hang
// over the gap; otherwise leave it (it's tracking a hovered block).
if (grip && !findActiveTopLevelEl(grip.id)) grip = null; if (grip && !findActiveTopLevelEl(grip.id)) grip = null;
return; return;
} }
// Re-anchor when: there's no grip yet, the grip already tracks the active
// block, the panel just opened (po) — OR the grip's block was removed from
// the DOM (orphaned, e.g. after a delete). The last case is what keeps the
// grip from sitting on a ghost once the panel is closed on delete.
const orphaned = !!grip && !findActiveTopLevelEl(grip.id); const orphaned = !!grip && !findActiveTopLevelEl(grip.id);
if (!grip || grip.id === activeId || po || orphaned) grip = gripFor(activeEl, h); if (!grip || grip.id === activeId || po || orphaned) grip = gripFor(activeEl, h);
}); });
}); });
// Follow the hovered block — but ONLY when the panel is closed. Listener goes // Follow the hovered block / cursor along the rail — ONLY when neither the
// through `dom.listen` (cross-cutting on the rail host, not the grip). During // panel nor the menu is open (so the grip stays put while a popup is up).
// a native drag, pointermove doesn't fire, so no drag guard is needed.
$effect(() => { $effect(() => {
const h = host(); const h = host();
if (!h) return; if (!h) return;
const onMove = (e: Event) => { const onMove = (e: Event) => {
if (panelOpen) return; if (panelOpen || menuOpen) return;
const pe = e as PointerEvent; const pe = e as PointerEvent;
// Over a block directly, OR over the rail/gutter → resolve the block by
// the cursor's Y so the grip follows the cursor along the gutter exactly
// like along the blocks (the rail isn't inside a block, so closest()
// returns null there and the grip used to freeze).
let block = (pe.target as HTMLElement | null)?.closest( let block = (pe.target as HTMLElement | null)?.closest(
'[data-words-id]' '[data-words-id]'
) as HTMLElement | null; ) as HTMLElement | null;
@ -170,7 +176,7 @@
grip = gripFor(block, h); grip = gripFor(block, h);
}; };
const onLeave = () => { const onLeave = () => {
if (panelOpen) return; if (panelOpen || menuOpen) return;
const activeId = api.activeBlockId; const activeId = api.activeBlockId;
const activeEl = activeId ? findActiveTopLevelEl(activeId) : null; const activeEl = activeId ? findActiveTopLevelEl(activeId) : null;
grip = activeEl ? gripFor(activeEl, h) : null; grip = activeEl ? gripFor(activeEl, h) : null;
@ -183,12 +189,22 @@
}; };
}); });
// HTML5 drag — byte-for-byte Words' `onGripDragStart`: set ONLY the canonical // Re-pressing the grip WHILE THE MENU IS OPEN must close it cleanly. The
// MIME + effectAllowed. NO `preventDefault` (that cancels the drag) and NO // menu's Dismissal layer listens for document pointerdowns and treats any
// reactive state writes (re-rendering the dragged element mid-drag is what // press OUTSIDE the menu content as an outside-dismiss — and the grip
// makes Chrome abort with the 🚫 cursor). The canvas <WordsBlockDrag> sink // (the trigger) is NOT inside `[data-palabras-handle-menu]`, so it counts as
// reads the MIME on dragover/drop and reorders; its own document-level // "outside". That closes the menu on pointerdown; the trailing click then
// `dragend` clears the indicator. // re-opens it via the trigger → close→reopen = the visible flicker.
// Stopping propagation only while open keeps the pointerdown from reaching
// the Dismissal, so the trigger's own click is the single source of the
// toggle (clean close). Closed-state pointerdowns still propagate so opening
// and HTML5 drag-reorder are untouched.
function onTriggerPointerDown(e: PointerEvent) {
if (menuOpen) e.stopPropagation();
}
// HTML5 drag — set ONLY the canonical MIME + effectAllowed (like Words'
// `onGripDragStart`). NO preventDefault, NO reactive state writes mid-drag.
function onDragStart(e: DragEvent) { function onDragStart(e: DragEvent) {
if (!grip || !e.dataTransfer) return; if (!grip || !e.dataTransfer) return;
const fromIndex = indexOfId(grip.id); const fromIndex = indexOfId(grip.id);
@ -197,53 +213,95 @@
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
} }
// Wired to the Button's `onPress` (fires on click AND keyboard Enter/Space via // ── Menu actions ─────────────────────────────────────────────────────────
// native button activation — so no bespoke keydown handler is needed). A drag function openProperties() {
// fires no trailing click, so this never runs after a reorder.
function onClick() {
if (!grip) return; if (!grip) return;
// Toggle: when the panel is already open on the grip's block, close it.
if (panelOpen && grip.id === api.activeBlockId) {
onClose();
return;
}
api.setActiveBlock(grip.id); api.setActiveBlock(grip.id);
onOpen(grip.id); onOpen(grip.id);
} }
function doMoveUp() {
if (!grip || blockIndex <= 0) return;
api.setActiveBlock(grip.id);
moveBlock(api, blockIndex, 'up');
}
function doMoveDown() {
if (!grip || blockIndex < 0 || blockIndex >= blockCount - 1) return;
api.setActiveBlock(grip.id);
moveBlock(api, blockIndex, 'down');
}
function doInsert() {
// Provisional: inserts an empty paragraph below. The block-type grid (#3)
// replaces this later (the shared popup also opened by the `+` button).
if (blockIndex >= 0) insertParagraphBelow(api, blockIndex);
}
function doDelete() {
if (blockIndex < 0) return;
void deleteBlockAndReanchor(api, content, blockIndex);
onClose();
}
</script> </script>
{#if grip} {#if grip}
<!-- Positioned slot: owns the rail placement + vertical centring. --> <!-- Positioned slot: owns the rail placement + vertical centring. -->
<Box data-palabras-handle-slot style="top: {grip.top}px"> <Box data-palabras-handle-slot style="top: {grip.top}px">
<!-- LITERAL COPY of Words' gutter grip (words-block-gutter.svelte): the <DropdownMenu bind:open={menuOpen}>
framework <Button> (variant="ghost" size="xs" iconOnly) — the SAME <!-- Trigger = the framework <Button> (ghost/xs/iconOnly), the drag source
element Words' DropdownMenu.Trigger renders — made a reliable HTML5 (draggable + ondragstart). `data-words-external-tool` marks it as part
drag source with `draggable` + `ondragstart`. Words' block-reorder of the editor's focus scope so opening the menu doesn't loop. -->
drag works through exactly this Button. The 6-dot icon + props are <DropdownMenu.Trigger
identical; the only difference is click → properties panel (onPress) variant="ghost"
instead of a dropdown menu. The visually-hidden body label is the size="xs"
accessible name (iconOnly keeps it in the a11y tree). --> iconOnly
<Button rounded="md"
variant="ghost" data-palabras-handle
size="xs" data-words-external-tool
iconOnly aria-label="Acciones del bloque"
rounded="md" draggable={true}
data-palabras-handle onpointerdown={onTriggerPointerDown}
draggable={true} ondragstart={onDragStart}
ondragstart={onDragStart} >
onPress={onClick} {#snippet icon()}
> <svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
{#snippet icon()} <circle cx="5.5" cy="3.5" r="1.35" />
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> <circle cx="10.5" cy="3.5" r="1.35" />
<circle cx="5.5" cy="3.5" r="1.35" /> <circle cx="5.5" cy="8" r="1.35" />
<circle cx="10.5" cy="3.5" r="1.35" /> <circle cx="10.5" cy="8" r="1.35" />
<circle cx="5.5" cy="8" r="1.35" /> <circle cx="5.5" cy="12.5" r="1.35" />
<circle cx="10.5" cy="8" r="1.35" /> <circle cx="10.5" cy="12.5" r="1.35" />
<circle cx="5.5" cy="12.5" r="1.35" /> </svg>
<circle cx="10.5" cy="12.5" r="1.35" /> {/snippet}
</svg> Acciones del bloque
{/snippet} </DropdownMenu.Trigger>
Propiedades del bloque <!-- `preventScroll={false}`: a block-action menu must NOT lock page
</Button> scroll (that's for modal dialogs). The default `preventScroll`
engages a ScrollLock that sets `body { overflow: hidden }` +
scrollbar-gutter padding on open → the scrollbar VANISHES on open
and returns on close. On a scrolling page that toggle is the
visible "flicker"/jump the user reported. Keep the page scrollable.
`data-words-external-tool`: focus-scope marker on the portaled menu
so the editor treats focus landing here as internal. -->
<DropdownMenu.Content
side="bottom"
align="start"
preventScroll={false}
data-palabras-handle-menu
data-words-external-tool
>
<DropdownMenu.Item onSelect={openProperties}>Propiedades</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={doMoveUp} disabled={blockIndex <= 0}>
Mover arriba
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={doMoveDown}
disabled={blockIndex < 0 || blockIndex >= blockCount - 1}
>
Mover abajo
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={doInsert}>Insertar bloque</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={doDelete}>Eliminar bloque</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</Box> </Box>
{/if} {/if}

@ -12,13 +12,20 @@
import { Trash2, ChevronUp, ChevronDown } from '$uix/eidos/components/icon'; import { Trash2, ChevronUp, ChevronDown } from '$uix/eidos/components/icon';
import { getPanelSchema } from './panel-schema'; import { getPanelSchema } from './panel-schema';
import { createDrag } from './palabras-drag.svelte'; import { createDrag } from './palabras-drag.svelte';
import { deleteBlockAndReanchor } from './palabras-block-actions';
import type { PalabrasFieldDef } from './types'; import type { PalabrasFieldDef } from './types';
let { let {
api, api,
content,
onEditAppearance, onEditAppearance,
onClose onClose
}: { api: ProviderSnippetProps; onEditAppearance: () => void; onClose: () => void } = $props(); }: {
api: ProviderSnippetProps;
content: HTMLElement | null;
onEditAppearance: () => void;
onClose: () => void;
} = $props();
const loc = $derived(api.activeBlockLocation); const loc = $derived(api.activeBlockLocation);
const schema = $derived(getPanelSchema(loc?.node.type)); const schema = $derived(getPanelSchema(loc?.node.type));
@ -54,18 +61,8 @@
} }
function deleteActive() { function deleteActive() {
const path = loc?.path; const path = loc?.path;
if (!path || path.length !== 1) return; if (!path || path.length !== 1 || !content) return;
const index = path[0]; void deleteBlockAndReanchor(api, content, 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);
// Close the panel on delete — the block it was inspecting is gone. // Close the panel on delete — the block it was inspecting is gone.
onClose(); onClose();
} }

@ -69,6 +69,19 @@
z-index: 5; z-index: 5;
transition: top var(--duration-fast, 0.12s) var(--ease-default, ease); 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 <Button> (variant="ghost" size="xs" iconOnly), /* Block grip — the framework <Button> (variant="ghost" size="xs" iconOnly),
copied from Words' gutter grip. The Button recipe owns ALL chrome (padding, copied from Words' gutter grip. The Button recipe owns ALL chrome (padding,
radius, colour, hover, focus ring, press transform) — we add ONLY the drag radius, colour, hover, focus ring, press transform) — we add ONLY the drag

@ -115,6 +115,7 @@
{#if panelOpen} {#if panelOpen}
<PalabrasPanel <PalabrasPanel
{api} {api}
content={contentEl}
onEditAppearance={() => (appearanceOpen = true)} onEditAppearance={() => (appearanceOpen = true)}
onClose={() => { onClose={() => {
panelOpen = false; panelOpen = false;

@ -1529,8 +1529,11 @@ export class WordsProvider {
// be listed here — otherwise the editor sees the click as an // be listed here — otherwise the editor sees the click as an
// external blur and emits commit-save-content + contact-focus // external blur and emits commit-save-content + contact-focus
// on the return (two perceptual cues per tool interaction). // on the return (two perceptual cues per tool interaction).
// Order is alphabetical for grep-ability; the set is the // Order is alphabetical for grep-ability; the Words companion /
// closed surface of Words companion / overlay parts. // overlay parts plus a generic hook (`data-words-external-tool`)
// that downstream editors built ON the engine (e.g. `palabras`)
// stamp on THEIR focus-stealing overlays (a portaled menu / grid)
// so opening one isn't read as an external blur and looped.
return !!element.closest( return !!element.closest(
[ [
'[data-words-block-gutter]', '[data-words-block-gutter]',
@ -1547,6 +1550,7 @@ export class WordsProvider {
'[data-words-column-inserter-menu]', '[data-words-column-inserter-menu]',
'[data-words-column-inserter-trigger]', '[data-words-column-inserter-trigger]',
'[data-words-drawer]', '[data-words-drawer]',
'[data-words-external-tool]',
'[data-words-find-replace]', '[data-words-find-replace]',
'[data-words-heading-picker]', '[data-words-heading-picker]',
'[data-words-image-float-bar]', '[data-words-image-float-bar]',

Loading…
Cancel
Save

Powered by TurnKey Linux.