@weasel-js/labkit 1.4.1 → 1.4.3
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/README.md +28 -2
- package/dist/_dts/{CanvasStackContext-BIRUVVGH.d.ts → CanvasStackContext-Dde8_1my.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-CgxUequc.d.ts → PrefsForm.d-DDHFANkc.d.ts} +1 -1
- package/dist/_dts/{frac-X7mWgd7y.d.ts → frac-B5mB8LsJ.d.ts} +33 -13
- package/dist/_dts/{index-FEa1MudK.d.ts → index-DelFfeV0.d.ts} +99 -31
- package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
- package/dist/_dts/{types-ChVJJHvk.d.ts → types-BP2OCcpg.d.ts} +41 -4
- package/dist/_dts/types-x92Kfeme.d.ts +62 -0
- package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-D6Vb3T-g.d.ts} +11 -7
- package/dist/canvas/index.d.ts +9 -8
- package/dist/canvas/index.js +1 -2
- package/dist/chrome/index.d.ts +16 -9
- package/dist/chrome/index.js +5 -5
- package/dist/{chunk-MSR7EVS6.js → chunk-2RQPGOQF.js} +6 -5
- package/dist/chunk-2RQPGOQF.js.map +1 -0
- package/dist/{chunk-LJSIUFYD.js → chunk-5TROQVQ2.js} +46 -21
- package/dist/chunk-5TROQVQ2.js.map +1 -0
- package/dist/{chunk-53AZ2O2N.js → chunk-ASWKLRKJ.js} +92 -45
- package/dist/chunk-ASWKLRKJ.js.map +1 -0
- package/dist/{chunk-BMQWLKOL.js → chunk-C42T6DJT.js} +94 -83
- package/dist/chunk-C42T6DJT.js.map +1 -0
- package/dist/chunk-E44SU6XS.js +50 -0
- package/dist/chunk-E44SU6XS.js.map +1 -0
- package/dist/{chunk-OFCBEZMU.js → chunk-FQMJUVHJ.js} +3 -3
- package/dist/{chunk-OFCBEZMU.js.map → chunk-FQMJUVHJ.js.map} +1 -1
- package/dist/{chunk-PP7ZWAMV.js → chunk-I6JCVE24.js} +3 -3
- package/dist/chunk-I6JCVE24.js.map +1 -0
- package/dist/chunk-ISCOFAUO.js +170 -0
- package/dist/chunk-ISCOFAUO.js.map +1 -0
- package/dist/chunk-MHAGC6VD.js +7320 -0
- package/dist/chunk-MHAGC6VD.js.map +1 -0
- package/dist/{chunk-7TRFMIWX.js → chunk-QAGAHJKZ.js} +4 -4
- package/dist/{chunk-7TRFMIWX.js.map → chunk-QAGAHJKZ.js.map} +1 -1
- package/dist/{chunk-D7QIPGEB.js → chunk-RS3HRQWU.js} +62 -60
- package/dist/chunk-RS3HRQWU.js.map +1 -0
- package/dist/{chunk-RLOXQZW3.js → chunk-TJ7QY3OC.js} +3 -3
- package/dist/{chunk-RLOXQZW3.js.map → chunk-TJ7QY3OC.js.map} +1 -1
- package/dist/controls/index.d.ts +5 -4
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +9 -8
- package/dist/dragdrop/index.js +1 -2
- package/dist/index.d.ts +152 -57
- package/dist/index.js +315 -200
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +27 -11
- package/dist/layers/index.js +2 -3
- package/dist/loupe/index.d.ts +8 -7
- package/dist/loupe/index.js +1 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -3
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-canvas.js.map +1 -1
- package/dist/passthrough/weasel-ui.d.ts +171 -22
- package/dist/passthrough/weasel-ui.js +1 -2
- package/dist/primitives/index.js +3 -4
- package/dist/state/index.d.ts +6 -3
- package/dist/state/index.js +3 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +21 -4
- package/dist/surface/index.js +1 -2
- package/dist/ui/layers/index.d.ts +22 -9
- package/dist/ui/layers/index.js +1 -2
- package/dist/undo/index.d.ts +6 -6
- package/package.json +7 -7
- package/src/annotations/AnnotationTargets.tsx +6 -1
- package/src/annotations/Annotations.overlay.test.tsx +2 -0
- package/src/annotations/ExportMenu.test.tsx +16 -0
- package/src/annotations/ExportMenu.tsx +0 -5
- package/src/annotations/types.ts +4 -1
- package/src/canvas/useOrbit.test.ts +7 -3
- package/src/canvas/usePanZoom.test.ts +7 -3
- package/src/chrome/ChromeRegions.stories.tsx +42 -1
- package/src/chrome/builtins.test.ts +4 -0
- package/src/chrome/builtins.tsx +18 -0
- package/src/chrome/index.ts +1 -1
- package/src/chrome/regions/SidebarRegion.tsx +7 -6
- package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
- package/src/chrome/regions/TitleBarRegion.tsx +16 -7
- package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
- package/src/chrome/regions/ToolbarRegion.tsx +2 -2
- package/src/chrome/regions/ViewportRegion.tsx +2 -2
- package/src/chrome/regions/regions.test.tsx +45 -1
- package/src/chrome/types.ts +21 -3
- package/src/config/builder.test.ts +56 -2
- package/src/config/builder.ts +73 -13
- package/src/config/index.ts +16 -1
- package/src/config/path.test.ts +109 -0
- package/src/config/path.ts +76 -0
- package/src/config/resolve.test.ts +93 -2
- package/src/config/resolve.ts +72 -29
- package/src/config/types.ts +99 -9
- package/src/config/visible.ts +6 -4
- package/src/controls/ControlPanel.stories.tsx +38 -4
- package/src/controls/ControlPanel.test.tsx +152 -0
- package/src/controls/ControlPanel.tsx +134 -40
- package/src/dragdrop/DragDropRuntime.tsx +74 -61
- package/src/dragdrop/Palette.tsx +4 -3
- package/src/dragdrop/dragDrop.test.tsx +35 -12
- package/src/index.ts +18 -2
- package/src/instrument/serializers.ts +36 -0
- package/src/instrument/types.ts +8 -7
- package/src/lab/Lab.test.tsx +36 -1
- package/src/lab/Lab.tsx +12 -3
- package/src/lab/LabContext.ts +5 -1
- package/src/lab/LabShell.less +17 -2
- package/src/lab/LabShell.test.tsx +8 -0
- package/src/lab/LabShell.tsx +3 -1
- package/src/layers/AGENTS.md +29 -7
- package/src/layers/LayerList.less +16 -0
- package/src/layers/LayerList.stories.tsx +66 -0
- package/src/layers/LayerList.test.tsx +117 -5
- package/src/layers/LayerList.tsx +192 -53
- package/src/layers/index.ts +1 -1
- package/src/passthrough/weasel-ui.ts +5 -0
- package/src/primitives/FloatingPanel.test.tsx +8 -3
- package/src/primitives/ZoomControl.less +6 -2
- package/src/primitives/ZoomControl.tsx +1 -0
- package/src/state/helpers.ts +1 -1
- package/src/state/store.test.ts +233 -1
- package/src/state/store.ts +57 -24
- package/src/state/types.ts +44 -3
- package/src/state/useTrialState.ts +1 -1
- package/src/trial/Trial.config.test.tsx +44 -0
- package/src/trial/Trial.less +4 -0
- package/src/trial/Trial.targets.test.tsx +96 -0
- package/src/trial/Trial.test.tsx +171 -14
- package/src/trial/Trial.tsx +7 -4
- package/src/trial/TrialChrome.tsx +32 -7
- package/src/trial/TrialTitleBar.tsx +7 -3
- package/src/trial/index.ts +1 -0
- package/src/trial/trialOps.test.ts +49 -1
- package/src/trial/trialOps.ts +24 -6
- package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
- package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
- package/dist/chunk-53AZ2O2N.js.map +0 -1
- package/dist/chunk-BMQWLKOL.js.map +0 -1
- package/dist/chunk-D7QIPGEB.js.map +0 -1
- package/dist/chunk-DG2IFMLA.js +0 -29970
- package/dist/chunk-DG2IFMLA.js.map +0 -1
- package/dist/chunk-J6SBFZYT.js +0 -7121
- package/dist/chunk-J6SBFZYT.js.map +0 -1
- package/dist/chunk-LJSIUFYD.js.map +0 -1
- package/dist/chunk-MIT3ISW4.js +0 -100
- package/dist/chunk-MIT3ISW4.js.map +0 -1
- package/dist/chunk-MSR7EVS6.js.map +0 -1
- package/dist/chunk-PP7ZWAMV.js.map +0 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// The slider is a weasel-ui component whose module class sets `width`.
|
|
17
17
|
// See the `.lk-root` prefix convention in theme/base.less.
|
|
18
18
|
.lk-root {
|
|
19
19
|
.lk-zoom {
|
|
@@ -24,8 +24,12 @@
|
|
|
24
24
|
min-width: 64px;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// `width: fit` sizes the field from this rather than from the input's
|
|
28
|
+
// 20-character intrinsic width. "800%" measures 33px against the 35px a
|
|
29
|
+
// 5ch box gives, which is no margin at all in another UI font; 6ch also
|
|
30
|
+
// holds the "1600%" a consumer raising `max` can reach.
|
|
27
31
|
&__field {
|
|
28
|
-
width:
|
|
32
|
+
--wzl-number-field-width: 6ch;
|
|
29
33
|
}
|
|
30
34
|
}
|
|
31
35
|
}
|
package/src/state/helpers.ts
CHANGED
|
@@ -58,7 +58,7 @@ export function deserializeTrials(
|
|
|
58
58
|
const d = deserializers[r.instrumentName];
|
|
59
59
|
return {
|
|
60
60
|
...r,
|
|
61
|
-
state: d?.deserialize ? d.deserialize(r.state) : r.state,
|
|
61
|
+
state: d?.deserialize ? d.deserialize(r.state, r.config) : r.state,
|
|
62
62
|
undoStack: emptyUndoStack(),
|
|
63
63
|
};
|
|
64
64
|
});
|
package/src/state/store.test.ts
CHANGED
|
@@ -100,10 +100,114 @@ describe('updateTrialConfig', () => {
|
|
|
100
100
|
state: {},
|
|
101
101
|
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
102
102
|
});
|
|
103
|
-
s.getState().updateTrialConfig('w1', 'x'
|
|
103
|
+
s.getState().updateTrialConfig('w1', 'x', 99);
|
|
104
104
|
expect((s.getState().trials[0]?.config as { x: number }).x).toBe(99);
|
|
105
105
|
expect((s.getState().trials[0]?.config as { y: number }).y).toBe(2);
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
it('writes down a dotted path without disturbing its siblings', () => {
|
|
109
|
+
const s = makeStore();
|
|
110
|
+
const config = { grid: { size: 20, color: '#fff' }, showGrid: true };
|
|
111
|
+
s.getState().addTrial({
|
|
112
|
+
id: 'w1',
|
|
113
|
+
instrumentName: 'T',
|
|
114
|
+
config,
|
|
115
|
+
state: {},
|
|
116
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
117
|
+
});
|
|
118
|
+
s.getState().updateTrialConfig('w1', 'grid.size', 40);
|
|
119
|
+
expect(s.getState().trials[0]?.config).toEqual({
|
|
120
|
+
grid: { size: 40, color: '#fff' },
|
|
121
|
+
showGrid: true,
|
|
122
|
+
});
|
|
123
|
+
// The record handed in is never mutated: a trial re-renders on identity.
|
|
124
|
+
expect(config.grid.size).toBe(20);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('createLabStore - config defaults', () => {
|
|
129
|
+
const stored = (config: unknown) =>
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
version: CURRENT_DOCUMENT_VERSION,
|
|
132
|
+
trials: [
|
|
133
|
+
{
|
|
134
|
+
id: 'w1',
|
|
135
|
+
instrumentName: 'T',
|
|
136
|
+
config,
|
|
137
|
+
state: {},
|
|
138
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
saves: [
|
|
142
|
+
{
|
|
143
|
+
id: 's1',
|
|
144
|
+
name: 'saved',
|
|
145
|
+
trialId: 'w1',
|
|
146
|
+
instrumentName: 'T',
|
|
147
|
+
config,
|
|
148
|
+
state: {},
|
|
149
|
+
savedAt: 1,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
layout: {},
|
|
153
|
+
undockedPanels: {},
|
|
154
|
+
mode: 'auto',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('fills a branch a config stored before the schema nested it never had', () => {
|
|
158
|
+
const storage = createMemoryAdapter();
|
|
159
|
+
storage.write(labDocumentKey('test'), stored({ showGrid: false, gridSize: 40 }));
|
|
160
|
+
const s = createLabStore({
|
|
161
|
+
storageKey: 'test',
|
|
162
|
+
storage,
|
|
163
|
+
configDefaults: { T: () => ({ showGrid: true, grid: { size: 20, color: '#fff' } }) },
|
|
164
|
+
});
|
|
165
|
+
expect(s.getState().trials[0]?.config).toEqual({
|
|
166
|
+
showGrid: false,
|
|
167
|
+
gridSize: 40,
|
|
168
|
+
grid: { size: 20, color: '#fff' },
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('fills the config on a saved snapshot the same way', () => {
|
|
173
|
+
const storage = createMemoryAdapter();
|
|
174
|
+
storage.write(labDocumentKey('test'), stored({ grid: { size: 40 } }));
|
|
175
|
+
const s = createLabStore({
|
|
176
|
+
storageKey: 'test',
|
|
177
|
+
storage,
|
|
178
|
+
configDefaults: { T: () => ({ grid: { size: 20, color: '#fff' } }) },
|
|
179
|
+
});
|
|
180
|
+
expect(s.getState().savedSnapshots[0]?.config).toEqual({
|
|
181
|
+
grid: { size: 40, color: '#fff' },
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('hands a deserializer the filled config, not the stored one', () => {
|
|
186
|
+
const storage = createMemoryAdapter();
|
|
187
|
+
storage.write(labDocumentKey('test'), stored({ grid: { size: 40 } }));
|
|
188
|
+
const seen: unknown[] = [];
|
|
189
|
+
createLabStore({
|
|
190
|
+
storageKey: 'test',
|
|
191
|
+
storage,
|
|
192
|
+
configDefaults: { T: () => ({ grid: { size: 20, color: '#fff' } }) },
|
|
193
|
+
serializers: {
|
|
194
|
+
T: {
|
|
195
|
+
deserialize: (state, config) => {
|
|
196
|
+
seen.push(config);
|
|
197
|
+
return state;
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
expect(seen).toEqual([{ grid: { size: 40, color: '#fff' } }]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('leaves a config alone when no defaults are registered for its instrument', () => {
|
|
206
|
+
const storage = createMemoryAdapter();
|
|
207
|
+
storage.write(labDocumentKey('test'), stored({ only: 1 }));
|
|
208
|
+
const s = createLabStore({ storageKey: 'test', storage });
|
|
209
|
+
expect(s.getState().trials[0]?.config).toEqual({ only: 1 });
|
|
210
|
+
});
|
|
107
211
|
});
|
|
108
212
|
|
|
109
213
|
describe('setMode', () => {
|
|
@@ -222,6 +326,56 @@ describe('persistence — hydration', () => {
|
|
|
222
326
|
expect(hydrated.getState().trials).toHaveLength(1);
|
|
223
327
|
expect((hydrated.getState().trials[0]?.state as { n: number }).n).toBe(7);
|
|
224
328
|
});
|
|
329
|
+
|
|
330
|
+
// An instrument holding a Map, a Set or anything else JSON drops needs these
|
|
331
|
+
// to run at both ends. They are read while the store is being built, which
|
|
332
|
+
// is why they arrive as an option rather than being registered afterwards.
|
|
333
|
+
it("runs an instrument's serializers at flush and at hydrate", () => {
|
|
334
|
+
vi.useFakeTimers();
|
|
335
|
+
const mem = createMemoryAdapter();
|
|
336
|
+
const serializers = {
|
|
337
|
+
T: {
|
|
338
|
+
serialize: (state: unknown) => [...(state as Map<string, number>)],
|
|
339
|
+
deserialize: (data: unknown) => new Map(data as [string, number][]),
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const seed = createLabStore({ storageKey: 'test', storage: mem, serializers });
|
|
344
|
+
seed.getState().addTrial({
|
|
345
|
+
id: 'w1',
|
|
346
|
+
instrumentName: 'T',
|
|
347
|
+
config: {},
|
|
348
|
+
state: new Map([['a', 1]]),
|
|
349
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
350
|
+
});
|
|
351
|
+
vi.advanceTimersByTime(500);
|
|
352
|
+
vi.useRealTimers();
|
|
353
|
+
|
|
354
|
+
const written = mem.read(labDocumentKey('test')) ?? '';
|
|
355
|
+
expect(written).toContain('[["a",1]]');
|
|
356
|
+
|
|
357
|
+
const hydrated = createLabStore({ storageKey: 'test', storage: mem, serializers });
|
|
358
|
+
expect(hydrated.getState().trials[0]?.state).toEqual(new Map([['a', 1]]));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('hands `deserialize` the config the state was saved against', () => {
|
|
362
|
+
vi.useFakeTimers();
|
|
363
|
+
const mem = createMemoryAdapter();
|
|
364
|
+
const deserialize = vi.fn((data: unknown) => data);
|
|
365
|
+
const seed = createLabStore({ storageKey: 'test', storage: mem });
|
|
366
|
+
seed.getState().addTrial({
|
|
367
|
+
id: 'w1',
|
|
368
|
+
instrumentName: 'T',
|
|
369
|
+
config: { scale: 4 },
|
|
370
|
+
state: { n: 1 },
|
|
371
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
372
|
+
});
|
|
373
|
+
vi.advanceTimersByTime(500);
|
|
374
|
+
vi.useRealTimers();
|
|
375
|
+
|
|
376
|
+
createLabStore({ storageKey: 'test', storage: mem, serializers: { T: { deserialize } } });
|
|
377
|
+
expect(deserialize).toHaveBeenCalledWith({ n: 1 }, { scale: 4 });
|
|
378
|
+
});
|
|
225
379
|
});
|
|
226
380
|
|
|
227
381
|
describe('persistence — debounced writes', () => {
|
|
@@ -673,3 +827,81 @@ describe('undocking a sidebar panel', () => {
|
|
|
673
827
|
expect(s.getState().undockedPanels).toEqual({});
|
|
674
828
|
});
|
|
675
829
|
});
|
|
830
|
+
|
|
831
|
+
describe('setTrialTitle', () => {
|
|
832
|
+
it('holds a title and returns to the instrument name on null', () => {
|
|
833
|
+
const s = makeStore();
|
|
834
|
+
s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
835
|
+
s.getState().setTrialTitle('w1', 'Sprocket 7');
|
|
836
|
+
expect(s.getState().trials[0]?.title).toBe('Sprocket 7');
|
|
837
|
+
s.getState().setTrialTitle('w1', null);
|
|
838
|
+
expect(s.getState().trials[0]?.title).toBeUndefined();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('leaves the record alone when the title is unchanged', () => {
|
|
842
|
+
const s = makeStore();
|
|
843
|
+
s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
844
|
+
s.getState().setTrialTitle('w1', 'Sprocket 7');
|
|
845
|
+
const before = s.getState().trials[0];
|
|
846
|
+
s.getState().setTrialTitle('w1', 'Sprocket 7');
|
|
847
|
+
expect(s.getState().trials[0]).toBe(before);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('round-trips through storage', () => {
|
|
851
|
+
vi.useFakeTimers();
|
|
852
|
+
const storage = createMemoryAdapter();
|
|
853
|
+
const first = makeStore({ storage });
|
|
854
|
+
first.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
855
|
+
first.getState().setTrialTitle('w1', 'Sprocket 7');
|
|
856
|
+
vi.advanceTimersByTime(400);
|
|
857
|
+
vi.useRealTimers();
|
|
858
|
+
|
|
859
|
+
expect(makeStore({ storage }).getState().trials[0]?.title).toBe('Sprocket 7');
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
describe('setTrialSectionCollapsed', () => {
|
|
864
|
+
it('folds one section without touching the others', () => {
|
|
865
|
+
const s = makeStore();
|
|
866
|
+
s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
867
|
+
s.getState().setTrialSectionCollapsed('w1', 'settings', true);
|
|
868
|
+
s.getState().setTrialSectionCollapsed('w1', 'marks', false);
|
|
869
|
+
expect(s.getState().trials[0]?.collapsedSections).toEqual({ settings: true, marks: false });
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('leaves the record alone when the fold is unchanged', () => {
|
|
873
|
+
const s = makeStore();
|
|
874
|
+
s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
875
|
+
s.getState().setTrialSectionCollapsed('w1', 'settings', true);
|
|
876
|
+
const before = s.getState().trials[0];
|
|
877
|
+
s.getState().setTrialSectionCollapsed('w1', 'settings', true);
|
|
878
|
+
expect(s.getState().trials[0]).toBe(before);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('round-trips through storage', () => {
|
|
882
|
+
vi.useFakeTimers();
|
|
883
|
+
const storage = createMemoryAdapter();
|
|
884
|
+
const first = makeStore({ storage });
|
|
885
|
+
first.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
886
|
+
first.getState().setTrialSectionCollapsed('w1', 'settings/Shape', true);
|
|
887
|
+
vi.advanceTimersByTime(400);
|
|
888
|
+
vi.useRealTimers();
|
|
889
|
+
|
|
890
|
+
expect(makeStore({ storage }).getState().trials[0]?.collapsedSections).toEqual({
|
|
891
|
+
'settings/Shape': true,
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it('hydrates a version-3 trial, which carries neither field', () => {
|
|
896
|
+
const storage = createMemoryAdapter();
|
|
897
|
+
writeDocument(storage, {
|
|
898
|
+
trials: [{ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW }],
|
|
899
|
+
saves: [],
|
|
900
|
+
layout: {},
|
|
901
|
+
mode: 'auto',
|
|
902
|
+
});
|
|
903
|
+
const trial = makeStore({ storage }).getState().trials[0];
|
|
904
|
+
expect(trial?.title).toBeUndefined();
|
|
905
|
+
expect(trial?.collapsedSections).toBeUndefined();
|
|
906
|
+
});
|
|
907
|
+
});
|
package/src/state/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createStore, type StoreApi } from 'zustand/vanilla';
|
|
2
|
+
import { fillConfigDefaults, withValueAtPath } from '../config/path';
|
|
2
3
|
import {
|
|
3
4
|
CURRENT_DOCUMENT_VERSION,
|
|
4
5
|
deleteLegacyKeys,
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
import { deserializeTrials, emptyUndoStack, serializeTrials } from './helpers';
|
|
14
15
|
import type {
|
|
15
16
|
CreateLabStoreOptions,
|
|
16
|
-
InstrumentSerializers,
|
|
17
17
|
LabDocument,
|
|
18
18
|
LabMode,
|
|
19
19
|
LabStoreState,
|
|
@@ -32,9 +32,15 @@ export interface LabStoreActions {
|
|
|
32
32
|
addTrial: (record: Omit<TrialRecord, 'undoStack'>) => void;
|
|
33
33
|
removeTrial: (id: string) => void;
|
|
34
34
|
updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
|
|
35
|
-
|
|
35
|
+
/** Write one config value. `path` is dotted for a value nested under an
|
|
36
|
+
* `f.group`, and the bare key for one at the root. */
|
|
37
|
+
updateTrialConfig: (id: string, path: string, value: unknown) => void;
|
|
36
38
|
updateTrialView: (id: string, view: unknown) => void;
|
|
37
39
|
updateTrialSidebarWidth: (id: string, width: number) => void;
|
|
40
|
+
/** Retitle a trial. `null` returns it to its instrument's name. */
|
|
41
|
+
setTrialTitle: (id: string, title: string | null) => void;
|
|
42
|
+
/** Fold or unfold one of a trial's sections. */
|
|
43
|
+
setTrialSectionCollapsed: (id: string, key: string, collapsed: boolean) => void;
|
|
38
44
|
updateTrialAnnotations: (id: string, doc: unknown) => void;
|
|
39
45
|
updateTrialUndoStack: (
|
|
40
46
|
id: string,
|
|
@@ -55,16 +61,14 @@ export interface LabStoreActions {
|
|
|
55
61
|
dockPanel: (trialId: string, sectionId?: string) => void;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
/** A lab's store: its state and actions
|
|
59
|
-
*
|
|
60
|
-
export type LabStore = StoreApi<LabStoreState & LabStoreActions
|
|
61
|
-
registerSerializers: (s: InstrumentSerializers) => void;
|
|
62
|
-
};
|
|
64
|
+
/** A lab's store: its state and its actions. How each instrument's state is
|
|
65
|
+
* serialized comes in through `CreateLabStoreOptions.serializers`. */
|
|
66
|
+
export type LabStore = StoreApi<LabStoreState & LabStoreActions>;
|
|
63
67
|
|
|
64
68
|
/** Build a lab store, hydrating from storage if anything was saved under the
|
|
65
69
|
* same key. Writes back are debounced. */
|
|
66
70
|
export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
67
|
-
|
|
71
|
+
const serializers = options.serializers ?? {};
|
|
68
72
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
69
73
|
|
|
70
74
|
const hydration = hydrateDocument(options);
|
|
@@ -73,8 +77,20 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
73
77
|
// Cleared once the legacy keys are actually gone; see the flush.
|
|
74
78
|
let foldedFromLegacy = hydration.foldedFromLegacy;
|
|
75
79
|
|
|
76
|
-
const
|
|
77
|
-
const
|
|
80
|
+
const configDefaults = options.configDefaults ?? {};
|
|
81
|
+
const filled = (instrumentName: string, config: unknown): unknown => {
|
|
82
|
+
const defaults = configDefaults[instrumentName];
|
|
83
|
+
return defaults ? fillConfigDefaults(config, defaults()) : config;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const hydratedTrials = deserializeTrials(
|
|
87
|
+
hydrated.trials.map((t) => ({ ...t, config: filled(t.instrumentName, t.config) })),
|
|
88
|
+
serializers,
|
|
89
|
+
);
|
|
90
|
+
const hydratedSnapshots = hydrated.saves.map((sn) => ({
|
|
91
|
+
...sn,
|
|
92
|
+
config: filled(sn.instrumentName, sn.config),
|
|
93
|
+
}));
|
|
78
94
|
const hydratedLayout = hydrated.layout;
|
|
79
95
|
const hydratedMode = hydrated.mode;
|
|
80
96
|
|
|
@@ -117,15 +133,11 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
117
133
|
scheduleFlush();
|
|
118
134
|
},
|
|
119
135
|
|
|
120
|
-
updateTrialConfig: (id,
|
|
136
|
+
updateTrialConfig: (id, path, value) => {
|
|
121
137
|
set((s) => ({
|
|
122
|
-
trials: s.trials.map((w) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
...w,
|
|
126
|
-
config: { ...(w.config as Record<string, unknown>), [key as string]: value },
|
|
127
|
-
};
|
|
128
|
-
}),
|
|
138
|
+
trials: s.trials.map((w) =>
|
|
139
|
+
w.id === id ? { ...w, config: withValueAtPath(w.config, path, value) } : w,
|
|
140
|
+
),
|
|
129
141
|
}));
|
|
130
142
|
scheduleFlush();
|
|
131
143
|
},
|
|
@@ -146,6 +158,29 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
146
158
|
scheduleFlush();
|
|
147
159
|
},
|
|
148
160
|
|
|
161
|
+
setTrialTitle: (id, title) => {
|
|
162
|
+
set((s) => ({
|
|
163
|
+
trials: s.trials.map((w) => {
|
|
164
|
+
if (w.id !== id) return w;
|
|
165
|
+
const next = title === null ? undefined : title;
|
|
166
|
+
if ((w.title ?? undefined) === next) return w;
|
|
167
|
+
return { ...w, title: next };
|
|
168
|
+
}),
|
|
169
|
+
}));
|
|
170
|
+
scheduleFlush();
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
setTrialSectionCollapsed: (id, key, collapsed) => {
|
|
174
|
+
set((s) => ({
|
|
175
|
+
trials: s.trials.map((w) => {
|
|
176
|
+
if (w.id !== id) return w;
|
|
177
|
+
if (w.collapsedSections?.[key] === collapsed) return w;
|
|
178
|
+
return { ...w, collapsedSections: { ...w.collapsedSections, [key]: collapsed } };
|
|
179
|
+
}),
|
|
180
|
+
}));
|
|
181
|
+
scheduleFlush();
|
|
182
|
+
},
|
|
183
|
+
|
|
149
184
|
updateTrialAnnotations: (id, doc) => {
|
|
150
185
|
set((s) => ({
|
|
151
186
|
trials: s.trials.map((w) =>
|
|
@@ -210,7 +245,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
210
245
|
return;
|
|
211
246
|
}
|
|
212
247
|
const reg = serializers[snapshot.instrumentName];
|
|
213
|
-
const restoredState = reg?.deserialize
|
|
248
|
+
const restoredState = reg?.deserialize
|
|
249
|
+
? reg.deserialize(snapshot.state, snapshot.config)
|
|
250
|
+
: snapshot.state;
|
|
214
251
|
set((s) => ({
|
|
215
252
|
trials: s.trials.map((w) =>
|
|
216
253
|
w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
|
|
@@ -286,11 +323,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
286
323
|
// fold; the flush is what removes the legacy keys.
|
|
287
324
|
if (foldedFromLegacy) scheduleFlush();
|
|
288
325
|
|
|
289
|
-
return
|
|
290
|
-
registerSerializers(s: InstrumentSerializers) {
|
|
291
|
-
serializers = s;
|
|
292
|
-
},
|
|
293
|
-
});
|
|
326
|
+
return store;
|
|
294
327
|
}
|
|
295
328
|
|
|
296
329
|
interface HydrateResult {
|
package/src/state/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConfigPath, ValueAtPath } from '../config/types';
|
|
1
2
|
import type { UndockedPanels } from './undock';
|
|
2
3
|
/** A trial's undo history, as snapshots of its state either side of the
|
|
3
4
|
* present. */
|
|
@@ -6,6 +7,15 @@ export interface UndoStack {
|
|
|
6
7
|
future: unknown[];
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
/** The trial a per-trial call is coming from. `id` is the id `useTileId`
|
|
11
|
+
* scopes a surface tile under, so a consumer keying its own per-trial
|
|
12
|
+
* registry and labkit's tiles agree on the key. */
|
|
13
|
+
export interface TrialInfo<TV = unknown> {
|
|
14
|
+
id: string;
|
|
15
|
+
/** The trial's view, in whatever shape the instrument keeps it. */
|
|
16
|
+
view: TV;
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
/** One trial as the store holds it: which instrument it runs, that
|
|
10
20
|
* instrument's config and state, the camera, and the undo history. */
|
|
11
21
|
export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
|
|
@@ -16,11 +26,22 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
|
|
|
16
26
|
/** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
|
|
17
27
|
* but never read into. A 3D lab puts an orbit here and keeps all three. */
|
|
18
28
|
view: TV;
|
|
29
|
+
/** The config `addTrial` opened this trial on, overlaid on the instrument's
|
|
30
|
+
* defaults. Kept so Reset restores the trial's own subject rather than the
|
|
31
|
+
* bare defaults. */
|
|
32
|
+
configSeed?: Partial<TC>;
|
|
19
33
|
/** This trial's own tool slot. Undefined means it reads the lab's. */
|
|
20
34
|
activeToolId?: string | null;
|
|
21
35
|
/** The extent the trial's sidebar was last dragged to, in pixels. Undefined
|
|
22
36
|
* until someone moves the seam. */
|
|
23
37
|
sidebarWidth?: number;
|
|
38
|
+
/** What the title bar reads. Undefined — the state a trial opens in, and the
|
|
39
|
+
* one `setTitle(null)` returns it to — means the instrument's name. */
|
|
40
|
+
title?: string | null;
|
|
41
|
+
/** Which of the trial's collapsible sections are folded, keyed as
|
|
42
|
+
* `TrialChromeContext.collapsedSections` describes. A key that is absent
|
|
43
|
+
* takes the section's own default. */
|
|
44
|
+
collapsedSections?: Record<string, boolean>;
|
|
24
45
|
/** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
|
|
25
46
|
* wrote them. Opaque here, and absent for a trial whose instrument declares
|
|
26
47
|
* no `annotations` — or declares its own `storage`. Not in `state`, which
|
|
@@ -75,7 +96,9 @@ export interface TrialStateHandle<TS, TC> {
|
|
|
75
96
|
state: TS;
|
|
76
97
|
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
77
98
|
config: TC;
|
|
78
|
-
|
|
99
|
+
/** Write one config value, by dotted path — `'grid.size'` for a leaf under
|
|
100
|
+
* an `f.group`, `'cellSize'` for one at the root. */
|
|
101
|
+
setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P>) => void;
|
|
79
102
|
}
|
|
80
103
|
|
|
81
104
|
/** Options for `createLabStore`. `storageKey` namespaces the keys written, so
|
|
@@ -84,13 +107,31 @@ export interface CreateLabStoreOptions {
|
|
|
84
107
|
storageKey: string;
|
|
85
108
|
storage: StorageAdapter;
|
|
86
109
|
initialMode?: LabMode;
|
|
110
|
+
/** Each instrument's default config, keyed by instrument name, used to fill
|
|
111
|
+
* the gaps in a stored one. A config saved before its schema grew a branch
|
|
112
|
+
* arrives holding that branch's defaults rather than `undefined`, and keeps
|
|
113
|
+
* whatever keys the schema has since stopped naming. `<Lab>` collects these
|
|
114
|
+
* off its `instruments`. */
|
|
115
|
+
configDefaults?: Record<string, () => unknown>;
|
|
116
|
+
/** How each instrument's state survives a reload. Hydration is the first
|
|
117
|
+
* thing `createLabStore` does, so these have to arrive with the store —
|
|
118
|
+
* anything registered afterwards is already too late to read the document
|
|
119
|
+
* it was built from. `<Lab>` collects them off its `instruments`. */
|
|
120
|
+
serializers?: InstrumentSerializers;
|
|
87
121
|
}
|
|
88
122
|
|
|
89
123
|
/** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
|
|
90
|
-
* instrument whose state is already JSON-safe needs no entry.
|
|
124
|
+
* instrument whose state is already JSON-safe needs no entry. `deserialize`
|
|
125
|
+
* is handed the config the state was saved against — a trial's own for a
|
|
126
|
+
* reload, the snapshot's for a load — since a state rebuilt without it can
|
|
127
|
+
* disagree with the settings sitting next to it. */
|
|
91
128
|
export type InstrumentSerializers = Record<
|
|
92
129
|
string,
|
|
93
|
-
|
|
130
|
+
| {
|
|
131
|
+
serialize?: (state: unknown) => unknown;
|
|
132
|
+
deserialize?: (data: unknown, config: unknown) => unknown;
|
|
133
|
+
}
|
|
134
|
+
| undefined
|
|
94
135
|
>;
|
|
95
136
|
|
|
96
137
|
/** A trial as it is persisted: everything but the undo history, which is
|
|
@@ -24,6 +24,6 @@ export function useTrialState<TS = unknown, TC = unknown>(): TrialStateHandle<TS
|
|
|
24
24
|
state: record.state as TS,
|
|
25
25
|
config: record.config as TC,
|
|
26
26
|
setState: (next) => updateTrialState(trialId, next as Parameters<typeof updateTrialState>[1]),
|
|
27
|
-
setConfig: (
|
|
27
|
+
setConfig: (path, value) => updateTrialConfig(trialId, path, value),
|
|
28
28
|
};
|
|
29
29
|
}
|
|
@@ -106,3 +106,47 @@ describe('lab-wide config seams', () => {
|
|
|
106
106
|
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
describe('a nested config value, end to end', () => {
|
|
111
|
+
const nestedInstrument = () => {
|
|
112
|
+
const seen: { config: unknown; prev: unknown }[] = [];
|
|
113
|
+
const instrument = defineInstrument({
|
|
114
|
+
name: 'Nested',
|
|
115
|
+
config: f.schema({
|
|
116
|
+
showGrid: f.boolean(true),
|
|
117
|
+
grid: f.group({ size: f.number(20).label('Cell size'), color: f.color('#ffffff') }),
|
|
118
|
+
}),
|
|
119
|
+
initialState: () => ({}),
|
|
120
|
+
onConfigChange: (config, prev, state) => {
|
|
121
|
+
seen.push({ config, prev });
|
|
122
|
+
return state;
|
|
123
|
+
},
|
|
124
|
+
render: (ctx) => (
|
|
125
|
+
<output>{String((ctx.config as { grid: { size: number } }).grid.size)}</output>
|
|
126
|
+
),
|
|
127
|
+
});
|
|
128
|
+
return { instrument, seen };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
it('renders a nested leaf and writes the change back into the tree', () => {
|
|
132
|
+
const { instrument } = nestedInstrument();
|
|
133
|
+
render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
|
|
134
|
+
expect(screen.getByText('20')).toBeInTheDocument();
|
|
135
|
+
fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
|
|
136
|
+
expect(screen.getByText('40')).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('hands onConfigChange the whole tree, with the sibling branch intact', () => {
|
|
140
|
+
const { instrument, seen } = nestedInstrument();
|
|
141
|
+
render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
|
|
142
|
+
fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
|
|
143
|
+
expect(seen.at(-1)?.config).toEqual({
|
|
144
|
+
showGrid: true,
|
|
145
|
+
grid: { size: 40, color: '#ffffff' },
|
|
146
|
+
});
|
|
147
|
+
expect(seen.at(-1)?.prev).toEqual({
|
|
148
|
+
showGrid: true,
|
|
149
|
+
grid: { size: 20, color: '#ffffff' },
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
package/src/trial/Trial.less
CHANGED