mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix split-pane right-edge resize clipping (#1261)
* fix(app): clamp split pane resize Prevent right-edge drags from overflowing the Electron viewport. * fix(app): isolate resize pointer state Keep concurrent resize drags from clobbering each other.
This commit is contained in:
committed by
GitHub
parent
539d2969f1
commit
ae5dfc0b3c
@@ -17,14 +17,18 @@
|
||||
<title>%WEB_TITLE%</title>
|
||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||
<style id="expo-reset">
|
||||
/* These styles make the body full-height */
|
||||
/* Keep the app shell fixed to the viewport. */
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
/* These styles smooth text rendering in the app shell. */
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -38,8 +42,12 @@
|
||||
/* These styles make the root element full-height */
|
||||
#root {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
|
||||
67
packages/app/src/components/resize-handle-sizes.test.ts
Normal file
67
packages/app/src/components/resize-handle-sizes.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
|
||||
describe("computeResizeHandleSizes", () => {
|
||||
it("clamps right-edge drags to the adjacent pane minimum", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: 0.5,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.65);
|
||||
expect(sizes[2]).toBeCloseTo(0.1, 10);
|
||||
});
|
||||
|
||||
it("clamps left-edge drags to the adjacent pane minimum", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: -0.5,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.1);
|
||||
expect(sizes[2]).toBeCloseTo(0.65, 10);
|
||||
});
|
||||
|
||||
it("moves adjacent pane sizes without clamping", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: 0.05,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.55);
|
||||
expect(sizes[2]).toBeCloseTo(0.2, 10);
|
||||
});
|
||||
|
||||
it("splits tiny adjacent pairs evenly when the configured minimum cannot fit", () => {
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.45, 0.05, 0.05, 0.45],
|
||||
index: 1,
|
||||
deltaRatio: 0.05,
|
||||
}),
|
||||
).toEqual([0.45, 0.05, 0.05, 0.45]);
|
||||
});
|
||||
|
||||
it("leaves sizes unchanged when the adjacent pair is invalid", () => {
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 3,
|
||||
deltaRatio: 0.25,
|
||||
}),
|
||||
).toEqual([0.25, 0.5, 0.25]);
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.25, 0, 0, 0.75],
|
||||
index: 1,
|
||||
deltaRatio: 0.25,
|
||||
}),
|
||||
).toEqual([0.25, 0, 0, 0.75]);
|
||||
});
|
||||
});
|
||||
36
packages/app/src/components/resize-handle-sizes.ts
Normal file
36
packages/app/src/components/resize-handle-sizes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
||||
|
||||
interface ComputeResizeHandleSizesInput {
|
||||
sizes: number[];
|
||||
index: number;
|
||||
deltaRatio: number;
|
||||
minSize?: number;
|
||||
}
|
||||
|
||||
export function computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
minSize = MIN_SPLIT_SIZE,
|
||||
}: ComputeResizeHandleSizesInput): number[] {
|
||||
const nextSizes = sizes.slice();
|
||||
const leftSize = sizes[index];
|
||||
const rightSize = sizes[index + 1];
|
||||
if (leftSize === undefined || rightSize === undefined) {
|
||||
return nextSizes;
|
||||
}
|
||||
|
||||
const pairSize = leftSize + rightSize;
|
||||
if (pairSize <= 0) {
|
||||
return nextSizes;
|
||||
}
|
||||
|
||||
const adjacentMinSize = Math.min(minSize, pairSize / 2);
|
||||
const nextLeftSize = Math.min(
|
||||
pairSize - adjacentMinSize,
|
||||
Math.max(adjacentMinSize, leftSize + deltaRatio),
|
||||
);
|
||||
nextSizes[index] = nextLeftSize;
|
||||
nextSizes[index + 1] = pairSize - nextLeftSize;
|
||||
return nextSizes;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, type PointerEvent as RNPointerEvent } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
|
||||
export interface ResizeHandleProps {
|
||||
direction: "horizontal" | "vertical";
|
||||
@@ -13,8 +14,14 @@ export interface ResizeHandleProps {
|
||||
interface PointerState {
|
||||
containerSize: number;
|
||||
pointerStart: number;
|
||||
leftSize: number;
|
||||
rightSize: number;
|
||||
}
|
||||
|
||||
function resetWindowHorizontalScroll() {
|
||||
// Clamp any browser scroll introduced while dragging past the viewport edge.
|
||||
if (window.scrollX === 0) {
|
||||
return;
|
||||
}
|
||||
window.scrollTo(0, window.scrollY);
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
@@ -25,7 +32,8 @@ export function ResizeHandle({
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pointerStateRef = useRef<PointerState | null>(null);
|
||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -34,7 +42,11 @@ export function ResizeHandle({
|
||||
const handlePointerDown = useCallback(
|
||||
(event: RNPointerEvent) => {
|
||||
const hitAreaElement = event.currentTarget as unknown as HTMLElement | null;
|
||||
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
|
||||
if (!hitAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
|
||||
if (!containerElement) {
|
||||
return;
|
||||
}
|
||||
@@ -45,51 +57,83 @@ export function ResizeHandle({
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerId = event.nativeEvent.pointerId;
|
||||
if (pointerStatesRef.current.has(pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragging(true);
|
||||
|
||||
pointerStateRef.current = {
|
||||
pointerStatesRef.current.set(pointerId, {
|
||||
containerSize,
|
||||
pointerStart:
|
||||
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
|
||||
leftSize: sizes[index] ?? 0,
|
||||
rightSize: sizes[index + 1] ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const previousCursor = document.body.style.cursor;
|
||||
if (pointerStatesRef.current.size === 1) {
|
||||
cursorBeforeDragRef.current = document.body.style.cursor;
|
||||
}
|
||||
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
|
||||
document.body.style.cursor = nextCursor;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const pointerCaptureElement = hitAreaElement;
|
||||
pointerCaptureElement.setPointerCapture?.(pointerId);
|
||||
resetWindowHorizontalScroll();
|
||||
|
||||
function cleanup() {
|
||||
pointerStateRef.current = null;
|
||||
setDragging(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
pointerStatesRef.current.delete(pointerId);
|
||||
setDragging(pointerStatesRef.current.size > 0);
|
||||
if (pointerStatesRef.current.size === 0) {
|
||||
document.body.style.cursor = cursorBeforeDragRef.current ?? "";
|
||||
cursorBeforeDragRef.current = null;
|
||||
}
|
||||
if (pointerCaptureElement.hasPointerCapture?.(pointerId)) {
|
||||
pointerCaptureElement.releasePointerCapture(pointerId);
|
||||
}
|
||||
resetWindowHorizontalScroll();
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
}
|
||||
|
||||
function handlePointerMove(moveEvent: PointerEvent) {
|
||||
const pointerState = pointerStateRef.current;
|
||||
if (moveEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerState = pointerStatesRef.current.get(pointerId);
|
||||
if (!pointerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveEvent.preventDefault();
|
||||
resetWindowHorizontalScroll();
|
||||
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
||||
const deltaRatio =
|
||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||
|
||||
const nextSizes = sizes.slice();
|
||||
nextSizes[index] = pointerState.leftSize + deltaRatio;
|
||||
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
|
||||
onResizeSplit(groupId, nextSizes);
|
||||
onResizeSplit(
|
||||
groupId,
|
||||
computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
function handlePointerUp(upEvent: PointerEvent) {
|
||||
if (upEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
},
|
||||
[direction, groupId, index, onResizeSplit, sizes],
|
||||
);
|
||||
@@ -130,6 +174,7 @@ export function ResizeHandle({
|
||||
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
|
||||
{
|
||||
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
|
||||
touchAction: "none",
|
||||
} as object,
|
||||
],
|
||||
[direction],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
||||
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
|
||||
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
|
||||
import {
|
||||
@@ -208,7 +209,6 @@ export interface WorkspaceTabSnapshot {
|
||||
}
|
||||
|
||||
const DEFAULT_PANE_ID = "main";
|
||||
const MIN_SPLIT_SIZE = 0.1;
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
|
||||
1
packages/app/src/stores/workspace-layout-constants.ts
Normal file
1
packages/app/src/stores/workspace-layout-constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MIN_SPLIT_SIZE = 0.1;
|
||||
Reference in New Issue
Block a user