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)
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-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-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-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. |
@ -57,7 +142,12 @@
| `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`. |
## 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),
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">
/**
* 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
* (`words-block-gutter.svelte`): the framework `<Button>`
* (`variant="ghost" size="xs" iconOnly`), the SAME element Words'
* `DropdownMenu.Trigger` renders, made a drag source with `draggable` +
* `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.
* Structure mirrors Words' gutter grip (`words-block-gutter.svelte`): a
* `DropdownMenu` whose Trigger is the framework `<Button>` (ghost/xs/iconOnly)
* made a drag source with `draggable` + `ondragstart`. Words' block-reorder
* drag works through exactly this trigger, so ours does too.
*
* Two behaviours from the same Button, distinguished by the BROWSER's native
* click-vs-drag detection (no custom press timer):
* - CLICK (press + release, no movement) → fires the Button's `onPress`,
* which opens / toggles the properties panel. Per the HTML spec a drag
* fires NO trailing click, so a reorder never opens the panel.
* - 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`.
* Two behaviours from the same trigger, by the browser's click-vs-drag:
* - CLICK → opens the ACTION MENU (Propiedades · Mover arriba · Mover abajo ·
* Insertar bloque · Eliminar bloque). A drag fires no trailing click, so a
* reorder never opens it.
* - DRAG → reorders (HTML5 drag; the canvas `<WordsBlockDrag>` is the sink).
*
* Why nothing re-renders the Button mid-drag: Chrome CANCELS an HTML5 drag
* when the source element changes between mousedown and mousemove. The grip
* carries no press-timer icon swap and no `data-dragging` toggle — exactly
* like Words' grip. The only state it reads (`grip`) is stable during a drag
* (pointermove doesn't fire while dragging; the anchor effect's deps don't
* change), so the Button element stays identical across the gesture.
* TWO open/close loops had to be killed, both seen as the menu flickering:
* 1. EDITOR ↔ menu — opening the menu steals focus from the contenteditable;
* without a marker the editor reads the blur as external, re-grabs focus,
* and the menu closes → reopens. Fix: the trigger and the portaled menu
* BOTH carry `data-words-external-tool`, so the provider's
* `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
* block — pointermove on other blocks does NOT pull the grip away. With the
* 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`.
* Grip placement: anchors to the active block; follows the hovered block (and
* the cursor along the rail, via `blockAtY`) when the panel/menu are closed.
*/
import { untrack } from 'svelte';
import type { ActiveDom } from '$adom';
import type { ProviderSnippetProps } from '$soma/components/words';
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 {
deleteBlockAndReanchor,
insertParagraphBelow,
moveBlock
} from './palabras-block-actions';
let {
api,
@ -60,7 +57,15 @@
onClose: () => void;
} = $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.
function idPathOf(target: HTMLElement, host: HTMLElement): string {
@ -76,7 +81,15 @@
function gripFor(block: HTMLElement, host: HTMLElement) {
const hr = host.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 {
return (content?.closest('[data-palabras-doc]') as HTMLElement | null) ?? null;
@ -90,12 +103,8 @@
const top = id.split('/')[0];
return topLevelBlocks().findIndex((el) => el.getAttribute('data-words-id') === top);
}
// Resolve the active TOP-LEVEL block's HTMLElement by its id, by iterating
// `content.children` (Words.Content renders top-level blocks 1:1 as direct
// 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`).
// Resolve the active TOP-LEVEL block's HTMLElement by its id, iterating
// `content.children` (top-level blocks render 1:1 as direct children).
function findActiveTopLevelEl(activeId: string): HTMLElement | null {
const topId = activeId.split('/')[0];
for (const child of content.children) {
@ -105,10 +114,9 @@
}
return null;
}
// Top-level block whose vertical band contains `clientY` (Voronoi-on-Y: the
// boundary between two blocks is the midpoint of the inter-block gap). Lets the
// grip follow the cursor while it's over the RAIL (gutter), where `closest()`
// finds no block.
// Top-level block whose vertical band contains `clientY` (Voronoi-on-Y). Lets
// the grip follow the cursor while it's over the RAIL (gutter), where
// `closest()` finds no block.
function blockAtY(clientY: number): HTMLElement | null {
const blocks = topLevelBlocks();
if (blocks.length === 0) return null;
@ -122,46 +130,44 @@
return null;
}
// Anchor to the active block. Reads `panelOpen` so the effect re-runs when
// the panel opens (the click sets active THEN opens the panel; the anchor
// must re-fire on panelOpen so the grip lands on the new active block).
// Index + count of the grip's block — read from the FROZEN grip, NOT from
// `api.html`. The grip is re-anchored (with fresh index/count) whenever it
// 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(() => {
const h = host();
const activeId = api.activeBlockId;
void api.html;
const po = panelOpen;
if (!h) return;
const mo = menuOpen;
if (!h || mo) return;
const activeEl = activeId ? findActiveTopLevelEl(activeId) : null;
untrack(() => {
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;
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);
if (!grip || grip.id === activeId || po || orphaned) grip = gripFor(activeEl, h);
});
});
// Follow the hovered block — but ONLY when the panel is closed. Listener goes
// through `dom.listen` (cross-cutting on the rail host, not the grip). During
// a native drag, pointermove doesn't fire, so no drag guard is needed.
// Follow the hovered block / cursor along the rail — ONLY when neither the
// panel nor the menu is open (so the grip stays put while a popup is up).
$effect(() => {
const h = host();
if (!h) return;
const onMove = (e: Event) => {
if (panelOpen) return;
if (panelOpen || menuOpen) return;
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(
'[data-words-id]'
) as HTMLElement | null;
@ -170,7 +176,7 @@
grip = gripFor(block, h);
};
const onLeave = () => {
if (panelOpen) return;
if (panelOpen || menuOpen) return;
const activeId = api.activeBlockId;
const activeEl = activeId ? findActiveTopLevelEl(activeId) : null;
grip = activeEl ? gripFor(activeEl, h) : null;
@ -183,12 +189,22 @@
};
});
// HTML5 drag — byte-for-byte Words' `onGripDragStart`: set ONLY the canonical
// MIME + effectAllowed. NO `preventDefault` (that cancels the drag) and NO
// reactive state writes (re-rendering the dragged element mid-drag is what
// makes Chrome abort with the 🚫 cursor). The canvas <WordsBlockDrag> sink
// reads the MIME on dragover/drop and reorders; its own document-level
// `dragend` clears the indicator.
// Re-pressing the grip WHILE THE MENU IS OPEN must close it cleanly. The
// menu's Dismissal layer listens for document pointerdowns and treats any
// press OUTSIDE the menu content as an outside-dismiss — and the grip
// (the trigger) is NOT inside `[data-palabras-handle-menu]`, so it counts as
// "outside". That closes the menu on pointerdown; the trailing click then
// 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) {
if (!grip || !e.dataTransfer) return;
const fromIndex = indexOfId(grip.id);
@ -197,53 +213,95 @@
e.dataTransfer.effectAllowed = 'move';
}
// Wired to the Button's `onPress` (fires on click AND keyboard Enter/Space via
// native button activation — so no bespoke keydown handler is needed). A drag
// fires no trailing click, so this never runs after a reorder.
function onClick() {
// ── Menu actions ─────────────────────────────────────────────────────────
function openProperties() {
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);
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>
{#if grip}
<!-- Positioned slot: owns the rail placement + vertical centring. -->
<Box data-palabras-handle-slot style="top: {grip.top}px">
<!-- LITERAL COPY of Words' gutter grip (words-block-gutter.svelte): the
framework <Button> (variant="ghost" size="xs" iconOnly) — the SAME
element Words' DropdownMenu.Trigger renders — made a reliable HTML5
drag source with `draggable` + `ondragstart`. Words' block-reorder
drag works through exactly this Button. The 6-dot icon + props are
identical; the only difference is click → properties panel (onPress)
instead of a dropdown menu. The visually-hidden body label is the
accessible name (iconOnly keeps it in the a11y tree). -->
<Button
variant="ghost"
size="xs"
iconOnly
rounded="md"
data-palabras-handle
draggable={true}
ondragstart={onDragStart}
onPress={onClick}
>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<circle cx="5.5" cy="3.5" r="1.35" />
<circle cx="10.5" cy="3.5" r="1.35" />
<circle cx="5.5" cy="8" r="1.35" />
<circle cx="10.5" cy="8" r="1.35" />
<circle cx="5.5" cy="12.5" r="1.35" />
<circle cx="10.5" cy="12.5" r="1.35" />
</svg>
{/snippet}
Propiedades del bloque
</Button>
<DropdownMenu bind:open={menuOpen}>
<!-- Trigger = the framework <Button> (ghost/xs/iconOnly), the drag source
(draggable + ondragstart). `data-words-external-tool` marks it as part
of the editor's focus scope so opening the menu doesn't loop. -->
<DropdownMenu.Trigger
variant="ghost"
size="xs"
iconOnly
rounded="md"
data-palabras-handle
data-words-external-tool
aria-label="Acciones del bloque"
draggable={true}
onpointerdown={onTriggerPointerDown}
ondragstart={onDragStart}
>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<circle cx="5.5" cy="3.5" r="1.35" />
<circle cx="10.5" cy="3.5" r="1.35" />
<circle cx="5.5" cy="8" r="1.35" />
<circle cx="10.5" cy="8" r="1.35" />
<circle cx="5.5" cy="12.5" r="1.35" />
<circle cx="10.5" cy="12.5" r="1.35" />
</svg>
{/snippet}
Acciones del bloque
</DropdownMenu.Trigger>
<!-- `preventScroll={false}`: a block-action menu must NOT lock page
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>
{/if}

@ -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();
}

@ -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 <Button> (variant="ghost" size="xs" iconOnly),
copied from Words' gutter grip. The Button recipe owns ALL chrome (padding,
radius, colour, hover, focus ring, press transform) — we add ONLY the drag

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

@ -1529,8 +1529,11 @@ export class WordsProvider {
// be listed here — otherwise the editor sees the click as an
// external blur and emits commit-save-content + contact-focus
// on the return (two perceptual cues per tool interaction).
// Order is alphabetical for grep-ability; the set is the
// closed surface of Words companion / overlay parts.
// Order is alphabetical for grep-ability; the Words companion /
// 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(
[
'[data-words-block-gutter]',
@ -1547,6 +1550,7 @@ export class WordsProvider {
'[data-words-column-inserter-menu]',
'[data-words-column-inserter-trigger]',
'[data-words-drawer]',
'[data-words-external-tool]',
'[data-words-find-replace]',
'[data-words-heading-picker]',
'[data-words-image-float-bar]',

Loading…
Cancel
Save

Powered by TurnKey Linux.