@weasel-js/labkit 1.4.0 → 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-D6M0o8cQ.d.ts → CanvasStackContext-BIRUVVGH.d.ts} +1 -1
- package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
- package/dist/_dts/{frac-QS7XzvpG.d.ts → frac-X7mWgd7y.d.ts} +9 -3
- package/dist/_dts/{index-B2X21G1c.d.ts → index-FEa1MudK.d.ts} +1 -1
- package/dist/_dts/{types-C1Zw7s5P.d.ts → types-lg4TSCb2.d.ts} +2 -1
- package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
- package/dist/canvas/index.d.ts +15 -15
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +5 -5
- package/dist/chrome/index.js +5 -5
- package/dist/{chunk-W3KSUXXR.js → chunk-53AZ2O2N.js} +4 -4
- package/dist/{chunk-W3KSUXXR.js.map → chunk-53AZ2O2N.js.map} +1 -1
- package/dist/{chunk-4AMN4MMB.js → chunk-7TRFMIWX.js} +53 -39
- package/dist/chunk-7TRFMIWX.js.map +1 -0
- package/dist/{chunk-UD7TEHZT.js → chunk-BMQWLKOL.js} +6 -6
- package/dist/{chunk-UD7TEHZT.js.map → chunk-BMQWLKOL.js.map} +1 -1
- package/dist/{chunk-YQGQUBKA.js → chunk-D7QIPGEB.js} +3 -3
- package/dist/{chunk-YQGQUBKA.js.map → chunk-D7QIPGEB.js.map} +1 -1
- package/dist/{chunk-7VW3S44V.js → chunk-DG2IFMLA.js} +802 -222
- 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-L5WBGUVR.js → chunk-J6SBFZYT.js} +2131 -2174
- package/dist/chunk-J6SBFZYT.js.map +1 -0
- package/dist/{chunk-XCBCZEBO.js → chunk-MIT3ISW4.js} +37 -31
- package/dist/chunk-MIT3ISW4.js.map +1 -0
- package/dist/{chunk-QR5V3AFG.js → chunk-MSR7EVS6.js} +5 -5
- package/dist/{chunk-QR5V3AFG.js.map → chunk-MSR7EVS6.js.map} +1 -1
- package/dist/{chunk-B5VWJBWM.js → chunk-OFCBEZMU.js} +3 -3
- package/dist/{chunk-B5VWJBWM.js.map → chunk-OFCBEZMU.js.map} +1 -1
- package/dist/{chunk-VHFQKAUL.js → chunk-PP7ZWAMV.js} +83 -75
- package/dist/chunk-PP7ZWAMV.js.map +1 -0
- package/dist/{chunk-Y2HK44TG.js → chunk-RLOXQZW3.js} +3 -3
- package/dist/{chunk-Y2HK44TG.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 +4 -4
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +8 -71
- package/dist/index.js +21 -21
- package/dist/index.js.map +1 -1
- package/dist/job/index.js +1 -1
- package/dist/layers/index.d.ts +5 -5
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +6 -12
- 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 +40 -3086
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.js +4 -4
- package/dist/styles.css +10 -0
- package/dist/surface/index.js +2 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +5 -4
- package/package.json +8 -8
- 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/job/useJob.ts +1 -1
- package/src/lab/Workspace.tsx +1 -1
- package/src/layers/LayerList.test.tsx +72 -1
- package/src/layers/LayerList.tsx +40 -32
- package/src/primitives/FloatingPanel.test.tsx +82 -0
- package/src/primitives/FloatingPanel.tsx +59 -41
- 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-VHFQKAUL.js.map +0 -1
- package/dist/chunk-XCBCZEBO.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
|
}
|
package/src/job/useJob.ts
CHANGED
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>
|
|
@@ -313,3 +313,85 @@ describe('a clickable row nested in a list, the shape a floating panel usually h
|
|
|
313
313
|
expect(panel.dataset.dragging).toBe('true');
|
|
314
314
|
});
|
|
315
315
|
});
|
|
316
|
+
|
|
317
|
+
describe('FloatingPanel drag lifecycle', () => {
|
|
318
|
+
const realCapture = Element.prototype.setPointerCapture;
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
Element.prototype.setPointerCapture = realCapture;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('keeps dragging from moves the panel never sees, and ends on a release off it', () => {
|
|
324
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
325
|
+
fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
|
|
326
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
|
|
327
|
+
expect(panel.dataset.dragging).toBe('true');
|
|
328
|
+
expect(panel.style.left).toBe('100px');
|
|
329
|
+
fireEvent.pointerUp(document.body, { pointerId: 1, clientX: 200, clientY: 180 });
|
|
330
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
331
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 300, clientY: 180 });
|
|
332
|
+
expect(panel.style.left).toBe('100px');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('ends the drag when capture is lost mid-gesture', () => {
|
|
336
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
337
|
+
fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
|
|
338
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
|
|
339
|
+
fireEvent(panel, new PointerEvent('lostpointercapture', { pointerId: 1, bubbles: true }));
|
|
340
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
341
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 300, clientY: 180 });
|
|
342
|
+
expect(panel.style.left).toBe('100px');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('reads a move with nothing held as the release that never arrived', () => {
|
|
346
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
347
|
+
fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
|
|
348
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
|
|
349
|
+
expect(panel.dataset.dragging).toBe('true');
|
|
350
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 0, clientX: 300, clientY: 180 });
|
|
351
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
352
|
+
expect(panel.style.left).toBe('100px');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('captures the pointer only once the press crosses the drag threshold', () => {
|
|
356
|
+
// A proxy for the real symptom: jsdom's setPointerCapture never retargets,
|
|
357
|
+
// so the child click a capture-on-press would swallow cannot be observed.
|
|
358
|
+
const captured: number[] = [];
|
|
359
|
+
Element.prototype.setPointerCapture = (id: number) => {
|
|
360
|
+
captured.push(id);
|
|
361
|
+
};
|
|
362
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
363
|
+
fireEvent.pointerDown(panel, { pointerId: 7, buttons: 1, clientX: 100, clientY: 100 });
|
|
364
|
+
expect(captured).toEqual([]);
|
|
365
|
+
fireEvent.pointerMove(document.body, { pointerId: 7, buttons: 1, clientX: 140, clientY: 100 });
|
|
366
|
+
expect(captured).toEqual([7]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('drops its document listeners when the panel unmounts mid-drag', () => {
|
|
370
|
+
const added: string[] = [];
|
|
371
|
+
const removed: string[] = [];
|
|
372
|
+
const realAdd = document.addEventListener.bind(document);
|
|
373
|
+
const realRemove = document.removeEventListener.bind(document);
|
|
374
|
+
const addSpy = vi
|
|
375
|
+
.spyOn(document, 'addEventListener')
|
|
376
|
+
.mockImplementation((type, listener, opts) => {
|
|
377
|
+
added.push(String(type));
|
|
378
|
+
realAdd(type as never, listener as never, opts as never);
|
|
379
|
+
});
|
|
380
|
+
const removeSpy = vi
|
|
381
|
+
.spyOn(document, 'removeEventListener')
|
|
382
|
+
.mockImplementation((type, listener, opts) => {
|
|
383
|
+
removed.push(String(type));
|
|
384
|
+
realRemove(type as never, listener as never, opts as never);
|
|
385
|
+
});
|
|
386
|
+
try {
|
|
387
|
+
const { panel, unmount } = renderPanel(<FloatingPanel>x</FloatingPanel>);
|
|
388
|
+
fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
|
|
389
|
+
expect(added).toContain('pointermove');
|
|
390
|
+
unmount();
|
|
391
|
+
expect(removed).toContain('pointermove');
|
|
392
|
+
} finally {
|
|
393
|
+
addSpy.mockRestore();
|
|
394
|
+
removeSpy.mockRestore();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|