Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BugFix: Resize handle hit detection respects stacking order #291

Merged
merged 3 commits into from
Feb 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions declaration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ declare module "*.module.css" {
const content: Record<string, string>;
export default content;
}

declare module "stacking-order" {
export function compare(a: Element, b: Element): number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type IconType =
| "close"
| "collapse"
| "css"
| "dialog"
| "expand"
| "files"
| "horizontal-collapse"
Expand Down Expand Up @@ -43,6 +44,10 @@ export default function Icon({
path =
"M5,3L4.35,6.34H17.94L17.5,8.5H3.92L3.26,11.83H16.85L16.09,15.64L10.61,17.45L5.86,15.64L6.19,14H2.85L2.06,18L9.91,21L18.96,18L20.16,11.97L20.4,10.76L21.94,3H5Z";
break;
case "dialog":
path =
"M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z";
break;
case "expand":
path =
"M10,21V19H6.41L10.91,14.5L9.5,13.09L5,17.59V14H3V21H10M14.5,10.91L19,6.41V10H21V3H14V5H17.59L13.09,9.5L14.5,10.91Z";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
left: 0;
width: 2rem;
height: 2rem;
border: 2px dotted #ffffffaa;
border: 2px dashed #ffffffaa;
border-radius: 1rem;
margin-left: -1rem;
margin-top: -1rem;
transition: background-color 250ms;
transition: border 250ms;
background-color: #00000044;
}
.VisibleCursor[data-state="down"] {
background-color: #ff000066;
border: 2px solid #ff0000;
}
.VisibleCursor[data-state="up"] {
background-color: #00000066;
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function EndToEndTesting() {
return urlToUrlData(url);
});

const [showModal, setShowModal] = useState(false);
const [panelId, setPanelId] = useState("");
const [panelIds, setPanelIds] = useState<string[]>([]);
const [panelGroupId, setPanelGroupId] = useState("");
Expand Down Expand Up @@ -214,6 +215,10 @@ function EndToEndTesting() {
}
};

const onToggleModalButtonClick = () => {
setShowModal(!showModal);
};

return (
<div className={styles.Container}>
<div className={styles.FormRow}>
Expand Down Expand Up @@ -258,6 +263,14 @@ function EndToEndTesting() {
<Icon type="resize" />
</button>
<div className={styles.Spacer} />
<button
id="toggleModalButton"
onClick={onToggleModalButtonClick}
title={showModal ? "Hide modal" : "Show modal"}
>
<Icon type="dialog" />
</button>
<div className={styles.Spacer} />
<select
className={styles.Input}
id="panelGroupIdSelect"
Expand Down Expand Up @@ -287,6 +300,16 @@ function EndToEndTesting() {
</div>
<DebugLog apiRef={debugLogRef} />
<div className={styles.Children}>{children}</div>
{showModal && <Modal />}
</div>
);
}

function Modal() {
return (
<div className={styles.Modal} data-test-id="ModalBox">
<p>Modal dialog</p>
<p>Block clicks events</p>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,11 @@

.PanelResizeHandle {
flex: 0 0 2px;
background: rgba(255, 255, 255, 0.25);
background: #ffffff55;
}
.PanelResizeHandle[data-resize-handle-state="drag"] {
background: #ff0000;
}
.PanelResizeHandle[data-resize-handle-state="hover"] {
background: #ffffffaa;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
height: 100vh;
display: flex;
flex-direction: column;
position: relative;
}

.FormRow {
Expand Down Expand Up @@ -32,3 +33,16 @@
.Spacer {
flex: 1;
}

.Modal {
position: absolute;
top: 50%;
left: 50%;
padding: 1rem 1.5rem;
transform: translate(-50%, -50%);
background-color: rgba(255, 255, 255, 0.5);
border-radius: 1rem;
border: 2px solid black;
user-select: none;
text-align: center;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Page, expect, test } from "@playwright/test";
import { createElement } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";

import { goToUrl } from "./utils/url";
import assert from "assert";
import { getBodyCursorStyle } from "./utils/cursor";

test.describe("stacking order", () => {
async function openPage(page: Page) {
await goToUrl(
page,
createElement(
PanelGroup,
{ direction: "horizontal" },
createElement(Panel, {
defaultSize: 50,
id: "left-panel",
minSize: 10,
}),
createElement(PanelResizeHandle),
createElement(Panel, {
defaultSize: 50,
id: "right-panel",
minSize: 10,
})
)
);
}

test("should not update cursor or start dragging if a resize handle is underneath another element", async ({
page,
}) => {
await openPage(page);

const toggleButton = page.locator("#toggleModalButton");
const modal = page.locator('[data-test-id="ModalBox"]');

// Show modal overlay
await toggleButton.click();
await expect(await modal.isHidden()).toBe(false);

const dragHandleRect = await modal.boundingBox();
assert(dragHandleRect);

const pageX = dragHandleRect.x + dragHandleRect.width / 2;
const pageY = dragHandleRect.y + dragHandleRect.height / 2;

{
page.mouse.move(pageX, pageY);

const actualCursor = await getBodyCursorStyle(page);
await expect(actualCursor).toBe("auto");
}

// Hide modal overlay
await toggleButton.click();
await expect(await modal.isHidden()).toBe(true);

page.mouse.move(0, 0);

{
page.mouse.move(pageX, pageY);

const actualCursor = await getBodyCursorStyle(page);
await expect(actualCursor).toBe("ew-resize");
}
});
});
15 changes: 9 additions & 6 deletions packages/react-resizable-panels-website/tests/utils/panels.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Locator, Page, expect } from "@playwright/test";
import { assert } from "react-resizable-panels";
import {
assert,
getIntersectingRectangle,
intersects,
} from "react-resizable-panels";
import { getBodyCursorStyle } from "./cursor";
import { verifyFuzzySizes } from "./verify";
import { getIntersectingRectangle, intersects } from "./rect";

type Operation = {
expectedCursor?: string;
Expand Down Expand Up @@ -59,9 +62,9 @@ export async function dragResizeIntersecting(
const rectOne = (await dragHandleOne.boundingBox())!;
const rectTwo = (await dragHandleTwo.boundingBox())!;

expect(intersects(rectOne, rectTwo)).toBe(true);
expect(intersects(rectOne, rectTwo, false)).toBe(true);

const rect = getIntersectingRectangle(rectOne, rectTwo);
const rect = getIntersectingRectangle(rectOne, rectTwo, false);
const centerPageX = rect.x + rect.width / 2;
const centerPageY = rect.y + rect.height / 2;

Expand Down Expand Up @@ -122,10 +125,10 @@ export async function dragResizeIntersecting(
await page.mouse.up();
await expect(
await dragHandleOne.getAttribute("data-resize-handle-state")
).toBe("inactive");
).toBe("hover");
await expect(
await dragHandleTwo.getAttribute("data-resize-handle-state")
).toBe("inactive");
).toBe("hover");
}

export async function dragResizeTo(
Expand Down
3 changes: 3 additions & 0 deletions packages/react-resizable-panels/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@
"test:watch": "jest --config=jest.config.js --watch",
"watch": "parcel watch --port=2345"
},
"dependencies": {
"stacking-order": "^1"
},
"devDependencies": {
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6",
"@babel/plugin-proposal-optional-chaining": "7.21.0",
Expand Down
83 changes: 75 additions & 8 deletions packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { compare } from "stacking-order";
import { Direction, ResizeEvent } from "./types";
import { resetGlobalCursorStyle, setGlobalCursorStyle } from "./utils/cursor";
import { getResizeEventCoordinates } from "./utils/events/getResizeEventCoordinates";
import { getInputType } from "./utils/getInputType";
import { intersects } from "./utils/rects/intersects";

export type ResizeHandlerAction = "down" | "move" | "up";
export type SetResizeHandlerState = (
Expand Down Expand Up @@ -75,11 +77,12 @@ export function registerResizeHandle(
}

function handlePointerDown(event: ResizeEvent) {
const { target } = event;
const { x, y } = getResizeEventCoordinates(event);

isPointerDown = true;

recalculateIntersectingHandles({ x, y });
recalculateIntersectingHandles({ target, x, y });
updateListeners();

if (intersectingHandles.length > 0) {
Expand All @@ -93,10 +96,12 @@ function handlePointerMove(event: ResizeEvent) {
const { x, y } = getResizeEventCoordinates(event);

if (!isPointerDown) {
const { target } = event;

// Recalculate intersecting handles whenever the pointer moves, except if it has already been pressed
// at that point, the handles may not move with the pointer (depending on constraints)
// but the same set of active handles should be locked until the pointer is released
recalculateIntersectingHandles({ x, y });
recalculateIntersectingHandles({ target, x, y });
}

updateResizeHandlerStates("move", event);
Expand All @@ -110,6 +115,7 @@ function handlePointerMove(event: ResizeEvent) {
}

function handlePointerUp(event: ResizeEvent) {
const { target } = event;
const { x, y } = getResizeEventCoordinates(event);

panelConstraintFlags.clear();
Expand All @@ -119,31 +125,92 @@ function handlePointerUp(event: ResizeEvent) {
event.preventDefault();
}

recalculateIntersectingHandles({ x, y });
updateResizeHandlerStates("up", event);
recalculateIntersectingHandles({ target, x, y });
updateCursor();

updateListeners();
}

function recalculateIntersectingHandles({ x, y }: { x: number; y: number }) {
function recalculateIntersectingHandles({
target,
x,
y,
}: {
target: EventTarget | null;
x: number;
y: number;
}) {
intersectingHandles.splice(0);

let targetElement: HTMLElement | null = null;
if (target instanceof HTMLElement) {
targetElement = target;
}

registeredResizeHandlers.forEach((data) => {
const { element, hitAreaMargins } = data;
const { bottom, left, right, top } = element.getBoundingClientRect();
const { element: dragHandleElement, hitAreaMargins } = data;

const dragHandleRect = dragHandleElement.getBoundingClientRect();
const { bottom, left, right, top } = dragHandleRect;

const margin = isCoarsePointer
? hitAreaMargins.coarse
: hitAreaMargins.fine;

const intersects =
const eventIntersects =
x >= left - margin &&
x <= right + margin &&
y >= top - margin &&
y <= bottom + margin;

if (intersects) {
if (eventIntersects) {
// TRICKY
// We listen for pointers events at the root in order to support hit area margins
// (determining when the pointer is close enough to an element to be considered a "hit")
// Clicking on an element "above" a handle (e.g. a modal) should prevent a hit though
// so at this point we need to compare stacking order of a potentially intersecting drag handle,
// and the element that was actually clicked/touched
if (
targetElement !== null &&
dragHandleElement !== targetElement &&
!dragHandleElement.contains(targetElement) &&
!targetElement.contains(dragHandleElement) &&
// Calculating stacking order has a cost, so we should avoid it if possible
// That is why we only check potentially intersecting handles,
// and why we skip if the event target is within the handle's DOM
compare(targetElement, dragHandleElement) > 0
) {
// If the target is above the drag handle, then we also need to confirm they overlap
// If they are beside each other (e.g. a panel and its drag handle) then the handle is still interactive
//
// It's not enough to compare only the target
// The target might be a small element inside of a larger container
// (For example, a SPAN or a DIV inside of a larger modal dialog)
let currentElement: HTMLElement | null = targetElement;
let didIntersect = false;
while (currentElement) {
if (currentElement.contains(dragHandleElement)) {
break;
} else if (
intersects(
currentElement.getBoundingClientRect(),
dragHandleRect,
true
)
) {
didIntersect = true;
break;
}

currentElement = currentElement.parentElement;
}

if (didIntersect) {
return;
}
}

intersectingHandles.push(data);
}
});
Expand Down
Loading
Loading