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);