main repo

This commit is contained in:
Basilosaurusrex
2025-11-24 18:09:40 +01:00
parent b636ee5e70
commit f027651f9b
34146 changed files with 4436636 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
import { PanelConstraints } from "../Panel";
import { assert } from "./assert";
import { fuzzyCompareNumbers } from "./numbers/fuzzyCompareNumbers";
import { fuzzyLayoutsEqual } from "./numbers/fuzzyLayoutsEqual";
import { fuzzyNumbersEqual } from "./numbers/fuzzyNumbersEqual";
import { resizePanel } from "./resizePanel";
// All units must be in percentages; pixel values should be pre-converted
export function adjustLayoutByDelta({
delta,
initialLayout,
panelConstraints: panelConstraintsArray,
pivotIndices,
prevLayout,
trigger,
}: {
delta: number;
initialLayout: number[];
panelConstraints: PanelConstraints[];
pivotIndices: number[];
prevLayout: number[];
trigger: "imperative-api" | "keyboard" | "mouse-or-touch";
}): number[] {
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;
}

View File

@@ -0,0 +1,13 @@
export function areEqual(arrayA: any[], arrayB: any[]): boolean {
if (arrayA.length !== arrayB.length) {
return false;
}
for (let index = 0; index < arrayA.length; index++) {
if (arrayA[index] !== arrayB[index]) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,10 @@
export function assert(
expectedCondition: any,
message: string
): asserts expectedCondition {
if (!expectedCondition) {
console.error(message);
throw Error(message);
}
}

View File

@@ -0,0 +1,106 @@
import { PanelConstraints, PanelData } from "../Panel";
import { calculateAriaValues } from "./calculateAriaValues";
describe("calculateAriaValues", () => {
let idCounter = 0;
let orderCounter = 0;
function createPanelData(constraints: PanelConstraints = {}): PanelData {
return {
callbacks: {
onCollapse: undefined,
onExpand: undefined,
onResize: undefined,
},
constraints,
id: `${idCounter++}`,
idIsFromProps: false,
order: orderCounter++,
};
}
beforeEach(() => {
idCounter = 0;
orderCounter = 0;
});
it("should work correctly for panels with no min/max constraints", () => {
expect(
calculateAriaValues({
layout: [50, 50],
panelsArray: [createPanelData(), createPanelData()],
pivotIndices: [0, 1],
})
).toEqual({
valueMax: 100,
valueMin: 0,
valueNow: 50,
});
expect(
calculateAriaValues({
layout: [20, 50, 30],
panelsArray: [createPanelData(), createPanelData(), createPanelData()],
pivotIndices: [0, 1],
})
).toEqual({
valueMax: 100,
valueMin: 0,
valueNow: 20,
});
expect(
calculateAriaValues({
layout: [20, 50, 30],
panelsArray: [createPanelData(), createPanelData(), createPanelData()],
pivotIndices: [1, 2],
})
).toEqual({
valueMax: 100,
valueMin: 0,
valueNow: 50,
});
});
it("should work correctly for panels with min/max constraints", () => {
expect(
calculateAriaValues({
layout: [25, 75],
panelsArray: [
createPanelData({
maxSize: 35,
minSize: 10,
}),
createPanelData(),
],
pivotIndices: [0, 1],
})
).toEqual({
valueMax: 35,
valueMin: 10,
valueNow: 25,
});
expect(
calculateAriaValues({
layout: [25, 50, 25],
panelsArray: [
createPanelData({
maxSize: 35,
minSize: 10,
}),
createPanelData(),
createPanelData({
maxSize: 35,
minSize: 10,
}),
],
pivotIndices: [1, 2],
})
).toEqual({
valueMax: 80,
valueMin: 30,
valueNow: 50,
});
});
});

View File

@@ -0,0 +1,45 @@
import { PanelData } from "../Panel";
import { assert } from "./assert";
export function calculateAriaValues({
layout,
panelsArray,
pivotIndices,
}: {
layout: number[];
panelsArray: PanelData[];
pivotIndices: number[];
}) {
let currentMinSize = 0;
let currentMaxSize = 100;
let totalMinSize = 0;
let totalMaxSize = 0;
const firstIndex = pivotIndices[0];
assert(firstIndex != null, "No pivot index found");
// 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,
};
}

View File

@@ -0,0 +1,63 @@
import { DragState, ResizeEvent } from "../PanelGroupContext";
import { Direction } from "../types";
import { calculateDragOffsetPercentage } from "./calculateDragOffsetPercentage";
import { isKeyDown } from "./events";
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX
export function calculateDeltaPercentage(
event: ResizeEvent,
dragHandleId: string,
direction: Direction,
initialDragState: DragState | null,
keyboardResizeBy: number | null,
panelGroupElement: HTMLElement
): number {
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
);
}
}

View File

@@ -0,0 +1,40 @@
import { DragState, ResizeEvent } from "../PanelGroupContext";
import { Direction } from "../types";
import { assert } from "./assert";
import { getPanelGroupElement } from "./dom/getPanelGroupElement";
import { getResizeHandleElement } from "./dom/getResizeHandleElement";
import { getResizeEventCursorPosition } from "./events/getResizeEventCursorPosition";
export function calculateDragOffsetPercentage(
event: ResizeEvent,
dragHandleId: string,
direction: Direction,
initialDragState: DragState,
panelGroupElement: HTMLElement
): number {
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;
}

View File

@@ -0,0 +1,87 @@
import { PanelConstraints, PanelData } from "../Panel";
import { calculateUnsafeDefaultLayout } from "./calculateUnsafeDefaultLayout";
import { expectToBeCloseToArray } from "./test-utils";
describe("calculateUnsafeDefaultLayout", () => {
let idCounter = 0;
let orderCounter = 0;
function createPanelData(constraints: PanelConstraints = {}): PanelData {
return {
callbacks: {
onCollapse: undefined,
onExpand: undefined,
onResize: undefined,
},
constraints,
id: `${idCounter++}`,
idIsFromProps: false,
order: orderCounter++,
};
}
beforeEach(() => {
idCounter = 0;
orderCounter = 0;
});
it("should assign even sizes for every panel by default", () => {
expectToBeCloseToArray(
calculateUnsafeDefaultLayout({
panelDataArray: [createPanelData()],
}),
[100]
);
expectToBeCloseToArray(
calculateUnsafeDefaultLayout({
panelDataArray: [createPanelData(), createPanelData()],
}),
[50, 50]
);
expectToBeCloseToArray(
calculateUnsafeDefaultLayout({
panelDataArray: [
createPanelData(),
createPanelData(),
createPanelData(),
],
}),
[33.3, 33.3, 33.3]
);
});
it("should respect default panel size constraints", () => {
expectToBeCloseToArray(
calculateUnsafeDefaultLayout({
panelDataArray: [
createPanelData({
defaultSize: 15,
}),
createPanelData({
defaultSize: 85,
}),
],
}),
[15, 85]
);
});
it("should ignore min and max panel size constraints", () => {
expectToBeCloseToArray(
calculateUnsafeDefaultLayout({
panelDataArray: [
createPanelData({
minSize: 40,
}),
createPanelData(),
createPanelData({
maxSize: 10,
}),
],
}),
[33.3, 33.3, 33.3]
);
});
});

View File

@@ -0,0 +1,50 @@
import { PanelData } from "../Panel";
import { assert } from "./assert";
export function calculateUnsafeDefaultLayout({
panelDataArray,
}: {
panelDataArray: PanelData[];
}): number[] {
const layout = Array<number>(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, `Panel constraints not found for index ${index}`);
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, `Panel constraints not found for index ${index}`);
const { defaultSize } = panelConstraints;
if (defaultSize != null) {
continue;
}
const numRemainingPanels = panelDataArray.length - numPanelsWithSizes;
const size = remainingSize / numRemainingPanels;
numPanelsWithSizes++;
layout[index] = size;
remainingSize -= size;
}
return layout;
}

View File

@@ -0,0 +1,49 @@
import { PanelData } from "../Panel";
import { assert } from "./assert";
import { fuzzyNumbersEqual } from "./numbers/fuzzyCompareNumbers";
// Layout should be pre-converted into percentages
export function callPanelCallbacks(
panelsArray: PanelData[],
layout: number[],
panelIdToLastNotifiedSizeMap: Record<string, number>
) {
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(lastNotifiedSize, collapsedSize)) &&
!fuzzyNumbersEqual(size, collapsedSize)
) {
onExpand();
}
if (
onCollapse &&
(lastNotifiedSize == null ||
!fuzzyNumbersEqual(lastNotifiedSize, collapsedSize)) &&
fuzzyNumbersEqual(size, collapsedSize)
) {
onCollapse();
}
}
}
});
}

View File

@@ -0,0 +1,9 @@
import { compareLayouts } from "./compareLayouts";
describe("compareLayouts", () => {
it("should work", () => {
expect(compareLayouts([1, 2], [1])).toBe(false);
expect(compareLayouts([1], [1, 2])).toBe(false);
expect(compareLayouts([1, 2, 3], [1, 2, 3])).toBe(true);
});
});

View File

@@ -0,0 +1,12 @@
export function compareLayouts(a: number[], b: number[]) {
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;
}

View File

@@ -0,0 +1,123 @@
import { PanelConstraints, PanelData } from "../Panel";
import { computePanelFlexBoxStyle } from "./computePanelFlexBoxStyle";
describe("computePanelFlexBoxStyle", () => {
function createPanelData(constraints: PanelConstraints = {}): PanelData {
return {
callbacks: {},
constraints,
id: "fake",
idIsFromProps: false,
order: undefined,
};
}
it("should observe a panel's default size if group layout has not yet been computed", () => {
expect(
computePanelFlexBoxStyle({
defaultSize: 0.1233456789,
dragState: null,
layout: [],
panelData: [
createPanelData({
defaultSize: 0.1233456789,
}),
createPanelData(),
],
panelIndex: 0,
precision: 2,
})
).toMatchInlineSnapshot(`
{
"flexBasis": 0,
"flexGrow": "0.12",
"flexShrink": 1,
"overflow": "hidden",
"pointerEvents": undefined,
}
`);
});
it("should always fill the full width for single-panel groups", () => {
expect(
computePanelFlexBoxStyle({
defaultSize: undefined,
dragState: null,
layout: [],
panelData: [createPanelData()],
panelIndex: 0,
precision: 2,
})
).toMatchInlineSnapshot(`
{
"flexBasis": 0,
"flexGrow": "1",
"flexShrink": 1,
"overflow": "hidden",
"pointerEvents": undefined,
}
`);
});
it("should round sizes to avoid floating point precision errors", () => {
const layout = [0.25435, 0.5758, 0.1698];
const panelData = [createPanelData(), createPanelData(), createPanelData()];
expect(
computePanelFlexBoxStyle({
defaultSize: undefined,
dragState: null,
layout,
panelData,
panelIndex: 0,
precision: 2,
})
).toMatchInlineSnapshot(`
{
"flexBasis": 0,
"flexGrow": "0.25",
"flexShrink": 1,
"overflow": "hidden",
"pointerEvents": undefined,
}
`);
expect(
computePanelFlexBoxStyle({
defaultSize: undefined,
dragState: null,
layout,
panelData,
panelIndex: 1,
precision: 2,
})
).toMatchInlineSnapshot(`
{
"flexBasis": 0,
"flexGrow": "0.58",
"flexShrink": 1,
"overflow": "hidden",
"pointerEvents": undefined,
}
`);
expect(
computePanelFlexBoxStyle({
defaultSize: undefined,
dragState: null,
layout,
panelData,
panelIndex: 2,
precision: 2,
})
).toMatchInlineSnapshot(`
{
"flexBasis": 0,
"flexGrow": "0.17",
"flexShrink": 1,
"overflow": "hidden",
"pointerEvents": undefined,
}
`);
});
});

View File

@@ -0,0 +1,50 @@
// This method returns a number between 1 and 100 representing
import { PanelData } from "../Panel";
import { DragState } from "../PanelGroupContext";
import { CSSProperties } from "../vendor/react";
// the % of the group's overall space this panel should occupy.
export function computePanelFlexBoxStyle({
defaultSize,
dragState,
layout,
panelData,
panelIndex,
precision = 3,
}: {
defaultSize: number | undefined;
layout: number[];
dragState: DragState | null;
panelData: PanelData[];
panelIndex: number;
precision?: number;
}): CSSProperties {
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,
};
}

9
node_modules/react-resizable-panels/src/utils/csp.ts generated vendored Normal file
View File

@@ -0,0 +1,9 @@
let nonce: string | null;
export function getNonce(): string | null {
return nonce;
}
export function setNonce(value: string | null) {
nonce = value;
}

103
node_modules/react-resizable-panels/src/utils/cursor.ts generated vendored Normal file
View File

@@ -0,0 +1,103 @@
import {
EXCEEDED_HORIZONTAL_MAX,
EXCEEDED_HORIZONTAL_MIN,
EXCEEDED_VERTICAL_MAX,
EXCEEDED_VERTICAL_MIN,
} from "../PanelResizeHandleRegistry";
import { getNonce } from "./csp";
type CursorState = "horizontal" | "intersection" | "vertical";
let currentCursorStyle: string | null = null;
let enabled: boolean = true;
let styleElement: HTMLStyleElement | null = null;
export function disableGlobalCursorStyles() {
enabled = false;
}
export function enableGlobalCursorStyles() {
enabled = true;
}
export function getCursorStyle(
state: CursorState,
constraintFlags: number
): string {
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";
}
}
export function resetGlobalCursorStyle() {
if (styleElement !== null) {
document.head.removeChild(styleElement);
currentCursorStyle = null;
styleElement = null;
}
}
export function setGlobalCursorStyle(
state: CursorState,
constraintFlags: number
) {
if (!enabled) {
return;
}
const style = getCursorStyle(state, constraintFlags);
if (currentCursorStyle === style) {
return;
}
currentCursorStyle = style;
if (styleElement === null) {
styleElement = document.createElement("style");
const nonce = getNonce();
if (nonce) {
styleElement.setAttribute("nonce", nonce);
}
document.head.appendChild(styleElement);
}
styleElement.innerHTML = `*{cursor: ${style}!important;}`;
}

View File

@@ -0,0 +1,18 @@
export default function debounce<T extends Function>(
callback: T,
durationMs: number = 10
) {
let timeoutId: NodeJS.Timeout | null = null;
let callable = (...args: any) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
callback(...args);
}, durationMs);
};
return callable as unknown as T;
}

View File

@@ -0,0 +1,15 @@
import { getResizeHandleElementIndex } from "../utils/dom/getResizeHandleElementIndex";
export function determinePivotIndices(
groupId: string,
dragHandleId: string,
panelGroupElement: ParentNode
): [indexBefore: number, indexAfter: number] {
const index = getResizeHandleElementIndex(
groupId,
dragHandleId,
panelGroupElement
);
return index != null ? [index, index + 1] : [-1, -1];
}

View File

@@ -0,0 +1,10 @@
export function getPanelElement(
id: string,
scope: ParentNode | HTMLElement = document
): HTMLElement | null {
const element = scope.querySelector(`[data-panel-id="${id}"]`);
if (element) {
return element as HTMLElement;
}
return null;
}

View File

@@ -0,0 +1,8 @@
export function getPanelElementsForGroup(
groupId: string,
scope: ParentNode | HTMLElement = document
): HTMLElement[] {
return Array.from(
scope.querySelectorAll(`[data-panel][data-panel-group-id="${groupId}"]`)
);
}

View File

@@ -0,0 +1,21 @@
export function getPanelGroupElement(
id: string,
rootElement: ParentNode | HTMLElement = document
): HTMLElement | null {
//If the root element is the PanelGroup
if (
rootElement instanceof HTMLElement &&
(rootElement as HTMLElement)?.dataset?.panelGroupId == id
) {
return rootElement as HTMLElement;
}
//Else query children
const element = rootElement.querySelector(
`[data-panel-group][data-panel-group-id="${id}"]`
);
if (element) {
return element as HTMLElement;
}
return null;
}

View File

@@ -0,0 +1,10 @@
export function getResizeHandleElement(
id: string,
scope: ParentNode | HTMLElement = document
): HTMLElement | null {
const element = scope.querySelector(`[data-panel-resize-handle-id="${id}"]`);
if (element) {
return element as HTMLElement;
}
return null;
}

View File

@@ -0,0 +1,13 @@
import { getResizeHandleElementsForGroup } from "./getResizeHandleElementsForGroup";
export function getResizeHandleElementIndex(
groupId: string,
id: string,
scope: ParentNode | HTMLElement = document
): number | null {
const handles = getResizeHandleElementsForGroup(groupId, scope);
const index = handles.findIndex(
(handle) => handle.getAttribute("data-panel-resize-handle-id") === id
);
return index ?? null;
}

View File

@@ -0,0 +1,10 @@
export function getResizeHandleElementsForGroup(
groupId: string,
scope: ParentNode | HTMLElement = document
): HTMLElement[] {
return Array.from(
scope.querySelectorAll(
`[data-panel-resize-handle-id][data-panel-group-id="${groupId}"]`
)
);
}

View File

@@ -0,0 +1,19 @@
import { PanelData } from "../../Panel";
import { getResizeHandleElement } from "./getResizeHandleElement";
import { getResizeHandleElementsForGroup } from "./getResizeHandleElementsForGroup";
export function getResizeHandlePanelIds(
groupId: string,
handleId: string,
panelsArray: PanelData[],
scope: ParentNode | HTMLElement = document
): [idBefore: string | null, idAfter: string | null] {
const handle = getResizeHandleElement(handleId, scope);
const handles = getResizeHandleElementsForGroup(groupId, scope);
const index = handle ? handles.indexOf(handle) : -1;
const idBefore: string | null = panelsArray[index]?.id ?? null;
const idAfter: string | null = panelsArray[index + 1]?.id ?? null;
return [idBefore, idAfter];
}

View File

@@ -0,0 +1,23 @@
import { ResizeEvent } from "../../types";
import { isMouseEvent, isPointerEvent } from ".";
export function getResizeEventCoordinates(event: ResizeEvent) {
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,
};
}

View File

@@ -0,0 +1,14 @@
import { ResizeEvent } from "../../PanelGroupContext";
import { Direction } from "../../types";
import { getResizeEventCoordinates } from "./getResizeEventCoordinates";
export function getResizeEventCursorPosition(
direction: Direction,
event: ResizeEvent
): number {
const isHorizontal = direction === "horizontal";
const { x, y } = getResizeEventCoordinates(event);
return isHorizontal ? x : y;
}

View File

@@ -0,0 +1,13 @@
import { ResizeEvent } from "../../PanelGroupContext";
export function isKeyDown(event: ResizeEvent): event is KeyboardEvent {
return event.type === "keydown";
}
export function isPointerEvent(event: ResizeEvent): event is PointerEvent {
return event.type.startsWith("pointer");
}
export function isMouseEvent(event: ResizeEvent): event is MouseEvent {
return event.type.startsWith("mouse");
}

View File

@@ -0,0 +1,5 @@
export function getInputType(): "coarse" | "fine" | undefined {
if (typeof matchMedia === "function") {
return matchMedia("(pointer:coarse)").matches ? "coarse" : "fine";
}
}

View File

@@ -0,0 +1,26 @@
import { PanelGroupStorage } from "../PanelGroup";
// 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.
export function initializeDefaultStorage(storageObject: PanelGroupStorage) {
try {
if (typeof localStorage !== "undefined") {
// Bypass this check for future calls
storageObject.getItem = (name: string) => {
return localStorage.getItem(name);
};
storageObject.setItem = (name: string, value: string) => {
localStorage.setItem(name, value);
};
} else {
throw new Error("localStorage not supported in this environment");
}
} catch (error) {
console.error(error);
storageObject.getItem = () => null;
storageObject.setItem = () => {};
}
}

View File

@@ -0,0 +1,16 @@
import { fuzzyCompareNumbers } from "./fuzzyCompareNumbers";
describe("fuzzyCompareNumbers", () => {
it("should return 0 when numbers are equal", () => {
expect(fuzzyCompareNumbers(10.123, 10.123, 5)).toBe(0);
});
it("should return 0 when numbers are fuzzy equal", () => {
expect(fuzzyCompareNumbers(0.000001, 0.000002, 5)).toBe(0);
});
it("should return a delta when numbers are not unequal", () => {
expect(fuzzyCompareNumbers(0.000001, 0.000002, 6)).toBe(-1);
expect(fuzzyCompareNumbers(0.000005, 0.000002, 6)).toBe(1);
});
});

View File

@@ -0,0 +1,21 @@
import { PRECISION } from "../../constants";
export function fuzzyCompareNumbers(
actual: number,
expected: number,
fractionDigits: number = PRECISION
): number {
if (actual.toFixed(fractionDigits) === expected.toFixed(fractionDigits)) {
return 0;
} else {
return actual > expected ? 1 : -1;
}
}
export function fuzzyNumbersEqual(
actual: number,
expected: number,
fractionDigits: number = PRECISION
): boolean {
return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0;
}

View File

@@ -0,0 +1,22 @@
import { fuzzyNumbersEqual } from "./fuzzyNumbersEqual";
export function fuzzyLayoutsEqual(
actual: number[],
expected: number[],
fractionDigits?: number
): boolean {
if (actual.length !== expected.length) {
return false;
}
for (let index = 0; index < actual.length; index++) {
const actualSize = actual[index] as number;
const expectedSize = expected[index] as number;
if (!fuzzyNumbersEqual(actualSize, expectedSize, fractionDigits)) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,9 @@
import { fuzzyCompareNumbers } from "./fuzzyCompareNumbers";
export function fuzzyNumbersEqual(
actual: number,
expected: number,
fractionDigits?: number
): boolean {
return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0;
}

View File

@@ -0,0 +1,198 @@
import { getIntersectingRectangle } from "./getIntersectingRectangle";
import { Rectangle } from "./types";
const emptyRect = { x: 0, y: 0, width: 0, height: 0 };
const rect = { x: 25, y: 25, width: 50, height: 50 };
function forkRect(partial: Partial<Rectangle>, baseRect: Rectangle = rect) {
return { ...rect, ...partial };
}
describe("getIntersectingRectangle", () => {
let strict: boolean = false;
function verify(rectOne: Rectangle, rectTwo: Rectangle, expected: Rectangle) {
const actual = getIntersectingRectangle(rectOne, rectTwo, strict);
try {
expect(actual).toEqual(expected);
} catch (thrown) {
console.log(
"Expect",
strict ? "strict mode" : "loose mode",
"\n",
rectOne,
"\n",
rectTwo,
"\n\nto intersect as:\n",
expected,
"\n\nbut got:\n",
actual
);
throw thrown;
}
}
describe("loose", () => {
beforeEach(() => {
strict = false;
});
it("should support empty rects", () => {
verify(emptyRect, emptyRect, emptyRect);
});
it("should support fully overlapping rects", () => {
verify(rect, forkRect({ x: 35, width: 30 }), {
x: 35,
y: 25,
width: 30,
height: 50,
});
verify(rect, forkRect({ y: 35, height: 30 }), {
x: 25,
y: 35,
width: 50,
height: 30,
});
verify(
rect,
forkRect({
x: 35,
y: 35,
width: 30,
height: 30,
}),
{
x: 35,
y: 35,
width: 30,
height: 30,
}
);
});
it("should support partially overlapping rects", () => {
verify(rect, forkRect({ x: 10, y: 10 }), {
x: 25,
y: 25,
width: 35,
height: 35,
});
verify(rect, forkRect({ x: 45, y: 30 }), {
x: 45,
y: 30,
width: 30,
height: 45,
});
});
it("should support non-overlapping rects", () => {
verify(rect, forkRect({ x: 100, y: 100 }), emptyRect);
});
it("should support all negative coordinates", () => {
verify(
{
x: -100,
y: -100,
width: 50,
height: 50,
},
{ x: -80, y: -80, width: 50, height: 50 },
{
x: -80,
y: -80,
width: 30,
height: 30,
}
);
});
});
describe("strict", () => {
beforeEach(() => {
strict = true;
});
it("should support empty rects", () => {
verify(emptyRect, emptyRect, emptyRect);
});
it("should support fully overlapping rects", () => {
verify(rect, forkRect({ x: 35, width: 30 }), {
x: 35,
y: 25,
width: 30,
height: 50,
});
verify(rect, forkRect({ y: 35, height: 30 }), {
x: 25,
y: 35,
width: 50,
height: 30,
});
verify(
rect,
forkRect({
x: 35,
y: 35,
width: 30,
height: 30,
}),
{
x: 35,
y: 35,
width: 30,
height: 30,
}
);
});
it("should support partially overlapping rects", () => {
verify(rect, forkRect({ x: 10, y: 10 }), {
x: 25,
y: 25,
width: 35,
height: 35,
});
verify(rect, forkRect({ x: 45, y: 30 }), {
x: 45,
y: 30,
width: 30,
height: 45,
});
});
it("should support non-overlapping rects", () => {
verify(rect, forkRect({ x: 100, y: 100 }), emptyRect);
});
it("should support all negative coordinates", () => {
verify(
{
x: -100,
y: -100,
width: 50,
height: 50,
},
{ x: -80, y: -80, width: 50, height: 50 },
{
x: -80,
y: -80,
width: 30,
height: 30,
}
);
});
});
});

View File

@@ -0,0 +1,28 @@
import { intersects } from "./intersects";
import { Rectangle } from "./types";
export function getIntersectingRectangle(
rectOne: Rectangle,
rectTwo: Rectangle,
strict: boolean
): Rectangle {
if (!intersects(rectOne, rectTwo, strict)) {
return {
x: 0,
y: 0,
width: 0,
height: 0,
};
}
return {
x: Math.max(rectOne.x, rectTwo.x),
y: Math.max(rectOne.y, rectTwo.y),
width:
Math.min(rectOne.x + rectOne.width, rectTwo.x + rectTwo.width) -
Math.max(rectOne.x, rectTwo.x),
height:
Math.min(rectOne.y + rectOne.height, rectTwo.y + rectTwo.height) -
Math.max(rectOne.y, rectTwo.y),
};
}

View File

@@ -0,0 +1,197 @@
import { intersects } from "./intersects";
import { Rectangle } from "./types";
const emptyRect = { x: 0, y: 0, width: 0, height: 0 };
const rect = { x: 25, y: 25, width: 50, height: 50 };
function forkRect(partial: Partial<Rectangle>, baseRect: Rectangle = rect) {
return { ...rect, ...partial };
}
describe("intersects", () => {
let strict: boolean = false;
function verify(rectOne: Rectangle, rectTwo: Rectangle, expected: boolean) {
const actual = intersects(rectOne, rectTwo, strict);
try {
expect(actual).toBe(expected);
} catch (thrown) {
console.log(
"Expected",
rectOne,
"to",
expected ? "intersect" : "not intersect",
rectTwo,
strict ? "in strict mode" : "in loose mode"
);
throw thrown;
}
}
describe("loose", () => {
beforeEach(() => {
strict = false;
});
it("should handle empty rects", () => {
verify(emptyRect, emptyRect, true);
});
it("should support fully overlapping rects", () => {
verify(rect, rect, true);
verify(rect, forkRect({ x: 35, width: 30 }), true);
verify(rect, forkRect({ y: 35, height: 30 }), true);
verify(
rect,
forkRect({
x: 35,
y: 35,
width: 30,
height: 30,
}),
true
);
verify(rect, forkRect({ x: 10, width: 100 }), true);
verify(rect, forkRect({ y: 10, height: 100 }), true);
verify(
rect,
forkRect({
x: 10,
y: 10,
width: 100,
height: 100,
}),
true
);
});
it("should support partially overlapping rects", () => {
const cases: Partial<Rectangle>[] = [
{ x: 0 },
{ y: 0 },
// Loose mode only
{ x: -25 },
{ x: 75 },
{ y: -25 },
{ y: 75 },
{ x: -25, y: -25 },
{ x: 75, y: 75 },
];
cases.forEach((partial) => {
verify(forkRect(partial), rect, true);
});
});
it("should support non-overlapping rects", () => {
const cases: Partial<Rectangle>[] = [
{ x: 100 },
{ x: -100 },
{ y: 100 },
{ y: -100 },
{ x: -100, y: -100 },
{ x: 100, y: 100 },
];
cases.forEach((partial) => {
verify(forkRect(partial), rect, false);
});
});
it("should support all negative coordinates", () => {
expect(
intersects(
{ x: -100, y: -100, width: 50, height: 50 },
{ x: -110, y: -90, width: 50, height: 50 },
false
)
).toBe(true);
});
});
describe("strict", () => {
beforeEach(() => {
strict = true;
});
it("should handle empty rects", () => {
verify(emptyRect, emptyRect, false);
});
it("should support fully overlapping rects", () => {
verify(rect, rect, true);
verify(rect, forkRect({ x: 35, width: 30 }), true);
verify(rect, forkRect({ y: 35, height: 30 }), true);
verify(
rect,
forkRect({
x: 35,
y: 35,
width: 30,
height: 30,
}),
true
);
verify(rect, forkRect({ x: 10, width: 100 }), true);
verify(rect, forkRect({ y: 10, height: 100 }), true);
verify(
rect,
forkRect({
x: 10,
y: 10,
width: 100,
height: 100,
}),
true
);
});
it("should support partially overlapping rects", () => {
const cases: Partial<Rectangle>[] = [{ x: 0 }, { y: 0 }];
cases.forEach((partial) => {
verify(forkRect(partial), rect, true);
});
});
it("should support non-overlapping rects", () => {
const cases: Partial<Rectangle>[] = [
{ x: 100 },
{ x: -100 },
{ y: 100 },
{ y: -100 },
{ x: -100, y: -100 },
{ x: 100, y: 100 },
// Strict mode only
{ x: -25 },
{ x: 75 },
{ y: -25 },
{ y: 75 },
{ x: -25, y: -25 },
{ x: 75, y: 75 },
];
cases.forEach((partial) => {
verify(forkRect(partial), rect, false);
});
});
it("should support all negative coordinates", () => {
expect(
intersects(
{ x: -100, y: -100, width: 50, height: 50 },
{ x: -110, y: -90, width: 50, height: 50 },
true
)
).toBe(true);
});
});
});

View File

@@ -0,0 +1,23 @@
import { Rectangle } from "./types";
export function intersects(
rectOne: Rectangle,
rectTwo: Rectangle,
strict: boolean
): boolean {
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
);
}
}

View File

@@ -0,0 +1,6 @@
export interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}

View File

@@ -0,0 +1,59 @@
import { resizePanel } from "./resizePanel";
describe("resizePanel", () => {
it("should not collapse (or expand) until a panel size dips below the halfway point between min size and collapsed size", () => {
expect(
resizePanel({
panelConstraints: [
{
collapsible: true,
collapsedSize: 10,
minSize: 20,
},
],
panelIndex: 0,
size: 15,
})
).toBe(20);
expect(
resizePanel({
panelConstraints: [
{
collapsible: true,
collapsedSize: 10,
minSize: 20,
},
],
panelIndex: 0,
size: 14,
})
).toBe(10);
expect(
resizePanel({
panelConstraints: [
{
collapsible: true,
minSize: 20,
},
],
panelIndex: 0,
size: 10,
})
).toBe(20);
expect(
resizePanel({
panelConstraints: [
{
collapsible: true,
minSize: 20,
},
],
panelIndex: 0,
size: 9,
})
).toBe(0);
});
});

View File

@@ -0,0 +1,47 @@
import { PanelConstraints } from "../Panel";
import { PRECISION } from "../constants";
import { assert } from "./assert";
import { fuzzyCompareNumbers } from "./numbers/fuzzyCompareNumbers";
// Panel size must be in percentages; pixel values should be pre-converted
export function resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex,
size,
}: {
panelConstraints: PanelConstraints[];
panelIndex: number;
size: number;
}) {
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;
}

View File

@@ -0,0 +1,87 @@
import { PanelData } from "../Panel";
import { PanelGroupStorage } from "../PanelGroup";
export type PanelConfigurationState = {
expandToSizes: {
[panelId: string]: number;
};
layout: number[];
};
export type SerializedPanelGroupState = {
[panelIds: string]: PanelConfigurationState;
};
function getPanelGroupKey(autoSaveId: string): string {
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: PanelData[]): string {
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: string,
storage: PanelGroupStorage
): SerializedPanelGroupState | null {
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 as SerializedPanelGroupState;
}
}
} catch (error) {}
return null;
}
export function loadPanelGroupState(
autoSaveId: string,
panels: PanelData[],
storage: PanelGroupStorage
): PanelConfigurationState | null {
const state = loadSerializedPanelGroupState(autoSaveId, storage) ?? {};
const panelKey = getPanelKey(panels);
return state[panelKey] ?? null;
}
export function savePanelGroupState(
autoSaveId: string,
panels: PanelData[],
panelSizesBeforeCollapse: Map<string, number>,
sizes: number[],
storage: PanelGroupStorage
): void {
const panelGroupKey = getPanelGroupKey(autoSaveId);
const panelKey = getPanelKey(panels);
const state = loadSerializedPanelGroupState(autoSaveId, storage) ?? {};
state[panelKey] = {
expandToSizes: Object.fromEntries(panelSizesBeforeCollapse.entries()),
layout: sizes,
};
try {
storage.setItem(panelGroupKey, JSON.stringify(state));
} catch (error) {
console.error(error);
}
}

View File

@@ -0,0 +1,205 @@
import { assert } from "./assert";
const util = require("util");
export function dispatchPointerEvent(type: string, target: HTMLElement) {
const rect = target.getBoundingClientRect();
const clientX = rect.left + rect.width / 2;
const clientY = rect.top + rect.height / 2;
const event = new MouseEvent(type, {
bubbles: true,
clientX,
clientY,
buttons: 1,
});
Object.defineProperties(event, {
pageX: {
get() {
return clientX;
},
},
pageY: {
get() {
return clientY;
},
},
isPrimary: {
value: true,
},
});
target.dispatchEvent(event);
}
export function expectToBeCloseToArray(
actualNumbers: number[],
expectedNumbers: number[]
) {
expect(actualNumbers.length).toBe(expectedNumbers.length);
try {
actualNumbers.forEach((actualNumber, index) => {
const expectedNumber = expectedNumbers[index];
assert(expectedNumber != null, `Expected number not found`);
expect(actualNumber).toBeCloseTo(expectedNumber, 1);
});
} catch (error) {
expect(actualNumbers).toEqual(expectedNumbers);
}
}
export function mockBoundingClientRect(
element: HTMLElement,
rect: {
height: number;
width: number;
x: number;
y: number;
}
) {
const { height, width, x, y } = rect;
Object.defineProperty(element, "getBoundingClientRect", {
configurable: true,
value: () =>
({
bottom: y + height,
height,
left: x,
right: x + width,
toJSON() {
return "";
},
top: y,
width,
x,
y,
}) satisfies DOMRect,
});
}
export function mockPanelGroupOffsetWidthAndHeight(
mockWidth = 1_000,
mockHeight = 1_000
) {
const offsetHeightPropertyDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetHeight"
);
const offsetWidthPropertyDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetWidth"
);
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: function () {
if (this.hasAttribute("data-resize-handle")) {
return 0;
} else if (this.hasAttribute("data-panel-group")) {
return mockHeight;
}
},
});
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: function () {
if (this.hasAttribute("data-resize-handle")) {
return 0;
} else if (this.hasAttribute("data-panel-group")) {
return mockWidth;
}
},
});
return function uninstallMocks() {
if (offsetHeightPropertyDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
"offsetHeight",
offsetHeightPropertyDescriptor
);
}
if (offsetWidthPropertyDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
"offsetWidth",
offsetWidthPropertyDescriptor
);
}
};
}
export function verifyAttribute(
element: HTMLElement,
attributeName: string,
expectedValue: string | null
) {
const actualValue = element.getAttribute(attributeName);
expect(actualValue).toBe(expectedValue);
}
export function verifyExpandedPanelGroupLayout(
actualLayout: number[],
expectedLayout: number[]
) {
expect(actualLayout).toEqual(expectedLayout);
}
export function verifyExpectedWarnings(
callback: Function,
...expectedMessages: string[]
) {
const consoleSpy = (format: any, ...args: any[]) => {
const message = util.format(format, ...args);
for (let index = 0; index < expectedMessages.length; index++) {
const expectedMessage = expectedMessages[index];
if (message.includes(expectedMessage)) {
expectedMessages.splice(index, 1);
return;
}
}
if (expectedMessages.length === 0) {
throw new Error(`Unexpected message recorded:\n\n${message}`);
}
};
const originalError = console.error;
const originalWarn = console.warn;
console.error = consoleSpy;
console.warn = consoleSpy;
let caughtError;
let didCatch = false;
try {
callback();
} catch (error) {
caughtError = error;
didCatch = true;
} finally {
console.error = originalError;
console.warn = originalWarn;
if (didCatch) {
throw caughtError;
}
// Any remaining messages indicate a failed expectations.
if (expectedMessages.length > 0) {
throw Error(
`Expected message(s) not recorded:\n\n${expectedMessages.join("\n")}`
);
}
return { pass: true };
}
}

View File

@@ -0,0 +1,143 @@
import { verifyExpectedWarnings } from "./test-utils";
import { validatePanelConstraints } from "./validatePanelConstraints";
describe("validatePanelConstraints", () => {
it("should not warn if there are no validation errors", () => {
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [{}],
panelIndex: 0,
panelId: "test",
});
});
});
it("should warn about conflicting min/max sizes", () => {
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
maxSize: 5,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "min size (10%) should not be greater than max size (5%)");
});
it("should warn about conflicting collapsed and min sizes", () => {
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
collapsedSize: 15,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "collapsed size should not be greater than min size");
});
it("should warn about conflicting default and min/max sizes", () => {
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
defaultSize: -1,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be less than 0");
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
defaultSize: 5,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be less than min size");
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
collapsedSize: 5,
collapsible: true,
defaultSize: 5,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
});
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
collapsedSize: 7,
collapsible: true,
defaultSize: 5,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be less than min size");
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
collapsedSize: 5,
collapsible: false,
defaultSize: 5,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be less than min size");
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
defaultSize: 101,
maxSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be greater than 100");
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
defaultSize: 15,
maxSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be greater than max size");
});
});

View File

@@ -0,0 +1,69 @@
import { isDevelopment } from "#is-development";
import { PanelConstraints } from "../Panel";
import { assert } from "./assert";
export function validatePanelConstraints({
panelConstraints: panelConstraintsArray,
panelId,
panelIndex,
}: {
panelConstraints: PanelConstraints[];
panelId: string | undefined;
panelIndex: number;
}): boolean {
if (isDevelopment) {
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;
}

View File

@@ -0,0 +1,148 @@
import { verifyExpectedWarnings } from "./test-utils";
import { validatePanelGroupLayout } from "./validatePanelGroupLayout";
describe("validatePanelGroupLayout", () => {
it("should accept requested layout if there are no constraints provided", () => {
expect(
validatePanelGroupLayout({
layout: [10, 60, 30],
panelConstraints: [{}, {}, {}],
})
).toEqual([10, 60, 30]);
});
it("should normalize layouts that do not total 100%", () => {
let layout;
verifyExpectedWarnings(() => {
layout = validatePanelGroupLayout({
layout: [10, 20, 20],
panelConstraints: [{}, {}, {}],
});
}, "Invalid layout total size");
expect(layout).toEqual([20, 40, 40]);
verifyExpectedWarnings(() => {
layout = validatePanelGroupLayout({
layout: [50, 100, 50],
panelConstraints: [{}, {}, {}],
});
}, "Invalid layout total size");
expect(layout).toEqual([25, 50, 25]);
});
it("should reject layouts that do not match the number of panels", () => {
expect(() =>
validatePanelGroupLayout({
layout: [10, 20, 30],
panelConstraints: [{}, {}],
})
).toThrow("Invalid 2 panel layout");
expect(() =>
validatePanelGroupLayout({
layout: [50, 50],
panelConstraints: [{}, {}, {}],
})
).toThrow("Invalid 3 panel layout");
});
describe("minimum size constraints", () => {
it("should adjust the layout to account for minimum percentage sizes", () => {
expect(
validatePanelGroupLayout({
layout: [25, 75],
panelConstraints: [
{
minSize: 35,
},
{},
],
})
).toEqual([35, 65]);
});
it("should account for multiple panels with minimum size constraints", () => {
expect(
validatePanelGroupLayout({
layout: [20, 60, 20],
panelConstraints: [
{
minSize: 25,
},
{},
{
minSize: 25,
},
],
})
).toEqual([25, 50, 25]);
});
});
describe("maximum size constraints", () => {
it("should adjust the layout to account for maximum percentage sizes", () => {
expect(
validatePanelGroupLayout({
layout: [25, 75],
panelConstraints: [{}, { maxSize: 65 }],
})
).toEqual([35, 65]);
});
it("should account for multiple panels with maximum size constraints", () => {
expect(
validatePanelGroupLayout({
layout: [20, 60, 20],
panelConstraints: [
{
maxSize: 15,
},
{ maxSize: 50 },
{},
],
})
).toEqual([15, 50, 35]);
});
});
describe("collapsible panels", () => {
it("should not collapse a panel that's at or above the minimum size", () => {
expect(
validatePanelGroupLayout({
layout: [25, 75],
panelConstraints: [{ collapsible: true, minSize: 25 }, {}],
})
).toEqual([25, 75]);
});
it("should collapse a panel once it drops below the halfway point between collapsed and minimum percentage sizes", () => {
expect(
validatePanelGroupLayout({
layout: [15, 85],
panelConstraints: [
{
collapsible: true,
collapsedSize: 10,
minSize: 20,
},
{},
],
})
).toEqual([20, 80]);
expect(
validatePanelGroupLayout({
layout: [14, 86],
panelConstraints: [
{
collapsible: true,
collapsedSize: 10,
minSize: 20,
},
{},
],
})
).toEqual([10, 90]);
});
});
});

View File

@@ -0,0 +1,95 @@
import { isDevelopment } from "#is-development";
import { PanelConstraints } from "../Panel";
import { assert } from "./assert";
import { fuzzyNumbersEqual } from "./numbers/fuzzyNumbersEqual";
import { resizePanel } from "./resizePanel";
// All units must be in percentages; pixel values should be pre-converted
export function validatePanelGroupLayout({
layout: prevLayout,
panelConstraints,
}: {
layout: number[];
panelConstraints: PanelConstraints[];
}): number[] {
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) &&
nextLayout.length > 0
) {
// This is not ideal so we should warn about it, but it may be recoverable in some cases
// (especially if the amount is small)
if (isDevelopment) {
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;
}