import { useEffect } from "react"; /** How close to the left edge a swipe must start to count as opening one. */ const EDGE_PX = 28; /** How far it must travel sideways before it is a swipe rather than a tap. */ const TRAVEL_PX = 60; /** And how straight: past this much vertical movement it is a scroll. */ const SLOPE = 1.2; interface EdgeSwipe { enabled: boolean; isOpen: boolean; onOpen: () => void; onClose: () => void; } /** * Dragging in from the left edge shows the sidebar; dragging it back hides it. * * The gesture every app on a phone has, and the reason it has to be a gesture * here too: the button that opens the sidebar sits in the top corner, which is * the one part of a tall screen a thumb cannot reach. * * Three rules keep it from firing by accident, and each is there for something * that went wrong without it: * * • it must **start at the edge**, or every sideways drag inside a note — a * text selection, a table being scrolled — would open the sidebar; * • it must travel far enough to be meant, or a tap that slides by two pixels * counts; * • it must be more sideways than downwards, or scrolling a long note with a * thumb near the edge opens it constantly. * * Listening on `document` rather than on the sidebar: the gesture starts where * the sidebar is *not*, which is the whole point of it. */ export function useEdgeSwipe({ enabled, isOpen, onOpen, onClose }: EdgeSwipe): void { useEffect(() => { if (!enabled) return undefined; let startX = 0; let startY = 0; let tracking = false; const onStart = (e: TouchEvent) => { if (e.touches.length !== 1) return; // a pinch is not a swipe const t = e.touches[0]; startX = t.clientX; startY = t.clientY; // Closed: only from the edge. Open: anywhere on the panel, because the // whole panel is the thing being pushed back. tracking = isOpen ? startX < window.innerWidth * 0.8 : startX <= EDGE_PX; }; const onMove = (e: TouchEvent) => { if (!tracking || e.touches.length !== 1) return; const t = e.touches[0]; const dx = t.clientX - startX; const dy = t.clientY - startY; if (Math.abs(dy) > Math.abs(dx) * SLOPE) { tracking = false; // a scroll, not a swipe return; } if (!isOpen && dx > TRAVEL_PX) { tracking = false; onOpen(); } else if (isOpen && dx < -TRAVEL_PX) { tracking = false; onClose(); } }; const stop = () => { tracking = false; }; // Passive: this never calls preventDefault — the page must keep scrolling // normally while the gesture is being judged. document.addEventListener("touchstart", onStart, { passive: true }); document.addEventListener("touchmove", onMove, { passive: true }); document.addEventListener("touchend", stop, { passive: true }); document.addEventListener("touchcancel", stop, { passive: true }); return () => { document.removeEventListener("touchstart", onStart); document.removeEventListener("touchmove", onMove); document.removeEventListener("touchend", stop); document.removeEventListener("touchcancel", stop); }; }, [enabled, isOpen, onOpen, onClose]); }