react-resizable-panels
Version:
React components for resizable panel groups/layouts
1,531 lines (1,441 loc) • 76.3 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var React = require('react');
function _interopNamespace(e) {
if (e && e.__esModule) return e;
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n["default"] = e;
return Object.freeze(n);
}
var React__namespace = /*#__PURE__*/_interopNamespace(React);
// This module exists to work around Webpack issue https://github.com/webpack/webpack/issues/14814
// eslint-disable-next-line no-restricted-imports
const {
createElement,
createContext,
createRef,
forwardRef,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState
} = React__namespace;
// `toString()` prevents bundlers from trying to `import { useId } from 'react'`
const useId = React__namespace["useId".toString()];
// The "contextmenu" event is not supported as a PointerEvent in all browsers yet, so MouseEvent still need to be handled
const PanelGroupContext = createContext(null);
PanelGroupContext.displayName = "PanelGroupContext";
const wrappedUseId = typeof useId === "function" ? useId : () => null;
let counter = 0;
function useUniqueId(idFromParams = null) {
const idFromUseId = wrappedUseId();
const idRef = useRef(idFromParams || idFromUseId || null);
if (idRef.current === null) {
idRef.current = "" + counter++;
}
return idFromParams !== null && idFromParams !== void 0 ? idFromParams : idRef.current;
}
function PanelWithForwardedRef({
children,
className: classNameFromProps = "",
collapsedSize,
collapsible,
defaultSize,
forwardedRef,
id: idFromProps,
maxSize,
minSize,
onCollapse,
onExpand,
onResize,
order,
style: styleFromProps,
tagName: Type = "div",
...rest
}) {
const context = useContext(PanelGroupContext);
if (context === null) {
throw Error(`Panel components must be rendered within a PanelGroup container`);
}
const {
collapsePanel,
expandPanel,
getPanelSize,
getPanelStyle,
groupId,
isPanelCollapsed,
reevaluatePanelConstraints,
registerPanel,
resizePanel,
unregisterPanel
} = context;
const panelId = useUniqueId(idFromProps);
const panelDataRef = useRef({
callbacks: {
onCollapse,
onExpand,
onResize
},
constraints: {
collapsedSize,
collapsible,
defaultSize,
maxSize,
minSize
},
id: panelId,
idIsFromProps: idFromProps !== undefined,
order
});
const devWarningsRef = useRef({
didLogMissingDefaultSizeWarning: false
});
// Normally we wouldn't log a warning during render,
// but effects don't run on the server, so we can't do it there
{
if (!devWarningsRef.current.didLogMissingDefaultSizeWarning) {
if (defaultSize == null) {
devWarningsRef.current.didLogMissingDefaultSizeWarning = true;
console.warn(`WARNING: Panel defaultSize prop recommended to avoid layout shift after server rendering`);
}
}
}
useImperativeHandle(forwardedRef, () => ({
collapse: () => {
collapsePanel(panelDataRef.current);
},
expand: minSize => {
expandPanel(panelDataRef.current, minSize);
},
getId() {
return panelId;
},
getSize() {
return getPanelSize(panelDataRef.current);
},
isCollapsed() {
return isPanelCollapsed(panelDataRef.current);
},
isExpanded() {
return !isPanelCollapsed(panelDataRef.current);
},
resize: size => {
resizePanel(panelDataRef.current, size);
}
}), [collapsePanel, expandPanel, getPanelSize, isPanelCollapsed, panelId, resizePanel]);
const style = getPanelStyle(panelDataRef.current, defaultSize);
return createElement(Type, {
...rest,
children,
className: classNameFromProps,
id: idFromProps,
style: {
...style,
...styleFromProps
},
// CSS selectors
"data-panel": "",
"data-panel-collapsible": collapsible || undefined,
"data-panel-group-id": groupId,
"data-panel-id": panelId,
"data-panel-size": parseFloat("" + style.flexGrow).toFixed(1)
});
}
const Panel = forwardRef((props, ref) => createElement(PanelWithForwardedRef, {
...props,
forwardedRef: ref
}));
PanelWithForwardedRef.displayName = "Panel";
Panel.displayName = "forwardRef(Panel)";
let currentCursorStyle = null;
let styleElement = null;
function getCursorStyle(state, constraintFlags) {
if (constraintFlags) {
const horizontalMin = (constraintFlags & EXCEEDED_HORIZONTAL_MIN) !== 0;
const horizontalMax = (constraintFlags & EXCEEDED_HORIZONTAL_MAX) !== 0;
const verticalMin = (constraintFlags & EXCEEDED_VERTICAL_MIN) !== 0;
const verticalMax = (constraintFlags & EXCEEDED_VERTICAL_MAX) !== 0;
if (horizontalMin) {
if (verticalMin) {
return "se-resize";
} else if (verticalMax) {
return "ne-resize";
} else {
return "e-resize";
}
} else if (horizontalMax) {
if (verticalMin) {
return "sw-resize";
} else if (verticalMax) {
return "nw-resize";
} else {
return "w-resize";
}
} else if (verticalMin) {
return "s-resize";
} else if (verticalMax) {
return "n-resize";
}
}
switch (state) {
case "horizontal":
return "ew-resize";
case "intersection":
return "move";
case "vertical":
return "ns-resize";
}
}
function resetGlobalCursorStyle() {
if (styleElement !== null) {
document.head.removeChild(styleElement);
currentCursorStyle = null;
styleElement = null;
}
}
function setGlobalCursorStyle(state, constraintFlags) {
const style = getCursorStyle(state, constraintFlags);
if (currentCursorStyle === style) {
return;
}
currentCursorStyle = style;
if (styleElement === null) {
styleElement = document.createElement("style");
document.head.appendChild(styleElement);
}
styleElement.innerHTML = `*{cursor: ${style}!important;}`;
}
function isKeyDown(event) {
return event.type === "keydown";
}
function isPointerEvent(event) {
return event.type.startsWith("pointer");
}
function isMouseEvent(event) {
return event.type.startsWith("mouse");
}
function getResizeEventCoordinates(event) {
if (isPointerEvent(event)) {
if (event.isPrimary) {
return {
x: event.clientX,
y: event.clientY
};
}
} else if (isMouseEvent(event)) {
return {
x: event.clientX,
y: event.clientY
};
}
return {
x: Infinity,
y: Infinity
};
}
function getInputType() {
if (typeof matchMedia === "function") {
return matchMedia("(pointer:coarse)").matches ? "coarse" : "fine";
}
}
function intersects(rectOne, rectTwo, strict) {
if (strict) {
return rectOne.x < rectTwo.x + rectTwo.width && rectOne.x + rectOne.width > rectTwo.x && rectOne.y < rectTwo.y + rectTwo.height && rectOne.y + rectOne.height > rectTwo.y;
} else {
return rectOne.x <= rectTwo.x + rectTwo.width && rectOne.x + rectOne.width >= rectTwo.x && rectOne.y <= rectTwo.y + rectTwo.height && rectOne.y + rectOne.height >= rectTwo.y;
}
}
// Forked from NPM stacking-order@2.0.0
/**
* Determine which of two nodes appears in front of the other —
* if `a` is in front, returns 1, otherwise returns -1
* @param {HTMLElement} a
* @param {HTMLElement} b
*/
function compare(a, b) {
if (a === b) throw new Error("Cannot compare node with itself");
const ancestors = {
a: get_ancestors(a),
b: get_ancestors(b)
};
let common_ancestor;
// remove shared ancestors
while (ancestors.a.at(-1) === ancestors.b.at(-1)) {
a = ancestors.a.pop();
b = ancestors.b.pop();
common_ancestor = a;
}
assert(common_ancestor, "Stacking order can only be calculated for elements with a common ancestor");
const z_indexes = {
a: get_z_index(find_stacking_context(ancestors.a)),
b: get_z_index(find_stacking_context(ancestors.b))
};
if (z_indexes.a === z_indexes.b) {
const children = common_ancestor.childNodes;
const furthest_ancestors = {
a: ancestors.a.at(-1),
b: ancestors.b.at(-1)
};
let i = children.length;
while (i--) {
const child = children[i];
if (child === furthest_ancestors.a) return 1;
if (child === furthest_ancestors.b) return -1;
}
}
return Math.sign(z_indexes.a - z_indexes.b);
}
const props = /\b(?:position|zIndex|opacity|transform|webkitTransform|mixBlendMode|filter|webkitFilter|isolation)\b/;
/** @param {HTMLElement} node */
function is_flex_item(node) {
var _get_parent;
// @ts-ignore
const display = getComputedStyle((_get_parent = get_parent(node)) !== null && _get_parent !== void 0 ? _get_parent : node).display;
return display === "flex" || display === "inline-flex";
}
/** @param {HTMLElement} node */
function creates_stacking_context(node) {
const style = getComputedStyle(node);
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context
if (style.position === "fixed") return true;
// Forked to fix upstream bug https://github.com/Rich-Harris/stacking-order/issues/3
// if (
// (style.zIndex !== "auto" && style.position !== "static") ||
// is_flex_item(node)
// )
if (style.zIndex !== "auto" && (style.position !== "static" || is_flex_item(node))) return true;
if (+style.opacity < 1) return true;
if ("transform" in style && style.transform !== "none") return true;
if ("webkitTransform" in style && style.webkitTransform !== "none") return true;
if ("mixBlendMode" in style && style.mixBlendMode !== "normal") return true;
if ("filter" in style && style.filter !== "none") return true;
if ("webkitFilter" in style && style.webkitFilter !== "none") return true;
if ("isolation" in style && style.isolation === "isolate") return true;
if (props.test(style.willChange)) return true;
// @ts-expect-error
if (style.webkitOverflowScrolling === "touch") return true;
return false;
}
/** @param {HTMLElement[]} nodes */
function find_stacking_context(nodes) {
let i = nodes.length;
while (i--) {
const node = nodes[i];
assert(node, "Missing node");
if (creates_stacking_context(node)) return node;
}
return null;
}
/** @param {HTMLElement} node */
function get_z_index(node) {
return node && Number(getComputedStyle(node).zIndex) || 0;
}
/** @param {HTMLElement} node */
function get_ancestors(node) {
const ancestors = [];
while (node) {
ancestors.push(node);
// @ts-ignore
node = get_parent(node);
}
return ancestors; // [ node, ... <body>, <html>, document ]
}
/** @param {HTMLElement} node */
function get_parent(node) {
const {
parentNode
} = node;
if (parentNode && parentNode instanceof ShadowRoot) {
return parentNode.host;
}
return parentNode;
}
const EXCEEDED_HORIZONTAL_MIN = 0b0001;
const EXCEEDED_HORIZONTAL_MAX = 0b0010;
const EXCEEDED_VERTICAL_MIN = 0b0100;
const EXCEEDED_VERTICAL_MAX = 0b1000;
const isCoarsePointer = getInputType() === "coarse";
let intersectingHandles = [];
let isPointerDown = false;
let ownerDocumentCounts = new Map();
let panelConstraintFlags = new Map();
const registeredResizeHandlers = new Set();
function registerResizeHandle(resizeHandleId, element, direction, hitAreaMargins, setResizeHandlerState) {
var _ownerDocumentCounts$;
const {
ownerDocument
} = element;
const data = {
direction,
element,
hitAreaMargins,
setResizeHandlerState
};
const count = (_ownerDocumentCounts$ = ownerDocumentCounts.get(ownerDocument)) !== null && _ownerDocumentCounts$ !== void 0 ? _ownerDocumentCounts$ : 0;
ownerDocumentCounts.set(ownerDocument, count + 1);
registeredResizeHandlers.add(data);
updateListeners();
return function unregisterResizeHandle() {
var _ownerDocumentCounts$2;
panelConstraintFlags.delete(resizeHandleId);
registeredResizeHandlers.delete(data);
const count = (_ownerDocumentCounts$2 = ownerDocumentCounts.get(ownerDocument)) !== null && _ownerDocumentCounts$2 !== void 0 ? _ownerDocumentCounts$2 : 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) {
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) {
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) {
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
}) {
intersectingHandles.splice(0);
let targetElement = 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 = 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);
}
});
}
function reportConstraintsViolation(resizeHandleId, flag) {
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, event) {
registeredResizeHandlers.forEach(data => {
const {
setResizeHandlerState
} = data;
const isActive = intersectingHandles.includes(data);
setResizeHandlerState(action, isActive, event);
});
}
function assert(expectedCondition, message) {
if (!expectedCondition) {
console.error(message);
throw Error(message);
}
}
const PRECISION = 10;
function fuzzyCompareNumbers(actual, expected, fractionDigits = PRECISION) {
if (actual.toFixed(fractionDigits) === expected.toFixed(fractionDigits)) {
return 0;
} else {
return actual > expected ? 1 : -1;
}
}
function fuzzyNumbersEqual$1(actual, expected, fractionDigits = PRECISION) {
return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0;
}
function fuzzyNumbersEqual(actual, expected, fractionDigits) {
return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0;
}
function fuzzyLayoutsEqual(actual, expected, fractionDigits) {
if (actual.length !== expected.length) {
return false;
}
for (let index = 0; index < actual.length; index++) {
const actualSize = actual[index];
const expectedSize = expected[index];
if (!fuzzyNumbersEqual(actualSize, expectedSize, fractionDigits)) {
return false;
}
}
return true;
}
// Panel size must be in percentages; pixel values should be pre-converted
function resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex,
size
}) {
const panelConstraints = panelConstraintsArray[panelIndex];
assert(panelConstraints != null, `Panel constraints not found for index ${panelIndex}`);
let {
collapsedSize = 0,
collapsible,
maxSize = 100,
minSize = 0
} = panelConstraints;
if (fuzzyCompareNumbers(size, minSize) < 0) {
if (collapsible) {
// Collapsible panels should snap closed or open only once they cross the halfway point between collapsed and min size.
const halfwayPoint = (collapsedSize + minSize) / 2;
if (fuzzyCompareNumbers(size, halfwayPoint) < 0) {
size = collapsedSize;
} else {
size = minSize;
}
} else {
size = minSize;
}
}
size = Math.min(maxSize, size);
size = parseFloat(size.toFixed(PRECISION));
return size;
}
// All units must be in percentages; pixel values should be pre-converted
function adjustLayoutByDelta({
delta,
initialLayout,
panelConstraints: panelConstraintsArray,
pivotIndices,
prevLayout,
trigger
}) {
if (fuzzyNumbersEqual(delta, 0)) {
return initialLayout;
}
const nextLayout = [...initialLayout];
const [firstPivotIndex, secondPivotIndex] = pivotIndices;
assert(firstPivotIndex != null, "Invalid first pivot index");
assert(secondPivotIndex != null, "Invalid second pivot index");
let deltaApplied = 0;
// const DEBUG = [];
// DEBUG.push(`adjustLayoutByDelta()`);
// DEBUG.push(` initialLayout: ${initialLayout.join(", ")}`);
// DEBUG.push(` prevLayout: ${prevLayout.join(", ")}`);
// DEBUG.push(` delta: ${delta}`);
// DEBUG.push(` pivotIndices: ${pivotIndices.join(", ")}`);
// DEBUG.push(` trigger: ${trigger}`);
// DEBUG.push("");
// A resizing panel affects the panels before or after it.
//
// A negative delta means the panel(s) immediately after the resize handle should grow/expand by decreasing its offset.
// Other panels may also need to shrink/contract (and shift) to make room, depending on the min weights.
//
// A positive delta means the panel(s) immediately before the resize handle should "expand".
// This is accomplished by shrinking/contracting (and shifting) one or more of the panels after the resize handle.
{
// If this is a resize triggered by a keyboard event, our logic for expanding/collapsing is different.
// We no longer check the halfway threshold because this may prevent the panel from expanding at all.
if (trigger === "keyboard") {
{
// Check if we should expand a collapsed panel
const index = delta < 0 ? secondPivotIndex : firstPivotIndex;
const panelConstraints = panelConstraintsArray[index];
assert(panelConstraints, `Panel constraints not found for index ${index}`);
const {
collapsedSize = 0,
collapsible,
minSize = 0
} = panelConstraints;
// DEBUG.push(`edge case check 1: ${index}`);
// DEBUG.push(` -> collapsible? ${collapsible}`);
if (collapsible) {
const prevSize = initialLayout[index];
assert(prevSize != null, `Previous layout not found for panel index ${index}`);
if (fuzzyNumbersEqual(prevSize, collapsedSize)) {
const localDelta = minSize - prevSize;
// DEBUG.push(` -> expand delta: ${localDelta}`);
if (fuzzyCompareNumbers(localDelta, Math.abs(delta)) > 0) {
delta = delta < 0 ? 0 - localDelta : localDelta;
// DEBUG.push(` -> delta: ${delta}`);
}
}
}
}
{
// Check if we should collapse a panel at its minimum size
const index = delta < 0 ? firstPivotIndex : secondPivotIndex;
const panelConstraints = panelConstraintsArray[index];
assert(panelConstraints, `No panel constraints found for index ${index}`);
const {
collapsedSize = 0,
collapsible,
minSize = 0
} = panelConstraints;
// DEBUG.push(`edge case check 2: ${index}`);
// DEBUG.push(` -> collapsible? ${collapsible}`);
if (collapsible) {
const prevSize = initialLayout[index];
assert(prevSize != null, `Previous layout not found for panel index ${index}`);
if (fuzzyNumbersEqual(prevSize, minSize)) {
const localDelta = prevSize - collapsedSize;
// DEBUG.push(` -> expand delta: ${localDelta}`);
if (fuzzyCompareNumbers(localDelta, Math.abs(delta)) > 0) {
delta = delta < 0 ? 0 - localDelta : localDelta;
// DEBUG.push(` -> delta: ${delta}`);
}
}
}
}
}
// DEBUG.push("");
}
{
// Pre-calculate max available delta in the opposite direction of our pivot.
// This will be the maximum amount we're allowed to expand/contract the panels in the primary direction.
// If this amount is less than the requested delta, adjust the requested delta.
// If this amount is greater than the requested delta, that's useful information too–
// as an expanding panel might change from collapsed to min size.
const increment = delta < 0 ? 1 : -1;
let index = delta < 0 ? secondPivotIndex : firstPivotIndex;
let maxAvailableDelta = 0;
// DEBUG.push("pre calc...");
while (true) {
const prevSize = initialLayout[index];
assert(prevSize != null, `Previous layout not found for panel index ${index}`);
const maxSafeSize = resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex: index,
size: 100
});
const delta = maxSafeSize - prevSize;
// DEBUG.push(` ${index}: ${prevSize} -> ${maxSafeSize}`);
maxAvailableDelta += delta;
index += increment;
if (index < 0 || index >= panelConstraintsArray.length) {
break;
}
}
// DEBUG.push(` -> max available delta: ${maxAvailableDelta}`);
const minAbsDelta = Math.min(Math.abs(delta), Math.abs(maxAvailableDelta));
delta = delta < 0 ? 0 - minAbsDelta : minAbsDelta;
// DEBUG.push(` -> adjusted delta: ${delta}`);
// DEBUG.push("");
}
{
// Delta added to a panel needs to be subtracted from other panels (within the constraints that those panels allow).
const pivotIndex = delta < 0 ? firstPivotIndex : secondPivotIndex;
let index = pivotIndex;
while (index >= 0 && index < panelConstraintsArray.length) {
const deltaRemaining = Math.abs(delta) - Math.abs(deltaApplied);
const prevSize = initialLayout[index];
assert(prevSize != null, `Previous layout not found for panel index ${index}`);
const unsafeSize = prevSize - deltaRemaining;
const safeSize = resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex: index,
size: unsafeSize
});
if (!fuzzyNumbersEqual(prevSize, safeSize)) {
deltaApplied += prevSize - safeSize;
nextLayout[index] = safeSize;
if (deltaApplied.toPrecision(3).localeCompare(Math.abs(delta).toPrecision(3), undefined, {
numeric: true
}) >= 0) {
break;
}
}
if (delta < 0) {
index--;
} else {
index++;
}
}
}
// DEBUG.push(`after 1: ${nextLayout.join(", ")}`);
// DEBUG.push(` deltaApplied: ${deltaApplied}`);
// DEBUG.push("");
// If we were unable to resize any of the panels panels, return the previous state.
// This will essentially bailout and ignore e.g. drags past a panel's boundaries
if (fuzzyLayoutsEqual(prevLayout, nextLayout)) {
// DEBUG.push(`bailout to previous layout: ${prevLayout.join(", ")}`);
// console.log(DEBUG.join("\n"));
return prevLayout;
}
{
// Now distribute the applied delta to the panels in the other direction
const pivotIndex = delta < 0 ? secondPivotIndex : firstPivotIndex;
const prevSize = initialLayout[pivotIndex];
assert(prevSize != null, `Previous layout not found for panel index ${pivotIndex}`);
const unsafeSize = prevSize + deltaApplied;
const safeSize = resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex: pivotIndex,
size: unsafeSize
});
// Adjust the pivot panel before, but only by the amount that surrounding panels were able to shrink/contract.
nextLayout[pivotIndex] = safeSize;
// Edge case where expanding or contracting one panel caused another one to change collapsed state
if (!fuzzyNumbersEqual(safeSize, unsafeSize)) {
let deltaRemaining = unsafeSize - safeSize;
const pivotIndex = delta < 0 ? secondPivotIndex : firstPivotIndex;
let index = pivotIndex;
while (index >= 0 && index < panelConstraintsArray.length) {
const prevSize = nextLayout[index];
assert(prevSize != null, `Previous layout not found for panel index ${index}`);
const unsafeSize = prevSize + deltaRemaining;
const safeSize = resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex: index,
size: unsafeSize
});
if (!fuzzyNumbersEqual(prevSize, safeSize)) {
deltaRemaining -= safeSize - prevSize;
nextLayout[index] = safeSize;
}
if (fuzzyNumbersEqual(deltaRemaining, 0)) {
break;
}
if (delta > 0) {
index--;
} else {
index++;
}
}
}
}
// DEBUG.push(`after 2: ${nextLayout.join(", ")}`);
// DEBUG.push(` deltaApplied: ${deltaApplied}`);
// DEBUG.push("");
const totalSize = nextLayout.reduce((total, size) => size + total, 0);
// DEBUG.push(`total size: ${totalSize}`);
// If our new layout doesn't add up to 100%, that means the requested delta can't be applied
// In that case, fall back to our most recent valid layout
if (!fuzzyNumbersEqual(totalSize, 100)) {
// DEBUG.push(`bailout to previous layout: ${prevLayout.join(", ")}`);
// console.log(DEBUG.join("\n"));
return prevLayout;
}
// console.log(DEBUG.join("\n"));
return nextLayout;
}
function getResizeHandleElementsForGroup(groupId, scope = document) {
return Array.from(scope.querySelectorAll(`[data-panel-resize-handle-id][data-panel-group-id="${groupId}"]`));
}
function getResizeHandleElementIndex(groupId, id, scope = document) {
const handles = getResizeHandleElementsForGroup(groupId, scope);
const index = handles.findIndex(handle => handle.getAttribute("data-panel-resize-handle-id") === id);
return index !== null && index !== void 0 ? index : null;
}
function determinePivotIndices(groupId, dragHandleId, panelGroupElement) {
const index = getResizeHandleElementIndex(groupId, dragHandleId, panelGroupElement);
return index != null ? [index, index + 1] : [-1, -1];
}
function getPanelGroupElement(id, rootElement = document) {
var _dataset;
//If the root element is the PanelGroup
if (rootElement instanceof HTMLElement && (rootElement === null || rootElement === void 0 ? void 0 : (_dataset = rootElement.dataset) === null || _dataset === void 0 ? void 0 : _dataset.panelGroupId) == id) {
return rootElement;
}
//Else query children
const element = rootElement.querySelector(`[data-panel-group][data-panel-group-id="${id}"]`);
if (element) {
return element;
}
return null;
}
function getResizeHandleElement(id, scope = document) {
const element = scope.querySelector(`[data-panel-resize-handle-id="${id}"]`);
if (element) {
return element;
}
return null;
}
function getResizeHandlePanelIds(groupId, handleId, panelsArray, scope = document) {
var _panelsArray$index$id, _panelsArray$index, _panelsArray$id, _panelsArray;
const handle = getResizeHandleElement(handleId, scope);
const handles = getResizeHandleElementsForGroup(groupId, scope);
const index = handle ? handles.indexOf(handle) : -1;
const idBefore = (_panelsArray$index$id = (_panelsArray$index = panelsArray[index]) === null || _panelsArray$index === void 0 ? void 0 : _panelsArray$index.id) !== null && _panelsArray$index$id !== void 0 ? _panelsArray$index$id : null;
const idAfter = (_panelsArray$id = (_panelsArray = panelsArray[index + 1]) === null || _panelsArray === void 0 ? void 0 : _panelsArray.id) !== null && _panelsArray$id !== void 0 ? _panelsArray$id : null;
return [idBefore, idAfter];
}
// https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/
function useWindowSplitterPanelGroupBehavior({
committedValuesRef,
eagerValuesRef,
groupId,
layout,
panelDataArray,
panelGroupElement,
setLayout
}) {
useRef({
didWarnAboutMissingResizeHandle: false
});
useEffect(() => {
if (!panelGroupElement) {
return;
}
const eagerValues = eagerValuesRef.current;
assert(eagerValues, `Eager values not found`);
const {
panelDataArray
} = eagerValues;
const groupElement = getPanelGroupElement(groupId, panelGroupElement);
assert(groupElement != null, `No group found for id "${groupId}"`);
const handles = getResizeHandleElementsForGroup(groupId, panelGroupElement);
assert(handles, `No resize handles found for group id "${groupId}"`);
const cleanupFunctions = handles.map(handle => {
const handleId = handle.getAttribute("data-panel-resize-handle-id");
assert(handleId, `Resize handle element has no handle id attribute`);
const [idBefore, idAfter] = getResizeHandlePanelIds(groupId, handleId, panelDataArray, panelGroupElement);
if (idBefore == null || idAfter == null) {
return () => {};
}
const onKeyDown = event => {
if (event.defaultPrevented) {
return;
}
switch (event.key) {
case "Enter":
{
event.preventDefault();
const index = panelDataArray.findIndex(panelData => panelData.id === idBefore);
if (index >= 0) {
const panelData = panelDataArray[index];
assert(panelData, `No panel data found for index ${index}`);
const size = layout[index];
const {
collapsedSize = 0,
collapsible,
minSize = 0
} = panelData.constraints;
if (size != null && collapsible) {
const nextLayout = adjustLayoutByDelta({
delta: fuzzyNumbersEqual(size, collapsedSize) ? minSize - collapsedSize : collapsedSize - size,
initialLayout: layout,
panelConstraints: panelDataArray.map(panelData => panelData.constraints),
pivotIndices: determinePivotIndices(groupId, handleId, panelGroupElement),
prevLayout: layout,
trigger: "keyboard"
});
if (layout !== nextLayout) {
setLayout(nextLayout);
}
}
}
break;
}
}
};
handle.addEventListener("keydown", onKeyDown);
return () => {
handle.removeEventListener("keydown", onKeyDown);
};
});
return () => {
cleanupFunctions.forEach(cleanupFunction => cleanupFunction());
};
}, [panelGroupElement, committedValuesRef, eagerValuesRef, groupId, layout, panelDataArray, setLayout]);
}
function areEqual(arrayA, arrayB) {
if (arrayA.length !== arrayB.length) {
return false;
}
for (let index = 0; index < arrayA.length; index++) {
if (arrayA[index] !== arrayB[index]) {
return false;
}
}
return true;
}
function getResizeEventCursorPosition(direction, event) {
const isHorizontal = direction === "horizontal";
const {
x,
y
} = getResizeEventCoordinates(event);
return isHorizontal ? x : y;
}
function calculateDragOffsetPercentage(event, dragHandleId, direction, initialDragState, panelGroupElement) {
const isHorizontal = direction === "horizontal";
const handleElement = getResizeHandleElement(dragHandleId, panelGroupElement);
assert(handleElement, `No resize handle element found for id "${dragHandleId}"`);
const groupId = handleElement.getAttribute("data-panel-group-id");
assert(groupId, `Resize handle element has no group id attribute`);
let {
initialCursorPosition
} = initialDragState;
const cursorPosition = getResizeEventCursorPosition(direction, event);
const groupElement = getPanelGroupElement(groupId, panelGroupElement);
assert(groupElement, `No group element found for id "${groupId}"`);
const groupRect = groupElement.getBoundingClientRect();
const groupSizeInPixels = isHorizontal ? groupRect.width : groupRect.height;
const offsetPixels = cursorPosition - initialCursorPosition;
const offsetPercentage = offsetPixels / groupSizeInPixels * 100;
return offsetPercentage;
}
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX
function calculateDeltaPercentage(event, dragHandleId, direction, initialDragState, keyboardResizeBy, panelGroupElement) {
if (isKeyDown(event)) {
const isHorizontal = direction === "horizontal";
let delta = 0;
if (event.shiftKey) {
delta = 100;
} else if (keyboardResizeBy != null) {
delta = keyboardResizeBy;
} else {
delta = 10;
}
let movement = 0;
switch (event.key) {
case "ArrowDown":
movement = isHorizontal ? 0 : delta;
break;
case "ArrowLeft":
movement = isHorizontal ? -delta : 0;
break;
case "ArrowRight":
movement = isHorizontal ? delta : 0;
break;
case "ArrowUp":
movement = isHorizontal ? 0 : -delta;
break;
case "End":
movement = 100;
break;
case "Home":
movement = -100;
break;
}
return movement;
} else {
if (initialDragState == null) {
return 0;
}
return calculateDragOffsetPercentage(event, dragHandleId, direction, initialDragState, panelGroupElement);
}
}
// Layout should be pre-converted into percentages
function callPanelCallbacks(panelsArray, layout, panelIdToLastNotifiedSizeMap) {
layout.forEach((size, index) => {
const panelData = panelsArray[index];
assert(panelData, `Panel data not found for index ${index}`);
const {
callbacks,
constraints,
id: panelId
} = panelData;
const {
collapsedSize = 0,
collapsible
} = constraints;
const lastNotifiedSize = panelIdToLastNotifiedSizeMap[panelId];
if (lastNotifiedSize == null || size !== lastNotifiedSize) {
panelIdToLastNotifiedSizeMap[panelId] = size;
const {
onCollapse,
onExpand,
onResize
} = callbacks;
if (onResize) {
onResize(size, lastNotifiedSize);
}
if (collapsible && (onCollapse || onExpand)) {
if (onExpand && (lastNotifiedSize == null || fuzzyNumbersEqual$1(lastNotifiedSize, collapsedSize)) && !fuzzyNumbersEqual$1(size, collapsedSize)) {
onExpand();
}
if (onCollapse && (lastNotifiedSize == null || !fuzzyNumbersEqual$1(lastNotifiedSize, collapsedSize)) && fuzzyNumbersEqual$1(size, collapsedSize)) {
onCollapse();
}
}
}
});
}
function compareLayouts(a, b) {
if (a.length !== b.length) {
return false;
} else {
for (let index = 0; index < a.length; index++) {
if (a[index] != b[index]) {
return false;
}
}
}
return true;
}
// This method returns a number between 1 and 100 representing
// the % of the group's overall space this panel should occupy.
function computePanelFlexBoxStyle({
defaultSize,
dragState,
layout,
panelData,
panelIndex,
precision = 3
}) {
const size = layout[panelIndex];
let flexGrow;
if (size == null) {
// Initial render (before panels have registered themselves)
// In order to support server rendering, fall back to default size if provided
flexGrow = defaultSize != undefined ? defaultSize.toPrecision(precision) : "1";
} else if (panelData.length === 1) {
// Special case: Single panel group should always fill full width/height
flexGrow = "1";
} else {
flexGrow = size.toPrecision(precision);
}
return {
flexBasis: 0,
flexGrow,
flexShrink: 1,
// Without this, Panel sizes may be unintentionally overridden by their content
overflow: "hidden",
// Disable pointer events inside of a panel during resize
// This avoid edge cases like nested iframes
pointerEvents: dragState !== null ? "none" : undefined
};
}
function debounce(callback, durationMs = 10) {
let timeoutId = null;
let callable = (...args) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
callback(...args);
}, durationMs);
};
return callable;
}
// PanelGroup might be rendering in a server-side environment where localStorage is not available
// or on a browser with cookies/storage disabled.
// In either case, this function avoids accessing localStorage until needed,
// and avoids throwing user-visible errors.
function initializeDefaultStorage(storageObject) {
try {
if (typeof localStorage !== "undefined") {
// Bypass this check for future calls
storageObject.getItem = name => {
return localStorage.getItem(name);
};
storageObject.setItem = (name, value) => {
localStorage.setItem(name, value);
};
} else {
throw new Error("localStorage not supported in this environment");
}
} catch (error) {
console.error(error);
storageObject.getItem = () => null;
storageObject.setItem = () => {};
}
}
function getPanelGroupKey(autoSaveId) {
return `react-resizable-panels:${autoSaveId}`;
}
// Note that Panel ids might be user-provided (stable) or useId generated (non-deterministic)
// so they should not be used as part of the serialization key.
// Using the min/max size attributes should work well enough as a backup.
// Pre-sorting by minSize allows remembering layouts even if panels are re-ordered/dragged.
function getPanelKey(panels) {
return panels.map(panel => {
const {
constraints,
id,
idIsFromProps,
order
} = panel;
if (idIsFromProps) {
return id;
} else {
return order ? `${order}:${JSON.stringify(constraints)}` : JSON.stringify(constraints);
}
}).sort((a, b) => a.localeCompare(b)).join(",");
}
function loadSerializedPanelGroupState(autoSaveId, storage) {
try {
const panelGroupKey = getPanelGroupKey(autoSaveId);
const serialized = storage.getItem(panelGroupKey);
if (serialized) {
const parsed = JSON.parse(serialized);
if (typeof parsed === "object" && parsed != null) {
return parsed;
}
}
} catch (error) {}
return null;
}
function savePanelGroupState(autoSaveId, panels, panelSizesBeforeCollapse, sizes, storage) {
var _loadSerializedPanelG2;
const panelGroupKey = getPanelGroupKey(autoSaveId);
const panelKey = getPanelKey(panels);
const state = (_loadSerializedPanelG2 = loadSerializedPanelGroupState(autoSaveId, storage)) !== null && _loadSerializedPanelG2 !== void 0 ? _loadSerializedPanelG2 : {};
state[panelKey] = {
expandToSizes: Object.fromEntries(panelSizesBeforeCollapse.entries()),
layout: sizes
};
try {
storage.setItem(panelGroupKey, JSON.stringify(state));
} catch (error) {
console.error(error);
}
}
function validatePanelConstraints({
panelConstraints: panelConstraintsArray,
panelId,
panelIndex
}) {
{
const warnings = [];
const panelConstraints = panelConstraintsArray[panelIndex];
assert(panelConstraints, `No panel constraints found for index ${panelIndex}`);
const {
collapsedSize = 0,
collapsible = false,
defaultSize,
maxSize = 100,
minSize = 0
} = panelConstraints;
if (minSize > maxSize) {
warnings.push(`min size (${minSize}%) should not be greater than max size (${maxSize}%)`);
}
if (defaultSize != null) {
if (defaultSize < 0) {
warnings.push("default size should not be less than 0");
} else if (defaultSize < minSize && (!collapsible || defaultSize !== collapsedSize)) {
warnings.push("default size should not be less than min size");
}
if (defaultSize > 100) {
warnings.push("default size should not be greater than 100");
} else if (defaultSize > maxSize) {
warnings.push("default size should not be greater than max size");
}
}
if (collapsedSize > minSize) {
warnings.push("collapsed size should not be greater than min size");
}
if (warnings.length > 0) {
const name = panelId != null ? `Panel "${panelId}"` : "Panel";
console.warn(`${name} has an invalid configuration:\n\n${warnings.join("\n")}`);
return false;
}
}
return true;
}
// All units must be in percentages; pixel values should be pre-converted
function validatePanelGroupLayout({
layout: prevLayout,
panelConstraints
}) {
const nextLayout = [...prevLayout];
const nextLayoutTotalSize = nextLayout.reduce((accumulated, current) => accumulated + current, 0);
// Validate layout expectations
if (nextLayout.length !== panelConstraints.length) {
throw Error(`Invalid ${panelConstraints.length} panel layout: ${nextLayout.map(size => `${size}%`).join(", ")}`);
} else if (!fuzzyNumbersEqual(nextLayoutTotalSize, 100)) {
// This is not ideal so we should warn about it, but it may be recoverable in some cases
// (especially if the amount is small)
{
console.warn(`WARNING: Invalid layout total size: ${nextLayout.map(size => `${size}%`).join(", ")}. Layout normalization will be applied.`);
}
for (let index = 0; index < panelConstraints.length; index++) {
const unsafeSize = nextLayout[index];
assert(unsafeSize != null, `No layout data found for index ${index}`);
const safeSize = 100 / nextLayoutTotalSize * unsafeSize;
nextLayout[index] = safeSize;
}
}
let remainingSize = 0;
// First pass: Validate the proposed layout given each panel's constraints
for (let index = 0; index < panelConstraints.length; index++) {
const unsafeSize = nextLayout[index];
assert(unsafeSize != null, `No layout data found for index ${index}`);
const safeSize = resizePanel({
panelConstraints,
panelIndex: index,
size: unsafeSize
});
if (unsafeSize != safeSize) {
remainingSize += unsafeSize - safeSize;
nextLayout[index] = safeSize;
}
}
// If there is additional, left over space, assign it to any panel(s) that permits it
// (It's not worth taking multiple additional passes to evenly distribute)
if (!fuzzyNumbersEqual(remainingSize, 0)) {
for (let index = 0; index < panelConstraints.length; index++) {
const prevSize = nextLayout[index];
assert(prevSize != null, `No layout data found for index ${index}`);
const unsafeSize = prevSize + remainingSize;
const safeSize = resizePanel({
panelConstraints,
panelIndex: index,
size: unsafeSize
});
if (prevSize !== safeSize) {
remainingSize -= safeSize - prevSize;
nextLayout[index] = safeSize;
// Once we've used up the remainder, bail
if (fuzzyNumbersEqual(remainingSize, 0)) {
break;
}
}
}
}
return nextLayout;
}
const LOCAL_STORAGE_DEBOUNCE_INTERVAL = 100;
const defaultStorage = {
getItem: name => {
initializeDefaultStorage(defaultStorage);
return defaultStorage.getItem(name);
},
setItem: (name, value) => {
initializeDefaultStorage(defaultStorage);
defaultStorage.setItem(name, value);
}
};
const debounceMap = {};
function PanelGroupWithForwardedRef({
autoSaveId = null,
children,
className: classNameFromProps = "",
direction,
forwardedRef,
id: idFromProps = null,
onLayout = null,
keyboardResizeBy = null,
storage = defaultStorage,
style: styleFromProps,
tagName: Type = "div",
...rest
}) {
const groupId = useUniqueId(idFromProps);
const panelGroupElementRef = useRef(null);
const [dragState, setDragState] = useState(null);
const [layout, setLayout] = useState([]);
const panelIdToLastNotifiedSizeMapRef = useRef({});
const panelSizeBeforeCollapseRef = useRef(new Map());
const prevDeltaRef = useRef(0);
const committedValuesRef = useRef({
autoSaveId,
direction,
dragState,
id: groupId,
keyboardResizeBy,
onLayout,
storage
});
const eagerValuesRef = useRef({
layout,
panelDataArray: [],
panelDataArrayChanged: false
});
const devWarningsRef = useRef({
didLogIdAndOrderWarning: false,
didLogPanelConstraintsWarning: false,
prevPanelIds: []
});
useImperativeHandle(forwardedRef, () => ({
getId: () => committedValuesRef.current.id,
getLayout: () => {
const {
layout
} = eagerValuesRef.current;
return layout;
},
setLayout: unsafeLayout => {
const {
onLayout
} = committedValuesRef.current;
const {
layout: prevLayout,
panelDataArray