import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { useCompactNav } from '../../hooks/useCompactNav'; import { useBackRoute } from '../../hooks/useBackRoute'; import * as css from './mobile-gestures.css'; const COMMIT_RATIO = 0.28; const MIN_COMMIT_PX = 72; const MAX_START_Y_RATIO = 0.88; type DragState = { pointerId: number; startX: number; startY: number; dragging: boolean; moved: boolean; }; type MobileSwipeBackPanelProps = { children: ReactNode; }; export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) { const compact = useCompactNav(); const { canGoBack, goBack } = useBackRoute(); const enabled = compact && canGoBack; const rootRef = useRef(null); const contentRef = useRef(null); const dragRef = useRef(null); const [offset, setOffset] = useState(0); const [animating, setAnimating] = useState(false); const resetTransform = useCallback((animate = true) => { const content = contentRef.current; if (!content) return; setAnimating(animate); setOffset(0); content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none'; content.style.transform = 'translateX(0px)'; }, []); const setTransform = useCallback((px: number, animate = false) => { const content = contentRef.current; if (!content) return; setOffset(px); content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none'; content.style.transform = `translateX(${px}px)`; }, []); const commitBack = useCallback(() => { const width = rootRef.current?.clientWidth ?? window.innerWidth; setAnimating(true); setTransform(width, true); window.setTimeout(() => { goBack(); resetTransform(false); setAnimating(false); }, 180); }, [goBack, resetTransform, setTransform]); const shouldIgnoreTarget = (target: EventTarget | null): boolean => { if (!(target instanceof Element)) return true; return Boolean( target.closest( 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-carousel-scroller], [data-disable-swipe-back="true"]' ) ); }; const handlePointerDown = useCallback( (evt: React.PointerEvent) => { if (!enabled || animating || evt.button !== 0 || !evt.isPrimary) return; if (shouldIgnoreTarget(evt.target)) return; if (evt.clientY > window.innerHeight * MAX_START_Y_RATIO) return; dragRef.current = { pointerId: evt.pointerId, startX: evt.clientX, startY: evt.clientY, dragging: true, moved: false, }; rootRef.current?.setPointerCapture(evt.pointerId); }, [animating, enabled] ); const handlePointerMove = useCallback( (evt: React.PointerEvent) => { const drag = dragRef.current; if (!drag || !drag.dragging || drag.pointerId !== evt.pointerId) return; const deltaX = evt.clientX - drag.startX; const deltaY = evt.clientY - drag.startY; if (!drag.moved) { if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return; if (Math.abs(deltaY) > Math.abs(deltaX)) { dragRef.current = null; return; } if (deltaX <= 0) { dragRef.current = null; return; } drag.moved = true; } evt.preventDefault(); const width = rootRef.current?.clientWidth ?? window.innerWidth; setTransform(Math.min(Math.max(deltaX, 0), width), false); }, [setTransform] ); const endDrag = useCallback( (pointerId: number) => { const drag = dragRef.current; if (!drag || drag.pointerId !== pointerId) return; if (rootRef.current?.hasPointerCapture(pointerId)) { rootRef.current.releasePointerCapture(pointerId); } dragRef.current = null; if (!drag.moved) { resetTransform(false); return; } const width = rootRef.current?.clientWidth ?? window.innerWidth; const shouldCommit = offset >= Math.max(width * COMMIT_RATIO, MIN_COMMIT_PX); if (shouldCommit) { commitBack(); return; } resetTransform(true); }, [commitBack, offset, resetTransform] ); const handlePointerUp = useCallback( (evt: React.PointerEvent) => { endDrag(evt.pointerId); }, [endDrag] ); useEffect(() => { resetTransform(false); }, [enabled, resetTransform]); if (!enabled) { return <>{children}; } return (
{children}
); }