From 73575cff7ad00e72decce4651d38c1e853b118a4 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 22 Feb 2026 15:06:23 -0500 Subject: [PATCH 1/3] Internal mutable state refactoring (#672) - Codify which parts of internal `RegisteredGroup` data structure are allowed to be mutable and which parts must be read-only after mounting. - Tidied up internal global+mutable state and removed some unnecessary events/listeners This is a lot less bold than the refactor I originally had in mind with #598, but it's also much safer and easier to land incrementally. --- lib/components/group/Group.test.tsx | 17 +- lib/components/group/Group.tsx | 167 +++++++++--------- lib/components/group/types.ts | 22 +-- lib/components/panel/Panel.test.tsx | 22 +-- lib/components/panel/Panel.tsx | 12 +- lib/components/separator/Separator.test.tsx | 23 ++- lib/components/separator/Separator.tsx | 65 +++---- lib/global/cursor/getCursorStyle.test.ts | 12 +- lib/global/cursor/getCursorStyle.ts | 4 +- lib/global/cursor/updateCursorStyle.ts | 6 +- .../dom/calculateAvailableGroupSize.test.ts | 16 +- .../event-handlers/onDocumentDoubleClick.ts | 5 +- .../event-handlers/onDocumentKeyDown.ts | 9 +- .../event-handlers/onDocumentPointerDown.ts | 18 +- .../event-handlers/onDocumentPointerLeave.ts | 8 +- .../event-handlers/onDocumentPointerMove.ts | 50 +++--- .../event-handlers/onDocumentPointerOut.ts | 14 +- .../event-handlers/onDocumentPointerUp.ts | 24 ++- lib/global/mountGroup.ts | 130 +++++++------- lib/global/mutable-state/groups.ts | 100 +++++++++++ lib/global/mutable-state/interactions.ts | 51 ++++++ lib/global/mutable-state/types.ts | 34 ++++ lib/global/mutableState.ts | 104 ----------- lib/global/test/mockGroup.ts | 18 +- lib/global/types.ts | 24 --- lib/global/utils/adjustLayoutForSeparator.ts | 28 +-- .../utils/findMatchingHitRegions.test.ts | 25 +-- lib/global/utils/findMatchingHitRegions.ts | 4 +- lib/global/utils/findSeparatorGroup.ts | 4 +- .../utils/getImperativeGroupMethods.test.ts | 70 ++++---- lib/global/utils/getImperativeGroupMethods.ts | 18 +- .../utils/getImperativePanelMethods.test.ts | 35 ++-- lib/global/utils/getImperativePanelMethods.ts | 18 +- lib/global/utils/getMountedGroup.ts | 12 -- lib/global/utils/updateActiveHitRegion.ts | 21 ++- 35 files changed, 617 insertions(+), 573 deletions(-) create mode 100644 lib/global/mutable-state/groups.ts create mode 100644 lib/global/mutable-state/interactions.ts create mode 100644 lib/global/mutable-state/types.ts delete mode 100644 lib/global/mutableState.ts delete mode 100644 lib/global/types.ts delete mode 100644 lib/global/utils/getMountedGroup.ts diff --git a/lib/components/group/Group.test.tsx b/lib/components/group/Group.test.tsx index b6b352ea9..5d48c8232 100644 --- a/lib/components/group/Group.test.tsx +++ b/lib/components/group/Group.test.tsx @@ -9,7 +9,7 @@ import { type RefObject } from "react"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import { eventEmitter } from "../../global/mutableState"; +import { subscribeToMountedGroup } from "../../global/mutable-state/groups"; import { moveSeparator } from "../../global/test/moveSeparator"; import { assert } from "../../utils/assert"; import { @@ -25,11 +25,8 @@ import { useGroupRef } from "./useGroupRef"; describe("Group", () => { test("changes to defaultProps or disableCursor should not cause Group to remount", () => { - const onMountedGroupsChange = vi.fn(); - const removeListener = eventEmitter.addListener( - "mountedGroupsChange", - onMountedGroupsChange - ); + const onChange = vi.fn(); + const removeListener = subscribeToMountedGroup("group", onChange); const { rerender } = render( { b: 50 }} disableCursor={false} + id="group" > ); - expect(onMountedGroupsChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalled(); - onMountedGroupsChange.mockReset(); + onChange.mockReset(); rerender( { b: 65 }} disableCursor={true} + id="group" > ); - expect(onMountedGroupsChange).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); removeListener(); }); diff --git a/lib/components/group/Group.tsx b/lib/components/group/Group.tsx index 17b5e2a35..064517600 100644 --- a/lib/components/group/Group.tsx +++ b/lib/components/group/Group.tsx @@ -3,7 +3,14 @@ import { useEffect, useMemo, useRef, type CSSProperties } from "react"; import { calculatePanelConstraints } from "../../global/dom/calculatePanelConstraints"; import { mountGroup } from "../../global/mountGroup"; -import { eventEmitter, read } from "../../global/mutableState"; +import { + getMountedGroupState, + getRegisteredGroup, + subscribeToMountedGroup, + updateMountedGroup +} from "../../global/mutable-state/groups"; +import { getInteractionState } from "../../global/mutable-state/interactions"; +import { layoutNumbersEqual } from "../../global/utils/layoutNumbersEqual"; import { layoutsEqual } from "../../global/utils/layoutsEqual"; import { useForceUpdate } from "../../hooks/useForceUpdate"; import { useId } from "../../hooks/useId"; @@ -109,28 +116,24 @@ export function Group({ // TRICKY Don't read for state; it will always lag behind by one tick const getPanelStyles = useStableCallback( (groupId: string, panelId: string) => { - const { interactionState, mountedGroups } = read(); - - for (const group of mountedGroups.keys()) { - if (group.id === groupId) { - const match = mountedGroups.get(group); - if (match) { - let dragActive = false; - switch (interactionState.state) { - case "active": { - dragActive = interactionState.hitRegions.some( - (current) => current.group === group - ); - break; - } - } - - return { - flexGrow: match.layout[panelId] ?? 1, - pointerEvents: dragActive ? "none" : undefined - } satisfies CSSProperties; + const interactionState = getInteractionState(); + const group = getRegisteredGroup(groupId); + const groupState = getMountedGroupState(groupId); + if (groupState) { + let dragActive = false; + switch (interactionState.state) { + case "active": { + dragActive = interactionState.hitRegions.some( + (current) => current.group === group + ); + break; } } + + return { + flexGrow: groupState.layout[panelId] ?? 1, + pointerEvents: dragActive ? "none" : undefined + } satisfies CSSProperties; } // This is unexpected except for the initial mount (before the group has registered with the global store) @@ -196,14 +199,13 @@ export function Group({ panel.panelConstraints.disabled = disabled; } - const { mountedGroups } = read(); - for (const group of mountedGroups.keys()) { - if (group.id === id) { - const match = mountedGroups.get(group); - if (match) { - match.derivedPanelConstraints = calculatePanelConstraints(group); - } - } + const group = getRegisteredGroup(id); + const groupState = getMountedGroupState(id); + if (group && groupState) { + updateMountedGroup(group, { + ...groupState, + derivedPanelConstraints: calculatePanelConstraints(group) + }); } }, toggleSeparatorDisabled: (separatorId: string, disabled: boolean) => { @@ -249,14 +251,15 @@ export function Group({ } const group: RegisteredGroup = { - defaultLayout: preSortedDefaultLayout, - disableCursor: !!stableProps.disableCursor, disabled: !!disabled, element, id, - inMemoryLastExpandedPanelSizes: - inMemoryValuesRef.current.lastExpandedPanelSizes, - inMemoryLayouts: inMemoryValuesRef.current.layouts, + mutableState: { + defaultLayout: preSortedDefaultLayout, + disableCursor: !!stableProps.disableCursor, + expandedPanelSizes: inMemoryValuesRef.current.lastExpandedPanelSizes, + layouts: inMemoryValuesRef.current.layouts + }, orientation, panels: inMemoryValues.panels, resizeTargetMinimumSize: inMemoryValues.resizeTargetMinimumSize, @@ -267,66 +270,66 @@ export function Group({ const unmountGroup = mountGroup(group); - const globalState = read(); - const match = globalState.mountedGroups.get(group); - if (match) { - const { defaultLayoutDeferred, derivedPanelConstraints, layout } = match; + const { defaultLayoutDeferred, derivedPanelConstraints, layout } = + getMountedGroupState(group.id, true); - if (!defaultLayoutDeferred && derivedPanelConstraints.length > 0) { - onLayoutChangeStable(layout); - onLayoutChangedStable(layout); - } + if (!defaultLayoutDeferred && derivedPanelConstraints.length > 0) { + onLayoutChangeStable(layout); + onLayoutChangedStable(layout); } - let prevInteractionStateActive = false; + const removeChangeEventListener = subscribeToMountedGroup(id, (event) => { + const { defaultLayoutDeferred, derivedPanelConstraints, layout } = + event.next; - const removeInteractionStateChangeListener = eventEmitter.addListener( - "interactionStateChange", - (interactionState) => { - const nextInteractionStateActive = interactionState.state === "active"; - if (prevInteractionStateActive !== nextInteractionStateActive) { - prevInteractionStateActive = nextInteractionStateActive; - } + if (defaultLayoutDeferred || derivedPanelConstraints.length === 0) { + // This indicates that the Group has not finished mounting yet + // Likely because it has been rendered inside of a hidden DOM subtree + // Ignore layouts in this case because they will not have been validated + return; } - ); - - const removeMountedGroupsChangeEventListener = eventEmitter.addListener( - "mountedGroupsChange", - (mountedGroups) => { - const match = mountedGroups.get(group); - if (match) { - const { defaultLayoutDeferred, derivedPanelConstraints, layout } = - match; - - if (defaultLayoutDeferred || derivedPanelConstraints.length === 0) { - // This indicates that the Group has not finished mounting yet - // Likely because it has been rendered inside of a hidden DOM subtree - // Ignore layouts in this case because they will not have been validated - return; - } - - // Save the layout to in-memory cache so it persists when panel configuration changes - // This improves UX for conditionally rendered panels without requiring defaultLayout - const panelIdsKey = group.panels.map(({ id }) => id).join(","); - group.inMemoryLayouts[panelIdsKey] = layout; - - const { interactionState } = read(); - const isCompleted = interactionState.state !== "active"; - onLayoutChangeStable(layout); - if (isCompleted) { - onLayoutChangedStable(layout); + // Save the layout to in-memory cache so it persists when panel configuration changes + // This improves UX for conditionally rendered panels without requiring defaultLayout + const panelIdsKey = group.panels.map(({ id }) => id).join(","); + group.mutableState.layouts[panelIdsKey] = layout; + + // Also check if any collapsible Panels were collapsed in this update, + // and record their previous sizes so we can restore them on expand + derivedPanelConstraints.forEach((constraints) => { + if (constraints.collapsible) { + const { layout: prevLayout } = event.prev ?? {}; + if (prevLayout) { + const isCollapsed = layoutNumbersEqual( + constraints.collapsedSize, + layout[constraints.panelId] + ); + const wasCollapsed = layoutNumbersEqual( + constraints.collapsedSize, + prevLayout[constraints.panelId] + ); + if (isCollapsed && !wasCollapsed) { + group.mutableState.expandedPanelSizes[constraints.panelId] = + prevLayout[constraints.panelId]; + } } } + }); + + // Lastly notify layout-change(d) handlers of the update + const interactionState = getInteractionState(); + const isCompleted = interactionState.state !== "active"; + onLayoutChangeStable(layout); + if (isCompleted) { + onLayoutChangedStable(layout); } - ); + }); return () => { registeredGroupRef.current = null; unmountGroup(); - removeInteractionStateChangeListener(); - removeMountedGroupsChangeEventListener(); + removeChangeEventListener(); }; }, [ disabled, @@ -343,8 +346,8 @@ export function Group({ useEffect(() => { const registeredGroup = registeredGroupRef.current; if (registeredGroup) { - registeredGroup.defaultLayout = defaultLayout; - registeredGroup.disableCursor = !!disableCursor; + registeredGroup.mutableState.defaultLayout = defaultLayout; + registeredGroup.mutableState.disableCursor = !!disableCursor; } }); diff --git a/lib/components/group/types.ts b/lib/components/group/types.ts index 0767d750b..df6fb4c10 100644 --- a/lib/components/group/types.ts +++ b/lib/components/group/types.ts @@ -27,25 +27,25 @@ export type ResizeTargetMinimumSize = { fine: number; }; -export type RegisteredGroup = { - defaultLayout: Layout | undefined; - disableCursor: boolean; +export type RegisteredGroup = Readonly<{ disabled: boolean; element: HTMLElement; id: string; - // TODO Move to mutable state - inMemoryLastExpandedPanelSizes: { - [panelId: string]: number; - }; - // TODO Move to mutable state - inMemoryLayouts: { - [panelIds: string]: Layout; + mutableState: { + defaultLayout: Readonly | undefined; + disableCursor: boolean; + expandedPanelSizes: { + [panelId: string]: number; + }; + layouts: { + [panelIds: string]: Layout; + }; }; orientation: Orientation; panels: RegisteredPanel[]; resizeTargetMinimumSize: ResizeTargetMinimumSize; separators: RegisteredSeparator[]; -}; +}>; export type GroupContextType = { disableCursor: boolean; diff --git a/lib/components/panel/Panel.test.tsx b/lib/components/panel/Panel.test.tsx index ec7a2c5a7..d18609dfc 100644 --- a/lib/components/panel/Panel.test.tsx +++ b/lib/components/panel/Panel.test.tsx @@ -1,7 +1,8 @@ import { act, render } from "@testing-library/react"; import { createRef, Profiler } from "react"; import { describe, expect, test, vi } from "vitest"; -import { eventEmitter } from "../../global/mutableState"; +import { getRegisteredGroup } from "../../global/mutable-state/groups"; +import { moveSeparator } from "../../global/test/moveSeparator"; import { assert } from "../../utils/assert"; import { setDefaultElementBounds, @@ -11,36 +12,29 @@ import { Group } from "../group/Group"; import type { GroupImperativeHandle } from "../group/types"; import { Separator } from "../separator/Separator"; import { Panel } from "./Panel"; -import { moveSeparator } from "../../global/test/moveSeparator"; describe("Panel", () => { describe("disabled prop", () => { test("changes to disabled prop should not cause the Panel to remount", () => { - const onMountedGroupsChange = vi.fn(); - const removeListener = eventEmitter.addListener( - "mountedGroupsChange", - onMountedGroupsChange - ); - const { rerender } = render( - + ); - expect(onMountedGroupsChange).toHaveBeenCalled(); - onMountedGroupsChange.mockReset(); + const { panels: panelsMounted } = getRegisteredGroup("group", true); rerender( - + ); - expect(onMountedGroupsChange).not.toHaveBeenCalled(); - removeListener(); + const { panels: panelsUpdated } = getRegisteredGroup("group", true); + + expect(panelsMounted).toBe(panelsUpdated); }); test("changes to this prop should update Panel behavior", async () => { diff --git a/lib/components/panel/Panel.tsx b/lib/components/panel/Panel.tsx index 01b840808..72a8d2507 100644 --- a/lib/components/panel/Panel.tsx +++ b/lib/components/panel/Panel.tsx @@ -6,15 +6,15 @@ import { useSyncExternalStore, type CSSProperties } from "react"; -import { eventEmitter } from "../../global/mutableState"; +import { subscribeToMountedGroup } from "../../global/mutable-state/groups"; import { useId } from "../../hooks/useId"; import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect"; import { useMergedRefs } from "../../hooks/useMergedRefs"; import { useStableCallback } from "../../hooks/useStableCallback"; +import { useStableObject } from "../../hooks/useStableObject"; import { useGroupContext } from "../group/useGroupContext"; import type { PanelProps, PanelSize, RegisteredPanel } from "./types"; import { usePanelImperativeHandle } from "./usePanelImperativeHandle"; -import { useStableObject } from "../../hooks/useStableObject"; /** * A Panel wraps resizable content and can be configured with min/max size constraints and collapsible behavior. @@ -135,13 +135,7 @@ export function Panel({ usePanelImperativeHandle(id, panelRef); const panelStylesString = useSyncExternalStore( - (subscribe) => { - eventEmitter.addListener("mountedGroupsChange", subscribe); - - return () => { - eventEmitter.removeListener("mountedGroupsChange", subscribe); - }; - }, + (subscribe) => subscribeToMountedGroup(groupId, subscribe), // useSyncExternalStore does not support a custom equality check // stringify avoids re-rendering when the style value hasn't changed diff --git a/lib/components/separator/Separator.test.tsx b/lib/components/separator/Separator.test.tsx index 88b34e469..fc81d806a 100644 --- a/lib/components/separator/Separator.test.tsx +++ b/lib/components/separator/Separator.test.tsx @@ -1,23 +1,20 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, test, vi } from "vitest"; +import { subscribeToMountedGroup } from "../../global/mutable-state/groups"; +import { moveSeparator } from "../../global/test/moveSeparator"; +import { setElementBoundsFunction } from "../../utils/test/mockBoundingClientRect"; import { Group } from "../group/Group"; import { Panel } from "../panel/Panel"; import { Separator } from "./Separator"; -import { eventEmitter } from "../../global/mutableState"; -import { setElementBoundsFunction } from "../../utils/test/mockBoundingClientRect"; -import { moveSeparator } from "../../global/test/moveSeparator"; describe("Separator", () => { describe("disabled prop", () => { test("changes to disabled prop should not cause the Separator to remount", () => { - const onMountedGroupsChange = vi.fn(); - const removeListener = eventEmitter.addListener( - "mountedGroupsChange", - onMountedGroupsChange - ); + const onChange = vi.fn(); + const removeListener = subscribeToMountedGroup("group", onChange); const { rerender } = render( - + @@ -25,12 +22,12 @@ describe("Separator", () => { ); - expect(onMountedGroupsChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalled(); - onMountedGroupsChange.mockReset(); + onChange.mockReset(); rerender( - + @@ -38,7 +35,7 @@ describe("Separator", () => { ); - expect(onMountedGroupsChange).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); removeListener(); }); diff --git a/lib/components/separator/Separator.tsx b/lib/components/separator/Separator.tsx index 07c3c681c..65beeb1d3 100644 --- a/lib/components/separator/Separator.tsx +++ b/lib/components/separator/Separator.tsx @@ -1,16 +1,17 @@ "use client"; +import type { Properties } from "csstype"; import { useEffect, useRef, useState } from "react"; -import { eventEmitter } from "../../global/mutableState"; -import type { InteractionState } from "../../global/types"; +import { subscribeToMountedGroup } from "../../global/mutable-state/groups"; +import { subscribeToInteractionState } from "../../global/mutable-state/interactions"; +import type { InteractionState } from "../../global/mutable-state/types"; import { calculateSeparatorAriaValues } from "../../global/utils/calculateSeparatorAriaValues"; import { useId } from "../../hooks/useId"; import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect"; import { useMergedRefs } from "../../hooks/useMergedRefs"; +import { useStableObject } from "../../hooks/useStableObject"; import { useGroupContext } from "../group/useGroupContext"; import type { RegisteredSeparator, SeparatorProps } from "./types"; -import type { Properties } from "csstype"; -import { useStableObject } from "../../hooks/useStableObject"; /** * Separators are not _required_ but they are _recommended_ as they improve keyboard accessibility. @@ -80,46 +81,38 @@ export function Separator({ const unregisterSeparator = registerSeparator(separator); - const removeInteractionStateChangeListener = eventEmitter.addListener( - "interactionStateChange", - (interactionState) => { + const removeInteractionStateChangeListener = subscribeToInteractionState( + (event) => { setDragState( - interactionState.state !== "inactive" && - interactionState.hitRegions.some( + event.next.state !== "inactive" && + event.next.hitRegions.some( (hitRegion) => hitRegion.separator === separator ) - ? interactionState.state + ? event.next.state : "inactive" ); } ); - const removeMountedGroupsChangeListener = eventEmitter.addListener( - "mountedGroupsChange", - (mountedGroups) => { - mountedGroups.forEach( - ( - { derivedPanelConstraints, layout, separatorToPanels }, - mountedGroup - ) => { - if (mountedGroup.id === groupId) { - const panels = separatorToPanels.get(separator); - if (panels) { - const primaryPanel = panels[0]; - const panelIndex = mountedGroup.panels.indexOf(primaryPanel); - - setAria( - calculateSeparatorAriaValues({ - layout, - panelConstraints: derivedPanelConstraints, - panelId: primaryPanel.id, - panelIndex - }) - ); - } - } - } - ); + const removeMountedGroupsChangeListener = subscribeToMountedGroup( + groupId, + (event) => { + const { derivedPanelConstraints, layout, separatorToPanels } = + event.next; + const panels = separatorToPanels.get(separator); + if (panels) { + const primaryPanel = panels[0]; + const panelIndex = panels.indexOf(primaryPanel); + + setAria( + calculateSeparatorAriaValues({ + layout, + panelConstraints: derivedPanelConstraints, + panelId: primaryPanel.id, + panelIndex + }) + ); + } } ); diff --git a/lib/global/cursor/getCursorStyle.test.ts b/lib/global/cursor/getCursorStyle.test.ts index ca00c8b09..68bf62bf6 100644 --- a/lib/global/cursor/getCursorStyle.test.ts +++ b/lib/global/cursor/getCursorStyle.test.ts @@ -10,18 +10,20 @@ import { getCursorStyle } from "./getCursorStyle"; import { overrideSupportsAdvancedCursorStylesForTesting } from "./supportsAdvancedCursorStyles"; describe("getCursorStyle", () => { - const horizontalGroup = mockGroup(new DOMRect(0, 0, 100, 50)); - horizontalGroup.orientation = "horizontal"; + const horizontalGroup = mockGroup(new DOMRect(0, 0, 100, 50), { + orientation: "horizontal" + }); horizontalGroup.addPanel(new DOMRect(0, 0, 50, 50)); horizontalGroup.addPanel(new DOMRect(50, 0, 50, 50)); - const verticalGroup = mockGroup(new DOMRect(0, 0, 100, 50)); - verticalGroup.orientation = "vertical"; + const verticalGroup = mockGroup(new DOMRect(0, 0, 100, 50), { + orientation: "vertical" + }); verticalGroup.addPanel(new DOMRect(0, 0, 50, 50)); verticalGroup.addPanel(new DOMRect(50, 0, 50, 50)); const disabledGroup = mockGroup(new DOMRect(0, 0, 100, 50)); - disabledGroup.disableCursor = true; + disabledGroup.mutableState.disableCursor = true; disabledGroup.addPanel(new DOMRect(0, 0, 50, 50)); disabledGroup.addPanel(new DOMRect(50, 0, 50, 50)); diff --git a/lib/global/cursor/getCursorStyle.ts b/lib/global/cursor/getCursorStyle.ts index e92a33d7e..af8e328ae 100644 --- a/lib/global/cursor/getCursorStyle.ts +++ b/lib/global/cursor/getCursorStyle.ts @@ -6,7 +6,7 @@ import { CURSOR_FLAG_VERTICAL_MAX, CURSOR_FLAG_VERTICAL_MIN } from "../../constants"; -import type { InteractionState } from "../types"; +import type { InteractionState } from "../mutable-state/types"; import { supportsAdvancedCursorStyles } from "./supportsAdvancedCursorStyles"; export function getCursorStyle({ @@ -25,7 +25,7 @@ export function getCursorStyle({ case "active": case "hover": { groups.forEach((group) => { - if (group.disableCursor) { + if (group.mutableState.disableCursor) { return; } diff --git a/lib/global/cursor/updateCursorStyle.ts b/lib/global/cursor/updateCursorStyle.ts index 30be3cb14..e9b311432 100644 --- a/lib/global/cursor/updateCursorStyle.ts +++ b/lib/global/cursor/updateCursorStyle.ts @@ -1,4 +1,4 @@ -import { read } from "../mutableState"; +import { getInteractionState } from "../mutable-state/interactions"; import { getCursorStyle } from "./getCursorStyle"; const documentToStyleMap = new WeakMap< @@ -30,13 +30,13 @@ export function updateCursorStyle(ownerDocument: Document) { } } - const { cursorFlags, interactionState } = read(); + const interactionState = getInteractionState(); switch (interactionState.state) { case "active": case "hover": { const cursorStyle = getCursorStyle({ - cursorFlags, + cursorFlags: interactionState.cursorFlags, groups: interactionState.hitRegions.map((current) => current.group), state: interactionState.state }); diff --git a/lib/global/dom/calculateAvailableGroupSize.test.ts b/lib/global/dom/calculateAvailableGroupSize.test.ts index f521e140b..b2d71a097 100644 --- a/lib/global/dom/calculateAvailableGroupSize.test.ts +++ b/lib/global/dom/calculateAvailableGroupSize.test.ts @@ -4,7 +4,9 @@ import { calculateAvailableGroupSize } from "./calculateAvailableGroupSize"; describe("calculateAvailableGroupSize", () => { test("panel widths", () => { - const group = mockGroup(new DOMRect(0, 0, 100, 50), "horizontal"); + const group = mockGroup(new DOMRect(0, 0, 100, 50), { + orientation: "horizontal" + }); group.addPanel(new DOMRect(0, 0, 25, 50)); group.addPanel(new DOMRect(0, 0, 75, 50)); @@ -12,7 +14,9 @@ describe("calculateAvailableGroupSize", () => { }); test("panel widths, excluding separators", () => { - const group = mockGroup(new DOMRect(0, 0, 100, 50), "horizontal"); + const group = mockGroup(new DOMRect(0, 0, 100, 50), { + orientation: "horizontal" + }); group.addHTMLElement(new DOMRect(0, 0, 10, 50)); group.addPanel(new DOMRect(0, 0, 20, 50)); group.addPanel(new DOMRect(0, 0, 30, 50)); @@ -22,7 +26,9 @@ describe("calculateAvailableGroupSize", () => { }); test("panel widths, excluding other DOM elements", () => { - const group = mockGroup(new DOMRect(0, 0, 100, 50), "horizontal"); + const group = mockGroup(new DOMRect(0, 0, 100, 50), { + orientation: "horizontal" + }); group.addPanel(new DOMRect(0, 0, 49, 50)); group.addSeparator(new DOMRect(0, 0, 2, 50)); group.addPanel(new DOMRect(0, 0, 49, 50)); @@ -31,7 +37,9 @@ describe("calculateAvailableGroupSize", () => { }); test("panel widths, excluding flex padding, gap, or margins", () => { - const group = mockGroup(new DOMRect(0, 0, 100, 50), "horizontal"); + const group = mockGroup(new DOMRect(0, 0, 100, 50), { + orientation: "horizontal" + }); group.addPanel(new DOMRect(0, 0, 45, 50)); group.addPanel(new DOMRect(0, 0, 45, 50)); diff --git a/lib/global/event-handlers/onDocumentDoubleClick.ts b/lib/global/event-handlers/onDocumentDoubleClick.ts index a539437d8..c962b51eb 100644 --- a/lib/global/event-handlers/onDocumentDoubleClick.ts +++ b/lib/global/event-handlers/onDocumentDoubleClick.ts @@ -1,4 +1,4 @@ -import { read } from "../mutableState"; +import { getMountedGroups } from "../mutable-state/groups"; import { findMatchingHitRegions } from "../utils/findMatchingHitRegions"; import { getImperativePanelMethods } from "../utils/getImperativePanelMethods"; @@ -7,8 +7,7 @@ export function onDocumentDoubleClick(event: MouseEvent) { return; } - const { mountedGroups } = read(); - + const mountedGroups = getMountedGroups(); const hitRegions = findMatchingHitRegions(event, mountedGroups); hitRegions.forEach((current) => { if (current.separator) { diff --git a/lib/global/event-handlers/onDocumentKeyDown.ts b/lib/global/event-handlers/onDocumentKeyDown.ts index e2dbc09d8..b126b37a0 100644 --- a/lib/global/event-handlers/onDocumentKeyDown.ts +++ b/lib/global/event-handlers/onDocumentKeyDown.ts @@ -1,7 +1,7 @@ import { assert } from "../../utils/assert"; +import { getMountedGroupState } from "../mutable-state/groups"; import { adjustLayoutForSeparator } from "../utils/adjustLayoutForSeparator"; import { findSeparatorGroup } from "../utils/findSeparatorGroup"; -import { getMountedGroup } from "../utils/getMountedGroup"; export function onDocumentKeyDown(event: KeyboardEvent) { if (event.defaultPrevented) { @@ -64,8 +64,9 @@ export function onDocumentKeyDown(event: KeyboardEvent) { // If the pane is collapsed, restores the splitter to its previous position. const group = findSeparatorGroup(separatorElement); - const { derivedPanelConstraints, layout, separatorToPanels } = - getMountedGroup(group); + + const groupState = getMountedGroupState(group.id, true); + const { derivedPanelConstraints, layout, separatorToPanels } = groupState; const separator = group.separators.find( (current) => current.element === separatorElement @@ -86,7 +87,7 @@ export function onDocumentKeyDown(event: KeyboardEvent) { const nextSize = constraints.collapsedSize === prevSize - ? (group.inMemoryLastExpandedPanelSizes[primaryPanel.id] ?? + ? (group.mutableState.expandedPanelSizes[primaryPanel.id] ?? constraints.minSize) : constraints.collapsedSize; diff --git a/lib/global/event-handlers/onDocumentPointerDown.ts b/lib/global/event-handlers/onDocumentPointerDown.ts index 9bf8d8f95..d8ce7ed76 100644 --- a/lib/global/event-handlers/onDocumentPointerDown.ts +++ b/lib/global/event-handlers/onDocumentPointerDown.ts @@ -1,5 +1,6 @@ import type { Layout, RegisteredGroup } from "../../components/group/types"; -import { read, update } from "../mutableState"; +import { getMountedGroups } from "../mutable-state/groups"; +import { updateInteractionState } from "../mutable-state/interactions"; import { findMatchingHitRegions } from "../utils/findMatchingHitRegions"; export function onDocumentPointerDown(event: PointerEvent) { @@ -9,7 +10,7 @@ export function onDocumentPointerDown(event: PointerEvent) { return; } - const { mountedGroups } = read(); + const mountedGroups = getMountedGroups(); const hitRegions = findMatchingHitRegions(event, mountedGroups); @@ -32,13 +33,12 @@ export function onDocumentPointerDown(event: PointerEvent) { } }); - update({ - interactionState: { - hitRegions, - initialLayoutMap, - pointerDownAtPoint: { x: event.clientX, y: event.clientY }, - state: "active" - } + updateInteractionState({ + cursorFlags: 0, + hitRegions, + initialLayoutMap, + pointerDownAtPoint: { x: event.clientX, y: event.clientY }, + state: "active" }); if (hitRegions.length) { diff --git a/lib/global/event-handlers/onDocumentPointerLeave.ts b/lib/global/event-handlers/onDocumentPointerLeave.ts index 8b5fd7c6d..8a53cddee 100644 --- a/lib/global/event-handlers/onDocumentPointerLeave.ts +++ b/lib/global/event-handlers/onDocumentPointerLeave.ts @@ -1,8 +1,10 @@ -import { read } from "../mutableState"; +import { getMountedGroups } from "../mutable-state/groups"; +import { getInteractionState } from "../mutable-state/interactions"; import { updateActiveHitRegions } from "../utils/updateActiveHitRegion"; export function onDocumentPointerLeave(event: PointerEvent) { - const { cursorFlags, interactionState, mountedGroups } = read(); + const mountedGroups = getMountedGroups(); + const interactionState = getInteractionState(); switch (interactionState.state) { case "active": { @@ -12,7 +14,7 @@ export function onDocumentPointerLeave(event: PointerEvent) { hitRegions: interactionState.hitRegions, initialLayoutMap: interactionState.initialLayoutMap, mountedGroups, - prevCursorFlags: cursorFlags + prevCursorFlags: interactionState.cursorFlags }); } } diff --git a/lib/global/event-handlers/onDocumentPointerMove.ts b/lib/global/event-handlers/onDocumentPointerMove.ts index 3fccefa4d..f30dc258f 100644 --- a/lib/global/event-handlers/onDocumentPointerMove.ts +++ b/lib/global/event-handlers/onDocumentPointerMove.ts @@ -1,5 +1,13 @@ import { updateCursorStyle } from "../cursor/updateCursorStyle"; -import { read, update } from "../mutableState"; +import { + getMountedGroups, + getMountedGroupState, + updateMountedGroup +} from "../mutable-state/groups"; +import { + getInteractionState, + updateInteractionState +} from "../mutable-state/interactions"; import { findMatchingHitRegions } from "../utils/findMatchingHitRegions"; import { updateActiveHitRegions } from "../utils/updateActiveHitRegion"; @@ -8,7 +16,8 @@ export function onDocumentPointerMove(event: PointerEvent) { return; } - const { cursorFlags, interactionState, mountedGroups } = read(); + const interactionState = getInteractionState(); + const mountedGroups = getMountedGroups(); switch (interactionState.state) { case "active": { @@ -18,20 +27,17 @@ export function onDocumentPointerMove(event: PointerEvent) { // Skip this check for "pointerleave" events, else Firefox triggers a false positive (see #514) event.buttons === 0 ) { - update((prevState) => - prevState.interactionState.state === "inactive" - ? prevState - : { - cursorFlags: 0, - interactionState: { state: "inactive" } - } - ); + updateInteractionState({ + cursorFlags: 0, + state: "inactive" + }); // Dispatch one more "change" event after the interaction state has been reset // Groups use this as a signal to call onLayoutChanged - update((prevState) => ({ - mountedGroups: new Map(prevState.mountedGroups) - })); + interactionState.hitRegions.forEach((hitRegion) => { + const groupState = getMountedGroupState(hitRegion.group.id, true); + updateMountedGroup(hitRegion.group, groupState); + }); return; } @@ -43,7 +49,7 @@ export function onDocumentPointerMove(event: PointerEvent) { initialLayoutMap: interactionState.initialLayoutMap, mountedGroups, pointerDownAtPoint: interactionState.pointerDownAtPoint, - prevCursorFlags: cursorFlags + prevCursorFlags: interactionState.cursorFlags }); break; } @@ -53,18 +59,16 @@ export function onDocumentPointerMove(event: PointerEvent) { if (hitRegions.length === 0) { if (interactionState.state !== "inactive") { - update({ - interactionState: { - state: "inactive" - } + updateInteractionState({ + cursorFlags: 0, + state: "inactive" }); } } else { - update({ - interactionState: { - hitRegions, - state: "hover" - } + updateInteractionState({ + cursorFlags: 0, + hitRegions, + state: "hover" }); } diff --git a/lib/global/event-handlers/onDocumentPointerOut.ts b/lib/global/event-handlers/onDocumentPointerOut.ts index 3d63d66c7..8f8f48fbe 100644 --- a/lib/global/event-handlers/onDocumentPointerOut.ts +++ b/lib/global/event-handlers/onDocumentPointerOut.ts @@ -1,4 +1,7 @@ -import { read, update } from "../mutableState"; +import { + getInteractionState, + updateInteractionState +} from "../mutable-state/interactions"; export function onDocumentPointerOut(event: PointerEvent) { // For some reason, "pointerout" events don't fire if the `relatedTarget` is an iframe @@ -6,13 +9,12 @@ export function onDocumentPointerOut(event: PointerEvent) { // The easiest fix for this case is to reset the interaction state in this specific circumstance // See issues/645 if (event.relatedTarget instanceof HTMLIFrameElement) { - const { interactionState } = read(); + const interactionState = getInteractionState(); switch (interactionState.state) { case "hover": { - update({ - interactionState: { - state: "inactive" - } + updateInteractionState({ + cursorFlags: 0, + state: "inactive" }); } } diff --git a/lib/global/event-handlers/onDocumentPointerUp.ts b/lib/global/event-handlers/onDocumentPointerUp.ts index 300a2009c..3f019786b 100644 --- a/lib/global/event-handlers/onDocumentPointerUp.ts +++ b/lib/global/event-handlers/onDocumentPointerUp.ts @@ -1,5 +1,12 @@ import { updateCursorStyle } from "../cursor/updateCursorStyle"; -import { read, update } from "../mutableState"; +import { + getMountedGroupState, + updateMountedGroup +} from "../mutable-state/groups"; +import { + getInteractionState, + updateInteractionState +} from "../mutable-state/interactions"; export function onDocumentPointerUp(event: PointerEvent) { if (event.defaultPrevented) { @@ -8,15 +15,13 @@ export function onDocumentPointerUp(event: PointerEvent) { return; } - const { interactionState } = read(); + const interactionState = getInteractionState(); switch (interactionState.state) { case "active": { - update({ + updateInteractionState({ cursorFlags: 0, - interactionState: { - state: "inactive" - } + state: "inactive" }); if (interactionState.hitRegions.length > 0) { @@ -24,9 +29,10 @@ export function onDocumentPointerUp(event: PointerEvent) { // Dispatch one more "change" event after the interaction state has been reset // Groups use this as a signal to call onLayoutChanged - update((prevState) => ({ - mountedGroups: new Map(prevState.mountedGroups) - })); + interactionState.hitRegions.forEach((hitRegion) => { + const groupState = getMountedGroupState(hitRegion.group.id, true); + updateMountedGroup(hitRegion.group, groupState); + }); event.preventDefault(); } diff --git a/lib/global/mountGroup.ts b/lib/global/mountGroup.ts index dea067fa3..73daed72c 100644 --- a/lib/global/mountGroup.ts +++ b/lib/global/mountGroup.ts @@ -10,7 +10,12 @@ import { onDocumentPointerLeave } from "./event-handlers/onDocumentPointerLeave" import { onDocumentPointerMove } from "./event-handlers/onDocumentPointerMove"; import { onDocumentPointerOut } from "./event-handlers/onDocumentPointerOut"; import { onDocumentPointerUp } from "./event-handlers/onDocumentPointerUp"; -import { update, type SeparatorToPanelsMap } from "./mutableState"; +import { + deleteMutableGroup, + getMountedGroupState, + updateMountedGroup +} from "./mutable-state/groups"; +import type { SeparatorToPanelsMap } from "./mutable-state/types"; import { calculateDefaultLayout } from "./utils/calculateDefaultLayout"; import { layoutsEqual } from "./utils/layoutsEqual"; import { notifyPanelOnResize } from "./utils/notifyPanelOnResize"; @@ -47,44 +52,40 @@ export function mountGroup(group: RegisteredGroup) { return; } - update((prevState) => { - const match = prevState.mountedGroups.get(group); - if (match) { - // Update non-percentage based constraints - const nextDerivedPanelConstraints = - calculatePanelConstraints(group); - - // Revalidate layout in case constraints have changed - const prevLayout = match.defaultLayoutDeferred - ? calculateDefaultLayout(nextDerivedPanelConstraints) - : match.layout; - const nextLayout = validatePanelGroupLayout({ - layout: prevLayout, - panelConstraints: nextDerivedPanelConstraints - }); - - if ( - !match.defaultLayoutDeferred && - layoutsEqual(prevLayout, nextLayout) && - objectsEqual( - match.derivedPanelConstraints, - nextDerivedPanelConstraints - ) - ) { - return prevState; - } - - return { - mountedGroups: new Map(prevState.mountedGroups).set(group, { - defaultLayoutDeferred: false, - derivedPanelConstraints: nextDerivedPanelConstraints, - layout: nextLayout, - separatorToPanels: match.separatorToPanels - }) - }; - } - - return prevState; + const groupState = getMountedGroupState(group.id); + if (!groupState) { + // Not mounted yet + return; + } + + // Update non-percentage based constraints + const nextDerivedPanelConstraints = calculatePanelConstraints(group); + + // Revalidate layout in case constraints have changed + const prevLayout = groupState.defaultLayoutDeferred + ? calculateDefaultLayout(nextDerivedPanelConstraints) + : groupState.layout; + const nextLayout = validatePanelGroupLayout({ + layout: prevLayout, + panelConstraints: nextDerivedPanelConstraints + }); + + if ( + !groupState.defaultLayoutDeferred && + layoutsEqual(prevLayout, nextLayout) && + objectsEqual( + groupState.derivedPanelConstraints, + nextDerivedPanelConstraints + ) + ) { + return; + } + + updateMountedGroup(group, { + defaultLayoutDeferred: false, + derivedPanelConstraints: nextDerivedPanelConstraints, + layout: nextLayout, + separatorToPanels: groupState.separatorToPanels }); } } else { @@ -92,7 +93,9 @@ export function mountGroup(group: RegisteredGroup) { } } }); + resizeObserver.observe(group.element); + group.panels.forEach((panel) => { assert( !panelIds.has(panel.id), @@ -115,7 +118,7 @@ export function mountGroup(group: RegisteredGroup) { // Gracefully handle an invalid default layout // This could happen when e.g. useDefaultLayout is combined with dynamic Panels // In this case the best we can do is ignore the incoming layout - let defaultLayout: Layout | undefined = group.defaultLayout; + let defaultLayout: Layout | undefined = group.mutableState.defaultLayout; if (defaultLayout) { if (!validateLayoutKeys(group.panels, defaultLayout)) { defaultLayout = undefined; @@ -123,7 +126,7 @@ export function mountGroup(group: RegisteredGroup) { } const defaultLayoutUnsafe: Layout = - group.inMemoryLayouts[panelIdsKey] ?? + group.mutableState.layouts[panelIdsKey] ?? defaultLayout ?? calculateDefaultLayout(derivedPanelConstraints); const defaultLayoutSafe = validatePanelGroupLayout({ @@ -131,32 +134,26 @@ export function mountGroup(group: RegisteredGroup) { panelConstraints: derivedPanelConstraints }); - const hitRegions = calculateHitRegions(group); - const ownerDocument = group.element.ownerDocument; - update((prevState) => { - const separatorToPanels: SeparatorToPanelsMap = new Map(); - - ownerDocumentReferenceCounts.set( - ownerDocument, - (ownerDocumentReferenceCounts.get(ownerDocument) ?? 0) + 1 - ); + ownerDocumentReferenceCounts.set( + ownerDocument, + (ownerDocumentReferenceCounts.get(ownerDocument) ?? 0) + 1 + ); - hitRegions.forEach((hitRegion) => { - if (hitRegion.separator) { - separatorToPanels.set(hitRegion.separator, hitRegion.panels); - } - }); + const separatorToPanels: SeparatorToPanelsMap = new Map(); + const hitRegions = calculateHitRegions(group); + hitRegions.forEach((hitRegion) => { + if (hitRegion.separator) { + separatorToPanels.set(hitRegion.separator, hitRegion.panels); + } + }); - return { - mountedGroups: new Map(prevState.mountedGroups).set(group, { - defaultLayoutDeferred: groupSize === 0, - derivedPanelConstraints, - layout: defaultLayoutSafe, - separatorToPanels - }) - }; + updateMountedGroup(group, { + defaultLayoutDeferred: groupSize === 0, + derivedPanelConstraints, + layout: defaultLayoutSafe, + separatorToPanels }); group.separators.forEach((separator) => { @@ -188,12 +185,7 @@ export function mountGroup(group: RegisteredGroup) { Math.max(0, (ownerDocumentReferenceCounts.get(ownerDocument) ?? 0) - 1) ); - update((prevState) => { - const mountedGroups = new Map(prevState.mountedGroups); - mountedGroups.delete(group); - - return { mountedGroups }; - }); + deleteMutableGroup(group); group.separators.forEach((separator) => { separator.element.removeEventListener("keydown", onDocumentKeyDown); diff --git a/lib/global/mutable-state/groups.ts b/lib/global/mutable-state/groups.ts new file mode 100644 index 000000000..4645201f4 --- /dev/null +++ b/lib/global/mutable-state/groups.ts @@ -0,0 +1,100 @@ +import type { Layout, RegisteredGroup } from "../../components/group/types"; +import type { PanelConstraints } from "../../components/panel/types"; +import { EventEmitter } from "../../utils/EventEmitter"; +import type { SeparatorToPanelsMap } from "./types"; + +type State = { + defaultLayoutDeferred: boolean; + derivedPanelConstraints: PanelConstraints[]; + layout: Layout; + separatorToPanels: SeparatorToPanelsMap; +}; + +export type MountedGroups = Map; + +let map: MountedGroups = new Map(); + +type GroupChangeEvent = { + group: RegisteredGroup; + next: State; + prev: State | undefined; +}; +type GroupsChangeEvent = { + next: MountedGroups; + prev: MountedGroups; +}; + +const eventEmitter = new EventEmitter<{ + groupChange: GroupChangeEvent; + groupsChange: GroupsChangeEvent; +}>(); + +export function deleteMutableGroup(group: RegisteredGroup) { + map = new Map(map); + map.delete(group); +} + +export function getRegisteredGroup( + groupId: string +): RegisteredGroup | undefined; +export function getRegisteredGroup( + groupId: string, + assert: true +): RegisteredGroup; +export function getRegisteredGroup(groupId: string, assert?: boolean) { + for (const [group] of map) { + if (group.id === groupId) { + return group; + } + } + + if (assert) { + throw Error(`Could not find data for Group with id ${groupId}`); + } + + return undefined; +} + +export function getMountedGroupState(groupId: string): State | undefined; +export function getMountedGroupState(groupId: string, assert: true): State; +export function getMountedGroupState(groupId: string, assert?: boolean) { + for (const [group, mountedGroup] of map) { + if (group.id === groupId) { + return mountedGroup; + } + } + + if (assert) { + throw Error(`Could not find data for Group with id ${groupId}`); + } + + return undefined; +} + +export function getMountedGroups() { + return map; +} + +export function subscribeToMountedGroup( + groupId: string, + callback: (event: GroupChangeEvent) => void +) { + return eventEmitter.addListener("groupChange", (event) => { + if (event.group.id === groupId) { + callback(event); + } + }); +} + +export function updateMountedGroup(group: RegisteredGroup, next: State) { + const prev = map.get(group); + + map = new Map(map); + map.set(group, next); + + eventEmitter.emit("groupChange", { + group, + prev, + next + }); +} diff --git a/lib/global/mutable-state/interactions.ts b/lib/global/mutable-state/interactions.ts new file mode 100644 index 000000000..4c479c541 --- /dev/null +++ b/lib/global/mutable-state/interactions.ts @@ -0,0 +1,51 @@ +import { EventEmitter } from "../../utils/EventEmitter"; +import type { InteractionState } from "./types"; + +let state: InteractionState = { + cursorFlags: 0, + state: "inactive" +}; + +type ChangeEvent = { + next: InteractionState; + prev: InteractionState; +}; + +const eventEmitter = new EventEmitter<{ + change: ChangeEvent; +}>(); + +export function getInteractionState() { + return state; +} + +export function subscribeToInteractionState( + callback: (event: ChangeEvent) => void +) { + return eventEmitter.addListener("change", callback); +} + +export function updateCursorFlags(cursorFlags: number) { + const prev = state; + + const next = { ...state }; + next.cursorFlags = cursorFlags; + + state = next; + + eventEmitter.emit("change", { + prev, + next + }); +} + +export function updateInteractionState(next: InteractionState) { + const prev = state; + + state = next; + + eventEmitter.emit("change", { + prev, + next + }); +} diff --git a/lib/global/mutable-state/types.ts b/lib/global/mutable-state/types.ts new file mode 100644 index 000000000..034f27c74 --- /dev/null +++ b/lib/global/mutable-state/types.ts @@ -0,0 +1,34 @@ +import type { Layout, RegisteredGroup } from "../../components/group/types"; +import type { RegisteredPanel } from "../../components/panel/types"; +import type { RegisteredSeparator } from "../../components/separator/types"; +import type { Point } from "../../types"; +import type { HitRegion } from "../dom/calculateHitRegions"; + +export type InteractionInactive = { + cursorFlags: 0; + state: "inactive"; +}; + +export type InteractionHover = { + cursorFlags: 0; + hitRegions: HitRegion[]; + state: "hover"; +}; + +export type InteractionActive = { + cursorFlags: number; + hitRegions: HitRegion[]; + initialLayoutMap: Map; + pointerDownAtPoint: Point; + state: "active"; +}; + +export type InteractionState = + | InteractionInactive + | InteractionHover + | InteractionActive; + +export type SeparatorToPanelsMap = Map< + RegisteredSeparator, + [primaryPanel: RegisteredPanel, secondaryPanel: RegisteredPanel] +>; diff --git a/lib/global/mutableState.ts b/lib/global/mutableState.ts deleted file mode 100644 index 3be0b62d5..000000000 --- a/lib/global/mutableState.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Layout, RegisteredGroup } from "../components/group/types"; -import type { - PanelConstraints, - RegisteredPanel -} from "../components/panel/types"; -import type { RegisteredSeparator } from "../components/separator/types"; -import { EventEmitter } from "../utils/EventEmitter"; -import type { InteractionState } from "./types"; -import { layoutNumbersEqual } from "./utils/layoutNumbersEqual"; - -type UpdaterFunction = (prevState: State) => Partial; - -export type SeparatorToPanelsMap = Map< - RegisteredSeparator, - [primaryPanel: RegisteredPanel, secondaryPanel: RegisteredPanel] ->; - -export type MountedGroupMap = Map< - RegisteredGroup, - { - defaultLayoutDeferred: boolean; - derivedPanelConstraints: PanelConstraints[]; - layout: Layout; - separatorToPanels: SeparatorToPanelsMap; - } ->; - -type Events = { - cursorFlagsChange: number; - interactionStateChange: InteractionState; - mountedGroupsChange: MountedGroupMap; -}; - -type State = { - cursorFlags: number; - interactionState: InteractionState; - mountedGroups: MountedGroupMap; -}; - -let state: State = { - cursorFlags: 0, - interactionState: { - state: "inactive" - }, - mountedGroups: new Map() -}; - -export const eventEmitter = new EventEmitter(); - -export function read(): State { - return state; -} - -export function update(value: Partial | UpdaterFunction) { - const partialState = typeof value === "function" ? value(state) : value; - if (state === partialState) { - return state; - } - - const prevState = state; - - state = { - ...state, - ...partialState - }; - - if (partialState.cursorFlags !== undefined) { - eventEmitter.emit("cursorFlagsChange", state.cursorFlags); - } - - if (partialState.interactionState !== undefined) { - eventEmitter.emit("interactionStateChange", state.interactionState); - } - - if (partialState.mountedGroups !== undefined) { - // If any collapsible Panels have been collapsed by this size change, record their previous sizes - state.mountedGroups.forEach((value, group) => { - value.derivedPanelConstraints.forEach((constraints) => { - if (constraints.collapsible) { - const { layout: prevLayout } = - prevState.mountedGroups.get(group) ?? {}; - if (prevLayout) { - const isCollapsed = layoutNumbersEqual( - constraints.collapsedSize, - value.layout[constraints.panelId] - ); - const wasCollapsed = layoutNumbersEqual( - constraints.collapsedSize, - prevLayout[constraints.panelId] - ); - if (isCollapsed && !wasCollapsed) { - group.inMemoryLastExpandedPanelSizes[constraints.panelId] = - prevLayout[constraints.panelId]; - } - } - } - }); - }); - - eventEmitter.emit("mountedGroupsChange", state.mountedGroups); - } - - return state; -} diff --git a/lib/global/test/mockGroup.ts b/lib/global/test/mockGroup.ts index c18aa17dd..f63f4e77a 100644 --- a/lib/global/test/mockGroup.ts +++ b/lib/global/test/mockGroup.ts @@ -28,10 +28,9 @@ let groupIdCounter = 0; export function mockGroup( groupBounds: DOMRect, - orientation: Orientation = "horizontal", - groupIdStable?: string + config: Partial = {} ): MockGroup { - const groupId = groupIdStable ?? `group-${++groupIdCounter}`; + const groupId = config.id ?? `group-${++groupIdCounter}`; let panelIdCounter = 0; let separatorIdCounter = 0; @@ -53,18 +52,21 @@ export function mockGroup( ); const group = { - defaultLayout: undefined, - disableCursor: false, disabled: false, element: groupElement, id: groupId, - inMemoryLastExpandedPanelSizes: {}, - inMemoryLayouts: {}, - orientation, + mutableState: { + defaultLayout: undefined, + disableCursor: false, + expandedPanelSizes: {}, + layouts: {} + }, + orientation: "horizontal" as Orientation, resizeTargetMinimumSize: { coarse: 20, fine: 10 }, + ...config, get panels() { return Array.from(mockPanels.values()); diff --git a/lib/global/types.ts b/lib/global/types.ts deleted file mode 100644 index 6a38a93d4..000000000 --- a/lib/global/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Layout, RegisteredGroup } from "../components/group/types"; -import type { Point } from "../types"; -import type { HitRegion } from "./dom/calculateHitRegions"; - -export type InteractionInactive = { - state: "inactive"; -}; - -export type InteractionHover = { - hitRegions: HitRegion[]; - state: "hover"; -}; - -export type InteractionActive = { - hitRegions: HitRegion[]; - initialLayoutMap: Map; - pointerDownAtPoint: Point; - state: "active"; -}; - -export type InteractionState = - | InteractionInactive - | InteractionHover - | InteractionActive; diff --git a/lib/global/utils/adjustLayoutForSeparator.ts b/lib/global/utils/adjustLayoutForSeparator.ts index f5a3725b5..5836d1ffa 100644 --- a/lib/global/utils/adjustLayoutForSeparator.ts +++ b/lib/global/utils/adjustLayoutForSeparator.ts @@ -1,9 +1,11 @@ import { assert } from "../../utils/assert"; -import { update } from "../mutableState"; +import { + getMountedGroupState, + updateMountedGroup +} from "../mutable-state/groups"; import { adjustLayoutByDelta } from "./adjustLayoutByDelta"; import { findSeparatorGroup } from "./findSeparatorGroup"; import { getImperativeGroupMethods } from "./getImperativeGroupMethods"; -import { getMountedGroup } from "./getMountedGroup"; import { layoutsEqual } from "./layoutsEqual"; import { validatePanelGroupLayout } from "./validatePanelGroupLayout"; @@ -12,14 +14,14 @@ export function adjustLayoutForSeparator( delta: number ) { const group = findSeparatorGroup(separatorElement); - const mountedGroup = getMountedGroup(group); + const groupState = getMountedGroupState(group.id, true); const separator = group.separators.find( (current) => current.element === separatorElement ); assert(separator, "Matching separator not found"); - const panels = mountedGroup.separatorToPanels.get(separator); + const panels = groupState.separatorToPanels.get(separator); assert(panels, "Matching panels not found"); const pivotIndices = panels.map((panel) => group.panels.indexOf(panel)); @@ -30,24 +32,22 @@ export function adjustLayoutForSeparator( const unsafeLayout = adjustLayoutByDelta({ delta, initialLayout: prevLayout, - panelConstraints: mountedGroup.derivedPanelConstraints, + panelConstraints: groupState.derivedPanelConstraints, pivotIndices, prevLayout, trigger: "keyboard" }); const nextLayout = validatePanelGroupLayout({ layout: unsafeLayout, - panelConstraints: mountedGroup.derivedPanelConstraints + panelConstraints: groupState.derivedPanelConstraints }); if (!layoutsEqual(prevLayout, nextLayout)) { - update((prevState) => ({ - mountedGroups: new Map(prevState.mountedGroups).set(group, { - defaultLayoutDeferred: mountedGroup.defaultLayoutDeferred, - derivedPanelConstraints: mountedGroup.derivedPanelConstraints, - layout: nextLayout, - separatorToPanels: mountedGroup.separatorToPanels - }) - })); + updateMountedGroup(group, { + defaultLayoutDeferred: groupState.defaultLayoutDeferred, + derivedPanelConstraints: groupState.derivedPanelConstraints, + layout: nextLayout, + separatorToPanels: groupState.separatorToPanels + }); } } diff --git a/lib/global/utils/findMatchingHitRegions.test.ts b/lib/global/utils/findMatchingHitRegions.test.ts index 748593a94..0cc76e857 100644 --- a/lib/global/utils/findMatchingHitRegions.test.ts +++ b/lib/global/utils/findMatchingHitRegions.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "vitest"; -import { read, type MountedGroupMap } from "../mutableState"; -import { mockGroup } from "../test/mockGroup"; -import { findMatchingHitRegions } from "./findMatchingHitRegions"; import { mountGroup } from "../mountGroup"; +import { getMountedGroups, type MountedGroups } from "../mutable-state/groups"; +import { mockGroup } from "../test/mockGroup"; import { mockPointerEvent } from "../test/mockPointerEvent"; +import { findMatchingHitRegions } from "./findMatchingHitRegions"; describe("findMatchingHitRegions", () => { - function serialize(event: PointerEvent, mountedGroups: MountedGroupMap) { + function serialize(event: PointerEvent, mountedGroups: MountedGroups) { const hitRegions = findMatchingHitRegions(event, mountedGroups); return JSON.stringify( @@ -24,7 +24,7 @@ describe("findMatchingHitRegions", () => { mountGroup(mockGroup(new DOMRect(0, 0, 10, 50))); expect( - serialize(mockPointerEvent(), read().mountedGroups) + serialize(mockPointerEvent(), getMountedGroups()) ).toMatchInlineSnapshot(`"[]"`); }); @@ -34,7 +34,7 @@ describe("findMatchingHitRegions", () => { group.addPanel(new DOMRect(50, 0, 50, 50), "right"); mountGroup(group); - expect(serialize(mockPointerEvent({ clientX: 50 }), read().mountedGroups)) + expect(serialize(mockPointerEvent({ clientX: 50 }), getMountedGroups())) .toMatchInlineSnapshot(` "[ { @@ -55,7 +55,7 @@ describe("findMatchingHitRegions", () => { group.addPanel(new DOMRect(70, 0, 50, 50), "right"); mountGroup(group); - expect(serialize(mockPointerEvent({ clientX: 60 }), read().mountedGroups)) + expect(serialize(mockPointerEvent({ clientX: 60 }), getMountedGroups())) .toMatchInlineSnapshot(` "[ { @@ -76,7 +76,9 @@ describe("findMatchingHitRegions", () => { outerGroup.addPanel(new DOMRect(50, 0, 50, 50), "right"); mountGroup(outerGroup); - const innerGroup = mockGroup(new DOMRect(0, 0, 50, 50), "vertical"); + const innerGroup = mockGroup(new DOMRect(0, 0, 50, 50), { + orientation: "vertical" + }); innerGroup.addPanel(new DOMRect(0, 0, 50, 25), "top"); innerGroup.addPanel(new DOMRect(0, 25, 50, 25), "bottom"); mountGroup(innerGroup); @@ -84,7 +86,7 @@ describe("findMatchingHitRegions", () => { expect( serialize( mockPointerEvent({ clientX: 50, clientY: 25 }), - read().mountedGroups + getMountedGroups() ) ).toMatchInlineSnapshot(` "[ @@ -107,13 +109,12 @@ describe("findMatchingHitRegions", () => { }); test("should skip disabled groups", () => { - const group = mockGroup(new DOMRect(0, 0, 100, 50)); + const group = mockGroup(new DOMRect(0, 0, 100, 50), { disabled: true }); group.addPanel(new DOMRect(0, 0, 50, 50), "left"); group.addPanel(new DOMRect(50, 0, 50, 50), "right"); - group.disabled = true; mountGroup(group); - expect(serialize(mockPointerEvent({ clientX: 50 }), read().mountedGroups)) + expect(serialize(mockPointerEvent({ clientX: 50 }), getMountedGroups())) .toMatchInlineSnapshot(` "[]" `); diff --git a/lib/global/utils/findMatchingHitRegions.ts b/lib/global/utils/findMatchingHitRegions.ts index 159dfcf9e..ed06e9825 100644 --- a/lib/global/utils/findMatchingHitRegions.ts +++ b/lib/global/utils/findMatchingHitRegions.ts @@ -2,7 +2,7 @@ import { calculateHitRegions, type HitRegion } from "../dom/calculateHitRegions"; -import type { MountedGroupMap } from "../mutableState"; +import type { MountedGroups } from "../mutable-state/groups"; import { findClosestHitRegion } from "./findClosestHitRegion"; import { isViableHitTarget } from "./isViableHitTarget"; @@ -12,7 +12,7 @@ export function findMatchingHitRegions( clientY: number; target: EventTarget | null; }, - mountedGroups: MountedGroupMap + mountedGroups: MountedGroups ): HitRegion[] { const matchingHitRegions: HitRegion[] = []; diff --git a/lib/global/utils/findSeparatorGroup.ts b/lib/global/utils/findSeparatorGroup.ts index 08430a18b..2b4aee142 100644 --- a/lib/global/utils/findSeparatorGroup.ts +++ b/lib/global/utils/findSeparatorGroup.ts @@ -1,7 +1,7 @@ -import { read } from "../mutableState"; +import { getMountedGroups } from "../mutable-state/groups"; export function findSeparatorGroup(separatorElement: HTMLElement) { - const { mountedGroups } = read(); + const mountedGroups = getMountedGroups(); for (const [group] of mountedGroups) { if ( diff --git a/lib/global/utils/getImperativeGroupMethods.test.ts b/lib/global/utils/getImperativeGroupMethods.test.ts index 298e069f9..e7585b3e1 100644 --- a/lib/global/utils/getImperativeGroupMethods.test.ts +++ b/lib/global/utils/getImperativeGroupMethods.test.ts @@ -9,20 +9,24 @@ import { } from "vitest"; import type { PanelConstraints } from "../../components/panel/types"; import { mountGroup } from "../mountGroup"; -import { eventEmitter } from "../mutableState"; +import { subscribeToMountedGroup } from "../mutable-state/groups"; import { mockGroup } from "../test/mockGroup"; import { getImperativeGroupMethods } from "./getImperativeGroupMethods"; describe("getImperativeGroupMethods", () => { + let removeChangeListener: (() => void) | undefined = undefined; let unmountGroup: (() => void) | undefined = undefined; - let onMountedGroupsChange: Mock; + let onGroupChange: Mock; function init( panelConstraints: (Partial & { defaultSize: number; })[] ) { - const group = mockGroup(new DOMRect(0, 0, 1000, 50), "horizontal", "A"); + const group = mockGroup(new DOMRect(0, 0, 1000, 50), { + id: "group", + orientation: "horizontal" + }); panelConstraints.forEach((current) => { group.addPanel( @@ -41,7 +45,7 @@ describe("getImperativeGroupMethods", () => { unmountGroup = mountGroup(group); - eventEmitter.addListener("mountedGroupsChange", onMountedGroupsChange); + removeChangeListener = subscribeToMountedGroup("group", onGroupChange); return { api: getImperativeGroupMethods({ groupId: group.id }), @@ -50,24 +54,26 @@ describe("getImperativeGroupMethods", () => { } beforeEach(() => { - onMountedGroupsChange = vi.fn(); + onGroupChange = vi.fn(); }); afterEach(() => { + if (removeChangeListener) { + removeChangeListener(); + } + if (unmountGroup) { unmountGroup(); } - - eventEmitter.removeListener("mountedGroupsChange", onMountedGroupsChange); }); describe("getLayout", () => { test("throws if group not mounted", () => { expect(() => getImperativeGroupMethods({ - groupId: "A" + groupId: "group" }).getLayout() - ).toThrowError('Could not find Group with id "A"'); + ).toThrowError('Could not find Group with id "group"'); }); test("returns the current group layout", () => { @@ -79,9 +85,9 @@ describe("getImperativeGroupMethods", () => { expect(api.getLayout()).toMatchInlineSnapshot(` { - "A-1": 20, - "A-2": 50, - "A-3": 30, + "group-1": 20, + "group-2": 50, + "group-3": 30, } `); }); @@ -91,19 +97,19 @@ describe("getImperativeGroupMethods", () => { test("throws if group not mounted", () => { expect(() => getImperativeGroupMethods({ - groupId: "A" + groupId: "group" }).setLayout({}) - ).toThrowError('Could not find Group with id "A"'); + ).toThrowError('Could not find Group with id "group"'); }); test("ignores a no-op layout update", () => { const { api } = init([{ defaultSize: 200 }, { defaultSize: 800 }]); api.setLayout({ - "A-1": 20, - "A-2": 80 + "group-1": 20, + "group-2": 80 }); - expect(onMountedGroupsChange).not.toHaveBeenCalled(); + expect(onGroupChange).not.toHaveBeenCalled(); }); test("ignores an invalid layout update", () => { @@ -112,11 +118,11 @@ describe("getImperativeGroupMethods", () => { { defaultSize: 800 } ]); api.setLayout({ - "A-1": 10, - "A-2": 90 + "group-1": 10, + "group-2": 90 }); - expect(onMountedGroupsChange).not.toHaveBeenCalled(); + expect(onGroupChange).not.toHaveBeenCalled(); }); test("validates and updates the group layout", () => { @@ -125,15 +131,15 @@ describe("getImperativeGroupMethods", () => { { defaultSize: 800 } ]); api.setLayout({ - "A-1": 0, - "A-2": 100 + "group-1": 0, + "group-2": 100 }); - expect(onMountedGroupsChange).toHaveBeenCalledTimes(1); + expect(onGroupChange).toHaveBeenCalledTimes(1); expect(api.getLayout()).toMatchInlineSnapshot(` { - "A-1": 10, - "A-2": 90, + "group-1": 10, + "group-2": 90, } `); }); @@ -146,21 +152,21 @@ describe("getImperativeGroupMethods", () => { expect(api.getLayout()).toMatchInlineSnapshot(` { - "A-1": 20, - "A-2": 80, + "group-1": 20, + "group-2": 80, } `); api.setLayout({ - "A-1": 30, - "A-2": 70 + "group-1": 30, + "group-2": 70 }); - expect(onMountedGroupsChange).toHaveBeenCalledTimes(1); + expect(onGroupChange).toHaveBeenCalledTimes(1); expect(api.getLayout()).toMatchInlineSnapshot(` { - "A-1": 30, - "A-2": 70, + "group-1": 30, + "group-2": 70, } `); }); diff --git a/lib/global/utils/getImperativeGroupMethods.ts b/lib/global/utils/getImperativeGroupMethods.ts index a294e26fe..56c7d4754 100644 --- a/lib/global/utils/getImperativeGroupMethods.ts +++ b/lib/global/utils/getImperativeGroupMethods.ts @@ -2,7 +2,7 @@ import type { GroupImperativeHandle, Layout } from "../../components/group/types"; -import { read, update } from "../mutableState"; +import { getMountedGroups, updateMountedGroup } from "../mutable-state/groups"; import { layoutsEqual } from "./layoutsEqual"; import { validatePanelGroupLayout } from "./validatePanelGroupLayout"; @@ -12,7 +12,7 @@ export function getImperativeGroupMethods({ groupId: string; }): GroupImperativeHandle { const find = () => { - const { mountedGroups } = read(); + const mountedGroups = getMountedGroups(); for (const [group, value] of mountedGroups) { if (group.id === groupId) { return { group, ...value }; @@ -59,14 +59,12 @@ export function getImperativeGroupMethods({ } if (!layoutsEqual(prevLayout, nextLayout)) { - update((prevState) => ({ - mountedGroups: new Map(prevState.mountedGroups).set(group, { - defaultLayoutDeferred, - derivedPanelConstraints, - layout: nextLayout, - separatorToPanels - }) - })); + updateMountedGroup(group, { + defaultLayoutDeferred, + derivedPanelConstraints, + layout: nextLayout, + separatorToPanels + }); } return nextLayout; diff --git a/lib/global/utils/getImperativePanelMethods.test.ts b/lib/global/utils/getImperativePanelMethods.test.ts index 5a59b806d..7967d8c25 100644 --- a/lib/global/utils/getImperativePanelMethods.test.ts +++ b/lib/global/utils/getImperativePanelMethods.test.ts @@ -12,7 +12,7 @@ import type { PanelImperativeHandle } from "../../components/panel/types"; import { mountGroup } from "../mountGroup"; -import { eventEmitter } from "../mutableState"; +import { subscribeToMountedGroup } from "../mutable-state/groups"; import { mockGroup } from "../test/mockGroup"; import { getImperativePanelMethods } from "./getImperativePanelMethods"; @@ -26,15 +26,18 @@ describe("getImperativePanelMethods", () => { ])("method %o: throws if group or panel not mounted", (name) => { const key = name as keyof PanelImperativeHandle; - const group = mockGroup(new DOMRect(), "horizontal", "A"); + const group = mockGroup(new DOMRect(), { + id: "group", + orientation: "horizontal" + }); const api = getImperativePanelMethods({ - groupId: "A", + groupId: "group", panelId: "B" }); expect(() => (key === "resize" ? api[key](1) : api[key]())).toThrow( - "Group A not found" + "Group group not found" ); const unmountGroup = mountGroup(group); @@ -53,7 +56,10 @@ describe("getImperativePanelMethods", () => { function init(panelConstraints: Partial[]) { const bounds = new DOMRect(0, 0, 1000, 50); - const group = mockGroup(bounds, "horizontal", "A"); + const group = mockGroup(bounds, { + id: "group", + orientation: "horizontal" + }); panelConstraints.forEach( ({ @@ -90,14 +96,9 @@ describe("getImperativePanelMethods", () => { unmountGroup = mountGroup(group); - removeChangeListener = eventEmitter.addListener( - "mountedGroupsChange", - (mountedGroups) => { - mountedGroups.forEach((group) => { - onLayoutChange(Object.values(group.layout)); - }); - } - ); + removeChangeListener = subscribeToMountedGroup("group", ({ next }) => { + onLayoutChange(Object.values(next.layout)); + }); return { group, @@ -115,15 +116,13 @@ describe("getImperativePanelMethods", () => { }); afterEach(() => { - if (unmountGroup) { - unmountGroup(); - } - if (removeChangeListener) { removeChangeListener(); } - eventEmitter.removeListener("mountedGroupsChange", onLayoutChange); + if (unmountGroup) { + unmountGroup(); + } }); describe("collapse", () => { diff --git a/lib/global/utils/getImperativePanelMethods.ts b/lib/global/utils/getImperativePanelMethods.ts index c5836d62f..b8ac8f489 100644 --- a/lib/global/utils/getImperativePanelMethods.ts +++ b/lib/global/utils/getImperativePanelMethods.ts @@ -1,6 +1,6 @@ import type { PanelImperativeHandle } from "../../components/panel/types"; import { calculateAvailableGroupSize } from "../dom/calculateAvailableGroupSize"; -import { read, update } from "../mutableState"; +import { getMountedGroups, updateMountedGroup } from "../mutable-state/groups"; import { adjustLayoutByDelta } from "./adjustLayoutByDelta"; import { formatLayoutNumber } from "./formatLayoutNumber"; import { layoutNumbersEqual } from "./layoutNumbersEqual"; @@ -15,7 +15,7 @@ export function getImperativePanelMethods({ panelId: string; }): PanelImperativeHandle { const find = () => { - const { mountedGroups } = read(); + const mountedGroups = getMountedGroups(); for (const [ group, { @@ -99,14 +99,12 @@ export function getImperativePanelMethods({ panelConstraints: derivedPanelConstraints }); if (!layoutsEqual(prevLayout, nextLayout)) { - update((prevState) => ({ - mountedGroups: new Map(prevState.mountedGroups).set(group, { - defaultLayoutDeferred, - derivedPanelConstraints, - layout: nextLayout, - separatorToPanels - }) - })); + updateMountedGroup(group, { + defaultLayoutDeferred, + derivedPanelConstraints, + layout: nextLayout, + separatorToPanels + }); } }; diff --git a/lib/global/utils/getMountedGroup.ts b/lib/global/utils/getMountedGroup.ts deleted file mode 100644 index 8c961fc44..000000000 --- a/lib/global/utils/getMountedGroup.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { RegisteredGroup } from "../../components/group/types"; -import { assert } from "../../utils/assert"; -import { read } from "../mutableState"; - -export function getMountedGroup(group: RegisteredGroup) { - const { mountedGroups } = read(); - - const mountedGroup = mountedGroups.get(group); - assert(mountedGroup, `Mounted Group ${group.id} not found`); - - return mountedGroup; -} diff --git a/lib/global/utils/updateActiveHitRegion.ts b/lib/global/utils/updateActiveHitRegion.ts index 3f1a3e471..0073c158c 100644 --- a/lib/global/utils/updateActiveHitRegion.ts +++ b/lib/global/utils/updateActiveHitRegion.ts @@ -10,7 +10,11 @@ import { import type { Point } from "../../types"; import { updateCursorStyle } from "../cursor/updateCursorStyle"; import type { HitRegion } from "../dom/calculateHitRegions"; -import { update, type MountedGroupMap } from "../mutableState"; +import { + updateMountedGroup, + type MountedGroups +} from "../mutable-state/groups"; +import { updateCursorFlags } from "../mutable-state/interactions"; import { adjustLayoutByDelta } from "./adjustLayoutByDelta"; import { layoutsEqual } from "./layoutsEqual"; @@ -32,19 +36,18 @@ export function updateActiveHitRegions({ }; hitRegions: HitRegion[]; initialLayoutMap: Map; - mountedGroups: MountedGroupMap; + mountedGroups: MountedGroups; pointerDownAtPoint?: Point; prevCursorFlags: number; }) { let nextCursorFlags = 0; - const nextMountedGroups = new Map(mountedGroups); - // Note that HitRegions are frozen once a drag has started // Modify the Group layouts for all matching HitRegions though hitRegions.forEach((current) => { const { group, groupSize } = current; - const { disableCursor, orientation, panels } = group; + const { orientation, panels } = group; + const { disableCursor } = group.mutableState; let deltaAsPercentage = 0; if (pointerDownAtPoint) { @@ -107,7 +110,7 @@ export function updateActiveHitRegions({ } } } else { - nextMountedGroups.set(current.group, { + updateMountedGroup(current.group, { defaultLayoutDeferred, derivedPanelConstraints: derivedPanelConstraints, layout: nextLayout, @@ -132,10 +135,6 @@ export function updateActiveHitRegions({ cursorFlags |= nextCursorFlags & CURSOR_FLAGS_VERTICAL; } - update({ - cursorFlags, - mountedGroups: nextMountedGroups - }); - + updateCursorFlags(cursorFlags); updateCursorStyle(document); } From 6483f0fd364de83bd5400b8b5d68f4b27951f473 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 22 Feb 2026 16:51:45 -0500 Subject: [PATCH 2/3] Add VS Code example (#673) --- src/components/vs-code/VSCode.tsx | 63 +++++++++++++++++++ .../vs-code/VSCodeCollapsibleSidePanel.tsx | 35 +++++++++++ src/components/vs-code/VSCodeContext.tsx | 18 ++++++ .../vs-code/VSCodePrimaryContentPanel.tsx | 5 ++ src/components/vs-code/VSCodeSeparator.tsx | 7 +++ src/components/vs-code/VSCodeSidebar.tsx | 21 +++++++ .../vs-code/VSCodeSidebarButton.tsx | 56 +++++++++++++++++ src/routes/TestRoute.tsx | 12 +++- 8 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 src/components/vs-code/VSCode.tsx create mode 100644 src/components/vs-code/VSCodeCollapsibleSidePanel.tsx create mode 100644 src/components/vs-code/VSCodeContext.tsx create mode 100644 src/components/vs-code/VSCodePrimaryContentPanel.tsx create mode 100644 src/components/vs-code/VSCodeSeparator.tsx create mode 100644 src/components/vs-code/VSCodeSidebar.tsx create mode 100644 src/components/vs-code/VSCodeSidebarButton.tsx diff --git a/src/components/vs-code/VSCode.tsx b/src/components/vs-code/VSCode.tsx new file mode 100644 index 000000000..44f79f520 --- /dev/null +++ b/src/components/vs-code/VSCode.tsx @@ -0,0 +1,63 @@ +import { useMemo, useState } from "react"; +import { Group, usePanelCallbackRef } from "react-resizable-panels"; +import { VSCodeCollapsibleSidePanel } from "./VSCodeCollapsibleSidePanel"; +import type { Tab } from "./VSCodeContext"; +import { VSCodeContext } from "./VSCodeContext"; +import { VSCodePrimaryContentPanel } from "./VSCodePrimaryContentPanel"; +import { VSCodeSeparator } from "./VSCodeSeparator"; +import { VSCodeSidebar } from "./VSCodeSidebar"; + +export function VSCode() { + const [panelRef, setPanelRef] = usePanelCallbackRef(); + + const [state, setState] = useState<{ + activeTab: Tab; + sidebarCollapsed: boolean; + }>({ + activeTab: "folders", + sidebarCollapsed: false + }); + + const context = useMemo( + () => ({ + ...state, + changeTab: (activeTab: Tab) => { + setState((prev) => ({ ...prev, activeTab })); + }, + toggleSidebar: () => { + if (panelRef) { + if (panelRef.isCollapsed()) { + panelRef.expand(); + } else { + panelRef.collapse(); + } + } + } + }), + [panelRef, state] + ); + + return ( + + + + { + const sidebarCollapsed = size.inPixels === 0; + setState((prev) => + prev.sidebarCollapsed === sidebarCollapsed + ? prev + : { + ...prev, + sidebarCollapsed + } + ); + }} + panelRef={setPanelRef} + /> + + + + + ); +} diff --git a/src/components/vs-code/VSCodeCollapsibleSidePanel.tsx b/src/components/vs-code/VSCodeCollapsibleSidePanel.tsx new file mode 100644 index 000000000..39d503627 --- /dev/null +++ b/src/components/vs-code/VSCodeCollapsibleSidePanel.tsx @@ -0,0 +1,35 @@ +import { useContext, type Ref } from "react"; +import { + Panel, + type OnPanelResize, + type PanelImperativeHandle +} from "react-resizable-panels"; +import { VSCodeContext } from "./VSCodeContext"; +import { Box } from "react-lib-tools"; + +export function VSCodeCollapsibleSidePanel({ + onResize, + panelRef +}: { + onResize: OnPanelResize; + panelRef: Ref | undefined; +}) { + const { activeTab } = useContext(VSCodeContext); + + return ( + + +
{activeTab}
+
Side panel content goes here
+
+
+ ); +} diff --git a/src/components/vs-code/VSCodeContext.tsx b/src/components/vs-code/VSCodeContext.tsx new file mode 100644 index 000000000..2d7a1d3a2 --- /dev/null +++ b/src/components/vs-code/VSCodeContext.tsx @@ -0,0 +1,18 @@ +import { createContext } from "react"; +import { NOOP_FUNCTION } from "../../../lib/constants"; + +export type Tab = "debug" | "folders" | "search"; + +export type VSCodeContextType = { + activeTab: Tab; + changeTab: (tab: Tab) => void; + sidebarCollapsed: boolean; + toggleSidebar: () => void; +}; + +export const VSCodeContext = createContext({ + activeTab: "folders", + changeTab: NOOP_FUNCTION, + sidebarCollapsed: false, + toggleSidebar: NOOP_FUNCTION +}); diff --git a/src/components/vs-code/VSCodePrimaryContentPanel.tsx b/src/components/vs-code/VSCodePrimaryContentPanel.tsx new file mode 100644 index 000000000..95902e9ff --- /dev/null +++ b/src/components/vs-code/VSCodePrimaryContentPanel.tsx @@ -0,0 +1,5 @@ +import { Panel } from "react-resizable-panels"; + +export function VSCodePrimaryContentPanel() { + return Primary content panel; +} diff --git a/src/components/vs-code/VSCodeSeparator.tsx b/src/components/vs-code/VSCodeSeparator.tsx new file mode 100644 index 000000000..c27634562 --- /dev/null +++ b/src/components/vs-code/VSCodeSeparator.tsx @@ -0,0 +1,7 @@ +import { Separator } from "react-resizable-panels"; + +export function VSCodeSeparator() { + return ( + + ); +} diff --git a/src/components/vs-code/VSCodeSidebar.tsx b/src/components/vs-code/VSCodeSidebar.tsx new file mode 100644 index 000000000..b1e64afbe --- /dev/null +++ b/src/components/vs-code/VSCodeSidebar.tsx @@ -0,0 +1,21 @@ +import { + FolderIcon, + MagnifyingGlassIcon, + PlayIcon +} from "@heroicons/react/20/solid"; +import { Box } from "react-lib-tools"; +import { VSCodeSidebarButton } from "./VSCodeSidebarButton"; + +export function VSCodeSidebar() { + return ( + + + + + + ); +} diff --git a/src/components/vs-code/VSCodeSidebarButton.tsx b/src/components/vs-code/VSCodeSidebarButton.tsx new file mode 100644 index 000000000..7c03f849f --- /dev/null +++ b/src/components/vs-code/VSCodeSidebarButton.tsx @@ -0,0 +1,56 @@ +import { + useContext, + type ForwardRefExoticComponent, + type RefAttributes, + type SVGProps +} from "react"; +import { cn, Tooltip } from "react-lib-tools"; +import { VSCodeContext, type Tab } from "./VSCodeContext"; + +type Icon = ForwardRefExoticComponent< + Omit, "ref"> & { + title?: string; + titleId?: string; + } & RefAttributes +>; + +export function VSCodeSidebarButton({ + Icon, + tab, + title +}: { + Icon: Icon; + tab: Tab; + title: string; +}) { + const { activeTab, changeTab, sidebarCollapsed, toggleSidebar } = + useContext(VSCodeContext); + + const isActive = activeTab === tab && !sidebarCollapsed; + + return ( + + + + ); +} diff --git a/src/routes/TestRoute.tsx b/src/routes/TestRoute.tsx index 4a95c9756..4b69484d0 100644 --- a/src/routes/TestRoute.tsx +++ b/src/routes/TestRoute.tsx @@ -1,19 +1,25 @@ -import { Box } from "react-lib-tools"; +import { Box, Callout, Header } from "react-lib-tools"; import { AccordionGroup } from "../components/accordion/AccordionGroup"; import { AccordionPanel } from "../components/accordion/AccordionPanel"; +import { VSCode } from "../components/vs-code/VSCode"; export default function TestRoute() { return ( -
+ This is a test route. Any content shown here is temporary and should not be referenced externally. -
+ +
+
This is the first panel. This is the second panel. This is the third panel. +
+
+ ); } From 5b01d38deeeb5f9f1941660d467693dcaa1d8d92 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 22 Feb 2026 16:53:23 -0500 Subject: [PATCH 3/3] Small style tweak of VS Code demo example --- src/components/vs-code/VSCode.tsx | 2 +- src/components/vs-code/VSCodeSidebarButton.tsx | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/vs-code/VSCode.tsx b/src/components/vs-code/VSCode.tsx index 44f79f520..eaacd567c 100644 --- a/src/components/vs-code/VSCode.tsx +++ b/src/components/vs-code/VSCode.tsx @@ -39,7 +39,7 @@ export function VSCode() { return ( - + { diff --git a/src/components/vs-code/VSCodeSidebarButton.tsx b/src/components/vs-code/VSCodeSidebarButton.tsx index 7c03f849f..df8008f89 100644 --- a/src/components/vs-code/VSCodeSidebarButton.tsx +++ b/src/components/vs-code/VSCodeSidebarButton.tsx @@ -32,10 +32,8 @@ export function VSCodeSidebarButton({