@ -105,6 +105,22 @@
}
}
return null;
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
// 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
// the panel opens (the click sets active THEN opens the panel; the anchor
@ -141,10 +157,16 @@
if (!h) return;
if (!h) return;
const onMove = (e: Event) => {
const onMove = (e: Event) => {
if (panelOpen) return;
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]'
'[data-words-id]'
) as HTMLElement | null;
) 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);
grip = gripFor(block, h);
};
};
const onLeave = () => {
const onLeave = () => {