paneweave 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kirill Osipov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,359 @@
1
+ # paneweave
2
+
3
+ Reusable React split-pane layout package for applications that need persistent, nested panel layouts without baking app-specific content into the layout engine.
4
+
5
+ ## What It Provides
6
+
7
+ - Recursive split-tree layout model
8
+ - Registry-driven panels
9
+ - Resize handles with nested split support
10
+ - Corner-handle split creation and collapse behavior
11
+ - Optional persisted layout state via Zustand
12
+ - Package-owned CSS entrypoint at `paneweave/styles.css`
13
+
14
+ ## Why The Name
15
+
16
+ `paneweave` reflects how the library composes UI panes into a woven, recursive split tree.
17
+
18
+ ## Installation
19
+
20
+ Install the package and import its stylesheet in the consuming app.
21
+
22
+ ```ts
23
+ import 'paneweave/styles.css'
24
+ import { PaneweaveLayout, type LayoutNode, type PanelDefinition } from 'paneweave'
25
+ import { createLayoutStore } from 'paneweave/zustand'
26
+ ```
27
+
28
+ ## Consumer Example
29
+
30
+ ```tsx
31
+ import 'paneweave/styles.css'
32
+ import {
33
+ PaneweaveLayout,
34
+ type LayoutNode,
35
+ type PanelDefinition,
36
+ } from 'paneweave'
37
+ import { createLayoutStore } from 'paneweave/zustand'
38
+
39
+ type PanelId = 'canvas' | 'inspector' | 'assets'
40
+
41
+ type AppContext = {
42
+ projectName: string
43
+ }
44
+
45
+ const initialLayout: LayoutNode<PanelId> = {
46
+ type: 'split',
47
+ id: 'root',
48
+ direction: 'vertical',
49
+ ratio: 0.7,
50
+ first: { type: 'leaf', id: 'canvas-pane', panelId: 'canvas' },
51
+ second: {
52
+ type: 'split',
53
+ id: 'sidebar',
54
+ direction: 'horizontal',
55
+ ratio: 0.5,
56
+ first: { type: 'leaf', id: 'inspector-pane', panelId: 'inspector' },
57
+ second: { type: 'leaf', id: 'assets-pane', panelId: 'assets' },
58
+ },
59
+ }
60
+
61
+ const layoutStore = createLayoutStore({
62
+ initialLayout,
63
+ persistence: {
64
+ storageKey: 'my-app-layout',
65
+ },
66
+ })
67
+
68
+ const panelDefinitions: readonly PanelDefinition<PanelId, AppContext>[] = [
69
+ {
70
+ id: 'canvas',
71
+ label: 'Canvas',
72
+ render: ({ context }) => <div>Canvas for {context.projectName}</div>,
73
+ },
74
+ {
75
+ id: 'inspector',
76
+ label: 'Inspector',
77
+ render: () => <div>Inspector</div>,
78
+ },
79
+ {
80
+ id: 'assets',
81
+ label: 'Assets',
82
+ render: () => <div>Assets</div>,
83
+ },
84
+ ]
85
+
86
+ export function ExampleApp() {
87
+ return (
88
+ <PaneweaveLayout
89
+ store={layoutStore}
90
+ panelDefinitions={panelDefinitions}
91
+ panelContext={{ projectName: 'Demo' }}
92
+ />
93
+ )
94
+ }
95
+ ```
96
+
97
+ ## Redux Example (Without Zustand)
98
+
99
+ You can use paneweave with Redux by creating a small adapter that matches the `LayoutStore` contract.
100
+
101
+ ```tsx
102
+ import 'paneweave/styles.css'
103
+ import {
104
+ PaneweaveLayout,
105
+ type LayoutNode,
106
+ type LayoutStore,
107
+ type LayoutStoreState,
108
+ type PanelDefinition,
109
+ type SplitDirection,
110
+ type SplitResult,
111
+ } from 'paneweave'
112
+ import { combineReducers, createStore } from 'redux'
113
+ import { Provider, useSelector } from 'react-redux'
114
+
115
+ type PanelId = 'canvas' | 'inspector' | 'assets'
116
+ type AppContext = { projectName: string }
117
+
118
+ const initialLayout: LayoutNode<PanelId> = {
119
+ type: 'split',
120
+ id: 'root',
121
+ direction: 'vertical',
122
+ ratio: 0.7,
123
+ first: { type: 'leaf', id: 'canvas-pane', panelId: 'canvas' },
124
+ second: { type: 'leaf', id: 'inspector-pane', panelId: 'inspector' },
125
+ }
126
+
127
+ type LayoutAction =
128
+ | { type: 'layout/set'; layout: LayoutNode<PanelId> }
129
+ | { type: 'layout/resize'; splitId: string; ratio: number }
130
+
131
+ function updateNode(
132
+ node: LayoutNode<PanelId>,
133
+ id: string,
134
+ updater: (node: LayoutNode<PanelId>) => LayoutNode<PanelId>,
135
+ ): LayoutNode<PanelId> {
136
+ if (node.id === id) return updater(node)
137
+ if (node.type === 'split') {
138
+ return {
139
+ ...node,
140
+ first: updateNode(node.first, id, updater),
141
+ second: updateNode(node.second, id, updater),
142
+ }
143
+ }
144
+ return node
145
+ }
146
+
147
+ function layoutReducer(
148
+ state: LayoutNode<PanelId> = initialLayout,
149
+ action: LayoutAction,
150
+ ): LayoutNode<PanelId> {
151
+ switch (action.type) {
152
+ case 'layout/set':
153
+ return action.layout
154
+ case 'layout/resize':
155
+ return updateNode(state, action.splitId, (node) =>
156
+ node.type === 'split'
157
+ ? { ...node, ratio: Math.max(0, Math.min(1, action.ratio)) }
158
+ : node,
159
+ )
160
+ default:
161
+ return state
162
+ }
163
+ }
164
+
165
+ const rootReducer = combineReducers({
166
+ layout: layoutReducer,
167
+ })
168
+
169
+ const reduxStore = createStore(rootReducer)
170
+
171
+ const layoutActions = {
172
+ setAreaPanel(areaId: string, panelId: PanelId) {
173
+ const current = reduxStore.getState().layout
174
+ const next = updateNode(current, areaId, (node) =>
175
+ node.type === 'leaf' ? { ...node, panelId } : node,
176
+ )
177
+ reduxStore.dispatch({ type: 'layout/set', layout: next })
178
+ },
179
+ splitArea(_areaId: string, _direction: SplitDirection): SplitResult | null {
180
+ // Implement split logic in your app reducer. Returning null keeps this minimal example focused.
181
+ return null
182
+ },
183
+ joinArea(_keepId: string) {},
184
+ removeArea(_targetId: string) {},
185
+ resizeSplit(splitId: string, ratio: number) {
186
+ reduxStore.dispatch({ type: 'layout/resize', splitId, ratio })
187
+ },
188
+ resetLayout() {
189
+ reduxStore.dispatch({ type: 'layout/set', layout: initialLayout })
190
+ },
191
+ moveArea(_sourceId: string, _targetId: string, _direction: SplitDirection, _insertBefore: boolean) {},
192
+ }
193
+
194
+ const useReduxLayoutStore = Object.assign(
195
+ <TSelected,>(selector: (state: LayoutStoreState<PanelId>) => TSelected): TSelected => {
196
+ const layout = useSelector((state: ReturnType<typeof rootReducer>) => state.layout)
197
+ return selector({
198
+ layout,
199
+ ...layoutActions,
200
+ })
201
+ },
202
+ {
203
+ getState: (): LayoutStoreState<PanelId> => {
204
+ const layout = reduxStore.getState().layout
205
+ return {
206
+ layout,
207
+ ...layoutActions,
208
+ }
209
+ },
210
+ setState: (partial: Partial<LayoutStoreState<PanelId>>) => {
211
+ if (partial.layout) {
212
+ reduxStore.dispatch({ type: 'layout/set', layout: partial.layout })
213
+ }
214
+ },
215
+ },
216
+ ) as LayoutStore<PanelId>
217
+
218
+ const panelDefinitions: readonly PanelDefinition<PanelId, AppContext>[] = [
219
+ {
220
+ id: 'canvas',
221
+ label: 'Canvas',
222
+ render: ({ context }) => <div>Canvas for {context.projectName}</div>,
223
+ },
224
+ {
225
+ id: 'inspector',
226
+ label: 'Inspector',
227
+ render: () => <div>Inspector</div>,
228
+ },
229
+ {
230
+ id: 'assets',
231
+ label: 'Assets',
232
+ render: () => <div>Assets</div>,
233
+ },
234
+ ]
235
+
236
+ export function AppWithRedux() {
237
+ return (
238
+ <Provider store={reduxStore}>
239
+ <PaneweaveLayout
240
+ store={useReduxLayoutStore}
241
+ panelDefinitions={panelDefinitions}
242
+ panelContext={{ projectName: 'Redux Demo' }}
243
+ />
244
+ </Provider>
245
+ )
246
+ }
247
+ ```
248
+
249
+ Install Redux dependencies in your app:
250
+
251
+ ```bash
252
+ npm install redux react-redux
253
+ ```
254
+
255
+ ## Near-Zero Collision Mode
256
+
257
+ If you host multiple UI libraries on one page and want near-zero class collision risk, use a unique class prefix per app/shell.
258
+
259
+ `PaneweaveLayout` supports:
260
+
261
+ - `classNamePrefix`: custom DOM class prefix for all internal nodes.
262
+ - `includeDefaultClassNames`: include `paneweave-layout-*` classes in addition to your custom prefix.
263
+
264
+ Example:
265
+
266
+ ```tsx
267
+ <PaneweaveLayout
268
+ store={layoutStore}
269
+ panelDefinitions={panelDefinitions}
270
+ panelContext={ctx}
271
+ classNamePrefix="acme-paneweave-7f3a"
272
+ includeDefaultClassNames={false}
273
+ />
274
+ ```
275
+
276
+ AI implementation guidance:
277
+
278
+ 1. Generate a unique, stable prefix per product shell (example: `acme-paneweave-7f3a`).
279
+ 2. Pass that prefix through `classNamePrefix` on every `PaneweaveLayout` instance.
280
+ 3. Set `includeDefaultClassNames={false}` in strict-isolation deployments.
281
+ 4. Keep theme overrides on instance root class (or wrapper) using `--pw-*` tokens.
282
+ 5. If legacy overrides rely on `paneweave-layout-*`, temporarily set `includeDefaultClassNames={true}` during migration.
283
+
284
+ Optional helper:
285
+
286
+ ```ts
287
+ import { createPaneweaveDomClassNames } from 'paneweave'
288
+
289
+ const classes = createPaneweaveDomClassNames({
290
+ classNamePrefix: 'acme-paneweave-7f3a',
291
+ includeDefaultClassNames: false,
292
+ })
293
+ ```
294
+
295
+ ## Examples
296
+
297
+ Standalone example projects live in `examples/`.
298
+
299
+ - `examples/zustand-react`: Vite + React + TypeScript sample that consumes the library source and demonstrates persisted nested layouts.
300
+ - `examples/redux-react`: Vite + React + TypeScript sample that uses a Redux-backed store adapter instead of `paneweave/zustand`.
301
+ - `examples/vanilla-react`: Vite + React + TypeScript sample that uses a custom store adapter with no external state library.
302
+
303
+ Run it with:
304
+
305
+ ```bash
306
+ cd examples/zustand-react
307
+ npm install
308
+ npm run dev
309
+ ```
310
+
311
+ ## Contributor Notes
312
+
313
+ - Agent/contributor quickstart and task routing: `CLAUDE.md`
314
+ - Repo-level guardrails for coding agents: `CLAUDE.md`
315
+
316
+ ## API Summary
317
+
318
+ - `PaneweaveLayout`: top-level React component that renders the nested split tree.
319
+ - `createLayoutStore`: creates the Zustand store used by the layout component.
320
+ - `LayoutNode`, `LayoutLeaf`, `LayoutSplit`: generic tree types for serialized layout state.
321
+ - `PanelDefinition`: registry record containing the panel id, label, optional className, and renderer.
322
+ - `findFirstLeafByPanelId`, `findRightResizeSplit`, `findTopResizeSplit`, `findSplitNode`: helper utilities for advanced consumers.
323
+
324
+ ## Persistence
325
+
326
+ Persistence is optional. When provided, the package stores only the `layout` tree and leaves the storage key up to the consumer.
327
+
328
+ ```ts
329
+ const store = createLayoutStore({
330
+ initialLayout,
331
+ persistence: {
332
+ storageKey: 'dashboard-layout',
333
+ storage: window.localStorage,
334
+ },
335
+ })
336
+ ```
337
+
338
+ ## Styling
339
+
340
+ Import `paneweave/styles.css` and provide theme variables such as `--accent`, `--panel-bg`, and `--panel-border` in the consuming app.
341
+
342
+ The package is style-agnostic by design:
343
+
344
+ - No global `:root` theme injection.
345
+ - Tokens are scoped per instance on `.paneweave-layout-root`.
346
+ - Internal tokens use `--pw-*` names and still accept legacy aliases (`--accent`, `--panel-bg`, etc.).
347
+
348
+ Example per-layout override:
349
+
350
+ ```css
351
+ .my-layout {
352
+ --pw-root-background: transparent;
353
+ --pw-panel-bg: #111827;
354
+ --pw-panel-bg-alt: #0b1220;
355
+ --pw-panel-border: #334155;
356
+ --pw-panel-text: #e5e7eb;
357
+ --pw-accent: #22d3ee;
358
+ }
359
+ ```
@@ -0,0 +1,17 @@
1
+ import type { PaneweaveDomClassNames } from '../domClassNames.js';
2
+ import type { LayoutStore } from '../store.js';
3
+ import type { LayoutNode, PanelDefinition } from '../types.js';
4
+ /** Props consumed by `AreaNodeRenderer`. */
5
+ interface AreaNodeRendererProps<TPanelId extends string, TContext> {
6
+ node: LayoutNode<TPanelId>;
7
+ store: LayoutStore<TPanelId>;
8
+ panelDefinitions: readonly PanelDefinition<TPanelId, TContext>[];
9
+ panelMap: Map<TPanelId, PanelDefinition<TPanelId, TContext>>;
10
+ panelContext: TContext;
11
+ domClassNames: PaneweaveDomClassNames;
12
+ }
13
+ /**
14
+ * Recursively renders a layout node as either an `<AreaView>` (leaf) or a `<SplitView>` (split).
15
+ */
16
+ export declare function AreaNodeRenderer<TPanelId extends string, TContext>({ node, store, panelDefinitions, panelMap, panelContext, domClassNames, }: AreaNodeRendererProps<TPanelId, TContext>): import("react/jsx-runtime").JSX.Element;
17
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { PaneweaveDomClassNames } from '../domClassNames.js';
2
+ import type { LayoutStore } from '../store.js';
3
+ import type { LayoutLeaf, PanelDefinition } from '../types.js';
4
+ /**
5
+ * Renders a single leaf panel area: header bar with panel selector, corner drag handle, and panel content.
6
+ */
7
+ export declare function AreaView<TPanelId extends string, TContext>({ area, store, panelDefinitions, panelMap, panelContext, domClassNames, }: {
8
+ area: LayoutLeaf<TPanelId>;
9
+ store: LayoutStore<TPanelId>;
10
+ panelDefinitions: readonly PanelDefinition<TPanelId, TContext>[];
11
+ panelMap: Map<TPanelId, PanelDefinition<TPanelId, TContext>>;
12
+ panelContext: TContext;
13
+ domClassNames: PaneweaveDomClassNames;
14
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import type { PaneweaveDomClassNames } from '../domClassNames.js';
2
+ import { type LayoutStore } from '../store.js';
3
+ /**
4
+ * A small drag handle in the header of each panel area.
5
+ *
6
+ * Dragging it creates a new split, or resizes an adjacent split when dragged
7
+ * toward an existing handle boundary.
8
+ */
9
+ export declare function CornerHandle<TPanelId extends string>({ areaId, store, domClassNames, }: {
10
+ areaId: string;
11
+ store: LayoutStore<TPanelId>;
12
+ domClassNames: PaneweaveDomClassNames;
13
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import type { DropZone } from '../dragState.js';
2
+ import type { PaneweaveDomClassNames } from '../domClassNames.js';
3
+ /**
4
+ * Absolute-positioned overlay rendered inside a panel area during a drag-over.
5
+ * Highlights the half of the area where the panel will be dropped.
6
+ */
7
+ export declare function DropOverlay({ zone, domClassNames, }: {
8
+ zone: DropZone;
9
+ domClassNames: PaneweaveDomClassNames;
10
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { type PaneweaveDomClassOptions } from '../domClassNames.js';
2
+ import type { LayoutStore } from '../store.js';
3
+ import type { PanelDefinition } from '../types.js';
4
+ /** Props for the `<PaneweaveLayout>` component. */
5
+ export interface PaneweaveLayoutProps<TPanelId extends string, TContext> {
6
+ store: LayoutStore<TPanelId>;
7
+ panelDefinitions: readonly PanelDefinition<TPanelId, TContext>[];
8
+ panelContext: TContext;
9
+ className?: string;
10
+ classNamePrefix?: PaneweaveDomClassOptions['classNamePrefix'];
11
+ includeDefaultClassNames?: PaneweaveDomClassOptions['includeDefaultClassNames'];
12
+ }
13
+ /**
14
+ * Top-level component that renders a recursive split-pane layout.
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * <PaneweaveLayout store={layoutStore} panelDefinitions={defs} panelContext={ctx} />
19
+ * ```
20
+ */
21
+ export declare function PaneweaveLayout<TPanelId extends string, TContext>({ store, panelDefinitions, panelContext, className, classNamePrefix, includeDefaultClassNames, }: PaneweaveLayoutProps<TPanelId, TContext>): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { PaneweaveDomClassNames } from '../domClassNames.js';
3
+ import type { LayoutStore } from '../store.js';
4
+ import type { LayoutNode, LayoutSplit } from '../types.js';
5
+ /**
6
+ * Renders a split container with a draggable resize handle between two child nodes.
7
+ *
8
+ * Child nodes are rendered via the `renderNode` callback to avoid circular imports.
9
+ */
10
+ export declare function SplitView<TPanelId extends string>({ split, store, renderNode, domClassNames, }: {
11
+ split: LayoutSplit<TPanelId>;
12
+ store: LayoutStore<TPanelId>;
13
+ renderNode: (node: LayoutNode<TPanelId>) => ReactNode;
14
+ domClassNames: PaneweaveDomClassNames;
15
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,29 @@
1
+ export interface PaneweaveDomClassNames {
2
+ rootClassName: string;
3
+ splitClassName: string;
4
+ splitVerticalClassName: string;
5
+ splitHorizontalClassName: string;
6
+ resizeHandleClassName: string;
7
+ resizeHandleVerticalClassName: string;
8
+ resizeHandleHorizontalClassName: string;
9
+ areaClassName: string;
10
+ headerClassName: string;
11
+ selectorClassName: string;
12
+ selectorTriggerClassName: string;
13
+ selectorArrowClassName: string;
14
+ selectorMenuClassName: string;
15
+ selectorOptionClassName: string;
16
+ headerSpacerClassName: string;
17
+ contentClassName: string;
18
+ cornerHandleClassName: string;
19
+ dropOverlayClassName: string;
20
+ rootSelector: string;
21
+ splitSelector: string;
22
+ selectorSelector: string;
23
+ cornerHandleSelector: string;
24
+ }
25
+ export interface PaneweaveDomClassOptions {
26
+ classNamePrefix?: string;
27
+ includeDefaultClassNames?: boolean;
28
+ }
29
+ export declare function createPaneweaveDomClassNames(options?: PaneweaveDomClassOptions): PaneweaveDomClassNames;
@@ -0,0 +1,11 @@
1
+ /** Direction quadrant where a dragged panel is hovering over a target. */
2
+ export type DropZone = 'left' | 'right' | 'top' | 'bottom';
3
+ /** Set which area is currently being dragged. Pass `null` on drag end. */
4
+ export declare function setDraggedAreaId(id: string | null): void;
5
+ /** Get id of area currently being dragged, or `null` if none. */
6
+ export declare function getDraggedAreaId(): string | null;
7
+ /**
8
+ * Determine drop zone quadrant based on pointer position within a rect.
9
+ * Divides the rect into 4 triangles from the center point.
10
+ */
11
+ export declare function getDropZone(rect: DOMRect, clientX: number, clientY: number): DropZone;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Finds the closest perpendicular resize handle to the current pointer position.
3
+ *
4
+ * Used when dragging a resize handle to simultaneously resize an intersecting split.
5
+ *
6
+ * @param paneweaveRoot - The root layout element to search within.
7
+ * @param isVertical - Whether the dragged handle is vertical (col-resize).
8
+ * @param clientX - Pointer X coordinate.
9
+ * @param clientY - Pointer Y coordinate.
10
+ * @param originContainer - Split container of the active dragged handle.
11
+ * @returns The nearest perpendicular handle's split id and container element, or null.
12
+ */
13
+ export declare function findPerpendicularHandle(paneweaveRoot: Element, isVertical: boolean, clientX: number, clientY: number, originContainer?: HTMLElement | null, splitSelector?: string): {
14
+ splitId: string;
15
+ container: HTMLElement;
16
+ } | null;
@@ -0,0 +1,6 @@
1
+ export { PaneweaveLayout } from './components/PaneweaveLayout.js';
2
+ export { createPaneweaveDomClassNames } from './domClassNames.js';
3
+ export type { PaneweaveDomClassNames, PaneweaveDomClassOptions, } from './domClassNames.js';
4
+ export type { CreateLayoutStoreOptions, LayoutStore, LayoutStoreState, } from './store.js';
5
+ export { createLayoutStore, createStateCreator, findFirstLeafByPanelId, findRightResizeSplit, findSiblingId, findSplitNode, findTopResizeSplit, } from './store.js';
6
+ export type { LayoutLeaf, LayoutNode, LayoutPersistenceOptions, LayoutSplit, PanelDefinition, PanelRenderArgs, SplitDirection, SplitResult, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import{useMemo as Pe}from"react";var le="paneweave-layout";function $(e,t){return`${e}-${t}`}function v(e,t,n){let r=$(e,t),i=$(le,t);return!n||r===i?r:`${i} ${r}`}function G(e={}){let{classNamePrefix:t=le,includeDefaultClassNames:n=!1}=e;return{rootClassName:v(t,"root",n),splitClassName:v(t,"split",n),splitVerticalClassName:v(t,"split-v",n),splitHorizontalClassName:v(t,"split-h",n),resizeHandleClassName:v(t,"resize-handle",n),resizeHandleVerticalClassName:v(t,"resize-handle-v",n),resizeHandleHorizontalClassName:v(t,"resize-handle-h",n),areaClassName:v(t,"area",n),headerClassName:v(t,"header",n),selectorClassName:v(t,"selector",n),selectorTriggerClassName:v(t,"selector-trigger",n),selectorArrowClassName:v(t,"selector-arrow",n),selectorMenuClassName:v(t,"selector-menu",n),selectorOptionClassName:v(t,"selector-option",n),headerSpacerClassName:v(t,"header-spacer",n),contentClassName:v(t,"content",n),cornerHandleClassName:v(t,"corner-handle",n),dropOverlayClassName:v(t,"drop-overlay",n),rootSelector:`.${$(t,"root")}`,splitSelector:`.${$(t,"split")}`,selectorSelector:`.${$(t,"selector")}`,cornerHandleSelector:`.${$(t,"corner-handle")}`}}import{useEffect as Me,useRef as we,useState as fe}from"react";var se=null;function Y(e){se=e}function Q(){return se}function ce(e,t,n){let r=(t-e.left)/e.width,i=(n-e.top)/e.height,a=Math.abs(r-.5),g=Math.abs(i-.5);return a>=g?r<.5?"left":"right":i<.5?"top":"bottom"}import{useCallback as he}from"react";import{useSyncExternalStore as ve}from"react";function k(){return crypto.randomUUID()}function R(e,t,n){return e.id===t?n(e):e.type==="split"?{...e,first:R(e.first,t,n),second:R(e.second,t,n)}:e}function U(e,t){return e.type!=="split"?null:e.first.id===t||e.second.id===t?e:U(e.first,t)??U(e.second,t)}function b(e,t){return e.id===t?!0:e.type!=="split"?!1:b(e.first,t)||b(e.second,t)}function Z(e){return e.type==="leaf"?e.id:Z(e.first)}function ee(e,t){return e.id===t?e.type==="leaf"?e:null:e.type==="split"?ee(e.first,t)??ee(e.second,t):null}function F(e,t){return e.type!=="split"?e:e.first.id===t?e.second:e.second.id===t?e.first:{...e,first:F(e.first,t),second:F(e.second,t)}}function de(e){return t=>({layout:e,setAreaPanel:(n,r)=>t(i=>({layout:R(i.layout,n,a=>a.type==="leaf"?{...a,panelId:r}:a)})),splitArea:(n,r,i=!1,a=.5)=>{let g=null;return t(c=>({layout:R(c.layout,n,P=>{if(P.type!=="leaf")return P;let u=k(),p=k(),d={type:"leaf",id:u,panelId:P.panelId};return g={splitId:p,newAreaId:u},{type:"split",id:p,direction:r,ratio:Math.max(0,Math.min(1,a)),first:i?d:P,second:i?P:d}})})),g},joinArea:n=>t(r=>{let i=U(r.layout,n);if(!i)return r;let a=i.first.id===n?i.first:i.second;return{layout:R(r.layout,i.id,()=>a)}}),removeArea:n=>t(r=>r.layout.type!=="split"?r:{layout:F(r.layout,n)}),resizeSplit:(n,r)=>t(i=>({layout:R(i.layout,n,a=>a.type==="split"?{...a,ratio:Math.max(0,Math.min(1,r))}:a)})),resetLayout:()=>t({layout:e}),moveArea:(n,r,i,a)=>t(g=>{if(n===r||g.layout.type==="leaf")return g;let c=ee(g.layout,n);if(!c)return g;let P=null,u=R(g.layout,r,p=>{if(p.type!=="leaf")return p;let d=k(),S=k();P=d;let m={type:"leaf",id:d,panelId:c.panelId};return{type:"split",id:S,direction:i,ratio:.5,first:a?m:p,second:a?p:m}});return P?(u.type==="split"&&(u=F(u,n)),{layout:u}):g})})}function Ne(e,t){try{let n=e.getItem(t);return n?JSON.parse(n):null}catch{return null}}function Ie({initialLayout:e,persistence:t}){let n=t?.storage??globalThis.localStorage,r=new Set,i=t?Ne(n,t.storageKey)??e:e,a=()=>{if(t)try{n.setItem(t.storageKey,JSON.stringify(i))}catch{}},g=()=>{r.forEach(s=>s())},u=de(e)(s=>{let l=typeof s=="function"?s(d()):s;l.layout!==void 0&&(i=l.layout,a(),p=null,g())}),p=null,d=()=>(p||(p={...u,layout:i}),p),S=s=>(r.add(s),()=>{r.delete(s)}),m=(s=>(ve(S,d,d),s(d())));return m.getState=()=>d(),m.setState=(s,l=!1)=>{let o=typeof s=="function"?s(d()):s;if(l){o.layout&&(i=o.layout,a(),g());return}o.layout&&(i=o.layout,a(),g())},m}function te(e,t){return e.type==="leaf"?e.panelId===t?e.id:null:te(e.first,t)??te(e.second,t)}function Ce(e,t,n){let r=U(e,t);return r?n==="right"&&r.direction==="vertical"&&r.first.id===t?Z(r.second):n==="left"&&r.direction==="vertical"&&r.second.id===t?Z(r.first):n==="below"&&r.direction==="horizontal"&&r.first.id===t?Z(r.second):n==="above"&&r.direction==="horizontal"&&r.second.id===t?Z(r.first):null:null}function O(e,t){return e.type!=="split"?null:e.direction==="vertical"&&b(e.first,t)?O(e.first,t)??e.id:b(e.first,t)?O(e.first,t):b(e.second,t)?O(e.second,t):null}function B(e,t){return e.type!=="split"?null:e.direction==="horizontal"&&b(e.second,t)?B(e.second,t)??e.id:b(e.first,t)?B(e.first,t):b(e.second,t)?B(e.second,t):null}function j(e,t){return e.type!=="split"?null:e.id===t?e:j(e.first,t)??j(e.second,t)}import{jsx as xe}from"react/jsx-runtime";function ue({areaId:e,store:t,domClassNames:n}){let r=t(c=>c.splitArea),i=t(c=>c.removeArea),a=t(c=>c.layout),g=he(c=>{c.preventDefault(),c.stopPropagation();let P=c.currentTarget.closest("[data-area-id]");if(!P)return;let u=P.getBoundingClientRect(),p=c.clientX,d=c.clientY,S=O(a,e),m=B(a,e),s=null,l=!1,o=!1,f=null,y=null,h=()=>{window.removeEventListener("pointermove",H),window.removeEventListener("pointerup",C),document.body.style.cursor=""},H=N=>{let L=N.clientX-p,T=N.clientY-d;if(!s&&!l&&!o){let I=L>18&&Math.abs(L)>=Math.abs(T),D=T<-18&&Math.abs(T)>Math.abs(L),A=L<-18&&Math.abs(L)>=Math.abs(T),K=T>18&&Math.abs(T)>Math.abs(L);if(!I&&!D&&!A&&!K)return;if(I&&S){let w=document.querySelector(`[data-split-id="${S}"]`)?.closest(n.splitSelector);w&&(f=w.getBoundingClientRect(),l=!0,document.body.style.cursor="col-resize")}if(D&&m){let w=document.querySelector(`[data-split-id="${m}"]`)?.closest(n.splitSelector);w&&(y=w.getBoundingClientRect(),o=!0,document.body.style.cursor="row-resize")}if(l||o)return;if(A){let E=r(e,"vertical",!1,1);E&&(s={...E,direction:"vertical"})}else{let E=r(e,"horizontal",!0,0);E&&(s={...E,direction:"horizontal"})}if(!s)return}if(l&&f&&S){f=document.querySelector(`[data-split-id="${S}"]`)?.closest(n.splitSelector)?.getBoundingClientRect()??f;let I=Math.max(f.width,1),D=(N.clientX-f.left)/I;t.getState().resizeSplit(S,D);return}if(o&&y&&m){y=document.querySelector(`[data-split-id="${m}"]`)?.closest(n.splitSelector)?.getBoundingClientRect()??y;let I=Math.max(y.height,1),D=(N.clientY-y.top)/I;t.getState().resizeSplit(m,D);return}if(!s)return;if(s.direction==="vertical"){let D=1-Math.max(0,Math.min(u.width,-L))/Math.max(u.width,1);t.getState().resizeSplit(s.splitId,D);return}let M=Math.max(0,Math.min(u.height,T))/Math.max(u.height,1);t.getState().resizeSplit(s.splitId,M)},C=N=>{if(h(),l&&S){let T=j(t.getState().layout,S),x=document.querySelector(`[data-split-id="${S}"]`)?.closest(n.splitSelector)?.getBoundingClientRect();if(T&&x){let M=T.ratio,I=M*x.width,D=(1-M)*x.width;I<10?i(T.first.id):D<10&&i(T.second.id)}return}if(o&&m){let T=j(t.getState().layout,m),x=document.querySelector(`[data-split-id="${m}"]`)?.closest(n.splitSelector)?.getBoundingClientRect();if(T&&x){let M=T.ratio,I=M*x.height,D=(1-M)*x.height;I<10?i(T.first.id):D<10&&i(T.second.id)}return}if(!s)return;if(s.direction==="vertical"){Math.max(0,Math.min(u.width,-(N.clientX-p)))<10&&i(s.newAreaId);return}Math.max(0,Math.min(u.height,N.clientY-d))<10&&i(s.newAreaId)};window.addEventListener("pointermove",H),window.addEventListener("pointerup",C)},[e,n.splitSelector,a,i,r,t]);return xe("div",{className:n.cornerHandleClassName,onPointerDown:g})}import{jsx as De}from"react/jsx-runtime";function pe({zone:e,domClassNames:t}){return De("div",{className:t.dropOverlayClassName,"data-zone":e})}import{jsx as z,jsxs as W}from"react/jsx-runtime";function He(e){switch(e){case"left":return{direction:"vertical",insertBefore:!0};case"right":return{direction:"vertical",insertBefore:!1};case"top":return{direction:"horizontal",insertBefore:!0};case"bottom":return{direction:"horizontal",insertBefore:!1}}}function J(e,t){return e.id===t?!0:e.type==="leaf"?!1:J(e.first,t)||J(e.second,t)}function me({area:e,store:t,panelDefinitions:n,panelMap:r,panelContext:i,domClassNames:a}){let g=t(o=>o.setAreaPanel),c=t(o=>o.moveArea),P=t(o=>o.layout),u=r.get(e.panelId),[p,d]=fe(!1),[S,m]=fe(null),s=we(null),l=u?.className?`${a.areaClassName} ${u.className}`:a.areaClassName;return Me(()=>{if(!p)return;let o=f=>{s.current&&!s.current.contains(f.target)&&d(!1)};return document.addEventListener("pointerdown",o),()=>document.removeEventListener("pointerdown",o)},[p]),W("section",{"data-area-id":e.id,"data-panel-id":e.panelId,className:l,onDragOver:o=>{let f=Q();if(!f||f===e.id){m(null);return}if(!J(P,f)){m(null);return}o.preventDefault();let y=o.currentTarget.getBoundingClientRect(),h=ce(y,o.clientX,o.clientY);(h==="top"||h==="bottom"?y.height:y.width)<20||m(h)},onDragLeave:o=>{o.currentTarget.contains(o.relatedTarget)||m(null)},onDrop:o=>{let f=Q();if(!f||!S)return;if(!J(P,f)){m(null);return}o.preventDefault();let y=o.currentTarget.getBoundingClientRect();if((S==="top"||S==="bottom"?y.height:y.width)<20){m(null);return}let{direction:C,insertBefore:N}=He(S);c(f,e.id,C,N),m(null)},children:[W("div",{className:a.headerClassName,draggable:!0,onDragStart:o=>{let f=o.target;if(f.closest(a.selectorSelector)||f.closest(a.cornerHandleSelector)){o.preventDefault(),Y(null);return}Y(e.id),o.dataTransfer.effectAllowed="move"},onDragEnd:()=>Y(null),children:[W("div",{className:a.selectorClassName,ref:s,children:[W("button",{type:"button",className:a.selectorTriggerClassName,onClick:()=>d(o=>!o),children:[u?.label??e.panelId,z("span",{className:a.selectorArrowClassName,children:"\u25BE"})]}),p&&z("div",{className:a.selectorMenuClassName,children:n.map(o=>z("button",{type:"button",className:`${a.selectorOptionClassName}${o.id===e.panelId?" is-active":""}`,onClick:()=>{g(e.id,o.id),d(!1)},children:o.label},o.id))})]}),z("div",{className:a.headerSpacerClassName}),z(ue,{areaId:e.id,store:t,domClassNames:a})]}),z("div",{className:a.contentClassName,children:u?u.render({area:e,context:i}):null}),S&&z(pe,{zone:S,domClassNames:a})]})}import{useCallback as X,useRef as ye}from"react";function oe(e,t,n,r,i,a=".paneweave-layout-split"){let g=t?'[data-split-dir="h"]':'[data-split-dir="v"]',c=e.querySelectorAll(g),P=null,u=null;for(let p of c){let d=p.getBoundingClientRect();if(!(t?r>=d.top-3&&r<=d.bottom+3:n>=d.left-3&&n<=d.right+3))continue;let m=Math.abs(t?r-(d.top+d.bottom)/2:n-(d.left+d.right)/2);if(!P||m<P.distance){let s=p.closest(a),l=p.dataset.splitId;s&&l&&(P={splitId:l,container:s,distance:m},i&&s!==i&&s.contains(i)&&(!u||m<u.distance)&&(u={splitId:l,container:s,distance:m}))}}return u??P}import{jsx as Ee,jsxs as be}from"react/jsx-runtime";function ge({split:e,store:t,renderNode:n,domClassNames:r}){let i=t(l=>l.resizeSplit),a=t(l=>l.removeArea),g=ye(null),c=e.direction==="vertical",P=ye(null),u=X(()=>{P.current?.classList.remove("is-hovered"),P.current=null},[]),p=X(l=>{let o=l.closest(r.splitSelector);if(!o)return!1;let f=o.getBoundingClientRect();return(l.dataset.splitDir==="v"?f.width:f.height)>=20},[]),d=X(l=>{let o=p(l);return l.classList.toggle("can-drag",o),o},[p]),S=X(l=>{let o=l.currentTarget,f=o.closest(r.rootSelector);if(!f)return;if(!d(o)){o.style.cursor="",u();return}let h=o.closest(r.splitSelector),H=oe(f,c,l.clientX,l.clientY,h,r.splitSelector),C=H?f.querySelector(`[data-split-id="${H.splitId}"]`):null,N=C?d(C):!1;o.style.cursor=N?"move":"";let L=N?C:null;P.current!==L&&(u(),L&&(L.classList.add("is-hovered"),P.current=L))},[r.rootSelector,r.splitSelector,c,u,d]),m=X(l=>{d(l.currentTarget)},[d]),s=X(l=>{l.preventDefault();let o=g.current;if(!o)return;let f=o.getBoundingClientRect(),y=l.currentTarget;if(!d(y))return;y.setPointerCapture(l.pointerId);let h=o.closest(r.rootSelector),H=y.closest(r.splitSelector),C=h?oe(h,c,l.clientX,l.clientY,H,r.splitSelector):null,N=C?h?.querySelector(`[data-split-id="${C.splitId}"]`)??null:null,L=C&&N&&d(N)?C:null;L&&(document.body.style.cursor="move");let T=M=>{let I=c?f.width:f.height,A=(c?M.clientX-f.left:M.clientY-f.top)/I,K=A*I,E=(1-A)*I;if(K<10){a(e.first.id),y.releasePointerCapture(l.pointerId),y.removeEventListener("pointermove",T),y.removeEventListener("pointerup",x),document.body.style.cursor="";return}if(E<10){a(e.second.id),y.releasePointerCapture(l.pointerId),y.removeEventListener("pointermove",T),y.removeEventListener("pointerup",x),document.body.style.cursor="";return}if(i(e.id,A),L&&L.container.isConnected){let w=L.container.getBoundingClientRect(),q=c?w.height:w.width;if(q>0){let Se=c?M.clientY-w.top:M.clientX-w.left,Le=Math.max(10/q,Math.min(1-10/q,Se/q));i(L.splitId,Le)}}},x=()=>{y.releasePointerCapture(l.pointerId),y.removeEventListener("pointermove",T),y.removeEventListener("pointerup",x),document.body.style.cursor=""};y.addEventListener("pointermove",T),y.addEventListener("pointerup",x)},[r.rootSelector,r.splitSelector,c,a,i,e.first.id,e.id,e.second.id,d]);return be("div",{ref:g,className:`${r.splitClassName} ${c?r.splitVerticalClassName:r.splitHorizontalClassName}`,style:c?{gridTemplateColumns:`minmax(0,${e.ratio}fr) 6px minmax(0,${1-e.ratio}fr)`}:{gridTemplateRows:`minmax(0,${e.ratio}fr) 6px minmax(0,${1-e.ratio}fr)`},children:[n(e.first),Ee("div",{className:`${r.resizeHandleClassName} ${c?r.resizeHandleVerticalClassName:r.resizeHandleHorizontalClassName}`,"data-split-id":e.id,"data-split-dir":c?"v":"h",onPointerDown:s,onPointerEnter:m,onPointerMove:S,onPointerLeave:l=>{l.currentTarget.style.cursor="",u()}}),n(e.second)]})}import{jsx as ae}from"react/jsx-runtime";function ie({node:e,store:t,panelDefinitions:n,panelMap:r,panelContext:i,domClassNames:a}){let g=c=>ae(ie,{node:c,store:t,panelDefinitions:n,panelMap:r,panelContext:i,domClassNames:a});return e.type==="leaf"?ae(me,{area:e,store:t,panelDefinitions:n,panelMap:r,panelContext:i,domClassNames:a}):ae(ge,{split:e,store:t,renderNode:g,domClassNames:a})}import{jsx as Te}from"react/jsx-runtime";function Re({store:e,panelDefinitions:t,panelContext:n,className:r,classNamePrefix:i,includeDefaultClassNames:a}){let g=e(p=>p.layout),c=Pe(()=>new Map(t.map(p=>[p.id,p])),[t]),P=Pe(()=>G({classNamePrefix:i,includeDefaultClassNames:a}),[i,a]),u=r?`${P.rootClassName} ${r}`:P.rootClassName;return Te("div",{className:u,children:Te(ie,{node:g,store:e,panelDefinitions:t,panelMap:c,panelContext:n,domClassNames:P})})}export{Re as PaneweaveLayout,Ie as createLayoutStore,G as createPaneweaveDomClassNames,de as createStateCreator,te as findFirstLeafByPanelId,O as findRightResizeSplit,Ce as findSiblingId,j as findSplitNode,B as findTopResizeSplit};
@@ -0,0 +1,8 @@
1
+ /** Minimum panel size in pixels before it collapses during a resize drag. */
2
+ export declare const MIN_AREA_PX = 10;
3
+ /** Snap tolerance in pixels for detecting perpendicular resize-handle intersections. */
4
+ export declare const INTERSECTION_SNAP = 3;
5
+ /** Minimum pointer travel in pixels before a corner-handle drag commits to a direction. */
6
+ export declare const MIN_DRAG = 18;
7
+ /** Minimum size in pixels for a newly created panel; smaller panels are discarded on pointer-up. */
8
+ export declare const MIN_CREATED_AREA_PX = 10;
@@ -0,0 +1,80 @@
1
+ import type { LayoutNode, LayoutPersistenceOptions, LayoutSplit, SplitDirection, SplitResult } from './types.js';
2
+ /** Full Zustand state shape for a layout store instance. */
3
+ export interface LayoutStoreState<TPanelId extends string = string> {
4
+ /** Current layout tree root. */
5
+ layout: LayoutNode<TPanelId>;
6
+ /** Replaces a leaf panel type by area id. */
7
+ setAreaPanel: (areaId: string, panelId: TPanelId) => void;
8
+ /** Splits a target leaf and returns ids for created nodes. */
9
+ splitArea: (areaId: string, direction: SplitDirection, insertBefore?: boolean, initialRatio?: number) => SplitResult | null;
10
+ /** Removes an area's sibling and keeps this area in place. */
11
+ joinArea: (keepId: string) => void;
12
+ /** Removes an area and collapses its parent split. */
13
+ removeArea: (targetId: string) => void;
14
+ /** Updates split ratio, clamped to the [0,1] range. */
15
+ resizeSplit: (splitId: string, ratio: number) => void;
16
+ /** Restores `layout` to the initial tree. */
17
+ resetLayout: () => void;
18
+ /**
19
+ * Moves a panel area next to another area by splitting the target.
20
+ * The source area is removed after the split. No-op if source equals target
21
+ * or source is the only remaining leaf.
22
+ */
23
+ moveArea: (sourceId: string, targetId: string, direction: SplitDirection, insertBefore: boolean) => void;
24
+ }
25
+ /**
26
+ * Generic store contract consumed by layout components.
27
+ *
28
+ * Compatible with Zustand-style hook stores, but does not require Zustand
29
+ * types in consumers that only import the root package API.
30
+ */
31
+ export interface LayoutStore<TPanelId extends string = string> {
32
+ <TSelected>(selector: (state: LayoutStoreState<TPanelId>) => TSelected): TSelected;
33
+ getState: () => LayoutStoreState<TPanelId>;
34
+ setState: (partial: Partial<LayoutStoreState<TPanelId>> | ((state: LayoutStoreState<TPanelId>) => Partial<LayoutStoreState<TPanelId>> | LayoutStoreState<TPanelId>), replace?: boolean) => void;
35
+ }
36
+ /**
37
+ * Creates the Zustand state creator with all layout operations.
38
+ *
39
+ * @param initialLayout - Baseline layout used for initialization and reset.
40
+ */
41
+ export declare function createStateCreator<TPanelId extends string>(initialLayout: LayoutNode<TPanelId>): (set: (partial: Partial<LayoutStoreState<TPanelId>> | ((state: LayoutStoreState<TPanelId>) => Partial<LayoutStoreState<TPanelId>> | LayoutStoreState<TPanelId>)) => void) => LayoutStoreState<TPanelId>;
42
+ /** Options accepted by `createLayoutStore`. */
43
+ export interface CreateLayoutStoreOptions<TPanelId extends string = string> {
44
+ initialLayout: LayoutNode<TPanelId>;
45
+ persistence?: LayoutPersistenceOptions;
46
+ }
47
+ /**
48
+ * Creates a vanilla store (no external state library) that manages the layout tree.
49
+ *
50
+ * @param options.initialLayout - The default layout tree used on first render and after `resetLayout`.
51
+ * @param options.persistence - Optional storage key and `Storage` implementation for persisting the layout.
52
+ * @returns A store compatible with `<PaneweaveLayout>`.
53
+ */
54
+ export declare function createLayoutStore<TPanelId extends string>({ initialLayout, persistence, }: CreateLayoutStoreOptions<TPanelId>): LayoutStore<TPanelId>;
55
+ /**
56
+ * Returns the id of the first leaf in traversal order whose `panelId` matches.
57
+ *
58
+ * @returns The leaf's `id`, or `null` if not found.
59
+ */
60
+ export declare function findFirstLeafByPanelId<TPanelId extends string>(node: LayoutNode<TPanelId>, panelId: TPanelId): string | null;
61
+ /**
62
+ * Returns the id of the first leaf on the given side of `areaId`, or `null` if none exists.
63
+ *
64
+ * Useful for moving focus between adjacent panels.
65
+ */
66
+ export declare function findSiblingId<TPanelId extends string>(root: LayoutNode<TPanelId>, areaId: string, side: 'right' | 'left' | 'above' | 'below'): string | null;
67
+ /**
68
+ * Returns the id of the vertical split that controls the right edge of `areaId`.
69
+ *
70
+ * Used by the corner handle to resize an adjacent split when dragging right.
71
+ */
72
+ export declare function findRightResizeSplit<TPanelId extends string>(root: LayoutNode<TPanelId>, areaId: string): string | null;
73
+ /**
74
+ * Returns the id of the horizontal split that controls the top edge of `areaId`.
75
+ *
76
+ * Used by the corner handle to resize an adjacent split when dragging upward.
77
+ */
78
+ export declare function findTopResizeSplit<TPanelId extends string>(root: LayoutNode<TPanelId>, areaId: string): string | null;
79
+ /** Looks up a split node by id in the layout tree. */
80
+ export declare function findSplitNode<TPanelId extends string>(root: LayoutNode<TPanelId>, splitId: string): LayoutSplit<TPanelId> | null;
@@ -0,0 +1 @@
1
+ .paneweave-layout-root{--pw-accent: var(--accent, #4d9eff);--pw-panel-bg: var(--panel-bg, #1e1e1e);--pw-panel-bg-alt: var(--panel-bg-alt, #252525);--pw-panel-border: var(--panel-border, #383838);--pw-panel-text: var(--panel-text, #d4d4d4);--pw-handle-bg: var(--handle-bg, #2e2e2e);--pw-handle-hover: var(--handle-hover, var(--pw-accent));--pw-handle-grip: var(--handle-grip, #555);--pw-shadow: var(--shadow, 0 8px 24px rgba(0, 0, 0, .5));--pw-handle-size: 5px;--pw-handle-grip-length: 32px;--pw-header-height: 30px;--pw-header-pad-x: 6px;--pw-corner-size: 14px;--pw-corner-radius: 3px;--pw-drop-border-width: 2px;--pw-drop-radius: 4px;--pw-selector-font-size: 12px;--pw-selector-radius: 5px;--pw-selector-menu-radius: 8px;width:100%;height:100%;min-height:0;min-width:0;overflow:hidden;overscroll-behavior:none;background:var(--pw-root-background, transparent);color:var(--pw-root-color, inherit);font-family:var(--pw-root-font-family, inherit)}.paneweave-layout-split{display:grid;width:100%;height:100%;min-height:0;min-width:0;overflow:hidden}.paneweave-layout-resize-handle{position:relative;background:var(--pw-handle-bg);border:0;z-index:10;transition:background .1s ease;flex-shrink:0}.paneweave-layout-resize-handle:after{content:"";position:absolute;inset:0;margin:auto;border-radius:2px;background:var(--pw-handle-grip);transition:background .1s ease}.paneweave-layout-resize-handle-v{width:var(--pw-handle-size)}.paneweave-layout-resize-handle-v:after{width:1px;height:var(--pw-handle-grip-length)}.paneweave-layout-resize-handle-h{height:var(--pw-handle-size)}.paneweave-layout-resize-handle-v.can-drag{cursor:col-resize}.paneweave-layout-resize-handle-h.can-drag{cursor:row-resize}.paneweave-layout-resize-handle-h:after{width:var(--pw-handle-grip-length);height:1px}.paneweave-layout-resize-handle.can-drag:hover,.paneweave-layout-resize-handle.can-drag.is-hovered,.paneweave-layout-resize-handle.can-drag:active{background:color-mix(in srgb,var(--pw-handle-hover) 25%,var(--pw-handle-bg))}.paneweave-layout-resize-handle.can-drag:hover:after,.paneweave-layout-resize-handle.can-drag.is-hovered:after,.paneweave-layout-resize-handle.can-drag:active:after{background:var(--pw-handle-hover)}.paneweave-layout-area{min-height:0;min-width:0;display:flex;flex-direction:column;overflow:hidden;background:var(--pw-panel-bg);border:1px solid var(--pw-panel-border);position:relative}.paneweave-layout-header{display:flex;align-items:center;gap:.25rem;flex-shrink:0;padding:0 var(--pw-header-pad-x);height:var(--pw-header-height);background:var(--pw-panel-bg-alt);border-bottom:1px solid var(--pw-panel-border);cursor:grab}.paneweave-layout-header:active{cursor:grabbing}.paneweave-layout-header .paneweave-layout-selector,.paneweave-layout-header .paneweave-layout-selector *,.paneweave-layout-header .paneweave-layout-corner-handle{cursor:auto}.paneweave-layout-header-spacer{flex:1}.paneweave-layout-content{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;overflow:auto;overscroll-behavior:contain}.paneweave-layout-corner-handle{width:var(--pw-corner-size);height:var(--pw-corner-size);flex-shrink:0;cursor:crosshair;border-radius:var(--pw-corner-radius);background:linear-gradient(135deg,var(--pw-accent) 33%,color-mix(in srgb,var(--pw-accent) 20%,transparent) 33%,color-mix(in srgb,var(--pw-accent) 20%,transparent) 66%,var(--pw-accent) 66%);opacity:.4;transition:opacity .1s ease}.paneweave-layout-corner-handle:hover{opacity:1}.paneweave-layout-drop-overlay{position:absolute;pointer-events:none;z-index:var(--pw-drop-z-index, 100);background:color-mix(in srgb,var(--pw-accent) 18%,transparent);border:var(--pw-drop-border-width) solid var(--pw-accent);border-radius:var(--pw-drop-radius);inset:0}.paneweave-layout-drop-overlay[data-zone=left]{right:50%}.paneweave-layout-drop-overlay[data-zone=right]{left:50%}.paneweave-layout-drop-overlay[data-zone=top]{bottom:50%}.paneweave-layout-drop-overlay[data-zone=bottom]{top:50%}.paneweave-layout-selector{position:relative}.paneweave-layout-selector-trigger{display:inline-flex;align-items:center;gap:.3rem;padding:2px 6px;border:1px solid transparent;border-radius:var(--pw-selector-radius);background:transparent;color:var(--pw-panel-text);font-size:var(--pw-selector-font-size);font-weight:500;cursor:pointer;transition:background .1s ease,border-color .1s ease}.paneweave-layout-selector-trigger:hover{background:color-mix(in srgb,var(--pw-accent) 12%,var(--pw-panel-bg-alt));border-color:var(--pw-panel-border)}.paneweave-layout-selector-arrow{font-size:.75em;opacity:.6}.paneweave-layout-selector-menu{position:absolute;top:calc(100% + 4px);left:0;display:flex;flex-direction:column;min-width:160px;padding:4px;border:1px solid var(--pw-panel-border);border-radius:var(--pw-selector-menu-radius);background:var(--pw-panel-bg-alt);box-shadow:var(--pw-shadow);z-index:20}.paneweave-layout-selector-option{width:100%;padding:5px 8px;text-align:left;border:none;border-radius:var(--pw-selector-radius);background:transparent;color:var(--pw-panel-text);font-size:var(--pw-selector-font-size);cursor:pointer;transition:background 80ms ease}.paneweave-layout-selector-option:hover{background:color-mix(in srgb,var(--pw-accent) 14%,var(--pw-panel-bg-alt))}.paneweave-layout-selector-option.is-active{background:color-mix(in srgb,var(--pw-accent) 22%,var(--pw-panel-bg-alt));color:var(--pw-accent)}
@@ -0,0 +1,42 @@
1
+ import type { ReactNode } from 'react';
2
+ /** Direction of a split: `'horizontal'` stacks panels top/bottom, `'vertical'` places them left/right. */
3
+ export type SplitDirection = 'horizontal' | 'vertical';
4
+ /** A leaf node in the layout tree — occupies a single panel area. */
5
+ export interface LayoutLeaf<TPanelId extends string = string> {
6
+ type: 'leaf';
7
+ id: string;
8
+ panelId: TPanelId;
9
+ }
10
+ /** An internal node in the layout tree that splits its space between two children. */
11
+ export interface LayoutSplit<TPanelId extends string = string> {
12
+ type: 'split';
13
+ id: string;
14
+ direction: SplitDirection;
15
+ ratio: number;
16
+ first: LayoutNode<TPanelId>;
17
+ second: LayoutNode<TPanelId>;
18
+ }
19
+ /** A node in the layout tree — either a leaf panel area or a split container. */
20
+ export type LayoutNode<TPanelId extends string = string> = LayoutLeaf<TPanelId> | LayoutSplit<TPanelId>;
21
+ /** Ids returned after a successful `splitArea` operation. */
22
+ export interface SplitResult {
23
+ splitId: string;
24
+ newAreaId: string;
25
+ }
26
+ /** Arguments passed to a panel's `render` function. */
27
+ export interface PanelRenderArgs<TPanelId extends string, TContext> {
28
+ area: LayoutLeaf<TPanelId>;
29
+ context: TContext;
30
+ }
31
+ /** Registry entry that describes a panel type and how to render it. */
32
+ export interface PanelDefinition<TPanelId extends string = string, TContext = unknown> {
33
+ id: TPanelId;
34
+ label: string;
35
+ className?: string;
36
+ render: (args: PanelRenderArgs<TPanelId, TContext>) => ReactNode;
37
+ }
38
+ /** Options for persisting the layout tree to a `Storage` implementation. */
39
+ export interface LayoutPersistenceOptions {
40
+ storageKey: string;
41
+ storage?: Storage;
42
+ }
@@ -0,0 +1,17 @@
1
+ import type { LayoutNode, LayoutPersistenceOptions } from './types.js';
2
+ import { findFirstLeafByPanelId, findRightResizeSplit, findSiblingId, findSplitNode, findTopResizeSplit, type LayoutStore, type LayoutStoreState } from './store.js';
3
+ export { findFirstLeafByPanelId, findRightResizeSplit, findSiblingId, findSplitNode, findTopResizeSplit, };
4
+ export type { LayoutStore, LayoutStoreState };
5
+ /** Options accepted by `createLayoutStore`. */
6
+ export interface CreateLayoutStoreOptions<TPanelId extends string = string> {
7
+ initialLayout: LayoutNode<TPanelId>;
8
+ persistence?: LayoutPersistenceOptions;
9
+ }
10
+ /**
11
+ * Creates a Zustand store that manages the layout tree.
12
+ *
13
+ * @param options.initialLayout - The default layout tree used on first render and after `resetLayout`.
14
+ * @param options.persistence - Optional storage key and `Storage` implementation for persisting the layout.
15
+ * @returns A bound Zustand store to pass to `<PaneweaveLayout>`.
16
+ */
17
+ export declare function createLayoutStore<TPanelId extends string>({ initialLayout, persistence, }: CreateLayoutStoreOptions<TPanelId>): LayoutStore<TPanelId>;
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "paneweave",
3
+ "version": "0.0.0",
4
+ "description": "Reusable React split-pane layout engine with registry-driven panels, typed APIs, and configurable persistence.",
5
+ "keywords": [
6
+ "react",
7
+ "layout",
8
+ "split-pane",
9
+ "panels"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "Kirill Osipov",
13
+ "type": "module",
14
+ "sideEffects": [
15
+ "./dist/styles.css"
16
+ ],
17
+ "main": "./dist/index.js",
18
+ "module": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ },
25
+ "./styles.css": "./dist/styles.css"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "build": "npm run clean && npm run build:types && npm run build:js && npm run build:css",
34
+ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly",
35
+ "build:js": "esbuild src/index.ts --bundle --format=esm --platform=browser --target=es2020 --minify --tree-shaking=true --external:react --external:react-dom --outdir=dist",
36
+ "build:css": "esbuild src/styles.css --minify --outfile=dist/styles.css",
37
+ "clean": "rm -rf dist",
38
+ "test": "npm run build && node --test tests/*.test.mjs",
39
+ "deploy": "npm run deploy:npm",
40
+ "deploy:dry": "npm run deploy:npm:dry",
41
+ "deploy:npm": "npm run test && npm publish",
42
+ "deploy:npm:dry": "npm run test && npm publish --dry-run"
43
+ },
44
+ "x-docs": {
45
+ "readme": "./README.md",
46
+ "usage": "See the Consumer Example section in README.md"
47
+ },
48
+ "x-consumer-example": {
49
+ "imports": [
50
+ "import { PaneweaveLayout } from 'paneweave'",
51
+ "import { createLayoutStore } from 'paneweave/zustand'",
52
+ "import 'paneweave/styles.css'"
53
+ ],
54
+ "steps": [
55
+ "Create a layout store with createLayoutStore({ initialLayout, persistence })",
56
+ "Register your panels with ids, labels, and render functions",
57
+ "Render <PaneweaveLayout store={store} panelDefinitions={panelDefinitions} panelContext={context} />"
58
+ ]
59
+ },
60
+ "peerDependencies": {
61
+ "react": "^19.0.0",
62
+ "react-dom": "^19.0.0",
63
+ "zustand": "^5.0.0"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "zustand": {
67
+ "optional": true
68
+ }
69
+ },
70
+ "devDependencies": {
71
+ "@types/react": "^19.2.14",
72
+ "@types/react-dom": "^19.2.3",
73
+ "esbuild": "^0.25.9",
74
+ "jsdom": "^26.1.0",
75
+ "react": "^19.2.4",
76
+ "react-dom": "^19.2.4",
77
+ "typescript": "~5.9.3",
78
+ "zustand": "^5.0.12"
79
+ }
80
+ }