@weasel-js/labkit 1.4.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
- package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
- package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
- package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
- package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
- package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
- package/dist/canvas/index.d.ts +17 -128
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +4 -92
- package/dist/chrome/index.js +9 -5
- package/dist/chunk-67SJLMC7.js +704 -0
- package/dist/chunk-67SJLMC7.js.map +1 -0
- package/dist/chunk-6ZDGOZQV.js +310 -0
- package/dist/chunk-6ZDGOZQV.js.map +1 -0
- package/dist/{chunk-MOM3GOVY.js → chunk-EKIICY6X.js} +111 -25
- package/dist/chunk-EKIICY6X.js.map +1 -0
- package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
- package/dist/chunk-H6ZAOWNE.js.map +1 -0
- package/dist/{chunk-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
- package/dist/chunk-KJALCDWE.js.map +1 -0
- package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
- package/dist/{chunk-BKFVHKJH.js.map → chunk-MUKOW3TC.js.map} +1 -1
- package/dist/chunk-NDRYLVOW.js +15 -0
- package/dist/chunk-NDRYLVOW.js.map +1 -0
- package/dist/{chunk-64ZCN3DA.js → chunk-NS64DXMN.js} +226 -159
- package/dist/chunk-NS64DXMN.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
- package/dist/chunk-PGETSEDK.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
- package/dist/chunk-QLSV2N3G.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-S2SHZRD7.js +7681 -0
- package/dist/chunk-S2SHZRD7.js.map +1 -0
- package/dist/{chunk-U3IYHIAE.js → chunk-TQBBYTAF.js} +19 -82
- package/dist/chunk-TQBBYTAF.js.map +1 -0
- package/dist/chunk-UU3NJO6N.js +72 -0
- package/dist/chunk-UU3NJO6N.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
- package/dist/chunk-ZPH5WTWF.js.map +1 -0
- package/dist/config/index.d.ts +232 -0
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +2 -3
- package/dist/controls/index.js +3 -2
- package/dist/dragdrop/index.d.ts +2 -4
- package/dist/index.d.ts +110 -381
- package/dist/index.js +691 -817
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +3 -5
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +6 -8
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +164 -122
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +53 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +12 -7
- package/dist/state/index.js +16 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +84 -50
- package/dist/surface/index.d.ts +44 -7
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +2 -4
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +53 -5
- package/src/annotations/Annotations.overlay.test.tsx +85 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/paint.test.ts +2 -2
- package/src/annotations/paint.ts +1 -1
- package/src/annotations/preload.ts +6 -0
- package/src/annotations/store.test.ts +18 -0
- package/src/annotations/store.ts +5 -4
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/toolMap.ts +3 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.test.tsx +25 -0
- package/src/canvas/CanvasStack.tsx +31 -4
- package/src/canvas/Stage.less +18 -0
- package/src/canvas/Stage.test.tsx +186 -0
- package/src/canvas/Stage.tsx +134 -0
- package/src/canvas/camera.test.ts +17 -2
- package/src/canvas/camera.ts +9 -9
- package/src/canvas/index.ts +4 -0
- package/src/canvas/usePanZoom.ts +10 -5
- package/src/chrome/ChromeRegions.stories.tsx +0 -3
- package/src/chrome/LabChrome.tsx +114 -0
- package/src/chrome/builtins.tsx +20 -31
- package/src/chrome/index.ts +15 -0
- package/src/chrome/labRegions.test.tsx +208 -0
- package/src/chrome/labTypes.ts +50 -0
- package/src/chrome/merge.ts +7 -9
- package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
- package/src/chrome/regions/PaletteRegion.tsx +21 -9
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +30 -16
- package/src/chrome/types.ts +47 -20
- package/src/config/builder.test.ts +4 -0
- package/src/config/builder.ts +16 -10
- package/src/config/declarationEmit.fixture.ts +11 -0
- package/src/config/declarationEmit.test.ts +51 -0
- package/src/config/entry.test.ts +46 -0
- package/src/config/index.ts +13 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/types.ts +3 -1
- package/src/controls/ControlPanel.test.tsx +16 -0
- package/src/controls/ControlPanel.tsx +5 -2
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +38 -5
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +11 -0
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +0 -2
- package/src/lab/Lab.surface.test.tsx +66 -3
- package/src/lab/Lab.test.tsx +91 -17
- package/src/lab/Lab.tsx +337 -124
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +0 -3
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -13
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabShell.less +49 -12
- package/src/lab/LabSwitcher.less +8 -1
- package/src/lab/Workspace.surface.test.tsx +2 -1
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/loupe/AGENTS.md +1 -1
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +1 -1
- package/src/loupe/useLoupe.ts +1 -1
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +13 -1
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +108 -0
- package/src/primitives/Split.tsx +159 -0
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- package/src/state/Persistence.stories.tsx +75 -0
- package/src/state/Persistence.tsx +33 -0
- package/src/state/SingletonExperiment.test.tsx +47 -53
- package/src/state/SingletonExperiment.tsx +24 -13
- package/src/state/adapterContract.ts +116 -0
- package/src/state/adapters.test.ts +113 -61
- package/src/state/adapters.ts +328 -69
- package/src/state/document.test.ts +71 -96
- package/src/state/document.ts +66 -52
- package/src/state/helpers.test.ts +1 -1
- package/src/state/index.ts +8 -0
- package/src/state/labRecords.test.ts +105 -0
- package/src/state/labRecords.ts +160 -0
- package/src/state/openLabStore.test.ts +658 -0
- package/src/state/openLabStore.ts +403 -0
- package/src/state/records.test.ts +234 -0
- package/src/state/records.ts +219 -0
- package/src/state/store.test.ts +104 -635
- package/src/state/store.ts +116 -157
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +39 -15
- package/src/state/useOpenOnce.ts +68 -0
- package/src/state/usePersistedState.test.tsx +125 -0
- package/src/state/usePersistedState.ts +66 -0
- package/src/state/useTrialState.test.tsx +2 -3
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +2 -0
- package/src/surface/AGENTS.md +39 -7
- package/src/surface/SurfaceContext.ts +26 -3
- package/src/surface/index.ts +2 -0
- package/src/surface/useSurfaceTile.test.tsx +2 -1
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +175 -0
- package/src/surface/useTiledSurface.ts +54 -5
- package/src/theme/Interstellar.stories.tsx +7 -7
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.tokens.json +2 -2
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +47 -4
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +1 -26
- package/src/trial/Trial.stories.tsx +1 -2
- package/src/trial/Trial.test.tsx +28 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +118 -38
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +7 -4
- package/src/trial/trialOps.ts +5 -3
- package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
- package/dist/chunk-2P6PP5N4.js +0 -553
- package/dist/chunk-2P6PP5N4.js.map +0 -1
- package/dist/chunk-64ZCN3DA.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MOM3GOVY.js.map +0 -1
- package/dist/chunk-SMHP6XZ4.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
- package/dist/chunk-U3IYHIAE.js.map +0 -1
- package/dist/chunk-W3ECWC2K.js +0 -7335
- package/dist/chunk-W3ECWC2K.js.map +0 -1
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* follow. The overlay's own geometry is covered in
|
|
5
5
|
* `annotations/Annotations.overlay.test.tsx`.
|
|
6
6
|
*/
|
|
7
|
-
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
7
|
+
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
|
8
8
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
9
9
|
import { useAnnotations, useAnnotationsOptional } from '../annotations/AnnotationsContext';
|
|
10
10
|
import type { AnnotationsApi } from '../annotations/types';
|
|
11
11
|
import { defineInstrument } from '../instrument/defineInstrument';
|
|
12
12
|
import { Lab } from '../lab/Lab';
|
|
13
|
+
import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
13
14
|
|
|
14
15
|
beforeAll(() => {
|
|
15
16
|
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
@@ -19,9 +20,9 @@ beforeAll(() => {
|
|
|
19
20
|
|
|
20
21
|
let api: AnnotationsApi | null = null;
|
|
21
22
|
|
|
22
|
-
function Pane() {
|
|
23
|
+
function Pane({ tool }: { tool: string | null }) {
|
|
23
24
|
api = useAnnotations();
|
|
24
|
-
return <div data-testid="pane" data-marks={api.query().length} />;
|
|
25
|
+
return <div data-testid="pane" data-marks={api.query().length} data-tool={tool ?? ''} />;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function marks(): AnnotationsApi {
|
|
@@ -33,7 +34,7 @@ const annotating = defineInstrument<Record<string, never>, Record<string, never>
|
|
|
33
34
|
name: 'Annotating',
|
|
34
35
|
defaultConfig: () => ({}),
|
|
35
36
|
initialState: () => ({}),
|
|
36
|
-
render: () => <Pane />,
|
|
37
|
+
render: (ctx) => <Pane tool={ctx.trial.activeToolId} />,
|
|
37
38
|
annotations: {
|
|
38
39
|
targets: () => [{ id: 'pane', ref: { current: null }, content: { w: 200, h: 100 } }],
|
|
39
40
|
},
|
|
@@ -72,6 +73,48 @@ describe('an instrument that declares annotations', () => {
|
|
|
72
73
|
});
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
let labRef: LabContextValue | null = null;
|
|
77
|
+
|
|
78
|
+
function CaptureLab() {
|
|
79
|
+
return (
|
|
80
|
+
<LabContext.Consumer>
|
|
81
|
+
{(value) => {
|
|
82
|
+
if (value) labRef = value;
|
|
83
|
+
return null;
|
|
84
|
+
}}
|
|
85
|
+
</LabContext.Consumer>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('the annotation tool', () => {
|
|
90
|
+
it("is offered in the lab's rail, not in a trial's", () => {
|
|
91
|
+
render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
|
|
92
|
+
expect(screen.getAllByRole('button', { name: 'Rectangle' })).toHaveLength(1);
|
|
93
|
+
const trial = screen.getByRole('region', { name: /trial/i });
|
|
94
|
+
expect(within(trial).queryByRole('button', { name: 'Rectangle' })).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('is one tool across every trial', () => {
|
|
98
|
+
// A tool is what the hand is holding, not a property of a picture: picking
|
|
99
|
+
// Rectangle once has to arm it wherever the next mark is drawn.
|
|
100
|
+
labRef = null;
|
|
101
|
+
render(
|
|
102
|
+
<Lab instruments={[annotating]} defaultInstrument="Annotating">
|
|
103
|
+
<CaptureLab />
|
|
104
|
+
</Lab>,
|
|
105
|
+
);
|
|
106
|
+
act(() => labRef?.addTrial('Annotating'));
|
|
107
|
+
const panes = screen.getAllByTestId('pane');
|
|
108
|
+
expect(panes).toHaveLength(2);
|
|
109
|
+
for (const p of panes) expect(p.dataset.tool).toBe('select');
|
|
110
|
+
|
|
111
|
+
act(() => {
|
|
112
|
+
fireEvent.click(screen.getByRole('button', { name: 'Rectangle' }));
|
|
113
|
+
});
|
|
114
|
+
for (const p of screen.getAllByTestId('pane')) expect(p.dataset.tool).toBe('rect');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
75
118
|
describe('the trial undo chrome over marks', () => {
|
|
76
119
|
it('gets undo and redo without the instrument declaring `undo`', () => {
|
|
77
120
|
// Weasel history is the authority for marks, so the capability that
|
|
@@ -36,7 +36,7 @@ function makeInstrument(name: string, n: number, delay: number) {
|
|
|
36
36
|
describe('a trial whose instrument declares a job', () => {
|
|
37
37
|
it('shows progress in the trial chrome', async () => {
|
|
38
38
|
const fast = makeInstrument('fast', 3, 1);
|
|
39
|
-
render(<Lab instruments={[fast]} defaultInstrument="fast"
|
|
39
|
+
render(<Lab instruments={[fast]} defaultInstrument="fast" />);
|
|
40
40
|
const bar = await waitFor(() => screen.getByRole('progressbar', { name: /job progress/i }));
|
|
41
41
|
await waitFor(() => {
|
|
42
42
|
// A job that reported a total drives a determinate bar, so the count and
|
|
@@ -49,7 +49,7 @@ describe('a trial whose instrument declares a job', () => {
|
|
|
49
49
|
|
|
50
50
|
it('offers a cancel control while running, and drops it once cancelled', async () => {
|
|
51
51
|
const slow = makeInstrument('slow', 40, 20);
|
|
52
|
-
render(<Lab instruments={[slow]} defaultInstrument="slow"
|
|
52
|
+
render(<Lab instruments={[slow]} defaultInstrument="slow" />);
|
|
53
53
|
const cancel = await waitFor(() => screen.getByRole('button', { name: /cancel/i }));
|
|
54
54
|
await userEvent.click(cancel);
|
|
55
55
|
await waitFor(() => {
|
|
@@ -64,7 +64,7 @@ describe('a trial whose instrument declares a job', () => {
|
|
|
64
64
|
initialState: () => ({ items: [] }),
|
|
65
65
|
render: () => null,
|
|
66
66
|
});
|
|
67
|
-
render(<Lab instruments={[plain]} defaultInstrument="plain"
|
|
67
|
+
render(<Lab instruments={[plain]} defaultInstrument="plain" />);
|
|
68
68
|
await waitFor(() => {
|
|
69
69
|
expect(document.querySelector('.lk-trial')).toBeInTheDocument();
|
|
70
70
|
});
|
package/src/trial/Trial.less
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// from its own workspace by nothing but a hairline. Raised reads as a panel,
|
|
10
10
|
// and in dark it is translucent, so the backdrop still shows through.
|
|
11
11
|
background: var(--wzl-surface-raised);
|
|
12
|
-
border: var(--wzl-border-w) solid var(--wzl-border-
|
|
12
|
+
border: var(--wzl-border-w) solid var(--wzl-border-strong);
|
|
13
13
|
border-radius: var(--wzl-radius-md);
|
|
14
14
|
box-shadow: 0 1px 3px var(--wzl-shadow);
|
|
15
15
|
overflow: hidden;
|
|
@@ -96,21 +96,6 @@
|
|
|
96
96
|
flex-shrink: 0;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
// The strip's own seam. windease positions and sizes it; this is only what
|
|
100
|
-
// it looks like.
|
|
101
|
-
&__panes [data-affordance-kind='resize-x'] {
|
|
102
|
-
background: var(--wzl-border);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
&__panes .windease-affordance-hit:hover [data-affordance-kind='resize-x'],
|
|
106
|
-
&__panes .windease-affordance-hit:focus-visible [data-affordance-kind='resize-x'] {
|
|
107
|
-
background: var(--wzl-accent);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
&__panes .windease-affordance-hit:focus-visible {
|
|
111
|
-
outline: none;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
99
|
&--unknown {
|
|
115
100
|
display: flex;
|
|
116
101
|
align-items: center;
|
|
@@ -126,16 +111,6 @@
|
|
|
126
111
|
padding: 0 var(--wzl-space-sm);
|
|
127
112
|
}
|
|
128
113
|
|
|
129
|
-
.lk-toolbar__load-select {
|
|
130
|
-
font-size: var(--wzl-font-size-sm);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Same `width: 100%` default as the header's Select — pinned so it cannot
|
|
134
|
-
// absorb the toolbar's slack. Placeholder-only, so it need only fit "Load…".
|
|
135
|
-
.lk-root .lk-toolbar__load-select {
|
|
136
|
-
width: 88px;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
114
|
.lk-sidebar__placeholder {
|
|
140
115
|
padding: var(--wzl-space-sm);
|
|
141
116
|
color: var(--wzl-fg-muted);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
import type { Instrument } from '../instrument/types';
|
|
3
3
|
import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
4
|
-
import { noneAdapter } from '../state/adapters';
|
|
5
4
|
import { LabStoreContext } from '../state/context';
|
|
6
5
|
import { createLabStore } from '../state/store';
|
|
7
6
|
import type { TrialRecord } from '../state/types';
|
|
@@ -27,7 +26,7 @@ const record: TrialRecord = {
|
|
|
27
26
|
};
|
|
28
27
|
|
|
29
28
|
function Harness() {
|
|
30
|
-
const store = createLabStore(
|
|
29
|
+
const store = createLabStore();
|
|
31
30
|
const lab: LabContextValue = {
|
|
32
31
|
instruments: [stub],
|
|
33
32
|
trials: [record],
|
package/src/trial/Trial.test.tsx
CHANGED
|
@@ -35,10 +35,7 @@ type ChromeProps = Parameters<typeof TrialChrome>[0];
|
|
|
35
35
|
* and section folds live on the record, so a test that outlives one mount
|
|
36
36
|
* keeps its own store and hands it to both. */
|
|
37
37
|
function makeChromeStore() {
|
|
38
|
-
const store = createLabStore(
|
|
39
|
-
storageKey: 'test',
|
|
40
|
-
storage: { read: () => null, write: () => {} },
|
|
41
|
-
});
|
|
38
|
+
const store = createLabStore();
|
|
42
39
|
const { undoStack: _undo, ...seed } = stubRecord;
|
|
43
40
|
store.getState().addTrial(seed);
|
|
44
41
|
return store;
|
|
@@ -205,6 +202,33 @@ describe('<TrialChrome>', () => {
|
|
|
205
202
|
expect(screen.getByRole('region', { name: 'Trial Sprocket 7' })).toBeInTheDocument();
|
|
206
203
|
});
|
|
207
204
|
|
|
205
|
+
it("reads the instrument's title over its name, and returns to it on setTitle(null)", () => {
|
|
206
|
+
const titled: Instrument = { ...stubInstrument, title: 'Stub / Default' };
|
|
207
|
+
render(
|
|
208
|
+
<ChromeHarness
|
|
209
|
+
instrument={titled}
|
|
210
|
+
chrome={[
|
|
211
|
+
{
|
|
212
|
+
id: 'rename',
|
|
213
|
+
region: 'titlebar',
|
|
214
|
+
item: { icon: Glyph, label: 'Rename', onActivate: (ctx) => ctx.setTitle('Sprocket 7') },
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: 'untitle',
|
|
218
|
+
region: 'titlebar',
|
|
219
|
+
item: { icon: Glyph, label: 'Untitle', onActivate: (ctx) => ctx.setTitle(null) },
|
|
220
|
+
},
|
|
221
|
+
]}
|
|
222
|
+
/>,
|
|
223
|
+
);
|
|
224
|
+
expect(screen.getByRole('region', { name: 'Trial Stub / Default' })).toBeInTheDocument();
|
|
225
|
+
expect(screen.getByText('Stub / Default')).toBeInTheDocument();
|
|
226
|
+
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
|
|
227
|
+
expect(screen.getByRole('region', { name: 'Trial Sprocket 7' })).toBeInTheDocument();
|
|
228
|
+
fireEvent.click(screen.getByRole('button', { name: 'Untitle' }));
|
|
229
|
+
expect(screen.getByRole('region', { name: 'Trial Stub / Default' })).toBeInTheDocument();
|
|
230
|
+
});
|
|
231
|
+
|
|
208
232
|
it('Cmd+S triggers saveSnapshot', () => {
|
|
209
233
|
const saveSnapshot = vi.fn();
|
|
210
234
|
render(<ChromeHarness labOverrides={{ saveSnapshot }} />);
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* `SingletonExperiment` — so `useTrialState()` threw everywhere else, which
|
|
5
5
|
* made the documented hook pattern unreachable from an instrument's `render`.
|
|
6
6
|
*/
|
|
7
|
-
import { render, screen } from '@testing-library/react';
|
|
7
|
+
import { act, render, screen } from '@testing-library/react';
|
|
8
8
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
9
9
|
import { defineInstrument } from '../instrument/defineInstrument';
|
|
10
10
|
import { Lab } from '../lab/Lab';
|
|
11
|
+
import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
11
12
|
import { useTrialState } from '../state/useTrialState';
|
|
12
13
|
|
|
13
14
|
beforeAll(() => {
|
|
@@ -34,3 +35,24 @@ describe('<Trial> trial-scoped hooks', () => {
|
|
|
34
35
|
expect(screen.getByTestId('probe')).toHaveTextContent('hits:7');
|
|
35
36
|
});
|
|
36
37
|
});
|
|
38
|
+
|
|
39
|
+
describe('<Trial> root element', () => {
|
|
40
|
+
it('carries its trial id, so a lab can find a trial from outside', () => {
|
|
41
|
+
let labRef: LabContextValue | null = null;
|
|
42
|
+
const { container } = render(
|
|
43
|
+
<Lab instruments={[reader]} defaultInstrument="Reader">
|
|
44
|
+
<LabContext.Consumer>
|
|
45
|
+
{(value) => {
|
|
46
|
+
if (value) labRef = value;
|
|
47
|
+
return null;
|
|
48
|
+
}}
|
|
49
|
+
</LabContext.Consumer>
|
|
50
|
+
</Lab>,
|
|
51
|
+
);
|
|
52
|
+
act(() => (labRef as LabContextValue | null)?.addTrial('Reader'));
|
|
53
|
+
const ids = (labRef as LabContextValue | null)?.trials.map((t) => t.id) ?? [];
|
|
54
|
+
expect(ids).toHaveLength(2);
|
|
55
|
+
const roots = container.querySelectorAll<HTMLElement>('.lk-trial[data-trial-id]');
|
|
56
|
+
expect([...roots].map((el) => el.dataset.trialId)).toEqual(ids);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/trial/Trial.tsx
CHANGED
|
@@ -2,15 +2,24 @@ import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from
|
|
|
2
2
|
import { useStore } from 'zustand/react';
|
|
3
3
|
import { AnnotationsContext } from '../annotations/AnnotationsContext';
|
|
4
4
|
import { AnnotationTargets } from '../annotations/AnnotationTargets';
|
|
5
|
+
import { AnnotationPreloadContext } from '../annotations/preload';
|
|
5
6
|
import { annotationsFromJSON } from '../annotations/store';
|
|
6
|
-
import type { AnnotationTargetInfo } from '../annotations/types';
|
|
7
|
+
import type { AnnotationStorage, AnnotationTargetInfo } from '../annotations/types';
|
|
8
|
+
import { CameraWheelContext, type CameraWheelSlot } from '../canvas/CameraWheelContext';
|
|
7
9
|
import { CanvasStack } from '../canvas/CanvasStack';
|
|
10
|
+
import { fitStage, Stage } from '../canvas/Stage';
|
|
8
11
|
import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
|
|
9
12
|
import { applyCamera, type ViewportSize } from '../canvas/worldSpec';
|
|
10
13
|
import type { TrialContribution } from '../chrome/types';
|
|
11
14
|
import { DragOverlay, useDragDrop } from '../dragdrop/DragDropRuntime';
|
|
12
15
|
import { Palette } from '../dragdrop/Palette';
|
|
13
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
Instrument,
|
|
18
|
+
InstrumentList,
|
|
19
|
+
LayerDescriptor,
|
|
20
|
+
PaletteItem,
|
|
21
|
+
RenderContext,
|
|
22
|
+
} from '../instrument/types';
|
|
14
23
|
import { useJob } from '../job/useJob';
|
|
15
24
|
import { useLabContext } from '../lab/LabContext';
|
|
16
25
|
import { LayerList } from '../layers/LayerList';
|
|
@@ -19,7 +28,8 @@ import { resolveLoupe } from '../loupe/types';
|
|
|
19
28
|
import { LabStoreContext, TrialIdProvider } from '../state/context';
|
|
20
29
|
import type { LabStore } from '../state/store';
|
|
21
30
|
import type { TrialRecord } from '../state/types';
|
|
22
|
-
import { as2DView, DEFAULT_VIEW } from '../state/view';
|
|
31
|
+
import { as2DView, DEFAULT_VIEW, withZoom } from '../state/view';
|
|
32
|
+
import { resolveLabTool } from '../tools/labTool';
|
|
23
33
|
import { createEventBus, type EventBus } from '../undo/eventBus';
|
|
24
34
|
import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
|
|
25
35
|
import type { LoupeBindings, UndoBindings } from './TrialChrome';
|
|
@@ -41,20 +51,25 @@ export function Trial({ id, chrome, suppress }: TrialProps) {
|
|
|
41
51
|
const lab = useLabContext();
|
|
42
52
|
const storeCtx = useContext(LabStoreContext);
|
|
43
53
|
if (!storeCtx) throw new Error('[labkit] <Trial> requires <LabStoreProvider>');
|
|
54
|
+
const instruments = useStore(storeCtx.store, (s) => s.instruments) ?? lab.instruments;
|
|
44
55
|
const record = useStore(storeCtx.store, (s) => s.trials.find((w) => w.id === id));
|
|
56
|
+
const instrument = record ? instruments.find((i) => i.name === record.instrumentName) : undefined;
|
|
57
|
+
const kept = useKeptMarks(instrument?.annotations?.storage, id);
|
|
45
58
|
if (!record) {
|
|
46
59
|
return <div className="lk-trial lk-trial--unknown">Trial not found: {id}</div>;
|
|
47
60
|
}
|
|
48
|
-
const instrument = lab.instruments.find((i) => i.name === record.instrumentName);
|
|
49
61
|
if (!instrument) {
|
|
50
62
|
return (
|
|
51
63
|
<div className="lk-trial lk-trial--unknown">Unknown instrument: {record.instrumentName}</div>
|
|
52
64
|
);
|
|
53
65
|
}
|
|
66
|
+
if (!kept.ready) return <div className="lk-trial lk-trial--loading" />;
|
|
54
67
|
return (
|
|
55
68
|
<TrialRuntime
|
|
69
|
+
keptMarks={kept.value}
|
|
56
70
|
record={record}
|
|
57
71
|
instrument={instrument}
|
|
72
|
+
instruments={instruments}
|
|
58
73
|
store={storeCtx.store}
|
|
59
74
|
isLast={lab.trials.length <= 1}
|
|
60
75
|
chrome={chrome}
|
|
@@ -63,16 +78,60 @@ export function Trial({ id, chrome, suppress }: TrialProps) {
|
|
|
63
78
|
);
|
|
64
79
|
}
|
|
65
80
|
|
|
81
|
+
/** The marks an instrument keeps in its own storage: preloaded by the lab for
|
|
82
|
+
* the trials it opened with, loaded here for any added since. */
|
|
83
|
+
function useKeptMarks(
|
|
84
|
+
storage: AnnotationStorage | undefined,
|
|
85
|
+
trialId: string,
|
|
86
|
+
): { ready: boolean; value: unknown } {
|
|
87
|
+
const preload = useContext(AnnotationPreloadContext);
|
|
88
|
+
const [loaded, setLoaded] = useState<{ value: unknown } | null>(() =>
|
|
89
|
+
storage && preload?.has(trialId) ? { value: preload.get(trialId) } : null,
|
|
90
|
+
);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!storage || loaded) return;
|
|
93
|
+
let live = true;
|
|
94
|
+
storage.load().then(
|
|
95
|
+
(value) => {
|
|
96
|
+
if (live) setLoaded({ value });
|
|
97
|
+
},
|
|
98
|
+
(error) => {
|
|
99
|
+
console.warn(`[labkit] could not load the marks of trial "${trialId}"`, error);
|
|
100
|
+
if (live) setLoaded({ value: null });
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
return () => {
|
|
104
|
+
live = false;
|
|
105
|
+
};
|
|
106
|
+
}, [storage, loaded, trialId]);
|
|
107
|
+
if (!storage) return { ready: true, value: undefined };
|
|
108
|
+
return loaded ? { ready: true, value: loaded.value } : { ready: false, value: undefined };
|
|
109
|
+
}
|
|
110
|
+
|
|
66
111
|
interface TrialRuntimeProps {
|
|
112
|
+
/** The marks an instrument's own `AnnotationStorage` loaded. */
|
|
113
|
+
keptMarks: unknown;
|
|
67
114
|
record: TrialRecord;
|
|
68
115
|
instrument: Instrument;
|
|
116
|
+
/** The lab's instruments as the store holds them now. */
|
|
117
|
+
instruments: InstrumentList;
|
|
69
118
|
store: LabStore;
|
|
70
119
|
isLast: boolean;
|
|
71
120
|
chrome?: readonly TrialContribution[];
|
|
72
121
|
suppress?: readonly string[];
|
|
73
122
|
}
|
|
74
123
|
|
|
75
|
-
function TrialRuntime({
|
|
124
|
+
function TrialRuntime({
|
|
125
|
+
keptMarks,
|
|
126
|
+
record,
|
|
127
|
+
instrument,
|
|
128
|
+
instruments,
|
|
129
|
+
store,
|
|
130
|
+
isLast,
|
|
131
|
+
chrome,
|
|
132
|
+
suppress,
|
|
133
|
+
}: TrialRuntimeProps) {
|
|
134
|
+
const wheelSlot = useRef<CameraWheelSlot['current']>(null);
|
|
76
135
|
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
|
|
77
136
|
const loupeHostRef = useRef<HTMLDivElement | null>(null);
|
|
78
137
|
const updateTrialState = useStore(store, (s) => s.updateTrialState);
|
|
@@ -117,8 +176,11 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
|
|
|
117
176
|
// A function `initialView` needs the canvas size, so `trialOps` leaves the
|
|
118
177
|
// view null and the first measurement resolves it. Reset nulls it again,
|
|
119
178
|
// which re-frames — so the guard is the null itself, not a "have I run" flag.
|
|
179
|
+
const stage = instrument.canvas ? undefined : instrument.stage;
|
|
120
180
|
const placeView = (size: ViewportSize): void => {
|
|
121
|
-
const declared =
|
|
181
|
+
const declared =
|
|
182
|
+
instrument.canvas?.initialView ??
|
|
183
|
+
(stage ? (stage.initialView ?? ((vp: ViewportSize) => fitStage(stage.size, vp))) : undefined);
|
|
122
184
|
if (typeof declared !== 'function') return;
|
|
123
185
|
if (record.view != null) return;
|
|
124
186
|
setView(declared(size));
|
|
@@ -134,21 +196,23 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
|
|
|
134
196
|
? annotationsCap.targets(record.state, record.config, { id: record.id, view: record.view })
|
|
135
197
|
: [];
|
|
136
198
|
|
|
137
|
-
// One store for the trial's lifetime.
|
|
138
|
-
// the storage slot is 3d.
|
|
199
|
+
// One store for the trial's lifetime.
|
|
139
200
|
const annotationsRef = useRef<ReturnType<typeof annotationsFromJSON> | null>(null);
|
|
140
201
|
// Read through refs for the same reason `targets` is: the store is built
|
|
141
202
|
// once, and an export must draw against the config the trial holds now.
|
|
142
203
|
const configRef = useRef<unknown>(record.config);
|
|
143
204
|
configRef.current = record.config;
|
|
205
|
+
// The instrument can be swapped under a live trial, so the capability is read at use too.
|
|
206
|
+
const capRef = useRef(annotationsCap);
|
|
207
|
+
capRef.current = annotationsCap;
|
|
144
208
|
if (annotationsRef.current === null) {
|
|
145
209
|
// Seeded from wherever the marks were kept: the instrument's own store if
|
|
146
210
|
// it declared one, else this trial's slot.
|
|
147
|
-
const kept = annotationsCap?.storage ?
|
|
211
|
+
const kept = annotationsCap?.storage ? keptMarks : record.annotations;
|
|
148
212
|
annotationsRef.current = annotationsFromJSON(kept, () => targetsRef.current(), {
|
|
149
|
-
meaning:
|
|
213
|
+
meaning: () => capRef.current?.meaning,
|
|
150
214
|
config: () => configRef.current,
|
|
151
|
-
onCapture: (result) =>
|
|
215
|
+
onCapture: (result) => capRef.current?.onCapture?.(result),
|
|
152
216
|
});
|
|
153
217
|
}
|
|
154
218
|
const annotations = annotationsRef.current;
|
|
@@ -203,15 +267,14 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
|
|
|
203
267
|
|
|
204
268
|
// A trial gets its own slot when its instrument declares tools; otherwise it
|
|
205
269
|
// reads the lab's. Which slot a change writes follows from the same thing.
|
|
206
|
-
// Annotation tools
|
|
207
|
-
//
|
|
208
|
-
|
|
270
|
+
// Annotation tools always read the lab's: a tool is what the hand holds, so
|
|
271
|
+
// picking one arms it in every trial, the way artboards share a tool. They
|
|
272
|
+
// were once per trial, back when one trial compared several pictures.
|
|
273
|
+
const declaresTools = instrument.tools != null;
|
|
274
|
+
const labTool = resolveLabTool(labToolId, instruments);
|
|
209
275
|
const resolvedToolId = declaresTools
|
|
210
|
-
? (record.activeToolId ??
|
|
211
|
-
|
|
212
|
-
instrument.tools?.tools[0]?.id ??
|
|
213
|
-
(instrument.annotations ? 'select' : null))
|
|
214
|
-
: labToolId;
|
|
276
|
+
? (record.activeToolId ?? instrument.tools?.initial ?? instrument.tools?.tools[0]?.id ?? null)
|
|
277
|
+
: labTool;
|
|
215
278
|
const setActiveTool = (id: string): void => {
|
|
216
279
|
if (declaresTools) setTrialTool(record.id, id);
|
|
217
280
|
else setLabTool(id);
|
|
@@ -254,7 +317,7 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
|
|
|
254
317
|
zoom: view2d?.zoom ?? 1,
|
|
255
318
|
setZoom: (z) => {
|
|
256
319
|
if (!view2d) return;
|
|
257
|
-
updateTrialView(record.id,
|
|
320
|
+
updateTrialView(record.id, withZoom(view2d, z));
|
|
258
321
|
},
|
|
259
322
|
activeToolId: resolvedToolId,
|
|
260
323
|
visibleLayers,
|
|
@@ -412,6 +475,21 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
|
|
|
412
475
|
<DragOverlay drag={dragDropResult.drag} />
|
|
413
476
|
</div>
|
|
414
477
|
);
|
|
478
|
+
} else if (stage) {
|
|
479
|
+
body = (
|
|
480
|
+
<Stage
|
|
481
|
+
size={stage.size}
|
|
482
|
+
view={view2d ?? DEFAULT_VIEW}
|
|
483
|
+
onViewChange={setView}
|
|
484
|
+
onResize={placeView}
|
|
485
|
+
minZoom={stage.minZoom}
|
|
486
|
+
maxZoom={stage.maxZoom}
|
|
487
|
+
hostRef={loupeHostRef}
|
|
488
|
+
overlay={lens}
|
|
489
|
+
>
|
|
490
|
+
{instrument.render(renderCtx)}
|
|
491
|
+
</Stage>
|
|
492
|
+
);
|
|
415
493
|
} else if (lens) {
|
|
416
494
|
body = (
|
|
417
495
|
<div ref={loupeHostRef} className="lk-trial__loupe-host">
|
|
@@ -478,30 +556,32 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
|
|
|
478
556
|
config={record.config}
|
|
479
557
|
trial={{ id: record.id, view: record.view }}
|
|
480
558
|
annotations={annotations}
|
|
481
|
-
activeToolId={
|
|
559
|
+
activeToolId={labTool}
|
|
482
560
|
/>
|
|
483
561
|
) : null;
|
|
484
562
|
|
|
485
563
|
return (
|
|
486
564
|
<TrialIdProvider trialId={record.id}>
|
|
487
565
|
<AnnotationsContext.Provider value={annotationsCap ? annotations : null}>
|
|
488
|
-
<
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
566
|
+
<CameraWheelContext.Provider value={wheelSlot}>
|
|
567
|
+
<TrialChrome
|
|
568
|
+
job={jobCap ? job : undefined}
|
|
569
|
+
loupe={loupeBindings}
|
|
570
|
+
trialId={record.id}
|
|
571
|
+
record={record}
|
|
572
|
+
instrument={instrument}
|
|
573
|
+
isLastTrial={isLast}
|
|
574
|
+
undoBindings={undoBindings}
|
|
575
|
+
trialChrome={extraChrome}
|
|
576
|
+
chrome={chrome}
|
|
577
|
+
suppress={suppress}
|
|
578
|
+
activeToolId={resolvedToolId}
|
|
579
|
+
setActiveTool={setActiveTool}
|
|
580
|
+
>
|
|
581
|
+
{body}
|
|
582
|
+
{annotationOverlays}
|
|
583
|
+
</TrialChrome>
|
|
584
|
+
</CameraWheelContext.Provider>
|
|
505
585
|
</AnnotationsContext.Provider>
|
|
506
586
|
</TrialIdProvider>
|
|
507
587
|
);
|