@weasel-js/labkit 1.0.3 → 1.0.4
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/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
- package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useExperimentState-D7EQnnwJ.d.ts} +4 -0
- package/dist/{chunk-N5KTQKQA.js → chunk-BAPZPDDA.js} +628 -588
- package/dist/chunk-BAPZPDDA.js.map +1 -0
- package/dist/{chunk-73PXCRCR.js → chunk-DEWXYFEU.js} +6 -6
- package/dist/chunk-DEWXYFEU.js.map +1 -0
- package/dist/{chunk-3WPOGKUP.js → chunk-DWV7SFKR.js} +518 -217
- package/dist/chunk-DWV7SFKR.js.map +1 -0
- package/dist/{chunk-T7OKNJTY.js → chunk-KSTEW2AF.js} +18 -2
- package/dist/chunk-KSTEW2AF.js.map +1 -0
- package/dist/index.d.ts +64 -15
- package/dist/index.js +133 -24
- package/dist/index.js.map +1 -1
- package/dist/passthrough/weasel-canvas.d.ts +1 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +1 -1
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +4 -4
- package/dist/state/index.js +2 -2
- package/dist/styles.css +163 -6
- package/dist/ui/layers/index.js +3 -3
- package/package.json +2 -1
- package/src/lab/Lab.tsx +14 -2
- package/src/lab/LabContext.ts +1 -0
- package/src/lab/WorkspaceGrid.less +37 -5
- package/src/lab/WorkspaceGrid.stories.tsx +16 -0
- package/src/lab/WorkspaceGrid.test.tsx +84 -10
- package/src/lab/WorkspaceGrid.tsx +188 -14
- package/src/lab/index.ts +0 -2
- package/src/state/helpers.ts +2 -2
- package/src/state/store.test.ts +2 -1
- package/src/state/store.ts +19 -0
- package/src/state/types.ts +3 -0
- package/src/test-setup.ts +19 -0
- package/src/workspace/Workspace.stories.tsx +1 -0
- package/src/workspace/Workspace.test.tsx +1 -0
- package/src/workspace/index.ts +1 -0
- package/src/workspace/workspaceOps.test.ts +34 -1
- package/src/workspace/workspaceOps.ts +16 -0
- package/dist/chunk-3WPOGKUP.js.map +0 -1
- package/dist/chunk-73PXCRCR.js.map +0 -1
- package/dist/chunk-N5KTQKQA.js.map +0 -1
- package/dist/chunk-T7OKNJTY.js.map +0 -1
- package/src/lab/gridDims.test.ts +0 -35
- package/src/lab/gridDims.ts +0 -13
|
@@ -59,3 +59,19 @@ export const SevenTiles: Story = {
|
|
|
59
59
|
</div>
|
|
60
60
|
),
|
|
61
61
|
};
|
|
62
|
+
|
|
63
|
+
/** Draggable seams. Drag a tile edge, or Tab to a seam and press an arrow —
|
|
64
|
+
* a grid moves extents a whole cell at a time. */
|
|
65
|
+
export const Resizable: Story = {
|
|
66
|
+
render: () => (
|
|
67
|
+
<div style={{ height: 500 }}>
|
|
68
|
+
<WorkspaceGrid ids={['a', 'b', 'c', 'd', 'e']} resizable>
|
|
69
|
+
<Tile>1</Tile>
|
|
70
|
+
<Tile>2</Tile>
|
|
71
|
+
<Tile>3</Tile>
|
|
72
|
+
<Tile>4</Tile>
|
|
73
|
+
<Tile>5</Tile>
|
|
74
|
+
</WorkspaceGrid>
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
|
-
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
3
3
|
import { WorkspaceGrid } from './WorkspaceGrid';
|
|
4
4
|
|
|
5
|
+
const VIEWPORT = { w: 800, h: 600 };
|
|
6
|
+
|
|
5
7
|
describe('WorkspaceGrid', () => {
|
|
6
8
|
test('renders all children', () => {
|
|
7
9
|
render(
|
|
8
|
-
<WorkspaceGrid>
|
|
10
|
+
<WorkspaceGrid viewport={VIEWPORT}>
|
|
9
11
|
<div>one</div>
|
|
10
12
|
<div>two</div>
|
|
11
13
|
<div>three</div>
|
|
@@ -16,25 +18,97 @@ describe('WorkspaceGrid', () => {
|
|
|
16
18
|
expect(screen.getByText('three')).toBeInTheDocument();
|
|
17
19
|
});
|
|
18
20
|
|
|
19
|
-
test('
|
|
20
|
-
const
|
|
21
|
-
|
|
21
|
+
test('keeps a child with the tile its id names when an earlier one closes', () => {
|
|
22
|
+
const ids = ['a', 'b', 'c'];
|
|
23
|
+
const { rerender, container } = render(
|
|
24
|
+
<WorkspaceGrid ids={ids} viewport={VIEWPORT}>
|
|
22
25
|
<div>a</div>
|
|
23
26
|
<div>b</div>
|
|
24
27
|
<div>c</div>
|
|
25
28
|
</WorkspaceGrid>,
|
|
26
29
|
);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
|
|
31
|
+
|
|
32
|
+
rerender(
|
|
33
|
+
<WorkspaceGrid ids={['b', 'c']} viewport={VIEWPORT}>
|
|
34
|
+
<div>b</div>
|
|
35
|
+
<div>c</div>
|
|
36
|
+
</WorkspaceGrid>,
|
|
37
|
+
);
|
|
38
|
+
expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
|
|
39
|
+
expect(container.querySelector('[data-node="a"]')).toBeNull();
|
|
30
40
|
});
|
|
31
41
|
|
|
32
42
|
test('uses lk-workspace-grid class', () => {
|
|
33
43
|
const { container } = render(
|
|
34
|
-
<WorkspaceGrid>
|
|
44
|
+
<WorkspaceGrid viewport={VIEWPORT}>
|
|
35
45
|
<div />
|
|
36
46
|
</WorkspaceGrid>,
|
|
37
47
|
);
|
|
38
|
-
expect((container.firstChild as HTMLElement).className).
|
|
48
|
+
expect((container.firstChild as HTMLElement).className).toContain('lk-workspace-grid');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('renders resize affordances only when resizable', () => {
|
|
52
|
+
const { container, rerender } = render(
|
|
53
|
+
<WorkspaceGrid ids={['a', 'b']} viewport={VIEWPORT}>
|
|
54
|
+
<div>a</div>
|
|
55
|
+
<div>b</div>
|
|
56
|
+
</WorkspaceGrid>,
|
|
57
|
+
);
|
|
58
|
+
expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
|
|
59
|
+
|
|
60
|
+
rerender(
|
|
61
|
+
<WorkspaceGrid ids={['a', 'b']} resizable viewport={VIEWPORT}>
|
|
62
|
+
<div>a</div>
|
|
63
|
+
<div>b</div>
|
|
64
|
+
</WorkspaceGrid>,
|
|
65
|
+
);
|
|
66
|
+
expect(container.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('applies a saved extent to a tile as it registers', () => {
|
|
70
|
+
const { container } = render(
|
|
71
|
+
<WorkspaceGrid
|
|
72
|
+
ids={['a', 'b']}
|
|
73
|
+
resizable
|
|
74
|
+
viewport={VIEWPORT}
|
|
75
|
+
layout={{ a: { span: { cols: 2 } } }}
|
|
76
|
+
>
|
|
77
|
+
<div>a</div>
|
|
78
|
+
<div>b</div>
|
|
79
|
+
</WorkspaceGrid>,
|
|
80
|
+
);
|
|
81
|
+
const a = container.querySelector('[data-node="a"]') as HTMLElement;
|
|
82
|
+
const b = container.querySelector('[data-node="b"]') as HTMLElement;
|
|
83
|
+
// `a` holds two columns, so it is wider than the single-column `b`.
|
|
84
|
+
expect(Number.parseFloat(a.style.width)).toBeGreaterThan(Number.parseFloat(b.style.width));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('reports the order a drop would produce instead of applying it', () => {
|
|
88
|
+
const onReorder = vi.fn();
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<WorkspaceGrid ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
|
|
91
|
+
<div>a</div>
|
|
92
|
+
<div>b</div>
|
|
93
|
+
</WorkspaceGrid>,
|
|
94
|
+
);
|
|
95
|
+
// The grid is controlled: it renders a handle per tile and commits nothing
|
|
96
|
+
// itself. Order still comes from `ids`.
|
|
97
|
+
expect(container.querySelectorAll('.lk-workspace-tile__grip')).toHaveLength(2);
|
|
98
|
+
expect(onReorder).not.toHaveBeenCalled();
|
|
99
|
+
const nodes = [...container.querySelectorAll('[data-node]')].map((el) =>
|
|
100
|
+
el.getAttribute('data-node'),
|
|
101
|
+
);
|
|
102
|
+
expect(nodes).toEqual(['a', 'b']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('renders no drag handles unless reorderable', () => {
|
|
106
|
+
const { container } = render(
|
|
107
|
+
<WorkspaceGrid ids={['a', 'b']} viewport={VIEWPORT}>
|
|
108
|
+
<div>a</div>
|
|
109
|
+
<div>b</div>
|
|
110
|
+
</WorkspaceGrid>,
|
|
111
|
+
);
|
|
112
|
+
expect(container.querySelectorAll('.lk-workspace-tile__grip')).toHaveLength(0);
|
|
39
113
|
});
|
|
40
114
|
});
|
|
@@ -1,22 +1,196 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { asNodeId, createNode, gridStrategy, type NodeId, Store } from 'windease';
|
|
11
|
+
import {
|
|
12
|
+
type ChromeMap,
|
|
13
|
+
Container,
|
|
14
|
+
DragHandle,
|
|
15
|
+
DragProvider,
|
|
16
|
+
Provider,
|
|
17
|
+
StrategyRegistryProvider,
|
|
18
|
+
} from 'windease/react';
|
|
19
|
+
|
|
20
|
+
const ZONE_ID = asNodeId('lk-workspaces');
|
|
21
|
+
const STRATEGIES = { grid: gridStrategy as never };
|
|
22
|
+
const KIND = 'workspace';
|
|
23
|
+
|
|
24
|
+
/** A tile's persisted extent, keyed by the id its caller gave it. Grid resizes
|
|
25
|
+
* write `span`; `size` is here because a strategy swap would write that. */
|
|
26
|
+
export type WorkspaceLayout = Record<
|
|
27
|
+
string,
|
|
28
|
+
{ size?: { w?: number; h?: number }; span?: { cols?: number; rows?: number } }
|
|
29
|
+
>;
|
|
3
30
|
|
|
4
|
-
/** Props for `<WorkspaceGrid>`. */
|
|
5
31
|
export interface WorkspaceGridProps {
|
|
6
32
|
children: ReactNode;
|
|
33
|
+
/**
|
|
34
|
+
* Stable id per child, positionally matched. Supply these whenever a tile
|
|
35
|
+
* can be closed from the middle: without them a tile is identified by its
|
|
36
|
+
* position, so closing one shifts every id after it and the panes inherit
|
|
37
|
+
* each other's dragged extents. `layout` and `reorderable` both key off
|
|
38
|
+
* these, so neither means much without them.
|
|
39
|
+
*/
|
|
40
|
+
ids?: readonly string[];
|
|
41
|
+
/** Draggable seams between tiles. Off by default — an even tiling is the
|
|
42
|
+
* behavior every existing caller has. */
|
|
43
|
+
resizable?: boolean;
|
|
44
|
+
/** Let a tile be dragged to a new position. Off by default. The grid never
|
|
45
|
+
* reorders `children` itself: it reports the order a drop would produce and
|
|
46
|
+
* the caller commits it. */
|
|
47
|
+
reorderable?: boolean;
|
|
48
|
+
/** The full id list a drop would produce, in its new order. */
|
|
49
|
+
onReorder?: (ids: string[]) => void;
|
|
50
|
+
/** Extents from a previous session, applied to tiles as they register. */
|
|
51
|
+
layout?: WorkspaceLayout;
|
|
52
|
+
/** Fires when a tile's extent changes. Persist it and hand it back as
|
|
53
|
+
* `layout` to make a resize survive a reload. */
|
|
54
|
+
onLayoutChange?: (layout: WorkspaceLayout) => void;
|
|
55
|
+
gap?: number;
|
|
56
|
+
padding?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Fixed tiling extent. Omit in an app — the grid measures its own box. Supply
|
|
59
|
+
* it where nothing measures, notably jsdom: at a zero measurement the grid
|
|
60
|
+
* renders no tiles at all.
|
|
61
|
+
*/
|
|
62
|
+
viewport?: { w: number; h: number };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extentOf(store: Store, id: NodeId): WorkspaceLayout[string] | null {
|
|
66
|
+
const p = store.getNode(id)?.membership?.placement as WorkspaceLayout[string] | undefined;
|
|
67
|
+
if (!p) return null;
|
|
68
|
+
const out: WorkspaceLayout[string] = {};
|
|
69
|
+
if (p.size) out.size = p.size;
|
|
70
|
+
if (p.span) out.span = p.span;
|
|
71
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
7
72
|
}
|
|
8
73
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Auto-balanced tiling of workspaces, `ceil(sqrt(n))` columns wide.
|
|
76
|
+
*
|
|
77
|
+
* Tiles are absolutely positioned at the rects `gridStrategy` computes, not
|
|
78
|
+
* laid out by CSS — `windease/styles.css` (folded into
|
|
79
|
+
* `@weasel-js/labkit/styles.css`) carries the rules that positioning depends on.
|
|
80
|
+
*/
|
|
81
|
+
export function WorkspaceGrid({
|
|
82
|
+
children,
|
|
83
|
+
ids,
|
|
84
|
+
resizable = false,
|
|
85
|
+
reorderable = false,
|
|
86
|
+
onReorder,
|
|
87
|
+
layout,
|
|
88
|
+
onLayoutChange,
|
|
89
|
+
gap = 12,
|
|
90
|
+
padding = 0,
|
|
91
|
+
viewport,
|
|
92
|
+
}: WorkspaceGridProps) {
|
|
93
|
+
const items = Children.toArray(children);
|
|
94
|
+
const idKey = ids ? ids.join(',') : `#${items.length}`;
|
|
95
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: idKey is the stable projection of items/ids; depending on those directly rebuilds every render and re-runs the sync effect forever
|
|
96
|
+
const nodeIds = useMemo(() => items.map((_, i) => asNodeId(ids?.[i] ?? `lk-ws-${i}`)), [idKey]);
|
|
97
|
+
|
|
98
|
+
// Held in refs rather than depended on: a fresh object each render would
|
|
99
|
+
// re-run the sync effect, and only a newly registered tile reads `layout`.
|
|
100
|
+
const layoutRef = useRef(layout);
|
|
101
|
+
layoutRef.current = layout;
|
|
102
|
+
const onLayoutChangeRef = useRef(onLayoutChange);
|
|
103
|
+
onLayoutChangeRef.current = onLayoutChange;
|
|
104
|
+
|
|
105
|
+
// One store for the component's lifetime: a tile's dragged extent lives in
|
|
106
|
+
// its node, so rebuilding the store on every add or close would silently
|
|
107
|
+
// reset every pane.
|
|
108
|
+
const storeRef = useRef<Store | null>(null);
|
|
109
|
+
if (storeRef.current === null) {
|
|
110
|
+
const store = new Store();
|
|
111
|
+
store.registerNode(
|
|
112
|
+
createNode({
|
|
113
|
+
kind: 'zone',
|
|
114
|
+
id: ZONE_ID,
|
|
115
|
+
container: { strategyId: 'grid', config: { resizable, gap, padding } },
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
storeRef.current = store;
|
|
119
|
+
}
|
|
120
|
+
const store = storeRef.current;
|
|
121
|
+
|
|
122
|
+
useLayoutEffect(() => {
|
|
123
|
+
store.updateContainerConfig(ZONE_ID, { resizable, gap, padding });
|
|
124
|
+
}, [store, resizable, gap, padding]);
|
|
125
|
+
|
|
126
|
+
useLayoutEffect(() => {
|
|
127
|
+
const present = new Set(store.getContainerView(ZONE_ID)?.childOrder ?? []);
|
|
128
|
+
const wanted = new Set(nodeIds);
|
|
129
|
+
for (const id of present) {
|
|
130
|
+
if (!wanted.has(id)) store.unregisterNode(id);
|
|
131
|
+
}
|
|
132
|
+
for (const id of nodeIds) {
|
|
133
|
+
if (present.has(id)) continue;
|
|
134
|
+
store.registerNode(createNode({ kind: KIND, id, parentId: ZONE_ID, focus: true }));
|
|
135
|
+
store.showNode(id);
|
|
136
|
+
const saved = layoutRef.current?.[id];
|
|
137
|
+
if (saved) store.patchPlacement(id, saved);
|
|
138
|
+
}
|
|
139
|
+
store.setChildOrder(ZONE_ID, [...nodeIds]);
|
|
140
|
+
}, [store, nodeIds]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!onLayoutChange) return;
|
|
144
|
+
return store.events.on('node.placementChanged', () => {
|
|
145
|
+
const next: WorkspaceLayout = {};
|
|
146
|
+
for (const child of store.getChildren(ZONE_ID)) {
|
|
147
|
+
const extent = extentOf(store, child.id);
|
|
148
|
+
if (extent) next[child.id] = extent;
|
|
149
|
+
}
|
|
150
|
+
onLayoutChangeRef.current?.(next);
|
|
151
|
+
});
|
|
152
|
+
}, [store, onLayoutChange]);
|
|
153
|
+
|
|
154
|
+
const commitOrder = useCallback(
|
|
155
|
+
(nextIds: NodeId[]) => onReorder?.(nextIds.map(String)),
|
|
156
|
+
[onReorder],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const chrome = useMemo<ChromeMap>(() => {
|
|
160
|
+
const byId = new Map<string, ReactNode>(nodeIds.map((id, i) => [id, items[i]]));
|
|
161
|
+
return {
|
|
162
|
+
[KIND]: ({ node }) => {
|
|
163
|
+
const content = byId.get(node.id) ?? null;
|
|
164
|
+
if (!reorderable) return content;
|
|
165
|
+
// A tile is full of controls, so the whole thing can't be the handle.
|
|
166
|
+
return (
|
|
167
|
+
<div className="lk-workspace-tile">
|
|
168
|
+
<DragHandle nodeId={node.id} className="lk-workspace-tile__grip">
|
|
169
|
+
<span className="lk-workspace-tile__grip-dots" aria-hidden="true" />
|
|
170
|
+
</DragHandle>
|
|
171
|
+
<div className="lk-workspace-tile__body">{content}</div>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}, [nodeIds, items, reorderable]);
|
|
177
|
+
|
|
178
|
+
const grid = (
|
|
179
|
+
<Container
|
|
180
|
+
parentId={ZONE_ID}
|
|
181
|
+
chrome={chrome}
|
|
182
|
+
className="lk-workspace-grid windease-zone"
|
|
183
|
+
affordances={resizable}
|
|
184
|
+
viewport={viewport}
|
|
185
|
+
onChildOrderChange={reorderable ? commitOrder : undefined}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
|
|
17
189
|
return (
|
|
18
|
-
<
|
|
19
|
-
{
|
|
20
|
-
|
|
190
|
+
<Provider store={store}>
|
|
191
|
+
<StrategyRegistryProvider strategies={STRATEGIES}>
|
|
192
|
+
{reorderable ? <DragProvider>{grid}</DragProvider> : grid}
|
|
193
|
+
</StrategyRegistryProvider>
|
|
194
|
+
</Provider>
|
|
21
195
|
);
|
|
22
196
|
}
|
package/src/lab/index.ts
CHANGED
package/src/state/helpers.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { InstrumentSerializers, UndoStack, WorkspaceRecord } from './types';
|
|
2
2
|
|
|
3
|
-
/** The storage key a lab writes one of its
|
|
3
|
+
/** The storage key a lab writes one of its buckets under. Namespaced by
|
|
4
4
|
* `storageKey` so two labs sharing an origin do not collide. */
|
|
5
5
|
export function labStorageKey(
|
|
6
6
|
storageKey: string,
|
|
7
|
-
bucket: 'workspaces' | 'saves' | 'theme',
|
|
7
|
+
bucket: 'workspaces' | 'saves' | 'theme' | 'layout',
|
|
8
8
|
): string {
|
|
9
9
|
return `lk:${storageKey}:${bucket}`;
|
|
10
10
|
}
|
package/src/state/store.test.ts
CHANGED
|
@@ -245,7 +245,8 @@ describe('persistence — debounced writes', () => {
|
|
|
245
245
|
vi.advanceTimersByTime(400);
|
|
246
246
|
const writesAfter = writeSpy.mock.calls.length;
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
// One flush, one write per storage key: workspaces, saves, theme, layout.
|
|
249
|
+
expect(writesAfter - writesBefore).toBe(4);
|
|
249
250
|
vi.useRealTimers();
|
|
250
251
|
});
|
|
251
252
|
});
|
package/src/state/store.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface LabStoreActions {
|
|
|
34
34
|
deleteSnapshot: (snapshotId: string) => void;
|
|
35
35
|
listSnapshots: (workspaceId?: string) => SavedSnapshot[];
|
|
36
36
|
setMode: (mode: LabMode) => void;
|
|
37
|
+
setLayout: (layout: Record<string, unknown>) => void;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/** A lab's store: its state and actions, plus the hook instruments use to
|
|
@@ -65,6 +66,17 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
const layoutRaw = options.storage.read(labStorageKey(options.storageKey, 'layout'));
|
|
70
|
+
let hydratedLayout: Record<string, unknown> = {};
|
|
71
|
+
if (layoutRaw) {
|
|
72
|
+
try {
|
|
73
|
+
hydratedLayout = JSON.parse(layoutRaw) as Record<string, unknown>;
|
|
74
|
+
} catch {
|
|
75
|
+
console.warn('[labkit] failed to parse saved layout, starting empty');
|
|
76
|
+
hydratedLayout = {};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
const modeRaw = options.storage.read(labStorageKey(options.storageKey, 'theme'));
|
|
69
81
|
// `interstellar` was the dark mode's name back when it was a theme.
|
|
70
82
|
const stored = modeRaw === 'interstellar' ? 'dark' : modeRaw;
|
|
@@ -79,6 +91,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
79
91
|
workspaces: hydratedWorkspaces,
|
|
80
92
|
savedSnapshots: hydratedSnapshots,
|
|
81
93
|
mode: hydratedMode,
|
|
94
|
+
layout: hydratedLayout,
|
|
82
95
|
|
|
83
96
|
addWorkspace: (record) => {
|
|
84
97
|
set((s) => ({
|
|
@@ -207,6 +220,11 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
207
220
|
set({ mode });
|
|
208
221
|
scheduleFlush();
|
|
209
222
|
},
|
|
223
|
+
|
|
224
|
+
setLayout: (layout) => {
|
|
225
|
+
set({ layout });
|
|
226
|
+
scheduleFlush();
|
|
227
|
+
},
|
|
210
228
|
}));
|
|
211
229
|
|
|
212
230
|
function scheduleFlush(): void {
|
|
@@ -222,6 +240,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
222
240
|
JSON.stringify(s.savedSnapshots),
|
|
223
241
|
);
|
|
224
242
|
options.storage.write(labStorageKey(options.storageKey, 'theme'), s.mode);
|
|
243
|
+
options.storage.write(labStorageKey(options.storageKey, 'layout'), JSON.stringify(s.layout));
|
|
225
244
|
flushTimer = null;
|
|
226
245
|
}, 300);
|
|
227
246
|
}
|
package/src/state/types.ts
CHANGED
|
@@ -37,6 +37,9 @@ export interface LabStoreState {
|
|
|
37
37
|
workspaces: WorkspaceRecord[];
|
|
38
38
|
savedSnapshots: SavedSnapshot[];
|
|
39
39
|
mode: LabMode;
|
|
40
|
+
/** Per-workspace tile extents, keyed by workspace id. Opaque here — the
|
|
41
|
+
* shape belongs to whatever lays the workspaces out. */
|
|
42
|
+
layout: Record<string, unknown>;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
/** Where a lab persists itself. Implementations are keyed string storage and
|
package/src/test-setup.ts
CHANGED
|
@@ -12,6 +12,25 @@ if (jsdomWin && typeof globalThis.localStorage === 'undefined') {
|
|
|
12
12
|
Object.defineProperty(globalThis, 'sessionStorage', { value: jsdomWin.sessionStorage });
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// windease's ContainerHost calls `new ResizeObserver` unguarded, and jsdom
|
|
16
|
+
// ships none. The stub must report a non-zero box: a windease container
|
|
17
|
+
// renders no children at all until something measures it, and jsdom's own
|
|
18
|
+
// geometry is always 0, so every tiled tile would vanish from the DOM.
|
|
19
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
20
|
+
globalThis.ResizeObserver = class {
|
|
21
|
+
#cb: ResizeObserverCallback;
|
|
22
|
+
constructor(cb: ResizeObserverCallback) {
|
|
23
|
+
this.#cb = cb;
|
|
24
|
+
}
|
|
25
|
+
observe(target: Element) {
|
|
26
|
+
const contentRect = { width: 1024, height: 768, x: 0, y: 0, top: 0, left: 0 };
|
|
27
|
+
this.#cb([{ target, contentRect } as ResizeObserverEntry], this);
|
|
28
|
+
}
|
|
29
|
+
unobserve() {}
|
|
30
|
+
disconnect() {}
|
|
31
|
+
} as unknown as typeof ResizeObserver;
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
afterEach(() => {
|
|
16
35
|
cleanup();
|
|
17
36
|
});
|
package/src/workspace/index.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { Instrument } from '../instrument/types';
|
|
3
3
|
import type { WorkspaceRecord } from '../state/types';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
addWorkspace,
|
|
6
|
+
cloneWorkspace,
|
|
7
|
+
closeWorkspace,
|
|
8
|
+
reorderWorkspaces,
|
|
9
|
+
resetWorkspace,
|
|
10
|
+
} from './workspaceOps';
|
|
5
11
|
|
|
6
12
|
interface CounterState {
|
|
7
13
|
count: number;
|
|
@@ -140,3 +146,30 @@ describe('resetWorkspace', () => {
|
|
|
140
146
|
expect((arr[0]?.config as CounterConfig).step).toBe(99);
|
|
141
147
|
});
|
|
142
148
|
});
|
|
149
|
+
|
|
150
|
+
describe('reorderWorkspaces', () => {
|
|
151
|
+
const ws = (id: string) =>
|
|
152
|
+
({
|
|
153
|
+
id,
|
|
154
|
+
instrumentName: 'T',
|
|
155
|
+
config: {},
|
|
156
|
+
state: {},
|
|
157
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
158
|
+
undoStack: { past: [], future: [] },
|
|
159
|
+
}) as never;
|
|
160
|
+
|
|
161
|
+
it('reorders to match the given ids', () => {
|
|
162
|
+
const next = reorderWorkspaces([ws('a'), ws('b'), ws('c')], ['c', 'a', 'b']);
|
|
163
|
+
expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('drops ids that no longer exist rather than resurrecting them', () => {
|
|
167
|
+
const next = reorderWorkspaces([ws('a'), ws('b')], ['gone', 'b', 'a']);
|
|
168
|
+
expect(next.map((w) => w.id)).toEqual(['b', 'a']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('keeps unmentioned workspaces, after the named ones', () => {
|
|
172
|
+
const next = reorderWorkspaces([ws('a'), ws('b'), ws('c')], ['c']);
|
|
173
|
+
expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -84,3 +84,19 @@ export function resetWorkspace(
|
|
|
84
84
|
};
|
|
85
85
|
return [...workspaces.slice(0, idx), reset, ...workspaces.slice(idx + 1)];
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reorder to match `ids`. Ids the list doesn't mention keep their relative
|
|
90
|
+
* order at the end, and ids it names that no longer exist are dropped — a
|
|
91
|
+
* reorder that raced a close should not resurrect the closed workspace.
|
|
92
|
+
*/
|
|
93
|
+
export function reorderWorkspaces(
|
|
94
|
+
workspaces: WorkspaceRecord[],
|
|
95
|
+
ids: readonly string[],
|
|
96
|
+
): WorkspaceRecord[] {
|
|
97
|
+
const byId = new Map(workspaces.map((w) => [w.id, w]));
|
|
98
|
+
const named = ids.map((id) => byId.get(id)).filter((w): w is WorkspaceRecord => w !== undefined);
|
|
99
|
+
const seen = new Set(named.map((w) => w.id));
|
|
100
|
+
const rest = workspaces.filter((w) => !seen.has(w.id));
|
|
101
|
+
return [...named, ...rest];
|
|
102
|
+
}
|