@weasel-js/labkit 1.4.0-pre.1 → 1.4.1
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/dist/_dts/{CanvasStackContext-LnCfqNBA.d.ts → CanvasStackContext-BIRUVVGH.d.ts} +1 -1
- package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
- package/dist/_dts/{frac-C-2c72Ij.d.ts → frac-X7mWgd7y.d.ts} +10 -4
- package/dist/_dts/{index-BDVzvRzQ.d.ts → index-FEa1MudK.d.ts} +8 -2
- package/dist/_dts/{types-DYMaEvM5.d.ts → types-ChVJJHvk.d.ts} +3 -0
- package/dist/_dts/{types-D6s4b7if.d.ts → types-lg4TSCb2.d.ts} +3 -1
- package/dist/_dts/{useTrialState-gmMvZqPc.d.ts → useTrialState-Cdm13fvl.d.ts} +2 -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 +6 -6
- package/dist/chrome/index.js +5 -5
- package/dist/chunk-2YILQ7Y5.js +33 -0
- package/dist/chunk-2YILQ7Y5.js.map +1 -0
- package/dist/{chunk-TO2FUOKF.js → chunk-53AZ2O2N.js} +16 -13
- package/dist/chunk-53AZ2O2N.js.map +1 -0
- package/dist/{chunk-QB5SM4BM.js → chunk-7TRFMIWX.js} +55 -41
- package/dist/chunk-7TRFMIWX.js.map +1 -0
- package/dist/{chunk-6OEYTYML.js → chunk-BMQWLKOL.js} +15 -15
- package/dist/chunk-BMQWLKOL.js.map +1 -0
- package/dist/{chunk-M75ZU6ZZ.js → chunk-D7QIPGEB.js} +3 -3
- package/dist/{chunk-M75ZU6ZZ.js.map → chunk-D7QIPGEB.js.map} +1 -1
- package/dist/{chunk-L5QJOOLV.js → chunk-DG2IFMLA.js} +1309 -273
- package/dist/chunk-DG2IFMLA.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-RBGKL7NF.js → chunk-J6SBFZYT.js} +1807 -1814
- package/dist/chunk-J6SBFZYT.js.map +1 -0
- package/dist/{chunk-XJ6N32QP.js → chunk-LJSIUFYD.js} +14 -30
- package/dist/chunk-LJSIUFYD.js.map +1 -0
- package/dist/{chunk-AE5CNVRU.js → chunk-MIT3ISW4.js} +37 -31
- package/dist/chunk-MIT3ISW4.js.map +1 -0
- package/dist/{chunk-HHGVISVZ.js → chunk-MSR7EVS6.js} +7 -5
- package/dist/chunk-MSR7EVS6.js.map +1 -0
- package/dist/{chunk-ESIQNQ6K.js → chunk-OFCBEZMU.js} +12 -6
- package/dist/chunk-OFCBEZMU.js.map +1 -0
- package/dist/{chunk-HMFODCOX.js → chunk-PP7ZWAMV.js} +83 -75
- package/dist/chunk-PP7ZWAMV.js.map +1 -0
- package/dist/{chunk-E2UQVZ44.js → chunk-RLOXQZW3.js} +3 -3
- package/dist/{chunk-E2UQVZ44.js.map → chunk-RLOXQZW3.js.map} +1 -1
- package/dist/controls/index.d.ts +4 -3
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +5 -5
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +57 -77
- package/dist/index.js +308 -161
- package/dist/index.js.map +1 -1
- package/dist/job/index.js +1 -1
- package/dist/layers/index.d.ts +6 -6
- 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 +141 -2866
- 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 +4 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +74 -5
- package/dist/surface/index.d.ts +17 -2
- package/dist/surface/index.js +3 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +6 -5
- package/package.json +8 -8
- package/src/annotations/AnnotationOverlay.tsx +10 -6
- package/src/annotations/Annotations.less +6 -0
- package/src/annotations/Annotations.overlay.test.tsx +113 -0
- package/src/annotations/MarkList.tsx +3 -2
- package/src/canvas/CanvasStack.tsx +8 -13
- package/src/canvas/useOrbit.test.ts +93 -1
- package/src/canvas/useOrbit.ts +41 -34
- package/src/canvas/usePanZoom.test.ts +102 -1
- package/src/canvas/usePanZoom.ts +54 -36
- package/src/config/builder.test.ts +5 -0
- package/src/config/builder.ts +6 -0
- package/src/config/types.ts +1 -0
- package/src/controls/ControlPanel.test.tsx +23 -0
- package/src/controls/ControlPanel.tsx +3 -0
- package/src/job/useJob.ts +1 -1
- package/src/lab/Lab.surface.test.tsx +8 -3
- package/src/lab/LabHeader.test.tsx +12 -0
- package/src/lab/LabHeader.tsx +16 -6
- package/src/lab/Workspace.tsx +1 -1
- package/src/layers/LayerList.test.tsx +72 -1
- package/src/layers/LayerList.tsx +40 -32
- package/src/passthrough/weasel-ui.test.ts +9 -0
- package/src/passthrough/weasel-ui.ts +4 -0
- package/src/primitives/FloatingPanel.test.tsx +82 -0
- package/src/primitives/FloatingPanel.tsx +59 -41
- package/src/primitives/ZoomControl.tsx +2 -0
- package/src/state/store.ts +10 -0
- package/src/state/types.ts +3 -0
- package/src/surface/AGENTS.md +11 -1
- package/src/surface/index.ts +7 -1
- package/src/surface/useSurfaceTile.ts +20 -2
- package/src/surface/useTiledSurface.ts +4 -1
- package/src/theme/Interstellar.stories.tsx +4 -3
- package/src/theme/base.less +39 -1
- package/src/trial/Trial.less +27 -5
- package/src/trial/TrialBody.test.tsx +67 -0
- package/src/trial/TrialBody.tsx +155 -0
- package/src/trial/TrialChrome.tsx +18 -10
- package/src/trial/index.ts +2 -0
- 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-6OEYTYML.js.map +0 -1
- package/dist/chunk-AE5CNVRU.js.map +0 -1
- package/dist/chunk-ESIQNQ6K.js.map +0 -1
- package/dist/chunk-HHGVISVZ.js.map +0 -1
- package/dist/chunk-HMFODCOX.js.map +0 -1
- package/dist/chunk-L5QJOOLV.js.map +0 -1
- package/dist/chunk-QB5SM4BM.js.map +0 -1
- package/dist/chunk-RBGKL7NF.js.map +0 -1
- package/dist/chunk-TO2FUOKF.js.map +0 -1
- package/dist/chunk-XJ6N32QP.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { act, renderHook } from '@testing-library/react';
|
|
2
|
-
import type { WheelEvent } from 'react';
|
|
2
|
+
import type { PointerEvent as ReactPointerEvent, WheelEvent } from 'react';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import type { ViewTransform } from '../instrument/types';
|
|
5
5
|
import { screenToWorld } from './canvasCoords';
|
|
@@ -109,3 +109,104 @@ describe('usePanZoom', () => {
|
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
/** The fields `usePanZoom` reads off a React pointerdown, plus the element the
|
|
114
|
+
* session hangs its document listeners off. */
|
|
115
|
+
function press(
|
|
116
|
+
el: HTMLElement,
|
|
117
|
+
x: number,
|
|
118
|
+
y: number,
|
|
119
|
+
init: { pointerId?: number; button?: number; buttons?: number } = {},
|
|
120
|
+
): ReactPointerEvent<HTMLElement> {
|
|
121
|
+
return {
|
|
122
|
+
pointerId: init.pointerId ?? 1,
|
|
123
|
+
button: init.button ?? 0,
|
|
124
|
+
buttons: init.buttons ?? 1,
|
|
125
|
+
clientX: x,
|
|
126
|
+
clientY: y,
|
|
127
|
+
currentTarget: el,
|
|
128
|
+
} as unknown as ReactPointerEvent<HTMLElement>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function dispatchOn(target: EventTarget, type: string, init: PointerEventInit) {
|
|
132
|
+
act(() => {
|
|
133
|
+
target.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function dragSetup(options?: Partial<UsePanZoomOptions>) {
|
|
138
|
+
let view: ViewTransform = { zoom: 1, pan: { x: 0, y: 0 } };
|
|
139
|
+
const onViewChange = (v: ViewTransform) => {
|
|
140
|
+
view = v;
|
|
141
|
+
};
|
|
142
|
+
const { result, unmount } = renderHook(() =>
|
|
143
|
+
usePanZoom({ ...options, view, onViewChange } as UsePanZoomOptions),
|
|
144
|
+
);
|
|
145
|
+
const el = document.createElement('div');
|
|
146
|
+
document.body.appendChild(el);
|
|
147
|
+
return { result, unmount, el, getView: () => view };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe('usePanZoom dragging', () => {
|
|
151
|
+
it('pans from a move the element never sees', () => {
|
|
152
|
+
const { result, el, getView } = dragSetup();
|
|
153
|
+
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
154
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
|
|
155
|
+
expect(getView().pan).toEqual({ x: 50, y: 20 });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('ends the drag on a release dispatched off the element', () => {
|
|
159
|
+
const { result, el, getView } = dragSetup();
|
|
160
|
+
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
161
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
|
|
162
|
+
dispatchOn(document, 'pointerup', { clientX: 150, clientY: 120 });
|
|
163
|
+
expect(result.current.isDragging()).toBe(false);
|
|
164
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
|
|
165
|
+
expect(getView().pan).toEqual({ x: 50, y: 20 });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('ends the drag when capture is lost mid-gesture', () => {
|
|
169
|
+
const { result, el, getView } = dragSetup();
|
|
170
|
+
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
171
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
|
|
172
|
+
dispatchOn(el, 'lostpointercapture', {});
|
|
173
|
+
expect(result.current.isDragging()).toBe(false);
|
|
174
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
|
|
175
|
+
expect(getView().pan).toEqual({ x: 50, y: 20 });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('reads a move with nothing held as the release that never arrived', () => {
|
|
179
|
+
const { result, el, getView } = dragSetup();
|
|
180
|
+
act(() => result.current.onPointerDown(press(el, 100, 100, { buttons: 1 })));
|
|
181
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
|
|
182
|
+
expect(getView().pan).toEqual({ x: 50, y: 20 });
|
|
183
|
+
dispatchOn(document, 'pointermove', { buttons: 0, clientX: 300, clientY: 300 });
|
|
184
|
+
expect(getView().pan).toEqual({ x: 50, y: 20 });
|
|
185
|
+
expect(result.current.isDragging()).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('reports a release that never crossed the threshold as a tap', () => {
|
|
189
|
+
const taps: number[] = [];
|
|
190
|
+
const { result, el } = dragSetup({ onTap: (e) => taps.push(e.clientX) });
|
|
191
|
+
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
192
|
+
dispatchOn(document, 'pointerup', { clientX: 101, clientY: 100 });
|
|
193
|
+
expect(taps).toEqual([101]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('does not report a tap when the pointer panned', () => {
|
|
197
|
+
const taps: number[] = [];
|
|
198
|
+
const { result, el } = dragSetup({ onTap: (e) => taps.push(e.clientX) });
|
|
199
|
+
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
200
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
|
|
201
|
+
dispatchOn(document, 'pointerup', { clientX: 150, clientY: 120 });
|
|
202
|
+
expect(taps).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('drops its listeners when the hook unmounts mid-drag', () => {
|
|
206
|
+
const { result, el, unmount, getView } = dragSetup();
|
|
207
|
+
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
208
|
+
unmount();
|
|
209
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
|
|
210
|
+
expect(getView().pan).toEqual({ x: 0, y: 0 });
|
|
211
|
+
});
|
|
212
|
+
});
|
package/src/canvas/usePanZoom.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { openPointerSession, type PointerSession } from '@weasel-js/core';
|
|
2
|
+
import {
|
|
3
|
+
type PointerEvent as ReactPointerEvent,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
type WheelEvent,
|
|
8
|
+
} from 'react';
|
|
2
9
|
import type { ViewTransform } from '../instrument/types';
|
|
3
10
|
import { zoomAt } from './camera';
|
|
4
11
|
import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
|
|
@@ -14,18 +21,19 @@ export interface UsePanZoomOptions {
|
|
|
14
21
|
* wheel anchors in this frame; omitting it anchors at the element's
|
|
15
22
|
* top-left, which is what labkit did before a frame could be declared. */
|
|
16
23
|
frame?: WorldFrame;
|
|
24
|
+
/** A primary-button press released without ever crossing the drag
|
|
25
|
+
* threshold. The pointer session owns the release, so the gesture's end is
|
|
26
|
+
* reported here rather than left for a consumer's own `onPointerUp`. */
|
|
27
|
+
onTap?: (e: PointerEvent) => void;
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export interface PanZoomHandlers {
|
|
20
31
|
onWheel: (e: WheelEvent<HTMLElement>) => void;
|
|
21
|
-
onPointerDown: (e:
|
|
22
|
-
onPointerMove: (e: PointerEvent<HTMLElement>) => void;
|
|
23
|
-
onPointerUp: (e: PointerEvent<HTMLElement>) => void;
|
|
32
|
+
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
|
|
24
33
|
isDragging: () => boolean;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
interface DragState {
|
|
28
|
-
pointerId: number;
|
|
29
37
|
startScreenX: number;
|
|
30
38
|
startScreenY: number;
|
|
31
39
|
startPan: { x: number; y: number };
|
|
@@ -44,10 +52,16 @@ export function usePanZoom({
|
|
|
44
52
|
minZoom = 0.1,
|
|
45
53
|
maxZoom = 32,
|
|
46
54
|
frame = DEFAULT_FRAME,
|
|
55
|
+
onTap,
|
|
47
56
|
}: UsePanZoomOptions): PanZoomHandlers {
|
|
48
57
|
const dragRef = useRef<DragState | null>(null);
|
|
58
|
+
const sessionRef = useRef<PointerSession | null>(null);
|
|
49
59
|
const viewRef = useRef(view);
|
|
50
60
|
viewRef.current = view;
|
|
61
|
+
const onViewChangeRef = useRef(onViewChange);
|
|
62
|
+
onViewChangeRef.current = onViewChange;
|
|
63
|
+
const onTapRef = useRef(onTap);
|
|
64
|
+
onTapRef.current = onTap;
|
|
51
65
|
// Captured once, from the view the canvas opened at — not the current view,
|
|
52
66
|
// which would let a prior zoom-out shrink the range and trap the opening
|
|
53
67
|
// view unreachable behind it.
|
|
@@ -74,44 +88,48 @@ export function usePanZoom({
|
|
|
74
88
|
[onViewChange, minZoom, maxZoom, frame],
|
|
75
89
|
);
|
|
76
90
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
dragRef.current = {
|
|
81
|
-
pointerId: e.pointerId,
|
|
82
|
-
startScreenX: e.clientX,
|
|
83
|
-
startScreenY: e.clientY,
|
|
84
|
-
startPan: { ...viewRef.current.pan },
|
|
85
|
-
moved: false,
|
|
86
|
-
};
|
|
91
|
+
const clearDrag = useCallback(() => {
|
|
92
|
+
dragRef.current = null;
|
|
93
|
+
sessionRef.current = null;
|
|
87
94
|
}, []);
|
|
88
95
|
|
|
89
|
-
const
|
|
90
|
-
(e:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
const onPointerDown = useCallback(
|
|
97
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
98
|
+
if (e.button !== 0) return;
|
|
99
|
+
const drag: DragState = {
|
|
100
|
+
startScreenX: e.clientX,
|
|
101
|
+
startScreenY: e.clientY,
|
|
102
|
+
startPan: { ...viewRef.current.pan },
|
|
103
|
+
moved: false,
|
|
104
|
+
};
|
|
105
|
+
dragRef.current = drag;
|
|
106
|
+
sessionRef.current = openPointerSession(e.currentTarget, e, {
|
|
107
|
+
onMove: (ev) => {
|
|
108
|
+
const dx = ev.clientX - drag.startScreenX;
|
|
109
|
+
const dy = ev.clientY - drag.startScreenY;
|
|
110
|
+
if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
111
|
+
drag.moved = true;
|
|
112
|
+
onViewChangeRef.current({
|
|
113
|
+
zoom: viewRef.current.zoom,
|
|
114
|
+
pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
onEnd: (ev) => {
|
|
118
|
+
const tapped = !drag.moved;
|
|
119
|
+
clearDrag();
|
|
120
|
+
if (tapped) onTapRef.current?.(ev);
|
|
121
|
+
},
|
|
122
|
+
// A cancelled pan keeps whatever it had already applied: every move
|
|
123
|
+
// was committed as it arrived, so there is nothing left to undo.
|
|
124
|
+
onCancel: clearDrag,
|
|
100
125
|
});
|
|
101
126
|
},
|
|
102
|
-
[
|
|
127
|
+
[clearDrag],
|
|
103
128
|
);
|
|
104
129
|
|
|
105
|
-
|
|
106
|
-
const drag = dragRef.current;
|
|
107
|
-
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
108
|
-
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
109
|
-
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
110
|
-
}
|
|
111
|
-
dragRef.current = null;
|
|
112
|
-
}, []);
|
|
130
|
+
useEffect(() => () => sessionRef.current?.cancel(), []);
|
|
113
131
|
|
|
114
132
|
const isDragging = useCallback(() => dragRef.current?.moved === true, []);
|
|
115
133
|
|
|
116
|
-
return { onWheel, onPointerDown,
|
|
134
|
+
return { onWheel, onPointerDown, isDragging };
|
|
117
135
|
}
|
|
@@ -29,6 +29,11 @@ describe('builder', () => {
|
|
|
29
29
|
});
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
it('annotates a number with a display suffix', () => {
|
|
33
|
+
const n = f.number(20).suffix('px');
|
|
34
|
+
expect(n.annotations.suffix).toBe('px');
|
|
35
|
+
});
|
|
36
|
+
|
|
32
37
|
it('keeps a custom node kind across a chain', () => {
|
|
33
38
|
const n = f.custom('vector2', { x: 0 }).label('Offset');
|
|
34
39
|
expect(n.kind).toBe('vector2');
|
package/src/config/builder.ts
CHANGED
|
@@ -90,6 +90,12 @@ class NumberNode extends BaseNode<number> {
|
|
|
90
90
|
return this.ann({ step });
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** A display suffix for the value — `'px'`, `'ms'`, `'°'`. Presentation only:
|
|
94
|
+
* it is never parsed, and the stored value stays a plain number. */
|
|
95
|
+
suffix(suffix: string): this {
|
|
96
|
+
return this.ann({ suffix });
|
|
97
|
+
}
|
|
98
|
+
|
|
93
99
|
/** Force a slider even without both bounds. */
|
|
94
100
|
slider(): this {
|
|
95
101
|
return this.ann({ control: 'slider' });
|
package/src/config/types.ts
CHANGED
|
@@ -420,3 +420,26 @@ describe('<ControlPanel> packing', () => {
|
|
|
420
420
|
expect(rowOf('Width')?.className).toMatch(/rowInline/);
|
|
421
421
|
});
|
|
422
422
|
});
|
|
423
|
+
|
|
424
|
+
describe('<ControlPanel> suffix', () => {
|
|
425
|
+
const panel = (node: ReturnType<typeof f.number>) =>
|
|
426
|
+
render(
|
|
427
|
+
<ControlPanel
|
|
428
|
+
schema={resolveConfigSchema(f.schema({ width: node.label('Width') }), [])}
|
|
429
|
+
config={{ width: 20 }}
|
|
430
|
+
setConfig={vi.fn()}
|
|
431
|
+
/>,
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
it('suffixes a slider readout with the suffix the leaf declares', () => {
|
|
435
|
+
const { container } = panel(f.number(20).range(0, 100).slider().suffix('px'));
|
|
436
|
+
expect(container.querySelector('input[type="range"]')).not.toBeNull();
|
|
437
|
+
expect(container.textContent).toContain('px');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('suffixes a typed number with the suffix the leaf declares', () => {
|
|
441
|
+
const { container } = panel(f.number(20).input().suffix('px'));
|
|
442
|
+
expect(container.querySelector('input[type="number"]')).not.toBeNull();
|
|
443
|
+
expect(container.textContent).toContain('px');
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -159,6 +159,7 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
159
159
|
const min = extra<number>(leaf, 'min');
|
|
160
160
|
const max = extra<number>(leaf, 'max');
|
|
161
161
|
const step = extra<number>(leaf, 'step');
|
|
162
|
+
const suffix = extra<string>(leaf, 'suffix');
|
|
162
163
|
if (extra<string>(leaf, 'control') === 'slider' && min !== undefined && max !== undefined) {
|
|
163
164
|
return (
|
|
164
165
|
<SliderRow
|
|
@@ -167,6 +168,7 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
167
168
|
min={min}
|
|
168
169
|
max={max}
|
|
169
170
|
step={step}
|
|
171
|
+
unit={suffix}
|
|
170
172
|
onChange={write}
|
|
171
173
|
layout={layout}
|
|
172
174
|
span={wide}
|
|
@@ -186,6 +188,7 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
186
188
|
min={min}
|
|
187
189
|
max={max}
|
|
188
190
|
step={step}
|
|
191
|
+
unit={suffix}
|
|
189
192
|
onChange={(n) => write(Math.min(hi, Math.max(lo, n)))}
|
|
190
193
|
layout={layout}
|
|
191
194
|
description={description}
|
package/src/job/useJob.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { ReactNode } from 'react';
|
|
|
12
12
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { defineInstrument } from '../instrument/defineInstrument';
|
|
14
14
|
import { SurfaceContext } from '../surface/SurfaceContext';
|
|
15
|
-
import { useSurfaceOptional, useSurfaceTile } from '../surface/useSurfaceTile';
|
|
15
|
+
import { useSurfaceOptional, useSurfaceTile, useTileId } from '../surface/useSurfaceTile';
|
|
16
16
|
import type { SurfaceFrame, SurfaceHandle } from '../surface/useTiledSurface';
|
|
17
17
|
import { useTiledSurface } from '../surface/useTiledSurface';
|
|
18
18
|
import { Lab } from './Lab';
|
|
@@ -39,9 +39,13 @@ beforeAll(() => {
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
let seen: SurfaceHandle | null | 'not-rendered' = 'not-rendered';
|
|
42
|
+
/** The key the probe's tile is actually registered under: a tile inside a
|
|
43
|
+
* trial is scoped by it, and the trial's id is minted by the store. */
|
|
44
|
+
let paneTileId = 'pane';
|
|
42
45
|
|
|
43
46
|
function Probe() {
|
|
44
47
|
seen = useSurfaceOptional();
|
|
48
|
+
paneTileId = useTileId('pane');
|
|
45
49
|
const tile = useSurfaceTile('pane');
|
|
46
50
|
return <div data-testid="pane" ref={tile} />;
|
|
47
51
|
}
|
|
@@ -146,7 +150,7 @@ describe('<Lab> tile round trip', () => {
|
|
|
146
150
|
|
|
147
151
|
act(() => {
|
|
148
152
|
handle?.invalidateRects();
|
|
149
|
-
handle?.invalidate(
|
|
153
|
+
handle?.invalidate(paneTileId);
|
|
150
154
|
});
|
|
151
155
|
act(() => {
|
|
152
156
|
vi.advanceTimersByTime(64);
|
|
@@ -156,7 +160,8 @@ describe('<Lab> tile round trip', () => {
|
|
|
156
160
|
// that passes because nothing ran is the failure mode this arc keeps
|
|
157
161
|
// hitting.
|
|
158
162
|
expect(frames.length).toBeGreaterThan(0);
|
|
159
|
-
|
|
163
|
+
expect(paneTileId).not.toBe('pane');
|
|
164
|
+
const rect = frames[frames.length - 1]?.rects.get(paneTileId);
|
|
160
165
|
expect(rect).toEqual({ x: 240, y: 100, w: 320, h: 200 });
|
|
161
166
|
});
|
|
162
167
|
});
|
|
@@ -39,4 +39,16 @@ describe('<LabHeader>', () => {
|
|
|
39
39
|
expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
it('draws each color mode as a glyph, with the word only as its name', () => {
|
|
44
|
+
// The assertion above passes whether the segment holds text or an icon,
|
|
45
|
+
// because both routes produce the same accessible name. This is the one
|
|
46
|
+
// that can tell them apart.
|
|
47
|
+
render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
|
|
48
|
+
for (const label of ['Auto', 'Light', 'Dark']) {
|
|
49
|
+
const segment = screen.getByRole('radio', { name: label });
|
|
50
|
+
expect(segment.querySelector('svg')).not.toBeNull();
|
|
51
|
+
expect(segment).toHaveTextContent('');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
42
54
|
});
|
package/src/lab/LabHeader.tsx
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { AddIcon } from '@weasel-js/ui';
|
|
1
|
+
import { AddIcon, ModeAutoIcon, ModeDarkIcon, ModeLightIcon } from '@weasel-js/ui';
|
|
2
2
|
import { Select, ToggleBar } from '../passthrough/weasel-ui';
|
|
3
3
|
import type { LabMode } from '../state/types';
|
|
4
4
|
import { useLabContext } from './LabContext';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
{ value: '
|
|
6
|
+
// The glyph is the segment's content and the word is its accessible name, so
|
|
7
|
+
// the bar stays a three-way radiogroup announcing Auto / Light / Dark.
|
|
8
|
+
const MODES = [
|
|
9
|
+
{ value: 'auto' as LabMode, label: 'Auto', glyph: <ModeAutoIcon size={14} /> },
|
|
10
|
+
{ value: 'light' as LabMode, label: 'Light', glyph: <ModeLightIcon size={14} /> },
|
|
11
|
+
{ value: 'dark' as LabMode, label: 'Dark', glyph: <ModeDarkIcon size={14} /> },
|
|
10
12
|
];
|
|
11
13
|
|
|
14
|
+
const MODE_ITEMS = MODES.map(({ value, label, glyph }) => ({
|
|
15
|
+
value,
|
|
16
|
+
label: glyph,
|
|
17
|
+
ariaLabel: label,
|
|
18
|
+
}));
|
|
19
|
+
|
|
12
20
|
/** The controls `<Lab>` puts in its header: add a trial, and choose the color
|
|
13
21
|
* mode. Both drive `LabContext`, which carried them with no UI at all — so
|
|
14
22
|
* every consumer rebuilt these two. Rendered before a consumer's own header
|
|
@@ -46,7 +54,9 @@ export function LabHeader() {
|
|
|
46
54
|
<ToggleBar
|
|
47
55
|
className="lk-lab-header__mode"
|
|
48
56
|
ariaLabel="Color mode"
|
|
49
|
-
|
|
57
|
+
size="sm"
|
|
58
|
+
variant="flat"
|
|
59
|
+
items={MODE_ITEMS}
|
|
50
60
|
value={lab.mode}
|
|
51
61
|
onChange={(next) => {
|
|
52
62
|
if (next) lab.setMode(next);
|
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;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from '@testing-library/react';
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import type { LayerDescriptor } from '../instrument/types';
|
|
4
4
|
import { LayerList } from './LayerList';
|
|
@@ -47,3 +47,74 @@ describe('<LayerList>', () => {
|
|
|
47
47
|
expect(onToggle).toHaveBeenCalledWith('a', false);
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
const twoLayers: LayerDescriptor[] = [
|
|
52
|
+
{ id: 'a', label: 'A' },
|
|
53
|
+
{ id: 'b', label: 'B' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function dispatchOn(target: EventTarget, type: string, init: PointerEventInit) {
|
|
57
|
+
act(() => {
|
|
58
|
+
target.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** jsdom measures every row at zero height, so `startDrag` falls back to a
|
|
63
|
+
* pitch of 1: one pixel of travel is one row. */
|
|
64
|
+
function renderDraggable(onReorder = vi.fn()) {
|
|
65
|
+
const result = render(
|
|
66
|
+
<LayerList
|
|
67
|
+
layers={twoLayers}
|
|
68
|
+
visibility={{ a: true, b: true }}
|
|
69
|
+
onReorder={onReorder}
|
|
70
|
+
onToggle={vi.fn()}
|
|
71
|
+
/>,
|
|
72
|
+
);
|
|
73
|
+
return { ...result, onReorder, handle: screen.getByLabelText('Reorder A') };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('<LayerList> reordering', () => {
|
|
77
|
+
it('reorders from a release that lands off the handle', () => {
|
|
78
|
+
const { onReorder, handle } = renderDraggable();
|
|
79
|
+
fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
|
|
80
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
|
|
81
|
+
dispatchOn(document, 'pointerup', { clientY: 1 });
|
|
82
|
+
expect(onReorder).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(onReorder.mock.calls[0][0].map((l: LayerDescriptor) => l.id)).toEqual(['b', 'a']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('abandons the reorder when capture is lost mid-drag', () => {
|
|
87
|
+
const { onReorder, handle, container } = renderDraggable();
|
|
88
|
+
fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
|
|
89
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
|
|
90
|
+
dispatchOn(handle, 'lostpointercapture', {});
|
|
91
|
+
expect(onReorder).not.toHaveBeenCalled();
|
|
92
|
+
expect(container.querySelector('.lk-layer-list__row--dragging')).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('reads a move with nothing held as the release that never arrived', () => {
|
|
96
|
+
const { onReorder, handle } = renderDraggable();
|
|
97
|
+
fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
|
|
98
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
|
|
99
|
+
dispatchOn(document, 'pointermove', { buttons: 0, clientY: 40 });
|
|
100
|
+
expect(onReorder).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(onReorder.mock.calls[0][0].map((l: LayerDescriptor) => l.id)).toEqual(['b', 'a']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('drops its listeners when the list unmounts mid-drag', () => {
|
|
105
|
+
const { onReorder, handle, unmount } = renderDraggable();
|
|
106
|
+
fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
|
|
107
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
|
|
108
|
+
unmount();
|
|
109
|
+
dispatchOn(document, 'pointerup', { clientY: 1 });
|
|
110
|
+
expect(onReorder).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not capture a second pointer into the same drag', () => {
|
|
114
|
+
const { onReorder, handle } = renderDraggable();
|
|
115
|
+
fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
|
|
116
|
+
dispatchOn(document, 'pointermove', { pointerId: 2, buttons: 1, clientY: 40 });
|
|
117
|
+
dispatchOn(document, 'pointerup', { pointerId: 1, clientY: 0 });
|
|
118
|
+
expect(onReorder).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/layers/LayerList.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { openPointerSession, type PointerSession } from '@weasel-js/core';
|
|
2
|
+
import { type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react';
|
|
2
3
|
import type { LayerDescriptor } from '../instrument/types';
|
|
3
4
|
import { DragHandleGlyph } from '../passthrough/weasel-ui';
|
|
4
5
|
|
|
@@ -12,8 +13,8 @@ export interface LayerListProps {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
interface DragState {
|
|
15
|
-
pointerId: number;
|
|
16
16
|
fromIndex: number;
|
|
17
|
+
toIndex: number;
|
|
17
18
|
startY: number;
|
|
18
19
|
/** Row height plus row gap, measured when the drag starts. */
|
|
19
20
|
pitch: number;
|
|
@@ -26,6 +27,13 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
|
|
|
26
27
|
const pinned = layers.filter((l) => l.alwaysOn);
|
|
27
28
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
28
29
|
const dragRef = useRef<DragState | null>(null);
|
|
30
|
+
const sessionRef = useRef<PointerSession | null>(null);
|
|
31
|
+
// The session's callbacks outlive the render that opened them, so the commit
|
|
32
|
+
// reads its inputs here rather than from that render's closure.
|
|
33
|
+
const commitRef = useRef({ reorderable, pinned, onReorder });
|
|
34
|
+
commitRef.current = { reorderable, pinned, onReorder };
|
|
35
|
+
|
|
36
|
+
useEffect(() => () => sessionRef.current?.cancel(), []);
|
|
29
37
|
|
|
30
38
|
if (layers.length === 0) {
|
|
31
39
|
return (
|
|
@@ -35,45 +43,47 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
|
|
|
35
43
|
);
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
const
|
|
39
|
-
|
|
46
|
+
const clearDrag = () => {
|
|
47
|
+
dragRef.current = null;
|
|
48
|
+
sessionRef.current = null;
|
|
49
|
+
setDragIndex(null);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const startDrag = (e: ReactPointerEvent<HTMLElement>, index: number) => {
|
|
40
53
|
// Measured at grab time rather than hardcoded: a restyle that changes row
|
|
41
54
|
// height or gap would otherwise silently skew every drag distance.
|
|
42
55
|
const row = e.currentTarget.closest('.lk-layer-list__row');
|
|
43
56
|
const list = row?.parentElement;
|
|
44
57
|
const gap = list ? Number.parseFloat(getComputedStyle(list).rowGap) || 0 : 0;
|
|
45
58
|
const pitch = row ? row.getBoundingClientRect().height + gap : 0;
|
|
46
|
-
|
|
47
|
-
pointerId: e.pointerId,
|
|
59
|
+
const drag: DragState = {
|
|
48
60
|
fromIndex: index,
|
|
61
|
+
toIndex: index,
|
|
49
62
|
startY: e.clientY,
|
|
50
63
|
pitch: pitch > 0 ? pitch : 1,
|
|
51
64
|
};
|
|
65
|
+
dragRef.current = drag;
|
|
52
66
|
setDragIndex(index);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
onReorder([...next, ...pinned]);
|
|
74
|
-
}
|
|
75
|
-
dragRef.current = null;
|
|
76
|
-
setDragIndex(null);
|
|
67
|
+
sessionRef.current = openPointerSession(e.currentTarget, e, {
|
|
68
|
+
onMove: (ev) => {
|
|
69
|
+
const delta = Math.round((ev.clientY - drag.startY) / drag.pitch);
|
|
70
|
+
const last = commitRef.current.reorderable.length - 1;
|
|
71
|
+
drag.toIndex = Math.min(last, Math.max(0, drag.fromIndex + delta));
|
|
72
|
+
setDragIndex(drag.toIndex);
|
|
73
|
+
},
|
|
74
|
+
onEnd: () => {
|
|
75
|
+
const { reorderable: rows, pinned: locked, onReorder: commit } = commitRef.current;
|
|
76
|
+
const { fromIndex, toIndex } = drag;
|
|
77
|
+
clearDrag();
|
|
78
|
+
if (toIndex === fromIndex) return;
|
|
79
|
+
const next = [...rows];
|
|
80
|
+
const [moved] = next.splice(fromIndex, 1);
|
|
81
|
+
if (moved) next.splice(toIndex, 0, moved);
|
|
82
|
+
commit([...next, ...locked]);
|
|
83
|
+
},
|
|
84
|
+
// An interrupted drag never named a destination, so it reorders nothing.
|
|
85
|
+
onCancel: clearDrag,
|
|
86
|
+
});
|
|
77
87
|
};
|
|
78
88
|
|
|
79
89
|
return (
|
|
@@ -92,8 +102,6 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
|
|
|
92
102
|
className="lk-layer-list__handle"
|
|
93
103
|
aria-label={`Reorder ${layer.label}`}
|
|
94
104
|
onPointerDown={(e) => startDrag(e, i)}
|
|
95
|
-
onPointerMove={moveDrag}
|
|
96
|
-
onPointerUp={endDrag}
|
|
97
105
|
>
|
|
98
106
|
<DragHandleGlyph size={13} />
|
|
99
107
|
</button>
|