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:
Éverton Toffanetto
2026-06-01 04:32:26 -03:00
committed by GitHub
parent 539d2969f1
commit ae5dfc0b3c
6 changed files with 181 additions and 24 deletions

View File

@@ -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>

View 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]);
});
});

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

View File

@@ -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],

View File

@@ -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") {

View File

@@ -0,0 +1 @@
export const MIN_SPLIT_SIZE = 0.1;