react-resizable-panels
Version:
React components for resizable panel groups/layouts
324 lines (263 loc) • 9.51 kB
text/typescript
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";
import { compare } from "./vendor/stacking-order";
export type ResizeHandlerAction = "down" | "move" | "up";
export type SetResizeHandlerState = (
action: ResizeHandlerAction,
isActive: boolean,
event: ResizeEvent
) => void;
export type PointerHitAreaMargins = {
coarse: number;
fine: number;
};
export type ResizeHandlerData = {
direction: Direction;
element: HTMLElement;
hitAreaMargins: PointerHitAreaMargins;
setResizeHandlerState: SetResizeHandlerState;
};
export const EXCEEDED_HORIZONTAL_MIN = 0b0001;
export const EXCEEDED_HORIZONTAL_MAX = 0b0010;
export const EXCEEDED_VERTICAL_MIN = 0b0100;
export const EXCEEDED_VERTICAL_MAX = 0b1000;
const isCoarsePointer = getInputType() === "coarse";
let intersectingHandles: ResizeHandlerData[] = [];
let isPointerDown = false;
let ownerDocumentCounts: Map<Document, number> = new Map();
let panelConstraintFlags: Map<string, number> = new Map();
const registeredResizeHandlers = new Set<ResizeHandlerData>();
export function registerResizeHandle(
resizeHandleId: string,
element: HTMLElement,
direction: Direction,
hitAreaMargins: PointerHitAreaMargins,
setResizeHandlerState: SetResizeHandlerState
) {
const { ownerDocument } = element;
const data: ResizeHandlerData = {
direction,
element,
hitAreaMargins,
setResizeHandlerState,
};
const count = ownerDocumentCounts.get(ownerDocument) ?? 0;
ownerDocumentCounts.set(ownerDocument, count + 1);
registeredResizeHandlers.add(data);
updateListeners();
return function unregisterResizeHandle() {
panelConstraintFlags.delete(resizeHandleId);
registeredResizeHandlers.delete(data);
const count = ownerDocumentCounts.get(ownerDocument) ?? 1;
ownerDocumentCounts.set(ownerDocument, count - 1);
updateListeners();
if (count === 1) {
ownerDocumentCounts.delete(ownerDocument);
}
// If the resize handle that is currently unmounting is intersecting with the pointer,
// update the global pointer to account for the change
if (intersectingHandles.includes(data)) {
const index = intersectingHandles.indexOf(data);
if (index >= 0) {
intersectingHandles.splice(index, 1);
}
updateCursor();
}
};
}
function handlePointerDown(event: ResizeEvent) {
const { target } = event;
const { x, y } = getResizeEventCoordinates(event);
isPointerDown = true;
recalculateIntersectingHandles({ target, x, y });
updateListeners();
if (intersectingHandles.length > 0) {
updateResizeHandlerStates("down", event);
event.preventDefault();
event.stopPropagation();
}
}
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({ target, x, y });
}
updateResizeHandlerStates("move", event);
// Update cursor based on return value(s) from active handles
updateCursor();
if (intersectingHandles.length > 0) {
event.preventDefault();
}
}
function handlePointerUp(event: ResizeEvent) {
const { target } = event;
const { x, y } = getResizeEventCoordinates(event);
panelConstraintFlags.clear();
isPointerDown = false;
if (intersectingHandles.length > 0) {
event.preventDefault();
}
updateResizeHandlerStates("up", event);
recalculateIntersectingHandles({ target, x, y });
updateCursor();
updateListeners();
}
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: dragHandleElement, hitAreaMargins } = data;
const dragHandleRect = dragHandleElement.getBoundingClientRect();
const { bottom, left, right, top } = dragHandleRect;
const margin = isCoarsePointer
? hitAreaMargins.coarse
: hitAreaMargins.fine;
const eventIntersects =
x >= left - margin &&
x <= right + margin &&
y >= top - margin &&
y <= bottom + margin;
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);
}
});
}
export function reportConstraintsViolation(
resizeHandleId: string,
flag: number
) {
panelConstraintFlags.set(resizeHandleId, flag);
}
function updateCursor() {
let intersectsHorizontal = false;
let intersectsVertical = false;
intersectingHandles.forEach((data) => {
const { direction } = data;
if (direction === "horizontal") {
intersectsHorizontal = true;
} else {
intersectsVertical = true;
}
});
let constraintFlags = 0;
panelConstraintFlags.forEach((flag) => {
constraintFlags |= flag;
});
if (intersectsHorizontal && intersectsVertical) {
setGlobalCursorStyle("intersection", constraintFlags);
} else if (intersectsHorizontal) {
setGlobalCursorStyle("horizontal", constraintFlags);
} else if (intersectsVertical) {
setGlobalCursorStyle("vertical", constraintFlags);
} else {
resetGlobalCursorStyle();
}
}
function updateListeners() {
ownerDocumentCounts.forEach((_, ownerDocument) => {
const { body } = ownerDocument;
body.removeEventListener("contextmenu", handlePointerUp);
body.removeEventListener("pointerdown", handlePointerDown);
body.removeEventListener("pointerleave", handlePointerMove);
body.removeEventListener("pointermove", handlePointerMove);
});
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
if (registeredResizeHandlers.size > 0) {
if (isPointerDown) {
if (intersectingHandles.length > 0) {
ownerDocumentCounts.forEach((count, ownerDocument) => {
const { body } = ownerDocument;
if (count > 0) {
body.addEventListener("contextmenu", handlePointerUp);
body.addEventListener("pointerleave", handlePointerMove);
body.addEventListener("pointermove", handlePointerMove);
}
});
}
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
} else {
ownerDocumentCounts.forEach((count, ownerDocument) => {
const { body } = ownerDocument;
if (count > 0) {
body.addEventListener("pointerdown", handlePointerDown, {
capture: true,
});
body.addEventListener("pointermove", handlePointerMove);
}
});
}
}
}
function updateResizeHandlerStates(
action: ResizeHandlerAction,
event: ResizeEvent
) {
registeredResizeHandlers.forEach((data) => {
const { setResizeHandlerState } = data;
const isActive = intersectingHandles.includes(data);
setResizeHandlerState(action, isActive, event);
});
}