@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
@@ -1,4 +1,4 @@
1
- import { act, fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, render, screen, within } from '@testing-library/react';
2
2
  import { useState } from 'react';
3
3
  import { beforeAll, describe, expect, it, vi } from 'vitest';
4
4
  import { defineInstrument } from '../instrument/defineInstrument';
@@ -169,19 +169,48 @@ describe('an instrument that declares a stage', () => {
169
169
 
170
170
  it('gets the zoom controls, and they move the stage', () => {
171
171
  const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
172
+ const trialView = within(screen.getByRole('toolbar', { name: 'View' }));
172
173
  act(() => {
173
- fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
174
+ fireEvent.click(trialView.getByRole('button', { name: 'Zoom in' }));
174
175
  });
175
176
  expect(zoomOf(content(container))).toBeCloseTo(1.25);
176
177
  act(() => {
177
- fireEvent.click(screen.getByRole('button', { name: 'Actual size' }));
178
+ fireEvent.click(trialView.getByRole('button', { name: 'Actual size' }));
178
179
  });
179
180
  expect(zoomOf(content(container))).toBe(1);
180
181
  });
181
182
 
183
+ it('zooms about the middle of the view, not the stage origin', () => {
184
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
185
+ x: 0,
186
+ y: 0,
187
+ left: 0,
188
+ top: 0,
189
+ right: 240,
190
+ bottom: 160,
191
+ width: 240,
192
+ height: 160,
193
+ toJSON: () => ({}),
194
+ });
195
+ try {
196
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
197
+ const trialView = within(screen.getByRole('toolbar', { name: 'View' }));
198
+ act(() => {
199
+ fireEvent.click(trialView.getByRole('button', { name: 'Zoom in' }));
200
+ });
201
+ const el = content(container);
202
+ expect(zoomOf(el)).toBeCloseTo(1.25);
203
+ // The screen middle (120, 80) still shows world (120, 80): pan = 120 - 120 * 1.25.
204
+ expect(el.style.getPropertyValue('--lk-stage-x')).toBe('-30px');
205
+ expect(el.style.getPropertyValue('--lk-stage-y')).toBe('-20px');
206
+ } finally {
207
+ rect.mockRestore();
208
+ }
209
+ });
210
+
182
211
  it('is what earns them: DOM content without one gets none', () => {
183
212
  render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
184
- expect(screen.queryByRole('button', { name: 'Zoom in' })).toBeNull();
213
+ expect(screen.queryByRole('toolbar', { name: 'View' })).toBeNull();
185
214
  });
186
215
 
187
216
  it('draws its overlay on the stage, outside the camera', () => {
@@ -1,10 +1,11 @@
1
- import { type CSSProperties, type ReactNode, useContext, useEffect, useRef } from 'react';
1
+ import type { Yoke } from '@weasel-js/core';
2
+ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
2
3
  import type { ViewTransform } from '../instrument/types';
3
4
  import { normalize2DView } from '../state/view';
4
5
  import { useSurfaceOptional } from '../surface/useSurfaceTile';
5
- import { CameraWheelContext } from './CameraWheelContext';
6
- import { usePanZoom } from './usePanZoom';
7
- import type { ViewportSize } from './worldSpec';
6
+ import { CameraContext, CameraInput, CameraScope, useCameraView } from './CameraInput';
7
+ import { LinkedCursor } from './LinkedCursor';
8
+ import { DEFAULT_FRAME, type ViewportSize } from './worldSpec';
8
9
 
9
10
  /** Props for `<Stage>`. */
10
11
  export interface StageProps {
@@ -21,6 +22,9 @@ export interface StageProps {
21
22
  /** Drawn over the content in viewport pixels, outside the camera. */
22
23
  overlay?: ReactNode;
23
24
  children?: ReactNode;
25
+ /** A yoke to join, from `useYoke()`: cameras on one yoke share their tool
26
+ * and gesture in flight. Omitted, this one keeps its own. */
27
+ yoke?: Yoke;
24
28
  }
25
29
 
26
30
  /**
@@ -43,7 +47,8 @@ export function fitStage(size: ViewportSize, viewport: ViewportSize): ViewTransf
43
47
 
44
48
  /**
45
49
  * 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
50
+ * `<CanvasStack>` is for layers drawn to a canvas. Pan and zoom route through
51
+ * weasel's gesture dispatcher (`<CameraInput>`). The content is laid out at
47
52
  * its own size and transformed, so everything inside keeps its layout and a
48
53
  * measurement of any element in it reports where it is actually drawn.
49
54
  */
@@ -57,28 +62,28 @@ export function Stage({
57
62
  hostRef,
58
63
  overlay,
59
64
  children,
65
+ yoke,
60
66
  }: StageProps) {
61
67
  const view = normalize2DView(viewProp);
62
68
  const host = useRef<HTMLDivElement | null>(null);
63
69
  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]);
70
+ const camera = useCameraView({
71
+ view: viewProp,
72
+ onViewChange,
73
+ frame: DEFAULT_FRAME,
74
+ hostRef: host,
75
+ minZoom,
76
+ maxZoom,
77
+ });
78
+ const cameraCtx = useMemo(
79
+ () => ({
80
+ view: camera,
81
+ frame: DEFAULT_FRAME,
82
+ element: () => host.current,
83
+ content: { x: 0, y: 0, width: size.width, height: size.height },
84
+ }),
85
+ [camera, size.width, size.height],
86
+ );
82
87
 
83
88
  const onResizeRef = useRef(onResize);
84
89
  onResizeRef.current = onResize;
@@ -108,7 +113,7 @@ export function Stage({
108
113
  // The camera changes every frame of a pan, and a transform has nowhere to
109
114
  // live but the element's own style: set as custom properties the stylesheet
110
115
  // reads.
111
- const camera = {
116
+ const cameraVars = {
112
117
  ['--lk-stage-x' as string]: `${view.pan.x}px`,
113
118
  ['--lk-stage-y' as string]: `${view.pan.y}px`,
114
119
  ['--lk-stage-zoom' as string]: String(view.zoom),
@@ -117,18 +122,23 @@ export function Stage({
117
122
  } as CSSProperties;
118
123
 
119
124
  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>
125
+ <CameraScope yoke={yoke}>
126
+ <CameraContext.Provider value={cameraCtx}>
127
+ <CameraInput hostRef={host} camera={camera} frame={DEFAULT_FRAME} />
128
+ <div
129
+ ref={(el) => {
130
+ host.current = el;
131
+ if (hostRef) hostRef.current = el;
132
+ }}
133
+ className="lk-stage"
134
+ >
135
+ <div className="lk-stage__content" style={cameraVars}>
136
+ {children}
137
+ </div>
138
+ <LinkedCursor />
139
+ {overlay}
140
+ </div>
141
+ </CameraContext.Provider>
142
+ </CameraScope>
133
143
  );
134
144
  }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { CameraView } from './CameraInput';
3
+ import { createCameraRegistry } from './cameraRegistry';
4
+
5
+ const camera = (): CameraView => ({
6
+ get: () => ({ x: 0, y: 0, scale: { x: 1, y: 1 } }),
7
+ set: () => {},
8
+ zoomRange: () => ({ min: 0.1, max: 32 }),
9
+ });
10
+
11
+ describe('createCameraRegistry', () => {
12
+ it('answers null for a key nothing registered', () => {
13
+ expect(createCameraRegistry().get('a')).toBeNull();
14
+ });
15
+
16
+ it('holds a camera until its release', () => {
17
+ const reg = createCameraRegistry();
18
+ const cam = camera();
19
+ const off = reg.register('a', cam);
20
+ expect(reg.get('a')).toBe(cam);
21
+ off();
22
+ expect(reg.get('a')).toBeNull();
23
+ });
24
+
25
+ it('keeps the newest of a key live, and a release uncovers the one it displaced', () => {
26
+ const reg = createCameraRegistry();
27
+ const first = camera();
28
+ const second = camera();
29
+ const offFirst = reg.register('a', first);
30
+ const offSecond = reg.register('a', second);
31
+ expect(reg.get('a')).toBe(second);
32
+ offFirst();
33
+ expect(reg.get('a')).toBe(second);
34
+ offSecond();
35
+ expect(reg.get('a')).toBeNull();
36
+ reg.register('a', first);
37
+ const offAgain = reg.register('a', second);
38
+ offAgain();
39
+ expect(reg.get('a')).toBe(first);
40
+ });
41
+
42
+ it('tells subscribers when a key changes hands, and stops when they leave', () => {
43
+ const reg = createCameraRegistry();
44
+ const listener = vi.fn();
45
+ const unsubscribe = reg.subscribe(listener);
46
+ const off = reg.register('a', camera());
47
+ expect(listener).toHaveBeenCalledTimes(1);
48
+ off();
49
+ off();
50
+ expect(listener).toHaveBeenCalledTimes(2);
51
+ unsubscribe();
52
+ reg.register('b', camera());
53
+ expect(listener).toHaveBeenCalledTimes(2);
54
+ });
55
+ });
@@ -0,0 +1,86 @@
1
+ import { useActionsRegistry } from '@weasel-js/core';
2
+ import { createContext, useContext, useEffect } from 'react';
3
+ import type { CameraView } from './CameraInput';
4
+
5
+ /**
6
+ * Cameras by key, for chrome that acts on a camera it does not contain — the
7
+ * lab's zoom controls, which drive whichever trial has the focus.
8
+ */
9
+ export interface CameraRegistry {
10
+ /** Publish `camera` under `key` and return its release. Registrants for one
11
+ * key stack, newest live. */
12
+ register(key: string, camera: CameraView): () => void;
13
+ get(key: string): CameraView | null;
14
+ /** Fires after any register or release. Shaped for `useSyncExternalStore`. */
15
+ subscribe(listener: () => void): () => void;
16
+ }
17
+
18
+ export function createCameraRegistry(): CameraRegistry {
19
+ const stacks = new Map<string, CameraView[]>();
20
+ const listeners = new Set<() => void>();
21
+ const notify = (): void => {
22
+ for (const l of listeners) l();
23
+ };
24
+ return {
25
+ register(key, camera) {
26
+ const stack = stacks.get(key) ?? [];
27
+ stack.push(camera);
28
+ stacks.set(key, stack);
29
+ notify();
30
+ let released = false;
31
+ return () => {
32
+ if (released) return;
33
+ released = true;
34
+ const cur = stacks.get(key);
35
+ const i = cur?.lastIndexOf(camera) ?? -1;
36
+ if (!cur || i === -1) return;
37
+ cur.splice(i, 1);
38
+ if (cur.length === 0) stacks.delete(key);
39
+ notify();
40
+ };
41
+ },
42
+ get: (key) => stacks.get(key)?.at(-1) ?? null,
43
+ subscribe(listener) {
44
+ listeners.add(listener);
45
+ return () => {
46
+ listeners.delete(listener);
47
+ };
48
+ },
49
+ };
50
+ }
51
+
52
+ /** The registry a lab keeps its trials' cameras in. */
53
+ export const CameraRegistryContext = createContext<CameraRegistry | null>(null);
54
+
55
+ /** Where a camera publishes itself. The host binds the key — a trial provides
56
+ * one that registers under its own id — so the camera needs no name. */
57
+ export const CameraPublishContext = createContext<((camera: CameraView) => () => void) | null>(
58
+ null,
59
+ );
60
+
61
+ /** Publish `camera` to the host in scope for as long as the caller is mounted,
62
+ * and again each time its input scope becomes the active one — a registry
63
+ * key stacks newest-live, so the camera last used is the one chrome reaches.
64
+ * Call it inside the camera's scope. */
65
+ export function usePublishCamera(camera: CameraView): void {
66
+ const publish = useContext(CameraPublishContext);
67
+ const actions = useActionsRegistry();
68
+ useEffect(() => {
69
+ if (!publish) return;
70
+ let release = publish(camera);
71
+ if (!actions) return release;
72
+ let was = actions.isActive();
73
+ const off = actions.subscribe(() => {
74
+ const now = actions.isActive();
75
+ if (now && !was) {
76
+ release();
77
+ release = publish(camera);
78
+ }
79
+ was = now;
80
+ });
81
+ return () => {
82
+ off();
83
+ release();
84
+ };
85
+ }, [publish, camera, actions]);
86
+ }
@@ -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
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * labkit's camera as a weasel `View`, so core's viewport actions drive it.
3
+ *
4
+ * labkit places a world point on screen at
5
+ * `originPx + pan + zoom * (x, yDir * y)`. A weasel `View` places one at
6
+ * `(p - (x, y)) * scale`. Over *frame-local* coordinates — the world with `y`
7
+ * multiplied by `yDir` — the two are the same affine map, which is all a pan
8
+ * or a zoom about a screen point needs.
9
+ */
10
+ import type { View } from '@weasel-js/core';
11
+ import type { Point, ViewTransform } from '../instrument/types';
12
+ import { normalize2DView } from '../state/view';
13
+ import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
14
+
15
+ /** The weasel `View` equal to `view` under `frame`. */
16
+ export function toCameraView(view: ViewTransform, frame: WorldFrame = DEFAULT_FRAME): View {
17
+ const v = normalize2DView(view);
18
+ return {
19
+ x: -(frame.originPx.x + v.pan.x) / v.zoom,
20
+ y: -(frame.originPx.y + v.pan.y) / v.zoom,
21
+ scale: { x: v.zoom, y: v.zoom },
22
+ };
23
+ }
24
+
25
+ /** The labkit camera equal to weasel `view` under `frame`. */
26
+ export function fromCameraView(view: View, frame: WorldFrame = DEFAULT_FRAME): ViewTransform {
27
+ const zoom = view.scale.x;
28
+ return {
29
+ zoom,
30
+ pan: { x: -view.x * zoom - frame.originPx.x, y: -view.y * zoom - frame.originPx.y },
31
+ };
32
+ }
33
+
34
+ /** A frame-local point in the instrument's world, and back — the map is its
35
+ * own inverse. */
36
+ export function frameLocalToWorld(p: Point, frame: WorldFrame = DEFAULT_FRAME): Point {
37
+ return { x: p.x, y: p.y * frame.yDir };
38
+ }
39
+
40
+ /**
41
+ * `next` with its zoom held to [`min`, `max`], about the screen point the step
42
+ * from `prev` was anchored on, so a zoom that hits a bound stays anchored where
43
+ * the pointer is. A pan (equal scales) passes through.
44
+ */
45
+ export function clampZoomAbout(prev: View, next: View, min: number, max: number): View {
46
+ const s0 = prev.scale.x;
47
+ const s1 = next.scale.x;
48
+ const clamped = Math.min(max, Math.max(min, s1));
49
+ if (clamped === s1) return next;
50
+ if (s0 === s1) return { ...next, scale: { x: clamped, y: clamped } };
51
+ // The screen point both views agree on: p / s0 + x0 = p / s1 + x1.
52
+ const inv = 1 / s0 - 1 / s1;
53
+ const px = (next.x - prev.x) / inv;
54
+ const py = (next.y - prev.y) / inv;
55
+ // The same point under the clamped scale.
56
+ return {
57
+ x: prev.x + px / s0 - px / clamped,
58
+ y: prev.y + py / s0 - py / clamped,
59
+ scale: { x: clamped, y: clamped },
60
+ };
61
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CameraView } from './CameraInput';
3
+ import { zoomCameraTo } from './cameraZoom';
4
+
5
+ function fakeCamera(
6
+ host: { width: number; height: number } | null,
7
+ range = { min: 0.1, max: 32 },
8
+ ): CameraView & {
9
+ view: { x: number; y: number; scale: { x: number; y: number } };
10
+ } {
11
+ const cam = {
12
+ view: { x: 0, y: 0, scale: { x: 1, y: 1 } },
13
+ get: () => cam.view,
14
+ set: (v: { x: number; y: number; scale: { x: number; y: number } }) => {
15
+ const s = Math.min(range.max, Math.max(range.min, v.scale.x));
16
+ cam.view = { ...v, scale: { x: s, y: s } };
17
+ },
18
+ hostSize: () => host,
19
+ zoomRange: () => range,
20
+ };
21
+ return cam;
22
+ }
23
+
24
+ describe('zoomCameraTo', () => {
25
+ it('keeps the middle of the view where it was', () => {
26
+ const cam = fakeCamera({ width: 200, height: 100 });
27
+ zoomCameraTo(cam, 2);
28
+ // The world point under the screen middle (100, 50) is still under it.
29
+ expect(cam.view.x + 100 / cam.view.scale.x).toBeCloseTo(100);
30
+ expect(cam.view.y + 50 / cam.view.scale.y).toBeCloseTo(50);
31
+ expect(cam.view.scale.x).toBe(2);
32
+ });
33
+
34
+ it('anchors at the origin when the camera cannot measure its host', () => {
35
+ const cam = fakeCamera(null);
36
+ zoomCameraTo(cam, 2);
37
+ expect(cam.view).toEqual({ x: 0, y: 0, scale: { x: 2, y: 2 } });
38
+ });
39
+
40
+ it('stops at the edge of the camera range', () => {
41
+ const cam = fakeCamera({ width: 200, height: 100 }, { min: 0.5, max: 1.2 });
42
+ zoomCameraTo(cam, 5);
43
+ expect(cam.view.scale.x).toBe(1.2);
44
+ expect(cam.view.x + 100 / cam.view.scale.x).toBeCloseTo(100);
45
+ });
46
+ });
@@ -0,0 +1,24 @@
1
+ import type { View } from '@weasel-js/core';
2
+ import type { CameraView } from './CameraInput';
3
+
4
+ /** `view` at `scale`, about the screen point `at`. */
5
+ export function atScale(view: View, scale: number, at: { x: number; y: number }): View {
6
+ return {
7
+ x: view.x + at.x / view.scale.x - at.x / scale,
8
+ y: view.y + at.y / view.scale.y - at.y / scale,
9
+ scale: { x: scale, y: scale },
10
+ };
11
+ }
12
+
13
+ /** The middle of the camera's host, in its own screen space; the origin when it cannot measure. */
14
+ export function viewMiddle(camera: CameraView): { x: number; y: number } {
15
+ const size = camera.hostSize?.();
16
+ return size ? { x: size.width / 2, y: size.height / 2 } : { x: 0, y: 0 };
17
+ }
18
+
19
+ /** Zoom `camera` to `zoom` about the middle of its view, within its range. */
20
+ export function zoomCameraTo(camera: CameraView, zoom: number): void {
21
+ const { min, max } = camera.zoomRange();
22
+ const scale = Math.min(max, Math.max(min, zoom));
23
+ camera.set(atScale(camera.get(), scale, viewMiddle(camera)));
24
+ }
@@ -1,3 +1,17 @@
1
+ export type {
2
+ CameraContextValue,
3
+ CameraInputProps,
4
+ CameraView,
5
+ CameraViewOptions,
6
+ ZoomRange,
7
+ } from './CameraInput';
8
+ export {
9
+ CameraContext,
10
+ CameraInput,
11
+ CameraScope,
12
+ STAGE_VIEW_ID,
13
+ useCameraView,
14
+ } from './CameraInput';
1
15
  export type { CameraWheelSlot } from './CameraWheelContext';
2
16
  export { CameraWheelContext } from './CameraWheelContext';
3
17
  export type { CanvasStackProps } from './CanvasStack';
@@ -6,11 +20,18 @@ export type { CanvasStackContextValue, CanvasStackSurface } from './CanvasStackC
6
20
  export { CanvasStackContext } from './CanvasStackContext';
7
21
  export type { ZoomAtOptions } from './camera';
8
22
  export { centerOn, zoomAt } from './camera';
23
+ export type { CameraRegistry } from './cameraRegistry';
24
+ export {
25
+ CameraPublishContext,
26
+ CameraRegistryContext,
27
+ createCameraRegistry,
28
+ usePublishCamera,
29
+ } from './cameraRegistry';
30
+ export { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
9
31
  export { screenToWorld, worldToScreen } from './canvasCoords';
32
+ export { LinkedCursor } from './LinkedCursor';
10
33
  export type { StageProps } from './Stage';
11
34
  export { fitStage, Stage } from './Stage';
12
35
  export type { CanvasLayerDescriptor } from './useLayerScheduler';
13
- export type { PanZoomHandlers, UsePanZoomOptions } from './usePanZoom';
14
- export { usePanZoom } from './usePanZoom';
15
36
  export type { ViewportSize, WorldFrame, WorldSpec } from './worldSpec';
16
37
  export { applyCamera, DEFAULT_FRAME, resolveFrame } from './worldSpec';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Two cameras under one host scope — what a trial gives every canvas inside it,
3
+ * and what a forge index page puts several of in. Each must answer only for
4
+ * itself: the newest must not take the others' gestures.
5
+ */
6
+
7
+ import { act, render } from '@testing-library/react';
8
+ import { WeaselProvider } from '@weasel-js/core';
9
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
10
+ import type { ViewTransform } from '../instrument/types';
11
+ import { CanvasStack } from './CanvasStack';
12
+
13
+ beforeAll(() => {
14
+ HTMLCanvasElement.prototype.getContext = vi.fn(
15
+ () => null,
16
+ ) as unknown as HTMLCanvasElement['getContext'];
17
+ const proto = HTMLElement.prototype as unknown as Record<string, unknown>;
18
+ proto.setPointerCapture = vi.fn();
19
+ proto.releasePointerCapture = vi.fn();
20
+ proto.hasPointerCapture = vi.fn(() => true);
21
+ });
22
+
23
+ function pointer(el: Element, type: string, x: number, y: number, buttons = 1) {
24
+ el.dispatchEvent(
25
+ new PointerEvent(type, {
26
+ bubbles: true,
27
+ cancelable: true,
28
+ pointerId: 1,
29
+ clientX: x,
30
+ clientY: y,
31
+ button: 0,
32
+ buttons,
33
+ }),
34
+ );
35
+ }
36
+
37
+ const VIEW: ViewTransform = { zoom: 1, pan: { x: 0, y: 0 } };
38
+
39
+ function mountTwo() {
40
+ const views = [vi.fn(), vi.fn()];
41
+ const taps = [vi.fn(), vi.fn()];
42
+ const { container } = render(
43
+ <WeaselProvider isolate>
44
+ {(['a', 'b'] as const).map((id, i) => (
45
+ <CanvasStack
46
+ key={id}
47
+ layers={[]}
48
+ view={VIEW}
49
+ onViewChange={views[i]}
50
+ onHitTest={taps[i]}
51
+ width={100}
52
+ height={100}
53
+ />
54
+ ))}
55
+ </WeaselProvider>,
56
+ );
57
+ const hosts = [...container.querySelectorAll<HTMLElement>('.lk-canvas-stack')];
58
+ return { hosts, views, taps };
59
+ }
60
+
61
+ describe('cameras sharing a host scope', () => {
62
+ it('pans the camera the drag began on, not the newest', () => {
63
+ const { hosts, views } = mountTwo();
64
+ act(() => {
65
+ pointer(hosts[0]!, 'pointerdown', 50, 50);
66
+ pointer(hosts[0]!, 'pointermove', 60, 55);
67
+ pointer(hosts[0]!, 'pointermove', 80, 70);
68
+ pointer(hosts[0]!, 'pointerup', 80, 70, 0);
69
+ });
70
+ expect(views[0]).toHaveBeenCalled();
71
+ expect(views[1]).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('reports a tap to the canvas it landed on', () => {
75
+ const { hosts, taps } = mountTwo();
76
+ act(() => {
77
+ pointer(hosts[0]!, 'pointerdown', 50, 50);
78
+ pointer(hosts[0]!, 'pointerup', 50, 50, 0);
79
+ });
80
+ expect(taps[0]).toHaveBeenCalledTimes(1);
81
+ expect(taps[1]).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('still drives the newest camera from its own canvas', () => {
85
+ const { hosts, views } = mountTwo();
86
+ act(() => {
87
+ pointer(hosts[1]!, 'pointerdown', 50, 50);
88
+ pointer(hosts[1]!, 'pointermove', 80, 70);
89
+ pointer(hosts[1]!, 'pointerup', 80, 70, 0);
90
+ });
91
+ expect(views[1]).toHaveBeenCalled();
92
+ expect(views[0]).not.toHaveBeenCalled();
93
+ });
94
+ });
@@ -72,6 +72,7 @@ export default meta;
72
72
  type Story = StoryObj<typeof Lab>;
73
73
 
74
74
  export const EveryRegion: Story = {
75
+ tags: ['gallery'],
75
76
  args: {
76
77
  instruments: [FullInstrument],
77
78
  defaultInstrument: 'Every Region',
@@ -4,11 +4,4 @@
4
4
  padding: var(--wzl-space-xs);
5
5
  border-right: var(--wzl-border-w) solid var(--wzl-line-subtle);
6
6
  background: var(--wzl-surface-sunken);
7
-
8
- // labkit's `:where(button)` default sets an explicit height, and because
9
- // :where() carries no specificity it also wins over ToolButton, which sizes
10
- // itself from padding.
11
- button {
12
- height: auto;
13
- }
14
7
  }