@weasel-js/labkit 1.4.2 → 1.4.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/README.md +20 -2
- package/dist/_dts/{CanvasStackContext-kjILVnPj.d.ts → CanvasStackContext-kTEZvEgE.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-CgxUequc.d.ts → PrefsForm.d-DDHFANkc.d.ts} +1 -1
- package/dist/_dts/{frac-A4R6v4ld.d.ts → frac-z7ker2Vx.d.ts} +11 -7
- package/dist/_dts/{index-CsU9WhjM.d.ts → index-Blv9uQzu.d.ts} +42 -9
- package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
- package/dist/_dts/{types-BbXpvQa8.d.ts → types-BP2OCcpg.d.ts} +21 -3
- package/dist/_dts/types-x92Kfeme.d.ts +62 -0
- package/dist/_dts/{useTrialState-DKjqpv20.d.ts → useTrialState-D6Vb3T-g.d.ts} +7 -7
- package/dist/canvas/index.d.ts +9 -8
- package/dist/canvas/index.js +1 -2
- package/dist/chrome/index.d.ts +7 -6
- package/dist/chrome/index.js +5 -5
- package/dist/{chunk-ULDW42CR.js → chunk-2P6PP5N4.js} +33 -23
- package/dist/chunk-2P6PP5N4.js.map +1 -0
- package/dist/{chunk-W2FJR5FF.js → chunk-64ZCN3DA.js} +74 -76
- package/dist/chunk-64ZCN3DA.js.map +1 -0
- package/dist/{chunk-NSOVI3AZ.js → chunk-BKFVHKJH.js} +6 -6
- package/dist/{chunk-NSOVI3AZ.js.map → chunk-BKFVHKJH.js.map} +1 -1
- package/dist/chunk-E44SU6XS.js +50 -0
- package/dist/chunk-E44SU6XS.js.map +1 -0
- package/dist/{chunk-WS6ZRV75.js → chunk-FQMJUVHJ.js} +3 -3
- package/dist/{chunk-WS6ZRV75.js.map → chunk-FQMJUVHJ.js.map} +1 -1
- package/dist/{chunk-FJG4PHTL.js → chunk-I6JCVE24.js} +3 -3
- package/dist/chunk-I6JCVE24.js.map +1 -0
- package/dist/{chunk-UDXOYZEC.js → chunk-MOM3GOVY.js} +4 -4
- package/dist/{chunk-UDXOYZEC.js.map → chunk-MOM3GOVY.js.map} +1 -1
- package/dist/{chunk-TN7YSJVU.js → chunk-RS3HRQWU.js} +3 -3
- package/dist/{chunk-TN7YSJVU.js.map → chunk-RS3HRQWU.js.map} +1 -1
- package/dist/{chunk-UTOEDPNU.js → chunk-SMHP6XZ4.js} +7 -6
- package/dist/chunk-SMHP6XZ4.js.map +1 -0
- package/dist/{chunk-D5KQ5OY6.js → chunk-TJ7QY3OC.js} +3 -3
- package/dist/{chunk-D5KQ5OY6.js.map → chunk-TJ7QY3OC.js.map} +1 -1
- package/dist/{chunk-XKENZTNE.js → chunk-U3IYHIAE.js} +71 -55
- package/dist/chunk-U3IYHIAE.js.map +1 -0
- package/dist/chunk-W3ECWC2K.js +7335 -0
- package/dist/chunk-W3ECWC2K.js.map +1 -0
- package/dist/controls/index.d.ts +5 -4
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +6 -5
- package/dist/dragdrop/index.js +1 -2
- package/dist/index.d.ts +139 -26
- package/dist/index.js +309 -144
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +7 -6
- 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 +98 -20
- 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 +82 -15
- package/dist/surface/index.js +1 -2
- package/dist/ui/layers/index.js +1 -2
- package/dist/undo/index.d.ts +6 -6
- package/package.json +7 -7
- package/src/annotations/ExportMenu.test.tsx +16 -0
- package/src/annotations/ExportMenu.tsx +0 -5
- 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/store.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -2
- 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 +114 -0
- package/src/controls/ControlPanel.tsx +99 -50
- package/src/index.ts +17 -2
- package/src/instrument/serializers.ts +36 -0
- package/src/instrument/types.ts +4 -2
- package/src/lab/Lab.test.tsx +29 -1
- package/src/lab/Lab.tsx +10 -1
- package/src/lab/LabShell.test.tsx +8 -0
- package/src/lab/LabShell.tsx +17 -3
- package/src/lab/LabSwitcher.less +85 -0
- package/src/lab/LabSwitcher.test.tsx +96 -0
- package/src/lab/LabSwitcher.tsx +112 -0
- package/src/lab/index.ts +2 -0
- package/src/primitives/ZoomControl.less +6 -2
- package/src/primitives/ZoomControl.tsx +1 -0
- package/src/state/helpers.test.ts +20 -0
- package/src/state/helpers.ts +17 -1
- package/src/state/store.test.ts +155 -1
- package/src/state/store.ts +32 -26
- package/src/state/types.ts +24 -3
- package/src/state/useTrialState.ts +1 -1
- package/src/styles.less +1 -0
- package/src/theme/base.less +4 -4
- package/src/trial/Trial.config.test.tsx +44 -0
- package/src/trial/Trial.tsx +3 -3
- package/src/trial/TrialChrome.tsx +10 -5
- package/src/trial/trialOps.ts +7 -10
- package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
- package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
- package/dist/chunk-54ZWZ5FQ.js +0 -29997
- package/dist/chunk-54ZWZ5FQ.js.map +0 -1
- package/dist/chunk-FJG4PHTL.js.map +0 -1
- package/dist/chunk-ISSVF5PT.js +0 -7263
- package/dist/chunk-ISSVF5PT.js.map +0 -1
- package/dist/chunk-ULDW42CR.js.map +0 -1
- package/dist/chunk-UTOEDPNU.js.map +0 -1
- package/dist/chunk-W2FJR5FF.js.map +0 -1
- package/dist/chunk-XKENZTNE.js.map +0 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/** One page in a project's set of labs. */
|
|
4
|
+
export interface LabPage {
|
|
5
|
+
/** The page's own URL, and how the switcher knows which page is open. */
|
|
6
|
+
href: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Props for `<LabSwitcher>`. */
|
|
11
|
+
export interface LabSwitcherProps {
|
|
12
|
+
/** The open page's title, shown as the control's own label. */
|
|
13
|
+
title: string;
|
|
14
|
+
pages: readonly LabPage[];
|
|
15
|
+
/** Defaults to the current location. Pass it in tests, or to render a
|
|
16
|
+
* switcher for a page other than the one being viewed. */
|
|
17
|
+
path?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Which of `pages` a path is on, or -1.
|
|
22
|
+
*
|
|
23
|
+
* Matched on the end of the path so a query string, a trailing slash or a
|
|
24
|
+
* leftover `.html` cannot lose it: a project whose dev server maps `/stats`
|
|
25
|
+
* to `stats.html` serves both spellings, and a bookmark from before the URLs
|
|
26
|
+
* lost their extension still resolves to the same page. */
|
|
27
|
+
export function currentPage(path: string, pages: readonly LabPage[]): number {
|
|
28
|
+
const here = path.split('?')[0]!.split('#')[0]!
|
|
29
|
+
.replace(/\/$/, '').replace(/\.html$/, '');
|
|
30
|
+
return pages.findIndex((p) => here.endsWith(p.href.replace(/\.html$/, '')));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A project's lab title, doubling as the way to reach its other labs.
|
|
34
|
+
*
|
|
35
|
+
* The menu holds real anchors, not a `<select>` and not buttons. These are
|
|
36
|
+
* separate documents, so an `href` is what gets middle-click, cmd-click and
|
|
37
|
+
* the back button for free; a select fires a change event and can do none of
|
|
38
|
+
* the three. The trigger is a button because it opens something rather than
|
|
39
|
+
* going anywhere.
|
|
40
|
+
*
|
|
41
|
+
* Pages accumulate -- a project grows a wall, a dashboard, an ingest page, a
|
|
42
|
+
* bench -- and a tab strip is the layout that stops working first. */
|
|
43
|
+
export function LabSwitcher({ title, pages, path, className }: LabSwitcherProps) {
|
|
44
|
+
const [open, setOpen] = useState(false);
|
|
45
|
+
const root = useRef<HTMLDivElement>(null);
|
|
46
|
+
const menuId = useId();
|
|
47
|
+
const here = currentPage(path ?? (typeof window === 'undefined'
|
|
48
|
+
? '' : window.location.pathname), pages);
|
|
49
|
+
|
|
50
|
+
const close = useCallback(() => setOpen(false), []);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!open) return;
|
|
54
|
+
const onKey = (e: KeyboardEvent) => {
|
|
55
|
+
if (e.key === 'Escape') {
|
|
56
|
+
close();
|
|
57
|
+
root.current?.querySelector('button')?.focus();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
// Pointer, not click: a press that starts outside should dismiss before
|
|
61
|
+
// whatever it lands on reacts, the way every other menu on the page does.
|
|
62
|
+
const onDown = (e: PointerEvent) => {
|
|
63
|
+
if (!root.current?.contains(e.target as Node)) close();
|
|
64
|
+
};
|
|
65
|
+
document.addEventListener('keydown', onKey);
|
|
66
|
+
document.addEventListener('pointerdown', onDown);
|
|
67
|
+
return () => {
|
|
68
|
+
document.removeEventListener('keydown', onKey);
|
|
69
|
+
document.removeEventListener('pointerdown', onDown);
|
|
70
|
+
};
|
|
71
|
+
}, [open, close]);
|
|
72
|
+
|
|
73
|
+
// One lab is not a set to choose from, and a disclosure arrow promising a
|
|
74
|
+
// menu of itself is worse than no control.
|
|
75
|
+
if (pages.length < 2) {
|
|
76
|
+
return <h1 className={`lk-switcher-title${className ? ` ${className}` : ''}`}>{title}</h1>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className={`lk-switcher${className ? ` ${className}` : ''}`} ref={root}>
|
|
81
|
+
<h1 className="lk-switcher-title">
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="lk-switcher-trigger"
|
|
85
|
+
aria-expanded={open}
|
|
86
|
+
aria-haspopup="menu"
|
|
87
|
+
aria-controls={open ? menuId : undefined}
|
|
88
|
+
onClick={() => setOpen((was) => !was)}
|
|
89
|
+
>
|
|
90
|
+
{title}
|
|
91
|
+
<span className="lk-switcher-caret" aria-hidden="true" />
|
|
92
|
+
</button>
|
|
93
|
+
</h1>
|
|
94
|
+
{open && (
|
|
95
|
+
<div className="lk-switcher-menu" id={menuId} role="menu">
|
|
96
|
+
{pages.map((page, i) => (
|
|
97
|
+
<a
|
|
98
|
+
key={page.href}
|
|
99
|
+
href={page.href}
|
|
100
|
+
role="menuitem"
|
|
101
|
+
className="lk-switcher-item"
|
|
102
|
+
aria-current={i === here ? 'page' : undefined}
|
|
103
|
+
onClick={close}
|
|
104
|
+
>
|
|
105
|
+
{page.label}
|
|
106
|
+
</a>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/lab/index.ts
CHANGED
|
@@ -6,5 +6,7 @@ export type { LabPaletteProps } from './LabPalette';
|
|
|
6
6
|
export { LabPalette } from './LabPalette';
|
|
7
7
|
export type { LabShellProps } from './LabShell';
|
|
8
8
|
export { LabShell } from './LabShell';
|
|
9
|
+
export type { LabPage, LabSwitcherProps } from './LabSwitcher';
|
|
10
|
+
export { currentPage, LabSwitcher } from './LabSwitcher';
|
|
9
11
|
export type { PanelDescriptor, TrialLayout, WorkspaceProps } from './Workspace';
|
|
10
12
|
export { Workspace } from './Workspace';
|
|
@@ -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
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
deserializeTrials,
|
|
5
5
|
emptyUndoStack,
|
|
6
6
|
encodeUrlHash,
|
|
7
|
+
newId,
|
|
7
8
|
labStorageKey,
|
|
8
9
|
serializeTrials,
|
|
9
10
|
} from './helpers';
|
|
@@ -114,4 +115,23 @@ describe('deserializeTrials', () => {
|
|
|
114
115
|
it('returns an empty list when given something that is not an array', () => {
|
|
115
116
|
expect(deserializeTrials(undefined as never, {})).toEqual([]);
|
|
116
117
|
});
|
|
118
|
+
|
|
119
|
+
describe('newId', () => {
|
|
120
|
+
it('mints distinct ids', () => {
|
|
121
|
+
expect(newId()).not.toBe(newId());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('falls back where crypto.randomUUID is absent', () => {
|
|
125
|
+
// What a lab reached by LAN IP gets: not a secure context, so the
|
|
126
|
+
// function is not there. It used to throw on the first render and show a
|
|
127
|
+
// blank page.
|
|
128
|
+
const real = globalThis.crypto;
|
|
129
|
+
Object.defineProperty(globalThis, 'crypto', { value: {}, configurable: true });
|
|
130
|
+
try {
|
|
131
|
+
expect(newId()).not.toBe(newId());
|
|
132
|
+
} finally {
|
|
133
|
+
Object.defineProperty(globalThis, 'crypto', { value: real, configurable: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
117
137
|
});
|
package/src/state/helpers.ts
CHANGED
|
@@ -25,6 +25,22 @@ export function decodeUrlHash(hash: string): string | null {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* An id for a trial or a snapshot.
|
|
30
|
+
*
|
|
31
|
+
* `crypto.randomUUID` exists only in a secure context, and a LAN address is
|
|
32
|
+
* not one — so a lab opened on a phone or a tablet by IP throws on its first
|
|
33
|
+
* render and shows a blank page with nothing in reach to say why. The counter
|
|
34
|
+
* is per page load, which is all these ids have to survive: they name records
|
|
35
|
+
* inside one session's store, and a saved snapshot carries its own name.
|
|
36
|
+
*/
|
|
37
|
+
let idCounter = 0;
|
|
38
|
+
export function newId(): string {
|
|
39
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
40
|
+
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
|
|
41
|
+
return `lk-${Date.now().toString(36)}-${idCounter++}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
/** A fresh, empty undo history. */
|
|
29
45
|
export function emptyUndoStack(): UndoStack {
|
|
30
46
|
return { past: [], future: [] };
|
|
@@ -58,7 +74,7 @@ export function deserializeTrials(
|
|
|
58
74
|
const d = deserializers[r.instrumentName];
|
|
59
75
|
return {
|
|
60
76
|
...r,
|
|
61
|
-
state: d?.deserialize ? d.deserialize(r.state) : r.state,
|
|
77
|
+
state: d?.deserialize ? d.deserialize(r.state, r.config) : r.state,
|
|
62
78
|
undoStack: emptyUndoStack(),
|
|
63
79
|
};
|
|
64
80
|
});
|
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', () => {
|
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,
|
|
@@ -10,10 +11,9 @@ import {
|
|
|
10
11
|
readLegacyDocument,
|
|
11
12
|
runMigrations,
|
|
12
13
|
} from './document';
|
|
13
|
-
import { deserializeTrials, emptyUndoStack, serializeTrials } from './helpers';
|
|
14
|
+
import { deserializeTrials, emptyUndoStack, newId, serializeTrials } from './helpers';
|
|
14
15
|
import type {
|
|
15
16
|
CreateLabStoreOptions,
|
|
16
|
-
InstrumentSerializers,
|
|
17
17
|
LabDocument,
|
|
18
18
|
LabMode,
|
|
19
19
|
LabStoreState,
|
|
@@ -32,7 +32,9 @@ 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;
|
|
38
40
|
/** Retitle a trial. `null` returns it to its instrument's name. */
|
|
@@ -59,16 +61,14 @@ export interface LabStoreActions {
|
|
|
59
61
|
dockPanel: (trialId: string, sectionId?: string) => void;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
/** A lab's store: its state and actions
|
|
63
|
-
*
|
|
64
|
-
export type LabStore = StoreApi<LabStoreState & LabStoreActions
|
|
65
|
-
registerSerializers: (s: InstrumentSerializers) => void;
|
|
66
|
-
};
|
|
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>;
|
|
67
67
|
|
|
68
68
|
/** Build a lab store, hydrating from storage if anything was saved under the
|
|
69
69
|
* same key. Writes back are debounced. */
|
|
70
70
|
export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
71
|
-
|
|
71
|
+
const serializers = options.serializers ?? {};
|
|
72
72
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
73
73
|
|
|
74
74
|
const hydration = hydrateDocument(options);
|
|
@@ -77,8 +77,20 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
77
77
|
// Cleared once the legacy keys are actually gone; see the flush.
|
|
78
78
|
let foldedFromLegacy = hydration.foldedFromLegacy;
|
|
79
79
|
|
|
80
|
-
const
|
|
81
|
-
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
|
+
}));
|
|
82
94
|
const hydratedLayout = hydrated.layout;
|
|
83
95
|
const hydratedMode = hydrated.mode;
|
|
84
96
|
|
|
@@ -121,15 +133,11 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
121
133
|
scheduleFlush();
|
|
122
134
|
},
|
|
123
135
|
|
|
124
|
-
updateTrialConfig: (id,
|
|
136
|
+
updateTrialConfig: (id, path, value) => {
|
|
125
137
|
set((s) => ({
|
|
126
|
-
trials: s.trials.map((w) =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
...w,
|
|
130
|
-
config: { ...(w.config as Record<string, unknown>), [key as string]: value },
|
|
131
|
-
};
|
|
132
|
-
}),
|
|
138
|
+
trials: s.trials.map((w) =>
|
|
139
|
+
w.id === id ? { ...w, config: withValueAtPath(w.config, path, value) } : w,
|
|
140
|
+
),
|
|
133
141
|
}));
|
|
134
142
|
scheduleFlush();
|
|
135
143
|
},
|
|
@@ -213,7 +221,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
213
221
|
const lastAt = get().savedSnapshots.reduce((m, sn) => (sn.savedAt > m ? sn.savedAt : m), 0);
|
|
214
222
|
const savedAt = Math.max(Date.now(), lastAt + 1);
|
|
215
223
|
const snapshot: SavedSnapshot = {
|
|
216
|
-
id:
|
|
224
|
+
id: newId(),
|
|
217
225
|
name,
|
|
218
226
|
trialId,
|
|
219
227
|
instrumentName: trial.instrumentName,
|
|
@@ -237,7 +245,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
237
245
|
return;
|
|
238
246
|
}
|
|
239
247
|
const reg = serializers[snapshot.instrumentName];
|
|
240
|
-
const restoredState = reg?.deserialize
|
|
248
|
+
const restoredState = reg?.deserialize
|
|
249
|
+
? reg.deserialize(snapshot.state, snapshot.config)
|
|
250
|
+
: snapshot.state;
|
|
241
251
|
set((s) => ({
|
|
242
252
|
trials: s.trials.map((w) =>
|
|
243
253
|
w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
|
|
@@ -313,11 +323,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
313
323
|
// fold; the flush is what removes the legacy keys.
|
|
314
324
|
if (foldedFromLegacy) scheduleFlush();
|
|
315
325
|
|
|
316
|
-
return
|
|
317
|
-
registerSerializers(s: InstrumentSerializers) {
|
|
318
|
-
serializers = s;
|
|
319
|
-
},
|
|
320
|
-
});
|
|
326
|
+
return store;
|
|
321
327
|
}
|
|
322
328
|
|
|
323
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. */
|
|
@@ -95,7 +96,9 @@ export interface TrialStateHandle<TS, TC> {
|
|
|
95
96
|
state: TS;
|
|
96
97
|
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
97
98
|
config: TC;
|
|
98
|
-
|
|
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;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
/** Options for `createLabStore`. `storageKey` namespaces the keys written, so
|
|
@@ -104,13 +107,31 @@ export interface CreateLabStoreOptions {
|
|
|
104
107
|
storageKey: string;
|
|
105
108
|
storage: StorageAdapter;
|
|
106
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;
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
/** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
|
|
110
|
-
* 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. */
|
|
111
128
|
export type InstrumentSerializers = Record<
|
|
112
129
|
string,
|
|
113
|
-
|
|
130
|
+
| {
|
|
131
|
+
serialize?: (state: unknown) => unknown;
|
|
132
|
+
deserialize?: (data: unknown, config: unknown) => unknown;
|
|
133
|
+
}
|
|
134
|
+
| undefined
|
|
114
135
|
>;
|
|
115
136
|
|
|
116
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
|
}
|
package/src/styles.less
CHANGED
package/src/theme/base.less
CHANGED
|
@@ -79,13 +79,13 @@
|
|
|
79
79
|
&::-webkit-slider-runnable-track {
|
|
80
80
|
height: var(--wzl-slider-track-h);
|
|
81
81
|
border-radius: var(--wzl-radius-sm);
|
|
82
|
-
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-
|
|
82
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-mix), transparent);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
&::-moz-range-track {
|
|
86
86
|
height: var(--wzl-slider-track-h);
|
|
87
87
|
border-radius: var(--wzl-radius-sm);
|
|
88
|
-
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-
|
|
88
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-mix), transparent);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
&::-webkit-slider-thumb {
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
height: var(--wzl-slider-thumb-size);
|
|
96
96
|
border: none;
|
|
97
97
|
border-radius: 50%;
|
|
98
|
-
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-
|
|
98
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-mix), transparent);
|
|
99
99
|
margin-top: calc((var(--wzl-slider-track-h) - var(--wzl-slider-thumb-size)) / 2);
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
height: var(--wzl-slider-thumb-size);
|
|
105
105
|
border: none;
|
|
106
106
|
border-radius: 50%;
|
|
107
|
-
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-
|
|
107
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-mix), transparent);
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
}
|
|
@@ -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
|
+
});
|