@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,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
|
@@ -202,7 +202,7 @@ export type { ValidationResult } from './instrument/validateConfigSchema';
|
|
|
202
202
|
export { validateConfigSchema } from './instrument/validateConfigSchema';
|
|
203
203
|
export * from './job';
|
|
204
204
|
export * from './lab';
|
|
205
|
-
export type { LayerListProps } from './layers';
|
|
205
|
+
export type { LayerListProps, LayerTreeNode } from './layers';
|
|
206
206
|
export { LayerList } from './layers';
|
|
207
207
|
export * from './loupe';
|
|
208
208
|
export * from './primitives';
|
|
@@ -230,6 +230,7 @@ export type {
|
|
|
230
230
|
SavedSnapshot,
|
|
231
231
|
SerializedTrial,
|
|
232
232
|
StorageAdapter,
|
|
233
|
+
TrialInfo,
|
|
233
234
|
TrialRecord,
|
|
234
235
|
TrialStateHandle,
|
|
235
236
|
UndoStack,
|
package/src/instrument/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { 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
|
|
@@ -15,11 +16,9 @@ export interface RenderContext<TS = unknown, TC = unknown> {
|
|
|
15
16
|
config: TC;
|
|
16
17
|
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
17
18
|
setConfig: (key: keyof TC, value: unknown) => void;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
* it and restores it on Reset without ever reading into it. */
|
|
22
|
-
view: unknown;
|
|
19
|
+
/** labkit persists `view` and restores it on Reset without ever reading
|
|
20
|
+
* into it. */
|
|
21
|
+
trial: TrialInfo & {
|
|
23
22
|
setView: (next: unknown) => void;
|
|
24
23
|
/** 2D convenience over `view`. Reads 1 and writes nothing when the trial holds
|
|
25
24
|
* a view that is not the 2D one. */
|
package/src/job/useJob.ts
CHANGED
package/src/lab/Lab.test.tsx
CHANGED
|
@@ -52,6 +52,13 @@ describe('<Lab>', () => {
|
|
|
52
52
|
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(2);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
it('addTrial opens each trial on the config it was given', () => {
|
|
56
|
+
mountLab();
|
|
57
|
+
act(() => labRef?.addTrial('Stub', { config: { count: 7 } }));
|
|
58
|
+
expect(labRef?.trials.map((t) => (t.config as { count: number }).count)).toEqual([0, 7]);
|
|
59
|
+
expect(labRef?.trials.map((t) => (t.state as { value: number }).value)).toEqual([0, 7]);
|
|
60
|
+
});
|
|
61
|
+
|
|
55
62
|
it('closeTrial removes one when more than one exists', () => {
|
|
56
63
|
mountLab();
|
|
57
64
|
act(() => labRef?.addTrial('Stub'));
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -220,8 +220,8 @@ export function Lab({
|
|
|
220
220
|
return {
|
|
221
221
|
instruments,
|
|
222
222
|
trials,
|
|
223
|
-
addTrial: (instrumentName) => {
|
|
224
|
-
const next = addTrialOp(store.getState().trials, instruments, instrumentName);
|
|
223
|
+
addTrial: (instrumentName, options) => {
|
|
224
|
+
const next = addTrialOp(store.getState().trials, instruments, instrumentName, options);
|
|
225
225
|
replaceTrials(next);
|
|
226
226
|
},
|
|
227
227
|
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
|
package/src/lab/Workspace.tsx
CHANGED
|
@@ -210,7 +210,7 @@ export function Workspace({
|
|
|
210
210
|
if (!surface) return;
|
|
211
211
|
surface.invalidateRects();
|
|
212
212
|
return store.events.on('node.placementChanged', () => surface.invalidateRects());
|
|
213
|
-
}, [store, surface, nodeIds]);
|
|
213
|
+
}, [store, surface, nodeIds]);
|
|
214
214
|
|
|
215
215
|
useEffect(() => {
|
|
216
216
|
if (!onLayoutChange) return;
|
package/src/layers/AGENTS.md
CHANGED
|
@@ -6,22 +6,26 @@ The `src/layers/` directory implements the `<LayerList>` UI used by the `layers`
|
|
|
6
6
|
|
|
7
7
|
| File | Role |
|
|
8
8
|
|---|---|
|
|
9
|
-
| `LayerList.tsx` | Layer toggle/reorder list (sidebar widget) |
|
|
10
|
-
| `LayerList.less` | Row styling, drag handle, pinned-row variant |
|
|
9
|
+
| `LayerList.tsx` | Layer toggle/reorder list, flat or nested (sidebar widget) |
|
|
10
|
+
| `LayerList.less` | Row styling, drag handle, pinned-row variant, subtree indent |
|
|
11
|
+
| `LayerList.stories.tsx` | Flat and nested renderings |
|
|
11
12
|
|
|
12
13
|
## Props
|
|
13
14
|
|
|
14
15
|
```ts
|
|
15
16
|
interface LayerListProps {
|
|
16
|
-
layers:
|
|
17
|
+
layers: LayerTreeNode[]; // LayerDescriptor + { children?, defaultCollapsed? }
|
|
17
18
|
visibility: Record<string, boolean>; // missing key → visible
|
|
18
|
-
onReorder: (newOrder:
|
|
19
|
+
onReorder: (newOrder: LayerTreeNode[]) => void;
|
|
19
20
|
onToggle: (id: string, visible: boolean) => void;
|
|
21
|
+
collapsedIds?: readonly string[]; // supplying it makes collapse controlled
|
|
22
|
+
onCollapsedChange?: (ids: string[]) => void;
|
|
20
23
|
className?: string;
|
|
21
24
|
}
|
|
22
25
|
```
|
|
23
26
|
|
|
24
|
-
`LayerDescriptor` lives in `src/instrument/types.ts
|
|
27
|
+
`LayerDescriptor` lives in `src/instrument/types.ts`; `LayerTreeNode` extends it
|
|
28
|
+
in `LayerList.tsx`, so a plain `LayerDescriptor[]` is still a valid `layers`.
|
|
25
29
|
|
|
26
30
|
## How it integrates
|
|
27
31
|
|
|
@@ -44,6 +48,24 @@ Setting `alwaysOn: true` on a `LayerDescriptor` does two things:
|
|
|
44
48
|
|
|
45
49
|
This is intended for legend/HUD layers that should never be toggled off. Visibility for pinned rows is **not** read from the `visibility` prop; they always render.
|
|
46
50
|
|
|
51
|
+
## Nesting
|
|
52
|
+
|
|
53
|
+
A node with `children` renders an expandable subtree. The expand control is
|
|
54
|
+
`<Disclosure>` from `@weasel-js/ui` — the same twisty every other collapsible
|
|
55
|
+
surface uses.
|
|
56
|
+
|
|
57
|
+
Reordering is scoped to siblings: a drag moves a row within its own parent's
|
|
58
|
+
child list and never reparents it. `onReorder` still receives the whole tree,
|
|
59
|
+
with only that sibling group's order changed.
|
|
60
|
+
|
|
61
|
+
The twisty column appears only when some node in the tree has children — a flat
|
|
62
|
+
list renders exactly the rows it always did. Within a tree, childless rows get
|
|
63
|
+
`.lk-layer-list__twisty-gap` so labels stay aligned down a level.
|
|
64
|
+
|
|
65
|
+
Collapse is uncontrolled by default, seeded once from each node's
|
|
66
|
+
`defaultCollapsed`. Pass `collapsedIds` to own it; `onCollapsedChange` fires
|
|
67
|
+
either way.
|
|
68
|
+
|
|
47
69
|
## Drag handle customization
|
|
48
70
|
|
|
49
71
|
The drag handle is a button with class `lk-layer-list__handle` holding `<DragHandleGlyph>` (from `@weasel-js/ui`), the same grip `LayerStack` uses. Its padding is transparent and cancelled by an equal negative margin, so the grab target is larger than the drawn dots without widening the row. Row pitch — height plus row gap — is measured from the DOM when a drag starts, so restyling the row does not skew drag distance. It used to be a hardcoded `28`, which had already drifted from the rendered height by the time it was found.
|
|
@@ -59,7 +81,7 @@ If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No laye
|
|
|
59
81
|
Fork this component if you need:
|
|
60
82
|
- Multi-select reorder (current implementation is single-row)
|
|
61
83
|
- Right-click context menu
|
|
62
|
-
-
|
|
84
|
+
- Reparenting by drag (nesting renders, but a drag stays within one parent)
|
|
63
85
|
- Solo/mute UI common in DAW-style apps
|
|
64
86
|
|
|
65
|
-
|
|
87
|
+
It is self-contained: `LayerDescriptor` from instrument types and `Disclosure`/`DragHandleGlyph` from `@weasel-js/ui`.
|
|
@@ -75,3 +75,19 @@
|
|
|
75
75
|
.lk-layer-list input[type='checkbox'] {
|
|
76
76
|
accent-color: var(--wzl-accent);
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
.lk-layer-list__children {
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
gap: 1px;
|
|
83
|
+
// Indents by the twisty column, so a child's own twisty starts where its
|
|
84
|
+
// parent's label does.
|
|
85
|
+
padding-left: var(--wzl-space-md);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Holds the twisty column open on rows that have nothing to expand, so labels
|
|
89
|
+
// line up down the level.
|
|
90
|
+
.lk-layer-list__twisty-gap {
|
|
91
|
+
flex: 0 0 auto;
|
|
92
|
+
width: var(--wzl-disclosure-target, 20px);
|
|
93
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { LayerList, type LayerTreeNode } from './LayerList';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof LayerList> = {
|
|
6
|
+
title: 'labkit/layers/LayerList',
|
|
7
|
+
component: LayerList,
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof LayerList>;
|
|
11
|
+
|
|
12
|
+
const FLAT: LayerTreeNode[] = [
|
|
13
|
+
{ id: 'plants', label: 'Plants' },
|
|
14
|
+
{ id: 'grid', label: 'Grid' },
|
|
15
|
+
{ id: 'legend', label: 'Legend', alwaysOn: true },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const NESTED: LayerTreeNode[] = [
|
|
19
|
+
{
|
|
20
|
+
id: 'ingest',
|
|
21
|
+
label: 'Ingest',
|
|
22
|
+
children: [
|
|
23
|
+
{ id: 'ingest.fetch', label: 'Fetch' },
|
|
24
|
+
{ id: 'ingest.parse', label: 'Parse' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'solve',
|
|
29
|
+
label: 'Solve',
|
|
30
|
+
defaultCollapsed: true,
|
|
31
|
+
children: [
|
|
32
|
+
{ id: 'solve.seed', label: 'Seed' },
|
|
33
|
+
{
|
|
34
|
+
id: 'solve.relax',
|
|
35
|
+
label: 'Relax',
|
|
36
|
+
children: [
|
|
37
|
+
{ id: 'solve.relax.sweep', label: 'Sweep' },
|
|
38
|
+
{ id: 'solve.relax.check', label: 'Check' },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{ id: 'report', label: 'Report', alwaysOn: true },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function Demo({ seed }: { seed: LayerTreeNode[] }) {
|
|
47
|
+
const [layers, setLayers] = useState(seed);
|
|
48
|
+
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
|
|
49
|
+
return (
|
|
50
|
+
<div style={{ width: 220 }}>
|
|
51
|
+
<LayerList
|
|
52
|
+
layers={layers}
|
|
53
|
+
visibility={visibility}
|
|
54
|
+
onReorder={setLayers}
|
|
55
|
+
onToggle={(id, visible) => setVisibility((v) => ({ ...v, [id]: visible }))}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** No node has children, so no row spends width on a twisty column. */
|
|
62
|
+
export const Flat: Story = { render: () => <Demo seed={FLAT} /> };
|
|
63
|
+
|
|
64
|
+
/** Dragging a row reorders it among its own siblings — a drag never moves a
|
|
65
|
+
* layer to a different parent. */
|
|
66
|
+
export const Tree: Story = { render: () => <Demo seed={NESTED} /> };
|