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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user