From 30ed78cf252c5d76c0016846ed9717c4eb4afc29 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 21 Oct 2023 09:51:18 -0400 Subject: [PATCH] Wired up onLayout and onResize/onExpand/onCollapse callbacks --- .../examples/ImperativePanelGroupApi.tsx | 9 +- packages/react-resizable-panels/src/index.ts | 2 + .../react-resizable-panels/src/new/Panel.ts | 11 +- .../src/new/PanelGroup.ts | 178 ++++++++++++++---- .../src/new/utils/callPanelCallbacks.ts | 77 ++++++++ .../getPercentageSizeFromMixedSizes.test.ts | 49 +++++ .../utils/getPercentageSizeFromMixedSizes.ts | 15 ++ 7 files changed, 297 insertions(+), 44 deletions(-) create mode 100644 packages/react-resizable-panels/src/new/utils/callPanelCallbacks.ts create mode 100644 packages/react-resizable-panels/src/new/utils/getPercentageSizeFromMixedSizes.test.ts create mode 100644 packages/react-resizable-panels/src/new/utils/getPercentageSizeFromMixedSizes.ts diff --git a/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx b/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx index df7ac93e8..b1274c969 100644 --- a/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx +++ b/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx @@ -1,5 +1,8 @@ import { useRef, useState } from "react"; -import type { new_ImperativePanelGroupHandle as ImperativePanelGroupHandle } from "react-resizable-panels"; +import type { + MixedSizes, + new_ImperativePanelGroupHandle as ImperativePanelGroupHandle, +} from "react-resizable-panels"; import { new_Panel as Panel, new_PanelGroup as PanelGroup, @@ -54,8 +57,8 @@ function Content() { const panelGroupRef = useRef(null); - const onLayout = (sizes: number[]) => { - setSizes(sizes); + const onLayout = (mixedSizes: MixedSizes[]) => { + setSizes(mixedSizes.map((mixedSize) => mixedSize.sizePercentage)); }; const resetLayout = () => { diff --git a/packages/react-resizable-panels/src/index.ts b/packages/react-resizable-panels/src/index.ts index dd53da68b..ae4763058 100644 --- a/packages/react-resizable-panels/src/index.ts +++ b/packages/react-resizable-panels/src/index.ts @@ -3,6 +3,7 @@ import { PanelGroup } from "./PanelGroup"; import { PanelResizeHandle } from "./PanelResizeHandle"; // TEMP +import type { MixedSizes } from "./new/types"; import { Panel as new_Panel } from "./new/Panel"; import type { ImperativePanelHandle as new_ImperativePanelHandle } from "./new/Panel"; import { PanelGroup as new_PanelGroup } from "./new/PanelGroup"; @@ -26,6 +27,7 @@ export { // TypeScript types ImperativePanelGroupHandle, ImperativePanelHandle, + MixedSizes, PanelOnCollapse, PanelOnResize, PanelGroupOnLayout, diff --git a/packages/react-resizable-panels/src/new/Panel.ts b/packages/react-resizable-panels/src/new/Panel.ts index 8a0248958..4bfb7d74b 100644 --- a/packages/react-resizable-panels/src/new/Panel.ts +++ b/packages/react-resizable-panels/src/new/Panel.ts @@ -16,13 +16,10 @@ import { MixedSizes } from "./types"; export type OnCollapse = () => void; export type OnExpand = () => void; -export type OnResize = ({ - sizePercentage, - sizePixels, -}: { - sizePercentage: number; - sizePixels: number; -}) => void; +export type OnResize = ( + mixedSizes: MixedSizes, + prevMixedSizes: MixedSizes +) => void; export type PanelCallbacks = { onCollapse?: OnCollapse; diff --git a/packages/react-resizable-panels/src/new/PanelGroup.ts b/packages/react-resizable-panels/src/new/PanelGroup.ts index c5a98f770..93d07ec55 100644 --- a/packages/react-resizable-panels/src/new/PanelGroup.ts +++ b/packages/react-resizable-panels/src/new/PanelGroup.ts @@ -24,15 +24,16 @@ import { Direction, MixedSizes } from "./types"; import { adjustLayoutByDelta } from "./utils/adjustLayoutByDelta"; import { calculateDefaultLayout } from "./utils/calculateDefaultLayout"; import { calculateDeltaPercentage } from "./utils/calculateDeltaPercentage"; +import { callPanelCallbacks } from "./utils/callPanelCallbacks"; import { compareLayouts } from "./utils/compareLayouts"; import { computePanelFlexBoxStyle } from "./utils/computePanelFlexBoxStyle"; import { computePercentagePanelConstraints } from "./utils/computePercentagePanelConstraints"; import { convertPercentageToPixels } from "./utils/convertPercentageToPixels"; -import { convertPixelsToPercentage } from "./utils/convertPixelsToPercentage"; import { determinePivotIndices } from "./utils/determinePivotIndices"; import { calculateAvailablePanelSizeInPixels } from "./utils/dom/calculateAvailablePanelSizeInPixels"; import { getResizeHandleElement } from "./utils/dom/getResizeHandleElement"; import { isKeyDown, isMouseEvent, isTouchEvent } from "./utils/events"; +import { getPercentageSizeFromMixedSizes } from "./utils/getPercentageSizeFromMixedSizes"; import { getResizeEventCursorPosition } from "./utils/getResizeEventCursorPosition"; import { initializeDefaultStorage } from "./utils/initializeDefaultStorage"; import { loadPanelLayout, savePanelGroupLayout } from "./utils/serialization"; @@ -41,6 +42,7 @@ import { validatePanelGroupLayout } from "./utils/validatePanelGroupLayout"; // TODO Move group/DOM helpers into new package // TODO Use ResizeObserver (but only if any Panels declare pixels units) +// ResizeObserver should trigger validatePanelGroupLayout() and callPanelCallbacks() when size changes export type ImperativePanelGroupHandle = { getId: () => string; @@ -53,7 +55,7 @@ export type PanelGroupStorage = { setItem(name: string, value: string): void; }; -export type PanelGroupOnLayout = (sizes: number[]) => void; +export type PanelGroupOnLayout = (layout: MixedSizes[]) => void; export type PanelOnCollapse = (collapsed: boolean) => void; export type PanelOnResize = (size: number, prevSize: number) => void; export type PanelResizeHandleOnDragging = (isDragging: boolean) => void; @@ -74,7 +76,7 @@ export type PanelGroupProps = PropsWithChildren<{ className?: string; direction: Direction; id?: string | null; - onLayout?: PanelGroupOnLayout; + onLayout?: PanelGroupOnLayout | null; storage?: PanelGroupStorage; style?: CSSProperties; tagName?: ElementType; @@ -91,7 +93,7 @@ function PanelGroupWithForwardedRef({ direction, forwardedRef, id: idFromProps, - onLayout, + onLayout = null, storage = defaultStorage, style: styleFromProps, tagName: Type = "div", @@ -104,6 +106,9 @@ function PanelGroupWithForwardedRef({ const [layout, setLayout] = useState([]); const [panelDataArray, setPanelDataArray] = useState([]); + const panelIdToLastNotifiedMixedSizesMapRef = useRef< + Record + >({}); const prevDeltaRef = useRef(0); const committedValuesRef = useRef<{ @@ -111,12 +116,14 @@ function PanelGroupWithForwardedRef({ dragState: DragState | null; id: string; layout: number[]; + onLayout: PanelGroupOnLayout | null; panelDataArray: PanelData[]; }>({ direction, dragState, id: groupId, layout, + onLayout, panelDataArray, }); @@ -143,21 +150,14 @@ function PanelGroupWithForwardedRef({ const { id: groupId, layout: prevLayout, + onLayout, panelDataArray, } = committedValuesRef.current; const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId); - const unsafeLayout = mixedSizes.map( - ({ sizePercentage, sizePixels }) => { - if (sizePercentage != null) { - return sizePercentage; - } else if (sizePixels != null) { - return convertPixelsToPercentage(sizePixels, groupSizePixels); - } else { - throw Error("Invalid layout"); - } - } + const unsafeLayout = mixedSizes.map((mixedSize) => + getPercentageSizeFromMixedSizes(mixedSize, groupSizePixels) ); const safeLayout = validatePanelGroupLayout({ @@ -171,7 +171,24 @@ function PanelGroupWithForwardedRef({ if (!areEqual(prevLayout, safeLayout)) { setLayout(safeLayout); - // TODO Callbacks + if (onLayout) { + onLayout( + safeLayout.map((sizePercentage) => ({ + sizePercentage, + sizePixels: convertPercentageToPixels( + sizePercentage, + groupSizePixels + ), + })) + ); + } + + callPanelCallbacks( + groupId, + panelDataArray, + safeLayout, + panelIdToLastNotifiedMixedSizesMapRef.current + ); } }, }), @@ -183,6 +200,7 @@ function PanelGroupWithForwardedRef({ committedValuesRef.current.dragState = dragState; committedValuesRef.current.id = groupId; committedValuesRef.current.layout = layout; + committedValuesRef.current.onLayout = onLayout; committedValuesRef.current.panelDataArray = panelDataArray; }); @@ -207,7 +225,7 @@ function PanelGroupWithForwardedRef({ // Compute the initial sizes based on default weights. // This assumes that panels register during initial mount (no conditional rendering)! useIsomorphicLayoutEffect(() => { - const { id: groupId, layout } = committedValuesRef.current; + const { id: groupId, layout, onLayout } = committedValuesRef.current; if (layout.length === panelDataArray.length) { // Only compute (or restore) default layout once per panel configuration. return; @@ -242,12 +260,28 @@ function PanelGroupWithForwardedRef({ if (!areEqual(layout, validatedLayout)) { setLayout(validatedLayout); } + + if (onLayout) { + onLayout( + validatedLayout.map((sizePercentage) => ({ + sizePercentage, + sizePixels: convertPercentageToPixels( + sizePercentage, + groupSizePixels + ), + })) + ); + } }, [autoSaveId, layout, panelDataArray, storage]); // External APIs are safe to memoize via committed values ref const collapsePanel = useCallback( (panelData: PanelData) => { - const { layout, panelDataArray } = committedValuesRef.current; + const { + layout: prevLayout, + onLayout, + panelDataArray, + } = committedValuesRef.current; if (panelData.constraints.collapsible) { const panelConstraintsArray = panelDataArray.map( @@ -259,7 +293,7 @@ function PanelGroupWithForwardedRef({ panelSizePercentage, pivotIndices, groupSizePixels, - } = panelDataHelper(groupId, panelDataArray, panelData, layout); + } = panelDataHelper(groupId, panelDataArray, panelData, prevLayout); if (panelSizePercentage !== collapsedSizePercentage) { // TODO Store size before collapse @@ -267,17 +301,33 @@ function PanelGroupWithForwardedRef({ const nextLayout = adjustLayoutByDelta({ delta: collapsedSizePercentage - panelSizePercentage, groupSizePixels, - layout, + layout: prevLayout, panelConstraints: panelConstraintsArray, pivotIndices, trigger: "imperative-api", }); - if (!compareLayouts(layout, nextLayout)) { + if (!compareLayouts(prevLayout, nextLayout)) { setLayout(nextLayout); - // TODO Callbacks and stuff (put in helper function that takes prevLayout, nextLayout) - // onLayout() + if (onLayout) { + onLayout( + nextLayout.map((sizePercentage) => ({ + sizePercentage, + sizePixels: convertPercentageToPixels( + sizePercentage, + groupSizePixels + ), + })) + ); + } + + callPanelCallbacks( + groupId, + panelDataArray, + nextLayout, + panelIdToLastNotifiedMixedSizesMapRef.current + ); } } } @@ -288,7 +338,11 @@ function PanelGroupWithForwardedRef({ // External APIs are safe to memoize via committed values ref const expandPanel = useCallback( (panelData: PanelData) => { - const { layout, panelDataArray } = committedValuesRef.current; + const { + layout: prevLayout, + onLayout, + panelDataArray, + } = committedValuesRef.current; if (panelData.constraints.collapsible) { const panelConstraintsArray = panelDataArray.map( @@ -301,24 +355,41 @@ function PanelGroupWithForwardedRef({ minSizePercentage, pivotIndices, groupSizePixels, - } = panelDataHelper(groupId, panelDataArray, panelData, layout); + } = panelDataHelper(groupId, panelDataArray, panelData, prevLayout); if (panelSizePercentage === collapsedSizePercentage) { - // TODO Retrieve size before collapse + // TODO Retrieve size before collapse; this should be the default new size const nextLayout = adjustLayoutByDelta({ delta: minSizePercentage - panelSizePercentage, groupSizePixels, - layout, + layout: prevLayout, panelConstraints: panelConstraintsArray, pivotIndices, trigger: "imperative-api", }); - if (!compareLayouts(layout, nextLayout)) { + if (!compareLayouts(prevLayout, nextLayout)) { setLayout(nextLayout); - // TODO Callbacks and stuff (put in helper function that takes prevLayout, nextLayout) + if (onLayout) { + onLayout( + nextLayout.map((sizePercentage) => ({ + sizePercentage, + sizePixels: convertPercentageToPixels( + sizePercentage, + groupSizePixels + ), + })) + ); + } + + callPanelCallbacks( + groupId, + panelDataArray, + nextLayout, + panelIdToLastNotifiedMixedSizesMapRef.current + ); } } } @@ -416,6 +487,7 @@ function PanelGroupWithForwardedRef({ direction, dragState, id: groupId, + onLayout, panelDataArray, layout: prevLayout, } = committedValuesRef.current; @@ -488,7 +560,24 @@ function PanelGroupWithForwardedRef({ if (layoutChanged) { setLayout(nextLayout); - // TODO Callbacks and stuff (put in helper function that takes prevLayout, nextLayout) + if (onLayout) { + onLayout( + nextLayout.map((sizePercentage) => ({ + sizePercentage, + sizePixels: convertPercentageToPixels( + sizePercentage, + groupSizePixels + ), + })) + ); + } + + callPanelCallbacks( + groupId, + panelDataArray, + nextLayout, + panelIdToLastNotifiedMixedSizesMapRef.current + ); } }; }, []); @@ -496,28 +585,49 @@ function PanelGroupWithForwardedRef({ // External APIs are safe to memoize via committed values ref const resizePanel = useCallback( (panelData: PanelData, sizePercentage: number) => { - const { layout, panelDataArray } = committedValuesRef.current; + const { + layout: prevLayout, + onLayout, + panelDataArray, + } = committedValuesRef.current; const panelConstraintsArray = panelDataArray.map( (panelData) => panelData.constraints ); const { groupSizePixels, panelSizePercentage, pivotIndices } = - panelDataHelper(groupId, panelDataArray, panelData, layout); + panelDataHelper(groupId, panelDataArray, panelData, prevLayout); const nextLayout = adjustLayoutByDelta({ delta: sizePercentage - panelSizePercentage, groupSizePixels, - layout, + layout: prevLayout, panelConstraints: panelConstraintsArray, pivotIndices, trigger: "imperative-api", }); - if (!compareLayouts(layout, nextLayout)) { + if (!compareLayouts(prevLayout, nextLayout)) { setLayout(nextLayout); - // TODO Callbacks and stuff (put in helper function that takes prevLayout, nextLayout) + if (onLayout) { + onLayout( + nextLayout.map((sizePercentage) => ({ + sizePercentage, + sizePixels: convertPercentageToPixels( + sizePercentage, + groupSizePixels + ), + })) + ); + } + + callPanelCallbacks( + groupId, + panelDataArray, + nextLayout, + panelIdToLastNotifiedMixedSizesMapRef.current + ); } }, [groupId] diff --git a/packages/react-resizable-panels/src/new/utils/callPanelCallbacks.ts b/packages/react-resizable-panels/src/new/utils/callPanelCallbacks.ts new file mode 100644 index 000000000..04e0ad306 --- /dev/null +++ b/packages/react-resizable-panels/src/new/utils/callPanelCallbacks.ts @@ -0,0 +1,77 @@ +import { PanelData } from "../Panel"; +import { MixedSizes } from "../types"; +import { convertPercentageToPixels } from "./convertPercentageToPixels"; +import { calculateAvailablePanelSizeInPixels } from "./dom/calculateAvailablePanelSizeInPixels"; +import { getPercentageSizeFromMixedSizes } from "./getPercentageSizeFromMixedSizes"; + +// Layout should be pre-converted into percentages +export function callPanelCallbacks( + groupId: string, + panelsArray: PanelData[], + layout: number[], + panelIdToLastNotifiedMixedSizesMap: Record +) { + const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId); + + layout.forEach((sizePercentage, index) => { + const panelData = panelsArray[index]; + if (!panelData) { + // Handle initial mount (when panels are registered too late to be in the panels array) + // The subsequent render+effects will handle the resize notification + return; + } + + const { callbacks, constraints, id: panelId } = panelData; + const { collapsible } = constraints; + + const mixedSizes: MixedSizes = { + sizePercentage, + sizePixels: convertPercentageToPixels(sizePercentage, groupSizePixels), + }; + + const lastNotifiedMixedSizes = panelIdToLastNotifiedMixedSizesMap[panelId]; + if ( + lastNotifiedMixedSizes == null || + mixedSizes.sizePercentage !== lastNotifiedMixedSizes.sizePercentage || + mixedSizes.sizePixels !== lastNotifiedMixedSizes.sizePixels + ) { + panelIdToLastNotifiedMixedSizesMap[panelId] = mixedSizes; + + const { onCollapse, onExpand, onResize } = callbacks; + + if (onResize) { + onResize(mixedSizes, lastNotifiedMixedSizes); + } + + if (collapsible && (onCollapse || onExpand)) { + const collapsedSize = getPercentageSizeFromMixedSizes( + { + sizePercentage: constraints.collapsedSizePercentage, + sizePixels: constraints.collapsedSizePixels, + }, + groupSizePixels + ); + + const size = getPercentageSizeFromMixedSizes( + mixedSizes, + groupSizePixels + ); + + if ( + onExpand && + (lastNotifiedMixedSizes == null || + lastNotifiedMixedSizes.sizePercentage === collapsedSize) && + size !== collapsedSize + ) { + onExpand(); + } else if ( + onCollapse && + lastNotifiedMixedSizes.sizePercentage !== collapsedSize && + size === collapsedSize + ) { + onCollapse(); + } + } + } + }); +} diff --git a/packages/react-resizable-panels/src/new/utils/getPercentageSizeFromMixedSizes.test.ts b/packages/react-resizable-panels/src/new/utils/getPercentageSizeFromMixedSizes.test.ts new file mode 100644 index 000000000..23b4892e1 --- /dev/null +++ b/packages/react-resizable-panels/src/new/utils/getPercentageSizeFromMixedSizes.test.ts @@ -0,0 +1,49 @@ +import { getPercentageSizeFromMixedSizes } from "./getPercentageSizeFromMixedSizes"; + +describe("getPercentageSizeFromMixedSizes", () => { + it("should return percentage sizes as-is", () => { + expect( + getPercentageSizeFromMixedSizes( + { + sizePercentage: 50, + }, + 100_000 + ) + ).toBe(50); + expect( + getPercentageSizeFromMixedSizes( + { + sizePercentage: 25, + sizePixels: 100, + }, + 100_000 + ) + ).toBe(25); + }); + + it("should convert pixels to percentages", () => { + expect( + getPercentageSizeFromMixedSizes( + { + sizePixels: 50_000, + }, + 100_000 + ) + ).toBe(50); + expect( + getPercentageSizeFromMixedSizes( + { + sizePercentage: 25, + sizePixels: 50_000, + }, + 100_000 + ) + ).toBe(25); + }); + + it("should throw if neither pixel nor percentage sizes specified", () => { + expect(() => getPercentageSizeFromMixedSizes({}, 100_000)).toThrowError( + "Invalid size" + ); + }); +}); diff --git a/packages/react-resizable-panels/src/new/utils/getPercentageSizeFromMixedSizes.ts b/packages/react-resizable-panels/src/new/utils/getPercentageSizeFromMixedSizes.ts new file mode 100644 index 000000000..3fc9ed910 --- /dev/null +++ b/packages/react-resizable-panels/src/new/utils/getPercentageSizeFromMixedSizes.ts @@ -0,0 +1,15 @@ +import { MixedSizes } from "../types"; +import { convertPixelsToPercentage } from "./convertPixelsToPercentage"; + +export function getPercentageSizeFromMixedSizes( + { sizePercentage, sizePixels }: Partial, + groupSizePixels: number +): number { + if (sizePercentage != null) { + return sizePercentage; + } else if (sizePixels != null) { + return convertPixelsToPercentage(sizePixels, groupSizePixels); + } + + throw Error("Invalid size"); +}