@weasel-js/labkit 1.6.0 → 1.6.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 (162) hide show
  1. package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
  2. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  3. package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-BoELAMsM.d.ts} +2 -2
  4. package/dist/_dts/{index-DINmCuLA.d.ts → PropertyField.d-D0ndfIIt.d.ts} +34 -83
  5. package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Cr8-zoup.d.ts} +4 -5
  6. package/dist/_dts/{usePanZoom-Bq73pwFe.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
  7. package/dist/_dts/{index-B0--8PEM.d.ts → index-CKyX0ME_.d.ts} +12 -13
  8. package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-B7yBTTF9.d.ts} +699 -649
  9. package/dist/_dts/{types-B7y8hX1D.d.ts → types-DTuViBvc.d.ts} +20 -1
  10. package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
  11. package/dist/canvas/index.d.ts +39 -7
  12. package/dist/canvas/index.js +5 -3
  13. package/dist/chrome/index.d.ts +4 -5
  14. package/dist/chrome/index.js +8 -6
  15. package/dist/chunk-24W5VEJW.js +18 -0
  16. package/dist/chunk-24W5VEJW.js.map +1 -0
  17. package/dist/chunk-3RY6WL5T.js +38 -0
  18. package/dist/chunk-3RY6WL5T.js.map +1 -0
  19. package/dist/{chunk-H5VIV7MQ.js → chunk-4TT3PNPR.js} +60 -51
  20. package/dist/chunk-4TT3PNPR.js.map +1 -0
  21. package/dist/{chunk-ZORVNOKX.js → chunk-6FLXL3I7.js} +9 -145
  22. package/dist/chunk-6FLXL3I7.js.map +1 -0
  23. package/dist/{chunk-HFRE4QQV.js → chunk-ASOK76ZH.js} +53 -22
  24. package/dist/chunk-ASOK76ZH.js.map +1 -0
  25. package/dist/chunk-AWQG7FCC.js +19 -0
  26. package/dist/chunk-AWQG7FCC.js.map +1 -0
  27. package/dist/chunk-CGHF6RZV.js +300 -0
  28. package/dist/chunk-CGHF6RZV.js.map +1 -0
  29. package/dist/chunk-DWMSB4CA.js +228 -0
  30. package/dist/chunk-DWMSB4CA.js.map +1 -0
  31. package/dist/{chunk-USO6TERP.js → chunk-GHGO52SO.js} +11 -9
  32. package/dist/chunk-GHGO52SO.js.map +1 -0
  33. package/dist/{chunk-MOLIUNVH.js → chunk-HKV7LRHY.js} +4 -4
  34. package/dist/{chunk-MOLIUNVH.js.map → chunk-HKV7LRHY.js.map} +1 -1
  35. package/dist/{chunk-VQ47KWD5.js → chunk-IRTGUQ5O.js} +5 -4
  36. package/dist/chunk-IRTGUQ5O.js.map +1 -0
  37. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  38. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  39. package/dist/chunk-KHK5FGER.js +145 -0
  40. package/dist/chunk-KHK5FGER.js.map +1 -0
  41. package/dist/chunk-NEXR37YJ.js +144 -0
  42. package/dist/chunk-NEXR37YJ.js.map +1 -0
  43. package/dist/{chunk-BVL7Y574.js → chunk-O6FFFOHS.js} +14 -6
  44. package/dist/chunk-O6FFFOHS.js.map +1 -0
  45. package/dist/chunk-QGMOA2YE.js +731 -0
  46. package/dist/chunk-QGMOA2YE.js.map +1 -0
  47. package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
  48. package/dist/chunk-S4NCI32V.js.map +1 -0
  49. package/dist/{chunk-3FI7Y4R4.js → chunk-SLAQPUI4.js} +4 -4
  50. package/dist/{chunk-3FI7Y4R4.js.map → chunk-SLAQPUI4.js.map} +1 -1
  51. package/dist/{chunk-AWH3SRPW.js → chunk-TJ77A4A3.js} +3 -31
  52. package/dist/chunk-TJ77A4A3.js.map +1 -0
  53. package/dist/{chunk-WUJXJC5O.js → chunk-TYN6MLXX.js} +691 -1395
  54. package/dist/chunk-TYN6MLXX.js.map +1 -0
  55. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  56. package/dist/chunk-WPLAANH4.js.map +1 -0
  57. package/dist/config/index.d.ts +36 -35
  58. package/dist/config/index.js +4 -5
  59. package/dist/controls/index.d.ts +91 -7
  60. package/dist/controls/index.js +5 -4
  61. package/dist/dragdrop/index.d.ts +8 -6
  62. package/dist/dragdrop/index.js +3 -2
  63. package/dist/index.d.ts +50 -77
  64. package/dist/index.js +138 -254
  65. package/dist/index.js.map +1 -1
  66. package/dist/layers/index.d.ts +3 -4
  67. package/dist/layers/index.js +2 -1
  68. package/dist/loupe/index.d.ts +19 -16
  69. package/dist/loupe/index.js +5 -3
  70. package/dist/overview/index.d.ts +56 -0
  71. package/dist/overview/index.js +288 -0
  72. package/dist/overview/index.js.map +1 -0
  73. package/dist/passthrough/weasel-ui.d.ts +38 -38
  74. package/dist/passthrough/weasel-ui.js +2 -1
  75. package/dist/primitives/index.d.ts +19 -43
  76. package/dist/primitives/index.js +5 -3
  77. package/dist/state/index.d.ts +7 -7
  78. package/dist/state/index.js +4 -5
  79. package/dist/state/index.js.map +1 -1
  80. package/dist/styles.css +63 -1
  81. package/dist/undo/index.d.ts +3 -3
  82. package/package.json +13 -9
  83. package/src/annotations/index.ts +1 -0
  84. package/src/annotations/store.test.ts +26 -0
  85. package/src/annotations/store.ts +25 -6
  86. package/src/annotations/toolMap.test.ts +37 -2
  87. package/src/annotations/toolMap.ts +13 -0
  88. package/src/annotations/types.ts +27 -0
  89. package/src/canvas/AGENTS.md +8 -10
  90. package/src/canvas/CameraInput.test.tsx +136 -0
  91. package/src/canvas/CameraInput.tsx +311 -0
  92. package/src/canvas/CanvasStack.tsx +42 -58
  93. package/src/canvas/LinkedCursor.less +16 -0
  94. package/src/canvas/LinkedCursor.tsx +54 -0
  95. package/src/canvas/Stage.tsx +42 -37
  96. package/src/canvas/cameraView.test.ts +60 -0
  97. package/src/canvas/cameraView.ts +61 -0
  98. package/src/canvas/index.ts +11 -2
  99. package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
  100. package/src/config/builder.ts +3 -3
  101. package/src/config/entry.test.ts +3 -1
  102. package/src/config/fromConfigField.ts +1 -0
  103. package/src/config/index.ts +4 -1
  104. package/src/config/resolve.ts +11 -1
  105. package/src/config/sectionTree.test.ts +12 -6
  106. package/src/config/sectionTree.ts +15 -5
  107. package/src/config/types.ts +21 -0
  108. package/src/controls/ControlPanel.test.tsx +12 -2
  109. package/src/controls/ControlPanel.tsx +28 -9
  110. package/src/controls/inDialog.test.tsx +18 -0
  111. package/src/controls/inDialog.tsx +2 -10
  112. package/src/index.test.ts +1 -1
  113. package/src/index.ts +8 -4
  114. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  115. package/src/instrument/types.ts +10 -1
  116. package/src/lab/Lab.nebula.test.tsx +11 -2
  117. package/src/lab/Lab.test.tsx +3 -1
  118. package/src/lab/Lab.tsx +4 -4
  119. package/src/loupe/AGENTS.md +7 -6
  120. package/src/loupe/LoupeGestures.tsx +14 -9
  121. package/src/loupe/TrialLoupe.tsx +5 -5
  122. package/src/loupe/index.ts +1 -0
  123. package/src/loupe/loupeActions.ts +6 -2
  124. package/src/overview/AGENTS.md +38 -0
  125. package/src/overview/OverviewMarks.test.tsx +100 -0
  126. package/src/overview/OverviewMarks.tsx +93 -0
  127. package/src/overview/TrialOverview.less +53 -0
  128. package/src/overview/TrialOverview.stories.tsx +50 -0
  129. package/src/overview/TrialOverview.test.tsx +116 -0
  130. package/src/overview/TrialOverview.tsx +374 -0
  131. package/src/overview/index.ts +2 -0
  132. package/src/passthrough/weasel-ui.ts +7 -0
  133. package/src/specimen/Specimen.tsx +19 -2
  134. package/src/state/labRecords.test.ts +4 -1
  135. package/src/state/store.test.ts +4 -4
  136. package/src/state/useTrialState.test.tsx +1 -1
  137. package/src/styles.less +2 -0
  138. package/src/theme/Interstellar.stories.tsx +1 -5
  139. package/src/tools/labTool.ts +6 -2
  140. package/src/trial/Trial.annotations.test.tsx +16 -0
  141. package/src/trial/Trial.loupe.test.tsx +73 -1
  142. package/src/trial/Trial.stories.tsx +2 -0
  143. package/src/trial/Trial.tsx +17 -1
  144. package/src/trial/trialOps.test.ts +4 -4
  145. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
  146. package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
  147. package/dist/chunk-2AYGEN57.js +0 -32
  148. package/dist/chunk-2AYGEN57.js.map +0 -1
  149. package/dist/chunk-AWH3SRPW.js.map +0 -1
  150. package/dist/chunk-BVL7Y574.js.map +0 -1
  151. package/dist/chunk-ERFMC6WU.js.map +0 -1
  152. package/dist/chunk-H5VIV7MQ.js.map +0 -1
  153. package/dist/chunk-HFRE4QQV.js.map +0 -1
  154. package/dist/chunk-RS3HRQWU.js.map +0 -1
  155. package/dist/chunk-TT5C5S6R.js +0 -368
  156. package/dist/chunk-TT5C5S6R.js.map +0 -1
  157. package/dist/chunk-USO6TERP.js.map +0 -1
  158. package/dist/chunk-VQ47KWD5.js.map +0 -1
  159. package/dist/chunk-WUJXJC5O.js.map +0 -1
  160. package/dist/chunk-ZORVNOKX.js.map +0 -1
  161. package/src/canvas/usePanZoom.test.ts +0 -244
  162. package/src/canvas/usePanZoom.ts +0 -147
@@ -0,0 +1,311 @@
1
+ /**
2
+ * A labkit camera driven by weasel's dispatcher: drag pans, the wheel zooms
3
+ * about the pointer, a tap reports the world point, and the pointer is
4
+ * published into the store in scope. What `usePanZoom` used to hand-roll.
5
+ */
6
+ import {
7
+ type Action,
8
+ ActionsProvider,
9
+ type ActionsRegistry,
10
+ ActiveToolContextProvider,
11
+ DepRegistryProvider,
12
+ makeViewportZoomAction,
13
+ PointerContextProvider,
14
+ type PointerWorldPos,
15
+ type Tool,
16
+ useActionsRegistry,
17
+ useDepSource,
18
+ useGestureDispatcher,
19
+ usePointerContext,
20
+ type View,
21
+ type ViewApi,
22
+ viewportDragPanAction,
23
+ } from '@weasel-js/core';
24
+ import {
25
+ createContext,
26
+ type ReactNode,
27
+ type RefObject,
28
+ useContext,
29
+ useEffect,
30
+ useMemo,
31
+ useRef,
32
+ } from 'react';
33
+ import type { Point, ViewTransform } from '../instrument/types';
34
+ import { normalize2DView } from '../state/view';
35
+ import { CameraWheelContext } from './CameraWheelContext';
36
+ import { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
37
+ import type { ViewportSize, WorldFrame } from './worldSpec';
38
+
39
+ /** The view id the stage publishes its pointer under. */
40
+ export const STAGE_VIEW_ID = 'stage';
41
+
42
+ /**
43
+ * A camera, as the things that sit beside it read it: an overview, a loupe, a
44
+ * linked cursor. Points are *frame-local* — the world with `y` multiplied by
45
+ * `frame.yDir` — which is the space `view` works in.
46
+ */
47
+ export interface CameraContextValue {
48
+ /** The camera as a weasel `ViewApi`, clamped to its zoom range. */
49
+ view: ViewApi;
50
+ frame: WorldFrame;
51
+ /** The element the camera's dispatcher listens on. */
52
+ element: () => HTMLElement | null;
53
+ /** The content's own rect, frame-local, when it has one — a `<Stage>`'s. */
54
+ content?: { x: number; y: number; width: number; height: number };
55
+ }
56
+
57
+ /** The camera around the caller, or `null` outside one. */
58
+ export const CameraContext = createContext<CameraContextValue | null>(null);
59
+
60
+ /** Set by a host that already provides one input scope for everything inside
61
+ * it — a trial — so the camera, the loupe and an overview share it. */
62
+ export const CameraScopeContext = createContext(false);
63
+
64
+ /**
65
+ * The actions and deps a camera routes through: the ones a trial provides, or
66
+ * an isolated set of its own. Isolated, because an actions registry holds one
67
+ * dispatcher, and a camera must not take input from a canvas beside it. The
68
+ * pointer store is the exception — one already in scope is kept, since sharing
69
+ * it is the point.
70
+ */
71
+ export function CameraScope({ children }: { children: ReactNode }) {
72
+ const inScope = useContext(CameraScopeContext);
73
+ const pointer = usePointerContext();
74
+ if (inScope) return <>{children}</>;
75
+ const scoped = <CameraScopeContext.Provider value={true}>{children}</CameraScopeContext.Provider>;
76
+ return (
77
+ <DepRegistryProvider>
78
+ <ActionsProvider>
79
+ <ActiveToolContextProvider>
80
+ {pointer ? scoped : <PointerContextProvider>{scoped}</PointerContextProvider>}
81
+ </ActiveToolContextProvider>
82
+ </ActionsProvider>
83
+ </DepRegistryProvider>
84
+ );
85
+ }
86
+
87
+ /** Options for {@link useCameraView}. */
88
+ export interface CameraViewOptions {
89
+ view: ViewTransform;
90
+ onViewChange: (v: ViewTransform) => void;
91
+ frame: WorldFrame;
92
+ hostRef: RefObject<HTMLElement | null>;
93
+ minZoom?: number;
94
+ maxZoom?: number;
95
+ }
96
+
97
+ function isPositiveFinite(n: number): boolean {
98
+ return Number.isFinite(n) && n > 0;
99
+ }
100
+
101
+ /**
102
+ * A labkit camera as a stable `ViewApi`. Its zoom range is widened to keep the
103
+ * opening zoom reachable — captured once, so a zoom-out cannot shrink the
104
+ * range and strand the opening view behind it.
105
+ */
106
+ export function useCameraView({
107
+ view,
108
+ onViewChange,
109
+ frame,
110
+ hostRef,
111
+ minZoom = 0.1,
112
+ maxZoom = 32,
113
+ }: CameraViewOptions): ViewApi {
114
+ const live = useRef({ view: normalize2DView(view), onViewChange, frame });
115
+ live.current = { view: normalize2DView(view), onViewChange, frame };
116
+ const initialZoom = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
117
+ const bounds = useRef({ min: minZoom, max: maxZoom });
118
+ const opening = initialZoom.current;
119
+ bounds.current = {
120
+ min: opening == null ? minZoom : Math.min(minZoom, opening),
121
+ max: opening == null ? maxZoom : Math.max(maxZoom, opening),
122
+ };
123
+
124
+ return useMemo<ViewApi>(() => {
125
+ const get = (): View => toCameraView(live.current.view, live.current.frame);
126
+ return {
127
+ get,
128
+ set: (next: View) => {
129
+ const { min, max } = bounds.current;
130
+ const clamped = clampZoomAbout(get(), next, min, max);
131
+ const vt = fromCameraView(clamped, live.current.frame);
132
+ // Written ahead of the render, so a second event inside one commit
133
+ // steps from this one.
134
+ live.current.view = vt;
135
+ live.current.onViewChange(vt);
136
+ },
137
+ hostSize: () => {
138
+ const r = hostRef.current?.getBoundingClientRect();
139
+ return r ? { width: r.width, height: r.height } : null;
140
+ },
141
+ };
142
+ }, [hostRef]);
143
+ }
144
+
145
+ const TAP_ID = 'camera.tap';
146
+ const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
147
+ const CHANNELS = { contextMenu: false, ingest: false } as const;
148
+
149
+ /** Props for `<CameraInput>`. */
150
+ export interface CameraInputProps {
151
+ hostRef: RefObject<HTMLElement | null>;
152
+ camera: ViewApi;
153
+ frame: WorldFrame;
154
+ /** A primary-button press released without crossing the drag threshold, at
155
+ * the world point it landed on. */
156
+ onTap?: (world: Point) => void;
157
+ }
158
+
159
+ /**
160
+ * Mounts the dispatcher on `hostRef` and routes the camera's gestures through
161
+ * it. Render it inside a `<CameraScope>`; with no actions registry in scope it
162
+ * renders nothing.
163
+ */
164
+ export function CameraInput(props: CameraInputProps) {
165
+ const registry = useActionsRegistry();
166
+ if (!registry) return null;
167
+ return <CameraDispatch {...props} registry={registry} />;
168
+ }
169
+
170
+ function CameraDispatch({
171
+ hostRef,
172
+ camera,
173
+ frame,
174
+ onTap,
175
+ registry,
176
+ }: CameraInputProps & { registry: ActionsRegistry }) {
177
+ const frameRef = useRef(frame);
178
+ frameRef.current = frame;
179
+ const onTapRef = useRef(onTap);
180
+ onTapRef.current = onTap;
181
+
182
+ useDepSource('view', () => camera);
183
+ useDepSource('rootView', () => camera);
184
+
185
+ const actions = useMemo<Action[]>(() => {
186
+ // Unclamped here: `camera.set` clamps, to a range widened around the
187
+ // opening zoom, and anchors the clamp where the wheel was.
188
+ const zoom = makeViewportZoomAction({
189
+ wheel: 'plain',
190
+ min: 1e-9,
191
+ max: Number.POSITIVE_INFINITY,
192
+ });
193
+ // The wheel and the trackpad's two pinch forms only: the zoom keys listen
194
+ // on the window, and every trial on a page would take them at once.
195
+ const wheelOnly = (zoom.defaultBinding as { spec: { kind: string } }[]).filter(
196
+ (b) => b.spec.kind === 'wheel' || b.spec.kind === 'pinch',
197
+ );
198
+ const tap: Action = {
199
+ id: TAP_ID,
200
+ label: 'Tap the canvas',
201
+ defaultBinding: { kind: 'click' },
202
+ invoker: {
203
+ timing: 'immediate',
204
+ run: (_deps, params) => {
205
+ const p = params as { worldX?: number; worldY?: number } | undefined;
206
+ if (p?.worldX === undefined || p.worldY === undefined) return;
207
+ onTapRef.current?.(frameLocalToWorld({ x: p.worldX, y: p.worldY }, frameRef.current));
208
+ },
209
+ },
210
+ };
211
+ return [
212
+ viewportDragPanAction,
213
+ { ...zoom, defaultBinding: wheelOnly as Action['defaultBinding'] },
214
+ tap,
215
+ ];
216
+ }, []);
217
+
218
+ useEffect(() => {
219
+ const offs = actions.map((a) => registry.register(a));
220
+ return () => {
221
+ for (const off of offs) off();
222
+ };
223
+ }, [registry, actions]);
224
+
225
+ const clientToWorld = useMemo(
226
+ () => (cx: number, cy: number) => {
227
+ const r = hostRef.current?.getBoundingClientRect();
228
+ const v = camera.get();
229
+ return {
230
+ x: (cx - (r?.left ?? 0)) / v.scale.x + v.x,
231
+ y: (cy - (r?.top ?? 0)) / v.scale.y + v.y,
232
+ };
233
+ },
234
+ [hostRef, camera],
235
+ );
236
+
237
+ useGestureDispatcher({
238
+ canvasRef: hostRef,
239
+ actions: registry,
240
+ toolsById: NO_TOOLS,
241
+ clientToWorld,
242
+ channels: CHANNELS,
243
+ });
244
+
245
+ usePublishPointer(hostRef, clientToWorld, frame, STAGE_VIEW_ID);
246
+
247
+ // Input that lands outside the camera's element — an annotation target's
248
+ // box, portalled into the surface — reaches the dispatcher through here.
249
+ const wheelSlot = useContext(CameraWheelContext);
250
+ useEffect(() => {
251
+ if (!wheelSlot) return;
252
+ const forward = (e: WheelEvent): void => {
253
+ hostRef.current?.dispatchEvent(new WheelEvent('wheel', e));
254
+ };
255
+ wheelSlot.current = forward;
256
+ return () => {
257
+ if (wheelSlot.current === forward) wheelSlot.current = null;
258
+ };
259
+ }, [wheelSlot, hostRef]);
260
+
261
+ return null;
262
+ }
263
+
264
+ /**
265
+ * Publish the pointer over `hostRef` into the store in scope, in the
266
+ * instrument's world, as `viewId`; clear it when the pointer leaves with no
267
+ * button held.
268
+ */
269
+ export function usePublishPointer(
270
+ hostRef: RefObject<HTMLElement | null>,
271
+ clientToLocal: (cx: number, cy: number) => Point,
272
+ frame: WorldFrame,
273
+ viewId: string,
274
+ ): void {
275
+ const store = usePointerContext();
276
+ const frameRef = useRef(frame);
277
+ frameRef.current = frame;
278
+ const toLocal = useRef(clientToLocal);
279
+ toLocal.current = clientToLocal;
280
+ useEffect(() => {
281
+ const el = hostRef.current;
282
+ if (!el || !store) return;
283
+ const onMove = (e: PointerEvent): void => {
284
+ const w = frameLocalToWorld(toLocal.current(e.clientX, e.clientY), frameRef.current);
285
+ store.set({ worldX: w.x, worldY: w.y, viewId });
286
+ };
287
+ const onLeave = (e: PointerEvent): void => {
288
+ if (e.buttons !== 0) return;
289
+ if (store.get()?.viewId === viewId) store.set(null);
290
+ };
291
+ el.addEventListener('pointermove', onMove);
292
+ el.addEventListener('pointerleave', onLeave);
293
+ return () => {
294
+ el.removeEventListener('pointermove', onMove);
295
+ el.removeEventListener('pointerleave', onLeave);
296
+ };
297
+ }, [hostRef, store, viewId]);
298
+ }
299
+
300
+ /** The pointer as a frame-local point, or `null` when there is none or it is
301
+ * over `viewId` itself. */
302
+ export function pointerElsewhere(
303
+ p: PointerWorldPos,
304
+ viewId: string,
305
+ frame: WorldFrame,
306
+ ): Point | null {
307
+ if (!p || p.viewId === viewId) return null;
308
+ return frameLocalToWorld({ x: p.worldX, y: p.worldY }, frame);
309
+ }
310
+
311
+ export type { ViewportSize };
@@ -1,19 +1,10 @@
1
- import {
2
- type CSSProperties,
3
- type ReactNode,
4
- useContext,
5
- useEffect,
6
- useMemo,
7
- useRef,
8
- useState,
9
- } from 'react';
1
+ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
10
2
  import type { Point, ViewTransform } from '../instrument/types';
11
3
  import { normalize2DView } from '../state/view';
12
- import { CameraWheelContext } from './CameraWheelContext';
4
+ import { CameraContext, CameraInput, CameraScope, useCameraView } from './CameraInput';
13
5
  import { CanvasStackContext } from './CanvasStackContext';
14
- import { screenToWorld } from './canvasCoords';
6
+ import { LinkedCursor } from './LinkedCursor';
15
7
  import { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';
16
- import { usePanZoom } from './usePanZoom';
17
8
  import { resolveFrame, type ViewportSize, type WorldSpec } from './worldSpec';
18
9
 
19
10
  /** Props for `<CanvasStack>`. */
@@ -38,7 +29,8 @@ export interface CanvasStackProps {
38
29
 
39
30
  /** Stacks one `<canvas>` per layer and drives them from a shared view, so a
40
31
  * layer that changes rarely is not redrawn with one that changes every frame.
41
- * Handles sizing, device pixel ratio, and pan/zoom. */
32
+ * Handles sizing and device pixel ratio; pan and zoom route through weasel's
33
+ * gesture dispatcher (`<CameraInput>`). */
42
34
  export function CanvasStack({
43
35
  layers,
44
36
  view: viewProp,
@@ -92,31 +84,18 @@ export function CanvasStack({
92
84
 
93
85
  const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
94
86
 
95
- const onTap = (e: PointerEvent) => {
96
- const el = containerRef.current;
97
- if (!onHitTest || !el) return;
98
- const rect = el.getBoundingClientRect();
99
- onHitTest(screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, view, frame));
100
- };
101
-
102
- const handlers = usePanZoom({ view: viewProp, onViewChange, minZoom, maxZoom, frame, onTap });
103
- const onWheelRef = useRef(handlers.onWheel);
104
- onWheelRef.current = handlers.onWheel;
105
- const wheelSlot = useContext(CameraWheelContext);
106
-
107
- // Bound by hand because React registers wheel listeners passive, where
108
- // `preventDefault` is refused and the page scrolls under the zoom.
109
- useEffect(() => {
110
- const el = containerRef.current;
111
- if (!el) return;
112
- const wheel = (e: WheelEvent): void => onWheelRef.current(e, el);
113
- el.addEventListener('wheel', wheel, { passive: false });
114
- if (wheelSlot) wheelSlot.current = wheel;
115
- return () => {
116
- el.removeEventListener('wheel', wheel);
117
- if (wheelSlot?.current === wheel) wheelSlot.current = null;
118
- };
119
- }, [wheelSlot]);
87
+ const camera = useCameraView({
88
+ view: viewProp,
89
+ onViewChange,
90
+ frame,
91
+ hostRef: containerRef,
92
+ minZoom,
93
+ maxZoom,
94
+ });
95
+ const cameraCtx = useMemo(
96
+ () => ({ view: camera, frame, element: () => containerRef.current }),
97
+ [camera, frame],
98
+ );
120
99
  useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });
121
100
 
122
101
  const ctxValue = useMemo(
@@ -139,25 +118,30 @@ export function CanvasStack({
139
118
  };
140
119
 
141
120
  return (
142
- <CanvasStackContext.Provider value={ctxValue}>
143
- <div
144
- ref={containerRef}
145
- className={className ? `lk-canvas-stack ${className}` : 'lk-canvas-stack'}
146
- style={containerStyle}
147
- onPointerDown={handlers.onPointerDown}
148
- >
149
- {layers.map((layer) => (
150
- <canvas
151
- key={layer.id}
152
- ref={(el) => setCanvasRef(layer.id, el)}
153
- className="lk-canvas-stack__canvas"
154
- width={canvasPx.width}
155
- height={canvasPx.height}
156
- style={{ ...canvasCss, display: layer.visible ? 'block' : 'none' }}
157
- />
158
- ))}
159
- <div className="lk-canvas-stack__overlay">{children}</div>
160
- </div>
161
- </CanvasStackContext.Provider>
121
+ <CameraScope>
122
+ <CameraContext.Provider value={cameraCtx}>
123
+ <CanvasStackContext.Provider value={ctxValue}>
124
+ <CameraInput hostRef={containerRef} camera={camera} frame={frame} onTap={onHitTest} />
125
+ <div
126
+ ref={containerRef}
127
+ className={className ? `lk-canvas-stack ${className}` : 'lk-canvas-stack'}
128
+ style={containerStyle}
129
+ >
130
+ {layers.map((layer) => (
131
+ <canvas
132
+ key={layer.id}
133
+ ref={(el) => setCanvasRef(layer.id, el)}
134
+ className="lk-canvas-stack__canvas"
135
+ width={canvasPx.width}
136
+ height={canvasPx.height}
137
+ style={{ ...canvasCss, display: layer.visible ? 'block' : 'none' }}
138
+ />
139
+ ))}
140
+ <LinkedCursor />
141
+ <div className="lk-canvas-stack__overlay">{children}</div>
142
+ </div>
143
+ </CanvasStackContext.Provider>
144
+ </CameraContext.Provider>
145
+ </CameraScope>
162
146
  );
163
147
  }
@@ -0,0 +1,16 @@
1
+ .lk-linked-cursor {
2
+ position: absolute;
3
+ inset: 0;
4
+ width: 100%;
5
+ height: 100%;
6
+ overflow: visible;
7
+ pointer-events: none;
8
+ }
9
+
10
+ .lk-linked-cursor__halo {
11
+ fill: rgb(0 0 0 / 60%);
12
+ }
13
+
14
+ .lk-linked-cursor__bar {
15
+ fill: var(--wzl-accent, #4c8dff);
16
+ }
@@ -0,0 +1,54 @@
1
+ import { crosshairRects, usePointerPosition } from '@weasel-js/core';
2
+ import { useContext } from 'react';
3
+ import { CameraContext, pointerElsewhere, STAGE_VIEW_ID } from './CameraInput';
4
+
5
+ /** `crosshairRects` returns its four arms in this order. */
6
+ const ARMS = ['left', 'right', 'up', 'down'] as const;
7
+
8
+ /**
9
+ * The other side of a linked cursor: a crosshair on the camera's own element
10
+ * wherever the shared pointer is over some other view — an overview, say. The
11
+ * same crosshair core paints on a weasel canvas.
12
+ */
13
+ export function LinkedCursor() {
14
+ const camera = useContext(CameraContext);
15
+ const pointer = usePointerPosition();
16
+ if (!camera) return null;
17
+ const local = pointerElsewhere(pointer, STAGE_VIEW_ID, camera.frame);
18
+ if (!local) return null;
19
+ // `pointerElsewhere` hands back the instrument's world; the camera works
20
+ // frame-local, and the map is its own inverse.
21
+ const v = camera.view.get();
22
+ const x = (local.x - v.x) * v.scale.x;
23
+ const y = (local.y * camera.frame.yDir - v.y) * v.scale.y;
24
+ const { halo, bars } = crosshairRects(x, y, 1);
25
+ const arms = ARMS.map((name, i) => ({ name, halo: halo[i], bar: bars[i] }));
26
+ return (
27
+ <svg className="lk-linked-cursor" aria-hidden="true">
28
+ {arms.map(({ name, halo: r }) =>
29
+ r ? (
30
+ <rect
31
+ key={`halo-${name}`}
32
+ className="lk-linked-cursor__halo"
33
+ x={r.x}
34
+ y={r.y}
35
+ width={r.w}
36
+ height={r.h}
37
+ />
38
+ ) : null,
39
+ )}
40
+ {arms.map(({ name, bar: r }) =>
41
+ r ? (
42
+ <rect
43
+ key={`bar-${name}`}
44
+ className="lk-linked-cursor__bar"
45
+ x={r.x}
46
+ y={r.y}
47
+ width={r.w}
48
+ height={r.h}
49
+ />
50
+ ) : null,
51
+ )}
52
+ </svg>
53
+ );
54
+ }
@@ -1,10 +1,10 @@
1
- import { type CSSProperties, type ReactNode, useContext, useEffect, useRef } from 'react';
1
+ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
2
2
  import type { ViewTransform } from '../instrument/types';
3
3
  import { normalize2DView } from '../state/view';
4
4
  import { useSurfaceOptional } from '../surface/useSurfaceTile';
5
- import { CameraWheelContext } from './CameraWheelContext';
6
- import { usePanZoom } from './usePanZoom';
7
- import type { ViewportSize } from './worldSpec';
5
+ import { CameraContext, CameraInput, CameraScope, useCameraView } from './CameraInput';
6
+ import { LinkedCursor } from './LinkedCursor';
7
+ import { DEFAULT_FRAME, type ViewportSize } from './worldSpec';
8
8
 
9
9
  /** Props for `<Stage>`. */
10
10
  export interface StageProps {
@@ -43,7 +43,8 @@ export function fitStage(size: ViewportSize, viewport: ViewportSize): ViewTransf
43
43
 
44
44
  /**
45
45
  * DOM content of a fixed size, panned and zoomed by a camera — what
46
- * `<CanvasStack>` is for layers drawn to a canvas. The content is laid out at
46
+ * `<CanvasStack>` is for layers drawn to a canvas. Pan and zoom route through
47
+ * weasel's gesture dispatcher (`<CameraInput>`). The content is laid out at
47
48
  * its own size and transformed, so everything inside keeps its layout and a
48
49
  * measurement of any element in it reports where it is actually drawn.
49
50
  */
@@ -61,24 +62,23 @@ export function Stage({
61
62
  const view = normalize2DView(viewProp);
62
63
  const host = useRef<HTMLDivElement | null>(null);
63
64
  const surface = useSurfaceOptional();
64
- const handlers = usePanZoom({ view: viewProp, onViewChange, minZoom, maxZoom });
65
- const onWheelRef = useRef(handlers.onWheel);
66
- onWheelRef.current = handlers.onWheel;
67
- const wheelSlot = useContext(CameraWheelContext);
68
-
69
- // Bound by hand because React registers wheel listeners passive, where
70
- // `preventDefault` is refused and the page scrolls under the zoom.
71
- useEffect(() => {
72
- const el = host.current;
73
- if (!el) return;
74
- const wheel = (e: WheelEvent): void => onWheelRef.current(e, el);
75
- el.addEventListener('wheel', wheel, { passive: false });
76
- if (wheelSlot) wheelSlot.current = wheel;
77
- return () => {
78
- el.removeEventListener('wheel', wheel);
79
- if (wheelSlot?.current === wheel) wheelSlot.current = null;
80
- };
81
- }, [wheelSlot]);
65
+ const camera = useCameraView({
66
+ view: viewProp,
67
+ onViewChange,
68
+ frame: DEFAULT_FRAME,
69
+ hostRef: host,
70
+ minZoom,
71
+ maxZoom,
72
+ });
73
+ const cameraCtx = useMemo(
74
+ () => ({
75
+ view: camera,
76
+ frame: DEFAULT_FRAME,
77
+ element: () => host.current,
78
+ content: { x: 0, y: 0, width: size.width, height: size.height },
79
+ }),
80
+ [camera, size.width, size.height],
81
+ );
82
82
 
83
83
  const onResizeRef = useRef(onResize);
84
84
  onResizeRef.current = onResize;
@@ -108,7 +108,7 @@ export function Stage({
108
108
  // The camera changes every frame of a pan, and a transform has nowhere to
109
109
  // live but the element's own style: set as custom properties the stylesheet
110
110
  // reads.
111
- const camera = {
111
+ const cameraVars = {
112
112
  ['--lk-stage-x' as string]: `${view.pan.x}px`,
113
113
  ['--lk-stage-y' as string]: `${view.pan.y}px`,
114
114
  ['--lk-stage-zoom' as string]: String(view.zoom),
@@ -117,18 +117,23 @@ export function Stage({
117
117
  } as CSSProperties;
118
118
 
119
119
  return (
120
- <div
121
- ref={(el) => {
122
- host.current = el;
123
- if (hostRef) hostRef.current = el;
124
- }}
125
- className="lk-stage"
126
- onPointerDown={handlers.onPointerDown}
127
- >
128
- <div className="lk-stage__content" style={camera}>
129
- {children}
130
- </div>
131
- {overlay}
132
- </div>
120
+ <CameraScope>
121
+ <CameraContext.Provider value={cameraCtx}>
122
+ <CameraInput hostRef={host} camera={camera} frame={DEFAULT_FRAME} />
123
+ <div
124
+ ref={(el) => {
125
+ host.current = el;
126
+ if (hostRef) hostRef.current = el;
127
+ }}
128
+ className="lk-stage"
129
+ >
130
+ <div className="lk-stage__content" style={cameraVars}>
131
+ {children}
132
+ </div>
133
+ <LinkedCursor />
134
+ {overlay}
135
+ </div>
136
+ </CameraContext.Provider>
137
+ </CameraScope>
133
138
  );
134
139
  }
@@ -0,0 +1,60 @@
1
+ import type { View } from '@weasel-js/core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
4
+ import { worldToScreen } from './canvasCoords';
5
+ import type { WorldFrame } from './worldSpec';
6
+
7
+ const UP: WorldFrame = { originPx: { x: 200, y: 150 }, yDir: -1 };
8
+ const vt = { zoom: 2.5, pan: { x: 30, y: -40 } };
9
+
10
+ /** Where a weasel View puts a frame-local point. */
11
+ const project = (v: View, p: { x: number; y: number }) => ({
12
+ x: (p.x - v.x) * v.scale.x,
13
+ y: (p.y - v.y) * v.scale.y,
14
+ });
15
+
16
+ describe('camera as a weasel View', () => {
17
+ it.each([
18
+ ['default frame', undefined],
19
+ ['moved origin, y up', UP],
20
+ ] as const)('places every world point where labkit does (%s)', (_, frame) => {
21
+ const v = toCameraView(vt, frame);
22
+ for (const w of [
23
+ { x: 0, y: 0 },
24
+ { x: 13, y: -7 },
25
+ { x: -120, y: 44 },
26
+ ]) {
27
+ const local = frameLocalToWorld(w, frame);
28
+ const a = project(v, local);
29
+ const b = worldToScreen(w, vt, frame);
30
+ expect(a.x).toBeCloseTo(b.x);
31
+ expect(a.y).toBeCloseTo(b.y);
32
+ }
33
+ });
34
+
35
+ it('round-trips', () => {
36
+ const back = fromCameraView(toCameraView(vt, UP), UP);
37
+ expect(back.zoom).toBeCloseTo(vt.zoom);
38
+ expect(back.pan.x).toBeCloseTo(vt.pan.x);
39
+ expect(back.pan.y).toBeCloseTo(vt.pan.y);
40
+ });
41
+ });
42
+
43
+ describe('clampZoomAbout', () => {
44
+ const prev: View = { x: 0, y: 0, scale: { x: 1, y: 1 } };
45
+ it('holds the anchor of a zoom that hits the bound', () => {
46
+ // A 4x zoom about screen (100, 50): world under it stays (100, 50).
47
+ const next: View = { x: 75, y: 37.5, scale: { x: 4, y: 4 } };
48
+ const out = clampZoomAbout(prev, next, 0.5, 2);
49
+ expect(out.scale.x).toBe(2);
50
+ expect(100 / out.scale.x + out.x).toBeCloseTo(100);
51
+ expect(50 / out.scale.y + out.y).toBeCloseTo(50);
52
+ });
53
+
54
+ it('passes a zoom inside the bounds and a pan through', () => {
55
+ const inside: View = { x: 10, y: 5, scale: { x: 1.5, y: 1.5 } };
56
+ expect(clampZoomAbout(prev, inside, 0.5, 2)).toBe(inside);
57
+ const pan: View = { x: 10, y: 5, scale: { x: 1, y: 1 } };
58
+ expect(clampZoomAbout(prev, pan, 0.5, 2)).toBe(pan);
59
+ });
60
+ });