@weasel-js/labkit 1.0.3 → 1.1.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 +6 -6
- package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
- package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useTrialState-DYe2vUwN.d.ts} +62 -34
- package/dist/canvas/index.d.ts +1 -1
- package/dist/chunk-3TYUJR7Z.js +488 -0
- package/dist/chunk-3TYUJR7Z.js.map +1 -0
- package/dist/{chunk-N5KTQKQA.js → chunk-C6GJKPUI.js} +835 -770
- package/dist/chunk-C6GJKPUI.js.map +1 -0
- package/dist/{chunk-3WPOGKUP.js → chunk-DJLDIRFN.js} +543 -226
- package/dist/chunk-DJLDIRFN.js.map +1 -0
- package/dist/{chunk-73PXCRCR.js → chunk-NRKWVTVT.js} +6 -6
- package/dist/chunk-NRKWVTVT.js.map +1 -0
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +197 -148
- package/dist/index.js +275 -161
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +10 -3
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +14 -14
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +179 -14
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +1 -1
- package/package.json +2 -1
- package/src/canvas/AGENTS.md +6 -2
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +52 -0
- package/src/index.ts +11 -7
- package/src/instrument/SineWave.smoke.test.tsx +1 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +18 -6
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +5 -5
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +56 -44
- package/src/lab/LabContext.ts +12 -11
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/Workspace.less +40 -0
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +27 -11
- package/src/lab/Workspace.test.tsx +114 -0
- package/src/lab/Workspace.tsx +196 -0
- package/src/lab/index.ts +2 -4
- package/src/layers/AGENTS.md +3 -3
- package/src/primitives/Toolbar.stories.tsx +2 -2
- package/src/state/SingletonExperiment.test.tsx +8 -8
- package/src/state/SingletonExperiment.tsx +9 -9
- package/src/state/context.tsx +12 -12
- package/src/state/document.test.ts +369 -0
- package/src/state/document.ts +194 -0
- package/src/state/helpers.test.ts +74 -46
- package/src/state/helpers.ts +23 -31
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -40
- package/src/state/store.ts +166 -90
- package/src/state/types.ts +30 -10
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/styles.less +2 -2
- package/src/test-setup.ts +19 -0
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -17
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -25
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
- package/src/{workspace → trial}/index.ts +13 -12
- package/src/{workspace → trial}/slotTypes.ts +16 -16
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +60 -33
- package/src/trial/trialOps.ts +99 -0
- package/src/ui/format.test.ts +33 -0
- package/src/ui/format.ts +11 -0
- package/src/ui/properties/CurveField.tsx +6 -6
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
- package/dist/chunk-3WPOGKUP.js.map +0 -1
- package/dist/chunk-73PXCRCR.js.map +0 -1
- package/dist/chunk-N5KTQKQA.js.map +0 -1
- package/dist/chunk-T7OKNJTY.js +0 -351
- package/dist/chunk-T7OKNJTY.js.map +0 -1
- package/src/lab/WorkspaceGrid.less +0 -8
- package/src/lab/WorkspaceGrid.test.tsx +0 -40
- package/src/lab/WorkspaceGrid.tsx +0 -22
- package/src/lab/gridDims.test.ts +0 -35
- package/src/lab/gridDims.ts +0 -13
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/workspaceOps.ts +0 -86
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { ControlPanel } from '../controls/ControlPanel';
|
|
2
2
|
import { Sidebar } from '../primitives/Sidebar';
|
|
3
|
-
import type {
|
|
3
|
+
import type { TrialSidebarContext } from './slotTypes';
|
|
4
4
|
|
|
5
5
|
/** Props for `<DefaultSidebar>`. */
|
|
6
6
|
export interface DefaultSidebarProps {
|
|
7
|
-
ctx:
|
|
7
|
+
ctx: TrialSidebarContext;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
/** The sidebar a
|
|
10
|
+
/** The sidebar a trial renders when no `sidebar` slot is supplied: a
|
|
11
11
|
* control panel over the instrument's config schema. */
|
|
12
12
|
export function DefaultSidebar({ ctx }: DefaultSidebarProps) {
|
|
13
13
|
if (ctx.configFields.length === 0) {
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { StatusBar } from '../primitives/StatusBar';
|
|
2
|
-
import
|
|
2
|
+
import { formatZoom } from '../ui/format';
|
|
3
|
+
import type { TrialStatusBarContext } from './slotTypes';
|
|
3
4
|
|
|
4
5
|
/** Props for `<DefaultStatusBar>`. */
|
|
5
6
|
export interface DefaultStatusBarProps {
|
|
6
|
-
ctx:
|
|
7
|
+
ctx: TrialStatusBarContext;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
/** The status bar a
|
|
10
|
+
/** The status bar a trial renders when no `statusBar` slot is supplied:
|
|
10
11
|
* the instrument's name and the current zoom. */
|
|
11
12
|
export function DefaultStatusBar({ ctx }: DefaultStatusBarProps) {
|
|
12
13
|
return (
|
|
13
14
|
<StatusBar>
|
|
14
15
|
<StatusBar.Section>{ctx.instrumentName}</StatusBar.Section>
|
|
15
|
-
<StatusBar.Section>{
|
|
16
|
+
<StatusBar.Section>{formatZoom(ctx.zoom)}</StatusBar.Section>
|
|
16
17
|
</StatusBar>
|
|
17
18
|
);
|
|
18
19
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Toolbar } from '../primitives/Toolbar';
|
|
2
|
-
import
|
|
2
|
+
import { formatZoom } from '../ui/format';
|
|
3
|
+
import type { TrialToolbarContext } from './slotTypes';
|
|
3
4
|
|
|
4
5
|
/** Props for `<DefaultToolbar>`. */
|
|
5
6
|
export interface DefaultToolbarProps {
|
|
6
|
-
ctx:
|
|
7
|
+
ctx: TrialToolbarContext;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
/** The toolbar a
|
|
10
|
+
/** The toolbar a trial renders when no `toolbar` slot is supplied. Only
|
|
10
11
|
* shows the controls the instrument's declared capabilities support. */
|
|
11
12
|
export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
|
|
12
13
|
return (
|
|
@@ -32,7 +33,7 @@ export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
|
|
|
32
33
|
<Toolbar.Button onClick={ctx.zoomOut} title="Zoom out">
|
|
33
34
|
−
|
|
34
35
|
</Toolbar.Button>
|
|
35
|
-
<span className="lk-toolbar__zoom-label">{
|
|
36
|
+
<span className="lk-toolbar__zoom-label">{formatZoom(ctx.zoom)}</span>
|
|
36
37
|
<Toolbar.Button onClick={ctx.zoomIn} title="Zoom in">
|
|
37
38
|
+
|
|
38
39
|
</Toolbar.Button>
|
|
@@ -73,16 +74,16 @@ export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
|
|
|
73
74
|
|
|
74
75
|
<Toolbar.Spacer />
|
|
75
76
|
|
|
76
|
-
<Toolbar.Button onClick={ctx.clone} title="Clone
|
|
77
|
+
<Toolbar.Button onClick={ctx.clone} title="Clone trial">
|
|
77
78
|
Clone
|
|
78
79
|
</Toolbar.Button>
|
|
79
|
-
<Toolbar.Button onClick={ctx.reset} title="Reset
|
|
80
|
+
<Toolbar.Button onClick={ctx.reset} title="Reset trial">
|
|
80
81
|
Reset
|
|
81
82
|
</Toolbar.Button>
|
|
82
83
|
<Toolbar.Button
|
|
83
84
|
onClick={ctx.close}
|
|
84
|
-
disabled={ctx.
|
|
85
|
-
title={ctx.
|
|
85
|
+
disabled={ctx.isLastTrial}
|
|
86
|
+
title={ctx.isLastTrial ? 'Cannot close the last trial' : 'Close trial'}
|
|
86
87
|
>
|
|
87
88
|
Close
|
|
88
89
|
</Toolbar.Button>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
4
|
+
import { Lab } from '../lab/Lab';
|
|
5
|
+
|
|
6
|
+
interface St {
|
|
7
|
+
n: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ctxSpies = {
|
|
11
|
+
save: vi.fn(),
|
|
12
|
+
restore: vi.fn(),
|
|
13
|
+
setTransform: vi.fn(),
|
|
14
|
+
clearRect: vi.fn(),
|
|
15
|
+
translate: vi.fn(),
|
|
16
|
+
scale: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
21
|
+
() => ctxSpies,
|
|
22
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function makeInstrument(draw: (ctx: CanvasRenderingContext2D, args: unknown) => void) {
|
|
26
|
+
return defineInstrument<St, Record<string, never>>({
|
|
27
|
+
name: 'Probe',
|
|
28
|
+
defaultConfig: () => ({}),
|
|
29
|
+
initialState: () => ({ n: 7 }),
|
|
30
|
+
render: ({ state }) => <output data-testid="readout">n = {state.n}</output>,
|
|
31
|
+
canvas: {
|
|
32
|
+
initialView: { zoom: 2, pan: { x: 30, y: 40 } },
|
|
33
|
+
layers: [{ id: 'main', draw }],
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('an instrument that draws', () => {
|
|
39
|
+
it('still renders its own DOM, as an overlay over the canvas', () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<Lab instruments={[makeInstrument(() => undefined)]} defaultInstrument="Probe" />,
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByTestId('readout')).toHaveTextContent('n = 7');
|
|
44
|
+
expect(container.querySelector('.lk-canvas-stack__overlay')).toContainElement(
|
|
45
|
+
screen.getByTestId('readout'),
|
|
46
|
+
);
|
|
47
|
+
expect(container.querySelectorAll('canvas').length).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('draws with the camera applied, so world geometry lands where the view says', async () => {
|
|
51
|
+
const draw = vi.fn();
|
|
52
|
+
render(<Lab instruments={[makeInstrument(draw)]} defaultInstrument="Probe" />);
|
|
53
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
54
|
+
|
|
55
|
+
expect(draw).toHaveBeenCalled();
|
|
56
|
+
expect(ctxSpies.translate).toHaveBeenCalledWith(30, 40);
|
|
57
|
+
expect(ctxSpies.scale).toHaveBeenCalledWith(2, 2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('passes zoom in the args so a layer can keep line widths unscaled', async () => {
|
|
61
|
+
const draw = vi.fn();
|
|
62
|
+
render(<Lab instruments={[makeInstrument(draw)]} defaultInstrument="Probe" />);
|
|
63
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
64
|
+
|
|
65
|
+
expect(draw).toHaveBeenCalledWith(
|
|
66
|
+
expect.anything(),
|
|
67
|
+
expect.objectContaining({ zoom: 2, state: { n: 7 } }),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('LabProps.instruments', () => {
|
|
73
|
+
it('accepts a typed instrument with no cast', () => {
|
|
74
|
+
// The assignment is the assertion: this file fails to compile if
|
|
75
|
+
// `defineInstrument<St, ...>`'s result stops being assignable.
|
|
76
|
+
const typed = makeInstrument(() => undefined);
|
|
77
|
+
render(<Lab instruments={[typed]} defaultInstrument="Probe" />);
|
|
78
|
+
expect(screen.getByTestId('readout')).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -4,8 +4,8 @@ import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
|
4
4
|
import { noneAdapter } from '../state/adapters';
|
|
5
5
|
import { LabStoreContext } from '../state/context';
|
|
6
6
|
import { createLabStore } from '../state/store';
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
7
|
+
import type { TrialRecord } from '../state/types';
|
|
8
|
+
import { TrialChrome } from './TrialChrome';
|
|
9
9
|
|
|
10
10
|
const noop = () => {};
|
|
11
11
|
|
|
@@ -16,7 +16,7 @@ const stub: Instrument = {
|
|
|
16
16
|
render: () => <div className="lk-stub-display">stub experiment area</div>,
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
const record:
|
|
19
|
+
const record: TrialRecord = {
|
|
20
20
|
id: 'ws-demo',
|
|
21
21
|
instrumentName: 'Stub',
|
|
22
22
|
config: {},
|
|
@@ -29,11 +29,12 @@ function Harness() {
|
|
|
29
29
|
const store = createLabStore({ storageKey: 'sb', storage: noneAdapter });
|
|
30
30
|
const lab: LabContextValue = {
|
|
31
31
|
instruments: [stub],
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
trials: [record],
|
|
33
|
+
addTrial: noop,
|
|
34
|
+
cloneTrial: noop,
|
|
35
|
+
closeTrial: noop,
|
|
36
|
+
resetTrial: noop,
|
|
37
|
+
reorderTrials: noop,
|
|
37
38
|
savedSnapshots: [],
|
|
38
39
|
saveSnapshot: noop,
|
|
39
40
|
loadSnapshot: noop,
|
|
@@ -45,21 +46,16 @@ function Harness() {
|
|
|
45
46
|
<LabStoreContext.Provider value={{ store }}>
|
|
46
47
|
<LabContext.Provider value={lab}>
|
|
47
48
|
<div style={{ height: '500px' }}>
|
|
48
|
-
<
|
|
49
|
-
workspaceId="ws-demo"
|
|
50
|
-
record={record}
|
|
51
|
-
instrument={stub}
|
|
52
|
-
isLastWorkspace={true}
|
|
53
|
-
>
|
|
49
|
+
<TrialChrome trialId="ws-demo" record={record} instrument={stub} isLastTrial={true}>
|
|
54
50
|
{stub.render({
|
|
55
51
|
state: {},
|
|
56
52
|
config: {},
|
|
57
53
|
setState: () => {},
|
|
58
54
|
setConfig: () => {},
|
|
59
|
-
|
|
55
|
+
trial: { id: 'ws-demo', zoom: 1, setZoom: () => {} },
|
|
60
56
|
emit: () => {},
|
|
61
57
|
})}
|
|
62
|
-
</
|
|
58
|
+
</TrialChrome>
|
|
63
59
|
</div>
|
|
64
60
|
</LabContext.Provider>
|
|
65
61
|
</LabStoreContext.Provider>
|
|
@@ -67,7 +63,7 @@ function Harness() {
|
|
|
67
63
|
}
|
|
68
64
|
|
|
69
65
|
const meta: Meta<typeof Harness> = {
|
|
70
|
-
title: 'labkit/
|
|
66
|
+
title: 'labkit/Trial/Trial',
|
|
71
67
|
component: Harness,
|
|
72
68
|
parameters: { layout: 'fullscreen' },
|
|
73
69
|
};
|
|
@@ -6,14 +6,14 @@ import { Lab } from '../lab/Lab';
|
|
|
6
6
|
import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
7
7
|
import { LabStoreContext } from '../state/context';
|
|
8
8
|
import { createLabStore } from '../state/store';
|
|
9
|
-
import type {
|
|
9
|
+
import type { TrialRecord } from '../state/types';
|
|
10
10
|
import { DefaultToolbar } from './DefaultToolbar';
|
|
11
|
-
import type {
|
|
12
|
-
import {
|
|
11
|
+
import type { TrialToolbarContext } from './slotTypes';
|
|
12
|
+
import { TrialChrome } from './TrialChrome';
|
|
13
13
|
|
|
14
|
-
function makeCtx(overrides: Partial<
|
|
14
|
+
function makeCtx(overrides: Partial<TrialToolbarContext> = {}): TrialToolbarContext {
|
|
15
15
|
return {
|
|
16
|
-
|
|
16
|
+
trialId: 'ws-1',
|
|
17
17
|
instrumentName: 'Stub',
|
|
18
18
|
hasUndo: false,
|
|
19
19
|
canUndo: false,
|
|
@@ -32,7 +32,7 @@ function makeCtx(overrides: Partial<WorkspaceToolbarContext> = {}): WorkspaceToo
|
|
|
32
32
|
clone: vi.fn(),
|
|
33
33
|
reset: vi.fn(),
|
|
34
34
|
close: vi.fn(),
|
|
35
|
-
|
|
35
|
+
isLastTrial: false,
|
|
36
36
|
...overrides,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
@@ -43,8 +43,8 @@ describe('<DefaultToolbar>', () => {
|
|
|
43
43
|
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
it('disables close when
|
|
47
|
-
render(<DefaultToolbar ctx={makeCtx({
|
|
46
|
+
it('disables close when isLastTrial is true', () => {
|
|
47
|
+
render(<DefaultToolbar ctx={makeCtx({ isLastTrial: true })} />);
|
|
48
48
|
expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
|
|
49
49
|
});
|
|
50
50
|
|
|
@@ -79,7 +79,7 @@ describe('<DefaultToolbar>', () => {
|
|
|
79
79
|
{
|
|
80
80
|
id: 's1',
|
|
81
81
|
name: 'First',
|
|
82
|
-
|
|
82
|
+
trialId: 'ws-1',
|
|
83
83
|
instrumentName: 'Stub',
|
|
84
84
|
config: {},
|
|
85
85
|
state: {},
|
|
@@ -99,7 +99,7 @@ const stubInstrument: Instrument = {
|
|
|
99
99
|
render: () => null,
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
const stubRecord:
|
|
102
|
+
const stubRecord: TrialRecord = {
|
|
103
103
|
id: 'ws-1',
|
|
104
104
|
instrumentName: 'Stub',
|
|
105
105
|
config: {},
|
|
@@ -108,7 +108,7 @@ const stubRecord: WorkspaceRecord = {
|
|
|
108
108
|
undoStack: { past: [], future: [] },
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
-
type ChromeProps = Parameters<typeof
|
|
111
|
+
type ChromeProps = Parameters<typeof TrialChrome>[0];
|
|
112
112
|
|
|
113
113
|
function ChromeHarness({
|
|
114
114
|
children,
|
|
@@ -121,11 +121,12 @@ function ChromeHarness({
|
|
|
121
121
|
});
|
|
122
122
|
const labCtx: LabContextValue = {
|
|
123
123
|
instruments: [stubInstrument],
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
trials: [stubRecord],
|
|
125
|
+
addTrial: vi.fn(),
|
|
126
|
+
cloneTrial: vi.fn(),
|
|
127
|
+
closeTrial: vi.fn(),
|
|
128
|
+
resetTrial: vi.fn(),
|
|
129
|
+
reorderTrials: vi.fn(),
|
|
129
130
|
savedSnapshots: [],
|
|
130
131
|
saveSnapshot: vi.fn(),
|
|
131
132
|
loadSnapshot: vi.fn(),
|
|
@@ -137,21 +138,21 @@ function ChromeHarness({
|
|
|
137
138
|
return (
|
|
138
139
|
<LabStoreContext.Provider value={{ store }}>
|
|
139
140
|
<LabContext.Provider value={labCtx}>
|
|
140
|
-
<
|
|
141
|
-
|
|
141
|
+
<TrialChrome
|
|
142
|
+
trialId="ws-1"
|
|
142
143
|
record={stubRecord}
|
|
143
144
|
instrument={stubInstrument}
|
|
144
|
-
|
|
145
|
+
isLastTrial={false}
|
|
145
146
|
{...props}
|
|
146
147
|
>
|
|
147
148
|
{children ?? <div data-testid="content">content</div>}
|
|
148
|
-
</
|
|
149
|
+
</TrialChrome>
|
|
149
150
|
</LabContext.Provider>
|
|
150
151
|
</LabStoreContext.Provider>
|
|
151
152
|
);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
describe('<
|
|
155
|
+
describe('<TrialChrome>', () => {
|
|
155
156
|
it('renders children in the content area', () => {
|
|
156
157
|
render(<ChromeHarness />);
|
|
157
158
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
|
@@ -171,16 +172,16 @@ describe('<WorkspaceChrome>', () => {
|
|
|
171
172
|
it('Cmd+S triggers saveSnapshot', () => {
|
|
172
173
|
const saveSnapshot = vi.fn();
|
|
173
174
|
render(<ChromeHarness labOverrides={{ saveSnapshot }} />);
|
|
174
|
-
const region = screen.getByRole('region', { name: /
|
|
175
|
+
const region = screen.getByRole('region', { name: /trial/i });
|
|
175
176
|
fireEvent.keyDown(region, { key: 's', metaKey: true });
|
|
176
177
|
expect(saveSnapshot).toHaveBeenCalledWith('ws-1', undefined);
|
|
177
178
|
});
|
|
178
179
|
});
|
|
179
180
|
|
|
180
|
-
describe('<Lab> +
|
|
181
|
-
it('Lab provides context for nested
|
|
181
|
+
describe('<Lab> + TrialChrome integration', () => {
|
|
182
|
+
it('Lab provides context for nested TrialChrome', () => {
|
|
182
183
|
render(<Lab instruments={[stubInstrument]} defaultInstrument="Stub" />);
|
|
183
|
-
// Lab seeds a
|
|
184
|
+
// Lab seeds a trial; no chrome rendered without children — sanity check Lab mounts.
|
|
184
185
|
expect(document.querySelector('.lk-lab')).toBeTruthy();
|
|
185
186
|
});
|
|
186
187
|
});
|
|
@@ -15,58 +15,56 @@ import { useLabContext } from '../lab/LabContext';
|
|
|
15
15
|
import { LayerList } from '../layers/LayerList';
|
|
16
16
|
import { LabStoreContext } from '../state/context';
|
|
17
17
|
import type { LabStore } from '../state/store';
|
|
18
|
-
import type {
|
|
18
|
+
import type { TrialRecord } from '../state/types';
|
|
19
19
|
import { createEventBus, type EventBus } from '../undo/eventBus';
|
|
20
20
|
import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
|
|
21
|
-
import type { UndoBindings } from './
|
|
22
|
-
import {
|
|
21
|
+
import type { UndoBindings } from './TrialChrome';
|
|
22
|
+
import { TrialChrome } from './TrialChrome';
|
|
23
23
|
|
|
24
|
-
/** Props for `<
|
|
25
|
-
export interface
|
|
24
|
+
/** Props for `<Trial>`. */
|
|
25
|
+
export interface TrialProps {
|
|
26
26
|
id: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** Renders one
|
|
30
|
-
* instrument, and mounts the instrument inside the
|
|
31
|
-
export function
|
|
29
|
+
/** Renders one trial from the lab store: looks up its record and
|
|
30
|
+
* instrument, and mounts the instrument inside the trial chrome. */
|
|
31
|
+
export function Trial({ id }: TrialProps) {
|
|
32
32
|
const lab = useLabContext();
|
|
33
33
|
const storeCtx = useContext(LabStoreContext);
|
|
34
|
-
if (!storeCtx) throw new Error('[labkit] <
|
|
35
|
-
const record = useStore(storeCtx.store, (s) => s.
|
|
34
|
+
if (!storeCtx) throw new Error('[labkit] <Trial> requires <LabStoreProvider>');
|
|
35
|
+
const record = useStore(storeCtx.store, (s) => s.trials.find((w) => w.id === id));
|
|
36
36
|
if (!record) {
|
|
37
|
-
return <div className="lk-
|
|
37
|
+
return <div className="lk-trial lk-trial--unknown">Trial not found: {id}</div>;
|
|
38
38
|
}
|
|
39
39
|
const instrument = lab.instruments.find((i) => i.name === record.instrumentName);
|
|
40
40
|
if (!instrument) {
|
|
41
41
|
return (
|
|
42
|
-
<div className="lk-
|
|
43
|
-
Unknown instrument: {record.instrumentName}
|
|
44
|
-
</div>
|
|
42
|
+
<div className="lk-trial lk-trial--unknown">Unknown instrument: {record.instrumentName}</div>
|
|
45
43
|
);
|
|
46
44
|
}
|
|
47
45
|
return (
|
|
48
|
-
<
|
|
46
|
+
<TrialRuntime
|
|
49
47
|
record={record}
|
|
50
48
|
instrument={instrument}
|
|
51
49
|
store={storeCtx.store}
|
|
52
|
-
isLast={lab.
|
|
50
|
+
isLast={lab.trials.length <= 1}
|
|
53
51
|
/>
|
|
54
52
|
);
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
interface
|
|
58
|
-
record:
|
|
55
|
+
interface TrialRuntimeProps {
|
|
56
|
+
record: TrialRecord;
|
|
59
57
|
instrument: Instrument;
|
|
60
58
|
store: LabStore;
|
|
61
59
|
isLast: boolean;
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
function
|
|
62
|
+
function TrialRuntime({ record, instrument, store, isLast }: TrialRuntimeProps) {
|
|
65
63
|
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
64
|
+
const updateTrialState = useStore(store, (s) => s.updateTrialState);
|
|
65
|
+
const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
|
|
66
|
+
const updateTrialView = useStore(store, (s) => s.updateTrialView);
|
|
67
|
+
const updateTrialUndoStack = useStore(store, (s) => s.updateTrialUndoStack);
|
|
70
68
|
|
|
71
69
|
const busRef = useRef<EventBus | null>(null);
|
|
72
70
|
if (busRef.current === null) busRef.current = createEventBus();
|
|
@@ -81,34 +79,34 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
81
79
|
|
|
82
80
|
const snapshotIfNeeded = (event: string): void => {
|
|
83
81
|
if (!undoCap || !undoEvents.has(event)) return;
|
|
84
|
-
const current = store.getState().
|
|
82
|
+
const current = store.getState().trials.find((w) => w.id === record.id);
|
|
85
83
|
if (!current) return;
|
|
86
84
|
const snap = structuredClone(current.state);
|
|
87
|
-
|
|
85
|
+
updateTrialUndoStack(record.id, (prev) => pushSnapshot(prev, snap, maxDepth));
|
|
88
86
|
};
|
|
89
87
|
|
|
90
|
-
const setView = (v: ViewTransform): void =>
|
|
88
|
+
const setView = (v: ViewTransform): void => updateTrialView(record.id, v);
|
|
91
89
|
|
|
92
90
|
const renderCtx: RenderContext<unknown, unknown> = {
|
|
93
91
|
state: record.state,
|
|
94
92
|
config: record.config,
|
|
95
93
|
setState: (next) => {
|
|
96
94
|
snapshotIfNeeded('state.change');
|
|
97
|
-
|
|
95
|
+
updateTrialState(record.id, next);
|
|
98
96
|
bus.emit('state.change');
|
|
99
97
|
},
|
|
100
98
|
setConfig: (key, value) => {
|
|
101
99
|
const evt = `config.change:${String(key)}`;
|
|
102
100
|
snapshotIfNeeded('config.change');
|
|
103
101
|
snapshotIfNeeded(evt);
|
|
104
|
-
|
|
102
|
+
updateTrialConfig(record.id, key as never, value as never);
|
|
105
103
|
bus.emit('config.change');
|
|
106
104
|
bus.emit(evt);
|
|
107
105
|
},
|
|
108
|
-
|
|
106
|
+
trial: {
|
|
109
107
|
id: record.id,
|
|
110
108
|
zoom: record.view.zoom,
|
|
111
|
-
setZoom: (z) =>
|
|
109
|
+
setZoom: (z) => updateTrialView(record.id, { ...record.view, zoom: z }),
|
|
112
110
|
},
|
|
113
111
|
emit: (event) => {
|
|
114
112
|
snapshotIfNeeded(event);
|
|
@@ -123,14 +121,14 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
123
121
|
undo: () => {
|
|
124
122
|
const result = undoUndo(record.undoStack, structuredClone(record.state));
|
|
125
123
|
if (!result) return;
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
updateTrialState(record.id, result.snapshot as never);
|
|
125
|
+
updateTrialUndoStack(record.id, result.stack);
|
|
128
126
|
},
|
|
129
127
|
redo: () => {
|
|
130
128
|
const result = undoRedo(record.undoStack, structuredClone(record.state));
|
|
131
129
|
if (!result) return;
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
updateTrialState(record.id, result.snapshot as never);
|
|
131
|
+
updateTrialUndoStack(record.id, result.stack);
|
|
134
132
|
},
|
|
135
133
|
}
|
|
136
134
|
: undefined;
|
|
@@ -144,8 +142,13 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
144
142
|
return ordered.map((layer) => ({
|
|
145
143
|
id: layer.id,
|
|
146
144
|
visible: layerVisibility[layer.id] !== false,
|
|
147
|
-
render: (ctx, view) =>
|
|
148
|
-
|
|
145
|
+
render: (ctx, view) => {
|
|
146
|
+
// Camera applied here, so a layer draws in world coordinates. `zoom`
|
|
147
|
+
// stays in the args for line widths, which must not scale with it.
|
|
148
|
+
ctx.translate(view.pan.x, view.pan.y);
|
|
149
|
+
ctx.scale(view.zoom, view.zoom);
|
|
150
|
+
layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom });
|
|
151
|
+
},
|
|
149
152
|
}));
|
|
150
153
|
}, [instrument.canvas, record.state, record.config, layerVisibility, layerOrder]);
|
|
151
154
|
|
|
@@ -162,7 +165,7 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
162
165
|
config: record.config,
|
|
163
166
|
setState: (next) => {
|
|
164
167
|
snapshotIfNeeded('canvas.itemAdded');
|
|
165
|
-
|
|
168
|
+
updateTrialState(record.id, next as never);
|
|
166
169
|
},
|
|
167
170
|
emit: (evt) => {
|
|
168
171
|
snapshotIfNeeded(evt);
|
|
@@ -206,10 +209,12 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
206
209
|
body = (
|
|
207
210
|
<div
|
|
208
211
|
ref={canvasContainerRef}
|
|
209
|
-
className="lk-
|
|
212
|
+
className="lk-trial__canvas-host"
|
|
210
213
|
style={{ width: '100%', height: '100%', position: 'relative' }}
|
|
211
214
|
>
|
|
212
|
-
<CanvasStack layers={layersWithFeedback} view={record.view} onViewChange={setView}
|
|
215
|
+
<CanvasStack layers={layersWithFeedback} view={record.view} onViewChange={setView}>
|
|
216
|
+
{instrument.render(renderCtx)}
|
|
217
|
+
</CanvasStack>
|
|
213
218
|
<DragOverlay drag={dragDropResult.drag} />
|
|
214
219
|
</div>
|
|
215
220
|
);
|
|
@@ -239,11 +244,11 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
239
244
|
) : null;
|
|
240
245
|
|
|
241
246
|
return (
|
|
242
|
-
<
|
|
243
|
-
|
|
247
|
+
<TrialChrome
|
|
248
|
+
trialId={record.id}
|
|
244
249
|
record={record}
|
|
245
250
|
instrument={instrument}
|
|
246
|
-
|
|
251
|
+
isLastTrial={isLast}
|
|
247
252
|
undoBindings={undoBindings}
|
|
248
253
|
sidebarExtras={
|
|
249
254
|
<>
|
|
@@ -253,6 +258,6 @@ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntim
|
|
|
253
258
|
}
|
|
254
259
|
>
|
|
255
260
|
{body}
|
|
256
|
-
</
|
|
261
|
+
</TrialChrome>
|
|
257
262
|
);
|
|
258
263
|
}
|