@weasel-js/labkit 1.4.4 → 1.5.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 (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. package/src/theme/interstellar.tokens.json +0 -61
@@ -29,7 +29,7 @@ export interface AnnotationStatus {
29
29
  id: string;
30
30
  label: string;
31
31
  /** What a mark in this status is drawn in. Omitted, it takes the default
32
- * mark colour — a status is allowed to be a label and nothing more. */
32
+ * mark color — a status is allowed to be a label and nothing more. */
33
33
  color?: string;
34
34
  }
35
35
 
@@ -150,9 +150,10 @@ export interface AnnotationTarget extends AnnotationTargetInfo {
150
150
  /** Where an instrument keeps its own marks. Declaring this means labkit never
151
151
  * writes its trial slot — for an instrument whose marks belong in a format it
152
152
  * already owns. Both halves are called outside React; `save` is already
153
- * debounced by the time it arrives. */
153
+ * debounced by the time it arrives. A trial shows an empty body until `load`
154
+ * settles. */
154
155
  export interface AnnotationStorage {
155
- load: () => SerializedAnnotations | null | undefined;
156
+ load: () => Promise<SerializedAnnotations | null | undefined>;
156
157
  save: (doc: SerializedAnnotations) => void;
157
158
  }
158
159
 
@@ -163,6 +164,8 @@ export interface AnnotationsCapability<TS = unknown, TC = unknown> {
163
164
  * is called once per trial, and its targets are that trial's own. */
164
165
  targets: (state: TS, config: TC, trial: TrialInfo) => readonly AnnotationTarget[];
165
166
  meaning?: AnnotationMeaning;
167
+ /** An instrument replacing this one under a live trial must pass the same
168
+ * object: the trial keeps the marks it loaded from the first. */
166
169
  storage?: AnnotationStorage;
167
170
  /** Fires after every finished export, labkit's own chrome included. A
168
171
  * notification, not an interception: a host wanting its own flow calls
@@ -0,0 +1,12 @@
1
+ import { createContext } from 'react';
2
+
3
+ /** Where a trial's camera takes a wheel event from outside its own element. */
4
+ export type CameraWheelSlot = { current: ((e: WheelEvent) => void) | null };
5
+
6
+ /**
7
+ * The trial's camera, for input that lands outside it. An annotation target's
8
+ * input box is portalled into the surface container, so a wheel over a mark
9
+ * never bubbles through the stage or canvas stack it sits on; the box hands
10
+ * the event here instead.
11
+ */
12
+ export const CameraWheelContext = createContext<CameraWheelSlot | null>(null);
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import { useState } from 'react';
3
3
  import { ScaleIndicator } from '../primitives/ScaleIndicator';
4
4
  import { CanvasStack } from './CanvasStack';
@@ -106,3 +106,28 @@ describe('<CanvasStack>', () => {
106
106
  vi.restoreAllMocks();
107
107
  });
108
108
  });
109
+
110
+ describe('<CanvasStack> zoom invariant', () => {
111
+ it('hit-tests a view that arrived with zoom 0 to a finite world point', () => {
112
+ const rect = { left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 };
113
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
114
+ rect as unknown as DOMRect,
115
+ );
116
+ const onHitTest = vi.fn();
117
+ const { container } = render(
118
+ <CanvasStack
119
+ layers={makeLayers(1)}
120
+ view={{ zoom: 0, pan: { x: 0, y: 0 } }}
121
+ onViewChange={vi.fn()}
122
+ onHitTest={onHitTest}
123
+ />,
124
+ );
125
+ const host = container.querySelector('.lk-canvas-stack');
126
+ if (!host) throw new Error('no stack host');
127
+ fireEvent.pointerDown(host, { button: 0, pointerId: 1, clientX: 500, clientY: 200 });
128
+ fireEvent.pointerUp(host, { pointerId: 1, clientX: 500, clientY: 200 });
129
+ const world = onHitTest.mock.calls.at(-1)?.[0] as { x: number; y: number };
130
+ expect(Number.isFinite(world.x) && Number.isFinite(world.y)).toBe(true);
131
+ vi.restoreAllMocks();
132
+ });
133
+ });
@@ -1,5 +1,15 @@
1
- import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
1
+ import {
2
+ type CSSProperties,
3
+ type ReactNode,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
2
10
  import type { Point, ViewTransform } from '../instrument/types';
11
+ import { normalize2DView } from '../state/view';
12
+ import { CameraWheelContext } from './CameraWheelContext';
3
13
  import { CanvasStackContext } from './CanvasStackContext';
4
14
  import { screenToWorld } from './canvasCoords';
5
15
  import { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';
@@ -31,7 +41,7 @@ export interface CanvasStackProps {
31
41
  * Handles sizing, device pixel ratio, and pan/zoom. */
32
42
  export function CanvasStack({
33
43
  layers,
34
- view,
44
+ view: viewProp,
35
45
  onViewChange,
36
46
  worldSpec,
37
47
  minZoom,
@@ -43,6 +53,7 @@ export function CanvasStack({
43
53
  onHitTest,
44
54
  children,
45
55
  }: CanvasStackProps) {
56
+ const view = normalize2DView(viewProp);
46
57
  const containerRef = useRef<HTMLDivElement | null>(null);
47
58
  const canvasMap = useRef<Map<string, HTMLCanvasElement>>(new Map());
48
59
  const [size, setSize] = useState({ width: 0, height: 0, dpr: 1 });
@@ -88,7 +99,24 @@ export function CanvasStack({
88
99
  onHitTest(screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, view, frame));
89
100
  };
90
101
 
91
- const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame, onTap });
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
120
  useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });
93
121
 
94
122
  const ctxValue = useMemo(
@@ -116,7 +144,6 @@ export function CanvasStack({
116
144
  ref={containerRef}
117
145
  className={className ? `lk-canvas-stack ${className}` : 'lk-canvas-stack'}
118
146
  style={containerStyle}
119
- onWheel={handlers.onWheel}
120
147
  onPointerDown={handlers.onPointerDown}
121
148
  >
122
149
  {layers.map((layer) => (
@@ -0,0 +1,18 @@
1
+ .lk-stage {
2
+ position: relative;
3
+ width: 100%;
4
+ height: 100%;
5
+ overflow: hidden;
6
+ background: var(--wzl-surface-sunken);
7
+ touch-action: none;
8
+ }
9
+
10
+ .lk-stage__content {
11
+ position: absolute;
12
+ top: 0;
13
+ left: 0;
14
+ width: var(--lk-stage-w);
15
+ height: var(--lk-stage-h);
16
+ transform-origin: 0 0;
17
+ transform: translate(var(--lk-stage-x), var(--lk-stage-y)) scale(var(--lk-stage-zoom));
18
+ }
@@ -0,0 +1,186 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
4
+ import { defineInstrument } from '../instrument/defineInstrument';
5
+ import type { ViewTransform } from '../instrument/types';
6
+ import { Lab } from '../lab/Lab';
7
+ import { SurfaceContext } from '../surface/SurfaceContext';
8
+ import type { SurfaceHandle } from '../surface/useTiledSurface';
9
+ import { CameraWheelContext, type CameraWheelSlot } from './CameraWheelContext';
10
+ import { fitStage, Stage } from './Stage';
11
+
12
+ beforeAll(() => {
13
+ HTMLCanvasElement.prototype.getContext = vi.fn(
14
+ () => null,
15
+ ) as unknown as HTMLCanvasElement['getContext'];
16
+ });
17
+
18
+ function content(container: HTMLElement): HTMLElement {
19
+ const el = container.querySelector<HTMLElement>('.lk-stage__content');
20
+ if (!el) throw new Error('no stage content');
21
+ return el;
22
+ }
23
+
24
+ const zoomOf = (el: HTMLElement): number => Number(el.style.getPropertyValue('--lk-stage-zoom'));
25
+
26
+ describe('fitStage', () => {
27
+ it('shrinks content that does not fit, and centers it on the short axis', () => {
28
+ expect(fitStage({ width: 400, height: 200 }, { width: 200, height: 200 })).toEqual({
29
+ zoom: 0.5,
30
+ pan: { x: 0, y: 50 },
31
+ });
32
+ });
33
+
34
+ it('never enlarges content past its own size', () => {
35
+ expect(fitStage({ width: 100, height: 50 }, { width: 300, height: 150 })).toEqual({
36
+ zoom: 1,
37
+ pan: { x: 100, y: 50 },
38
+ });
39
+ });
40
+
41
+ it('answers the identity for an unmeasured viewport', () => {
42
+ expect(fitStage({ width: 100, height: 50 }, { width: 0, height: 0 })).toEqual({
43
+ zoom: 1,
44
+ pan: { x: 0, y: 0 },
45
+ });
46
+ });
47
+ });
48
+
49
+ describe('<Stage>', () => {
50
+ it('draws its content through the camera', () => {
51
+ const { container } = render(
52
+ <Stage
53
+ size={{ width: 300, height: 100 }}
54
+ view={{ zoom: 2, pan: { x: 10, y: 20 } }}
55
+ onViewChange={() => {}}
56
+ >
57
+ <div data-testid="art" />
58
+ </Stage>,
59
+ );
60
+ const el = content(container);
61
+ expect(el).toContainElement(screen.getByTestId('art'));
62
+ expect(zoomOf(el)).toBe(2);
63
+ expect(el.style.getPropertyValue('--lk-stage-x')).toBe('10px');
64
+ expect(el.style.getPropertyValue('--lk-stage-y')).toBe('20px');
65
+ expect(el.style.getPropertyValue('--lk-stage-w')).toBe('300px');
66
+ expect(el.style.getPropertyValue('--lk-stage-h')).toBe('100px');
67
+ });
68
+
69
+ it('zooms on the wheel', () => {
70
+ function Host() {
71
+ const [view, setView] = useState<ViewTransform>({ zoom: 1, pan: { x: 0, y: 0 } });
72
+ return (
73
+ <Stage size={{ width: 100, height: 100 }} view={view} onViewChange={setView}>
74
+ <div />
75
+ </Stage>
76
+ );
77
+ }
78
+ const { container } = render(<Host />);
79
+ const host = container.querySelector('.lk-stage');
80
+ if (!host) throw new Error('no stage');
81
+ fireEvent.wheel(host, { deltaY: -200, clientX: 0, clientY: 0 });
82
+ expect(zoomOf(content(container))).toBeGreaterThan(1);
83
+ });
84
+
85
+ it('zooms on a wheel handed to its camera from outside the stage', () => {
86
+ // What an annotation target's input box does: it is portalled out of the
87
+ // stage, so its wheel never bubbles here.
88
+ const slot: CameraWheelSlot = { current: null };
89
+ function Host() {
90
+ const [view, setView] = useState<ViewTransform>({ zoom: 1, pan: { x: 0, y: 0 } });
91
+ return (
92
+ <CameraWheelContext.Provider value={slot}>
93
+ <Stage size={{ width: 100, height: 100 }} view={view} onViewChange={setView}>
94
+ <div />
95
+ </Stage>
96
+ </CameraWheelContext.Provider>
97
+ );
98
+ }
99
+ const { container } = render(<Host />);
100
+ act(() => {
101
+ slot.current?.(new WheelEvent('wheel', { deltaY: -200, cancelable: true }));
102
+ });
103
+ expect(zoomOf(content(container))).toBeGreaterThan(1);
104
+ });
105
+
106
+ it('listens for the wheel actively, so it can keep the page from scrolling', () => {
107
+ // A proxy: jsdom does not enforce passive listeners, so a preventDefault in
108
+ // a passive one succeeds here and fails in a browser. React registers its
109
+ // wheel listeners passive; asserting the registration is what can fail.
110
+ const add = vi.spyOn(HTMLElement.prototype, 'addEventListener');
111
+ const { container } = render(
112
+ <Stage
113
+ size={{ width: 100, height: 100 }}
114
+ view={{ zoom: 1, pan: { x: 0, y: 0 } }}
115
+ onViewChange={() => {}}
116
+ >
117
+ <div />
118
+ </Stage>,
119
+ );
120
+ const host = container.querySelector('.lk-stage');
121
+ const wheel = add.mock.calls.filter(
122
+ (call, i) => call[0] === 'wheel' && add.mock.contexts[i] === host,
123
+ );
124
+ expect(wheel.map((call) => call[2])).toContainEqual(
125
+ expect.objectContaining({ passive: false }),
126
+ );
127
+ add.mockRestore();
128
+ });
129
+
130
+ it('tells the surface its tiles moved when the camera does', () => {
131
+ // A transform moves a tile without resizing anything, which is the one
132
+ // kind of move a ResizeObserver never reports.
133
+ const invalidateRects = vi.fn();
134
+ const surface = { invalidateRects } as unknown as SurfaceHandle;
135
+ const at = (view: ViewTransform) => (
136
+ <SurfaceContext.Provider value={surface}>
137
+ <Stage size={{ width: 100, height: 100 }} view={view} onViewChange={() => {}}>
138
+ <div />
139
+ </Stage>
140
+ </SurfaceContext.Provider>
141
+ );
142
+ const { rerender } = render(at({ zoom: 1, pan: { x: 0, y: 0 } }));
143
+ invalidateRects.mockClear();
144
+ rerender(at({ zoom: 1.5, pan: { x: 0, y: 0 } }));
145
+ expect(invalidateRects).toHaveBeenCalled();
146
+ });
147
+ });
148
+
149
+ const staged = defineInstrument<Record<string, never>, Record<string, never>>({
150
+ name: 'Staged',
151
+ defaultConfig: () => ({}),
152
+ initialState: () => ({}),
153
+ render: () => <div data-testid="art" />,
154
+ stage: { size: { width: 240, height: 160 }, initialView: { zoom: 1, pan: { x: 0, y: 0 } } },
155
+ });
156
+
157
+ const plain = defineInstrument<Record<string, never>, Record<string, never>>({
158
+ name: 'Plain',
159
+ defaultConfig: () => ({}),
160
+ initialState: () => ({}),
161
+ render: () => <div data-testid="art" />,
162
+ });
163
+
164
+ describe('an instrument that declares a stage', () => {
165
+ it('renders its DOM on the stage', () => {
166
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
167
+ expect(content(container)).toContainElement(screen.getByTestId('art'));
168
+ });
169
+
170
+ it('gets the zoom controls, and they move the stage', () => {
171
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
172
+ act(() => {
173
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
174
+ });
175
+ expect(zoomOf(content(container))).toBeCloseTo(1.25);
176
+ act(() => {
177
+ fireEvent.click(screen.getByRole('button', { name: 'Actual size' }));
178
+ });
179
+ expect(zoomOf(content(container))).toBe(1);
180
+ });
181
+
182
+ it('is what earns them: DOM content without one gets none', () => {
183
+ render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
184
+ expect(screen.queryByRole('button', { name: 'Zoom in' })).toBeNull();
185
+ });
186
+ });
@@ -0,0 +1,134 @@
1
+ import { type CSSProperties, type ReactNode, useContext, useEffect, useRef } from 'react';
2
+ import type { ViewTransform } from '../instrument/types';
3
+ import { normalize2DView } from '../state/view';
4
+ import { useSurfaceOptional } from '../surface/useSurfaceTile';
5
+ import { CameraWheelContext } from './CameraWheelContext';
6
+ import { usePanZoom } from './usePanZoom';
7
+ import type { ViewportSize } from './worldSpec';
8
+
9
+ /** Props for `<Stage>`. */
10
+ export interface StageProps {
11
+ /** The content's own size in CSS pixels, at zoom 1. */
12
+ size: ViewportSize;
13
+ view: ViewTransform;
14
+ onViewChange: (v: ViewTransform) => void;
15
+ minZoom?: number;
16
+ maxZoom?: number;
17
+ /** Fired whenever the stage's viewport is measured at a non-zero size. */
18
+ onResize?: (viewport: ViewportSize) => void;
19
+ /** Handed the viewport element, for a consumer tracking the pointer over it. */
20
+ hostRef?: { current: HTMLDivElement | null };
21
+ /** Drawn over the content in viewport pixels, outside the camera. */
22
+ overlay?: ReactNode;
23
+ children?: ReactNode;
24
+ }
25
+
26
+ /**
27
+ * The camera a stage opens at: the content centered in its viewport, shrunk to
28
+ * fit if it has to be and never enlarged past its own size.
29
+ */
30
+ export function fitStage(size: ViewportSize, viewport: ViewportSize): ViewTransform {
31
+ if (!(size.width > 0 && size.height > 0 && viewport.width > 0 && viewport.height > 0)) {
32
+ return { zoom: 1, pan: { x: 0, y: 0 } };
33
+ }
34
+ const zoom = Math.min(1, viewport.width / size.width, viewport.height / size.height);
35
+ return {
36
+ zoom,
37
+ pan: {
38
+ x: (viewport.width - size.width * zoom) / 2,
39
+ y: (viewport.height - size.height * zoom) / 2,
40
+ },
41
+ };
42
+ }
43
+
44
+ /**
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
47
+ * its own size and transformed, so everything inside keeps its layout and a
48
+ * measurement of any element in it reports where it is actually drawn.
49
+ */
50
+ export function Stage({
51
+ size,
52
+ view: viewProp,
53
+ onViewChange,
54
+ minZoom,
55
+ maxZoom,
56
+ onResize,
57
+ hostRef,
58
+ overlay,
59
+ children,
60
+ }: StageProps) {
61
+ const view = normalize2DView(viewProp);
62
+ const host = useRef<HTMLDivElement | null>(null);
63
+ 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]);
82
+
83
+ const onResizeRef = useRef(onResize);
84
+ onResizeRef.current = onResize;
85
+ useEffect(() => {
86
+ const el = host.current;
87
+ if (!el) return;
88
+ const measure = () => {
89
+ const rect = el.getBoundingClientRect();
90
+ if (rect.width > 0 && rect.height > 0) {
91
+ onResizeRef.current?.({ width: rect.width, height: rect.height });
92
+ }
93
+ };
94
+ measure();
95
+ if (typeof ResizeObserver === 'undefined') return;
96
+ const ro = new ResizeObserver(measure);
97
+ ro.observe(el);
98
+ return () => ro.disconnect();
99
+ }, []);
100
+
101
+ // A transform moves every tile inside the content without resizing anything,
102
+ // and a ResizeObserver reports only resizes.
103
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the camera is the trigger, not an input — the effect re-measures whenever it moves.
104
+ useEffect(() => {
105
+ surface?.invalidateRects();
106
+ }, [surface, view.zoom, view.pan.x, view.pan.y]);
107
+
108
+ // The camera changes every frame of a pan, and a transform has nowhere to
109
+ // live but the element's own style: set as custom properties the stylesheet
110
+ // reads.
111
+ const camera = {
112
+ ['--lk-stage-x' as string]: `${view.pan.x}px`,
113
+ ['--lk-stage-y' as string]: `${view.pan.y}px`,
114
+ ['--lk-stage-zoom' as string]: String(view.zoom),
115
+ ['--lk-stage-w' as string]: `${size.width}px`,
116
+ ['--lk-stage-h' as string]: `${size.height}px`,
117
+ } as CSSProperties;
118
+
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>
133
+ );
134
+ }
@@ -41,11 +41,18 @@ describe('zoomAt', () => {
41
41
  expect(screenToWorld(at, next).x).toBeCloseTo(before.x, 10);
42
42
  });
43
43
 
44
- it('treats a non-finite zoom as 1 rather than propagating NaN', () => {
44
+ it('keeps a view that arrived with a non-finite zoom finite', () => {
45
45
  const next = zoomAt({ zoom: Number.NaN, pan: { x: 0, y: 0 } }, 2, { x: 10, y: 10 });
46
- expect(next.zoom).toBe(2);
46
+ expect(next.zoom > 0 && Number.isFinite(next.zoom)).toBe(true);
47
47
  expect(Number.isFinite(next.pan.x)).toBe(true);
48
48
  });
49
+
50
+ it('keeps the zoom positive when a factor and bound would drive it to 0', () => {
51
+ const at = { x: 10, y: 10 };
52
+ const next = zoomAt(view, 0, at, { min: 0 });
53
+ expect(next.zoom > 0 && Number.isFinite(next.zoom)).toBe(true);
54
+ expect(Number.isFinite(screenToWorld(at, next).x)).toBe(true);
55
+ });
49
56
  });
50
57
 
51
58
  describe('centerOn', () => {
@@ -76,3 +83,11 @@ describe('centerOn', () => {
76
83
  expect(centerOn({ x: 0, y: 0 }, 6, { width: 10, height: 10 }, DEFAULT_FRAME).zoom).toBe(6);
77
84
  });
78
85
  });
86
+
87
+ describe('centerOn zoom invariant', () => {
88
+ it('builds a camera with a positive zoom from a zoom of 0', () => {
89
+ const v = centerOn({ x: 1, y: 1 }, 0, { width: 100, height: 100 });
90
+ expect(v.zoom > 0 && Number.isFinite(v.zoom)).toBe(true);
91
+ expect(Number.isFinite(screenToWorld({ x: 5, y: 5 }, v).x)).toBe(true);
92
+ });
93
+ });
@@ -1,3 +1,4 @@
1
+ import { normalizeZoom } from '@weasel-js/core';
1
2
  import type { Point, ViewTransform } from '../instrument/types';
2
3
  import { DEFAULT_FRAME, type ViewportSize, type WorldFrame } from './worldSpec';
3
4
 
@@ -10,10 +11,6 @@ export interface ZoomAtOptions {
10
11
  max?: number;
11
12
  }
12
13
 
13
- function usableZoom(zoom: number): number {
14
- return Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
15
- }
16
-
17
14
  /**
18
15
  * Zoom a view by `factor` about a fixed point, so whatever world coordinate
19
16
  * `at` was over stays under it.
@@ -32,8 +29,10 @@ export function zoomAt(
32
29
  // drifts by `(1 - ratio) * originPx` per step on any frame that moves it.
33
30
  const anchorX = at.x - frame.originPx.x;
34
31
  const anchorY = at.y - frame.originPx.y;
35
- const current = usableZoom(view.zoom);
36
- const zoom = Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? 0, current * factor));
32
+ const current = normalizeZoom(view.zoom);
33
+ const zoom = normalizeZoom(
34
+ Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? 0, current * factor)),
35
+ );
37
36
  const ratio = zoom / current;
38
37
  return {
39
38
  zoom,
@@ -52,11 +51,12 @@ export function centerOn(
52
51
  size: ViewportSize,
53
52
  frame: WorldFrame = DEFAULT_FRAME,
54
53
  ): ViewTransform {
54
+ const z = normalizeZoom(zoom);
55
55
  return {
56
- zoom,
56
+ zoom: z,
57
57
  pan: {
58
- x: size.width / 2 - frame.originPx.x - world.x * zoom,
59
- y: size.height / 2 - frame.originPx.y - world.y * zoom * frame.yDir,
58
+ x: size.width / 2 - frame.originPx.x - world.x * z,
59
+ y: size.height / 2 - frame.originPx.y - world.y * z * frame.yDir,
60
60
  },
61
61
  };
62
62
  }
@@ -1,3 +1,5 @@
1
+ export type { CameraWheelSlot } from './CameraWheelContext';
2
+ export { CameraWheelContext } from './CameraWheelContext';
1
3
  export type { CanvasStackProps } from './CanvasStack';
2
4
  export { CanvasStack } from './CanvasStack';
3
5
  export type { CanvasStackContextValue, CanvasStackSurface } from './CanvasStackContext';
@@ -5,6 +7,8 @@ export { CanvasStackContext } from './CanvasStackContext';
5
7
  export type { ZoomAtOptions } from './camera';
6
8
  export { centerOn, zoomAt } from './camera';
7
9
  export { screenToWorld, worldToScreen } from './canvasCoords';
10
+ export type { StageProps } from './Stage';
11
+ export { fitStage, Stage } from './Stage';
8
12
  export type { CanvasLayerDescriptor } from './useLayerScheduler';
9
13
  export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './useOrbit';
10
14
  export {
@@ -37,6 +37,34 @@ function setup(initialView: ViewTransform, options?: Partial<UsePanZoomOptions>)
37
37
  }
38
38
 
39
39
  describe('usePanZoom', () => {
40
+ it('stacks two wheel events that land before a re-render', () => {
41
+ const { wheel } = setup({ zoom: 1, pan: { x: 0, y: 0 } });
42
+ const once = wheel(-100).zoom;
43
+
44
+ const views: ViewTransform[] = [];
45
+ const { result } = renderHook(() =>
46
+ usePanZoom({ view: { zoom: 1, pan: { x: 0, y: 0 } }, onViewChange: (v) => views.push(v) }),
47
+ );
48
+ act(() => {
49
+ result.current.onWheel(wheelEvent(-100));
50
+ result.current.onWheel(wheelEvent(-100));
51
+ });
52
+ expect(views[1].zoom).toBeCloseTo(once * once, 10);
53
+ });
54
+
55
+ it('lets the view prop win at the next render', () => {
56
+ const views: ViewTransform[] = [];
57
+ const { result, rerender } = renderHook(
58
+ (props: { view: ViewTransform }) =>
59
+ usePanZoom({ view: props.view, onViewChange: (v) => views.push(v) }),
60
+ { initialProps: { view: { zoom: 1, pan: { x: 0, y: 0 } } } },
61
+ );
62
+ act(() => result.current.onWheel(wheelEvent(-100)));
63
+ rerender({ view: { zoom: 2, pan: { x: 0, y: 0 } } });
64
+ act(() => result.current.onWheel(wheelEvent(0)));
65
+ expect(views[1].zoom).toBeCloseTo(2, 10);
66
+ });
67
+
40
68
  it('does not collapse a canvas opening far outside the default range on the first wheel event', () => {
41
69
  const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } });
42
70
  const next = wheel(-1); // zoom in slightly