fix(words): eliminate block-handle flicker in the gutter + bump hit area

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 `<Words.Drawer side="left">`.

- **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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 4cd54e2111
commit 2bf6aef035

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

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

Loading…
Cancel
Save

Powered by TurnKey Linux.