UNPKG

react-resizable-panels

Version:

React components for resizable panel groups/layouts

1,576 lines (1,491 loc) 74 kB
'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); const isBrowser = typeof window !== "undefined"; // 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()]; const PanelGroupContext = createContext(null); PanelGroupContext.displayName = "PanelGroupContext"; const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : () => {}; 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 (!isBrowser && defaultSize == null) { devWarningsRef.current.didLogMissingDefaultSizeWarning = true; console.warn(`WARNING: Panel defaultSize prop recommended to avoid layout shift after server rendering`); } } } useIsomorphicLayoutEffect(() => { const { callbacks, constraints } = panelDataRef.current; const prevConstraints = { ...constraints }; panelDataRef.current.id = panelId; panelDataRef.current.idIsFromProps = idFromProps !== undefined; panelDataRef.current.order = order; callbacks.onCollapse = onCollapse; callbacks.onExpand = onExpand; callbacks.onResize = onResize; constraints.collapsedSize = collapsedSize; constraints.collapsible = collapsible; constraints.defaultSize = defaultSize; constraints.maxSize = maxSize; constraints.minSize = minSize; // If constraints have changed, we should revisit panel sizes. // This is uncommon but may happen if people are trying to implement pixel based constraints. if (prevConstraints.collapsedSize !== constraints.collapsedSize || prevConstraints.collapsible !== constraints.collapsible || prevConstraints.maxSize !== constraints.maxSize || prevConstraints.minSize !== constraints.minSize) { reevaluatePanelConstraints(panelDataRef.current, prevConstraints); } }); useIsomorphicLayoutEffect(() => { const panelData = panelDataRef.current; registerPanel(panelData); return () => { unregisterPanel(panelData); }; }, [order, panelId, registerPanel, unregisterPanel]); useImperativeHandle(forwardedRef, () => ({ collapse: () => { collapsePanel(panelDataRef.current); }, expand: () => { expandPanel(panelDataRef.current); }, 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, style: { ...style, ...styleFromProps }, // CSS selectors "data-panel": "", "data-panel-id": panelId, "data-panel-group-id": groupId, // e2e test attributes "data-panel-collapsible": collapsible || undefined , "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 isMouseEvent(event) { return event.type.startsWith("mouse"); } function isTouchEvent(event) { return event.type.startsWith("touch"); } function getResizeEventCoordinates(event) { if (isMouseEvent(event)) { return { x: event.pageX, y: event.pageY }; } else if (isTouchEvent(event)) { const touch = event.touches[0]; if (touch && touch.pageX && touch.pageY) { return { x: touch.pageX, y: touch.pageY }; } } return { x: Infinity, y: Infinity }; } function getInputType() { if (typeof matchMedia === "function") { return matchMedia("(pointer:coarse)").matches ? "coarse" : "fine"; } } 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); } }; } function handlePointerDown(event) { const { x, y } = getResizeEventCoordinates(event); isPointerDown = true; recalculateIntersectingHandles({ x, y }); updateListeners(); if (intersectingHandles.length > 0) { updateResizeHandlerStates("down", event); event.preventDefault(); } } function handlePointerMove(event) { const { x, y } = getResizeEventCoordinates(event); if (isPointerDown) { intersectingHandles.forEach(data => { const { setResizeHandlerState } = data; setResizeHandlerState("move", "drag", event); }); // Update cursor based on return value(s) from active handles updateCursor(); } else { recalculateIntersectingHandles({ x, y }); updateResizeHandlerStates("move", event); updateCursor(); } if (intersectingHandles.length > 0) { event.preventDefault(); } } function handlePointerUp(event) { const { x, y } = getResizeEventCoordinates(event); panelConstraintFlags.clear(); isPointerDown = false; if (intersectingHandles.length > 0) { event.preventDefault(); } recalculateIntersectingHandles({ x, y }); updateResizeHandlerStates("up", event); updateCursor(); updateListeners(); } function recalculateIntersectingHandles({ x, y }) { intersectingHandles.splice(0); registeredResizeHandlers.forEach(data => { const { element, hitAreaMargins } = data; const { bottom, left, right, top } = element.getBoundingClientRect(); const margin = isCoarsePointer ? hitAreaMargins.coarse : hitAreaMargins.fine; const intersects = x >= left - margin && x <= right + margin && y >= top - margin && y <= bottom + margin; if (intersects) { 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("mousedown", handlePointerDown); body.removeEventListener("mouseleave", handlePointerMove); body.removeEventListener("mousemove", handlePointerMove); body.removeEventListener("touchmove", handlePointerMove); body.removeEventListener("touchstart", handlePointerDown); }); window.removeEventListener("mouseup", handlePointerUp); window.removeEventListener("touchcancel", handlePointerUp); window.removeEventListener("touchend", handlePointerUp); if (registerResizeHandle.length > 0) { if (isPointerDown) { if (intersectingHandles.length > 0) { ownerDocumentCounts.forEach((count, ownerDocument) => { const { body } = ownerDocument; if (count > 0) { body.addEventListener("contextmenu", handlePointerUp); body.addEventListener("mouseleave", handlePointerMove); body.addEventListener("mousemove", handlePointerMove); body.addEventListener("touchmove", handlePointerMove, { passive: false }); } }); } window.addEventListener("mouseup", handlePointerUp); window.addEventListener("touchcancel", handlePointerUp); window.addEventListener("touchend", handlePointerUp); } else { ownerDocumentCounts.forEach((count, ownerDocument) => { const { body } = ownerDocument; if (count > 0) { body.addEventListener("mousedown", handlePointerDown); body.addEventListener("mousemove", handlePointerMove); body.addEventListener("touchmove", handlePointerMove, { passive: false }); body.addEventListener("touchstart", handlePointerDown); } }); } } } function updateResizeHandlerStates(action, event) { registeredResizeHandlers.forEach(data => { const { setResizeHandlerState } = data; if (intersectingHandles.includes(data)) { if (isPointerDown) { setResizeHandlerState(action, "drag", event); } else { setResizeHandlerState(action, "hover", event); } } else { setResizeHandlerState(action, "inactive", event); } }); } function assert(expectedCondition, message = "Assertion failed!") { if (!expectedCondition) { console.error(message); throw Error(message); } } const PRECISION = 10; function fuzzyCompareNumbers(actual, expected, fractionDigits = PRECISION) { actual = parseFloat(actual.toFixed(fractionDigits)); expected = parseFloat(expected.toFixed(fractionDigits)); const delta = actual - expected; if (delta === 0) { return 0; } else { return delta > 0 ? 1 : -1; } } function fuzzyNumbersEqual(actual, expected, fractionDigits) { return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0; } // 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); 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, layout: prevLayout, panelConstraints: panelConstraintsArray, pivotIndices, trigger }) { if (fuzzyNumbersEqual(delta, 0)) { return prevLayout; } const nextLayout = [...prevLayout]; const [firstPivotIndex, secondPivotIndex] = pivotIndices; assert(firstPivotIndex != null); assert(secondPivotIndex != null); let deltaApplied = 0; //const DEBUG = []; //DEBUG.push(`adjustLayoutByDelta() ${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); //DEBUG.push(`edge case check 1: ${index}`); //DEBUG.push(` -> collapsible? ${constraints.collapsible}`); if (panelConstraints.collapsible) { const prevSize = prevLayout[index]; assert(prevSize != null); const panelConstraints = panelConstraintsArray[index]; assert(panelConstraints); const { collapsedSize = 0, minSize = 0 } = panelConstraints; 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); const { collapsible } = panelConstraints; //DEBUG.push(`edge case check 2: ${index}`); //DEBUG.push(` -> collapsible? ${collapsible}`); if (collapsible) { const prevSize = prevLayout[index]; assert(prevSize != null); const panelConstraints = panelConstraintsArray[index]; assert(panelConstraints); const { collapsedSize = 0, minSize = 0 } = panelConstraints; 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 = prevLayout[index]; assert(prevSize != null); 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 = prevLayout[index]; assert(prevSize != null); 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 (fuzzyNumbersEqual(deltaApplied, 0)) { //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 = prevLayout[pivotIndex]; assert(prevSize != null); 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); 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}`); //console.log(DEBUG.join("\n")); if (!fuzzyNumbersEqual(totalSize, 100)) { return prevLayout; } return nextLayout; } function calculateAriaValues({ layout, panelsArray, pivotIndices }) { let currentMinSize = 0; let currentMaxSize = 100; let totalMinSize = 0; let totalMaxSize = 0; const firstIndex = pivotIndices[0]; assert(firstIndex != null); // A panel's effective min/max sizes also need to account for other panel's sizes. panelsArray.forEach((panelData, index) => { const { constraints } = panelData; const { maxSize = 100, minSize = 0 } = constraints; if (index === firstIndex) { currentMinSize = minSize; currentMaxSize = maxSize; } else { totalMinSize += minSize; totalMaxSize += maxSize; } }); const valueMax = Math.min(currentMaxSize, 100 - totalMinSize); const valueMin = Math.max(currentMinSize, 100 - totalMaxSize); const valueNow = layout[firstIndex]; return { valueMax, valueMin, valueNow }; } 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 }) { const devWarningsRef = useRef({ didWarnAboutMissingResizeHandle: false }); useIsomorphicLayoutEffect(() => { if (!panelGroupElement) { return; } const resizeHandleElements = getResizeHandleElementsForGroup(groupId, panelGroupElement); for (let index = 0; index < panelDataArray.length - 1; index++) { const { valueMax, valueMin, valueNow } = calculateAriaValues({ layout, panelsArray: panelDataArray, pivotIndices: [index, index + 1] }); const resizeHandleElement = resizeHandleElements[index]; if (resizeHandleElement == null) { { const { didWarnAboutMissingResizeHandle } = devWarningsRef.current; if (!didWarnAboutMissingResizeHandle) { devWarningsRef.current.didWarnAboutMissingResizeHandle = true; console.warn(`WARNING: Missing resize handle for PanelGroup "${groupId}"`); } } } else { const panelData = panelDataArray[index]; assert(panelData); resizeHandleElement.setAttribute("aria-controls", panelData.id); resizeHandleElement.setAttribute("aria-valuemax", "" + Math.round(valueMax)); resizeHandleElement.setAttribute("aria-valuemin", "" + Math.round(valueMin)); resizeHandleElement.setAttribute("aria-valuenow", valueNow != null ? "" + Math.round(valueNow) : ""); } } return () => { resizeHandleElements.forEach((resizeHandleElement, index) => { resizeHandleElement.removeAttribute("aria-controls"); resizeHandleElement.removeAttribute("aria-valuemax"); resizeHandleElement.removeAttribute("aria-valuemin"); resizeHandleElement.removeAttribute("aria-valuenow"); }); }; }, [groupId, layout, panelDataArray, panelGroupElement]); useEffect(() => { if (!panelGroupElement) { return; } const eagerValues = eagerValuesRef.current; assert(eagerValues); const { panelDataArray } = eagerValues; const groupElement = getPanelGroupElement(groupId, panelGroupElement); assert(groupElement != null, `No group found for id "${groupId}"`); const handles = getResizeHandleElementsForGroup(groupId, panelGroupElement); assert(handles); const cleanupFunctions = handles.map(handle => { const handleId = handle.getAttribute("data-panel-resize-handle-id"); assert(handleId); 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); 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, layout, panelConstraints: panelDataArray.map(panelData => panelData.constraints), pivotIndices: determinePivotIndices(groupId, handleId, panelGroupElement), 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); const groupId = handleElement.getAttribute("data-panel-group-id"); assert(groupId); let { initialCursorPosition } = initialDragState; const cursorPosition = getResizeEventCursorPosition(direction, event); const groupElement = getPanelGroupElement(groupId, panelGroupElement); assert(groupElement); 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); } } function calculateUnsafeDefaultLayout({ panelDataArray }) { const layout = Array(panelDataArray.length); const panelConstraintsArray = panelDataArray.map(panelData => panelData.constraints); let numPanelsWithSizes = 0; let remainingSize = 100; // Distribute default sizes first for (let index = 0; index < panelDataArray.length; index++) { const panelConstraints = panelConstraintsArray[index]; assert(panelConstraints); const { defaultSize } = panelConstraints; if (defaultSize != null) { numPanelsWithSizes++; layout[index] = defaultSize; remainingSize -= defaultSize; } } // Remaining size should be distributed evenly between panels without default sizes for (let index = 0; index < panelDataArray.length; index++) { const panelConstraints = panelConstraintsArray[index]; assert(panelConstraints); const { defaultSize } = panelConstraints; if (defaultSize != null) { continue; } const numRemainingPanels = panelDataArray.length - numPanelsWithSizes; const size = remainingSize / numRemainingPanels; numPanelsWithSizes++; layout[index] = size; remainingSize -= size; } return layout; } // Layout should be pre-converted into percentages function callPanelCallbacks(panelsArray, layout, panelIdToLastNotifiedSizeMap) { layout.forEach((size, index) => { const panelData = panelsArray[index]; assert(panelData); 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 || lastNotifiedSize === collapsedSize) && size !== collapsedSize) { onExpand(); } if (onCollapse && (lastNotifiedSize == null || lastNotifiedSize !== collapsedSize) && 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 !== null && defaultSize !== void 0 ? defaultSize : "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 loadPanelGroupState(autoSaveId, panels, storage) { var _loadSerializedPanelG, _state$panelKey; const state = (_loadSerializedPanelG = loadSerializedPanelGroupState(autoSaveId, storage)) !== null && _loadSerializedPanelG !== void 0 ? _loadSerializedPanelG : {}; const panelKey = getPanelKey(panels); return (_state$panelKey = state[panelKey]) !== null && _state$panelKey !== void 0 ? _state$panelKey : 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); 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); 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); 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); 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 } = eagerValuesRef.current; const safeLayout = validatePanelGroupLayout({ layout: unsafeLayout, panelConstraints: panelDataArray.map(panelData => panelData.constraints) }); if (!areEqual(prevLayout, safeLayout)) { setLayout(safeLayout); eagerValuesRef.current.layout = safeLayout; if (onLayout) { onLayout(safeLayout); } callPanelCallbacks(panelDataArray, safeLayout, panelIdToLastNotifiedSizeMapRef.current); } } }), []); useIsomorphicLayoutEffect(() => { committedValuesRef.current.autoSaveId = autoSaveId; committedValuesRef.current.direction = direction; committedValuesRef.current.dragState = dragState; committedValuesRef.current.id = groupId; committedValuesRef.current.onLayout = onLayout; committedValuesRef.current.storage = storage; }); useWindowSplitterPanelGroupBehavior({ committedValuesRef, eagerValuesRef, groupId, layout, panelDataArray: eagerValuesRef.current.panelDataArray, setLayout, panelGroupElement: panelGroupElementRef.current }); useEffect(() => { const { panelDataArray } = eagerValuesRef.current; // If this panel has been configured to persist sizing information, save sizes to local storage. if (autoSaveId) { if (layout.length === 0 || layout.length !== panelDataArray.length) { return; } let debouncedSave = debounceMap[autoSaveId]; // Limit the frequency of localStorage updates. if (debouncedSave == null) { debouncedSave = debounce(savePanelGroupState, LOCAL_STORAGE_DEBOUNCE_INTERVAL); debounceMap[autoSaveId] = debouncedSave; } // Clone mutable data before passing to the debounced function, // else we run the risk of saving an incorrect combination of mutable and immutable values to state. const clonedPanelDataArray = [...panelDataArray]; const clonedPanelSizesBeforeCollapse = new Map(panelSizeBeforeCollapseRef.current); debouncedSave(autoSaveId, clonedPanelDataArray, clonedPanelSizesBeforeCollapse, layout, storage); } }, [autoSaveId, layout, storage]); // DEV warnings useEffect(() => { { const { panelDataArray