diff --git a/src/uix/eidos/components/palabras/palabras-handle.svelte b/src/uix/eidos/components/palabras/palabras-handle.svelte index b804cc3ca..2c3d4377a 100644 --- a/src/uix/eidos/components/palabras/palabras-handle.svelte +++ b/src/uix/eidos/components/palabras/palabras-handle.svelte @@ -105,6 +105,22 @@ } 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. + function blockAtY(clientY: number): HTMLElement | null { + const blocks = topLevelBlocks(); + if (blocks.length === 0) return null; + const rects = blocks.map((b) => b.getBoundingClientRect()); + for (let i = 0; i < blocks.length; i++) { + const r = rects[i]; + const from = rects[i - 1] ? (rects[i - 1].bottom + r.top) / 2 : -Infinity; + const to = rects[i + 1] ? (r.bottom + rects[i + 1].top) / 2 : Infinity; + if (clientY >= from && clientY < to) return blocks[i]; + } + 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 @@ -141,10 +157,16 @@ if (!h) return; const onMove = (e: Event) => { if (panelOpen) return; - const block = (e.target as HTMLElement | null)?.closest( + 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; - if (!block || !h.contains(block)) return; + if (!block || !h.contains(block)) block = blockAtY(pe.clientY); + if (!block) return; grip = gripFor(block, h); }; const onLeave = () => {