@weasel-js/labkit 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/dist/_dts/{CanvasStackContext-BIRUVVGH.d.ts → CanvasStackContext-Dde8_1my.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-CgxUequc.d.ts → PrefsForm.d-DDHFANkc.d.ts} +1 -1
- package/dist/_dts/{frac-X7mWgd7y.d.ts → frac-B5mB8LsJ.d.ts} +33 -13
- package/dist/_dts/{index-FEa1MudK.d.ts → index-DelFfeV0.d.ts} +99 -31
- package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
- package/dist/_dts/{types-ChVJJHvk.d.ts → types-BP2OCcpg.d.ts} +41 -4
- package/dist/_dts/types-x92Kfeme.d.ts +62 -0
- package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-D6Vb3T-g.d.ts} +11 -7
- package/dist/canvas/index.d.ts +9 -8
- package/dist/canvas/index.js +1 -2
- package/dist/chrome/index.d.ts +16 -9
- package/dist/chrome/index.js +5 -5
- package/dist/{chunk-MSR7EVS6.js → chunk-2RQPGOQF.js} +6 -5
- package/dist/chunk-2RQPGOQF.js.map +1 -0
- package/dist/{chunk-LJSIUFYD.js → chunk-5TROQVQ2.js} +46 -21
- package/dist/chunk-5TROQVQ2.js.map +1 -0
- package/dist/{chunk-53AZ2O2N.js → chunk-ASWKLRKJ.js} +92 -45
- package/dist/chunk-ASWKLRKJ.js.map +1 -0
- package/dist/{chunk-BMQWLKOL.js → chunk-C42T6DJT.js} +94 -83
- package/dist/chunk-C42T6DJT.js.map +1 -0
- package/dist/chunk-E44SU6XS.js +50 -0
- package/dist/chunk-E44SU6XS.js.map +1 -0
- package/dist/{chunk-OFCBEZMU.js → chunk-FQMJUVHJ.js} +3 -3
- package/dist/{chunk-OFCBEZMU.js.map → chunk-FQMJUVHJ.js.map} +1 -1
- package/dist/{chunk-PP7ZWAMV.js → chunk-I6JCVE24.js} +3 -3
- package/dist/chunk-I6JCVE24.js.map +1 -0
- package/dist/chunk-ISCOFAUO.js +170 -0
- package/dist/chunk-ISCOFAUO.js.map +1 -0
- package/dist/chunk-MHAGC6VD.js +7320 -0
- package/dist/chunk-MHAGC6VD.js.map +1 -0
- package/dist/{chunk-7TRFMIWX.js → chunk-QAGAHJKZ.js} +4 -4
- package/dist/{chunk-7TRFMIWX.js.map → chunk-QAGAHJKZ.js.map} +1 -1
- package/dist/{chunk-D7QIPGEB.js → chunk-RS3HRQWU.js} +62 -60
- package/dist/chunk-RS3HRQWU.js.map +1 -0
- package/dist/{chunk-RLOXQZW3.js → chunk-TJ7QY3OC.js} +3 -3
- package/dist/{chunk-RLOXQZW3.js.map → chunk-TJ7QY3OC.js.map} +1 -1
- package/dist/controls/index.d.ts +5 -4
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +9 -8
- package/dist/dragdrop/index.js +1 -2
- package/dist/index.d.ts +152 -57
- package/dist/index.js +315 -200
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +27 -11
- package/dist/layers/index.js +2 -3
- package/dist/loupe/index.d.ts +8 -7
- package/dist/loupe/index.js +1 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -3
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-canvas.js.map +1 -1
- package/dist/passthrough/weasel-ui.d.ts +171 -22
- package/dist/passthrough/weasel-ui.js +1 -2
- package/dist/primitives/index.js +3 -4
- package/dist/state/index.d.ts +6 -3
- package/dist/state/index.js +3 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +21 -4
- package/dist/surface/index.js +1 -2
- package/dist/ui/layers/index.d.ts +22 -9
- package/dist/ui/layers/index.js +1 -2
- package/dist/undo/index.d.ts +6 -6
- package/package.json +7 -7
- package/src/annotations/AnnotationTargets.tsx +6 -1
- package/src/annotations/Annotations.overlay.test.tsx +2 -0
- package/src/annotations/ExportMenu.test.tsx +16 -0
- package/src/annotations/ExportMenu.tsx +0 -5
- package/src/annotations/types.ts +4 -1
- package/src/canvas/useOrbit.test.ts +7 -3
- package/src/canvas/usePanZoom.test.ts +7 -3
- package/src/chrome/ChromeRegions.stories.tsx +42 -1
- package/src/chrome/builtins.test.ts +4 -0
- package/src/chrome/builtins.tsx +18 -0
- package/src/chrome/index.ts +1 -1
- package/src/chrome/regions/SidebarRegion.tsx +7 -6
- package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
- package/src/chrome/regions/TitleBarRegion.tsx +16 -7
- package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
- package/src/chrome/regions/ToolbarRegion.tsx +2 -2
- package/src/chrome/regions/ViewportRegion.tsx +2 -2
- package/src/chrome/regions/regions.test.tsx +45 -1
- package/src/chrome/types.ts +21 -3
- package/src/config/builder.test.ts +56 -2
- package/src/config/builder.ts +73 -13
- package/src/config/index.ts +16 -1
- package/src/config/path.test.ts +109 -0
- package/src/config/path.ts +76 -0
- package/src/config/resolve.test.ts +93 -2
- package/src/config/resolve.ts +72 -29
- package/src/config/types.ts +99 -9
- package/src/config/visible.ts +6 -4
- package/src/controls/ControlPanel.stories.tsx +38 -4
- package/src/controls/ControlPanel.test.tsx +152 -0
- package/src/controls/ControlPanel.tsx +134 -40
- package/src/dragdrop/DragDropRuntime.tsx +74 -61
- package/src/dragdrop/Palette.tsx +4 -3
- package/src/dragdrop/dragDrop.test.tsx +35 -12
- package/src/index.ts +18 -2
- package/src/instrument/serializers.ts +36 -0
- package/src/instrument/types.ts +8 -7
- package/src/lab/Lab.test.tsx +36 -1
- package/src/lab/Lab.tsx +12 -3
- package/src/lab/LabContext.ts +5 -1
- package/src/lab/LabShell.less +17 -2
- package/src/lab/LabShell.test.tsx +8 -0
- package/src/lab/LabShell.tsx +3 -1
- package/src/layers/AGENTS.md +29 -7
- package/src/layers/LayerList.less +16 -0
- package/src/layers/LayerList.stories.tsx +66 -0
- package/src/layers/LayerList.test.tsx +117 -5
- package/src/layers/LayerList.tsx +192 -53
- package/src/layers/index.ts +1 -1
- package/src/passthrough/weasel-ui.ts +5 -0
- package/src/primitives/FloatingPanel.test.tsx +8 -3
- package/src/primitives/ZoomControl.less +6 -2
- package/src/primitives/ZoomControl.tsx +1 -0
- package/src/state/helpers.ts +1 -1
- package/src/state/store.test.ts +233 -1
- package/src/state/store.ts +57 -24
- package/src/state/types.ts +44 -3
- package/src/state/useTrialState.ts +1 -1
- package/src/trial/Trial.config.test.tsx +44 -0
- package/src/trial/Trial.less +4 -0
- package/src/trial/Trial.targets.test.tsx +96 -0
- package/src/trial/Trial.test.tsx +171 -14
- package/src/trial/Trial.tsx +7 -4
- package/src/trial/TrialChrome.tsx +32 -7
- package/src/trial/TrialTitleBar.tsx +7 -3
- package/src/trial/index.ts +1 -0
- package/src/trial/trialOps.test.ts +49 -1
- package/src/trial/trialOps.ts +24 -6
- package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
- package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
- package/dist/chunk-53AZ2O2N.js.map +0 -1
- package/dist/chunk-BMQWLKOL.js.map +0 -1
- package/dist/chunk-D7QIPGEB.js.map +0 -1
- package/dist/chunk-DG2IFMLA.js +0 -29970
- package/dist/chunk-DG2IFMLA.js.map +0 -1
- package/dist/chunk-J6SBFZYT.js +0 -7121
- package/dist/chunk-J6SBFZYT.js.map +0 -1
- package/dist/chunk-LJSIUFYD.js.map +0 -1
- package/dist/chunk-MIT3ISW4.js +0 -100
- package/dist/chunk-MIT3ISW4.js.map +0 -1
- package/dist/chunk-MSR7EVS6.js.map +0 -1
- package/dist/chunk-PP7ZWAMV.js.map +0 -1
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import { useVisibleRaf } from '@weasel-js/core';
|
|
2
|
-
import {
|
|
1
|
+
import { openPointerSession, type PointerSession, useVisibleRaf } from '@weasel-js/core';
|
|
2
|
+
import {
|
|
3
|
+
type PointerEvent as ReactPointerEvent,
|
|
4
|
+
type RefObject,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
3
10
|
import { screenToWorld } from '../canvas/canvasCoords';
|
|
4
11
|
import { resolveFrame, type WorldSpec } from '../canvas/worldSpec';
|
|
5
12
|
import type {
|
|
@@ -32,7 +39,7 @@ export interface UseDragDropArgs<TS, TC> {
|
|
|
32
39
|
|
|
33
40
|
export interface UseDragDropResult {
|
|
34
41
|
drag: DragState | null;
|
|
35
|
-
startDrag: (item: PaletteItem,
|
|
42
|
+
startDrag: (item: PaletteItem, e: ReactPointerEvent) => void;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export function useDragDrop<TS, TC>({
|
|
@@ -51,6 +58,12 @@ export function useDragDrop<TS, TC>({
|
|
|
51
58
|
/** The move the next frame will resolve. Doubles as the throttle's flag:
|
|
52
59
|
* non-null means a frame is already queued. */
|
|
53
60
|
const pendingPos = useRef<Point | null>(null);
|
|
61
|
+
const sessionRef = useRef<PointerSession | null>(null);
|
|
62
|
+
|
|
63
|
+
/** A session keeps the closure it was opened with, so everything the drop
|
|
64
|
+
* reads has to come from here rather than from that closure. */
|
|
65
|
+
const liveRef = useRef({ capability, state, config, setState, emit, view, worldSpec });
|
|
66
|
+
liveRef.current = { capability, state, config, setState, emit, view, worldSpec };
|
|
54
67
|
|
|
55
68
|
const isOverCanvas = useCallback(
|
|
56
69
|
(screenPos: Point): boolean => {
|
|
@@ -72,10 +85,14 @@ export function useDragDrop<TS, TC>({
|
|
|
72
85
|
const el = canvasContainerRef.current;
|
|
73
86
|
if (!el) return null;
|
|
74
87
|
const r = el.getBoundingClientRect();
|
|
75
|
-
const frame = resolveFrame(worldSpec, { width: r.width, height: r.height });
|
|
76
|
-
return screenToWorld(
|
|
88
|
+
const frame = resolveFrame(liveRef.current.worldSpec, { width: r.width, height: r.height });
|
|
89
|
+
return screenToWorld(
|
|
90
|
+
{ x: screenPos.x - r.left, y: screenPos.y - r.top },
|
|
91
|
+
liveRef.current.view,
|
|
92
|
+
frame,
|
|
93
|
+
);
|
|
77
94
|
},
|
|
78
|
-
[canvasContainerRef
|
|
95
|
+
[canvasContainerRef],
|
|
79
96
|
);
|
|
80
97
|
|
|
81
98
|
const frameLoop = useVisibleRaf(() => {
|
|
@@ -83,68 +100,64 @@ export function useDragDrop<TS, TC>({
|
|
|
83
100
|
pendingPos.current = null;
|
|
84
101
|
const active = dragRef.current;
|
|
85
102
|
if (!active || !screenPos) return;
|
|
103
|
+
const live = liveRef.current;
|
|
86
104
|
let feedback: DragFeedback | null = null;
|
|
87
|
-
if (capability.onDragOver && isOverCanvas(screenPos)) {
|
|
105
|
+
if (live.capability.onDragOver && isOverCanvas(screenPos)) {
|
|
88
106
|
const world = screenToWorldFromContainer(screenPos);
|
|
89
|
-
if (world) feedback = capability.onDragOver(world, active.item, state, config);
|
|
107
|
+
if (world) feedback = live.capability.onDragOver(world, active.item, live.state, live.config);
|
|
90
108
|
}
|
|
91
109
|
setDrag({ ...active, screenPos, feedback });
|
|
92
110
|
});
|
|
93
111
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
isOverCanvas,
|
|
142
|
-
screenToWorldFromContainer,
|
|
143
|
-
]);
|
|
112
|
+
const clearDrag = useCallback(() => {
|
|
113
|
+
pendingPos.current = null;
|
|
114
|
+
frameLoop.cancel();
|
|
115
|
+
sessionRef.current = null;
|
|
116
|
+
dragRef.current = null;
|
|
117
|
+
setDrag(null);
|
|
118
|
+
}, [frameLoop]);
|
|
119
|
+
|
|
120
|
+
const startDrag = useCallback(
|
|
121
|
+
(item: PaletteItem, e: ReactPointerEvent) => {
|
|
122
|
+
if (sessionRef.current) return;
|
|
123
|
+
const next: DragState = { item, screenPos: { x: e.clientX, y: e.clientY }, feedback: null };
|
|
124
|
+
dragRef.current = next;
|
|
125
|
+
setDrag(next);
|
|
126
|
+
sessionRef.current = openPointerSession(e.currentTarget, e, {
|
|
127
|
+
onMove: (ev) => {
|
|
128
|
+
const screenPos = { x: ev.clientX, y: ev.clientY };
|
|
129
|
+
const current = dragRef.current;
|
|
130
|
+
if (!current) return;
|
|
131
|
+
if (pendingPos.current !== null) {
|
|
132
|
+
// A frame is already queued: keep the ghost under the cursor and
|
|
133
|
+
// let that frame probe the drop target from the newest position.
|
|
134
|
+
pendingPos.current = screenPos;
|
|
135
|
+
dragRef.current = { ...current, screenPos };
|
|
136
|
+
setDrag(dragRef.current);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
pendingPos.current = screenPos;
|
|
140
|
+
frameLoop.request();
|
|
141
|
+
},
|
|
142
|
+
onEnd: (ev) => {
|
|
143
|
+
const screenPos = { x: ev.clientX, y: ev.clientY };
|
|
144
|
+
const active = dragRef.current;
|
|
145
|
+
clearDrag();
|
|
146
|
+
if (!active || !isOverCanvas(screenPos)) return;
|
|
147
|
+
const world = screenToWorldFromContainer(screenPos);
|
|
148
|
+
if (!world) return;
|
|
149
|
+
const live = liveRef.current;
|
|
150
|
+
live.setState(live.capability.onDrop(world, active.item, live.state, live.config));
|
|
151
|
+
live.emit('canvas.itemAdded');
|
|
152
|
+
},
|
|
153
|
+
// An interrupted gesture never named a destination, so it drops nothing.
|
|
154
|
+
onCancel: clearDrag,
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
[clearDrag, frameLoop, isOverCanvas, screenToWorldFromContainer],
|
|
158
|
+
);
|
|
144
159
|
|
|
145
|
-
|
|
146
|
-
setDrag({ item, screenPos: originScreenPos, feedback: null });
|
|
147
|
-
}, []);
|
|
160
|
+
useEffect(() => () => sessionRef.current?.cancel(), []);
|
|
148
161
|
|
|
149
162
|
return { drag, startDrag };
|
|
150
163
|
}
|
package/src/dragdrop/Palette.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PointerEvent as ReactPointerEvent } from 'react';
|
|
2
|
+
import type { PaletteItem } from '../instrument/types';
|
|
2
3
|
|
|
3
4
|
export interface PaletteProps {
|
|
4
5
|
items: PaletteItem[];
|
|
5
|
-
onDragStart: (item: PaletteItem,
|
|
6
|
+
onDragStart: (item: PaletteItem, e: ReactPointerEvent) => void;
|
|
6
7
|
className?: string;
|
|
7
8
|
}
|
|
8
9
|
|
|
@@ -16,7 +17,7 @@ export function Palette({ items, onDragStart, className }: PaletteProps) {
|
|
|
16
17
|
className="lk-palette__item"
|
|
17
18
|
onPointerDown={(e) => {
|
|
18
19
|
e.preventDefault();
|
|
19
|
-
onDragStart(item,
|
|
20
|
+
onDragStart(item, e);
|
|
20
21
|
}}
|
|
21
22
|
>
|
|
22
23
|
<span className="lk-palette__label">{item.label}</span>
|
|
@@ -83,19 +83,23 @@ afterEach(() => {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
function pointerDown(el: Element, x: number, y: number) {
|
|
86
|
-
fireEvent.pointerDown(el, { clientX: x, clientY: y });
|
|
86
|
+
fireEvent.pointerDown(el, { pointerId: 1, buttons: 1, clientX: x, clientY: y });
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
/** The session listens on the document, so that is where the rest of the
|
|
90
|
+
* gesture has to arrive — a dispatch on `window` reaches nothing. */
|
|
91
|
+
function dispatchOnDocument(type: string, init: PointerEventInit) {
|
|
90
92
|
act(() => {
|
|
91
|
-
|
|
93
|
+
document.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
|
|
92
94
|
});
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
function pointerMove(x: number, y: number) {
|
|
98
|
+
dispatchOnDocument('pointermove', { buttons: 1, clientX: x, clientY: y });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pointerUp(x: number, y: number) {
|
|
102
|
+
dispatchOnDocument('pointerup', { clientX: x, clientY: y });
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
describe('DragDrop integration', () => {
|
|
@@ -114,7 +118,7 @@ describe('DragDrop integration', () => {
|
|
|
114
118
|
act(() => {
|
|
115
119
|
pointerDown(palette, 0, 0);
|
|
116
120
|
});
|
|
117
|
-
|
|
121
|
+
pointerUp(250, 250);
|
|
118
122
|
|
|
119
123
|
expect(latest.items).toHaveLength(1);
|
|
120
124
|
expect(latest.items[0]?.id).toBe('a');
|
|
@@ -130,8 +134,8 @@ describe('DragDrop integration', () => {
|
|
|
130
134
|
act(() => {
|
|
131
135
|
pointerDown(palette, 0, 0);
|
|
132
136
|
});
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
pointerMove(10, 10);
|
|
138
|
+
pointerUp(10, 10);
|
|
135
139
|
|
|
136
140
|
expect(latest.items).toHaveLength(0);
|
|
137
141
|
expect(document.querySelector('.lk-drag-ghost')).toBeNull();
|
|
@@ -146,7 +150,26 @@ describe('DragDrop integration', () => {
|
|
|
146
150
|
});
|
|
147
151
|
expect(document.querySelector('.lk-drag-ghost')).not.toBeNull();
|
|
148
152
|
|
|
149
|
-
|
|
153
|
+
pointerUp(250, 250);
|
|
154
|
+
expect(document.querySelector('.lk-drag-ghost')).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('a cancelled gesture drops nothing and clears the ghost', () => {
|
|
158
|
+
let latest: DropState = { items: [] };
|
|
159
|
+
renderLab((s) => {
|
|
160
|
+
latest = s;
|
|
161
|
+
});
|
|
162
|
+
const palette = screen.getByRole('button', { name: 'Item A' });
|
|
163
|
+
|
|
164
|
+
act(() => {
|
|
165
|
+
pointerDown(palette, 0, 0);
|
|
166
|
+
});
|
|
167
|
+
pointerMove(250, 250);
|
|
168
|
+
expect(document.querySelector('.lk-drag-ghost')).not.toBeNull();
|
|
169
|
+
|
|
170
|
+
dispatchOnDocument('pointercancel', {});
|
|
171
|
+
|
|
172
|
+
expect(latest.items).toHaveLength(0);
|
|
150
173
|
expect(document.querySelector('.lk-drag-ghost')).toBeNull();
|
|
151
174
|
});
|
|
152
175
|
|
|
@@ -162,7 +185,7 @@ describe('DragDrop integration', () => {
|
|
|
162
185
|
});
|
|
163
186
|
// The host spans (100,100)-(500,500), so its centre is at (300,300).
|
|
164
187
|
// Dropping 50px right of centre and 50px above it, at zoom 1, y up.
|
|
165
|
-
|
|
188
|
+
pointerUp(350, 250);
|
|
166
189
|
|
|
167
190
|
expect(latest.items[0]).toMatchObject({ x: 50, y: 50 });
|
|
168
191
|
});
|
package/src/index.ts
CHANGED
|
@@ -145,25 +145,40 @@ export {
|
|
|
145
145
|
zoomAt,
|
|
146
146
|
} from './canvas';
|
|
147
147
|
export * from './chrome';
|
|
148
|
-
export { f } from './config/builder';
|
|
148
|
+
export { f, isConfigBranch } from './config/builder';
|
|
149
149
|
export { fromConfigFields } from './config/fromConfigField';
|
|
150
|
+
export {
|
|
151
|
+
fillConfigDefaults,
|
|
152
|
+
hasConfigPath,
|
|
153
|
+
schemaNodeAtPath,
|
|
154
|
+
valueAtPath,
|
|
155
|
+
withValueAtPath,
|
|
156
|
+
} from './config/path';
|
|
150
157
|
export { resolveConfigSchema } from './config/resolve';
|
|
151
158
|
export { applyRules, builtinRules, titleCase } from './config/rules';
|
|
152
159
|
export type {
|
|
153
160
|
Annotations,
|
|
161
|
+
BranchAnnotations,
|
|
162
|
+
BranchOptions,
|
|
163
|
+
ConfigBranch,
|
|
164
|
+
ConfigEntry,
|
|
154
165
|
ConfigNode,
|
|
155
166
|
ConfigOf,
|
|
156
167
|
ConfigOption,
|
|
168
|
+
ConfigPath,
|
|
157
169
|
ConfigRule,
|
|
158
170
|
ConfigRuleContext,
|
|
159
171
|
ConfigSchema,
|
|
172
|
+
ConfigShape,
|
|
160
173
|
ControlRenderer,
|
|
174
|
+
EntryValue,
|
|
161
175
|
InferConfig,
|
|
162
176
|
LeafPatch,
|
|
163
177
|
NodeOptions,
|
|
164
178
|
NodeValue,
|
|
165
179
|
ResolvedConfig,
|
|
166
180
|
SectionSpec,
|
|
181
|
+
ValueAtPath,
|
|
167
182
|
} from './config/types';
|
|
168
183
|
export { useConfigSchema } from './config/useConfigSchema';
|
|
169
184
|
export { isLeafVisible } from './config/visible';
|
|
@@ -202,7 +217,7 @@ export type { ValidationResult } from './instrument/validateConfigSchema';
|
|
|
202
217
|
export { validateConfigSchema } from './instrument/validateConfigSchema';
|
|
203
218
|
export * from './job';
|
|
204
219
|
export * from './lab';
|
|
205
|
-
export type { LayerListProps } from './layers';
|
|
220
|
+
export type { LayerListProps, LayerTreeNode } from './layers';
|
|
206
221
|
export { LayerList } from './layers';
|
|
207
222
|
export * from './loupe';
|
|
208
223
|
export * from './primitives';
|
|
@@ -230,6 +245,7 @@ export type {
|
|
|
230
245
|
SavedSnapshot,
|
|
231
246
|
SerializedTrial,
|
|
232
247
|
StorageAdapter,
|
|
248
|
+
TrialInfo,
|
|
233
249
|
TrialRecord,
|
|
234
250
|
TrialStateHandle,
|
|
235
251
|
UndoStack,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { InstrumentSerializers } from '../state/types';
|
|
2
|
+
import type { InstrumentList } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Each instrument's default config, keyed by name — what `createLabStore`
|
|
6
|
+
* fills a stored config's gaps from, so one saved before its schema grew a
|
|
7
|
+
* branch still loads.
|
|
8
|
+
*/
|
|
9
|
+
export function configDefaultsOf(instruments: InstrumentList): Record<string, () => unknown> {
|
|
10
|
+
const out: Record<string, () => unknown> = {};
|
|
11
|
+
for (const instrument of instruments) out[instrument.name] = () => instrument.defaultConfig();
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `serialize` / `deserialize` an instrument list declares, keyed by
|
|
17
|
+
* instrument name — what `createLabStore` needs to rebuild a trial's state
|
|
18
|
+
* from storage.
|
|
19
|
+
*
|
|
20
|
+
* An instrument declaring neither gets no entry, which is what leaves a
|
|
21
|
+
* JSON-safe state to pass through untouched.
|
|
22
|
+
*/
|
|
23
|
+
export function serializersOf(instruments: InstrumentList): InstrumentSerializers {
|
|
24
|
+
const out: InstrumentSerializers = {};
|
|
25
|
+
for (const instrument of instruments) {
|
|
26
|
+
const { name, serialize, deserialize } = instrument;
|
|
27
|
+
if (!serialize && !deserialize) continue;
|
|
28
|
+
out[name] = {
|
|
29
|
+
...(serialize ? { serialize: (state: unknown) => serialize(state) } : {}),
|
|
30
|
+
...(deserialize
|
|
31
|
+
? { deserialize: (data: unknown, config: unknown) => deserialize(data, config) }
|
|
32
|
+
: {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
package/src/instrument/types.ts
CHANGED
|
@@ -2,10 +2,11 @@ import type { ReactNode } from 'react';
|
|
|
2
2
|
import type { AnnotationsCapability } from '../annotations/types';
|
|
3
3
|
import type { ViewportSize, WorldSpec } from '../canvas/worldSpec';
|
|
4
4
|
import type { TrialContribution } from '../chrome/types';
|
|
5
|
-
import type { ConfigSchema } from '../config/types';
|
|
5
|
+
import type { ConfigPath, ConfigSchema } from '../config/types';
|
|
6
6
|
import type { ConfigField } from '../controls/types';
|
|
7
7
|
import type { JobCapability, JobHandle } from '../job/types';
|
|
8
8
|
import type { LoupeDeclaration } from '../loupe/types';
|
|
9
|
+
import type { TrialInfo } from '../state/types';
|
|
9
10
|
import type { ToolCapability } from '../tools/types';
|
|
10
11
|
|
|
11
12
|
/** What an instrument's `render` is handed: its state and config, the setters
|
|
@@ -14,12 +15,12 @@ export interface RenderContext<TS = unknown, TC = unknown> {
|
|
|
14
15
|
state: TS;
|
|
15
16
|
config: TC;
|
|
16
17
|
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
/** Write one config value, by dotted path — `'grid.size'` for a leaf under
|
|
19
|
+
* an `f.group`, `'cellSize'` for one at the root. */
|
|
20
|
+
setConfig: (path: ConfigPath<TC>, value: unknown) => void;
|
|
21
|
+
/** labkit persists `view` and restores it on Reset without ever reading
|
|
22
|
+
* into it. */
|
|
23
|
+
trial: TrialInfo & {
|
|
23
24
|
setView: (next: unknown) => void;
|
|
24
25
|
/** 2D convenience over `view`. Reads 1 and writes nothing when the trial holds
|
|
25
26
|
* a view that is not the 2D one. */
|
package/src/lab/Lab.test.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { act, render, screen } from '@testing-library/react';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import type { Instrument } from '../instrument/types';
|
|
5
|
+
import { createMemoryAdapter } from '../state/adapters';
|
|
6
|
+
import { labDocumentKey } from '../state/document';
|
|
5
7
|
import { Lab } from './Lab';
|
|
6
8
|
import { LabContext, type LabContextValue } from './LabContext';
|
|
7
9
|
|
|
@@ -52,6 +54,13 @@ describe('<Lab>', () => {
|
|
|
52
54
|
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(2);
|
|
53
55
|
});
|
|
54
56
|
|
|
57
|
+
it('addTrial opens each trial on the config it was given', () => {
|
|
58
|
+
mountLab();
|
|
59
|
+
act(() => labRef?.addTrial('Stub', { config: { count: 7 } }));
|
|
60
|
+
expect(labRef?.trials.map((t) => (t.config as { count: number }).count)).toEqual([0, 7]);
|
|
61
|
+
expect(labRef?.trials.map((t) => (t.state as { value: number }).value)).toEqual([0, 7]);
|
|
62
|
+
});
|
|
63
|
+
|
|
55
64
|
it('closeTrial removes one when more than one exists', () => {
|
|
56
65
|
mountLab();
|
|
57
66
|
act(() => labRef?.addTrial('Stub'));
|
|
@@ -128,4 +137,30 @@ describe('<Lab>', () => {
|
|
|
128
137
|
/Unknown instrument/,
|
|
129
138
|
);
|
|
130
139
|
});
|
|
140
|
+
|
|
141
|
+
// The store reads its serializers while it is being built, so `<Lab>` is
|
|
142
|
+
// the only thing positioned to collect them off the instruments. They went
|
|
143
|
+
// uncollected for months, and an instrument holding anything JSON drops
|
|
144
|
+
// lost it with no error.
|
|
145
|
+
it('gives the store the serializers its instruments declare', () => {
|
|
146
|
+
vi.useFakeTimers();
|
|
147
|
+
const storage = createMemoryAdapter();
|
|
148
|
+
const mapped: Instrument = {
|
|
149
|
+
...stub,
|
|
150
|
+
name: 'Mapped',
|
|
151
|
+
initialState: () => ({ seen: new Set(['a']) }),
|
|
152
|
+
serialize: (state) => ({ seen: [...(state as { seen: Set<string> }).seen] }),
|
|
153
|
+
deserialize: (data) => ({ seen: new Set((data as { seen: string[] }).seen) }),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
render(
|
|
157
|
+
<Lab instruments={[mapped]} defaultInstrument="Mapped" storage={storage} storageKey="s" />,
|
|
158
|
+
);
|
|
159
|
+
act(() => vi.advanceTimersByTime(500));
|
|
160
|
+
expect(storage.read(labDocumentKey('s')) ?? '').toContain('"seen":["a"]');
|
|
161
|
+
vi.useRealTimers();
|
|
162
|
+
|
|
163
|
+
mountLab({ instruments: [mapped], defaultInstrument: 'Mapped', storage, storageKey: 's' });
|
|
164
|
+
expect((labRef?.trials[0]?.state as { seen: Set<string> }).seen).toEqual(new Set(['a']));
|
|
165
|
+
});
|
|
131
166
|
});
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { useStore } from 'zustand/react';
|
|
12
12
|
import type { TrialContribution } from '../chrome/types';
|
|
13
13
|
import type { ConfigRule, ControlRenderer } from '../config/types';
|
|
14
|
+
import { configDefaultsOf, serializersOf } from '../instrument/serializers';
|
|
14
15
|
import type { InstrumentList } from '../instrument/types';
|
|
15
16
|
import { noneAdapter } from '../state/adapters';
|
|
16
17
|
import { LabStoreContext } from '../state/context';
|
|
@@ -78,7 +79,15 @@ function buildStore(
|
|
|
78
79
|
storageKey: string,
|
|
79
80
|
initialMode: LabMode,
|
|
80
81
|
): LabStore {
|
|
81
|
-
|
|
82
|
+
// Hydration reads the serializers, so they go in with the store rather than
|
|
83
|
+
// being registered onto it afterwards.
|
|
84
|
+
const store = createLabStore({
|
|
85
|
+
storageKey,
|
|
86
|
+
storage,
|
|
87
|
+
initialMode,
|
|
88
|
+
serializers: serializersOf(instruments),
|
|
89
|
+
configDefaults: configDefaultsOf(instruments),
|
|
90
|
+
});
|
|
82
91
|
if (store.getState().trials.length === 0) {
|
|
83
92
|
const seeded = addTrialOp([], instruments, defaultInstrument);
|
|
84
93
|
const record = seeded[0];
|
|
@@ -220,8 +229,8 @@ export function Lab({
|
|
|
220
229
|
return {
|
|
221
230
|
instruments,
|
|
222
231
|
trials,
|
|
223
|
-
addTrial: (instrumentName) => {
|
|
224
|
-
const next = addTrialOp(store.getState().trials, instruments, instrumentName);
|
|
232
|
+
addTrial: (instrumentName, options) => {
|
|
233
|
+
const next = addTrialOp(store.getState().trials, instruments, instrumentName, options);
|
|
225
234
|
replaceTrials(next);
|
|
226
235
|
},
|
|
227
236
|
cloneTrial: (id) => {
|
package/src/lab/LabContext.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createContext, useContext } from 'react';
|
|
|
2
2
|
import type { ConfigRule, ControlRenderer } from '../config/types';
|
|
3
3
|
import type { InstrumentList } from '../instrument/types';
|
|
4
4
|
import type { LabMode, SavedSnapshot, TrialRecord } from '../state/types';
|
|
5
|
+
import type { AddTrialOptions } from '../trial/trialOps';
|
|
5
6
|
|
|
6
7
|
/** Lab-wide state and commands: the available instruments, the open
|
|
7
8
|
* trials and the operations over them, saved snapshots, and the color
|
|
@@ -9,7 +10,10 @@ import type { LabMode, SavedSnapshot, TrialRecord } from '../state/types';
|
|
|
9
10
|
export interface LabContextValue {
|
|
10
11
|
instruments: InstrumentList;
|
|
11
12
|
trials: TrialRecord[];
|
|
12
|
-
|
|
13
|
+
/** Open a trial running `instrumentName`. `options.config` is written over
|
|
14
|
+
* that instrument's defaults, which is where a trial opened on a subject
|
|
15
|
+
* says which subject. */
|
|
16
|
+
addTrial: (instrumentName: string, options?: AddTrialOptions) => void;
|
|
13
17
|
cloneTrial: (id: string) => void;
|
|
14
18
|
closeTrial: (id: string) => void;
|
|
15
19
|
resetTrial: (id: string) => void;
|
package/src/lab/LabShell.less
CHANGED
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
.lk-shell-header {
|
|
16
16
|
display: flex;
|
|
17
|
-
|
|
17
|
+
// Baseline, not center: the title is `--wzl-font-size-xl` and the controls
|
|
18
|
+
// beside it are not, so centering them leaves every label sitting off the
|
|
19
|
+
// title's baseline by half the difference in leading.
|
|
20
|
+
align-items: baseline;
|
|
18
21
|
gap: var(--wzl-space-md);
|
|
19
22
|
padding: var(--wzl-space-sm) var(--wzl-space-md);
|
|
20
23
|
background: var(--wzl-surface-raised);
|
|
@@ -32,6 +35,9 @@
|
|
|
32
35
|
.lk-shell-header-actions {
|
|
33
36
|
margin-left: auto;
|
|
34
37
|
display: flex;
|
|
38
|
+
// A flex container reports its first item's baseline, so this is what
|
|
39
|
+
// carries the row's baseline up to the header.
|
|
40
|
+
align-items: baseline;
|
|
35
41
|
gap: var(--wzl-space-sm);
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -73,12 +79,21 @@
|
|
|
73
79
|
font-size: var(--wzl-font-size-sm);
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
// A flex container reports its *first* item's baseline, and the leading icon
|
|
83
|
+
// is an <svg> with none — so a centered button hands the header a baseline
|
|
84
|
+
// synthesized from the icon's bottom edge, and the label lands 2px off the
|
|
85
|
+
// title. Baseline here makes the label the button's baseline; the icon keeps
|
|
86
|
+
// its own centering through `align-self`.
|
|
76
87
|
.lk-lab-header__add {
|
|
77
88
|
display: inline-flex;
|
|
78
|
-
align-items:
|
|
89
|
+
align-items: baseline;
|
|
79
90
|
gap: var(--wzl-space-xs);
|
|
80
91
|
}
|
|
81
92
|
|
|
93
|
+
.lk-lab-header__add > svg {
|
|
94
|
+
align-self: center;
|
|
95
|
+
}
|
|
96
|
+
|
|
82
97
|
// `Select` is a form field and defaults to `width: 100%`. Left to resolve that
|
|
83
98
|
// against a shrink-to-fit header row it swallows the slack, which pushed the
|
|
84
99
|
// consumer's own header content down to min-content and wrapped the row to
|
|
@@ -39,6 +39,14 @@ describe('LabShell', () => {
|
|
|
39
39
|
expect(root.getAttribute('data-wzl-mode')).toMatch(/^(light|dark)$/);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
// The overlays weasel-ui ships default to the nearest portal host, and
|
|
43
|
+
// `.lk-root` is where labkit's element defaults live — not the themed
|
|
44
|
+
// wrapper above it.
|
|
45
|
+
test('marks lk-root as the portal host for overlays', () => {
|
|
46
|
+
const { container } = render(<LabShell title="t">x</LabShell>);
|
|
47
|
+
expect(container.querySelector('.lk-root')?.hasAttribute('data-wzl-portal-host')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
42
50
|
test('always applies lk-root class', () => {
|
|
43
51
|
const { container } = render(<LabShell title="t">x</LabShell>);
|
|
44
52
|
expect(container.querySelector('.lk-root')).not.toBeNull();
|
package/src/lab/LabShell.tsx
CHANGED
|
@@ -24,7 +24,9 @@ export function LabShell({ title, children, header, footer, mode = 'auto' }: Lab
|
|
|
24
24
|
const outer = useThemeOptional();
|
|
25
25
|
|
|
26
26
|
const shell = (
|
|
27
|
-
|
|
27
|
+
// Overlays portal here rather than to the themed wrapper above: `.lk-root`
|
|
28
|
+
// carries the element defaults a lab's bare markup is styled by.
|
|
29
|
+
<div className="lk-root lk-shell" data-wzl-portal-host="">
|
|
28
30
|
<header className="lk-shell-header">
|
|
29
31
|
<h1 className="lk-shell-title">{title}</h1>
|
|
30
32
|
{header && <div className="lk-shell-header-actions">{header}</div>}
|