mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
fix(app): retain Explorer resize width until acknowledged
Co-authored-by: Elsa Granger <zeyugao@outlook.com>
This commit is contained in:
co-authored by
Elsa Granger
parent
0952ddbd4b
commit
fced746a27
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
finishExplorerSidebarResize,
|
||||
reconcileExplorerSidebarResize,
|
||||
resolveExplorerSidebarDockSizes,
|
||||
resolveExplorerSidebarFrameWidth,
|
||||
resolveExplorerSidebarWidth,
|
||||
type ExplorerSidebarResize,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
|
||||
describe("Explorer sidebar layout", () => {
|
||||
@@ -19,4 +23,120 @@ describe("Explorer sidebar layout", () => {
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390);
|
||||
});
|
||||
|
||||
describe("native resize ownership", () => {
|
||||
const allocation = {
|
||||
width: 320,
|
||||
containerWidth: 1200,
|
||||
overlay: false,
|
||||
resizeEnabled: true,
|
||||
};
|
||||
const dragging: ExplorerSidebarResize = {
|
||||
sourceWidth: allocation.width,
|
||||
containerWidth: allocation.containerWidth,
|
||||
pendingWidth: null,
|
||||
};
|
||||
|
||||
it("keeps the drag width after release while the JS commit still has the old prop", () => {
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: dragging, resizeWidth: 480 }),
|
||||
).toBe(480);
|
||||
|
||||
const released = finishExplorerSidebarResize(dragging, 480, true);
|
||||
expect(reconcileExplorerSidebarResize(released, allocation)).toBe(released);
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 480 }),
|
||||
).toBe(480);
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 320 }),
|
||||
).toBe(480);
|
||||
});
|
||||
|
||||
it("acknowledges the committed width and immediately follows subsequent props", () => {
|
||||
const released = finishExplorerSidebarResize(dragging, 480, true);
|
||||
const acknowledgedAllocation = { ...allocation, width: 480 };
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({
|
||||
...acknowledgedAllocation,
|
||||
resize: released,
|
||||
resizeWidth: 480,
|
||||
}),
|
||||
).toBe(480);
|
||||
const acknowledged = reconcileExplorerSidebarResize(released, acknowledgedAllocation);
|
||||
expect(acknowledged).toBeNull();
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({
|
||||
...allocation,
|
||||
width: 360,
|
||||
resize: acknowledged,
|
||||
resizeWidth: 480,
|
||||
}),
|
||||
).toBe(360);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "a new width prop", next: { ...allocation, width: 360 }, expected: 360 },
|
||||
{
|
||||
name: "a window allocation with an unchanged width prop",
|
||||
next: { ...allocation, containerWidth: 900 },
|
||||
expected: 320,
|
||||
},
|
||||
{
|
||||
name: "a window allocation that clamps the width prop",
|
||||
next: { ...allocation, width: 540, containerWidth: 900 },
|
||||
expected: 540,
|
||||
},
|
||||
{ name: "overlay presentation", next: { ...allocation, overlay: true }, expected: 1200 },
|
||||
{ name: "an inactive dock", next: { ...allocation, resizeEnabled: false }, expected: 320 },
|
||||
])(
|
||||
"yields synchronously to $name and does not resurrect the old handoff",
|
||||
({ next, expected }) => {
|
||||
const released = finishExplorerSidebarResize(dragging, 600, true);
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 600 }),
|
||||
).toBe(expected);
|
||||
const interrupted = reconcileExplorerSidebarResize(released, next);
|
||||
expect(interrupted).toBeNull();
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({
|
||||
...allocation,
|
||||
resize: interrupted,
|
||||
resizeWidth: 600,
|
||||
}),
|
||||
).toBe(320);
|
||||
},
|
||||
);
|
||||
|
||||
it("restores canonical intent when a drag is canceled", () => {
|
||||
const canceled = finishExplorerSidebarResize(dragging, 480, false);
|
||||
expect(canceled).toBeNull();
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 480 }),
|
||||
).toBe(320);
|
||||
});
|
||||
|
||||
it("cannot finish an interrupted drag over a newer allocation", () => {
|
||||
const next = { ...allocation, width: 360, containerWidth: 1000 };
|
||||
const interrupted = reconcileExplorerSidebarResize(dragging, next);
|
||||
const released = finishExplorerSidebarResize(interrupted, 480, true);
|
||||
expect(released).toBeNull();
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 480 }),
|
||||
).toBe(360);
|
||||
});
|
||||
|
||||
it("settles a release at the already-committed width without retaining ownership", () => {
|
||||
const released = finishExplorerSidebarResize(dragging, allocation.width, true);
|
||||
expect(reconcileExplorerSidebarResize(released, allocation)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an earlier released handoff when a later touch fails before activation", () => {
|
||||
const released = finishExplorerSidebarResize(dragging, 480, true);
|
||||
const finalized = finishExplorerSidebarResize(released, 480, false);
|
||||
expect(finalized).toBe(released);
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: finalized, resizeWidth: 480 }),
|
||||
).toBe(480);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,61 @@ export function resolveExplorerSidebarWidth(input: {
|
||||
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth));
|
||||
}
|
||||
|
||||
// A release keeps its UI width until React acknowledges it or replaces its allocation.
|
||||
// Null pendingWidth means the gesture still owns the live resizeWidth instead.
|
||||
export interface ExplorerSidebarResize {
|
||||
sourceWidth: number;
|
||||
containerWidth: number;
|
||||
pendingWidth: number | null;
|
||||
}
|
||||
|
||||
interface ExplorerSidebarAllocation {
|
||||
width: number;
|
||||
containerWidth: number;
|
||||
overlay: boolean;
|
||||
resizeEnabled: boolean;
|
||||
}
|
||||
|
||||
export function reconcileExplorerSidebarResize(
|
||||
resize: ExplorerSidebarResize | null,
|
||||
allocation: ExplorerSidebarAllocation,
|
||||
): ExplorerSidebarResize | null {
|
||||
"worklet";
|
||||
if (
|
||||
!resize ||
|
||||
allocation.overlay ||
|
||||
!allocation.resizeEnabled ||
|
||||
allocation.containerWidth !== resize.containerWidth ||
|
||||
allocation.width !== resize.sourceWidth ||
|
||||
allocation.width === resize.pendingWidth
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resize;
|
||||
}
|
||||
|
||||
export function finishExplorerSidebarResize(
|
||||
resize: ExplorerSidebarResize | null,
|
||||
width: number,
|
||||
success: boolean,
|
||||
): ExplorerSidebarResize | null {
|
||||
"worklet";
|
||||
if (!resize || resize.pendingWidth !== null) return resize;
|
||||
return success ? { ...resize, pendingWidth: width } : null;
|
||||
}
|
||||
|
||||
export function resolveExplorerSidebarFrameWidth(
|
||||
allocation: ExplorerSidebarAllocation & {
|
||||
resize: ExplorerSidebarResize | null;
|
||||
resizeWidth: number;
|
||||
},
|
||||
): number {
|
||||
"worklet";
|
||||
if (allocation.overlay) return allocation.containerWidth;
|
||||
const resize = reconcileExplorerSidebarResize(allocation.resize, allocation);
|
||||
return resize ? (resize.pendingWidth ?? allocation.resizeWidth) : allocation.width;
|
||||
}
|
||||
|
||||
export function resolveExplorerSidebarDockSizes(input: {
|
||||
requestedWidth?: number;
|
||||
containerWidth: number;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import {
|
||||
runOnJS,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
||||
@@ -9,7 +14,13 @@ import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout";
|
||||
import {
|
||||
finishExplorerSidebarResize,
|
||||
reconcileExplorerSidebarResize,
|
||||
resolveExplorerSidebarFrameWidth,
|
||||
resolveExplorerSidebarWidth,
|
||||
type ExplorerSidebarResize,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
import type { ExplorerGeometryInput, ExplorerGeometry } from "./geometry-types";
|
||||
export function useExplorerGeometry({
|
||||
width,
|
||||
@@ -21,10 +32,16 @@ export function useExplorerGeometry({
|
||||
const insets = useSafeAreaInsets();
|
||||
const resizeWidth = useSharedValue(width);
|
||||
const startWidth = useSharedValue(width);
|
||||
const resize = useSharedValue<ExplorerSidebarResize | null>(null);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
useEffect(() => {
|
||||
resizeWidth.value = width;
|
||||
}, [resizeWidth, width]);
|
||||
useAnimatedReaction(
|
||||
() => ({ resize: resize.value, width, containerWidth, overlay, resizeEnabled }),
|
||||
({ resize: currentResize, ...allocation }) => {
|
||||
const nextResize = reconcileExplorerSidebarResize(currentResize, allocation);
|
||||
if (nextResize !== currentResize) resize.value = nextResize;
|
||||
},
|
||||
[containerWidth, overlay, resize, resizeEnabled, width],
|
||||
);
|
||||
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||
@@ -37,22 +54,45 @@ export function useExplorerGeometry({
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidth.value = width + event.translationX;
|
||||
resizeWidth.value = width;
|
||||
const currentWidth = resolveExplorerSidebarFrameWidth({
|
||||
width,
|
||||
containerWidth,
|
||||
overlay,
|
||||
resizeEnabled,
|
||||
resize: resize.value,
|
||||
resizeWidth: resizeWidth.value,
|
||||
});
|
||||
resize.value = { sourceWidth: width, containerWidth, pendingWidth: null };
|
||||
startWidth.value = currentWidth + event.translationX;
|
||||
resizeWidth.value = currentWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (!resize.value) return;
|
||||
resizeWidth.value = resolveExplorerSidebarWidth({
|
||||
requestedWidth: startWidth.value - event.translationX,
|
||||
containerWidth,
|
||||
});
|
||||
})
|
||||
.onEnd(() => runOnJS(onResize)(resizeWidth.value))
|
||||
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
|
||||
.onEnd((_event, success) => {
|
||||
{
|
||||
resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, success);
|
||||
if (!success || !resize.value) return;
|
||||
}
|
||||
runOnJS(onResize)(resizeWidth.value);
|
||||
})
|
||||
.onFinalize((_event, success) => {
|
||||
if (!success) {
|
||||
resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, false);
|
||||
}
|
||||
scheduleOnRN(hideResizeGrip);
|
||||
}),
|
||||
[
|
||||
resizeEnabled,
|
||||
onResize,
|
||||
containerWidth,
|
||||
hideResizeGrip,
|
||||
overlay,
|
||||
resize,
|
||||
resizeWidth,
|
||||
showResizeGrip,
|
||||
startWidth,
|
||||
@@ -61,12 +101,21 @@ export function useExplorerGeometry({
|
||||
);
|
||||
// Reanimated retains the last animated value when its style is removed. Keep this
|
||||
// style attached across folding and explicitly size overlays to the workspace viewport.
|
||||
const animatedWidthStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
width: overlay ? containerWidth : resizeWidth.value,
|
||||
}),
|
||||
[containerWidth, overlay],
|
||||
);
|
||||
const animatedWidthStyle = useAnimatedStyle(() => {
|
||||
if (overlay) return { width: containerWidth };
|
||||
{
|
||||
return {
|
||||
width: resolveExplorerSidebarFrameWidth({
|
||||
width,
|
||||
containerWidth,
|
||||
overlay,
|
||||
resizeEnabled,
|
||||
resize: resize.value,
|
||||
resizeWidth: resizeWidth.value,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}, [containerWidth, overlay, resizeEnabled, width]);
|
||||
return {
|
||||
frameStyle: animatedWidthStyle,
|
||||
panelStyle: {
|
||||
|
||||
Reference in New Issue
Block a user