@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.
Files changed (133) hide show
  1. package/README.md +8 -0
  2. package/dist/_dts/{CanvasStackContext-D6M0o8cQ.d.ts → CanvasStackContext-kjILVnPj.d.ts} +1 -1
  3. package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
  4. package/dist/_dts/{frac-QS7XzvpG.d.ts → frac-A4R6v4ld.d.ts} +36 -13
  5. package/dist/_dts/{index-B2X21G1c.d.ts → index-CsU9WhjM.d.ts} +90 -30
  6. package/dist/_dts/{types-ChVJJHvk.d.ts → types-BbXpvQa8.d.ts} +20 -1
  7. package/dist/_dts/{types-C1Zw7s5P.d.ts → types-lg4TSCb2.d.ts} +2 -1
  8. package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-DKjqpv20.d.ts} +5 -1
  9. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
  10. package/dist/canvas/index.d.ts +16 -16
  11. package/dist/canvas/index.js +2 -2
  12. package/dist/chrome/index.d.ts +15 -9
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-7VW3S44V.js → chunk-54ZWZ5FQ.js} +908 -301
  15. package/dist/chunk-54ZWZ5FQ.js.map +1 -0
  16. package/dist/{chunk-Y2HK44TG.js → chunk-D5KQ5OY6.js} +3 -3
  17. package/dist/{chunk-Y2HK44TG.js.map → chunk-D5KQ5OY6.js.map} +1 -1
  18. package/dist/{chunk-VHFQKAUL.js → chunk-FJG4PHTL.js} +83 -75
  19. package/dist/chunk-FJG4PHTL.js.map +1 -0
  20. package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
  21. package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
  22. package/dist/{chunk-L5WBGUVR.js → chunk-ISSVF5PT.js} +2753 -2654
  23. package/dist/chunk-ISSVF5PT.js.map +1 -0
  24. package/dist/chunk-NSOVI3AZ.js +170 -0
  25. package/dist/chunk-NSOVI3AZ.js.map +1 -0
  26. package/dist/{chunk-YQGQUBKA.js → chunk-TN7YSJVU.js} +62 -60
  27. package/dist/chunk-TN7YSJVU.js.map +1 -0
  28. package/dist/{chunk-4AMN4MMB.js → chunk-UDXOYZEC.js} +53 -39
  29. package/dist/chunk-UDXOYZEC.js.map +1 -0
  30. package/dist/{chunk-LJSIUFYD.js → chunk-ULDW42CR.js} +23 -2
  31. package/dist/chunk-ULDW42CR.js.map +1 -0
  32. package/dist/{chunk-QR5V3AFG.js → chunk-UTOEDPNU.js} +5 -5
  33. package/dist/{chunk-QR5V3AFG.js.map → chunk-UTOEDPNU.js.map} +1 -1
  34. package/dist/{chunk-UD7TEHZT.js → chunk-W2FJR5FF.js} +35 -22
  35. package/dist/chunk-W2FJR5FF.js.map +1 -0
  36. package/dist/{chunk-B5VWJBWM.js → chunk-WS6ZRV75.js} +3 -3
  37. package/dist/{chunk-B5VWJBWM.js.map → chunk-WS6ZRV75.js.map} +1 -1
  38. package/dist/{chunk-W3KSUXXR.js → chunk-XKENZTNE.js} +45 -14
  39. package/dist/chunk-XKENZTNE.js.map +1 -0
  40. package/dist/controls/index.d.ts +4 -3
  41. package/dist/controls/index.js +3 -3
  42. package/dist/dragdrop/index.d.ts +8 -8
  43. package/dist/dragdrop/index.js +2 -2
  44. package/dist/index.d.ts +75 -110
  45. package/dist/index.js +148 -115
  46. package/dist/index.js.map +1 -1
  47. package/dist/job/index.js +1 -1
  48. package/dist/layers/index.d.ts +26 -11
  49. package/dist/layers/index.js +3 -3
  50. package/dist/loupe/index.d.ts +7 -13
  51. package/dist/loupe/index.js +2 -2
  52. package/dist/passthrough/weasel-canvas.d.ts +3 -343
  53. package/dist/passthrough/weasel-canvas.js +1 -1
  54. package/dist/passthrough/weasel-ui.d.ts +123 -3088
  55. package/dist/passthrough/weasel-ui.js +2 -2
  56. package/dist/primitives/index.js +4 -4
  57. package/dist/state/index.d.ts +3 -3
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +30 -3
  60. package/dist/surface/index.js +2 -2
  61. package/dist/ui/layers/index.d.ts +22 -9
  62. package/dist/ui/layers/index.js +2 -2
  63. package/dist/undo/index.d.ts +6 -5
  64. package/package.json +8 -8
  65. package/src/annotations/AnnotationTargets.tsx +6 -1
  66. package/src/annotations/Annotations.overlay.test.tsx +2 -0
  67. package/src/annotations/types.ts +4 -1
  68. package/src/canvas/CanvasStack.tsx +8 -13
  69. package/src/canvas/useOrbit.test.ts +97 -1
  70. package/src/canvas/useOrbit.ts +41 -34
  71. package/src/canvas/usePanZoom.test.ts +106 -1
  72. package/src/canvas/usePanZoom.ts +54 -36
  73. package/src/chrome/ChromeRegions.stories.tsx +42 -1
  74. package/src/chrome/builtins.test.ts +4 -0
  75. package/src/chrome/builtins.tsx +18 -0
  76. package/src/chrome/index.ts +1 -1
  77. package/src/chrome/regions/SidebarRegion.tsx +7 -6
  78. package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
  79. package/src/chrome/regions/TitleBarRegion.tsx +16 -7
  80. package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
  81. package/src/chrome/regions/ToolbarRegion.tsx +2 -2
  82. package/src/chrome/regions/ViewportRegion.tsx +2 -2
  83. package/src/chrome/regions/regions.test.tsx +45 -1
  84. package/src/chrome/types.ts +21 -3
  85. package/src/controls/ControlPanel.test.tsx +38 -0
  86. package/src/controls/ControlPanel.tsx +51 -6
  87. package/src/dragdrop/DragDropRuntime.tsx +74 -61
  88. package/src/dragdrop/Palette.tsx +4 -3
  89. package/src/dragdrop/dragDrop.test.tsx +35 -12
  90. package/src/index.ts +2 -1
  91. package/src/instrument/types.ts +4 -5
  92. package/src/job/useJob.ts +1 -1
  93. package/src/lab/Lab.test.tsx +7 -0
  94. package/src/lab/Lab.tsx +2 -2
  95. package/src/lab/LabContext.ts +5 -1
  96. package/src/lab/LabShell.less +17 -2
  97. package/src/lab/Workspace.tsx +1 -1
  98. package/src/layers/AGENTS.md +29 -7
  99. package/src/layers/LayerList.less +16 -0
  100. package/src/layers/LayerList.stories.tsx +66 -0
  101. package/src/layers/LayerList.test.tsx +185 -2
  102. package/src/layers/LayerList.tsx +221 -74
  103. package/src/layers/index.ts +1 -1
  104. package/src/passthrough/weasel-ui.ts +5 -0
  105. package/src/primitives/FloatingPanel.test.tsx +87 -0
  106. package/src/primitives/FloatingPanel.tsx +59 -41
  107. package/src/state/store.test.ts +78 -0
  108. package/src/state/store.ts +27 -0
  109. package/src/state/types.ts +20 -0
  110. package/src/trial/Trial.less +4 -0
  111. package/src/trial/Trial.targets.test.tsx +96 -0
  112. package/src/trial/Trial.test.tsx +171 -14
  113. package/src/trial/Trial.tsx +4 -1
  114. package/src/trial/TrialChrome.tsx +22 -2
  115. package/src/trial/TrialTitleBar.tsx +7 -3
  116. package/src/trial/index.ts +1 -0
  117. package/src/trial/trialOps.test.ts +49 -1
  118. package/src/trial/trialOps.ts +28 -6
  119. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
  120. package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
  121. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
  122. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
  123. package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
  124. package/dist/chunk-4AMN4MMB.js.map +0 -1
  125. package/dist/chunk-7VW3S44V.js.map +0 -1
  126. package/dist/chunk-L5WBGUVR.js.map +0 -1
  127. package/dist/chunk-LJSIUFYD.js.map +0 -1
  128. package/dist/chunk-UD7TEHZT.js.map +0 -1
  129. package/dist/chunk-VHFQKAUL.js.map +0 -1
  130. package/dist/chunk-W3KSUXXR.js.map +0 -1
  131. package/dist/chunk-XCBCZEBO.js +0 -94
  132. package/dist/chunk-XCBCZEBO.js.map +0 -1
  133. package/dist/chunk-YQGQUBKA.js.map +0 -1
@@ -1,4 +1,4 @@
1
- export { c3 as LayerStack } from '../../chunk-L5WBGUVR.js';
2
- import '../../chunk-7VW3S44V.js';
1
+ export { c3 as LayerStack } from '../../chunk-ISSVF5PT.js';
2
+ import '../../chunk-54ZWZ5FQ.js';
3
3
  //# sourceMappingURL=index.js.map
4
4
  //# sourceMappingURL=index.js.map
@@ -1,10 +1,11 @@
1
- export { a0 as UndoCapability } from '../_dts/frac-QS7XzvpG.js';
1
+ export { a0 as UndoCapability } from '../_dts/frac-A4R6v4ld.js';
2
2
  import 'react';
3
- import '../_dts/types-C1Zw7s5P.js';
4
- import '../_dts/PrefsForm-BkUJZx0A.js';
5
- import '../_dts/types-ChVJJHvk.js';
3
+ import '../_dts/types-lg4TSCb2.js';
4
+ import '../_dts/weasel-canvas-FJTFi3ZZ.js';
5
+ import 'react/jsx-runtime';
6
+ import '../_dts/PrefsForm.d-CgxUequc.js';
7
+ import '../_dts/types-BbXpvQa8.js';
6
8
  import '../_dts/types-DJ79Tg5J.js';
7
- import '../_dts/types-C-gh9Ap-.js';
8
9
 
9
10
  /** A subscriber to a named event. */
10
11
  type EventListener = () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -93,7 +93,7 @@
93
93
  "dev:drag": "LABKIT_EXAMPLE=drag-lab vite",
94
94
  "dev:weasel": "LABKIT_EXAMPLE=weasel-lab vite",
95
95
  "build": "tsup && npm run build:dts && npm run build:css && npm run build:fonts",
96
- "build:dts": "NODE_OPTIONS=--max-old-space-size=8192 tsx scripts/build-dts.mts",
96
+ "build:dts": "NODE_OPTIONS=--max-old-space-size=3072 tsx scripts/build-dts.mts",
97
97
  "prepublishOnly": "npm run build",
98
98
  "build:css": "node scripts/build-css.mjs",
99
99
  "build:fonts": "mkdir -p dist/fonts && cp ../theme/fonts/*.woff2 dist/fonts/",
@@ -114,11 +114,11 @@
114
114
  "react-dom": "^19.0.0"
115
115
  },
116
116
  "dependencies": {
117
- "@weasel-js/core": "1.4.0",
118
- "@weasel-js/loupe": "1.4.0",
119
- "@weasel-js/svg": "1.4.0",
120
- "@weasel-js/theme": "1.4.0",
121
- "@weasel-js/ui": "1.4.0",
117
+ "@weasel-js/core": "1.4.2",
118
+ "@weasel-js/loupe": "1.4.2",
119
+ "@weasel-js/svg": "1.4.2",
120
+ "@weasel-js/theme": "1.4.2",
121
+ "@weasel-js/ui": "1.4.2",
122
122
  "earcut": "2.2.4",
123
123
  "polygon-clipping": "^0.15.7",
124
124
  "react-aria-components": "^1.5.0",
@@ -129,7 +129,7 @@
129
129
  "@biomejs/biome": "^2.4.12",
130
130
  "@rollup/plugin-alias": "^6.0.0",
131
131
  "@testing-library/jest-dom": "^6.6.0",
132
- "@weasel-js/modes": "1.4.0",
132
+ "@weasel-js/modes": "1.4.2",
133
133
  "less": "^4.2.0",
134
134
  "rollup-plugin-dts": "^6.4.1",
135
135
  "tsx": "^4.19.0",
@@ -1,3 +1,4 @@
1
+ import type { TrialInfo } from '../state/types';
1
2
  import { AnnotationOverlay } from './AnnotationOverlay';
2
3
  import type { AnnotationsApi, AnnotationsCapability } from './types';
3
4
 
@@ -6,6 +7,9 @@ export interface AnnotationTargetsProps {
6
7
  capability: AnnotationsCapability;
7
8
  state: unknown;
8
9
  config: unknown;
10
+ /** The trial these targets belong to. `capability.targets` is told it, so a
11
+ * consumer holding per-trial refs can hand back this trial's. */
12
+ trial: TrialInfo;
9
13
  annotations: AnnotationsApi;
10
14
  activeToolId: string | null;
11
15
  }
@@ -15,10 +19,11 @@ export function AnnotationTargets({
15
19
  capability,
16
20
  state,
17
21
  config,
22
+ trial,
18
23
  annotations,
19
24
  activeToolId,
20
25
  }: AnnotationTargetsProps) {
21
- const targets = capability.targets(state, config);
26
+ const targets = capability.targets(state, config, trial);
22
27
 
23
28
  return (
24
29
  <>
@@ -82,6 +82,7 @@ function Harness({ toolId = 'rect' }: { toolId?: string }) {
82
82
  capability={capability}
83
83
  state={{}}
84
84
  config={{}}
85
+ trial={{ id: 'harness', view: null }}
85
86
  annotations={annotations}
86
87
  activeToolId={toolId}
87
88
  />
@@ -170,6 +171,7 @@ function OneTrial({
170
171
  capability={capability}
171
172
  state={{}}
172
173
  config={{}}
174
+ trial={{ id: trialId, view: null }}
173
175
  annotations={annotations}
174
176
  activeToolId="rect"
175
177
  />
@@ -1,5 +1,6 @@
1
1
  import type { RefObject } from 'react';
2
2
  import type { ViewTransform } from '../instrument/types';
3
+ import type { TrialInfo } from '../state/types';
3
4
  import type { MarkScene } from './store';
4
5
 
5
6
  /** A point in fractions of a target's content box. */
@@ -158,7 +159,9 @@ export interface AnnotationStorage {
158
159
  /** Declares that an instrument accepts marks: which regions take them,
159
160
  * optionally what a mark is allowed to mean, and optionally where they live. */
160
161
  export interface AnnotationsCapability<TS = unknown, TC = unknown> {
161
- targets: (state: TS, config: TC) => readonly AnnotationTarget[];
162
+ /** `trial` is which trial is asking: a declaration made once per instrument
163
+ * is called once per trial, and its targets are that trial's own. */
164
+ targets: (state: TS, config: TC, trial: TrialInfo) => readonly AnnotationTarget[];
162
165
  meaning?: AnnotationMeaning;
163
166
  storage?: AnnotationStorage;
164
167
  /** Fires after every finished export, labkit's own chrome included. A
@@ -81,7 +81,14 @@ export function CanvasStack({
81
81
 
82
82
  const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
83
83
 
84
- const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame });
84
+ const onTap = (e: PointerEvent) => {
85
+ const el = containerRef.current;
86
+ if (!onHitTest || !el) return;
87
+ const rect = el.getBoundingClientRect();
88
+ onHitTest(screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, view, frame));
89
+ };
90
+
91
+ const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame, onTap });
85
92
  useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });
86
93
 
87
94
  const ctxValue = useMemo(
@@ -103,16 +110,6 @@ export function CanvasStack({
103
110
  height: size.height,
104
111
  };
105
112
 
106
- const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
107
- const wasDragging = handlers.isDragging();
108
- handlers.onPointerUp(e);
109
- if (!wasDragging && onHitTest && containerRef.current) {
110
- const rect = containerRef.current.getBoundingClientRect();
111
- const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };
112
- onHitTest(screenToWorld(screen, view, frame));
113
- }
114
- };
115
-
116
113
  return (
117
114
  <CanvasStackContext.Provider value={ctxValue}>
118
115
  <div
@@ -121,8 +118,6 @@ export function CanvasStack({
121
118
  style={containerStyle}
122
119
  onWheel={handlers.onWheel}
123
120
  onPointerDown={handlers.onPointerDown}
124
- onPointerMove={handlers.onPointerMove}
125
- onPointerUp={handlePointerUp}
126
121
  >
127
122
  {layers.map((layer) => (
128
123
  <canvas
@@ -1,5 +1,15 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import type { PointerEvent as ReactPointerEvent } from 'react';
1
3
  import { describe, expect, it } from 'vitest';
2
- import { clampPitch, orbitAfterDrag, orbitAfterWheel, PITCH_LIMIT, wrapYaw } from './useOrbit';
4
+ import {
5
+ clampPitch,
6
+ type OrbitView,
7
+ orbitAfterDrag,
8
+ orbitAfterWheel,
9
+ PITCH_LIMIT,
10
+ useOrbit,
11
+ wrapYaw,
12
+ } from './useOrbit';
3
13
 
4
14
  const view = { yaw: 0, pitch: 0, distance: 5, target: { x: 0, y: 0, z: 0 } };
5
15
 
@@ -69,3 +79,89 @@ describe('orbitAfterWheel', () => {
69
79
  expect(orbitAfterWheel(view, -100_000, 0.5, 50).distance).toBe(0.5);
70
80
  });
71
81
  });
82
+
83
+ /** The fields `useOrbit` reads off a React pointerdown, plus the element the
84
+ * session hangs its document listeners off. */
85
+ function press(
86
+ el: HTMLElement,
87
+ x: number,
88
+ y: number,
89
+ init: { pointerId?: number; button?: number; buttons?: number } = {},
90
+ ): ReactPointerEvent<HTMLElement> {
91
+ return {
92
+ pointerId: init.pointerId ?? 1,
93
+ button: init.button ?? 0,
94
+ buttons: init.buttons ?? 1,
95
+ clientX: x,
96
+ clientY: y,
97
+ currentTarget: el,
98
+ } as unknown as ReactPointerEvent<HTMLElement>;
99
+ }
100
+
101
+ function dispatchOn(target: EventTarget, type: string, init: PointerEventInit) {
102
+ act(() => {
103
+ target.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
104
+ });
105
+ }
106
+
107
+ function dragSetup() {
108
+ let current: OrbitView = view;
109
+ const onViewChange = (v: OrbitView) => {
110
+ current = v;
111
+ };
112
+ const { result, unmount } = renderHook(() => useOrbit({ view, onViewChange }));
113
+ const el = document.createElement('div');
114
+ document.body.appendChild(el);
115
+ return { result, unmount, el, getView: () => current };
116
+ }
117
+
118
+ describe('useOrbit dragging', () => {
119
+ it('turns from a move the element never sees', () => {
120
+ const { result, el, getView } = dragSetup();
121
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
122
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 200, clientY: 150 });
123
+ expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
124
+ });
125
+
126
+ it('ends the drag on a release dispatched off the element', () => {
127
+ const { result, el, getView } = dragSetup();
128
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
129
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 200, clientY: 150 });
130
+ dispatchOn(document, 'pointerup', { clientX: 200, clientY: 150 });
131
+ expect(result.current.isDragging()).toBe(false);
132
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
133
+ expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
134
+ });
135
+
136
+ // The other half of the rule — a detached origin does cancel — is owned by
137
+ // pointerSession.test.ts, which exercises it without React in the way.
138
+ it('keeps turning when capture is lost and the element is still in the document', () => {
139
+ const { result, el, getView } = dragSetup();
140
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
141
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 200, clientY: 150 });
142
+ // Chrome releases capture a beat before it delivers pointerup, so ending
143
+ // here throws away a release that has already been dispatched.
144
+ dispatchOn(el, 'lostpointercapture', {});
145
+ expect(result.current.isDragging()).toBe(true);
146
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
147
+ expect(getView()).toEqual(orbitAfterDrag(view, 300, 300));
148
+ });
149
+
150
+ it('reads a move with nothing held as the release that never arrived', () => {
151
+ const { result, el, getView } = dragSetup();
152
+ act(() => result.current.onPointerDown(press(el, 100, 100, { buttons: 1 })));
153
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 200, clientY: 150 });
154
+ expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
155
+ dispatchOn(document, 'pointermove', { buttons: 0, clientX: 400, clientY: 400 });
156
+ expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
157
+ expect(result.current.isDragging()).toBe(false);
158
+ });
159
+
160
+ it('drops its listeners when the hook unmounts mid-drag', () => {
161
+ const { result, el, unmount, getView } = dragSetup();
162
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
163
+ unmount();
164
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
165
+ expect(getView()).toEqual(view);
166
+ });
167
+ });
@@ -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
 
3
10
  /** A point in the space the instrument works in. */
4
11
  export interface Vec3 {
@@ -70,15 +77,12 @@ export interface UseOrbitOptions {
70
77
 
71
78
  export interface OrbitHandlers {
72
79
  onWheel: (e: WheelEvent<HTMLElement>) => void;
73
- onPointerDown: (e: PointerEvent<HTMLElement>) => void;
74
- onPointerMove: (e: PointerEvent<HTMLElement>) => void;
75
- onPointerUp: (e: PointerEvent<HTMLElement>) => void;
80
+ onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
76
81
  onDoubleClick: () => void;
77
82
  isDragging: () => boolean;
78
83
  }
79
84
 
80
85
  interface DragState {
81
- pointerId: number;
82
86
  startX: number;
83
87
  startY: number;
84
88
  startView: OrbitView;
@@ -95,8 +99,11 @@ export function useOrbit({
95
99
  maxDistance = 1000,
96
100
  }: UseOrbitOptions): OrbitHandlers {
97
101
  const dragRef = useRef<DragState | null>(null);
102
+ const sessionRef = useRef<PointerSession | null>(null);
98
103
  const viewRef = useRef(view);
99
104
  viewRef.current = view;
105
+ const onViewChangeRef = useRef(onViewChange);
106
+ onViewChangeRef.current = onViewChange;
100
107
  const homeRef = useRef(home ?? view);
101
108
  if (home) homeRef.current = home;
102
109
 
@@ -108,42 +115,42 @@ export function useOrbit({
108
115
  [onViewChange, minDistance, maxDistance],
109
116
  );
110
117
 
111
- const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
112
- if (e.button !== 0) return;
113
- e.currentTarget.setPointerCapture(e.pointerId);
114
- dragRef.current = {
115
- pointerId: e.pointerId,
116
- startX: e.clientX,
117
- startY: e.clientY,
118
- startView: viewRef.current,
119
- moved: false,
120
- };
118
+ const clearDrag = useCallback(() => {
119
+ dragRef.current = null;
120
+ sessionRef.current = null;
121
121
  }, []);
122
122
 
123
- const onPointerMove = useCallback(
124
- (e: PointerEvent<HTMLElement>) => {
125
- const drag = dragRef.current;
126
- if (!drag || drag.pointerId !== e.pointerId) return;
127
- const dx = e.clientX - drag.startX;
128
- const dy = e.clientY - drag.startY;
129
- if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
130
- drag.moved = true;
131
- onViewChange(orbitAfterDrag(drag.startView, dx, dy));
123
+ const onPointerDown = useCallback(
124
+ (e: ReactPointerEvent<HTMLElement>) => {
125
+ if (e.button !== 0) return;
126
+ const drag: DragState = {
127
+ startX: e.clientX,
128
+ startY: e.clientY,
129
+ startView: viewRef.current,
130
+ moved: false,
131
+ };
132
+ dragRef.current = drag;
133
+ sessionRef.current = openPointerSession(e.currentTarget, e, {
134
+ onMove: (ev) => {
135
+ const dx = ev.clientX - drag.startX;
136
+ const dy = ev.clientY - drag.startY;
137
+ if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
138
+ drag.moved = true;
139
+ onViewChangeRef.current(orbitAfterDrag(drag.startView, dx, dy));
140
+ },
141
+ onEnd: clearDrag,
142
+ // A cancelled turn keeps where it got to: every move was committed as
143
+ // it arrived, so there is nothing left to undo.
144
+ onCancel: clearDrag,
145
+ });
132
146
  },
133
- [onViewChange],
147
+ [clearDrag],
134
148
  );
135
149
 
136
- const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {
137
- const drag = dragRef.current;
138
- if (!drag || drag.pointerId !== e.pointerId) return;
139
- if (e.currentTarget.hasPointerCapture(e.pointerId)) {
140
- e.currentTarget.releasePointerCapture(e.pointerId);
141
- }
142
- dragRef.current = null;
143
- }, []);
150
+ useEffect(() => () => sessionRef.current?.cancel(), []);
144
151
 
145
152
  const onDoubleClick = useCallback(() => onViewChange(homeRef.current), [onViewChange]);
146
153
  const isDragging = useCallback(() => dragRef.current?.moved === true, []);
147
154
 
148
- return { onWheel, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, isDragging };
155
+ return { onWheel, onPointerDown, onDoubleClick, isDragging };
149
156
  }
@@ -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,108 @@ 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
+ // The other half of the rule — a detached origin does cancel — is owned by
169
+ // pointerSession.test.ts, which exercises it without React in the way.
170
+ it('keeps panning when capture is lost and the element is still in the document', () => {
171
+ const { result, el, getView } = dragSetup();
172
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
173
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
174
+ // Chrome releases capture a beat before it delivers pointerup, so ending
175
+ // here throws away a release that has already been dispatched.
176
+ dispatchOn(el, 'lostpointercapture', {});
177
+ expect(result.current.isDragging()).toBe(true);
178
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
179
+ expect(getView().pan).toEqual({ x: 300, y: 300 });
180
+ });
181
+
182
+ it('reads a move with nothing held as the release that never arrived', () => {
183
+ const { result, el, getView } = dragSetup();
184
+ act(() => result.current.onPointerDown(press(el, 100, 100, { buttons: 1 })));
185
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
186
+ expect(getView().pan).toEqual({ x: 50, y: 20 });
187
+ dispatchOn(document, 'pointermove', { buttons: 0, clientX: 300, clientY: 300 });
188
+ expect(getView().pan).toEqual({ x: 50, y: 20 });
189
+ expect(result.current.isDragging()).toBe(false);
190
+ });
191
+
192
+ it('reports a release that never crossed the threshold as a tap', () => {
193
+ const taps: number[] = [];
194
+ const { result, el } = dragSetup({ onTap: (e) => taps.push(e.clientX) });
195
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
196
+ dispatchOn(document, 'pointerup', { clientX: 101, clientY: 100 });
197
+ expect(taps).toEqual([101]);
198
+ });
199
+
200
+ it('does not report a tap when the pointer panned', () => {
201
+ const taps: number[] = [];
202
+ const { result, el } = dragSetup({ onTap: (e) => taps.push(e.clientX) });
203
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
204
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
205
+ dispatchOn(document, 'pointerup', { clientX: 150, clientY: 120 });
206
+ expect(taps).toEqual([]);
207
+ });
208
+
209
+ it('drops its listeners when the hook unmounts mid-drag', () => {
210
+ const { result, el, unmount, getView } = dragSetup();
211
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
212
+ unmount();
213
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
214
+ expect(getView().pan).toEqual({ x: 0, y: 0 });
215
+ });
216
+ });
@@ -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
  }