From 2bf6aef035a4f0a1b473e74a58c1b15bca02fb42 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 28 May 2026 00:08:21 +0200 Subject: [PATCH] fix(words): eliminate block-handle flicker in the gutter + bump hit area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues with the block-handle grip the user just hit: 1. Same flicker pattern the inserter had before its fix: the handle's listener was on `[data-words-content]`, and the grip sits OUTSIDE that box in the gutter. The moment the cursor moved toward the grip, `mouseleave` fired on content → grip vanished → cursor back inside content → grip reappeared → loop. 2. The 24×24 hit area sitting in a 28-px-wide gutter was hard to land, especially when the gutter also got the flicker treatment. Fixes: - **Document-level mousemove** (was: content-level). Bounds are checked manually against the content rect with an EXPANDED buffer on the side where the grip lives (56 px) and a tight 8 px on the opposite. Drawer side is read from the DOM so the bounds flip automatically with ``. - **Early-return when the cursor is over the handle or its menu** (`target.closest('[data-words-block-handle]' | '...-menu')`) so hovering the grip itself never recomputes/hides the state. - **Geometric fallback** when the cursor is in the gutter and `findBlockElement(e.target)` would return null: walk the top-level blocks and pick the one whose vertical band contains `e.clientY`. Keeps the grip pinned to the right block while the user is moving toward it. - **Mouseleave listener removed entirely** — the bounds check above handles the "cursor truly left the editor area" case without the flicker-prone per-element leave events. - **Hit area bumped to 28×28** (`1.75rem`) so it's comfortably above WCAG 2.5.5's 24×24 minimum and easier to grab in the narrow gutter. Visible icon stays at `size="sm"`. Centering math updated (-14 instead of -12); horizontal offset bumped from `-28` to `-32` to keep a small gap between grip and text descenders. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../words/words-block-handle.svelte | 86 ++++++++++++++++--- src/uix/eidos/components/words/words.css | 7 +- 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/uix/eidos/components/words/words-block-handle.svelte b/src/uix/eidos/components/words/words-block-handle.svelte index 0e7c820c7..a7e5dd8be 100644 --- a/src/uix/eidos/components/words/words-block-handle.svelte +++ b/src/uix/eidos/components/words/words-block-handle.svelte @@ -56,11 +56,13 @@ } const rect = el.getBoundingClientRect(); const side = detectDrawerSide(); + // Grip is 28×28 (`1.75rem`) so the centering offset is 14. position = { - top: rect.top + rect.height / 2 - 12, - // Drawer on RIGHT → grip on block LEFT (left edge − 28). + top: rect.top + rect.height / 2 - 14, + // Drawer on RIGHT → grip on block LEFT (left edge − 32, small + // gap so it doesn't visually overlap text descenders). // Drawer on LEFT → grip on block RIGHT (right edge + 4). - left: side === 'left' ? rect.right + 4 : rect.left - 28 + left: side === 'left' ? rect.right + 4 : rect.left - 32 }; } @@ -90,24 +92,83 @@ return node; } + /** + * Find the block whose vertical range covers the cursor's Y. Used + * when the cursor is in the gutter (where there's no block element + * to closest()-walk from). The block-handle sits OUTSIDE the + * content box, so once the user moves toward it the event target + * stops being a block — we need to anchor by geometry instead. + */ + function blockUnderCursorY(content: HTMLElement, clientY: number): HTMLElement | null { + const blocks = Array.from( + content.querySelectorAll( + '[data-words-node="block"][data-words-path]' + ) + ).filter((b) => !(b.getAttribute('data-words-path') ?? '').includes('.')); + for (const block of blocks) { + const r = block.getBoundingClientRect(); + if (clientY >= r.top - 4 && clientY <= r.bottom + 4) return block; + } + return null; + } + $effect(() => { const content = document.querySelector('[data-words-content]'); if (!content) return; + // Listen at the document level (not on `content`) so the cursor + // crossing into the grip — which lives OUTSIDE the content box, + // in the gutter — doesn't fire `mouseleave` on content and + // flicker us into a hide/show loop. Bounds are checked manually + // against the content rect, expanded toward whichever side the + // grip occupies. function onmove(e: MouseEvent) { if (menuOpen) return; - const block = findBlockElement(e.target); + const target = e.target; + // While the cursor is on the handle itself (or its menu), + // hold the current state — don't recompute or hide. + if ( + target instanceof Element && + (target.closest('[data-words-block-handle]') || + target.closest('[data-words-block-handle-menu]')) + ) { + return; + } + const r = content.getBoundingClientRect(); + // Grip lives in the gutter opposite to the drawer. Expand the + // bounds on that side so the cursor stays "in zone" while + // moving toward the grip. + const side = document + .querySelector('[data-words-drawer]') + ?.getAttribute('data-side'); + const gripOnLeft = side !== 'left'; + const leftBuffer = gripOnLeft ? 56 : 8; + const rightBuffer = gripOnLeft ? 8 : 56; + if ( + e.clientX < r.left - leftBuffer || + e.clientX > r.right + rightBuffer || + e.clientY < r.top - 12 || + e.clientY > r.bottom + 12 + ) { + if (hoverBlockEl !== null) { + hoverBlockEl = null; + position = null; + } + return; + } + // Prefer the block under e.target (cursor over text); fall + // back to the block whose vertical band the cursor crosses + // (cursor over gutter). + let block: HTMLElement | null = null; + if (target instanceof Element) { + block = findBlockElement(target); + } + if (!block) block = blockUnderCursorY(content, e.clientY); if (block === hoverBlockEl) return; hoverBlockEl = block; measureFromEl(block); } - function onleave() { - if (menuOpen) return; - hoverBlockEl = null; - position = null; - } - content.addEventListener('mousemove', onmove); - content.addEventListener('mouseleave', onleave); + document.addEventListener('mousemove', onmove); const win = content.ownerDocument.defaultView; const onscroll = () => measureFromEl(hoverBlockEl); @@ -115,8 +176,7 @@ win?.addEventListener('resize', onscroll, { passive: true }); return () => { - content.removeEventListener('mousemove', onmove); - content.removeEventListener('mouseleave', onleave); + document.removeEventListener('mousemove', onmove); win?.removeEventListener('scroll', onscroll); win?.removeEventListener('resize', onscroll); }; diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 8df0a50da..fd7adfc36 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -753,8 +753,11 @@ display: inline-flex; align-items: center; justify-content: center; - inline-size: 1.5rem; - block-size: 1.5rem; + /* Hit area: 28×28 — comfortably above the WCAG 2.5.5 target-size + minimum (24×24) and easier to land than the previous 24×24 in + the narrow gutter. The visible icon stays small via `size="sm"`. */ + inline-size: 1.75rem; + block-size: 1.75rem; padding: 0; border: 0; border-radius: var(--words-command-radius);