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 15:32:26 +08:00
committed by GitHub
parent 539d2969f1
commit ae5dfc0b3c
6 changed files with 181 additions and 24 deletions
+12 -4
View File
@@ -17,14 +17,18 @@
<title>%WEB_TITLE%</title> <title>%WEB_TITLE%</title>
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element --> <!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
<style id="expo-reset"> <style id="expo-reset">
/* These styles make the body full-height */ /* Keep the app shell fixed to the viewport. */
html, html,
body { body {
width: 100%;
height: 100%; height: 100%;
} margin: 0;
/* These styles disable body scrolling if you are using <ScrollView> */ padding: 0;
body {
overflow: hidden; overflow: hidden;
overscroll-behavior: none;
}
/* These styles smooth text rendering in the app shell. */
body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
@@ -38,8 +42,12 @@
/* These styles make the root element full-height */ /* These styles make the root element full-height */
#root { #root {
display: flex; display: flex;
width: 100%;
height: 100%; height: 100%;
flex: 1; flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
} }
</style> </style>
<style> <style>
@@ -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]);
});
});
@@ -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;
}
+64 -19
View File
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { View, type PointerEvent as RNPointerEvent } from "react-native"; import { View, type PointerEvent as RNPointerEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
export interface ResizeHandleProps { export interface ResizeHandleProps {
direction: "horizontal" | "vertical"; direction: "horizontal" | "vertical";
@@ -13,8 +14,14 @@ export interface ResizeHandleProps {
interface PointerState { interface PointerState {
containerSize: number; containerSize: number;
pointerStart: 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({ export function ResizeHandle({
@@ -25,7 +32,8 @@ export function ResizeHandle({
onResizeSplit, onResizeSplit,
}: ResizeHandleProps) { }: ResizeHandleProps) {
const { theme } = useUnistyles(); 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 hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
@@ -34,7 +42,11 @@ export function ResizeHandle({
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(event: RNPointerEvent) => { (event: RNPointerEvent) => {
const hitAreaElement = event.currentTarget as unknown as HTMLElement | null; 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) { if (!containerElement) {
return; return;
} }
@@ -45,51 +57,83 @@ export function ResizeHandle({
return; return;
} }
const pointerId = event.nativeEvent.pointerId;
if (pointerStatesRef.current.has(pointerId)) {
return;
}
setDragging(true); setDragging(true);
pointerStateRef.current = { pointerStatesRef.current.set(pointerId, {
containerSize, containerSize,
pointerStart: pointerStart:
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY, 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"; const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
document.body.style.cursor = nextCursor; document.body.style.cursor = nextCursor;
event.preventDefault(); event.preventDefault();
event.stopPropagation();
const pointerCaptureElement = hitAreaElement;
pointerCaptureElement.setPointerCapture?.(pointerId);
resetWindowHorizontalScroll();
function cleanup() { function cleanup() {
pointerStateRef.current = null; pointerStatesRef.current.delete(pointerId);
setDragging(false); setDragging(pointerStatesRef.current.size > 0);
document.body.style.cursor = previousCursor; 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("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
} }
function handlePointerMove(moveEvent: PointerEvent) { function handlePointerMove(moveEvent: PointerEvent) {
const pointerState = pointerStateRef.current; if (moveEvent.pointerId !== pointerId) {
return;
}
const pointerState = pointerStatesRef.current.get(pointerId);
if (!pointerState) { if (!pointerState) {
return; return;
} }
moveEvent.preventDefault();
resetWindowHorizontalScroll();
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY; const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio = const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize; (pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
const nextSizes = sizes.slice(); onResizeSplit(
nextSizes[index] = pointerState.leftSize + deltaRatio; groupId,
nextSizes[index + 1] = pointerState.rightSize - deltaRatio; computeResizeHandleSizes({
onResizeSplit(groupId, nextSizes); sizes,
index,
deltaRatio,
}),
);
} }
function handlePointerUp() { function handlePointerUp(upEvent: PointerEvent) {
if (upEvent.pointerId !== pointerId) {
return;
}
cleanup(); cleanup();
} }
window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true }); window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, },
[direction, groupId, index, onResizeSplit, sizes], [direction, groupId, index, onResizeSplit, sizes],
); );
@@ -130,6 +174,7 @@ export function ResizeHandle({
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical, direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{ {
cursor: direction === "horizontal" ? "col-resize" : "row-resize", cursor: direction === "horizontal" ? "col-resize" : "row-resize",
touchAction: "none",
} as object, } as object,
], ],
[direction], [direction],
@@ -1,5 +1,6 @@
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; 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 { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids"; import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
import { import {
@@ -208,7 +209,6 @@ export interface WorkspaceTabSnapshot {
} }
const DEFAULT_PANE_ID = "main"; const DEFAULT_PANE_ID = "main";
const MIN_SPLIT_SIZE = 0.1;
function trimNonEmpty(value: string | null | undefined): string | null { function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -0,0 +1 @@
export const MIN_SPLIT_SIZE = 0.1;