@weasel-js/labkit 1.4.3 → 1.5.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/README.md +13 -2
- package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
- package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
- package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
- package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
- package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
- package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
- package/dist/canvas/index.d.ts +17 -128
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +4 -92
- package/dist/chrome/index.js +9 -5
- package/dist/chunk-67SJLMC7.js +704 -0
- package/dist/chunk-67SJLMC7.js.map +1 -0
- package/dist/chunk-6ZDGOZQV.js +310 -0
- package/dist/chunk-6ZDGOZQV.js.map +1 -0
- package/dist/{chunk-QAGAHJKZ.js → chunk-EKIICY6X.js} +111 -25
- package/dist/chunk-EKIICY6X.js.map +1 -0
- package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
- package/dist/chunk-H6ZAOWNE.js.map +1 -0
- package/dist/{chunk-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
- package/dist/chunk-KJALCDWE.js.map +1 -0
- package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
- package/dist/{chunk-ISCOFAUO.js.map → chunk-MUKOW3TC.js.map} +1 -1
- package/dist/chunk-NDRYLVOW.js +15 -0
- package/dist/chunk-NDRYLVOW.js.map +1 -0
- package/dist/{chunk-C42T6DJT.js → chunk-NS64DXMN.js} +226 -159
- package/dist/chunk-NS64DXMN.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
- package/dist/chunk-PGETSEDK.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
- package/dist/chunk-QLSV2N3G.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-S2SHZRD7.js +7681 -0
- package/dist/chunk-S2SHZRD7.js.map +1 -0
- package/dist/{chunk-ASWKLRKJ.js → chunk-TQBBYTAF.js} +19 -82
- package/dist/chunk-TQBBYTAF.js.map +1 -0
- package/dist/chunk-UU3NJO6N.js +72 -0
- package/dist/chunk-UU3NJO6N.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
- package/dist/chunk-ZPH5WTWF.js.map +1 -0
- package/dist/config/index.d.ts +232 -0
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +2 -3
- package/dist/controls/index.js +3 -2
- package/dist/dragdrop/index.d.ts +2 -4
- package/dist/index.d.ts +123 -348
- package/dist/index.js +794 -837
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +3 -5
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +6 -8
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +169 -117
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +53 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +12 -7
- package/dist/state/index.js +16 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +163 -62
- package/dist/surface/index.d.ts +44 -7
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +2 -4
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +53 -5
- package/src/annotations/Annotations.overlay.test.tsx +85 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/frac.test.ts +4 -4
- package/src/annotations/frac.ts +3 -2
- package/src/annotations/index.ts +1 -1
- package/src/annotations/paint.test.ts +2 -2
- package/src/annotations/paint.ts +1 -1
- package/src/annotations/preload.ts +6 -0
- package/src/annotations/store.test.ts +18 -0
- package/src/annotations/store.ts +7 -6
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -2
- package/src/annotations/toolMap.ts +3 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.test.tsx +25 -0
- package/src/canvas/CanvasStack.tsx +31 -4
- package/src/canvas/Stage.less +18 -0
- package/src/canvas/Stage.test.tsx +186 -0
- package/src/canvas/Stage.tsx +134 -0
- package/src/canvas/camera.test.ts +17 -2
- package/src/canvas/camera.ts +9 -9
- package/src/canvas/index.ts +4 -0
- package/src/canvas/usePanZoom.ts +10 -5
- package/src/chrome/ChromeRegions.stories.tsx +0 -3
- package/src/chrome/LabChrome.tsx +114 -0
- package/src/chrome/builtins.tsx +20 -31
- package/src/chrome/index.ts +15 -0
- package/src/chrome/labRegions.test.tsx +208 -0
- package/src/chrome/labTypes.ts +50 -0
- package/src/chrome/merge.ts +7 -9
- package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
- package/src/chrome/regions/PaletteRegion.tsx +21 -9
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +30 -16
- package/src/chrome/types.ts +47 -20
- package/src/config/builder.test.ts +4 -0
- package/src/config/builder.ts +16 -10
- package/src/config/declarationEmit.fixture.ts +11 -0
- package/src/config/declarationEmit.test.ts +51 -0
- package/src/config/entry.test.ts +46 -0
- package/src/config/index.ts +13 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/types.ts +3 -1
- package/src/controls/ControlPanel.test.tsx +16 -0
- package/src/controls/ControlPanel.tsx +5 -2
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +39 -6
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +11 -0
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +0 -2
- package/src/lab/Lab.surface.test.tsx +66 -3
- package/src/lab/Lab.test.tsx +91 -17
- package/src/lab/Lab.tsx +337 -124
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +0 -3
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -13
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabShell.less +49 -12
- package/src/lab/LabShell.tsx +14 -2
- package/src/lab/LabSwitcher.less +92 -0
- package/src/lab/LabSwitcher.test.tsx +96 -0
- package/src/lab/LabSwitcher.tsx +112 -0
- package/src/lab/Workspace.surface.test.tsx +2 -1
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/lab/index.ts +2 -0
- package/src/loupe/AGENTS.md +1 -1
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +1 -1
- package/src/loupe/useLoupe.ts +1 -1
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +13 -1
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +108 -0
- package/src/primitives/Split.tsx +159 -0
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- package/src/state/Persistence.stories.tsx +75 -0
- package/src/state/Persistence.tsx +33 -0
- package/src/state/SingletonExperiment.test.tsx +47 -53
- package/src/state/SingletonExperiment.tsx +24 -13
- package/src/state/adapterContract.ts +116 -0
- package/src/state/adapters.test.ts +113 -61
- package/src/state/adapters.ts +328 -69
- package/src/state/document.test.ts +71 -96
- package/src/state/document.ts +66 -52
- package/src/state/helpers.test.ts +20 -0
- package/src/state/helpers.ts +16 -0
- package/src/state/index.ts +8 -0
- package/src/state/labRecords.test.ts +105 -0
- package/src/state/labRecords.ts +160 -0
- package/src/state/openLabStore.test.ts +658 -0
- package/src/state/openLabStore.ts +403 -0
- package/src/state/records.test.ts +234 -0
- package/src/state/records.ts +219 -0
- package/src/state/store.test.ts +104 -635
- package/src/state/store.ts +117 -158
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +39 -15
- package/src/state/useOpenOnce.ts +68 -0
- package/src/state/usePersistedState.test.tsx +125 -0
- package/src/state/usePersistedState.ts +66 -0
- package/src/state/useTrialState.test.tsx +2 -3
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +3 -0
- package/src/surface/AGENTS.md +39 -7
- package/src/surface/SurfaceContext.ts +26 -3
- package/src/surface/index.ts +2 -0
- package/src/surface/useSurfaceTile.test.tsx +2 -1
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +175 -0
- package/src/surface/useTiledSurface.ts +54 -5
- package/src/theme/Interstellar.stories.tsx +7 -7
- package/src/theme/base.less +4 -4
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.tokens.json +2 -2
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +47 -4
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +1 -26
- package/src/trial/Trial.stories.tsx +1 -2
- package/src/trial/Trial.test.tsx +28 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +118 -38
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +7 -4
- package/src/trial/trialOps.ts +7 -4
- package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
- package/dist/chunk-2RQPGOQF.js.map +0 -1
- package/dist/chunk-5TROQVQ2.js +0 -547
- package/dist/chunk-5TROQVQ2.js.map +0 -1
- package/dist/chunk-ASWKLRKJ.js.map +0 -1
- package/dist/chunk-C42T6DJT.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MHAGC6VD.js +0 -7320
- package/dist/chunk-MHAGC6VD.js.map +0 -1
- package/dist/chunk-QAGAHJKZ.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
package/src/state/store.ts
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
import { createStore, type StoreApi } from 'zustand/vanilla';
|
|
2
2
|
import { fillConfigDefaults, withValueAtPath } from '../config/path';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
labDocumentKey,
|
|
8
|
-
MIGRATIONS,
|
|
9
|
-
normalizeDocument,
|
|
10
|
-
quarantineDocument,
|
|
11
|
-
readLegacyDocument,
|
|
12
|
-
runMigrations,
|
|
13
|
-
} from './document';
|
|
14
|
-
import { deserializeTrials, emptyUndoStack, serializeTrials } from './helpers';
|
|
3
|
+
import { configDefaultsOf, configMigrationsOf, serializersOf } from '../instrument/serializers';
|
|
4
|
+
import type { InstrumentList } from '../instrument/types';
|
|
5
|
+
import { emptyDocument } from './document';
|
|
6
|
+
import { deserializeTrials, emptyUndoStack, newId } from './helpers';
|
|
15
7
|
import type {
|
|
16
8
|
CreateLabStoreOptions,
|
|
17
|
-
|
|
9
|
+
InstrumentHooks,
|
|
10
|
+
InstrumentSerializers,
|
|
18
11
|
LabMode,
|
|
19
12
|
LabStoreState,
|
|
20
13
|
SavedSnapshot,
|
|
14
|
+
SerializedTrial,
|
|
21
15
|
TrialRecord,
|
|
22
16
|
} from './types';
|
|
23
17
|
import {
|
|
@@ -59,54 +53,44 @@ export interface LabStoreActions {
|
|
|
59
53
|
undockPanel: (trialId: string, sectionId: string, as?: UndockedPanel['as']) => void;
|
|
60
54
|
/** Put one section back, or — with no `sectionId` — every panel the trial owns. */
|
|
61
55
|
dockPanel: (trialId: string, sectionId?: string) => void;
|
|
56
|
+
/** Replace the lab's instruments. Trials and saves of an instrument that is
|
|
57
|
+
* new or a different object are brought up to date against it in the same
|
|
58
|
+
* write; every other trial keeps its object. */
|
|
59
|
+
setInstruments: (instruments: InstrumentList) => void;
|
|
60
|
+
/** The per-instrument hooks in force now. Read them at use; never keep them. */
|
|
61
|
+
instrumentHooks: () => InstrumentHooks;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** A lab's store: its state and its actions. How each instrument's state is
|
|
65
|
-
* serialized comes in through `CreateLabStoreOptions.
|
|
65
|
+
* serialized comes in through `CreateLabStoreOptions.instruments`. */
|
|
66
66
|
export type LabStore = StoreApi<LabStoreState & LabStoreActions>;
|
|
67
67
|
|
|
68
|
-
/** Build a lab store
|
|
69
|
-
*
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
}));
|
|
94
|
-
const hydratedLayout = hydrated.layout;
|
|
95
|
-
const hydratedMode = hydrated.mode;
|
|
68
|
+
/** Build a lab store holding `initial`, or an empty lab. It knows nothing
|
|
69
|
+
* about storage; `openLabStore` reads a stored lab and binds one of these to
|
|
70
|
+
* it. */
|
|
71
|
+
export function createLabStore(options: CreateLabStoreOptions = {}): LabStore {
|
|
72
|
+
let hooks: InstrumentHooks = options.instruments
|
|
73
|
+
? hooksOf(options.instruments)
|
|
74
|
+
: {
|
|
75
|
+
serializers: options.serializers ?? {},
|
|
76
|
+
configDefaults: options.configDefaults ?? {},
|
|
77
|
+
configMigrations: options.configMigrations ?? {},
|
|
78
|
+
};
|
|
79
|
+
const initial = options.initial ?? emptyDocument(options.initialMode ?? 'auto');
|
|
96
80
|
|
|
97
81
|
const store = createStore<LabStoreState & LabStoreActions>()((set, get) => ({
|
|
98
|
-
trials:
|
|
99
|
-
savedSnapshots:
|
|
100
|
-
mode:
|
|
101
|
-
layout:
|
|
102
|
-
undockedPanels:
|
|
82
|
+
trials: hydrateTrials(initial.trials, hooks.serializers, hooks),
|
|
83
|
+
savedSnapshots: hydrateSnapshots(initial.saves, hooks),
|
|
84
|
+
mode: initial.mode,
|
|
85
|
+
layout: initial.layout,
|
|
86
|
+
undockedPanels: initial.undockedPanels,
|
|
103
87
|
activeToolId: null,
|
|
88
|
+
instruments: options.instruments ?? null,
|
|
104
89
|
|
|
105
90
|
addTrial: (record) => {
|
|
106
91
|
set((s) => ({
|
|
107
92
|
trials: [...s.trials, { ...record, undoStack: emptyUndoStack() }],
|
|
108
93
|
}));
|
|
109
|
-
scheduleFlush();
|
|
110
94
|
},
|
|
111
95
|
|
|
112
96
|
removeTrial: (id) => {
|
|
@@ -114,7 +98,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
114
98
|
trials: s.trials.filter((w) => w.id !== id),
|
|
115
99
|
undockedPanels: dockPanelIn(s.undockedPanels, id),
|
|
116
100
|
}));
|
|
117
|
-
scheduleFlush();
|
|
118
101
|
},
|
|
119
102
|
|
|
120
103
|
updateTrialState: (id, next) => {
|
|
@@ -130,7 +113,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
130
113
|
return { ...w, state: nextState };
|
|
131
114
|
}),
|
|
132
115
|
}));
|
|
133
|
-
scheduleFlush();
|
|
134
116
|
},
|
|
135
117
|
|
|
136
118
|
updateTrialConfig: (id, path, value) => {
|
|
@@ -139,14 +121,12 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
139
121
|
w.id === id ? { ...w, config: withValueAtPath(w.config, path, value) } : w,
|
|
140
122
|
),
|
|
141
123
|
}));
|
|
142
|
-
scheduleFlush();
|
|
143
124
|
},
|
|
144
125
|
|
|
145
126
|
updateTrialView: (id, view) => {
|
|
146
127
|
set((s) => ({
|
|
147
128
|
trials: s.trials.map((w) => (w.id === id && !Object.is(view, w.view) ? { ...w, view } : w)),
|
|
148
129
|
}));
|
|
149
|
-
scheduleFlush();
|
|
150
130
|
},
|
|
151
131
|
|
|
152
132
|
updateTrialSidebarWidth: (id, width) => {
|
|
@@ -155,7 +135,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
155
135
|
w.id === id && w.sidebarWidth !== width ? { ...w, sidebarWidth: width } : w,
|
|
156
136
|
),
|
|
157
137
|
}));
|
|
158
|
-
scheduleFlush();
|
|
159
138
|
},
|
|
160
139
|
|
|
161
140
|
setTrialTitle: (id, title) => {
|
|
@@ -167,7 +146,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
167
146
|
return { ...w, title: next };
|
|
168
147
|
}),
|
|
169
148
|
}));
|
|
170
|
-
scheduleFlush();
|
|
171
149
|
},
|
|
172
150
|
|
|
173
151
|
setTrialSectionCollapsed: (id, key, collapsed) => {
|
|
@@ -178,7 +156,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
178
156
|
return { ...w, collapsedSections: { ...w.collapsedSections, [key]: collapsed } };
|
|
179
157
|
}),
|
|
180
158
|
}));
|
|
181
|
-
scheduleFlush();
|
|
182
159
|
},
|
|
183
160
|
|
|
184
161
|
updateTrialAnnotations: (id, doc) => {
|
|
@@ -187,7 +164,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
187
164
|
w.id === id && !Object.is(doc, w.annotations) ? { ...w, annotations: doc } : w,
|
|
188
165
|
),
|
|
189
166
|
}));
|
|
190
|
-
scheduleFlush();
|
|
191
167
|
},
|
|
192
168
|
|
|
193
169
|
updateTrialUndoStack: (id, next) => {
|
|
@@ -207,13 +183,12 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
207
183
|
set((s) => ({
|
|
208
184
|
trials: s.trials.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
|
|
209
185
|
}));
|
|
210
|
-
scheduleFlush();
|
|
211
186
|
},
|
|
212
187
|
|
|
213
188
|
saveSnapshot: (trialId, name) => {
|
|
214
189
|
const trial = get().trials.find((w) => w.id === trialId);
|
|
215
190
|
if (!trial) return;
|
|
216
|
-
const reg = serializers[trial.instrumentName];
|
|
191
|
+
const reg = hooks.serializers[trial.instrumentName];
|
|
217
192
|
const serializedState = reg?.serialize
|
|
218
193
|
? reg.serialize(trial.state)
|
|
219
194
|
: structuredClone(trial.state);
|
|
@@ -221,7 +196,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
221
196
|
const lastAt = get().savedSnapshots.reduce((m, sn) => (sn.savedAt > m ? sn.savedAt : m), 0);
|
|
222
197
|
const savedAt = Math.max(Date.now(), lastAt + 1);
|
|
223
198
|
const snapshot: SavedSnapshot = {
|
|
224
|
-
id:
|
|
199
|
+
id: newId(),
|
|
225
200
|
name,
|
|
226
201
|
trialId,
|
|
227
202
|
instrumentName: trial.instrumentName,
|
|
@@ -230,7 +205,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
230
205
|
savedAt,
|
|
231
206
|
};
|
|
232
207
|
set((s) => ({ savedSnapshots: [...s.savedSnapshots, snapshot] }));
|
|
233
|
-
scheduleFlush();
|
|
234
208
|
},
|
|
235
209
|
|
|
236
210
|
loadSnapshot: (snapshotId, trialId) => {
|
|
@@ -244,7 +218,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
244
218
|
);
|
|
245
219
|
return;
|
|
246
220
|
}
|
|
247
|
-
const reg = serializers[snapshot.instrumentName];
|
|
221
|
+
const reg = hooks.serializers[snapshot.instrumentName];
|
|
248
222
|
const restoredState = reg?.deserialize
|
|
249
223
|
? reg.deserialize(snapshot.state, snapshot.config)
|
|
250
224
|
: snapshot.state;
|
|
@@ -253,14 +227,12 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
253
227
|
w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
|
|
254
228
|
),
|
|
255
229
|
}));
|
|
256
|
-
scheduleFlush();
|
|
257
230
|
},
|
|
258
231
|
|
|
259
232
|
deleteSnapshot: (snapshotId) => {
|
|
260
233
|
set((s) => ({
|
|
261
234
|
savedSnapshots: s.savedSnapshots.filter((sn) => sn.id !== snapshotId),
|
|
262
235
|
}));
|
|
263
|
-
scheduleFlush();
|
|
264
236
|
},
|
|
265
237
|
|
|
266
238
|
listSnapshots: (trialId) => {
|
|
@@ -271,7 +243,6 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
271
243
|
|
|
272
244
|
setMode: (mode) => {
|
|
273
245
|
set({ mode });
|
|
274
|
-
scheduleFlush();
|
|
275
246
|
},
|
|
276
247
|
|
|
277
248
|
setLabTool: (id) => set({ activeToolId: id }),
|
|
@@ -283,119 +254,107 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
283
254
|
|
|
284
255
|
undockPanel: (trialId, sectionId, as) => {
|
|
285
256
|
set((s) => ({ undockedPanels: undockPanelIn(s.undockedPanels, trialId, sectionId, as) }));
|
|
286
|
-
scheduleFlush();
|
|
287
257
|
},
|
|
288
258
|
|
|
289
259
|
dockPanel: (trialId, sectionId) => {
|
|
290
260
|
set((s) => ({ undockedPanels: dockPanelIn(s.undockedPanels, trialId, sectionId) }));
|
|
291
|
-
scheduleFlush();
|
|
292
261
|
},
|
|
293
262
|
|
|
294
263
|
setLayout: (layout) => {
|
|
295
264
|
set({ layout });
|
|
296
|
-
scheduleFlush();
|
|
297
265
|
},
|
|
298
|
-
}));
|
|
299
266
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
};
|
|
313
|
-
const serialized = JSON.stringify(document);
|
|
314
|
-
options.storage.write(labDocumentKey(options.storageKey), serialized);
|
|
315
|
-
if (foldedFromLegacy && deleteLegacyKeys(options.storage, options.storageKey, serialized)) {
|
|
316
|
-
foldedFromLegacy = false;
|
|
267
|
+
setInstruments: (instruments) => {
|
|
268
|
+
const before = get().instruments;
|
|
269
|
+
if (before === instruments) return;
|
|
270
|
+
const next = mergedHooks(hooks, instruments);
|
|
271
|
+
const previous = new Map((before ?? []).map((i) => [i.name, i]));
|
|
272
|
+
const changed = new Set(
|
|
273
|
+
instruments.filter((i) => previous.get(i.name) !== i).map((i) => i.name),
|
|
274
|
+
);
|
|
275
|
+
if (changed.size === 0) {
|
|
276
|
+
hooks = next;
|
|
277
|
+
set({ instruments });
|
|
278
|
+
return;
|
|
317
279
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
280
|
+
const s = get();
|
|
281
|
+
const trials = s.trials.map((w) =>
|
|
282
|
+
changed.has(w.instrumentName)
|
|
283
|
+
? { ...w, config: hydratedConfig(next, w.instrumentName, w.config) }
|
|
284
|
+
: w,
|
|
285
|
+
);
|
|
286
|
+
const savedSnapshots = s.savedSnapshots.map((sn) =>
|
|
287
|
+
changed.has(sn.instrumentName)
|
|
288
|
+
? { ...sn, config: hydratedConfig(next, sn.instrumentName, sn.config) }
|
|
289
|
+
: sn,
|
|
290
|
+
);
|
|
291
|
+
hooks = next;
|
|
292
|
+
set({ instruments, trials, savedSnapshots });
|
|
293
|
+
},
|
|
321
294
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (foldedFromLegacy) scheduleFlush();
|
|
295
|
+
instrumentHooks: () => hooks,
|
|
296
|
+
}));
|
|
325
297
|
|
|
326
298
|
return store;
|
|
327
299
|
}
|
|
328
300
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
foldedFromLegacy: boolean;
|
|
301
|
+
function hooksOf(instruments: InstrumentList): InstrumentHooks {
|
|
302
|
+
return {
|
|
303
|
+
serializers: serializersOf(instruments),
|
|
304
|
+
configDefaults: configDefaultsOf(instruments),
|
|
305
|
+
configMigrations: configMigrationsOf(instruments),
|
|
306
|
+
};
|
|
336
307
|
}
|
|
337
308
|
|
|
338
|
-
/**
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
function
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (error === undefined) console.warn(message);
|
|
352
|
-
else console.warn(message, error);
|
|
353
|
-
return quarantined;
|
|
309
|
+
/** Hooks for `instruments`, keeping those of any instrument no longer listed:
|
|
310
|
+
* its trials stay open, and their state still has to round-trip. A listed
|
|
311
|
+
* name takes its hooks wholly from the new list. */
|
|
312
|
+
function mergedHooks(old: InstrumentHooks, instruments: InstrumentList): InstrumentHooks {
|
|
313
|
+
const listed = new Set(instruments.map((i) => i.name));
|
|
314
|
+
const fresh = hooksOf(instruments);
|
|
315
|
+
const unlisted = <T>(entries: Record<string, T>): Record<string, T> =>
|
|
316
|
+
Object.fromEntries(Object.entries(entries).filter(([name]) => !listed.has(name)));
|
|
317
|
+
return {
|
|
318
|
+
serializers: { ...unlisted(old.serializers), ...fresh.serializers },
|
|
319
|
+
configDefaults: { ...unlisted(old.configDefaults), ...fresh.configDefaults },
|
|
320
|
+
configMigrations: { ...unlisted(old.configMigrations), ...fresh.configMigrations },
|
|
321
|
+
};
|
|
354
322
|
}
|
|
355
323
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
parsed = readLegacyDocument(options.storage, options.storageKey, options.initialMode ?? 'auto');
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (parsed === null) {
|
|
373
|
-
return { document: fallback, persistDisabled: false, foldedFromLegacy: false };
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const foldedFromLegacy = raw === null;
|
|
377
|
-
const outcome = runMigrations(parsed, MIGRATIONS, CURRENT_DOCUMENT_VERSION);
|
|
378
|
-
|
|
379
|
-
if (!outcome.ok) {
|
|
380
|
-
if (outcome.reason === 'future') {
|
|
381
|
-
console.warn(
|
|
382
|
-
'[labkit] lab document is from a newer version of labkit; starting empty and leaving it alone',
|
|
383
|
-
);
|
|
384
|
-
return { document: fallback, persistDisabled: true, foldedFromLegacy: false };
|
|
385
|
-
}
|
|
386
|
-
const stored = JSON.stringify(parsed);
|
|
387
|
-
const persistDisabled = !setAside(
|
|
388
|
-
options,
|
|
389
|
-
stored,
|
|
390
|
-
'lab document failed to migrate',
|
|
391
|
-
outcome.error,
|
|
392
|
-
);
|
|
393
|
-
return { document: fallback, persistDisabled, foldedFromLegacy: false };
|
|
394
|
-
}
|
|
324
|
+
/** How a stored config is brought up to date on the way in. */
|
|
325
|
+
export type ConfigHydration = Pick<CreateLabStoreOptions, 'configDefaults' | 'configMigrations'>;
|
|
326
|
+
|
|
327
|
+
function hydratedConfig(
|
|
328
|
+
hydration: ConfigHydration,
|
|
329
|
+
instrumentName: string,
|
|
330
|
+
config: unknown,
|
|
331
|
+
): unknown {
|
|
332
|
+
const migrate = hydration.configMigrations?.[instrumentName];
|
|
333
|
+
const moved = migrate ? migrate(config) : config;
|
|
334
|
+
const defaults = hydration.configDefaults?.[instrumentName];
|
|
335
|
+
return defaults ? fillConfigDefaults(moved, defaults()) : moved;
|
|
336
|
+
}
|
|
395
337
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
338
|
+
/** Rebuild stored trials: migrate each config, fill its gaps from its
|
|
339
|
+
* instrument's defaults, then run the deserializer against the result. */
|
|
340
|
+
export function hydrateTrials(
|
|
341
|
+
trials: SerializedTrial[],
|
|
342
|
+
serializers: InstrumentSerializers,
|
|
343
|
+
hydration: ConfigHydration = {},
|
|
344
|
+
): TrialRecord[] {
|
|
345
|
+
return deserializeTrials(
|
|
346
|
+
trials.map((t) => ({ ...t, config: hydratedConfig(hydration, t.instrumentName, t.config) })),
|
|
347
|
+
serializers,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Bring each stored snapshot's config up to date the way `hydrateTrials` does a trial's. */
|
|
352
|
+
export function hydrateSnapshots(
|
|
353
|
+
saves: SavedSnapshot[],
|
|
354
|
+
hydration: ConfigHydration = {},
|
|
355
|
+
): SavedSnapshot[] {
|
|
356
|
+
return saves.map((sn) => ({
|
|
357
|
+
...sn,
|
|
358
|
+
config: hydratedConfig(hydration, sn.instrumentName, sn.config),
|
|
359
|
+
}));
|
|
401
360
|
}
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { createLabStore } from './store';
|
|
3
3
|
|
|
4
4
|
function store() {
|
|
5
|
-
return createLabStore(
|
|
5
|
+
return createLabStore();
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
const record = { id: 't1', instrumentName: 'X', config: {}, state: {}, view: {} };
|
package/src/state/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConfigPath, ValueAtPath } from '../config/types';
|
|
2
|
+
import type { InstrumentList } from '../instrument/types';
|
|
2
3
|
import type { UndockedPanels } from './undock';
|
|
3
4
|
/** A trial's undo history, as snapshots of its state either side of the
|
|
4
5
|
* present. */
|
|
@@ -79,15 +80,29 @@ export interface LabStoreState {
|
|
|
79
80
|
/** Sidebar sections torn out of their trial. A panel here is not rendered in
|
|
80
81
|
* its trial's sidebar; the workspace renders it instead. */
|
|
81
82
|
undockedPanels: UndockedPanels;
|
|
83
|
+
/** The instruments this store serializes, migrates and fills configs for.
|
|
84
|
+
* Null for a store built without them, whose trials look their instrument
|
|
85
|
+
* up on the lab instead. Not persisted. */
|
|
86
|
+
instruments: InstrumentList | null;
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
|
|
89
|
+
/** A change someone else made to one record: its new value, or `undefined`
|
|
90
|
+
* when it was deleted. */
|
|
91
|
+
export type StorageChange = [key: string, value: unknown];
|
|
92
|
+
|
|
93
|
+
/** Where a lab persists itself: asynchronous keyed storage of
|
|
94
|
+
* structured-clone values, so IndexedDB, the URL, memory or a server can all
|
|
95
|
+
* back one. */
|
|
87
96
|
export interface StorageAdapter {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
/** `undefined` when the key is absent. */
|
|
98
|
+
get(key: string): Promise<unknown>;
|
|
99
|
+
list(prefix: string): Promise<[string, unknown][]>;
|
|
100
|
+
/** Rejects when the value did not land. */
|
|
101
|
+
set(key: string, value: unknown): Promise<void>;
|
|
102
|
+
delete(key: string): Promise<void>;
|
|
103
|
+
/** Reports writes under `prefix` made by anyone but this adapter — another
|
|
104
|
+
* tab, another instance, a server. Omit when the substrate cannot tell. */
|
|
105
|
+
subscribe?(prefix: string, on: (changes: StorageChange[]) => void): () => void;
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
/** What `useTrialState` hands an instrument: its state and config, with
|
|
@@ -101,23 +116,32 @@ export interface TrialStateHandle<TS, TC> {
|
|
|
101
116
|
setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P>) => void;
|
|
102
117
|
}
|
|
103
118
|
|
|
104
|
-
/** Options for `createLabStore
|
|
105
|
-
* two labs on one origin do not collide. */
|
|
119
|
+
/** Options for `createLabStore`, which knows nothing about storage. */
|
|
106
120
|
export interface CreateLabStoreOptions {
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
/** A document already read and migrated — what `openLabStore` hands over. */
|
|
122
|
+
initial?: LabDocument;
|
|
109
123
|
initialMode?: LabMode;
|
|
110
124
|
/** Each instrument's default config, keyed by instrument name, used to fill
|
|
111
125
|
* the gaps in a stored one. A config saved before its schema grew a branch
|
|
112
126
|
* arrives holding that branch's defaults rather than `undefined`, and keeps
|
|
113
|
-
* whatever keys the schema has since stopped naming.
|
|
114
|
-
* off its `instruments`. */
|
|
127
|
+
* whatever keys the schema has since stopped naming. */
|
|
115
128
|
configDefaults?: Record<string, () => unknown>;
|
|
129
|
+
/** Each instrument's `migrateConfig`, keyed by instrument name, run on a
|
|
130
|
+
* stored config before its defaults fill it. */
|
|
131
|
+
configMigrations?: Record<string, (stored: unknown) => unknown>;
|
|
116
132
|
/** 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`. */
|
|
133
|
+
* thing `createLabStore` does, so these have to arrive with the store. */
|
|
120
134
|
serializers?: InstrumentSerializers;
|
|
135
|
+
/** The instruments to serialize, migrate and fill configs for. Given, it
|
|
136
|
+
* supplies all three and the three fields above are ignored. */
|
|
137
|
+
instruments?: InstrumentList;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a store does per instrument on the way in and out. */
|
|
141
|
+
export interface InstrumentHooks {
|
|
142
|
+
serializers: InstrumentSerializers;
|
|
143
|
+
configDefaults: Record<string, () => unknown>;
|
|
144
|
+
configMigrations: Record<string, (stored: unknown) => unknown>;
|
|
121
145
|
}
|
|
122
146
|
|
|
123
147
|
/** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface Closable {
|
|
4
|
+
close(): Promise<void> | void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface Entry<T> {
|
|
8
|
+
value: T | null;
|
|
9
|
+
pending: Promise<T> | null;
|
|
10
|
+
mounts: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Open something once for a component's lifetime: at once when `open` returns
|
|
15
|
+
* a value, after it settles when it returns a promise. Closed on unmount — but
|
|
16
|
+
* the close waits a microtask, and the remount React StrictMode performs right
|
|
17
|
+
* after its rehearsal unmount cancels it, so StrictMode opens it once.
|
|
18
|
+
*/
|
|
19
|
+
export function useOpenOnce<T extends Closable>(open: () => T | Promise<T>): T | null {
|
|
20
|
+
const entry = useRef<Entry<T> | null>(null);
|
|
21
|
+
if (entry.current === null) {
|
|
22
|
+
const result = open();
|
|
23
|
+
entry.current =
|
|
24
|
+
result instanceof Promise
|
|
25
|
+
? { value: null, pending: result, mounts: 0 }
|
|
26
|
+
: { value: result, pending: null, mounts: 0 };
|
|
27
|
+
}
|
|
28
|
+
const [value, setValue] = useState<T | null>(entry.current.value);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const e = entry.current as Entry<T>;
|
|
32
|
+
e.mounts += 1;
|
|
33
|
+
let live = true;
|
|
34
|
+
void e.pending?.then((opened) => {
|
|
35
|
+
e.value = opened;
|
|
36
|
+
if (live) setValue(opened);
|
|
37
|
+
});
|
|
38
|
+
return () => {
|
|
39
|
+
live = false;
|
|
40
|
+
e.mounts -= 1;
|
|
41
|
+
queueMicrotask(() => {
|
|
42
|
+
if (e.mounts > 0) return;
|
|
43
|
+
if (e.value) void e.value.close();
|
|
44
|
+
else
|
|
45
|
+
void e.pending?.then((opened) => {
|
|
46
|
+
if (e.mounts === 0) void opened.close();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Warn once, in development, when a prop read only at mount changes after it. */
|
|
56
|
+
export function useWarnIgnoredChange(component: string, props: Record<string, unknown>): void {
|
|
57
|
+
const first = useRef(props);
|
|
58
|
+
const warned = useRef(false);
|
|
59
|
+
if (process.env.NODE_ENV === 'production' || warned.current) return;
|
|
60
|
+
for (const key of Object.keys(props)) {
|
|
61
|
+
if (Object.is(first.current[key], props[key])) continue;
|
|
62
|
+
warned.current = true;
|
|
63
|
+
console.warn(
|
|
64
|
+
`[labkit] ${component} reads \`${key}\` once, at mount; a later change is ignored`,
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|