diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..ac1fbb593 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 0.0.3 +* [#3](https://github.com/bvaughn/react-resizable-panels/issues/3): `Panel` visibility can be toggled on/off. `PanelGroup` will persist separate layouts for each combination of visible panels. + +## 0.0.2 +* Documentation-only update. + +## 0.0.1 +* Initial release. diff --git a/README.md b/README.md index 04775e8dd..455e16207 100644 --- a/README.md +++ b/README.md @@ -48,5 +48,5 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; | `children` | `?ReactNode` | Custom drag UI; can be any arbitrary React element(s) | `className` | `?string` | Class name | `disabled` | `?boolean` | Disable drag handle -| `panelAfter` | `PanelId` | Id of panel after (below or to the right of) the drag handle -| `panelBefore` | `PanelId` | Id of panel before (above or to the left of) the drag handle \ No newline at end of file +| `panelAfter` | `string` | Id of panel after (below or to the right of) the drag handle +| `panelBefore` | `string` | Id of panel before (above or to the left of) the drag handle \ No newline at end of file diff --git a/declaration.d.ts b/declaration.d.ts new file mode 100644 index 000000000..d0043f383 --- /dev/null +++ b/declaration.d.ts @@ -0,0 +1,4 @@ +declare module "*.module.css" { + const content: Record; + export default content; +} diff --git a/package.json b/package.json index 448c10d09..e437bfdb2 100644 --- a/package.json +++ b/package.json @@ -14,5 +14,10 @@ "prettier": "latest", "process": "^0.11.10", "typescript": ">=3.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.26", + "@types/react-dom": "^18.0.10", + "@types/react-virtualized-auto-sizer": "^1.0.1" } } diff --git a/packages/react-resizable-panels-website/index.js b/packages/react-resizable-panels-website/index.js new file mode 100644 index 000000000..c030b3dd7 --- /dev/null +++ b/packages/react-resizable-panels-website/index.js @@ -0,0 +1,7 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App"; +var rootElement = document.getElementById("root"); +var root = createRoot(rootElement); +root.render(_jsx(StrictMode, { children: _jsx(App, {}) })); diff --git a/packages/react-resizable-panels-website/src/App.js b/packages/react-resizable-panels-website/src/App.js new file mode 100644 index 000000000..9ecf835a0 --- /dev/null +++ b/packages/react-resizable-panels-website/src/App.js @@ -0,0 +1,29 @@ +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +import { jsx as _jsx } from "react/jsx-runtime"; +import { useCallback, useState } from "react"; +import HorizontalGroup from "./HorizontalGroup"; +import styles from "./styles.module.css"; +export default function DemoApp() { + var _a = useState(0), key = _a[0], setKey = _a[1]; + var clearSavedSizes = useCallback(function () { + var groupIds = []; + for (var _i = 0; _i < arguments.length; _i++) { + groupIds[_i] = arguments[_i]; + } + groupIds.forEach(function (groupId) { + localStorage.removeItem("PanelGroup:sizes:".concat(groupId)); + }); + setKey(function (prevKey) { return prevKey + 1; }); + }, []); + return (_jsx("div", __assign({ className: styles.FullHeightAndWidth }, { children: _jsx(HorizontalGroup, { clearSavedSizes: clearSavedSizes }, key) }))); +} diff --git a/packages/react-resizable-panels-website/src/AutoSizedPanelGroup.js b/packages/react-resizable-panels-website/src/AutoSizedPanelGroup.js new file mode 100644 index 000000000..cc4a7e257 --- /dev/null +++ b/packages/react-resizable-panels-website/src/AutoSizedPanelGroup.js @@ -0,0 +1,4 @@ +import { PanelGroup } from "react-resizable-panels"; +import withAutoSizer from "./withAutoSizer"; +var AutoSizedPanelGroup = withAutoSizer(PanelGroup); +export default AutoSizedPanelGroup; diff --git a/packages/react-resizable-panels-website/src/HorizontalGroup.js b/packages/react-resizable-panels-website/src/HorizontalGroup.js new file mode 100644 index 000000000..17b0cf0fc --- /dev/null +++ b/packages/react-resizable-panels-website/src/HorizontalGroup.js @@ -0,0 +1,21 @@ +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { Panel, PanelResizeHandle } from "react-resizable-panels"; +import PanelGroup from "./AutoSizedPanelGroup"; +import styles from "./styles.module.css"; +import { GROUP_ID as GROUP_ID_VERTICAL, VerticalGroup } from "./VerticalGroup"; +export var GROUP_ID = "horizontal"; +export default function HorizontalGroup(_a) { + var clearSavedSizes = _a.clearSavedSizes; + return (_jsxs(PanelGroup, __assign({ autoSaveId: GROUP_ID, direction: "horizontal" }, { children: [_jsx(Panel, __assign({ className: styles.PanelRow, defaultSize: 0.2, id: "left" }, { children: _jsxs("div", __assign({ className: styles.HorizontalFiller, style: { backgroundColor: "var(--color-horizontal)" } }, { children: [_jsxs("p", __assign({ className: styles.ParagraphOfText }, { children: ["This is a \"", _jsx("em", { children: "horizontal" }), "\" ", _jsx("code", { children: "PanelGroup" })] })), _jsxs("p", __assign({ className: styles.ParagraphOfText }, { children: ["It has an empty/implied resize bar, like", " ", _jsx("a", __assign({ href: "https://replay.io", target: "_blank", rel: "noreferrer noopener" }, { children: "Replay.io" })), "."] }))] })) })), _jsxs(Panel, __assign({ className: styles.PanelRow, defaultSize: 0.4, id: "middle", minSize: 0.25 }, { children: [_jsx(PanelResizeHandle, { className: styles.HorizontalResizeHandle, panelBefore: "left", panelAfter: "middle" }), _jsxs("div", __assign({ className: styles.HorizontalFiller, style: { backgroundColor: "var(--color-horizontal)" } }, { children: [_jsx("h2", { children: "Auto Save" }), _jsxs("p", __assign({ className: styles.ParagraphOfText }, { children: ["This demo uses the ", _jsx("code", { children: "autoSaveId" }), " prop to remember sizes."] })), _jsx("p", __assign({ className: styles.ParagraphOfText }, { children: "Reset saved sizes by clicking the buttons below." })), _jsxs("p", __assign({ className: styles.ParagraphOfText }, { children: [_jsxs("button", __assign({ className: styles.Button, onClick: function () { return clearSavedSizes(GROUP_ID); } }, { children: ["reset horizontal sizes", _jsx("div", { className: styles.HorizontalDot })] })), _jsx("br", {}), _jsxs("button", __assign({ className: styles.Button, onClick: function () { return clearSavedSizes(GROUP_ID_VERTICAL); } }, { children: ["reset vertical sizes", _jsx("div", { className: styles.VerticalDot })] })), _jsx("hr", {}), _jsxs("button", __assign({ className: styles.Button, onClick: function () { return clearSavedSizes(GROUP_ID, GROUP_ID_VERTICAL); } }, { children: ["reset both", _jsx("div", { className: styles.HorizontalDot }), _jsx("div", { className: styles.VerticalDot })] }))] })), _jsx("p", __assign({ className: styles.ParagraphOfText }, { children: "It won't shrink beyond 25% of the total width." }))] })), _jsx(PanelResizeHandle, { className: styles.HorizontalResizeHandle, panelBefore: "middle", panelAfter: "stacked" })] })), _jsx(Panel, __assign({ className: styles.PanelRow, defaultSize: 0.3, id: "stacked" }, { children: _jsx("div", __assign({ className: styles.Grower }, { children: _jsx(VerticalGroup, {}) })) })), _jsxs(Panel, __assign({ className: styles.PanelRow, defaultSize: 0.2, id: "right" }, { children: [_jsx(PanelResizeHandle, { className: styles.HorizontalResizeHandle, panelBefore: "stacked", panelAfter: "right" }), _jsx("div", __assign({ className: styles.HorizontalFiller, style: { backgroundColor: "var(--color-horizontal)" } }, { children: _jsxs("p", __assign({ className: styles.ParagraphOfText }, { children: ["Read more on", " ", _jsx("a", __assign({ href: "https://github.com/bvaughn/react-resizable-panels", target: "_blank", rel: "noreferrer noopener" }, { children: "GitHub" })), "."] })) }))] }))] }))); +} diff --git a/packages/react-resizable-panels-website/src/HorizontalGroup.tsx b/packages/react-resizable-panels-website/src/HorizontalGroup.tsx index e18b24959..5e37c5f45 100644 --- a/packages/react-resizable-panels-website/src/HorizontalGroup.tsx +++ b/packages/react-resizable-panels-website/src/HorizontalGroup.tsx @@ -1,8 +1,4 @@ -import { - Panel, - PanelGroup as PanelGroupWithSizes, - PanelResizeHandle, -} from "react-resizable-panels"; +import { Panel, PanelResizeHandle } from "react-resizable-panels"; import PanelGroup from "./AutoSizedPanelGroup"; import styles from "./styles.module.css"; @@ -62,7 +58,7 @@ export default function HorizontalGroup({



+

+ )} + {isPanelHidden || ( + + +
+ +
+

+ This panel's visibility can be toggled on or off. +

+

+ +

+
+ + )} ); } diff --git a/packages/react-resizable-panels-website/src/styles.module.css b/packages/react-resizable-panels-website/src/styles.module.css index 551b23567..e279abdcb 100644 --- a/packages/react-resizable-panels-website/src/styles.module.css +++ b/packages/react-resizable-panels-website/src/styles.module.css @@ -59,7 +59,7 @@ border-top: 1px solid #4a4c50; } -.ResetButton { +.Button { color: #ffffff; background: #2a3343; border: 1px solid #18181a; @@ -67,7 +67,7 @@ padding: 0.25rem 0.5rem; cursor: pointer; } -.ResetButton:hover { +.Button:hover { background: #454950; } diff --git a/packages/react-resizable-panels-website/src/withAutoSizer.js b/packages/react-resizable-panels-website/src/withAutoSizer.js new file mode 100644 index 000000000..f25e8ae8f --- /dev/null +++ b/packages/react-resizable-panels-website/src/withAutoSizer.js @@ -0,0 +1,22 @@ +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +import { createElement } from "react"; +import AutoSizer from "react-virtualized-auto-sizer"; +export default function withAutoSizer(Component, autoSizerProps) { + var AutoSizerWrapper = function (props) { + return createElement(AutoSizer, __assign(__assign({}, autoSizerProps), { children: function (_a) { + var height = _a.height, width = _a.width; + return createElement(Component, __assign(__assign({}, props), { height: height, width: width })); + } })); + }; + return AutoSizerWrapper; +} diff --git a/packages/react-resizable-panels/src/Panel.js b/packages/react-resizable-panels/src/Panel.js new file mode 100644 index 000000000..4066a2ad7 --- /dev/null +++ b/packages/react-resizable-panels/src/Panel.js @@ -0,0 +1,42 @@ +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +import { jsx as _jsx } from "react/jsx-runtime"; +import { useContext, useLayoutEffect } from "react"; +import { PanelGroupContext } from "./PanelContexts"; +// TODO [panels] +// Support min pixel size too. +// PanelGroup should warn if total width is less min pixel widths. +export default function Panel(_a) { + var _b = _a.children, children = _b === void 0 ? null : _b, _c = _a.className, className = _c === void 0 ? "" : _c, _d = _a.defaultSize, defaultSize = _d === void 0 ? 0.1 : _d, id = _a.id, _e = _a.minSize, minSize = _e === void 0 ? 0.1 : _e; + var context = useContext(PanelGroupContext); + if (context === null) { + throw Error("Panel components must be rendered within a PanelGroup container"); + } + if (minSize > defaultSize) { + console.error("Panel minSize ".concat(minSize, " cannot be greater than defaultSize ").concat(defaultSize)); + defaultSize = minSize; + } + var getPanelStyle = context.getPanelStyle, registerPanel = context.registerPanel, unregisterPanel = context.unregisterPanel; + useLayoutEffect(function () { + var panel = { + defaultSize: defaultSize, + id: id, + minSize: minSize + }; + registerPanel(id, panel); + return function () { + unregisterPanel(id); + }; + }, [defaultSize, id, minSize, registerPanel, unregisterPanel]); + var style = getPanelStyle(id); + return (_jsx("div", __assign({ className: className, style: style }, { children: children }))); +} diff --git a/packages/react-resizable-panels/src/Panel.tsx b/packages/react-resizable-panels/src/Panel.tsx index 225cdbd64..0c29f09c4 100644 --- a/packages/react-resizable-panels/src/Panel.tsx +++ b/packages/react-resizable-panels/src/Panel.tsx @@ -1,22 +1,21 @@ import { ReactNode, useContext, useLayoutEffect } from "react"; import { PanelGroupContext } from "./PanelContexts"; -import { PanelId } from "./types"; // TODO [panels] // Support min pixel size too. // PanelGroup should warn if total width is less min pixel widths. export default function Panel({ - children, + children = null, className = "", defaultSize = 0.1, id, minSize = 0.1, }: { - children: ReactNode; + children?: ReactNode; className?: string; defaultSize?: number; - id: PanelId; + id: string; minSize?: number; }) { const context = useContext(PanelGroupContext); @@ -34,7 +33,7 @@ export default function Panel({ defaultSize = minSize; } - const { getPanelStyle, registerPanel } = context; + const { getPanelStyle, registerPanel, unregisterPanel } = context; useLayoutEffect(() => { const panel = { @@ -44,7 +43,11 @@ export default function Panel({ }; registerPanel(id, panel); - }, [defaultSize, minSize, registerPanel, id]); + + return () => { + unregisterPanel(id); + }; + }, [defaultSize, id, minSize, registerPanel, unregisterPanel]); const style = getPanelStyle(id); diff --git a/packages/react-resizable-panels/src/PanelContexts.js b/packages/react-resizable-panels/src/PanelContexts.js new file mode 100644 index 000000000..bf070b7d9 --- /dev/null +++ b/packages/react-resizable-panels/src/PanelContexts.js @@ -0,0 +1,2 @@ +import { createContext } from "react"; +export var PanelGroupContext = createContext(null); diff --git a/packages/react-resizable-panels/src/PanelContexts.ts b/packages/react-resizable-panels/src/PanelContexts.ts index f40d59e95..f3d97b803 100644 --- a/packages/react-resizable-panels/src/PanelContexts.ts +++ b/packages/react-resizable-panels/src/PanelContexts.ts @@ -1,10 +1,11 @@ import { CSSProperties, createContext } from "react"; -import { PanelData, PanelId, ResizeHandler } from "./types"; +import { PanelData, ResizeHandler } from "./types"; export const PanelGroupContext = createContext<{ direction: "horizontal" | "vertical"; - getPanelStyle: (id: PanelId) => CSSProperties; - registerResizeHandle: (idBefore: PanelId, idAfter: PanelId) => ResizeHandler; - registerPanel: (id: PanelId, panel: PanelData) => void; + getPanelStyle: (id: string) => CSSProperties; + registerPanel: (id: string, panel: PanelData) => void; + registerResizeHandle: (idBefore: string, idAfter: string) => ResizeHandler; + unregisterPanel: (id: string) => void; } | null>(null); diff --git a/packages/react-resizable-panels/src/PanelGroup.js b/packages/react-resizable-panels/src/PanelGroup.js new file mode 100644 index 000000000..703778e5c --- /dev/null +++ b/packages/react-resizable-panels/src/PanelGroup.js @@ -0,0 +1,230 @@ +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +import { jsx as _jsx } from "react/jsx-runtime"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; +import { PanelGroupContext } from "./PanelContexts"; +var PRECISION = 5; +// TODO [panels] +// Within an active drag, remember original positions to refine more easily on expand. +// Look at what the Chrome devtools Sources does. +export default function PanelGroup(_a) { + var autoSaveId = _a.autoSaveId, _b = _a.children, children = _b === void 0 ? null : _b, _c = _a.className, className = _c === void 0 ? "" : _c, direction = _a.direction, height = _a.height, width = _a.width; + var _d = useState(new Map()), panels = _d[0], setPanels = _d[1]; + // 0-1 values representing the relative size of each panel. + var _e = useState([]), sizes = _e[0], setSizes = _e[1]; + // Store committed values to avoid unnecessarily re-running memoization/effects functions. + var committedValuesRef = useRef({ + direction: direction, + height: height, + panels: panels, + sizes: sizes, + width: width + }); + useLayoutEffect(function () { + committedValuesRef.current.direction = direction; + committedValuesRef.current.height = height; + committedValuesRef.current.panels = panels; + committedValuesRef.current.sizes = sizes; + committedValuesRef.current.width = width; + }); + // Once all panels have registered themselves, + // Compute the initial sizes based on default weights. + // This assumes that panels register during initial mount (no conditional rendering)! + useLayoutEffect(function () { + var sizes = committedValuesRef.current.sizes; + if (sizes.length === panels.size) { + return; + } + // TODO [panels] + // Validate that the total minSize is <= 1. + // If this panel has been configured to persist sizing information, + // default size should be restored from local storage if possible. + var defaultSizes = undefined; + if (autoSaveId) { + try { + var value = localStorage.getItem(createLocalStorageKey(autoSaveId, panels)); + if (value) { + defaultSizes = JSON.parse(value); + } + } + catch (error) { } + } + if (sizes.length === 0 && + defaultSizes != null && + defaultSizes.length === panels.size) { + setSizes(defaultSizes); + } + else { + var panelsArray = Array.from(panels.values()); + var totalWeight_1 = panelsArray.reduce(function (weight, panel) { + return weight + panel.defaultSize; + }, 0); + setSizes(panelsArray.map(function (panel) { return panel.defaultSize / totalWeight_1; })); + } + }, [autoSaveId, panels]); + useEffect(function () { + if (autoSaveId && sizes.length > 0) { + // If this panel has been configured to persist sizing information, save sizes to local storage. + localStorage.setItem(createLocalStorageKey(autoSaveId, panels), JSON.stringify(sizes)); + } + }, [autoSaveId, panels, sizes]); + var getPanelStyle = useCallback(function (id) { + var panels = committedValuesRef.current.panels; + var offset = getOffset(panels, id, direction, sizes, height, width); + var size = getSize(panels, id, direction, sizes, height, width); + if (direction === "horizontal") { + return { + height: "100%", + position: "absolute", + left: offset, + top: 0, + width: size + }; + } + else { + return { + height: size, + position: "absolute", + left: 0, + top: offset, + width: "100%" + }; + } + }, [direction, height, sizes, width]); + var registerPanel = useCallback(function (id, panel) { + setPanels(function (prevPanels) { + if (prevPanels.has(id)) { + return prevPanels; + } + var nextPanels = new Map(prevPanels); + nextPanels.set(id, panel); + return nextPanels; + }); + }, []); + var registerResizeHandle = useCallback(function (idBefore, idAfter) { + return function (event) { + event.preventDefault(); + var _a = committedValuesRef.current, direction = _a.direction, height = _a.height, panels = _a.panels, prevSizes = _a.sizes, width = _a.width; + var isHorizontal = direction === "horizontal"; + var movement = isHorizontal ? event.movementX : event.movementY; + var delta = isHorizontal ? movement / width : movement / height; + var nextSizes = adjustByDelta(panels, idBefore, idAfter, delta, prevSizes); + if (prevSizes !== nextSizes) { + setSizes(nextSizes); + } + }; + // TODO [issues/5] Add to Map + }, []); + var unregisterPanel = useCallback(function (id) { + setPanels(function (prevPanels) { + if (!prevPanels.has(id)) { + return prevPanels; + } + var nextPanels = new Map(prevPanels); + nextPanels["delete"](id); + return nextPanels; + }); + }, []); + var context = useMemo(function () { return ({ + direction: direction, + getPanelStyle: getPanelStyle, + registerPanel: registerPanel, + registerResizeHandle: registerResizeHandle, + unregisterPanel: unregisterPanel + }); }, [ + direction, + getPanelStyle, + registerPanel, + registerResizeHandle, + unregisterPanel, + ]); + return (_jsx(PanelGroupContext.Provider, __assign({ value: context }, { children: _jsx("div", __assign({ className: className }, { children: children })) }))); +} +function adjustByDelta(panels, idBefore, idAfter, delta, prevSizes) { + if (delta === 0) { + return prevSizes; + } + var panelsArray = Array.from(panels.values()); + var nextSizes = prevSizes.concat(); + var deltaApplied = 0; + // A resizing panel affects the panels before or after it. + // + // A negative delta means the panel immediately after the resizer should grow/expand by decreasing its offset. + // Other panels may also need to shrink/contract (and shift) to make room, depending on the min weights. + // + // A positive delta means the panel immediately before the resizer should "expand". + // This is accomplished by shrinking/contracting (and shifting) one or more of the panels after the resizer. + var pivotId = delta < 0 ? idBefore : idAfter; + var index = panelsArray.findIndex(function (panel) { return panel.id === pivotId; }); + while (true) { + var panel = panelsArray[index]; + var prevSize = prevSizes[index]; + var nextSize = Math.max(prevSize - Math.abs(delta), panel.minSize); + if (prevSize !== nextSize) { + deltaApplied += prevSize - nextSize; + nextSizes[index] = nextSize; + if (deltaApplied.toPrecision(PRECISION) >= delta.toPrecision(PRECISION)) { + break; + } + } + if (delta < 0) { + if (--index < 0) { + break; + } + } + else { + if (++index >= panelsArray.length) { + break; + } + } + } + // If we were unable to resize any of the panels panels, return the previous state. + // This will essentially bailout and ignore the "mousemove" event. + if (deltaApplied === 0) { + return prevSizes; + } + // Adjust the pivot panel before, but only by the amount that surrounding panels were able to shrink/contract. + pivotId = delta < 0 ? idAfter : idBefore; + index = panelsArray.findIndex(function (panel) { return panel.id === pivotId; }); + nextSizes[index] = prevSizes[index] + deltaApplied; + return nextSizes; +} +function createLocalStorageKey(autoSaveId, panels) { + var panelIds = Array.from(panels.keys()).sort(); + return "PanelGroup:sizes:".concat(autoSaveId).concat(panelIds.join("|")); +} +function getOffset(panels, id, direction, sizes, height, width) { + var panelsArray = Array.from(panels.values()); + var index = panelsArray.findIndex(function (panel) { return panel.id === id; }); + if (index < 0) { + return 0; + } + var scaledOffset = 0; + for (index = index - 1; index >= 0; index--) { + var panel = panelsArray[index]; + scaledOffset += getSize(panels, panel.id, direction, sizes, height, width); + } + return Math.round(scaledOffset); +} +function getSize(panels, id, direction, sizes, height, width) { + var totalSize = direction === "horizontal" ? width : height; + if (panels.size === 1) { + return totalSize; + } + var panelsArray = Array.from(panels.values()); + var index = panelsArray.findIndex(function (panel) { return panel.id === id; }); + var size = sizes[index]; + if (size == null) { + return 0; + } + return Math.round(size * totalSize); +} diff --git a/packages/react-resizable-panels/src/PanelGroup.tsx b/packages/react-resizable-panels/src/PanelGroup.tsx index 75ac8ffdd..5a80d1dff 100644 --- a/packages/react-resizable-panels/src/PanelGroup.tsx +++ b/packages/react-resizable-panels/src/PanelGroup.tsx @@ -10,11 +10,11 @@ import { } from "react"; import { PanelGroupContext } from "./PanelContexts"; -import { Direction, PanelData, PanelId } from "./types"; +import { Direction, PanelData } from "./types"; type Props = { autoSaveId?: string; - children: ReactNode[]; + children?: ReactNode[]; className?: string; direction: Direction; height: number; @@ -29,13 +29,13 @@ const PRECISION = 5; export default function PanelGroup({ autoSaveId, - children, + children = null, className = "", direction, height, width, }: Props) { - const panelsRef = useRef([]); + const [panels, setPanels] = useState>(new Map()); // 0-1 values representing the relative size of each panel. const [sizes, setSizes] = useState([]); @@ -44,17 +44,20 @@ export default function PanelGroup({ const committedValuesRef = useRef<{ direction: Direction; height: number; + panels: Map; sizes: number[]; width: number; }>({ direction, height, + panels, sizes, width, }); useLayoutEffect(() => { committedValuesRef.current.direction = direction; committedValuesRef.current.height = height; + committedValuesRef.current.panels = panels; committedValuesRef.current.sizes = sizes; committedValuesRef.current.width = width; }); @@ -63,9 +66,8 @@ export default function PanelGroup({ // Compute the initial sizes based on default weights. // This assumes that panels register during initial mount (no conditional rendering)! useLayoutEffect(() => { - const panels = panelsRef.current; const sizes = committedValuesRef.current.sizes; - if (sizes.length === panels.length) { + if (sizes.length === panels.size) { return; } @@ -77,41 +79,44 @@ export default function PanelGroup({ let defaultSizes: number[] | undefined = undefined; if (autoSaveId) { try { - const value = localStorage.getItem(`PanelGroup:sizes:${autoSaveId}`); + const value = localStorage.getItem( + createLocalStorageKey(autoSaveId, panels) + ); if (value) { defaultSizes = JSON.parse(value); } } catch (error) {} } - if ( - sizes.length === 0 && - defaultSizes != null && - defaultSizes.length === panels.length - ) { + if (defaultSizes != null) { setSizes(defaultSizes); } else { - const totalWeight = panels.reduce((weight, panel) => { + const panelsArray: PanelData[] = Array.from(panels.values()); + const totalWeight = panelsArray.reduce((weight, panel) => { return weight + panel.defaultSize; }, 0); - setSizes(panels.map((panel) => panel.defaultSize / totalWeight)); + setSizes(panelsArray.map((panel) => panel.defaultSize / totalWeight)); } - }, [autoSaveId]); + }, [autoSaveId, panels]); useEffect(() => { - if (autoSaveId && sizes.length > 0) { + if (autoSaveId) { + if (sizes.length === 0 || sizes.length !== panels.size) { + return; + } + // If this panel has been configured to persist sizing information, save sizes to local storage. localStorage.setItem( - `PanelGroup:sizes:${autoSaveId}`, + createLocalStorageKey(autoSaveId, panels), JSON.stringify(sizes) ); } - }, [autoSaveId, sizes]); + }, [autoSaveId, panels, sizes]); const getPanelStyle = useCallback( - (id: PanelId): CSSProperties => { - const panels = panelsRef.current; + (id: string): CSSProperties => { + const { panels } = committedValuesRef.current; const offset = getOffset(panels, id, direction, sizes, height, width); const size = getSize(panels, id, direction, sizes, height, width); @@ -137,24 +142,28 @@ export default function PanelGroup({ [direction, height, sizes, width] ); - const registerPanel = useCallback((id: PanelId, panel: PanelData) => { - const panels = panelsRef.current; - const index = panels.findIndex((panel) => panel.id === id); - if (index >= 0) { - panels.splice(index, 1); - } - panels.push(panel); + const registerPanel = useCallback((id: string, panel: PanelData) => { + setPanels((prevPanels) => { + if (prevPanels.has(id)) { + return prevPanels; + } + + const nextPanels = new Map(prevPanels); + nextPanels.set(id, panel); + + return nextPanels; + }); }, []); const registerResizeHandle = useCallback( - (idBefore: PanelId, idAfter: PanelId) => { + (idBefore: string, idAfter: string) => { return (event: MouseEvent) => { event.preventDefault(); - const panels = panelsRef.current; const { direction, height, + panels, sizes: prevSizes, width, } = committedValuesRef.current; @@ -174,18 +183,40 @@ export default function PanelGroup({ setSizes(nextSizes); } }; + + // TODO [issues/5] Add to Map }, [] ); + const unregisterPanel = useCallback((id: string) => { + setPanels((prevPanels) => { + if (!prevPanels.has(id)) { + return prevPanels; + } + + const nextPanels = new Map(prevPanels); + nextPanels.delete(id); + + return nextPanels; + }); + }, []); + const context = useMemo( () => ({ direction, getPanelStyle, registerPanel, registerResizeHandle, + unregisterPanel, }), - [direction, getPanelStyle, registerPanel, registerResizeHandle] + [ + direction, + getPanelStyle, + registerPanel, + registerResizeHandle, + unregisterPanel, + ] ); return ( @@ -196,9 +227,9 @@ export default function PanelGroup({ } function adjustByDelta( - panels: PanelData[], - idBefore: PanelId, - idAfter: PanelId, + panels: Map, + idBefore: string, + idAfter: string, delta: number, prevSizes: number[] ): number[] { @@ -206,6 +237,8 @@ function adjustByDelta( return prevSizes; } + const panelsArray: PanelData[] = Array.from(panels.values()); + const nextSizes = prevSizes.concat(); let deltaApplied = 0; @@ -218,9 +251,9 @@ function adjustByDelta( // A positive delta means the panel immediately before the resizer should "expand". // This is accomplished by shrinking/contracting (and shifting) one or more of the panels after the resizer. let pivotId = delta < 0 ? idBefore : idAfter; - let index = panels.findIndex((panel) => panel.id === pivotId); + let index = panelsArray.findIndex((panel) => panel.id === pivotId); while (true) { - const panel = panels[index]; + const panel = panelsArray[index]; const prevSize = prevSizes[index]; const nextSize = Math.max(prevSize - Math.abs(delta), panel.minSize); if (prevSize !== nextSize) { @@ -238,7 +271,7 @@ function adjustByDelta( break; } } else { - if (++index >= panels.length) { + if (++index >= panelsArray.length) { break; } } @@ -252,21 +285,32 @@ function adjustByDelta( // Adjust the pivot panel before, but only by the amount that surrounding panels were able to shrink/contract. pivotId = delta < 0 ? idAfter : idBefore; - index = panels.findIndex((panel) => panel.id === pivotId); + index = panelsArray.findIndex((panel) => panel.id === pivotId); nextSizes[index] = prevSizes[index] + deltaApplied; return nextSizes; } +function createLocalStorageKey( + autoSaveId: string, + panels: Map +): string { + const panelIds = Array.from(panels.keys()).sort(); + + return `PanelGroup:sizes:${autoSaveId}${panelIds.join("|")}`; +} + function getOffset( - panels: PanelData[], - id: PanelId, + panels: Map, + id: string, direction: Direction, sizes: number[], height: number, width: number ): number { - let index = panels.findIndex((panel) => panel.id === id); + const panelsArray: PanelData[] = Array.from(panels.values()); + + let index = panelsArray.findIndex((panel) => panel.id === id); if (index < 0) { return 0; } @@ -274,7 +318,7 @@ function getOffset( let scaledOffset = 0; for (index = index - 1; index >= 0; index--) { - const panel = panels[index]; + const panel = panelsArray[index]; scaledOffset += getSize(panels, panel.id, direction, sizes, height, width); } @@ -282,24 +326,26 @@ function getOffset( } function getSize( - panels: PanelData[], - id: PanelId, + panels: Map, + id: string, direction: Direction, sizes: number[], height: number, width: number ): number { - const index = panels.findIndex((panel) => panel.id === id); + const totalSize = direction === "horizontal" ? width : height; + + if (panels.size === 1) { + return totalSize; + } + + const panelsArray: PanelData[] = Array.from(panels.values()); + + const index = panelsArray.findIndex((panel) => panel.id === id); const size = sizes[index]; if (size == null) { return 0; } - const totalSize = direction === "horizontal" ? width : height; - - if (panels.length === 1) { - return totalSize; - } else { - return Math.round(size * totalSize); - } + return Math.round(size * totalSize); } diff --git a/packages/react-resizable-panels/src/PanelResizeHandle.js b/packages/react-resizable-panels/src/PanelResizeHandle.js new file mode 100644 index 000000000..d4a3566d4 --- /dev/null +++ b/packages/react-resizable-panels/src/PanelResizeHandle.js @@ -0,0 +1,60 @@ +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +import { jsx as _jsx } from "react/jsx-runtime"; +import { useContext, useEffect, useState } from "react"; +import { PanelGroupContext } from "./PanelContexts"; +export default function PanelResizeHandle(_a) { + var _b = _a.children, children = _b === void 0 ? null : _b, _c = _a.className, className = _c === void 0 ? "" : _c, _d = _a.disabled, disabled = _d === void 0 ? false : _d, panelAfter = _a.panelAfter, panelBefore = _a.panelBefore; + var context = useContext(PanelGroupContext); + if (context === null) { + throw Error("PanelResizeHandle components must be rendered within a PanelGroup container"); + } + var direction = context.direction, registerResizeHandle = context.registerResizeHandle; + var _e = useState(null), resizeHandler = _e[0], setResizeHandler = _e[1]; + var _f = useState(false), isDragging = _f[0], setIsDragging = _f[1]; + useEffect(function () { + if (disabled) { + setResizeHandler(null); + } + else { + setResizeHandler(function () { return registerResizeHandle(panelBefore, panelAfter); }); + } + }, [disabled, panelAfter, panelBefore, registerResizeHandle]); + useEffect(function () { + if (disabled || resizeHandler == null || !isDragging) { + return; + } + document.body.style.cursor = + direction === "horizontal" ? "ew-resize" : "ns-resize"; + var onMouseLeave = function (_) { + setIsDragging(false); + }; + var onMouseMove = function (event) { + resizeHandler(event); + }; + var onMouseUp = function (_) { + setIsDragging(false); + }; + document.body.addEventListener("mouseleave", onMouseLeave); + document.body.addEventListener("mousemove", onMouseMove); + document.body.addEventListener("mouseup", onMouseUp); + return function () { + document.body.style.cursor = ""; + document.body.removeEventListener("mouseleave", onMouseLeave); + document.body.removeEventListener("mousemove", onMouseMove); + document.body.removeEventListener("mouseup", onMouseUp); + }; + }, [direction, disabled, isDragging, resizeHandler]); + return (_jsx("div", __assign({ className: className, onMouseDown: function () { return setIsDragging(true); }, onMouseUp: function () { return setIsDragging(false); }, style: { + cursor: direction === "horizontal" ? "ew-resize" : "ns-resize" + } }, { children: children }))); +} diff --git a/packages/react-resizable-panels/src/PanelResizeHandle.tsx b/packages/react-resizable-panels/src/PanelResizeHandle.tsx index 750481824..3e2a86544 100644 --- a/packages/react-resizable-panels/src/PanelResizeHandle.tsx +++ b/packages/react-resizable-panels/src/PanelResizeHandle.tsx @@ -1,7 +1,7 @@ import { ReactNode, useContext, useEffect, useState } from "react"; import { PanelGroupContext } from "./PanelContexts"; -import { PanelId, ResizeHandler } from "./types"; +import { ResizeHandler } from "./types"; export default function PanelResizeHandle({ children = null, @@ -13,8 +13,8 @@ export default function PanelResizeHandle({ children?: ReactNode; className?: string; disabled?: boolean; - panelAfter: PanelId; - panelBefore: PanelId; + panelAfter: string; + panelBefore: string; }) { const context = useContext(PanelGroupContext); if (context === null) { diff --git a/packages/react-resizable-panels/src/index.js b/packages/react-resizable-panels/src/index.js new file mode 100644 index 000000000..475470737 --- /dev/null +++ b/packages/react-resizable-panels/src/index.js @@ -0,0 +1,4 @@ +import Panel from "./Panel"; +import PanelGroup from "./PanelGroup"; +import PanelResizeHandle from "./PanelResizeHandle"; +export { Panel, PanelGroup, PanelResizeHandle }; diff --git a/packages/react-resizable-panels/src/types.js b/packages/react-resizable-panels/src/types.js new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/react-resizable-panels/src/types.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/react-resizable-panels/src/types.ts b/packages/react-resizable-panels/src/types.ts index 2b4f5e9e3..ff5744d8b 100644 --- a/packages/react-resizable-panels/src/types.ts +++ b/packages/react-resizable-panels/src/types.ts @@ -1,10 +1,8 @@ export type Direction = "horizontal" | "vertical"; -export type PanelId = string; - export type PanelData = { defaultSize: number; - id: PanelId; + id: string; minSize: number; }; diff --git a/tsconfig.json b/tsconfig.json index d92212f83..72fb4a7ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,17 @@ { "compilerOptions": { "lib": ["ES2015", "DOM"], + "module": "es6", + "moduleResolution": "node", + "noImplicitAny": true, "jsx": "react-jsx" }, "exclude": ["node_modules"], - "include": ["**/*.ts", "**/*.tsx"], + "include": [ + "declaration.d.ts", + "packages/**/*.ts", + "packages/**/*.tsx" + ], "strict": true, "target": "es2015" } diff --git a/yarn.lock b/yarn.lock index 0d5c16bb6..f6d42d7a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -746,6 +746,39 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/prop-types@*": + version "15.7.5" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" + integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== + +"@types/react-dom@^18.0.10": + version "18.0.10" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.10.tgz#3b66dec56aa0f16a6cc26da9e9ca96c35c0b4352" + integrity sha512-E42GW/JA4Qv15wQdqJq8DL4JhNpB3prJgjgapN3qJT9K2zO5IIAQh4VXvCEDupoqAwnz0cY4RlXeC/ajX5SFHg== + dependencies: + "@types/react" "*" + +"@types/react-virtualized-auto-sizer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz#b3187dae1dfc4c15880c9cfc5b45f2719ea6ebd4" + integrity sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@^18.0.26": + version "18.0.26" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.26.tgz#8ad59fc01fef8eaf5c74f4ea392621749f0b7917" + integrity sha512-hCR3PJQsAIXyxhTNSiDFY//LhnMZWpNNr5etoCqx/iUfGc5gXWtQR2Phl908jVR6uPXacojQWTg4qRpkxTuGug== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + abortcontroller-polyfill@^1.1.9: version "1.7.5" resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz#6738495f4e901fbb57b6c0611d0c75f76c485bed" @@ -910,6 +943,11 @@ csso@^4.2.0: dependencies: css-tree "^1.1.2" +csstype@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" + integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== + detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"