import { useCallback, useMemo } from "react"; import { Gesture } from "react-native-gesture-handler"; import { useSharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { isWeb } from "@/constants/platform"; import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; import { usePanelStore } from "@/stores/panel-store"; import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model"; import { useMobilePanelsRuntime } from "./provider"; import { resolveMobilePanelGestureIntent } from "./gesture-intent"; const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32; function isCurrentSelection(startedRevision: number): boolean { return usePanelStore.getState().mobilePanel.revision === startedRevision; } function useGestureState() { return { startedRevision: useSharedValue(-1), touchStartX: useSharedValue(0), touchStartY: useSharedValue(0), }; } function useRevisionCommit(action: () => void) { return useCallback( (revision: number) => { if (isCurrentSelection(revision)) { action(); } }, [action], ); } export function useOpenAgentListGesture(enabled: boolean) { const { beginGesture, finishGesture, leftOpenGestureRef, motionState, openGesturesBlocked, position, updateGesture, windowWidth, } = useMobilePanelsRuntime(); const horizontalScroll = useHorizontalScrollOptional(); const { startedRevision, touchStartX, touchStartY } = useGestureState(); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); const commit = useRevisionCommit(showMobileAgentList); return useMemo( () => Gesture.Pan() .withRef(leftOpenGestureRef) .enabled(enabled) .manualActivation(true) .onTouchesDown((event) => { const touch = event.changedTouches[0]; if (touch) { touchStartX.value = touch.absoluteX; touchStartY.value = touch.absoluteY; } }) .onTouchesMove((event, stateManager) => { const touch = event.changedTouches[0]; if (!touch || event.numberOfTouches !== 1) { stateManager.fail(); return; } const deltaX = touch.absoluteX - touchStartX.value; const deltaY = touch.absoluteY - touchStartY.value; if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) { return; } const panIntent = resolveMobilePanelGestureIntent({ deltaX, deltaY, direction: 1, openGesturesBlocked: openGesturesBlocked.value, }); if ( !canBeginMobilePanelGesture(motionState.value, "agent", position.value) || horizontalScroll?.isAnyScrolledRight.value || (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) || panIntent === "fail" ) { stateManager.fail(); return; } if (panIntent === "activate") { stateManager.activate(); } }) .onStart(() => { startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" }); }) .onUpdate((event) => { updateGesture(startedRevision.value, -event.translationX / windowWidth); }) .onEnd((event, success) => { const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; const result = finishGesture({ startedRevision: startedRevision.value, target: shouldOpen ? "agent-list" : "agent", success, }); if (result) { scheduleOnRN(commit, result.startedRevision); } }), [ commit, enabled, beginGesture, finishGesture, horizontalScroll?.isAnyScrolledRight, leftOpenGestureRef, motionState, openGesturesBlocked, position, startedRevision, touchStartX, touchStartY, updateGesture, windowWidth, ], ); } export function useCloseAgentListGesture() { const { beginGesture, finishGesture, leftCloseGestureRef, motionState, position, updateGesture, windowWidth, } = useMobilePanelsRuntime(); const { startedRevision, touchStartX, touchStartY } = useGestureState(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const commit = useRevisionCommit(showMobileAgent); const gesture = useMemo( () => Gesture.Pan() .withRef(leftCloseGestureRef) .manualActivation(true) .onTouchesDown((event) => { const touch = event.changedTouches[0]; if (touch) { touchStartX.value = touch.absoluteX; touchStartY.value = touch.absoluteY; } }) .onTouchesMove((event, stateManager) => { const touch = event.changedTouches[0]; if (!touch || event.numberOfTouches !== 1) { stateManager.fail(); return; } const deltaX = touch.absoluteX - touchStartX.value; const deltaY = touch.absoluteY - touchStartY.value; if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) { return; } const panIntent = resolveMobilePanelGestureIntent({ deltaX, deltaY, direction: -1, openGesturesBlocked: false, }); if ( !canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) || panIntent === "fail" ) { stateManager.fail(); return; } if (panIntent === "activate") { stateManager.activate(); } }) .onStart(() => { startedRevision.value = beginGesture({ origin: "agent-list", preview: "agent-list", }); }) .onUpdate((event) => { updateGesture(startedRevision.value, -1 - event.translationX / windowWidth); }) .onEnd((event, success) => { const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; const result = finishGesture({ startedRevision: startedRevision.value, target: shouldClose ? "agent" : "agent-list", success, }); if (result) { scheduleOnRN(commit, result.startedRevision); } }), [ beginGesture, commit, finishGesture, leftCloseGestureRef, motionState, position, startedRevision, touchStartX, touchStartY, updateGesture, windowWidth, ], ); return { gesture, gestureRef: leftCloseGestureRef }; } interface OpenFileExplorerGestureOptions { enabled: boolean; onOpen: () => void; } export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorerGestureOptions) { const { beginGesture, finishGesture, leftOpenGestureRef, motionState, openGesturesBlocked, position, rightOpenGestureRef, updateGesture, windowWidth, } = useMobilePanelsRuntime(); const { startedRevision, touchStartX, touchStartY } = useGestureState(); const commit = useRevisionCommit(onOpen); return useMemo( () => Gesture.Pan() .withRef(rightOpenGestureRef) .simultaneousWithExternalGesture(leftOpenGestureRef) .enabled(enabled) .manualActivation(true) .onTouchesDown((event) => { const touch = event.changedTouches[0]; if (touch) { touchStartX.value = touch.absoluteX; touchStartY.value = touch.absoluteY; } }) .onTouchesMove((event, stateManager) => { const touch = event.changedTouches[0]; if (!touch || event.numberOfTouches !== 1) { stateManager.fail(); return; } const deltaX = touch.absoluteX - touchStartX.value; const deltaY = touch.absoluteY - touchStartY.value; if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) { return; } const panIntent = resolveMobilePanelGestureIntent({ deltaX, deltaY, direction: -1, openGesturesBlocked: openGesturesBlocked.value, }); if ( !canBeginMobilePanelGesture(motionState.value, "agent", position.value) || (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) || panIntent === "fail" ) { stateManager.fail(); return; } if (panIntent === "activate") { stateManager.activate(); } }) .onStart(() => { startedRevision.value = beginGesture({ origin: "agent", preview: "file-explorer", }); }) .onUpdate((event) => { updateGesture(startedRevision.value, -event.translationX / windowWidth); }) .onEnd((event, success) => { const shouldOpen = event.translationX < -windowWidth / 3 || event.velocityX < -500; const result = finishGesture({ startedRevision: startedRevision.value, target: shouldOpen ? "file-explorer" : "agent", success, }); if (result) { scheduleOnRN(commit, result.startedRevision); } }), [ beginGesture, commit, enabled, finishGesture, leftOpenGestureRef, motionState, openGesturesBlocked, position, rightOpenGestureRef, startedRevision, touchStartX, touchStartY, updateGesture, windowWidth, ], ); } export function useCloseFileExplorerGesture() { const { beginGesture, finishGesture, motionState, position, rightCloseGestureRef, updateGesture, windowWidth, } = useMobilePanelsRuntime(); const { startedRevision, touchStartX, touchStartY } = useGestureState(); const horizontalScroll = useHorizontalScrollOptional(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const commit = useRevisionCommit(showMobileAgent); const gesture = useMemo( () => Gesture.Pan() .withRef(rightCloseGestureRef) .manualActivation(true) .onTouchesDown((event) => { const touch = event.changedTouches[0]; if (touch) { touchStartX.value = touch.absoluteX; touchStartY.value = touch.absoluteY; } }) .onTouchesMove((event, stateManager) => { const touch = event.changedTouches[0]; if (!touch || event.numberOfTouches !== 1) { stateManager.fail(); return; } const deltaX = touch.absoluteX - touchStartX.value; const deltaY = touch.absoluteY - touchStartY.value; if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) { return; } const panIntent = resolveMobilePanelGestureIntent({ deltaX, deltaY, direction: 1, openGesturesBlocked: false, }); if ( !canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) || horizontalScroll?.activeGestureStartedScrolled.value || panIntent === "fail" ) { stateManager.fail(); return; } if (panIntent === "activate") { stateManager.activate(); } }) .onStart(() => { startedRevision.value = beginGesture({ origin: "file-explorer", preview: "file-explorer", }); }) .onUpdate((event) => { updateGesture(startedRevision.value, 1 - event.translationX / windowWidth); }) .onEnd((event, success) => { const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500; const result = finishGesture({ startedRevision: startedRevision.value, target: shouldClose ? "agent" : "file-explorer", success, }); if (result) { scheduleOnRN(commit, result.startedRevision); } }), [ beginGesture, commit, finishGesture, horizontalScroll?.activeGestureStartedScrolled, motionState, position, rightCloseGestureRef, startedRevision, touchStartX, touchStartY, updateGesture, windowWidth, ], ); return { gesture, gestureRef: rightCloseGestureRef }; } export function useFileExplorerCloseGestureRef() { return useMobilePanelsRuntime().rightCloseGestureRef; }