@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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `annotations.targets` is declared once per instrument and called once per
|
|
3
|
+
* trial, so everything here runs two trials of one instrument: with a single
|
|
4
|
+
* trial open, a `targets` that ignored the trial it was asked about would look
|
|
5
|
+
* identical.
|
|
6
|
+
*/
|
|
7
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
8
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import type { AnnotationTarget } from '../annotations/types';
|
|
10
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
11
|
+
import { Lab } from '../lab/Lab';
|
|
12
|
+
import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
13
|
+
import type { TrialInfo } from '../state/types';
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
17
|
+
() => null,
|
|
18
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const asked: TrialInfo[] = [];
|
|
22
|
+
|
|
23
|
+
/** The last thing `targets` was told about `id`. */
|
|
24
|
+
function lastAsk(id: string): TrialInfo {
|
|
25
|
+
const found = [...asked].reverse().find((t) => t.id === id);
|
|
26
|
+
if (!found) throw new Error(`targets was never called for ${id}`);
|
|
27
|
+
return found;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pane: AnnotationTarget = { id: 'pane', ref: { current: null }, content: { w: 200, h: 100 } };
|
|
31
|
+
|
|
32
|
+
const subject = defineInstrument<Record<string, never>, { subject: string }>({
|
|
33
|
+
name: 'Subject',
|
|
34
|
+
defaultConfig: () => ({ subject: 'none' }),
|
|
35
|
+
initialState: () => ({}),
|
|
36
|
+
render: (ctx) => (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
data-testid={`zoom-${ctx.trial.id}`}
|
|
40
|
+
onClick={() => ctx.trial.setView({ zoom: 3, pan: { x: 0, y: 0 } })}
|
|
41
|
+
>
|
|
42
|
+
zoom
|
|
43
|
+
</button>
|
|
44
|
+
),
|
|
45
|
+
annotations: {
|
|
46
|
+
targets: (_state, _config, trial) => {
|
|
47
|
+
asked.push({ id: trial.id, view: trial.view });
|
|
48
|
+
return [pane];
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let labRef: LabContextValue | null = null;
|
|
54
|
+
|
|
55
|
+
function CaptureLab() {
|
|
56
|
+
return (
|
|
57
|
+
<LabContext.Consumer>
|
|
58
|
+
{(value) => {
|
|
59
|
+
if (value) labRef = value;
|
|
60
|
+
return null;
|
|
61
|
+
}}
|
|
62
|
+
</LabContext.Consumer>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mountTwoTrials(): [string, string] {
|
|
67
|
+
render(
|
|
68
|
+
<Lab instruments={[subject]} defaultInstrument="Subject">
|
|
69
|
+
<CaptureLab />
|
|
70
|
+
</Lab>,
|
|
71
|
+
);
|
|
72
|
+
act(() => labRef?.addTrial('Subject'));
|
|
73
|
+
const ids = labRef?.trials.map((t) => t.id) ?? [];
|
|
74
|
+
const [a, b] = ids;
|
|
75
|
+
if (!a || !b) throw new Error('expected two trials');
|
|
76
|
+
return [a, b];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('annotations.targets', () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
asked.length = 0;
|
|
82
|
+
labRef = null;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('is told which trial is asking', () => {
|
|
86
|
+
const [a, b] = mountTwoTrials();
|
|
87
|
+
expect(new Set(asked.map((t) => t.id))).toEqual(new Set([a, b]));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("is told that trial's own view, not the other's", () => {
|
|
91
|
+
const [a, b] = mountTwoTrials();
|
|
92
|
+
fireEvent.click(screen.getByTestId(`zoom-${a}`));
|
|
93
|
+
expect(lastAsk(a).view).toEqual({ zoom: 3, pan: { x: 0, y: 0 } });
|
|
94
|
+
expect(lastAsk(b).view).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
|
|
95
|
+
});
|
|
96
|
+
});
|
package/src/trial/Trial.test.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
|
-
import type
|
|
2
|
+
import { type ReactNode, useState } from 'react';
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { useStore } from 'zustand/react';
|
|
5
|
+
import { f } from '../config/builder';
|
|
4
6
|
import type { Instrument } from '../instrument/types';
|
|
5
7
|
import { Lab } from '../lab/Lab';
|
|
6
8
|
import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
@@ -9,6 +11,8 @@ import { createLabStore } from '../state/store';
|
|
|
9
11
|
import type { SavedSnapshot, TrialRecord } from '../state/types';
|
|
10
12
|
import { TrialChrome } from './TrialChrome';
|
|
11
13
|
|
|
14
|
+
const Glyph = () => <svg />;
|
|
15
|
+
|
|
12
16
|
const stubInstrument: Instrument = {
|
|
13
17
|
name: 'Stub',
|
|
14
18
|
defaultConfig: () => ({}),
|
|
@@ -27,16 +31,32 @@ const stubRecord: TrialRecord = {
|
|
|
27
31
|
|
|
28
32
|
type ChromeProps = Parameters<typeof TrialChrome>[0];
|
|
29
33
|
|
|
34
|
+
/** A store holding the stub trial, as `Trial` would have put it there. Title
|
|
35
|
+
* and section folds live on the record, so a test that outlives one mount
|
|
36
|
+
* keeps its own store and hands it to both. */
|
|
37
|
+
function makeChromeStore() {
|
|
38
|
+
const store = createLabStore({
|
|
39
|
+
storageKey: 'test',
|
|
40
|
+
storage: { read: () => null, write: () => {} },
|
|
41
|
+
});
|
|
42
|
+
const { undoStack: _undo, ...seed } = stubRecord;
|
|
43
|
+
store.getState().addTrial(seed);
|
|
44
|
+
return store;
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
function ChromeHarness({
|
|
31
48
|
children,
|
|
32
49
|
labOverrides,
|
|
33
50
|
instrument = stubInstrument,
|
|
51
|
+
store: storeProp,
|
|
34
52
|
...props
|
|
35
|
-
}: {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
}: {
|
|
54
|
+
children?: ReactNode;
|
|
55
|
+
labOverrides?: Partial<LabContextValue>;
|
|
56
|
+
store?: ReturnType<typeof createLabStore>;
|
|
57
|
+
} & Partial<ChromeProps>) {
|
|
58
|
+
const [ownStore] = useState(makeChromeStore);
|
|
59
|
+
const store = storeProp ?? ownStore;
|
|
40
60
|
const labCtx: LabContextValue = {
|
|
41
61
|
instruments: [instrument],
|
|
42
62
|
trials: [stubRecord],
|
|
@@ -56,20 +76,31 @@ function ChromeHarness({
|
|
|
56
76
|
return (
|
|
57
77
|
<LabStoreContext.Provider value={{ store }}>
|
|
58
78
|
<LabContext.Provider value={labCtx}>
|
|
59
|
-
<
|
|
60
|
-
trialId="ws-1"
|
|
61
|
-
record={stubRecord}
|
|
62
|
-
instrument={instrument}
|
|
63
|
-
isLastTrial={false}
|
|
64
|
-
{...props}
|
|
65
|
-
>
|
|
79
|
+
<ChromeFromStore store={store} instrument={instrument} {...props}>
|
|
66
80
|
{children ?? <div data-testid="content">content</div>}
|
|
67
|
-
</
|
|
81
|
+
</ChromeFromStore>
|
|
68
82
|
</LabContext.Provider>
|
|
69
83
|
</LabStoreContext.Provider>
|
|
70
84
|
);
|
|
71
85
|
}
|
|
72
86
|
|
|
87
|
+
/** Reads the record out of the store the way `Trial` does, so a mutation the
|
|
88
|
+
* chrome writes comes back to it. */
|
|
89
|
+
function ChromeFromStore({
|
|
90
|
+
store,
|
|
91
|
+
children,
|
|
92
|
+
...props
|
|
93
|
+
}: { store: ReturnType<typeof createLabStore>; children: ReactNode } & Partial<ChromeProps> &
|
|
94
|
+
Pick<ChromeProps, 'instrument'>) {
|
|
95
|
+
const record = useStore(store, (s) => s.trials.find((t) => t.id === 'ws-1'));
|
|
96
|
+
if (!record) throw new Error('no stub trial in the store');
|
|
97
|
+
return (
|
|
98
|
+
<TrialChrome trialId="ws-1" record={record} isLastTrial={false} {...props}>
|
|
99
|
+
{children}
|
|
100
|
+
</TrialChrome>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
73
104
|
describe('<TrialChrome>', () => {
|
|
74
105
|
it('renders children in the content area', () => {
|
|
75
106
|
render(<ChromeHarness />);
|
|
@@ -131,6 +162,49 @@ describe('<TrialChrome>', () => {
|
|
|
131
162
|
expect(screen.getByText('ready')).toBeInTheDocument();
|
|
132
163
|
});
|
|
133
164
|
|
|
165
|
+
it('leads the title bar with a contribution that does not set `end`', () => {
|
|
166
|
+
const { container } = render(
|
|
167
|
+
<ChromeHarness
|
|
168
|
+
chrome={[
|
|
169
|
+
{
|
|
170
|
+
id: 'subject',
|
|
171
|
+
region: 'titlebar',
|
|
172
|
+
item: { icon: Glyph, label: 'Pick subject', onActivate: () => {} },
|
|
173
|
+
},
|
|
174
|
+
]}
|
|
175
|
+
/>,
|
|
176
|
+
);
|
|
177
|
+
const titlebar = container.querySelector('.lk-trial__titlebar');
|
|
178
|
+
if (!titlebar) throw new Error('no title bar');
|
|
179
|
+
const lead = screen.getByRole('button', { name: 'Pick subject' });
|
|
180
|
+
const heading = titlebar.querySelector('.lk-trial__title');
|
|
181
|
+
if (!heading) throw new Error('no title');
|
|
182
|
+
expect(titlebar.querySelector('.lk-trial__titlebar-lead')).toContainElement(lead);
|
|
183
|
+
expect(lead.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('reads the instrument name until a contribution calls setTitle', () => {
|
|
187
|
+
render(
|
|
188
|
+
<ChromeHarness
|
|
189
|
+
chrome={[
|
|
190
|
+
{
|
|
191
|
+
id: 'rename',
|
|
192
|
+
region: 'titlebar',
|
|
193
|
+
item: {
|
|
194
|
+
icon: Glyph,
|
|
195
|
+
label: 'Rename',
|
|
196
|
+
onActivate: (ctx) => ctx.setTitle('Sprocket 7'),
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
]}
|
|
200
|
+
/>,
|
|
201
|
+
);
|
|
202
|
+
expect(screen.getByRole('region', { name: 'Trial Stub' })).toBeInTheDocument();
|
|
203
|
+
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
|
|
204
|
+
expect(screen.getByText('Sprocket 7')).toBeInTheDocument();
|
|
205
|
+
expect(screen.getByRole('region', { name: 'Trial Sprocket 7' })).toBeInTheDocument();
|
|
206
|
+
});
|
|
207
|
+
|
|
134
208
|
it('Cmd+S triggers saveSnapshot', () => {
|
|
135
209
|
const saveSnapshot = vi.fn();
|
|
136
210
|
render(<ChromeHarness labOverrides={{ saveSnapshot }} />);
|
|
@@ -168,3 +242,86 @@ describe('chrome regions in a mounted lab', () => {
|
|
|
168
242
|
expect(document.querySelector('.lk-lab')).toBeTruthy();
|
|
169
243
|
});
|
|
170
244
|
});
|
|
245
|
+
|
|
246
|
+
describe('<TrialChrome> — state that outlives a mount', () => {
|
|
247
|
+
const renameChrome: ChromeProps['chrome'] = [
|
|
248
|
+
{
|
|
249
|
+
id: 'rename',
|
|
250
|
+
region: 'titlebar',
|
|
251
|
+
item: {
|
|
252
|
+
icon: Glyph,
|
|
253
|
+
label: 'Rename',
|
|
254
|
+
onActivate: (ctx) => ctx.setTitle('Sprocket 7'),
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
const sectionChrome: ChromeProps['chrome'] = [
|
|
260
|
+
{
|
|
261
|
+
id: 'notes',
|
|
262
|
+
region: 'sidebar',
|
|
263
|
+
item: { title: 'Notes', body: <p data-testid="notes-body">notes</p> },
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
it('keeps a title across a remount', () => {
|
|
268
|
+
const store = makeChromeStore();
|
|
269
|
+
const first = render(<ChromeHarness store={store} chrome={renameChrome} />);
|
|
270
|
+
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
|
|
271
|
+
first.unmount();
|
|
272
|
+
|
|
273
|
+
render(<ChromeHarness store={store} chrome={renameChrome} />);
|
|
274
|
+
expect(screen.getByRole('region', { name: 'Trial Sprocket 7' })).toBeInTheDocument();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('keeps a folded sidebar section across a remount', () => {
|
|
278
|
+
const store = makeChromeStore();
|
|
279
|
+
const first = render(<ChromeHarness store={store} chrome={sectionChrome} />);
|
|
280
|
+
expect(screen.getByTestId('notes-body')).toBeInTheDocument();
|
|
281
|
+
fireEvent.click(screen.getByRole('button', { name: 'Notes' }));
|
|
282
|
+
expect(screen.queryByTestId('notes-body')).toBeNull();
|
|
283
|
+
first.unmount();
|
|
284
|
+
|
|
285
|
+
render(<ChromeHarness store={store} chrome={sectionChrome} />);
|
|
286
|
+
expect(screen.getByRole('button', { name: 'Notes' })).toHaveAttribute('aria-expanded', 'false');
|
|
287
|
+
expect(screen.queryByTestId('notes-body')).toBeNull();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('keeps a folded settings group across a remount', () => {
|
|
291
|
+
const sectioned: Instrument = {
|
|
292
|
+
...stubInstrument,
|
|
293
|
+
config: f.schema({ size: f.number(1).section('Shape') }),
|
|
294
|
+
};
|
|
295
|
+
const store = makeChromeStore();
|
|
296
|
+
const first = render(<ChromeHarness store={store} instrument={sectioned} />);
|
|
297
|
+
const twisty = screen.getByRole('button', { name: 'Shape' });
|
|
298
|
+
expect(twisty).toHaveAttribute('aria-expanded', 'true');
|
|
299
|
+
fireEvent.click(twisty);
|
|
300
|
+
first.unmount();
|
|
301
|
+
|
|
302
|
+
render(<ChromeHarness store={store} instrument={sectioned} />);
|
|
303
|
+
expect(screen.getByRole('button', { name: 'Shape' })).toHaveAttribute('aria-expanded', 'false');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('reopens a section that starts collapsed, and keeps it open across a remount', () => {
|
|
307
|
+
const collapsedChrome: ChromeProps['chrome'] = [
|
|
308
|
+
{
|
|
309
|
+
id: 'notes',
|
|
310
|
+
region: 'sidebar',
|
|
311
|
+
item: {
|
|
312
|
+
title: 'Notes',
|
|
313
|
+
defaultCollapsed: true,
|
|
314
|
+
body: <p data-testid="notes-body">notes</p>,
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
];
|
|
318
|
+
const store = makeChromeStore();
|
|
319
|
+
const first = render(<ChromeHarness store={store} chrome={collapsedChrome} />);
|
|
320
|
+
expect(screen.queryByTestId('notes-body')).toBeNull();
|
|
321
|
+
fireEvent.click(screen.getByRole('button', { name: 'Notes' }));
|
|
322
|
+
first.unmount();
|
|
323
|
+
|
|
324
|
+
render(<ChromeHarness store={store} chrome={collapsedChrome} />);
|
|
325
|
+
expect(screen.getByTestId('notes-body')).toBeInTheDocument();
|
|
326
|
+
});
|
|
327
|
+
});
|
package/src/trial/Trial.tsx
CHANGED
|
@@ -130,7 +130,9 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
|
|
|
130
130
|
const annotationsCap = instrument.annotations;
|
|
131
131
|
const targetsRef = useRef<() => readonly AnnotationTargetInfo[]>(() => []);
|
|
132
132
|
targetsRef.current = () =>
|
|
133
|
-
annotationsCap
|
|
133
|
+
annotationsCap
|
|
134
|
+
? annotationsCap.targets(record.state, record.config, { id: record.id, view: record.view })
|
|
135
|
+
: [];
|
|
134
136
|
|
|
135
137
|
// One store for the trial's lifetime. Marks do not survive a reload yet —
|
|
136
138
|
// the storage slot is 3d.
|
|
@@ -237,11 +239,11 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
|
|
|
237
239
|
updateTrialState(record.id, next);
|
|
238
240
|
bus.emit('state.change');
|
|
239
241
|
},
|
|
240
|
-
setConfig: (
|
|
241
|
-
const evt = `config.change:${
|
|
242
|
+
setConfig: (path, value) => {
|
|
243
|
+
const evt = `config.change:${path}`;
|
|
242
244
|
snapshotIfNeeded('config.change');
|
|
243
245
|
snapshotIfNeeded(evt);
|
|
244
|
-
updateTrialConfig(record.id,
|
|
246
|
+
updateTrialConfig(record.id, path, value);
|
|
245
247
|
bus.emit('config.change');
|
|
246
248
|
bus.emit(evt);
|
|
247
249
|
},
|
|
@@ -474,6 +476,7 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
|
|
|
474
476
|
capability={annotationsCap}
|
|
475
477
|
state={record.state}
|
|
476
478
|
config={record.config}
|
|
479
|
+
trial={{ id: record.id, view: record.view }}
|
|
477
480
|
annotations={annotations}
|
|
478
481
|
activeToolId={resolvedToolId}
|
|
479
482
|
/>
|
|
@@ -9,6 +9,7 @@ import { TitleBarRegion } from '../chrome/regions/TitleBarRegion';
|
|
|
9
9
|
import { ToolbarRegion } from '../chrome/regions/ToolbarRegion';
|
|
10
10
|
import { ViewportRegion } from '../chrome/regions/ViewportRegion';
|
|
11
11
|
import type { TrialChromeContext, TrialContribution, TrialRegion } from '../chrome/types';
|
|
12
|
+
import { hasConfigPath, schemaNodeAtPath, withValueAtPath } from '../config/path';
|
|
12
13
|
import { useConfigSchema } from '../config/useConfigSchema';
|
|
13
14
|
import type { Instrument } from '../instrument/types';
|
|
14
15
|
import type { JobHandle } from '../job/types';
|
|
@@ -62,6 +63,10 @@ export interface TrialChromeProps {
|
|
|
62
63
|
|
|
63
64
|
const NO_OP = (): void => {};
|
|
64
65
|
|
|
66
|
+
/** One frozen empty map, so a trial that has folded nothing keeps a stable
|
|
67
|
+
* context identity across renders. */
|
|
68
|
+
const NO_SECTIONS: Readonly<Record<string, boolean>> = Object.freeze({});
|
|
69
|
+
|
|
65
70
|
/** The frame around a running instrument. Builds one chrome context, assembles
|
|
66
71
|
* the contributions the instrument, the runtime and the lab declare, and hands
|
|
67
72
|
* each region its slice. */
|
|
@@ -85,6 +90,8 @@ export function TrialChrome({
|
|
|
85
90
|
if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
|
|
86
91
|
const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
|
|
87
92
|
const updateTrialSidebarWidth = useStore(storeCtx.store, (s) => s.updateTrialSidebarWidth);
|
|
93
|
+
const setTrialTitle = useStore(storeCtx.store, (s) => s.setTrialTitle);
|
|
94
|
+
const setTrialSectionCollapsed = useStore(storeCtx.store, (s) => s.setTrialSectionCollapsed);
|
|
88
95
|
const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
|
|
89
96
|
const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
|
|
90
97
|
const undockPanelAction = useStore(storeCtx.store, (s) => s.undockPanel);
|
|
@@ -104,6 +111,9 @@ export function TrialChrome({
|
|
|
104
111
|
|
|
105
112
|
const configSchema = useConfigSchema(instrument);
|
|
106
113
|
|
|
114
|
+
const title = record.title ?? record.instrumentName;
|
|
115
|
+
const collapsedSections = record.collapsedSections ?? NO_SECTIONS;
|
|
116
|
+
|
|
107
117
|
const ctx = useMemo<TrialChromeContext>(() => {
|
|
108
118
|
const setZoom = (z: number): void => {
|
|
109
119
|
if (!view2d) return;
|
|
@@ -112,6 +122,8 @@ export function TrialChrome({
|
|
|
112
122
|
return {
|
|
113
123
|
trialId,
|
|
114
124
|
instrumentName: record.instrumentName,
|
|
125
|
+
title,
|
|
126
|
+
setTitle: (next) => setTrialTitle(trialId, next),
|
|
115
127
|
isLastTrial,
|
|
116
128
|
zoom: view2d ? view2d.zoom : null,
|
|
117
129
|
setZoom,
|
|
@@ -124,20 +136,26 @@ export function TrialChrome({
|
|
|
124
136
|
configSchema,
|
|
125
137
|
configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
|
|
126
138
|
config: record.config,
|
|
127
|
-
setConfig: (
|
|
139
|
+
setConfig: (path, value) => {
|
|
128
140
|
const prevConfig = record.config as Record<string, unknown>;
|
|
129
|
-
if (
|
|
141
|
+
if (
|
|
142
|
+
process.env.NODE_ENV !== 'production' &&
|
|
143
|
+
!hasConfigPath(prevConfig, path) &&
|
|
144
|
+
schemaNodeAtPath(configSchema.group, path) === undefined
|
|
145
|
+
) {
|
|
130
146
|
console.warn(
|
|
131
|
-
`[labkit] setConfig: unknown
|
|
147
|
+
`[labkit] setConfig: unknown path "${path}" for instrument "${record.instrumentName}"`,
|
|
132
148
|
);
|
|
133
149
|
}
|
|
134
|
-
updateTrialConfig(trialId,
|
|
150
|
+
updateTrialConfig(trialId, path, value);
|
|
135
151
|
if (instrument.onConfigChange) {
|
|
136
|
-
const nextConfig =
|
|
152
|
+
const nextConfig = withValueAtPath(prevConfig, path, value);
|
|
137
153
|
const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
|
|
138
154
|
updateTrialState(trialId, nextState as never);
|
|
139
155
|
}
|
|
140
156
|
},
|
|
157
|
+
collapsedSections,
|
|
158
|
+
setSectionCollapsed: (key, collapsed) => setTrialSectionCollapsed(trialId, key, collapsed),
|
|
141
159
|
undockedPanels: undockedIds,
|
|
142
160
|
undockPanel: (sectionId, as) => undockPanelAction(trialId, sectionId, as),
|
|
143
161
|
dockPanel: (sectionId) => dockPanelAction(trialId, sectionId),
|
|
@@ -155,6 +173,7 @@ export function TrialChrome({
|
|
|
155
173
|
record,
|
|
156
174
|
instrument,
|
|
157
175
|
lab,
|
|
176
|
+
title,
|
|
158
177
|
isLastTrial,
|
|
159
178
|
updateTrialView,
|
|
160
179
|
updateTrialConfig,
|
|
@@ -168,6 +187,9 @@ export function TrialChrome({
|
|
|
168
187
|
undockedIds,
|
|
169
188
|
undockPanelAction,
|
|
170
189
|
dockPanelAction,
|
|
190
|
+
setTrialTitle,
|
|
191
|
+
setTrialSectionCollapsed,
|
|
192
|
+
collapsedSections,
|
|
171
193
|
]);
|
|
172
194
|
|
|
173
195
|
const contributions = useMemo(
|
|
@@ -203,11 +225,14 @@ export function TrialChrome({
|
|
|
203
225
|
return (
|
|
204
226
|
<section
|
|
205
227
|
className="lk-trial"
|
|
206
|
-
aria-label={`Trial ${
|
|
228
|
+
aria-label={`Trial ${title}`}
|
|
207
229
|
tabIndex={-1}
|
|
208
230
|
onKeyDown={handleKeyDown}
|
|
209
231
|
>
|
|
210
|
-
<TrialTitleBar
|
|
232
|
+
<TrialTitleBar
|
|
233
|
+
title={title}
|
|
234
|
+
lead={<TitleBarRegion placement="lead" contributions={inRegion('titlebar')} ctx={ctx} />}
|
|
235
|
+
>
|
|
211
236
|
<TitleBarRegion contributions={inRegion('titlebar')} ctx={ctx} />
|
|
212
237
|
</TrialTitleBar>
|
|
213
238
|
<div className="lk-trial__toolbar">
|
|
@@ -6,15 +6,18 @@ import { useTrialDrag } from './TrialDragContext';
|
|
|
6
6
|
/** Props for `<TrialTitleBar>`. */
|
|
7
7
|
export interface TrialTitleBarProps {
|
|
8
8
|
title: string;
|
|
9
|
+
/** Rendered at the leading edge, before the title. */
|
|
10
|
+
lead?: ReactNode;
|
|
9
11
|
children?: ReactNode;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
// `useDragHandle` needs a node id and windease's DragProvider, so the draggable
|
|
13
15
|
// form is a separate component rather than a conditional hook.
|
|
14
|
-
function Draggable({ nodeId, title, children }: TrialTitleBarProps & { nodeId: NodeId }) {
|
|
16
|
+
function Draggable({ nodeId, title, lead, children }: TrialTitleBarProps & { nodeId: NodeId }) {
|
|
15
17
|
const handlers = useDragHandle(nodeId);
|
|
16
18
|
return (
|
|
17
19
|
<div className="lk-trial__titlebar lk-trial__titlebar--draggable" {...handlers}>
|
|
20
|
+
{lead}
|
|
18
21
|
<span className="lk-trial__title">{title}</span>
|
|
19
22
|
{children}
|
|
20
23
|
</div>
|
|
@@ -24,16 +27,17 @@ function Draggable({ nodeId, title, children }: TrialTitleBarProps & { nodeId: N
|
|
|
24
27
|
/** A trial's title bar. When the workspace allows reordering the whole bar is
|
|
25
28
|
* the drag surface — there is no separate grip, because a window's title bar
|
|
26
29
|
* is already the thing you expect to drag. */
|
|
27
|
-
export function TrialTitleBar({ title, children }: TrialTitleBarProps) {
|
|
30
|
+
export function TrialTitleBar({ title, lead, children }: TrialTitleBarProps) {
|
|
28
31
|
const drag = useTrialDrag();
|
|
29
32
|
if (drag)
|
|
30
33
|
return (
|
|
31
|
-
<Draggable nodeId={drag.nodeId} title={title}>
|
|
34
|
+
<Draggable nodeId={drag.nodeId} title={title} lead={lead}>
|
|
32
35
|
{children}
|
|
33
36
|
</Draggable>
|
|
34
37
|
);
|
|
35
38
|
return (
|
|
36
39
|
<div className="lk-trial__titlebar">
|
|
40
|
+
{lead}
|
|
37
41
|
<span className="lk-trial__title">{title}</span>
|
|
38
42
|
{children}
|
|
39
43
|
</div>
|
package/src/trial/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export type { TrialBodyProps } from './TrialBody';
|
|
|
4
4
|
export { TrialBody } from './TrialBody';
|
|
5
5
|
export type { TrialChromeProps, UndoBindings } from './TrialChrome';
|
|
6
6
|
export { TrialChrome } from './TrialChrome';
|
|
7
|
+
export type { AddTrialOptions } from './trialOps';
|
|
7
8
|
export {
|
|
8
9
|
addTrial,
|
|
9
10
|
cloneTrial,
|
|
@@ -26,7 +26,23 @@ const counterWithCanvas: Instrument<CounterState, CounterConfig> = {
|
|
|
26
26
|
},
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
interface SubjectConfig {
|
|
30
|
+
subject: string;
|
|
31
|
+
zoom: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const subjectInstrument: Instrument<{ label: string }, SubjectConfig> = {
|
|
35
|
+
name: 'Subject',
|
|
36
|
+
defaultConfig: () => ({ subject: 'none', zoom: 1 }),
|
|
37
|
+
initialState: (config) => ({ label: config.subject.toUpperCase() }),
|
|
38
|
+
render: () => null,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const instruments: Instrument[] = [
|
|
42
|
+
counter as Instrument,
|
|
43
|
+
counterWithCanvas as Instrument,
|
|
44
|
+
subjectInstrument as Instrument,
|
|
45
|
+
];
|
|
30
46
|
|
|
31
47
|
function head<T>(arr: T[]): T {
|
|
32
48
|
const first = arr[0];
|
|
@@ -69,6 +85,28 @@ describe('addTrial', () => {
|
|
|
69
85
|
addTrial(arr, instruments, 'Counter');
|
|
70
86
|
expect(arr).toHaveLength(0);
|
|
71
87
|
});
|
|
88
|
+
|
|
89
|
+
it('overlays a seed config on the instrument defaults', () => {
|
|
90
|
+
const out = addTrial([], instruments, 'Subject', { config: { subject: 'brick' } });
|
|
91
|
+
expect(out[0]?.config).toEqual({ subject: 'brick', zoom: 1 });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('seeds the config before initialState reads it', () => {
|
|
95
|
+
const out = addTrial([], instruments, 'Subject', { config: { subject: 'brick' } });
|
|
96
|
+
expect(out[0]?.state).toEqual({ label: 'BRICK' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('gives each trial the seed it was opened with', () => {
|
|
100
|
+
const a = addTrial([], instruments, 'Subject', { config: { subject: 'one' } });
|
|
101
|
+
const b = addTrial(a, instruments, 'Subject', { config: { subject: 'two' } });
|
|
102
|
+
expect(b.map((t) => (t.config as SubjectConfig).subject)).toEqual(['one', 'two']);
|
|
103
|
+
expect(b.map((t) => (t.state as { label: string }).label)).toEqual(['ONE', 'TWO']);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('ignores a seed key whose value is undefined', () => {
|
|
107
|
+
const out = addTrial([], instruments, 'Subject', { config: { subject: undefined } });
|
|
108
|
+
expect(out[0]?.config).toEqual({ subject: 'none', zoom: 1 });
|
|
109
|
+
});
|
|
72
110
|
});
|
|
73
111
|
|
|
74
112
|
describe('cloneTrial', () => {
|
|
@@ -139,6 +177,16 @@ describe('resetTrial', () => {
|
|
|
139
177
|
resetTrial(arr, head(arr).id, instruments);
|
|
140
178
|
expect((arr[0]?.config as CounterConfig).step).toBe(99);
|
|
141
179
|
});
|
|
180
|
+
|
|
181
|
+
it('restores the seed the trial was opened with, not the bare defaults', () => {
|
|
182
|
+
let arr = addTrial([], instruments, 'Subject', { config: { subject: 'one' } });
|
|
183
|
+
arr = addTrial(arr, instruments, 'Subject', { config: { subject: 'two' } });
|
|
184
|
+
head(arr).config = { subject: 'edited', zoom: 4 } as SubjectConfig;
|
|
185
|
+
const reset = resetTrial(arr, head(arr).id, instruments);
|
|
186
|
+
expect(reset[0]?.config).toEqual({ subject: 'one', zoom: 1 });
|
|
187
|
+
expect(reset[0]?.state).toEqual({ label: 'ONE' });
|
|
188
|
+
expect(reset[1]?.config).toEqual({ subject: 'two', zoom: 1 });
|
|
189
|
+
});
|
|
142
190
|
});
|
|
143
191
|
|
|
144
192
|
describe('reorderTrials', () => {
|
package/src/trial/trialOps.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fillConfigDefaults } from '../config/path';
|
|
1
2
|
import type { Instrument, InstrumentList } from '../instrument/types';
|
|
2
3
|
import type { TrialRecord } from '../state/types';
|
|
3
4
|
|
|
@@ -19,15 +20,31 @@ function initialView(instrument: Instrument): TrialRecord['view'] {
|
|
|
19
20
|
return declared ? structuredClone(declared) : { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/** What a trial opens on beyond its instrument's own defaults. */
|
|
24
|
+
export interface AddTrialOptions<TC = Record<string, unknown>> {
|
|
25
|
+
/** Overlaid on `defaultConfig()` before `initialState` reads it, so a trial
|
|
26
|
+
* opened on a subject is running that subject from its first frame. Keys
|
|
27
|
+
* whose value is `undefined` are left to the default. */
|
|
28
|
+
config?: Partial<TC>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `defaultConfig()` with `seed` written over it, down the tree: a seed naming
|
|
32
|
+
* one leaf of a group leaves that group's other leaves at their defaults. */
|
|
33
|
+
function seedConfig<TC>(defaults: TC, seed: Partial<TC> | undefined): TC {
|
|
34
|
+
return seed ? fillConfigDefaults(seed, defaults) : defaults;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Append a new trial running `instrumentName`, at that instrument's default
|
|
38
|
+
* config — with `options.config` written over it — and the initial state
|
|
39
|
+
* that config produces. */
|
|
24
40
|
export function addTrial(
|
|
25
41
|
trials: TrialRecord[],
|
|
26
42
|
instruments: InstrumentList,
|
|
27
43
|
instrumentName: string,
|
|
44
|
+
options: AddTrialOptions = {},
|
|
28
45
|
): TrialRecord[] {
|
|
29
46
|
const instrument = findInstrument(instruments, instrumentName);
|
|
30
|
-
const config = instrument.defaultConfig();
|
|
47
|
+
const config = seedConfig(instrument.defaultConfig(), options.config);
|
|
31
48
|
const state = instrument.initialState(config);
|
|
32
49
|
const record: TrialRecord = {
|
|
33
50
|
id: crypto.randomUUID(),
|
|
@@ -37,6 +54,7 @@ export function addTrial(
|
|
|
37
54
|
view: initialView(instrument),
|
|
38
55
|
undoStack: { past: [], future: [] },
|
|
39
56
|
};
|
|
57
|
+
if (options.config) record.configSeed = options.config;
|
|
40
58
|
return [...trials, record];
|
|
41
59
|
}
|
|
42
60
|
|
|
@@ -65,8 +83,8 @@ export function closeTrial(trials: TrialRecord[], id: string): TrialRecord[] {
|
|
|
65
83
|
return next.length === trials.length ? trials : next;
|
|
66
84
|
}
|
|
67
85
|
|
|
68
|
-
/** Return a trial to its instrument's defaults
|
|
69
|
-
* place in the list. */
|
|
86
|
+
/** Return a trial to what it opened on — its instrument's defaults under the
|
|
87
|
+
* seed `addTrial` was given — keeping its id and its place in the list. */
|
|
70
88
|
export function resetTrial(
|
|
71
89
|
trials: TrialRecord[],
|
|
72
90
|
id: string,
|
|
@@ -76,7 +94,7 @@ export function resetTrial(
|
|
|
76
94
|
const current = trials[idx];
|
|
77
95
|
if (!current) return trials;
|
|
78
96
|
const instrument = findInstrument(instruments, current.instrumentName);
|
|
79
|
-
const config = instrument.defaultConfig();
|
|
97
|
+
const config = seedConfig(instrument.defaultConfig(), current.configSeed);
|
|
80
98
|
const state = instrument.initialState(config);
|
|
81
99
|
const reset: TrialRecord = {
|
|
82
100
|
...current,
|