@weasel-js/labkit 1.6.0 → 1.7.0

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 (222) hide show
  1. package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-D2jnIwfk.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-DiuEArKq.d.ts} +4 -4
  4. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
  5. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
  6. package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Bensr_MT.d.ts} +15 -6
  7. package/dist/_dts/Stage-CCWfQQWM.d.ts +202 -0
  8. package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
  9. package/dist/_dts/{index-B0--8PEM.d.ts → index-CsYqUHD3.d.ts} +12 -13
  10. package/dist/_dts/{types-B7y8hX1D.d.ts → types-CRPSGFAp.d.ts} +33 -5
  11. package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-CSm9jgol.d.ts} +700 -650
  12. package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CTZPgeZ7.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +40 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +5 -6
  16. package/dist/chrome/index.js +9 -7
  17. package/dist/chunk-24W5VEJW.js +18 -0
  18. package/dist/chunk-24W5VEJW.js.map +1 -0
  19. package/dist/chunk-275JRRVD.js +728 -0
  20. package/dist/chunk-275JRRVD.js.map +1 -0
  21. package/dist/chunk-3RY6WL5T.js +38 -0
  22. package/dist/chunk-3RY6WL5T.js.map +1 -0
  23. package/dist/chunk-4LJSC7MB.js +143 -0
  24. package/dist/chunk-4LJSC7MB.js.map +1 -0
  25. package/dist/{chunk-3FI7Y4R4.js → chunk-7BO7HNGF.js} +5 -5
  26. package/dist/{chunk-3FI7Y4R4.js.map → chunk-7BO7HNGF.js.map} +1 -1
  27. package/dist/chunk-AWQG7FCC.js +19 -0
  28. package/dist/chunk-AWQG7FCC.js.map +1 -0
  29. package/dist/chunk-CZZPXUOU.js +3 -0
  30. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  31. package/dist/{chunk-ZORVNOKX.js → chunk-FMK7UQ6V.js} +24 -145
  32. package/dist/chunk-FMK7UQ6V.js.map +1 -0
  33. package/dist/{chunk-BVL7Y574.js → chunk-FZ7YFQN7.js} +26 -10
  34. package/dist/chunk-FZ7YFQN7.js.map +1 -0
  35. package/dist/{chunk-USO6TERP.js → chunk-GOZLNM4L.js} +11 -9
  36. package/dist/chunk-GOZLNM4L.js.map +1 -0
  37. package/dist/{chunk-H5VIV7MQ.js → chunk-I5DUQ5HM.js} +88 -82
  38. package/dist/chunk-I5DUQ5HM.js.map +1 -0
  39. package/dist/chunk-J7OWWTCW.js +143 -0
  40. package/dist/chunk-J7OWWTCW.js.map +1 -0
  41. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  42. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  43. package/dist/{chunk-VQ47KWD5.js → chunk-KMJ7RWD3.js} +18 -7
  44. package/dist/chunk-KMJ7RWD3.js.map +1 -0
  45. package/dist/{chunk-ERFMC6WU.js → chunk-M7HP3FTK.js} +20 -11
  46. package/dist/chunk-M7HP3FTK.js.map +1 -0
  47. package/dist/chunk-MIM5R4ZT.js +14972 -0
  48. package/dist/chunk-MIM5R4ZT.js.map +1 -0
  49. package/dist/{chunk-HFRE4QQV.js → chunk-T5IBSPAM.js} +96 -32
  50. package/dist/chunk-T5IBSPAM.js.map +1 -0
  51. package/dist/chunk-UNAZSLWE.js +294 -0
  52. package/dist/chunk-UNAZSLWE.js.map +1 -0
  53. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  54. package/dist/chunk-WPLAANH4.js.map +1 -0
  55. package/dist/{chunk-AWH3SRPW.js → chunk-Y23G2VZL.js} +5 -37
  56. package/dist/chunk-Y23G2VZL.js.map +1 -0
  57. package/dist/{chunk-MOLIUNVH.js → chunk-YT75VYRD.js} +4 -4
  58. package/dist/{chunk-MOLIUNVH.js.map → chunk-YT75VYRD.js.map} +1 -1
  59. package/dist/chunk-ZIFPHIWZ.js +302 -0
  60. package/dist/chunk-ZIFPHIWZ.js.map +1 -0
  61. package/dist/config/index.d.ts +48 -40
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +91 -7
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -7
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +294 -82
  68. package/dist/index.js +425 -402
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +4 -5
  71. package/dist/layers/index.js +2 -1
  72. package/dist/loupe/index.d.ts +20 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +289 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +239 -69
  78. package/dist/passthrough/weasel-ui.js +3 -2
  79. package/dist/primitives/index.d.ts +5 -204
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -8
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +152 -82
  85. package/dist/undo/index.d.ts +4 -4
  86. package/package.json +17 -12
  87. package/src/annotations/AnnotationOverlay.tsx +2 -2
  88. package/src/annotations/Annotations.less +0 -10
  89. package/src/annotations/MarkList.tsx +4 -8
  90. package/src/annotations/index.ts +1 -0
  91. package/src/annotations/paint.test.ts +3 -14
  92. package/src/annotations/paint.ts +3 -19
  93. package/src/annotations/store.test.ts +26 -0
  94. package/src/annotations/store.ts +25 -6
  95. package/src/annotations/svgNodes.test.ts +6 -9
  96. package/src/annotations/svgNodes.ts +3 -3
  97. package/src/annotations/toolMap.test.ts +37 -2
  98. package/src/annotations/toolMap.ts +13 -0
  99. package/src/annotations/types.ts +27 -0
  100. package/src/canvas/AGENTS.md +9 -10
  101. package/src/canvas/CameraInput.test.tsx +136 -0
  102. package/src/canvas/CameraInput.tsx +322 -0
  103. package/src/canvas/CanvasStack.tsx +47 -58
  104. package/src/canvas/LinkedCursor.less +16 -0
  105. package/src/canvas/LinkedCursor.tsx +54 -0
  106. package/src/canvas/Stage.test.tsx +33 -4
  107. package/src/canvas/Stage.tsx +47 -37
  108. package/src/canvas/cameraRegistry.test.ts +55 -0
  109. package/src/canvas/cameraRegistry.ts +86 -0
  110. package/src/canvas/cameraView.test.ts +60 -0
  111. package/src/canvas/cameraView.ts +61 -0
  112. package/src/canvas/cameraZoom.test.ts +46 -0
  113. package/src/canvas/cameraZoom.ts +24 -0
  114. package/src/canvas/index.ts +23 -2
  115. package/src/canvas/sharedScope.test.tsx +94 -0
  116. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  117. package/src/chrome/regions/PaletteRegion.less +0 -7
  118. package/src/chrome/regions/SidebarRegion.less +12 -2
  119. package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
  120. package/src/config/builder.test.ts +2 -1
  121. package/src/config/builder.ts +15 -8
  122. package/src/config/entry.test.ts +3 -1
  123. package/src/config/fromConfigField.test.ts +14 -0
  124. package/src/config/fromConfigField.ts +19 -4
  125. package/src/config/index.ts +4 -1
  126. package/src/config/path.ts +2 -5
  127. package/src/config/resolve.ts +15 -1
  128. package/src/config/sectionTree.test.ts +12 -6
  129. package/src/config/sectionTree.ts +15 -5
  130. package/src/config/types.ts +34 -9
  131. package/src/controls/ControlPanel.less +1 -1
  132. package/src/controls/ControlPanel.stories.tsx +3 -2
  133. package/src/controls/ControlPanel.test.tsx +76 -13
  134. package/src/controls/ControlPanel.tsx +80 -11
  135. package/src/controls/inDialog.test.tsx +18 -0
  136. package/src/controls/inDialog.tsx +2 -10
  137. package/src/index.test.ts +1 -1
  138. package/src/index.ts +23 -5
  139. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  140. package/src/instrument/types.ts +10 -1
  141. package/src/lab/Lab.nebula.test.tsx +11 -2
  142. package/src/lab/Lab.test.tsx +3 -1
  143. package/src/lab/Lab.tsx +87 -76
  144. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  145. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  146. package/src/lab/LabFullChrome.stories.tsx +1 -0
  147. package/src/lab/LabHeader.test.tsx +24 -14
  148. package/src/lab/LabHeader.tsx +11 -12
  149. package/src/lab/LabShell.less +8 -17
  150. package/src/lab/LabSwitcher.less +6 -6
  151. package/src/lab/LabZoom.test.tsx +223 -0
  152. package/src/lab/LabZoom.tsx +170 -0
  153. package/src/lab/index.ts +1 -0
  154. package/src/loupe/AGENTS.md +7 -6
  155. package/src/loupe/LoupeGestures.tsx +15 -10
  156. package/src/loupe/TrialLoupe.tsx +5 -5
  157. package/src/loupe/index.ts +1 -0
  158. package/src/loupe/loupeActions.ts +6 -2
  159. package/src/overview/AGENTS.md +38 -0
  160. package/src/overview/OverviewMarks.test.tsx +100 -0
  161. package/src/overview/OverviewMarks.tsx +93 -0
  162. package/src/overview/TrialOverview.less +53 -0
  163. package/src/overview/TrialOverview.stories.tsx +50 -0
  164. package/src/overview/TrialOverview.test.tsx +116 -0
  165. package/src/overview/TrialOverview.tsx +376 -0
  166. package/src/overview/index.ts +2 -0
  167. package/src/passthrough/weasel-ui.test.ts +2 -0
  168. package/src/passthrough/weasel-ui.ts +23 -4
  169. package/src/primitives/FloatingPanel.test.tsx +11 -0
  170. package/src/primitives/FloatingPanel.tsx +2 -5
  171. package/src/primitives/JobProgress.less +1 -1
  172. package/src/primitives/Legend.stories.tsx +1 -0
  173. package/src/primitives/Readout.browser.test.tsx +137 -0
  174. package/src/primitives/Readout.less +12 -0
  175. package/src/primitives/Readout.stories.tsx +58 -0
  176. package/src/primitives/Readout.test.tsx +93 -0
  177. package/src/primitives/Readout.tsx +56 -0
  178. package/src/primitives/index.ts +2 -0
  179. package/src/specimen/Specimen.stories.tsx +1 -0
  180. package/src/specimen/Specimen.tsx +21 -4
  181. package/src/state/Persistence.browser.test.tsx +47 -0
  182. package/src/state/labRecords.test.ts +4 -1
  183. package/src/state/store.test.ts +9 -5
  184. package/src/state/usePersistedState.test.tsx +1 -1
  185. package/src/state/useTrialState.test.tsx +1 -1
  186. package/src/styles.less +3 -0
  187. package/src/theme/Interstellar.stories.tsx +4 -6
  188. package/src/theme/base.less +21 -47
  189. package/src/theme/interstellar.test.ts +1 -1
  190. package/src/theme/interstellar.theme.json +10 -3
  191. package/src/tools/labTool.ts +6 -2
  192. package/src/trial/Trial.annotations.test.tsx +16 -0
  193. package/src/trial/Trial.less +0 -13
  194. package/src/trial/Trial.loupe.test.tsx +73 -1
  195. package/src/trial/Trial.stories.tsx +2 -0
  196. package/src/trial/Trial.tsx +23 -1
  197. package/src/trial/TrialChrome.tsx +21 -3
  198. package/src/trial/trialOps.test.ts +4 -4
  199. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  200. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
  201. package/dist/_dts/index-DINmCuLA.d.ts +0 -453
  202. package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
  203. package/dist/_dts/usePanZoom-Bq73pwFe.d.ts +0 -117
  204. package/dist/chunk-2AYGEN57.js +0 -32
  205. package/dist/chunk-2AYGEN57.js.map +0 -1
  206. package/dist/chunk-AWH3SRPW.js.map +0 -1
  207. package/dist/chunk-BVL7Y574.js.map +0 -1
  208. package/dist/chunk-ERFMC6WU.js.map +0 -1
  209. package/dist/chunk-H5VIV7MQ.js.map +0 -1
  210. package/dist/chunk-HFRE4QQV.js.map +0 -1
  211. package/dist/chunk-RS3HRQWU.js.map +0 -1
  212. package/dist/chunk-SBXNQW4G.js +0 -3
  213. package/dist/chunk-TT5C5S6R.js +0 -368
  214. package/dist/chunk-TT5C5S6R.js.map +0 -1
  215. package/dist/chunk-USO6TERP.js.map +0 -1
  216. package/dist/chunk-VQ47KWD5.js.map +0 -1
  217. package/dist/chunk-WUJXJC5O.js +0 -14568
  218. package/dist/chunk-WUJXJC5O.js.map +0 -1
  219. package/dist/chunk-ZORVNOKX.js.map +0 -1
  220. package/src/canvas/usePanZoom.test.ts +0 -244
  221. package/src/canvas/usePanZoom.ts +0 -147
  222. package/src/state/Persistence.stories.tsx +0 -75
@@ -0,0 +1,136 @@
1
+ /**
2
+ * A labkit camera on weasel's dispatcher: drag pans by the screen delta, the
3
+ * zoom range holds with the opening zoom kept reachable, and the pointer is
4
+ * published in the instrument's world.
5
+ */
6
+
7
+ import { act, render } from '@testing-library/react';
8
+ import { createPointerStore, PointerContextProvider } from '@weasel-js/core';
9
+ import { useState } from 'react';
10
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
11
+ import type { ViewTransform } from '../instrument/types';
12
+ import { CanvasStack } from './CanvasStack';
13
+ import { Stage } from './Stage';
14
+
15
+ beforeAll(() => {
16
+ HTMLCanvasElement.prototype.getContext = vi.fn(
17
+ () => null,
18
+ ) as unknown as HTMLCanvasElement['getContext'];
19
+ const proto = HTMLElement.prototype as unknown as Record<string, unknown>;
20
+ proto.setPointerCapture = vi.fn();
21
+ proto.releasePointerCapture = vi.fn();
22
+ proto.hasPointerCapture = vi.fn(() => true);
23
+ });
24
+
25
+ function lastOf<T>(list: readonly T[]): T {
26
+ const item = list[list.length - 1];
27
+ if (item === undefined) throw new Error('nothing recorded');
28
+ return item;
29
+ }
30
+
31
+ function pointer(el: Element, type: string, x: number, y: number, buttons = 1) {
32
+ el.dispatchEvent(
33
+ new PointerEvent(type, {
34
+ bubbles: true,
35
+ cancelable: true,
36
+ pointerId: 1,
37
+ clientX: x,
38
+ clientY: y,
39
+ button: 0,
40
+ buttons,
41
+ }),
42
+ );
43
+ }
44
+
45
+ function mountStage(initial: ViewTransform, opts: { minZoom?: number; maxZoom?: number } = {}) {
46
+ const seen: ViewTransform[] = [];
47
+ function Host() {
48
+ const [view, setView] = useState(initial);
49
+ return (
50
+ <Stage
51
+ size={{ width: 100, height: 100 }}
52
+ view={view}
53
+ onViewChange={(v) => {
54
+ seen.push(v);
55
+ setView(v);
56
+ }}
57
+ {...opts}
58
+ >
59
+ <div />
60
+ </Stage>
61
+ );
62
+ }
63
+ const { container } = render(<Host />);
64
+ const host = container.querySelector('.lk-stage') as HTMLElement;
65
+ return { host, seen };
66
+ }
67
+
68
+ describe('camera input', () => {
69
+ it('pans by the screen delta of a drag', () => {
70
+ const { host, seen } = mountStage({ zoom: 2, pan: { x: 10, y: 20 } });
71
+ act(() => {
72
+ pointer(host, 'pointerdown', 50, 50);
73
+ pointer(host, 'pointermove', 60, 55);
74
+ pointer(host, 'pointermove', 80, 70);
75
+ pointer(host, 'pointerup', 80, 70, 0);
76
+ });
77
+ const last = lastOf(seen);
78
+ expect(last.zoom).toBeCloseTo(2);
79
+ expect(last.pan.x).toBeCloseTo(40);
80
+ expect(last.pan.y).toBeCloseTo(40);
81
+ });
82
+
83
+ it('stops the zoom at the bound, anchored on the pointer', () => {
84
+ const { host, seen } = mountStage({ zoom: 1, pan: { x: 0, y: 0 } }, { maxZoom: 1.2 });
85
+ act(() => {
86
+ host.dispatchEvent(
87
+ new WheelEvent('wheel', {
88
+ bubbles: true,
89
+ cancelable: true,
90
+ deltaY: -2000,
91
+ clientX: 40,
92
+ clientY: 30,
93
+ }),
94
+ );
95
+ });
96
+ const last = lastOf(seen);
97
+ expect(last.zoom).toBeCloseTo(1.2);
98
+ // The world point under (40, 30) stayed there.
99
+ expect((40 - last.pan.x) / last.zoom).toBeCloseTo(40);
100
+ expect((30 - last.pan.y) / last.zoom).toBeCloseTo(30);
101
+ });
102
+
103
+ it('keeps the opening zoom reachable past a narrower range', () => {
104
+ const { host, seen } = mountStage({ zoom: 4, pan: { x: 0, y: 0 } }, { maxZoom: 2 });
105
+ act(() => {
106
+ host.dispatchEvent(
107
+ new WheelEvent('wheel', {
108
+ bubbles: true,
109
+ cancelable: true,
110
+ deltaY: 100,
111
+ clientX: 0,
112
+ clientY: 0,
113
+ }),
114
+ );
115
+ });
116
+ // Zooming out from 4 lands between 2 and 4, not snapped to 2.
117
+ expect(lastOf(seen).zoom).toBeGreaterThan(2);
118
+ });
119
+
120
+ it("publishes the pointer in the instrument's world as the stage", () => {
121
+ const store = createPointerStore();
122
+ const { container } = render(
123
+ <PointerContextProvider store={store}>
124
+ <CanvasStack
125
+ layers={[]}
126
+ view={{ zoom: 2, pan: { x: 0, y: 0 } }}
127
+ onViewChange={() => {}}
128
+ worldSpec={{ yAxis: 'up' }}
129
+ />
130
+ </PointerContextProvider>,
131
+ );
132
+ const host = container.querySelector('.lk-canvas-stack') as HTMLElement;
133
+ act(() => pointer(host, 'pointermove', 40, 30, 0));
134
+ expect(store.get()).toEqual({ worldX: 20, worldY: -15, viewId: 'stage' });
135
+ });
136
+ });
@@ -0,0 +1,322 @@
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
+ type ActionsRegistry,
9
+ InputScope,
10
+ makeViewportZoomAction,
11
+ PointerContextProvider,
12
+ type PointerWorldPos,
13
+ type Tool,
14
+ useActionsRegistry,
15
+ useDepSource,
16
+ useGestureDispatcher,
17
+ usePointerContext,
18
+ type View,
19
+ type ViewApi,
20
+ viewportDragPanAction,
21
+ type Yoke,
22
+ } from '@weasel-js/core';
23
+ import {
24
+ createContext,
25
+ type ReactNode,
26
+ type RefObject,
27
+ useContext,
28
+ useEffect,
29
+ useMemo,
30
+ useRef,
31
+ } from 'react';
32
+ import type { Point, ViewTransform } from '../instrument/types';
33
+ import { normalize2DView } from '../state/view';
34
+ import { CameraWheelContext } from './CameraWheelContext';
35
+ import { usePublishCamera } from './cameraRegistry';
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
+ /**
61
+ * The input scope a camera routes through. Inside another camera — a loupe or
62
+ * an overlay laid over a canvas stack — it joins that camera's scope, so its
63
+ * actions reach the dispatcher already listening there. Anywhere else it
64
+ * mounts a scope of its own, which keeps its own tool unless it joins `yoke`:
65
+ * a camera beside another never takes that one's gestures. The pointer store is the exception — one
66
+ * already in scope is kept, since sharing it is the point.
67
+ */
68
+ export function CameraScope({ yoke, children }: { yoke?: Yoke; children: ReactNode }) {
69
+ const around = useContext(CameraContext);
70
+ const pointer = usePointerContext();
71
+ if (around) return <>{children}</>;
72
+ const scoped = <InputScope yoke={yoke}>{children}</InputScope>;
73
+ return pointer ? scoped : <PointerContextProvider>{scoped}</PointerContextProvider>;
74
+ }
75
+
76
+ /** Options for {@link useCameraView}. */
77
+ export interface CameraViewOptions {
78
+ view: ViewTransform;
79
+ onViewChange: (v: ViewTransform) => void;
80
+ frame: WorldFrame;
81
+ hostRef: RefObject<HTMLElement | null>;
82
+ minZoom?: number;
83
+ maxZoom?: number;
84
+ }
85
+
86
+ /** A camera's zoom limits, after widening to admit its opening zoom. */
87
+ export interface ZoomRange {
88
+ min: number;
89
+ max: number;
90
+ }
91
+
92
+ /** A labkit camera: a weasel `ViewApi` that also says how far it zooms. */
93
+ export interface CameraView extends ViewApi {
94
+ zoomRange(): ZoomRange;
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): CameraView {
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<CameraView>(() => {
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
+ zoomRange: () => ({ ...bounds.current }),
142
+ };
143
+ }, [hostRef]);
144
+ }
145
+
146
+ const TAP_ID = 'camera.tap';
147
+ const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
148
+ const CHANNELS = { contextMenu: false, ingest: false } as const;
149
+
150
+ /** Props for `<CameraInput>`. */
151
+ export interface CameraInputProps {
152
+ hostRef: RefObject<HTMLElement | null>;
153
+ /** Published to the host's camera registry while its scope is the active
154
+ * one, so chrome acting on "the camera" acts on the one last used. */
155
+ camera: CameraView;
156
+ frame: WorldFrame;
157
+ /** A primary-button press released without crossing the drag threshold, at
158
+ * the world point it landed on. */
159
+ onTap?: (world: Point) => void;
160
+ }
161
+
162
+ /**
163
+ * Mounts the dispatcher on `hostRef` and routes the camera's gestures through
164
+ * it. Render it inside a `<CameraScope>`; with no actions registry in scope it
165
+ * renders nothing.
166
+ */
167
+ export function CameraInput(props: CameraInputProps) {
168
+ const registry = useActionsRegistry();
169
+ if (!registry) return null;
170
+ return <CameraDispatch {...props} registry={registry} />;
171
+ }
172
+
173
+ function CameraDispatch({
174
+ hostRef,
175
+ camera,
176
+ frame,
177
+ onTap,
178
+ registry,
179
+ }: CameraInputProps & { registry: ActionsRegistry }) {
180
+ const frameRef = useRef(frame);
181
+ frameRef.current = frame;
182
+ const onTapRef = useRef(onTap);
183
+ onTapRef.current = onTap;
184
+
185
+ useDepSource('view', () => camera);
186
+ useDepSource('rootView', () => camera);
187
+
188
+ const actions = useMemo<Action[]>(() => {
189
+ // Unclamped here: `camera.set` clamps, to a range widened around the
190
+ // opening zoom, and anchors the clamp where the wheel was.
191
+ const zoom = makeViewportZoomAction({
192
+ wheel: 'plain',
193
+ min: 1e-9,
194
+ max: Number.POSITIVE_INFINITY,
195
+ });
196
+ // The wheel and the trackpad's two pinch forms only: the zoom keys listen
197
+ // on the window, and every trial on a page would take them at once.
198
+ const wheelOnly = (zoom.defaultBinding as { spec: { kind: string } }[]).filter(
199
+ (b) => b.spec.kind === 'wheel' || b.spec.kind === 'pinch',
200
+ );
201
+ const tap: Action = {
202
+ id: TAP_ID,
203
+ label: 'Tap the canvas',
204
+ defaultBinding: { kind: 'click' },
205
+ invoker: {
206
+ timing: 'immediate',
207
+ run: (_deps, params) => {
208
+ const p = params as { worldX?: number; worldY?: number } | undefined;
209
+ if (p?.worldX === undefined || p.worldY === undefined) return;
210
+ onTapRef.current?.(frameLocalToWorld({ x: p.worldX, y: p.worldY }, frameRef.current));
211
+ },
212
+ },
213
+ };
214
+ return [
215
+ // A trial pans on a plain drag; the kit action binds none of its own.
216
+ { ...viewportDragPanAction, defaultBinding: { kind: 'drag' } },
217
+ { ...zoom, defaultBinding: wheelOnly as Action['defaultBinding'] },
218
+ tap,
219
+ ];
220
+ }, []);
221
+
222
+ useEffect(() => {
223
+ const offs = actions.map((a) => registry.register(a));
224
+ return () => {
225
+ for (const off of offs) off();
226
+ };
227
+ }, [registry, actions]);
228
+
229
+ const clientToWorld = useMemo(
230
+ () => (cx: number, cy: number) => {
231
+ const r = hostRef.current?.getBoundingClientRect();
232
+ const v = camera.get();
233
+ return {
234
+ x: (cx - (r?.left ?? 0)) / v.scale.x + v.x,
235
+ y: (cy - (r?.top ?? 0)) / v.scale.y + v.y,
236
+ };
237
+ },
238
+ [hostRef, camera],
239
+ );
240
+
241
+ useGestureDispatcher({
242
+ canvasRef: hostRef,
243
+ actions: registry,
244
+ entriesById: NO_TOOLS,
245
+ clientToWorld,
246
+ channels: CHANNELS,
247
+ });
248
+
249
+ usePublishPointer(hostRef, clientToWorld, frame, STAGE_VIEW_ID);
250
+ usePublishCamera(camera);
251
+
252
+ // Input that lands outside the camera's element — an annotation target's
253
+ // box, portalled into the surface — reaches the active camera's dispatcher
254
+ // through here.
255
+ const wheelSlot = useContext(CameraWheelContext);
256
+ useEffect(() => {
257
+ if (!wheelSlot) return;
258
+ const forward = (e: WheelEvent): void => {
259
+ hostRef.current?.dispatchEvent(new WheelEvent('wheel', e));
260
+ };
261
+ const claim = (): void => {
262
+ if (registry.isActive()) wheelSlot.current = forward;
263
+ };
264
+ claim();
265
+ const off = registry.subscribe(claim);
266
+ return () => {
267
+ off();
268
+ if (wheelSlot.current === forward) wheelSlot.current = null;
269
+ };
270
+ }, [wheelSlot, hostRef, registry]);
271
+
272
+ return null;
273
+ }
274
+
275
+ /**
276
+ * Publish the pointer over `hostRef` into the store in scope, in the
277
+ * instrument's world, as `viewId`; clear it when the pointer leaves with no
278
+ * button held.
279
+ */
280
+ export function usePublishPointer(
281
+ hostRef: RefObject<HTMLElement | null>,
282
+ clientToLocal: (cx: number, cy: number) => Point,
283
+ frame: WorldFrame,
284
+ viewId: string,
285
+ ): void {
286
+ const store = usePointerContext();
287
+ const frameRef = useRef(frame);
288
+ frameRef.current = frame;
289
+ const toLocal = useRef(clientToLocal);
290
+ toLocal.current = clientToLocal;
291
+ useEffect(() => {
292
+ const el = hostRef.current;
293
+ if (!el || !store) return;
294
+ const onMove = (e: PointerEvent): void => {
295
+ const w = frameLocalToWorld(toLocal.current(e.clientX, e.clientY), frameRef.current);
296
+ store.set({ worldX: w.x, worldY: w.y, viewId });
297
+ };
298
+ const onLeave = (e: PointerEvent): void => {
299
+ if (e.buttons !== 0) return;
300
+ if (store.get()?.viewId === viewId) store.set(null);
301
+ };
302
+ el.addEventListener('pointermove', onMove);
303
+ el.addEventListener('pointerleave', onLeave);
304
+ return () => {
305
+ el.removeEventListener('pointermove', onMove);
306
+ el.removeEventListener('pointerleave', onLeave);
307
+ };
308
+ }, [hostRef, store, viewId]);
309
+ }
310
+
311
+ /** The pointer as a frame-local point, or `null` when there is none or it is
312
+ * over `viewId` itself. */
313
+ export function pointerElsewhere(
314
+ p: PointerWorldPos,
315
+ viewId: string,
316
+ frame: WorldFrame,
317
+ ): Point | null {
318
+ if (!p || p.viewId === viewId) return null;
319
+ return frameLocalToWorld({ x: p.worldX, y: p.worldY }, frame);
320
+ }
321
+
322
+ export type { ViewportSize };
@@ -1,19 +1,11 @@
1
- import {
2
- type CSSProperties,
3
- type ReactNode,
4
- useContext,
5
- useEffect,
6
- useMemo,
7
- useRef,
8
- useState,
9
- } from 'react';
1
+ import type { Yoke } from '@weasel-js/core';
2
+ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
10
3
  import type { Point, ViewTransform } from '../instrument/types';
11
4
  import { normalize2DView } from '../state/view';
12
- import { CameraWheelContext } from './CameraWheelContext';
5
+ import { CameraContext, CameraInput, CameraScope, useCameraView } from './CameraInput';
13
6
  import { CanvasStackContext } from './CanvasStackContext';
14
- import { screenToWorld } from './canvasCoords';
7
+ import { LinkedCursor } from './LinkedCursor';
15
8
  import { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';
16
- import { usePanZoom } from './usePanZoom';
17
9
  import { resolveFrame, type ViewportSize, type WorldSpec } from './worldSpec';
18
10
 
19
11
  /** Props for `<CanvasStack>`. */
@@ -34,11 +26,15 @@ export interface CanvasStackProps {
34
26
  onResize?: (size: ViewportSize) => void;
35
27
  onHitTest?: (worldPos: Point) => void;
36
28
  children?: ReactNode;
29
+ /** A yoke to join, from `useYoke()`: cameras on one yoke share their tool
30
+ * and gesture in flight. Omitted, this one keeps its own. */
31
+ yoke?: Yoke;
37
32
  }
38
33
 
39
34
  /** Stacks one `<canvas>` per layer and drives them from a shared view, so a
40
35
  * layer that changes rarely is not redrawn with one that changes every frame.
41
- * Handles sizing, device pixel ratio, and pan/zoom. */
36
+ * Handles sizing and device pixel ratio; pan and zoom route through weasel's
37
+ * gesture dispatcher (`<CameraInput>`). */
42
38
  export function CanvasStack({
43
39
  layers,
44
40
  view: viewProp,
@@ -52,6 +48,7 @@ export function CanvasStack({
52
48
  onResize,
53
49
  onHitTest,
54
50
  children,
51
+ yoke,
55
52
  }: CanvasStackProps) {
56
53
  const view = normalize2DView(viewProp);
57
54
  const containerRef = useRef<HTMLDivElement | null>(null);
@@ -92,31 +89,18 @@ export function CanvasStack({
92
89
 
93
90
  const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
94
91
 
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]);
92
+ const camera = useCameraView({
93
+ view: viewProp,
94
+ onViewChange,
95
+ frame,
96
+ hostRef: containerRef,
97
+ minZoom,
98
+ maxZoom,
99
+ });
100
+ const cameraCtx = useMemo(
101
+ () => ({ view: camera, frame, element: () => containerRef.current }),
102
+ [camera, frame],
103
+ );
120
104
  useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });
121
105
 
122
106
  const ctxValue = useMemo(
@@ -139,25 +123,30 @@ export function CanvasStack({
139
123
  };
140
124
 
141
125
  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>
126
+ <CameraScope yoke={yoke}>
127
+ <CameraContext.Provider value={cameraCtx}>
128
+ <CanvasStackContext.Provider value={ctxValue}>
129
+ <CameraInput hostRef={containerRef} camera={camera} frame={frame} onTap={onHitTest} />
130
+ <div
131
+ ref={containerRef}
132
+ className={className ? `lk-canvas-stack ${className}` : 'lk-canvas-stack'}
133
+ style={containerStyle}
134
+ >
135
+ {layers.map((layer) => (
136
+ <canvas
137
+ key={layer.id}
138
+ ref={(el) => setCanvasRef(layer.id, el)}
139
+ className="lk-canvas-stack__canvas"
140
+ width={canvasPx.width}
141
+ height={canvasPx.height}
142
+ style={{ ...canvasCss, display: layer.visible ? 'block' : 'none' }}
143
+ />
144
+ ))}
145
+ <LinkedCursor />
146
+ <div className="lk-canvas-stack__overlay">{children}</div>
147
+ </div>
148
+ </CanvasStackContext.Provider>
149
+ </CameraContext.Provider>
150
+ </CameraScope>
162
151
  );
163
152
  }
@@ -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);
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
+ }