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;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MobilePanelView } from "@/stores/panel-store";
|
||||
import {
|
||||
canBeginMobilePanelGesture,
|
||||
createMobilePanelMotionState,
|
||||
getMobilePanelFrame,
|
||||
isMobilePanelGestureCurrent,
|
||||
transitionMobilePanel,
|
||||
type MobilePanelCommit,
|
||||
type MobilePanelEvent,
|
||||
type MobilePanelMotionState,
|
||||
} from "./model";
|
||||
|
||||
class MobilePanelsScenario {
|
||||
private nextRevision = 0;
|
||||
private startedRevision = -1;
|
||||
private state: MobilePanelMotionState;
|
||||
readonly commits: MobilePanelCommit[] = [];
|
||||
|
||||
constructor(target: MobilePanelView = "agent") {
|
||||
this.state = createMobilePanelMotionState({ target, revision: this.nextRevision });
|
||||
}
|
||||
|
||||
command(target: MobilePanelView) {
|
||||
this.nextRevision += 1;
|
||||
this.dispatch({ type: "command", selection: { target, revision: this.nextRevision } });
|
||||
return this;
|
||||
}
|
||||
|
||||
beginGesture(origin: MobilePanelView) {
|
||||
this.dispatch({ type: "gesture.begin", origin });
|
||||
this.startedRevision = this.state.gesture?.startedRevision ?? -1;
|
||||
return this;
|
||||
}
|
||||
|
||||
finishGesture(target: MobilePanelView) {
|
||||
this.dispatch({
|
||||
type: "gesture.finish",
|
||||
startedRevision: this.startedRevision,
|
||||
success: true,
|
||||
target,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
cancelGesture() {
|
||||
this.dispatch({
|
||||
type: "gesture.finish",
|
||||
startedRevision: this.startedRevision,
|
||||
success: false,
|
||||
target: this.state.target,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
finishAnimation(target: MobilePanelView, revision = this.state.revision) {
|
||||
this.dispatch({ type: "animation.finished", revision, target });
|
||||
return this;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return {
|
||||
motionTarget: this.state.motionTarget,
|
||||
revision: this.state.revision,
|
||||
settledTarget: this.state.settledTarget,
|
||||
target: this.state.target,
|
||||
};
|
||||
}
|
||||
|
||||
private dispatch(event: MobilePanelEvent) {
|
||||
const transition = transitionMobilePanel(this.state, event);
|
||||
this.state = transition.state;
|
||||
if (transition.commit) {
|
||||
this.commits.push(transition.commit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("mobile panel ownership", () => {
|
||||
it("follows programmatic commands through left, center, and right", () => {
|
||||
const panels = new MobilePanelsScenario();
|
||||
|
||||
panels.command("agent-list").finishAnimation("agent-list");
|
||||
expect(panels.snapshot()).toEqual({
|
||||
target: "agent-list",
|
||||
motionTarget: "agent-list",
|
||||
settledTarget: "agent-list",
|
||||
revision: 1,
|
||||
});
|
||||
|
||||
panels.command("agent").command("file-explorer").finishAnimation("file-explorer");
|
||||
expect(panels.snapshot()).toEqual({
|
||||
target: "file-explorer",
|
||||
motionTarget: "file-explorer",
|
||||
settledTarget: "file-explorer",
|
||||
revision: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a completed drag into one semantic commit", () => {
|
||||
const panels = new MobilePanelsScenario();
|
||||
|
||||
panels.beginGesture("agent").finishGesture("agent-list");
|
||||
|
||||
expect(panels.snapshot()).toEqual({
|
||||
target: "agent",
|
||||
motionTarget: "agent-list",
|
||||
settledTarget: "agent",
|
||||
revision: 0,
|
||||
});
|
||||
expect(panels.commits).toEqual([{ target: "agent-list", startedRevision: 0 }]);
|
||||
});
|
||||
|
||||
it("returns a canceled drag to the latest canonical target", () => {
|
||||
const panels = new MobilePanelsScenario("agent-list");
|
||||
|
||||
panels.beginGesture("agent-list").cancelGesture();
|
||||
|
||||
expect(panels.snapshot()).toEqual({
|
||||
target: "agent-list",
|
||||
motionTarget: "agent-list",
|
||||
settledTarget: "agent-list",
|
||||
revision: 0,
|
||||
});
|
||||
expect(panels.commits).toEqual([]);
|
||||
});
|
||||
|
||||
it("makes a command during a drag invalidate the stale gesture finish", () => {
|
||||
const panels = new MobilePanelsScenario();
|
||||
|
||||
panels.beginGesture("agent").command("file-explorer").finishGesture("agent-list");
|
||||
|
||||
expect(panels.snapshot()).toEqual({
|
||||
target: "file-explorer",
|
||||
motionTarget: "file-explorer",
|
||||
settledTarget: "agent",
|
||||
revision: 1,
|
||||
});
|
||||
expect(panels.commits).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the latest rapid command and rejects stale animation completion", () => {
|
||||
const panels = new MobilePanelsScenario();
|
||||
|
||||
panels.command("agent-list");
|
||||
const staleRevision = panels.snapshot().revision;
|
||||
panels.command("agent").command("file-explorer");
|
||||
panels.finishAnimation("agent-list", staleRevision);
|
||||
|
||||
expect(panels.snapshot()).toEqual({
|
||||
target: "file-explorer",
|
||||
motionTarget: "file-explorer",
|
||||
settledTarget: "agent",
|
||||
revision: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an activated drag current while blocking a second drag", () => {
|
||||
const initial = createMobilePanelMotionState({ target: "agent", revision: 7 });
|
||||
const active = transitionMobilePanel(initial, {
|
||||
type: "gesture.begin",
|
||||
origin: "agent",
|
||||
}).state;
|
||||
|
||||
expect(isMobilePanelGestureCurrent(active, 7)).toBe(true);
|
||||
expect(canBeginMobilePanelGesture(active, "agent", 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("derives both transforms and both backdrops from one normalized position", () => {
|
||||
expect(getMobilePanelFrame(0.25, 400)).toEqual({
|
||||
leftBackdropOpacity: 0,
|
||||
leftTranslateX: -400,
|
||||
rightBackdropOpacity: 0.25,
|
||||
rightTranslateX: 300,
|
||||
});
|
||||
expect(getMobilePanelFrame(0.25, 800)).toEqual({
|
||||
leftBackdropOpacity: 0,
|
||||
leftTranslateX: -800,
|
||||
rightBackdropOpacity: 0.25,
|
||||
rightTranslateX: 600,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { MobilePanelSelection, MobilePanelView } from "@/stores/panel-store";
|
||||
|
||||
interface MobilePanelGesture {
|
||||
startedRevision: number;
|
||||
}
|
||||
|
||||
export interface MobilePanelMotionState extends MobilePanelSelection {
|
||||
gesture: MobilePanelGesture | null;
|
||||
motionTarget: MobilePanelView;
|
||||
settledTarget: MobilePanelView;
|
||||
}
|
||||
|
||||
export interface MobilePanelCommit {
|
||||
startedRevision: number;
|
||||
target: MobilePanelView;
|
||||
}
|
||||
|
||||
export interface MobilePanelTransition {
|
||||
animationTarget?: MobilePanelView;
|
||||
commit?: MobilePanelCommit;
|
||||
state: MobilePanelMotionState;
|
||||
}
|
||||
|
||||
export type MobilePanelEvent =
|
||||
| { type: "command"; selection: MobilePanelSelection }
|
||||
| { type: "gesture.begin"; origin: MobilePanelView }
|
||||
| {
|
||||
type: "gesture.finish";
|
||||
startedRevision: number;
|
||||
success: boolean;
|
||||
target: MobilePanelView;
|
||||
}
|
||||
| { type: "animation.finished"; revision: number; target: MobilePanelView };
|
||||
|
||||
export function createMobilePanelMotionState(
|
||||
selection: MobilePanelSelection,
|
||||
): MobilePanelMotionState {
|
||||
return {
|
||||
...selection,
|
||||
gesture: null,
|
||||
motionTarget: selection.target,
|
||||
settledTarget: selection.target,
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionMobilePanel(
|
||||
state: MobilePanelMotionState,
|
||||
event: MobilePanelEvent,
|
||||
): MobilePanelTransition {
|
||||
"worklet";
|
||||
if (event.type === "command") {
|
||||
if (event.selection.revision <= state.revision) {
|
||||
return { state };
|
||||
}
|
||||
return {
|
||||
animationTarget: event.selection.target,
|
||||
state: {
|
||||
...state,
|
||||
...event.selection,
|
||||
gesture: null,
|
||||
motionTarget: event.selection.target,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "gesture.begin") {
|
||||
if (
|
||||
state.gesture ||
|
||||
state.target !== event.origin ||
|
||||
state.motionTarget !== event.origin ||
|
||||
state.settledTarget !== event.origin
|
||||
) {
|
||||
return { state };
|
||||
}
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
gesture: {
|
||||
startedRevision: state.revision,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "gesture.finish") {
|
||||
const ownsCurrentRevision = state.gesture?.startedRevision === state.revision;
|
||||
const ownsFinish = state.gesture?.startedRevision === event.startedRevision;
|
||||
if (!ownsCurrentRevision || !ownsFinish || !state.gesture) {
|
||||
return { state };
|
||||
}
|
||||
const startedRevision = state.gesture.startedRevision;
|
||||
const target = event.success ? event.target : state.target;
|
||||
return {
|
||||
animationTarget: target,
|
||||
commit: event.success && target !== state.target ? { startedRevision, target } : undefined,
|
||||
state: {
|
||||
...state,
|
||||
gesture: null,
|
||||
motionTarget: target,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ownsCurrentRevision = event.revision === state.revision;
|
||||
const isCanonicalTarget = event.target === state.target;
|
||||
const isCurrentMotionTarget = event.target === state.motionTarget;
|
||||
if (!ownsCurrentRevision || !isCanonicalTarget || !isCurrentMotionTarget) {
|
||||
return { state };
|
||||
}
|
||||
return {
|
||||
state: { ...state, settledTarget: event.target },
|
||||
};
|
||||
}
|
||||
|
||||
export function getMobilePanelAnchor(panel: MobilePanelView): number {
|
||||
"worklet";
|
||||
if (panel === "agent-list") {
|
||||
return -1;
|
||||
}
|
||||
if (panel === "file-explorer") {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function canBeginMobilePanelGesture(
|
||||
state: MobilePanelMotionState,
|
||||
origin: MobilePanelView,
|
||||
position: number,
|
||||
): boolean {
|
||||
"worklet";
|
||||
const isCanonical = state.target === origin;
|
||||
const isMotionSettled = state.motionTarget === origin && state.settledTarget === origin;
|
||||
const isAtOrigin = Math.abs(position - getMobilePanelAnchor(origin)) <= 0.002;
|
||||
return !state.gesture && isCanonical && isMotionSettled && isAtOrigin;
|
||||
}
|
||||
|
||||
export function isMobilePanelGestureCurrent(
|
||||
state: MobilePanelMotionState,
|
||||
startedRevision: number,
|
||||
): boolean {
|
||||
"worklet";
|
||||
return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision;
|
||||
}
|
||||
|
||||
export function getMobilePanelFrame(position: number, width: number) {
|
||||
"worklet";
|
||||
const clampedPosition = Math.max(-1, Math.min(1, position));
|
||||
return {
|
||||
leftBackdropOpacity: Math.max(0, -clampedPosition),
|
||||
leftTranslateX: -Math.min(1, clampedPosition + 1) * width,
|
||||
rightBackdropOpacity: Math.max(0, clampedPosition),
|
||||
rightTranslateX: Math.min(1, 1 - clampedPosition) * width,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo, type ComponentProps, type ReactNode } from "react";
|
||||
import { Pressable, StyleSheet, View } from "react-native";
|
||||
import { GestureDetector, type GestureType } from "react-native-gesture-handler";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
|
||||
import { getMobilePanelFrame } from "./model";
|
||||
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider";
|
||||
|
||||
type OverlayPanel = Exclude<MobilePanelView, "agent">;
|
||||
|
||||
interface MobilePanelOverlayProps {
|
||||
children: ReactNode;
|
||||
closeGesture: GestureType;
|
||||
panel: OverlayPanel;
|
||||
panelStyle?: ComponentProps<typeof Animated.View>["style"];
|
||||
}
|
||||
|
||||
export function MobilePanelOverlay({
|
||||
children,
|
||||
closeGesture,
|
||||
panel,
|
||||
panelStyle,
|
||||
}: MobilePanelOverlayProps) {
|
||||
const { position, windowWidth } = useMobilePanelsRuntime();
|
||||
const target = usePanelStore((state) => state.mobilePanel.target);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const isOpen = target === panel;
|
||||
const isPresented = useIsMobilePanelPresented(panel);
|
||||
const isLeft = panel === "agent-list";
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => {
|
||||
const frame = getMobilePanelFrame(position.value, windowWidth);
|
||||
return {
|
||||
transform: [{ translateX: isLeft ? frame.leftTranslateX : frame.rightTranslateX }],
|
||||
};
|
||||
}, [isLeft, windowWidth]);
|
||||
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => {
|
||||
const frame = getMobilePanelFrame(position.value, windowWidth);
|
||||
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
|
||||
}, [isLeft, windowWidth]);
|
||||
|
||||
const overlayStyle = useMemo(
|
||||
() => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }],
|
||||
[isPresented],
|
||||
);
|
||||
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
|
||||
const backdropStyle = useMemo(
|
||||
() => [styles.backdrop, backdropAnimatedStyle],
|
||||
[backdropAnimatedStyle],
|
||||
);
|
||||
const combinedPanelStyle = useMemo(
|
||||
() => [
|
||||
styles.panel,
|
||||
positionedPanelStyle,
|
||||
{ width: windowWidth },
|
||||
panelStyle,
|
||||
sidebarAnimatedStyle,
|
||||
],
|
||||
[panelStyle, positionedPanelStyle, sidebarAnimatedStyle, windowWidth],
|
||||
);
|
||||
let overlayPointerEvents: "auto" | "box-none" | "none";
|
||||
if (!isWeb) {
|
||||
overlayPointerEvents = "box-none";
|
||||
} else {
|
||||
overlayPointerEvents = isOpen ? "auto" : "none";
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Pressable
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
onPress={showMobileAgent}
|
||||
pointerEvents={isOpen ? "auto" : "none"}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
testID={`${panel}-backdrop`}
|
||||
>
|
||||
<Animated.View pointerEvents="none" style={backdropStyle} />
|
||||
</Pressable>
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Reanimated owns only these static native styles and the derived transform.
|
||||
// Theme values stay inline at call sites, avoiding Unistyles patching the same
|
||||
// native node after Fabric commits.
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
panel: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
leftPanel: {
|
||||
left: 0,
|
||||
},
|
||||
rightPanel: {
|
||||
right: 0,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { Keyboard, useWindowDimensions } from "react-native";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
import {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
usePanelStore,
|
||||
type MobilePanelSelection,
|
||||
type MobilePanelView,
|
||||
} from "@/stores/panel-store";
|
||||
import {
|
||||
canBeginMobilePanelGesture,
|
||||
createMobilePanelMotionState,
|
||||
getMobilePanelAnchor,
|
||||
isMobilePanelGestureCurrent,
|
||||
transitionMobilePanel,
|
||||
type MobilePanelCommit,
|
||||
type MobilePanelMotionState,
|
||||
type MobilePanelTransition,
|
||||
} from "./model";
|
||||
|
||||
const ANIMATION_DURATION = 220;
|
||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||
const LEFT_PANEL_MASK = 1;
|
||||
const RIGHT_PANEL_MASK = 2;
|
||||
|
||||
function getPanelMask(panel: MobilePanelView): number {
|
||||
if (panel === "agent-list") {
|
||||
return LEFT_PANEL_MASK;
|
||||
}
|
||||
if (panel === "file-explorer") {
|
||||
return RIGHT_PANEL_MASK;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface MobilePanelsRuntime {
|
||||
beginGesture: (input: BeginGestureInput) => number;
|
||||
finishGesture: (input: FinishGestureInput) => MobilePanelCommit | null;
|
||||
leftCloseGestureRef: RefObject<GestureType | undefined>;
|
||||
leftOpenGestureRef: RefObject<GestureType | undefined>;
|
||||
motionState: SharedValue<MobilePanelMotionState>;
|
||||
position: SharedValue<number>;
|
||||
rightCloseGestureRef: RefObject<GestureType | undefined>;
|
||||
rightOpenGestureRef: RefObject<GestureType | undefined>;
|
||||
updateGesture: (startedRevision: number, nextPosition: number) => boolean;
|
||||
windowWidth: number;
|
||||
}
|
||||
|
||||
interface BeginGestureInput {
|
||||
origin: MobilePanelView;
|
||||
preview: MobilePanelView;
|
||||
}
|
||||
|
||||
interface FinishGestureInput {
|
||||
startedRevision: number;
|
||||
success: boolean;
|
||||
target: MobilePanelView;
|
||||
}
|
||||
|
||||
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
|
||||
const MobilePanelPresentationContext = createContext(0);
|
||||
|
||||
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const initialSelection = useRef(usePanelStore.getState().mobilePanel).current;
|
||||
const position = useSharedValue(getMobilePanelAnchor(initialSelection.target));
|
||||
const motionState = useSharedValue(createMobilePanelMotionState(initialSelection));
|
||||
const leftOpenGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target));
|
||||
|
||||
const presentPanel = useCallback((panel: MobilePanelView) => {
|
||||
const mask = getPanelMask(panel);
|
||||
if (mask) {
|
||||
setPresentedPanels((current) => current | mask);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const settlePresentation = useCallback((panel: MobilePanelView, revision: number) => {
|
||||
const selection = usePanelStore.getState().mobilePanel;
|
||||
if (selection.revision !== revision || selection.target !== panel) {
|
||||
return;
|
||||
}
|
||||
setPresentedPanels(getPanelMask(panel));
|
||||
}, []);
|
||||
|
||||
const animateTransition = useCallback(
|
||||
(transition: MobilePanelTransition) => {
|
||||
"worklet";
|
||||
if (!transition.animationTarget) {
|
||||
return;
|
||||
}
|
||||
const target = transition.animationTarget;
|
||||
const revision = transition.state.revision;
|
||||
position.value = withTiming(
|
||||
getMobilePanelAnchor(target),
|
||||
{ duration: ANIMATION_DURATION, easing: ANIMATION_EASING },
|
||||
(finished) => {
|
||||
if (!finished) {
|
||||
return;
|
||||
}
|
||||
const currentState = motionState.value;
|
||||
const settled = transitionMobilePanel(currentState, {
|
||||
type: "animation.finished",
|
||||
revision,
|
||||
target,
|
||||
});
|
||||
if (settled.state === currentState) {
|
||||
return;
|
||||
}
|
||||
motionState.value = settled.state;
|
||||
scheduleOnRN(settlePresentation, target, revision);
|
||||
},
|
||||
);
|
||||
},
|
||||
[motionState, position, settlePresentation],
|
||||
);
|
||||
|
||||
const applySelection = useCallback(
|
||||
(selection: MobilePanelSelection) => {
|
||||
"worklet";
|
||||
const currentState = motionState.value;
|
||||
const transition = transitionMobilePanel(currentState, {
|
||||
type: "command",
|
||||
selection,
|
||||
});
|
||||
if (transition.state === currentState) {
|
||||
return;
|
||||
}
|
||||
motionState.value = transition.state;
|
||||
animateTransition(transition);
|
||||
},
|
||||
[animateTransition, motionState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return usePanelStore.subscribe((state, previousState) => {
|
||||
const selection = state.mobilePanel;
|
||||
if (selection === previousState.mobilePanel) {
|
||||
return;
|
||||
}
|
||||
if (selection.target !== "agent") {
|
||||
presentPanel(selection.target);
|
||||
if (isNative) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
}
|
||||
scheduleOnUI(applySelection, selection);
|
||||
});
|
||||
}, [applySelection, presentPanel]);
|
||||
|
||||
const beginGesture = useCallback(
|
||||
({ origin, preview }: BeginGestureInput): number => {
|
||||
"worklet";
|
||||
const currentState = motionState.value;
|
||||
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
|
||||
return -1;
|
||||
}
|
||||
const transition = transitionMobilePanel(currentState, {
|
||||
type: "gesture.begin",
|
||||
origin,
|
||||
});
|
||||
motionState.value = transition.state;
|
||||
cancelAnimation(position);
|
||||
scheduleOnRN(presentPanel, preview);
|
||||
return transition.state.gesture?.startedRevision ?? -1;
|
||||
},
|
||||
[motionState, position, presentPanel],
|
||||
);
|
||||
|
||||
const updateGesture = useCallback(
|
||||
(startedRevision: number, nextPosition: number): boolean => {
|
||||
"worklet";
|
||||
if (!isMobilePanelGestureCurrent(motionState.value, startedRevision)) {
|
||||
return false;
|
||||
}
|
||||
position.value = Math.max(-1, Math.min(1, nextPosition));
|
||||
return true;
|
||||
},
|
||||
[motionState, position],
|
||||
);
|
||||
|
||||
const finishGesture = useCallback(
|
||||
({ startedRevision, target, success }: FinishGestureInput): MobilePanelCommit | null => {
|
||||
"worklet";
|
||||
const currentState = motionState.value;
|
||||
const transition = transitionMobilePanel(currentState, {
|
||||
type: "gesture.finish",
|
||||
startedRevision,
|
||||
success,
|
||||
target,
|
||||
});
|
||||
if (transition.state === currentState) {
|
||||
return null;
|
||||
}
|
||||
motionState.value = transition.state;
|
||||
animateTransition(transition);
|
||||
return transition.commit ?? null;
|
||||
},
|
||||
[animateTransition, motionState],
|
||||
);
|
||||
|
||||
const value = useMemo<MobilePanelsRuntime>(
|
||||
() => ({
|
||||
beginGesture,
|
||||
finishGesture,
|
||||
leftCloseGestureRef,
|
||||
leftOpenGestureRef,
|
||||
motionState,
|
||||
position,
|
||||
rightCloseGestureRef,
|
||||
rightOpenGestureRef,
|
||||
updateGesture,
|
||||
windowWidth,
|
||||
}),
|
||||
[beginGesture, finishGesture, motionState, position, updateGesture, windowWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
<MobilePanelsContext.Provider value={value}>
|
||||
<MobilePanelPresentationContext.Provider value={presentedPanels}>
|
||||
{children}
|
||||
</MobilePanelPresentationContext.Provider>
|
||||
</MobilePanelsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Internal to the mobile-panels module. Callers use gesture and presentation adapters. */
|
||||
export function useMobilePanelsRuntime(): MobilePanelsRuntime {
|
||||
const context = useContext(MobilePanelsContext);
|
||||
if (!context) {
|
||||
throw new Error("useMobilePanelsRuntime must be used within MobilePanelsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useIsMobilePanelPresented(panel: MobilePanelView): boolean {
|
||||
const presentedPanels = useContext(MobilePanelPresentationContext);
|
||||
return (presentedPanels & getPanelMask(panel)) !== 0;
|
||||
}
|
||||
Reference in New Issue
Block a user