Files
paseo/packages/app/src/mobile-panels/gestures.ts
T
Mohamed Boudra c7b103a2cd fix(app): stabilize canvas diff rendering
Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets.

Complete review, selection, typography, tree, and palette parity without returning line rendering to React.
2026-08-17 21:14:38 +02:00

428 lines
13 KiB
TypeScript

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;
}