Stop mobile sidebars getting stuck out of sync (#1953)
* fix(app): keep mobile sidebars synced with store Gesture-owned skip flags could swallow the next store sync after a no-op or overlapping gesture, leaving React state and Reanimated shared values split. Always reconcile both mobile sidebars from the panel store after gesture animations. * test(app): keep sidebar sync coverage pure * fix(app): keep sidebar close transitions idempotent * fix(app): keep mobile sidebars stable through interruptions Independent React and worklet state let stale gesture or animation callbacks overwrite newer panel commands. Use one revisioned target and one transient motion runtime so outdated callbacks cannot restore an earlier panel.
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
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";
|
||||
|
||||
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
|
||||
const PAN_INTENT_FAIL = -1;
|
||||
const PAN_INTENT_WAIT = 0;
|
||||
const PAN_INTENT_ACTIVATE = 1;
|
||||
type PanDirection = -1 | 1;
|
||||
type PanIntent = typeof PAN_INTENT_ACTIVATE | typeof PAN_INTENT_FAIL | typeof PAN_INTENT_WAIT;
|
||||
|
||||
function isCurrentSelection(startedRevision: number): boolean {
|
||||
return usePanelStore.getState().mobilePanel.revision === startedRevision;
|
||||
}
|
||||
|
||||
function getHorizontalPanIntent(
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
direction: PanDirection,
|
||||
): PanIntent {
|
||||
"worklet";
|
||||
const directedDelta = deltaX * direction;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
if (directedDelta <= -10 || (absDeltaY > 10 && absDeltaY > absDeltaX)) {
|
||||
return PAN_INTENT_FAIL;
|
||||
}
|
||||
if (directedDelta >= 15 && absDeltaX > absDeltaY) {
|
||||
return PAN_INTENT_ACTIVATE;
|
||||
}
|
||||
return PAN_INTENT_WAIT;
|
||||
}
|
||||
|
||||
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,
|
||||
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 = getHorizontalPanIntent(deltaX, deltaY, 1);
|
||||
if (
|
||||
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
|
||||
horizontalScroll?.isAnyScrolledRight.value ||
|
||||
(isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
|
||||
panIntent === PAN_INTENT_FAIL
|
||||
) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (panIntent === PAN_INTENT_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,
|
||||
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 = getHorizontalPanIntent(deltaX, deltaY, -1);
|
||||
if (
|
||||
!canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) ||
|
||||
panIntent === PAN_INTENT_FAIL
|
||||
) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (panIntent === PAN_INTENT_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,
|
||||
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 = getHorizontalPanIntent(deltaX, deltaY, -1);
|
||||
if (
|
||||
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
|
||||
(isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
|
||||
panIntent === PAN_INTENT_FAIL
|
||||
) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (panIntent === PAN_INTENT_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,
|
||||
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 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 = getHorizontalPanIntent(deltaX, deltaY, 1);
|
||||
if (
|
||||
!canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) ||
|
||||
panIntent === PAN_INTENT_FAIL
|
||||
) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (panIntent === PAN_INTENT_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,
|
||||
motionState,
|
||||
position,
|
||||
rightCloseGestureRef,
|
||||
startedRevision,
|
||||
touchStartX,
|
||||
touchStartY,
|
||||
updateGesture,
|
||||
windowWidth,
|
||||
],
|
||||
);
|
||||
|
||||
return { gesture, gestureRef: rightCloseGestureRef };
|
||||
}
|
||||
|
||||
export function useFileExplorerCloseGestureRef() {
|
||||
return useMobilePanelsRuntime().rightCloseGestureRef;
|
||||
}
|
||||
Reference in New Issue
Block a user