@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.
Files changed (77) hide show
  1. package/dist/_dts/{CanvasStackContext-D6M0o8cQ.d.ts → CanvasStackContext-BIRUVVGH.d.ts} +1 -1
  2. package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
  3. package/dist/_dts/{frac-QS7XzvpG.d.ts → frac-X7mWgd7y.d.ts} +9 -3
  4. package/dist/_dts/{index-B2X21G1c.d.ts → index-FEa1MudK.d.ts} +1 -1
  5. package/dist/_dts/{types-C1Zw7s5P.d.ts → types-lg4TSCb2.d.ts} +2 -1
  6. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
  7. package/dist/canvas/index.d.ts +15 -15
  8. package/dist/canvas/index.js +2 -2
  9. package/dist/chrome/index.d.ts +5 -5
  10. package/dist/chrome/index.js +5 -5
  11. package/dist/{chunk-W3KSUXXR.js → chunk-53AZ2O2N.js} +4 -4
  12. package/dist/{chunk-W3KSUXXR.js.map → chunk-53AZ2O2N.js.map} +1 -1
  13. package/dist/{chunk-4AMN4MMB.js → chunk-7TRFMIWX.js} +53 -39
  14. package/dist/chunk-7TRFMIWX.js.map +1 -0
  15. package/dist/{chunk-UD7TEHZT.js → chunk-BMQWLKOL.js} +6 -6
  16. package/dist/{chunk-UD7TEHZT.js.map → chunk-BMQWLKOL.js.map} +1 -1
  17. package/dist/{chunk-YQGQUBKA.js → chunk-D7QIPGEB.js} +3 -3
  18. package/dist/{chunk-YQGQUBKA.js.map → chunk-D7QIPGEB.js.map} +1 -1
  19. package/dist/{chunk-7VW3S44V.js → chunk-DG2IFMLA.js} +802 -222
  20. package/dist/chunk-DG2IFMLA.js.map +1 -0
  21. package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
  22. package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
  23. package/dist/{chunk-L5WBGUVR.js → chunk-J6SBFZYT.js} +2131 -2174
  24. package/dist/chunk-J6SBFZYT.js.map +1 -0
  25. package/dist/{chunk-XCBCZEBO.js → chunk-MIT3ISW4.js} +37 -31
  26. package/dist/chunk-MIT3ISW4.js.map +1 -0
  27. package/dist/{chunk-QR5V3AFG.js → chunk-MSR7EVS6.js} +5 -5
  28. package/dist/{chunk-QR5V3AFG.js.map → chunk-MSR7EVS6.js.map} +1 -1
  29. package/dist/{chunk-B5VWJBWM.js → chunk-OFCBEZMU.js} +3 -3
  30. package/dist/{chunk-B5VWJBWM.js.map → chunk-OFCBEZMU.js.map} +1 -1
  31. package/dist/{chunk-VHFQKAUL.js → chunk-PP7ZWAMV.js} +83 -75
  32. package/dist/chunk-PP7ZWAMV.js.map +1 -0
  33. package/dist/{chunk-Y2HK44TG.js → chunk-RLOXQZW3.js} +3 -3
  34. package/dist/{chunk-Y2HK44TG.js.map → chunk-RLOXQZW3.js.map} +1 -1
  35. package/dist/controls/index.d.ts +4 -3
  36. package/dist/controls/index.js +3 -3
  37. package/dist/dragdrop/index.d.ts +4 -4
  38. package/dist/dragdrop/index.js +2 -2
  39. package/dist/index.d.ts +8 -71
  40. package/dist/index.js +21 -21
  41. package/dist/index.js.map +1 -1
  42. package/dist/job/index.js +1 -1
  43. package/dist/layers/index.d.ts +5 -5
  44. package/dist/layers/index.js +3 -3
  45. package/dist/loupe/index.d.ts +6 -12
  46. package/dist/loupe/index.js +2 -2
  47. package/dist/passthrough/weasel-canvas.d.ts +3 -343
  48. package/dist/passthrough/weasel-canvas.js +1 -1
  49. package/dist/passthrough/weasel-ui.d.ts +40 -3086
  50. package/dist/passthrough/weasel-ui.js +2 -2
  51. package/dist/primitives/index.js +4 -4
  52. package/dist/styles.css +10 -0
  53. package/dist/surface/index.js +2 -2
  54. package/dist/ui/layers/index.js +2 -2
  55. package/dist/undo/index.d.ts +5 -4
  56. package/package.json +8 -8
  57. package/src/canvas/CanvasStack.tsx +8 -13
  58. package/src/canvas/useOrbit.test.ts +93 -1
  59. package/src/canvas/useOrbit.ts +41 -34
  60. package/src/canvas/usePanZoom.test.ts +102 -1
  61. package/src/canvas/usePanZoom.ts +54 -36
  62. package/src/job/useJob.ts +1 -1
  63. package/src/lab/Workspace.tsx +1 -1
  64. package/src/layers/LayerList.test.tsx +72 -1
  65. package/src/layers/LayerList.tsx +40 -32
  66. package/src/primitives/FloatingPanel.test.tsx +82 -0
  67. package/src/primitives/FloatingPanel.tsx +59 -41
  68. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
  69. package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
  70. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
  71. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
  72. package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
  73. package/dist/chunk-4AMN4MMB.js.map +0 -1
  74. package/dist/chunk-7VW3S44V.js.map +0 -1
  75. package/dist/chunk-L5WBGUVR.js.map +0 -1
  76. package/dist/chunk-VHFQKAUL.js.map +0 -1
  77. 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
+ });
@@ -1,4 +1,11 @@
1
- import { type PointerEvent, useCallback, useRef, type WheelEvent } from 'react';
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: PointerEvent<HTMLElement>) => void;
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 onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
78
- if (e.button !== 0) return;
79
- e.currentTarget.setPointerCapture(e.pointerId);
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 onPointerMove = useCallback(
90
- (e: PointerEvent<HTMLElement>) => {
91
- const drag = dragRef.current;
92
- if (!drag || drag.pointerId !== e.pointerId) return;
93
- const dx = e.clientX - drag.startScreenX;
94
- const dy = e.clientY - drag.startScreenY;
95
- if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
96
- drag.moved = true;
97
- onViewChange({
98
- zoom: viewRef.current.zoom,
99
- pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },
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
- [onViewChange],
127
+ [clearDrag],
103
128
  );
104
129
 
105
- const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {
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, onPointerMove, onPointerUp, isDragging };
134
+ return { onWheel, onPointerDown, isDragging };
117
135
  }
package/src/job/useJob.ts CHANGED
@@ -111,7 +111,7 @@ export function useJob<TS, TC, TItem>({
111
111
  lastKey.current = key;
112
112
  started.current = true;
113
113
  start();
114
- }, [auto, key, start]); // eslint-disable-line react-hooks/exhaustive-deps
114
+ }, [auto, key, start]);
115
115
 
116
116
  useEffect(
117
117
  () => () => {
@@ -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]); // eslint-disable-line react-hooks/exhaustive-deps
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
+ });
@@ -1,4 +1,5 @@
1
- import { type PointerEvent, useRef, useState } from 'react';
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 startDrag = (e: PointerEvent<HTMLElement>, index: number) => {
39
- e.currentTarget.setPointerCapture(e.pointerId);
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
- dragRef.current = {
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
- const moveDrag = (e: PointerEvent<HTMLElement>) => {
56
- const drag = dragRef.current;
57
- if (!drag || drag.pointerId !== e.pointerId) return;
58
- const delta = Math.round((e.clientY - drag.startY) / drag.pitch);
59
- const target = Math.min(reorderable.length - 1, Math.max(0, drag.fromIndex + delta));
60
- setDragIndex(target);
61
- };
62
-
63
- const endDrag = (e: PointerEvent<HTMLElement>) => {
64
- const drag = dragRef.current;
65
- if (!drag || drag.pointerId !== e.pointerId) return;
66
- if (e.currentTarget.hasPointerCapture(e.pointerId)) {
67
- e.currentTarget.releasePointerCapture(e.pointerId);
68
- }
69
- if (dragIndex !== null && dragIndex !== drag.fromIndex) {
70
- const next = [...reorderable];
71
- const [moved] = next.splice(drag.fromIndex, 1);
72
- if (moved) next.splice(dragIndex, 0, moved);
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
+ });