@weasel-js/labkit 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/_dts/{CanvasStackContext-D6M0o8cQ.d.ts → CanvasStackContext-kjILVnPj.d.ts} +1 -1
- package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
- package/dist/_dts/{frac-QS7XzvpG.d.ts → frac-A4R6v4ld.d.ts} +36 -13
- package/dist/_dts/{index-B2X21G1c.d.ts → index-CsU9WhjM.d.ts} +90 -30
- package/dist/_dts/{types-ChVJJHvk.d.ts → types-BbXpvQa8.d.ts} +20 -1
- package/dist/_dts/{types-C1Zw7s5P.d.ts → types-lg4TSCb2.d.ts} +2 -1
- package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-DKjqpv20.d.ts} +5 -1
- package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
- package/dist/canvas/index.d.ts +16 -16
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +15 -9
- package/dist/chrome/index.js +5 -5
- package/dist/{chunk-7VW3S44V.js → chunk-54ZWZ5FQ.js} +908 -301
- package/dist/chunk-54ZWZ5FQ.js.map +1 -0
- package/dist/{chunk-Y2HK44TG.js → chunk-D5KQ5OY6.js} +3 -3
- package/dist/{chunk-Y2HK44TG.js.map → chunk-D5KQ5OY6.js.map} +1 -1
- package/dist/{chunk-VHFQKAUL.js → chunk-FJG4PHTL.js} +83 -75
- package/dist/chunk-FJG4PHTL.js.map +1 -0
- package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
- package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
- package/dist/{chunk-L5WBGUVR.js → chunk-ISSVF5PT.js} +2753 -2654
- package/dist/chunk-ISSVF5PT.js.map +1 -0
- package/dist/chunk-NSOVI3AZ.js +170 -0
- package/dist/chunk-NSOVI3AZ.js.map +1 -0
- package/dist/{chunk-YQGQUBKA.js → chunk-TN7YSJVU.js} +62 -60
- package/dist/chunk-TN7YSJVU.js.map +1 -0
- package/dist/{chunk-4AMN4MMB.js → chunk-UDXOYZEC.js} +53 -39
- package/dist/chunk-UDXOYZEC.js.map +1 -0
- package/dist/{chunk-LJSIUFYD.js → chunk-ULDW42CR.js} +23 -2
- package/dist/chunk-ULDW42CR.js.map +1 -0
- package/dist/{chunk-QR5V3AFG.js → chunk-UTOEDPNU.js} +5 -5
- package/dist/{chunk-QR5V3AFG.js.map → chunk-UTOEDPNU.js.map} +1 -1
- package/dist/{chunk-UD7TEHZT.js → chunk-W2FJR5FF.js} +35 -22
- package/dist/chunk-W2FJR5FF.js.map +1 -0
- package/dist/{chunk-B5VWJBWM.js → chunk-WS6ZRV75.js} +3 -3
- package/dist/{chunk-B5VWJBWM.js.map → chunk-WS6ZRV75.js.map} +1 -1
- package/dist/{chunk-W3KSUXXR.js → chunk-XKENZTNE.js} +45 -14
- package/dist/chunk-XKENZTNE.js.map +1 -0
- package/dist/controls/index.d.ts +4 -3
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +8 -8
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +75 -110
- package/dist/index.js +148 -115
- package/dist/index.js.map +1 -1
- package/dist/job/index.js +1 -1
- package/dist/layers/index.d.ts +26 -11
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +7 -13
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-canvas.d.ts +3 -343
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +123 -3088
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.js +4 -4
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/styles.css +30 -3
- package/dist/surface/index.js +2 -2
- package/dist/ui/layers/index.d.ts +22 -9
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +6 -5
- package/package.json +8 -8
- package/src/annotations/AnnotationTargets.tsx +6 -1
- package/src/annotations/Annotations.overlay.test.tsx +2 -0
- package/src/annotations/types.ts +4 -1
- package/src/canvas/CanvasStack.tsx +8 -13
- package/src/canvas/useOrbit.test.ts +97 -1
- package/src/canvas/useOrbit.ts +41 -34
- package/src/canvas/usePanZoom.test.ts +106 -1
- package/src/canvas/usePanZoom.ts +54 -36
- package/src/chrome/ChromeRegions.stories.tsx +42 -1
- package/src/chrome/builtins.test.ts +4 -0
- package/src/chrome/builtins.tsx +18 -0
- package/src/chrome/index.ts +1 -1
- package/src/chrome/regions/SidebarRegion.tsx +7 -6
- package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
- package/src/chrome/regions/TitleBarRegion.tsx +16 -7
- package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
- package/src/chrome/regions/ToolbarRegion.tsx +2 -2
- package/src/chrome/regions/ViewportRegion.tsx +2 -2
- package/src/chrome/regions/regions.test.tsx +45 -1
- package/src/chrome/types.ts +21 -3
- package/src/controls/ControlPanel.test.tsx +38 -0
- package/src/controls/ControlPanel.tsx +51 -6
- package/src/dragdrop/DragDropRuntime.tsx +74 -61
- package/src/dragdrop/Palette.tsx +4 -3
- package/src/dragdrop/dragDrop.test.tsx +35 -12
- package/src/index.ts +2 -1
- package/src/instrument/types.ts +4 -5
- package/src/job/useJob.ts +1 -1
- package/src/lab/Lab.test.tsx +7 -0
- package/src/lab/Lab.tsx +2 -2
- package/src/lab/LabContext.ts +5 -1
- package/src/lab/LabShell.less +17 -2
- package/src/lab/Workspace.tsx +1 -1
- package/src/layers/AGENTS.md +29 -7
- package/src/layers/LayerList.less +16 -0
- package/src/layers/LayerList.stories.tsx +66 -0
- package/src/layers/LayerList.test.tsx +185 -2
- package/src/layers/LayerList.tsx +221 -74
- package/src/layers/index.ts +1 -1
- package/src/passthrough/weasel-ui.ts +5 -0
- package/src/primitives/FloatingPanel.test.tsx +87 -0
- package/src/primitives/FloatingPanel.tsx +59 -41
- package/src/state/store.test.ts +78 -0
- package/src/state/store.ts +27 -0
- package/src/state/types.ts +20 -0
- package/src/trial/Trial.less +4 -0
- package/src/trial/Trial.targets.test.tsx +96 -0
- package/src/trial/Trial.test.tsx +171 -14
- package/src/trial/Trial.tsx +4 -1
- package/src/trial/TrialChrome.tsx +22 -2
- package/src/trial/TrialTitleBar.tsx +7 -3
- package/src/trial/index.ts +1 -0
- package/src/trial/trialOps.test.ts +49 -1
- package/src/trial/trialOps.ts +28 -6
- package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
- package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
- package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
- package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
- package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
- package/dist/chunk-4AMN4MMB.js.map +0 -1
- package/dist/chunk-7VW3S44V.js.map +0 -1
- package/dist/chunk-L5WBGUVR.js.map +0 -1
- package/dist/chunk-LJSIUFYD.js.map +0 -1
- package/dist/chunk-UD7TEHZT.js.map +0 -1
- package/dist/chunk-VHFQKAUL.js.map +0 -1
- package/dist/chunk-W3KSUXXR.js.map +0 -1
- package/dist/chunk-XCBCZEBO.js +0 -94
- package/dist/chunk-XCBCZEBO.js.map +0 -1
- package/dist/chunk-YQGQUBKA.js.map +0 -1
package/dist/ui/layers/index.js
CHANGED
package/dist/undo/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
export { a0 as UndoCapability } from '../_dts/frac-
|
|
1
|
+
export { a0 as UndoCapability } from '../_dts/frac-A4R6v4ld.js';
|
|
2
2
|
import 'react';
|
|
3
|
-
import '../_dts/types-
|
|
4
|
-
import '../_dts/
|
|
5
|
-
import '
|
|
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.
|
|
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=
|
|
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.
|
|
118
|
-
"@weasel-js/loupe": "1.4.
|
|
119
|
-
"@weasel-js/svg": "1.4.
|
|
120
|
-
"@weasel-js/theme": "1.4.
|
|
121
|
-
"@weasel-js/ui": "1.4.
|
|
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.
|
|
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
|
/>
|
package/src/annotations/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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
|
+
});
|
package/src/canvas/useOrbit.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
|
|
|
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:
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
124
|
-
(e:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
[
|
|
147
|
+
[clearDrag],
|
|
134
148
|
);
|
|
135
149
|
|
|
136
|
-
|
|
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,
|
|
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
|
+
});
|
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
|
}
|