Make Sasquatch bubble open reliably on first tap (mobile)

The "1st tap does nothing, 2nd/3rd works" bug came from relying solely on
pointer-capture tap detection, which is flaky on the first interaction after
mount in mobile WebViews — and the earlier e.preventDefault() on pointerup was
suppressing the click we could otherwise use.

Fix: add a plain onClick as a reliable open path (the most dependable "tapped"
signal in a WebView), extract an idempotent openPanel() used by both pointerup
and click, and drop the preventDefault so the click isn't swallowed. The
justOpenedRef lock still blocks the ghost-click that would re-close it. Double
firing is harmless since openPanel just sets visible=true.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-25 15:36:11 +07:00
parent ebbd0fd125
commit 834f33d53e

View file

@ -392,14 +392,15 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
setDragPos({ x: newX, y: newY }); setDragPos({ x: newX, y: newY });
}, [setDragPos]); }, [setDragPos]);
// Single source of truth for opening the panel. Sets the "just opened" lock
// so a stray follow-up event from the same tap can't immediately close it.
const openPanel = useCallback(() => {
justOpenedRef.current = Date.now();
setIsMinimized(false);
setIsVisible(true);
}, [setIsMinimized]);
const onPointerUp = useCallback((e: React.PointerEvent) => { const onPointerUp = useCallback((e: React.PointerEvent) => {
// Suppress the synthesized mouse "click" that follows a touch pointerup.
// Without this, on mobile WebViews the ghost click lands on whatever element
// is now under the finger (the panel unmounts the bubble the instant we
// open) and can immediately re-close the panel — the "opens then closes"
// bug.
e.preventDefault();
e.stopPropagation();
try { try {
if (dragRef.current) dragRef.current.releasePointerCapture(e.pointerId); if (dragRef.current) dragRef.current.releasePointerCapture(e.pointerId);
} catch { } catch {
@ -409,15 +410,24 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
dragStart.current = null; dragStart.current = null;
dragMoved.current = false; dragMoved.current = false;
setTimeout(() => setIsDragging(false), 50); setTimeout(() => setIsDragging(false), 50);
// If this pointer sequence was a genuine tap (no real drag), open here.
// A parallel onClick handler also opens as a reliable fallback for mobile
// WebViews where the pointer-capture tap can be flaky on first interaction;
// openPanel is idempotent so double-firing is harmless.
if (!wasDrag) { if (!wasDrag) {
// Always open the panel — even on pages without a context message. openPanel();
// Set a short "just opened" lock so a stray follow-up event from the same
// tap can't immediately minimize/close it.
justOpenedRef.current = Date.now();
setIsMinimized(false);
setIsVisible(true);
} }
}, [setIsMinimized]); }, [openPanel]);
// Reliable open path: plain click. On mobile WebViews the pointer-capture tap
// detection can miss the very first interaction after mount (the "1st tap does
// nothing, 2nd works" bug). A normal onClick is the most dependable "I tapped
// this" signal, so we use it as a fallback — but ignore it if the gesture was
// actually a drag.
const onBubbleClick = useCallback(() => {
if (dragMoved.current) return;
openPanel();
}, [openPanel]);
const bubbleStyle = useMemo(() => { const bubbleStyle = useMemo(() => {
if (dragPos) { if (dragPos) {
@ -682,6 +692,7 @@ Instructions:
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onClick={onBubbleClick}
title="Press and drag to move · Tap to open" title="Press and drag to move · Tap to open"
> >
<div <div