@weasel-js/labkit 1.4.0 → 1.4.2
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 +8 -0
- package/dist/_dts/{CanvasStackContext-D6M0o8cQ.d.ts → CanvasStackContext-kjILVnPj.d.ts} +1 -1
- package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
- package/dist/_dts/{frac-QS7XzvpG.d.ts → frac-A4R6v4ld.d.ts} +36 -13
- package/dist/_dts/{index-B2X21G1c.d.ts → index-CsU9WhjM.d.ts} +90 -30
- package/dist/_dts/{types-ChVJJHvk.d.ts → types-BbXpvQa8.d.ts} +20 -1
- package/dist/_dts/{types-C1Zw7s5P.d.ts → types-lg4TSCb2.d.ts} +2 -1
- package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-DKjqpv20.d.ts} +5 -1
- package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
- package/dist/canvas/index.d.ts +16 -16
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +15 -9
- package/dist/chrome/index.js +5 -5
- package/dist/{chunk-7VW3S44V.js → chunk-54ZWZ5FQ.js} +908 -301
- package/dist/chunk-54ZWZ5FQ.js.map +1 -0
- package/dist/{chunk-Y2HK44TG.js → chunk-D5KQ5OY6.js} +3 -3
- package/dist/{chunk-Y2HK44TG.js.map → chunk-D5KQ5OY6.js.map} +1 -1
- package/dist/{chunk-VHFQKAUL.js → chunk-FJG4PHTL.js} +83 -75
- package/dist/chunk-FJG4PHTL.js.map +1 -0
- package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
- package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
- package/dist/{chunk-L5WBGUVR.js → chunk-ISSVF5PT.js} +2753 -2654
- package/dist/chunk-ISSVF5PT.js.map +1 -0
- package/dist/chunk-NSOVI3AZ.js +170 -0
- package/dist/chunk-NSOVI3AZ.js.map +1 -0
- package/dist/{chunk-YQGQUBKA.js → chunk-TN7YSJVU.js} +62 -60
- package/dist/chunk-TN7YSJVU.js.map +1 -0
- package/dist/{chunk-4AMN4MMB.js → chunk-UDXOYZEC.js} +53 -39
- package/dist/chunk-UDXOYZEC.js.map +1 -0
- package/dist/{chunk-LJSIUFYD.js → chunk-ULDW42CR.js} +23 -2
- package/dist/chunk-ULDW42CR.js.map +1 -0
- package/dist/{chunk-QR5V3AFG.js → chunk-UTOEDPNU.js} +5 -5
- package/dist/{chunk-QR5V3AFG.js.map → chunk-UTOEDPNU.js.map} +1 -1
- package/dist/{chunk-UD7TEHZT.js → chunk-W2FJR5FF.js} +35 -22
- package/dist/chunk-W2FJR5FF.js.map +1 -0
- package/dist/{chunk-B5VWJBWM.js → chunk-WS6ZRV75.js} +3 -3
- package/dist/{chunk-B5VWJBWM.js.map → chunk-WS6ZRV75.js.map} +1 -1
- package/dist/{chunk-W3KSUXXR.js → chunk-XKENZTNE.js} +45 -14
- package/dist/chunk-XKENZTNE.js.map +1 -0
- package/dist/controls/index.d.ts +4 -3
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +8 -8
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +75 -110
- package/dist/index.js +148 -115
- package/dist/index.js.map +1 -1
- package/dist/job/index.js +1 -1
- package/dist/layers/index.d.ts +26 -11
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +7 -13
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-canvas.d.ts +3 -343
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +123 -3088
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.js +4 -4
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/styles.css +30 -3
- package/dist/surface/index.js +2 -2
- package/dist/ui/layers/index.d.ts +22 -9
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +6 -5
- package/package.json +8 -8
- package/src/annotations/AnnotationTargets.tsx +6 -1
- package/src/annotations/Annotations.overlay.test.tsx +2 -0
- package/src/annotations/types.ts +4 -1
- package/src/canvas/CanvasStack.tsx +8 -13
- package/src/canvas/useOrbit.test.ts +97 -1
- package/src/canvas/useOrbit.ts +41 -34
- package/src/canvas/usePanZoom.test.ts +106 -1
- package/src/canvas/usePanZoom.ts +54 -36
- 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/controls/ControlPanel.test.tsx +38 -0
- package/src/controls/ControlPanel.tsx +51 -6
- 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 +2 -1
- package/src/instrument/types.ts +4 -5
- package/src/job/useJob.ts +1 -1
- package/src/lab/Lab.test.tsx +7 -0
- package/src/lab/Lab.tsx +2 -2
- package/src/lab/LabContext.ts +5 -1
- package/src/lab/LabShell.less +17 -2
- package/src/lab/Workspace.tsx +1 -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 +185 -2
- package/src/layers/LayerList.tsx +221 -74
- package/src/layers/index.ts +1 -1
- package/src/passthrough/weasel-ui.ts +5 -0
- package/src/primitives/FloatingPanel.test.tsx +87 -0
- package/src/primitives/FloatingPanel.tsx +59 -41
- package/src/state/store.test.ts +78 -0
- package/src/state/store.ts +27 -0
- package/src/state/types.ts +20 -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 +4 -1
- package/src/trial/TrialChrome.tsx +22 -2
- 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 +28 -6
- package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
- package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
- package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
- package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
- package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
- package/dist/chunk-4AMN4MMB.js.map +0 -1
- package/dist/chunk-7VW3S44V.js.map +0 -1
- package/dist/chunk-L5WBGUVR.js.map +0 -1
- package/dist/chunk-LJSIUFYD.js.map +0 -1
- package/dist/chunk-UD7TEHZT.js.map +0 -1
- package/dist/chunk-VHFQKAUL.js.map +0 -1
- package/dist/chunk-W3KSUXXR.js.map +0 -1
- package/dist/chunk-XCBCZEBO.js +0 -94
- package/dist/chunk-XCBCZEBO.js.map +0 -1
- package/dist/chunk-YQGQUBKA.js.map +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { openPointerSession, type PointerSession } from '@weasel-js/core';
|
|
1
2
|
import type React from 'react';
|
|
2
3
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
4
|
import {
|
|
@@ -130,67 +131,84 @@ export function FloatingPanel({
|
|
|
130
131
|
.placements.get(ITEM_ID);
|
|
131
132
|
|
|
132
133
|
const dragging = useRef<{ x: number; y: number } | null>(null);
|
|
133
|
-
|
|
134
|
-
const pending = useRef<{ x: number; y: number; pointerId: number } | null>(null);
|
|
134
|
+
const session = useRef<PointerSession | null>(null);
|
|
135
135
|
const [isDragging, setDragging] = useState(false);
|
|
136
|
+
// A drag outlives the render that started it, so the reducer's inputs are
|
|
137
|
+
// read here and not from that render's closure — a container resized
|
|
138
|
+
// mid-drag would otherwise place the panel against its old box.
|
|
139
|
+
const layout = useRef({ container, options, item });
|
|
140
|
+
layout.current = { container, options, item };
|
|
141
|
+
|
|
142
|
+
useEffect(() => () => session.current?.cancel(), []);
|
|
136
143
|
|
|
137
144
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
138
145
|
if (isInteractive(e.target)) return;
|
|
139
146
|
// The canvas stack underneath owns pan/zoom on the same pointer events.
|
|
140
147
|
e.stopPropagation();
|
|
148
|
+
const origin = e.currentTarget;
|
|
149
|
+
const pointerId = e.pointerId;
|
|
150
|
+
const start = { x: e.clientX, y: e.clientY };
|
|
151
|
+
|
|
152
|
+
const end = () => {
|
|
153
|
+
session.current = null;
|
|
154
|
+
if (!dragging.current) return;
|
|
155
|
+
dragging.current = null;
|
|
156
|
+
setDragging(false);
|
|
157
|
+
try {
|
|
158
|
+
origin.releasePointerCapture?.(pointerId);
|
|
159
|
+
} catch {
|
|
160
|
+
// Capture is already gone whenever the session ended by losing it.
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
141
164
|
// Armed, not capturing. Capture retargets mouseup to this element, and the
|
|
142
165
|
// browser then synthesizes no `click` on the child under the cursor — so
|
|
143
166
|
// capturing on press makes every non-native control in the panel dead to a
|
|
144
167
|
// real mouse, silently. `isInteractive` only exempts the names it lists; a
|
|
145
168
|
// `role="button"` span, a react-aria control or a canvas is not among them.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
session.current = openPointerSession(
|
|
170
|
+
origin,
|
|
171
|
+
e,
|
|
172
|
+
{
|
|
173
|
+
onMove: (ev) => {
|
|
174
|
+
if (!dragging.current) {
|
|
175
|
+
if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return;
|
|
176
|
+
dragging.current = { ...start };
|
|
177
|
+
setDragging(true);
|
|
178
|
+
origin.setPointerCapture?.(pointerId);
|
|
179
|
+
}
|
|
180
|
+
const from = dragging.current;
|
|
181
|
+
const dx = ev.clientX - from.x;
|
|
182
|
+
const dy = ev.clientY - from.y;
|
|
183
|
+
if (dx === 0 && dy === 0) return;
|
|
184
|
+
dragging.current = { x: ev.clientX, y: ev.clientY };
|
|
185
|
+
const { container: box, options: opts, item: only } = layout.current;
|
|
186
|
+
setPlace(
|
|
187
|
+
(prev) =>
|
|
188
|
+
strategy.reduce?.(
|
|
189
|
+
{ at: { [ITEM_ID]: prev }, inner: undefined },
|
|
190
|
+
{
|
|
191
|
+
affordanceId: `${FLOATING_DRAG_PREFIX}${ITEM_ID}`,
|
|
192
|
+
kind: 'drag',
|
|
193
|
+
payload: { dx, dy },
|
|
194
|
+
},
|
|
195
|
+
{ container: box, options: opts, items: [only] },
|
|
196
|
+
).at[ITEM_ID] ?? prev,
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
onEnd: end,
|
|
200
|
+
onCancel: end,
|
|
201
|
+
},
|
|
202
|
+
{ capture: false },
|
|
174
203
|
);
|
|
175
204
|
};
|
|
176
205
|
|
|
177
|
-
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
178
|
-
pending.current = null;
|
|
179
|
-
if (!dragging.current) return;
|
|
180
|
-
dragging.current = null;
|
|
181
|
-
setDragging(false);
|
|
182
|
-
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
183
|
-
};
|
|
184
|
-
|
|
185
206
|
return (
|
|
186
207
|
<div
|
|
187
208
|
className={className ? `lk-floating-panel ${className}` : 'lk-floating-panel'}
|
|
188
209
|
data-dragging={isDragging ? 'true' : undefined}
|
|
189
210
|
data-placed={rect ? 'true' : undefined}
|
|
190
|
-
onPointerCancel={endDrag}
|
|
191
211
|
onPointerDown={onPointerDown}
|
|
192
|
-
onPointerMove={onPointerMove}
|
|
193
|
-
onPointerUp={endDrag}
|
|
194
212
|
ref={ref}
|
|
195
213
|
style={
|
|
196
214
|
{
|
package/src/state/store.test.ts
CHANGED
|
@@ -673,3 +673,81 @@ describe('undocking a sidebar panel', () => {
|
|
|
673
673
|
expect(s.getState().undockedPanels).toEqual({});
|
|
674
674
|
});
|
|
675
675
|
});
|
|
676
|
+
|
|
677
|
+
describe('setTrialTitle', () => {
|
|
678
|
+
it('holds a title and returns to the instrument name on null', () => {
|
|
679
|
+
const s = makeStore();
|
|
680
|
+
s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
681
|
+
s.getState().setTrialTitle('w1', 'Sprocket 7');
|
|
682
|
+
expect(s.getState().trials[0]?.title).toBe('Sprocket 7');
|
|
683
|
+
s.getState().setTrialTitle('w1', null);
|
|
684
|
+
expect(s.getState().trials[0]?.title).toBeUndefined();
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('leaves the record alone when the title is unchanged', () => {
|
|
688
|
+
const s = makeStore();
|
|
689
|
+
s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
690
|
+
s.getState().setTrialTitle('w1', 'Sprocket 7');
|
|
691
|
+
const before = s.getState().trials[0];
|
|
692
|
+
s.getState().setTrialTitle('w1', 'Sprocket 7');
|
|
693
|
+
expect(s.getState().trials[0]).toBe(before);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it('round-trips through storage', () => {
|
|
697
|
+
vi.useFakeTimers();
|
|
698
|
+
const storage = createMemoryAdapter();
|
|
699
|
+
const first = makeStore({ storage });
|
|
700
|
+
first.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
701
|
+
first.getState().setTrialTitle('w1', 'Sprocket 7');
|
|
702
|
+
vi.advanceTimersByTime(400);
|
|
703
|
+
vi.useRealTimers();
|
|
704
|
+
|
|
705
|
+
expect(makeStore({ storage }).getState().trials[0]?.title).toBe('Sprocket 7');
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
describe('setTrialSectionCollapsed', () => {
|
|
710
|
+
it('folds one section without touching the others', () => {
|
|
711
|
+
const s = makeStore();
|
|
712
|
+
s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
713
|
+
s.getState().setTrialSectionCollapsed('w1', 'settings', true);
|
|
714
|
+
s.getState().setTrialSectionCollapsed('w1', 'marks', false);
|
|
715
|
+
expect(s.getState().trials[0]?.collapsedSections).toEqual({ settings: true, marks: false });
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('leaves the record alone when the fold is unchanged', () => {
|
|
719
|
+
const s = makeStore();
|
|
720
|
+
s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
721
|
+
s.getState().setTrialSectionCollapsed('w1', 'settings', true);
|
|
722
|
+
const before = s.getState().trials[0];
|
|
723
|
+
s.getState().setTrialSectionCollapsed('w1', 'settings', true);
|
|
724
|
+
expect(s.getState().trials[0]).toBe(before);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it('round-trips through storage', () => {
|
|
728
|
+
vi.useFakeTimers();
|
|
729
|
+
const storage = createMemoryAdapter();
|
|
730
|
+
const first = makeStore({ storage });
|
|
731
|
+
first.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
|
|
732
|
+
first.getState().setTrialSectionCollapsed('w1', 'settings/Shape', true);
|
|
733
|
+
vi.advanceTimersByTime(400);
|
|
734
|
+
vi.useRealTimers();
|
|
735
|
+
|
|
736
|
+
expect(makeStore({ storage }).getState().trials[0]?.collapsedSections).toEqual({
|
|
737
|
+
'settings/Shape': true,
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('hydrates a version-3 trial, which carries neither field', () => {
|
|
742
|
+
const storage = createMemoryAdapter();
|
|
743
|
+
writeDocument(storage, {
|
|
744
|
+
trials: [{ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW }],
|
|
745
|
+
saves: [],
|
|
746
|
+
layout: {},
|
|
747
|
+
mode: 'auto',
|
|
748
|
+
});
|
|
749
|
+
const trial = makeStore({ storage }).getState().trials[0];
|
|
750
|
+
expect(trial?.title).toBeUndefined();
|
|
751
|
+
expect(trial?.collapsedSections).toBeUndefined();
|
|
752
|
+
});
|
|
753
|
+
});
|
package/src/state/store.ts
CHANGED
|
@@ -35,6 +35,10 @@ export interface LabStoreActions {
|
|
|
35
35
|
updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
|
|
36
36
|
updateTrialView: (id: string, view: unknown) => void;
|
|
37
37
|
updateTrialSidebarWidth: (id: string, width: number) => void;
|
|
38
|
+
/** Retitle a trial. `null` returns it to its instrument's name. */
|
|
39
|
+
setTrialTitle: (id: string, title: string | null) => void;
|
|
40
|
+
/** Fold or unfold one of a trial's sections. */
|
|
41
|
+
setTrialSectionCollapsed: (id: string, key: string, collapsed: boolean) => void;
|
|
38
42
|
updateTrialAnnotations: (id: string, doc: unknown) => void;
|
|
39
43
|
updateTrialUndoStack: (
|
|
40
44
|
id: string,
|
|
@@ -146,6 +150,29 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
146
150
|
scheduleFlush();
|
|
147
151
|
},
|
|
148
152
|
|
|
153
|
+
setTrialTitle: (id, title) => {
|
|
154
|
+
set((s) => ({
|
|
155
|
+
trials: s.trials.map((w) => {
|
|
156
|
+
if (w.id !== id) return w;
|
|
157
|
+
const next = title === null ? undefined : title;
|
|
158
|
+
if ((w.title ?? undefined) === next) return w;
|
|
159
|
+
return { ...w, title: next };
|
|
160
|
+
}),
|
|
161
|
+
}));
|
|
162
|
+
scheduleFlush();
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
setTrialSectionCollapsed: (id, key, collapsed) => {
|
|
166
|
+
set((s) => ({
|
|
167
|
+
trials: s.trials.map((w) => {
|
|
168
|
+
if (w.id !== id) return w;
|
|
169
|
+
if (w.collapsedSections?.[key] === collapsed) return w;
|
|
170
|
+
return { ...w, collapsedSections: { ...w.collapsedSections, [key]: collapsed } };
|
|
171
|
+
}),
|
|
172
|
+
}));
|
|
173
|
+
scheduleFlush();
|
|
174
|
+
},
|
|
175
|
+
|
|
149
176
|
updateTrialAnnotations: (id, doc) => {
|
|
150
177
|
set((s) => ({
|
|
151
178
|
trials: s.trials.map((w) =>
|
package/src/state/types.ts
CHANGED
|
@@ -6,6 +6,15 @@ export interface UndoStack {
|
|
|
6
6
|
future: unknown[];
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/** The trial a per-trial call is coming from. `id` is the id `useTileId`
|
|
10
|
+
* scopes a surface tile under, so a consumer keying its own per-trial
|
|
11
|
+
* registry and labkit's tiles agree on the key. */
|
|
12
|
+
export interface TrialInfo<TV = unknown> {
|
|
13
|
+
id: string;
|
|
14
|
+
/** The trial's view, in whatever shape the instrument keeps it. */
|
|
15
|
+
view: TV;
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
/** One trial as the store holds it: which instrument it runs, that
|
|
10
19
|
* instrument's config and state, the camera, and the undo history. */
|
|
11
20
|
export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
|
|
@@ -16,11 +25,22 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
|
|
|
16
25
|
/** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
|
|
17
26
|
* but never read into. A 3D lab puts an orbit here and keeps all three. */
|
|
18
27
|
view: TV;
|
|
28
|
+
/** The config `addTrial` opened this trial on, overlaid on the instrument's
|
|
29
|
+
* defaults. Kept so Reset restores the trial's own subject rather than the
|
|
30
|
+
* bare defaults. */
|
|
31
|
+
configSeed?: Partial<TC>;
|
|
19
32
|
/** This trial's own tool slot. Undefined means it reads the lab's. */
|
|
20
33
|
activeToolId?: string | null;
|
|
21
34
|
/** The extent the trial's sidebar was last dragged to, in pixels. Undefined
|
|
22
35
|
* until someone moves the seam. */
|
|
23
36
|
sidebarWidth?: number;
|
|
37
|
+
/** What the title bar reads. Undefined — the state a trial opens in, and the
|
|
38
|
+
* one `setTitle(null)` returns it to — means the instrument's name. */
|
|
39
|
+
title?: string | null;
|
|
40
|
+
/** Which of the trial's collapsible sections are folded, keyed as
|
|
41
|
+
* `TrialChromeContext.collapsedSections` describes. A key that is absent
|
|
42
|
+
* takes the section's own default. */
|
|
43
|
+
collapsedSections?: Record<string, boolean>;
|
|
24
44
|
/** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
|
|
25
45
|
* wrote them. Opaque here, and absent for a trial whose instrument declares
|
|
26
46
|
* no `annotations` — or declares its own `storage`. Not in `state`, which
|
package/src/trial/Trial.less
CHANGED
|
@@ -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.
|
|
@@ -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
|
/>
|