@weasel-js/labkit 1.4.3 → 1.5.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 (229) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-QAGAHJKZ.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-ISCOFAUO.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-C42T6DJT.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-ASWKLRKJ.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +123 -348
  53. package/dist/index.js +794 -837
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +169 -117
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +163 -62
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/frac.test.ts +4 -4
  77. package/src/annotations/frac.ts +3 -2
  78. package/src/annotations/index.ts +1 -1
  79. package/src/annotations/paint.test.ts +2 -2
  80. package/src/annotations/paint.ts +1 -1
  81. package/src/annotations/preload.ts +6 -0
  82. package/src/annotations/store.test.ts +18 -0
  83. package/src/annotations/store.ts +7 -6
  84. package/src/annotations/svgNodes.test.ts +2 -2
  85. package/src/annotations/svgNodes.ts +1 -2
  86. package/src/annotations/toolMap.ts +3 -3
  87. package/src/annotations/types.ts +6 -3
  88. package/src/canvas/CameraWheelContext.ts +12 -0
  89. package/src/canvas/CanvasStack.test.tsx +25 -0
  90. package/src/canvas/CanvasStack.tsx +31 -4
  91. package/src/canvas/Stage.less +18 -0
  92. package/src/canvas/Stage.test.tsx +186 -0
  93. package/src/canvas/Stage.tsx +134 -0
  94. package/src/canvas/camera.test.ts +17 -2
  95. package/src/canvas/camera.ts +9 -9
  96. package/src/canvas/index.ts +4 -0
  97. package/src/canvas/usePanZoom.ts +10 -5
  98. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  99. package/src/chrome/LabChrome.tsx +114 -0
  100. package/src/chrome/builtins.tsx +20 -31
  101. package/src/chrome/index.ts +15 -0
  102. package/src/chrome/labRegions.test.tsx +208 -0
  103. package/src/chrome/labTypes.ts +50 -0
  104. package/src/chrome/merge.ts +7 -9
  105. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  106. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  107. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  108. package/src/chrome/regions/StatusRegion.tsx +15 -8
  109. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  110. package/src/chrome/types.ts +47 -20
  111. package/src/config/builder.test.ts +4 -0
  112. package/src/config/builder.ts +16 -10
  113. package/src/config/declarationEmit.fixture.ts +11 -0
  114. package/src/config/declarationEmit.test.ts +51 -0
  115. package/src/config/entry.test.ts +46 -0
  116. package/src/config/index.ts +13 -1
  117. package/src/config/nodeClasses.test.ts +18 -0
  118. package/src/config/types.ts +3 -1
  119. package/src/controls/ControlPanel.test.tsx +16 -0
  120. package/src/controls/ControlPanel.tsx +5 -2
  121. package/src/fake-indexeddb-auto.d.ts +5 -0
  122. package/src/index.test.ts +7 -0
  123. package/src/index.ts +39 -6
  124. package/src/instrument/serializers.test.ts +22 -0
  125. package/src/instrument/serializers.ts +11 -0
  126. package/src/instrument/types.ts +25 -1
  127. package/src/lab/Lab.less +4 -13
  128. package/src/lab/Lab.persist.test.tsx +147 -0
  129. package/src/lab/Lab.stories.tsx +0 -2
  130. package/src/lab/Lab.surface.test.tsx +66 -3
  131. package/src/lab/Lab.test.tsx +91 -17
  132. package/src/lab/Lab.tsx +337 -124
  133. package/src/lab/LabFit.stories.less +18 -0
  134. package/src/lab/LabFit.stories.tsx +219 -0
  135. package/src/lab/LabFullChrome.stories.tsx +0 -3
  136. package/src/lab/LabHeader.test.tsx +40 -9
  137. package/src/lab/LabHeader.tsx +7 -13
  138. package/src/lab/LabPalette.tsx +13 -26
  139. package/src/lab/LabShell.less +49 -12
  140. package/src/lab/LabShell.tsx +14 -2
  141. package/src/lab/LabSwitcher.less +92 -0
  142. package/src/lab/LabSwitcher.test.tsx +96 -0
  143. package/src/lab/LabSwitcher.tsx +112 -0
  144. package/src/lab/Workspace.surface.test.tsx +2 -1
  145. package/src/lab/fitCheck.test.ts +57 -0
  146. package/src/lab/fitCheck.ts +125 -0
  147. package/src/lab/index.ts +2 -0
  148. package/src/loupe/AGENTS.md +1 -1
  149. package/src/loupe/Loupe.less +1 -1
  150. package/src/loupe/canvasLens.ts +1 -1
  151. package/src/loupe/types.ts +1 -1
  152. package/src/loupe/useLoupe.test.tsx +1 -1
  153. package/src/loupe/useLoupe.ts +1 -1
  154. package/src/passthrough/weasel-ui.ts +9 -0
  155. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  156. package/src/primitives/FloatingPanel.test.tsx +47 -16
  157. package/src/primitives/FloatingPanel.tsx +11 -25
  158. package/src/primitives/Split.less +14 -0
  159. package/src/primitives/Split.test.tsx +108 -0
  160. package/src/primitives/Split.tsx +159 -0
  161. package/src/primitives/ZoomControl.tsx +5 -2
  162. package/src/primitives/index.ts +2 -0
  163. package/src/state/Persistence.stories.tsx +75 -0
  164. package/src/state/Persistence.tsx +33 -0
  165. package/src/state/SingletonExperiment.test.tsx +47 -53
  166. package/src/state/SingletonExperiment.tsx +24 -13
  167. package/src/state/adapterContract.ts +116 -0
  168. package/src/state/adapters.test.ts +113 -61
  169. package/src/state/adapters.ts +328 -69
  170. package/src/state/document.test.ts +71 -96
  171. package/src/state/document.ts +66 -52
  172. package/src/state/helpers.test.ts +20 -0
  173. package/src/state/helpers.ts +16 -0
  174. package/src/state/index.ts +8 -0
  175. package/src/state/labRecords.test.ts +105 -0
  176. package/src/state/labRecords.ts +160 -0
  177. package/src/state/openLabStore.test.ts +658 -0
  178. package/src/state/openLabStore.ts +403 -0
  179. package/src/state/records.test.ts +234 -0
  180. package/src/state/records.ts +219 -0
  181. package/src/state/store.test.ts +104 -635
  182. package/src/state/store.ts +117 -158
  183. package/src/state/toolSlot.test.ts +1 -1
  184. package/src/state/types.ts +39 -15
  185. package/src/state/useOpenOnce.ts +68 -0
  186. package/src/state/usePersistedState.test.tsx +125 -0
  187. package/src/state/usePersistedState.ts +66 -0
  188. package/src/state/useTrialState.test.tsx +2 -3
  189. package/src/state/view.test.ts +49 -15
  190. package/src/state/view.ts +18 -1
  191. package/src/styles.less +3 -0
  192. package/src/surface/AGENTS.md +39 -7
  193. package/src/surface/SurfaceContext.ts +26 -3
  194. package/src/surface/index.ts +2 -0
  195. package/src/surface/useSurfaceTile.test.tsx +2 -1
  196. package/src/surface/useSurfaceTile.ts +6 -5
  197. package/src/surface/useTiledSurface.test.tsx +175 -0
  198. package/src/surface/useTiledSurface.ts +54 -5
  199. package/src/theme/Interstellar.stories.tsx +7 -7
  200. package/src/theme/base.less +4 -4
  201. package/src/theme/interstellar.test.ts +1 -1
  202. package/src/theme/interstellar.tokens.json +2 -2
  203. package/src/tools/labTool.ts +11 -0
  204. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  205. package/src/trial/Trial.annotations.test.tsx +47 -4
  206. package/src/trial/Trial.job.test.tsx +3 -3
  207. package/src/trial/Trial.less +1 -26
  208. package/src/trial/Trial.stories.tsx +1 -2
  209. package/src/trial/Trial.test.tsx +28 -4
  210. package/src/trial/Trial.trialId.test.tsx +23 -1
  211. package/src/trial/Trial.tsx +118 -38
  212. package/src/trial/TrialBody.tsx +17 -141
  213. package/src/trial/TrialChrome.tsx +7 -4
  214. package/src/trial/trialOps.ts +7 -4
  215. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  216. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  217. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  218. package/dist/chunk-2RQPGOQF.js.map +0 -1
  219. package/dist/chunk-5TROQVQ2.js +0 -547
  220. package/dist/chunk-5TROQVQ2.js.map +0 -1
  221. package/dist/chunk-ASWKLRKJ.js.map +0 -1
  222. package/dist/chunk-C42T6DJT.js.map +0 -1
  223. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  224. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  225. package/dist/chunk-I6JCVE24.js.map +0 -1
  226. package/dist/chunk-MHAGC6VD.js +0 -7320
  227. package/dist/chunk-MHAGC6VD.js.map +0 -1
  228. package/dist/chunk-QAGAHJKZ.js.map +0 -1
  229. package/dist/chunk-TJ7QY3OC.js.map +0 -1
@@ -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);
@@ -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 {
@@ -7,6 +7,7 @@ import {
7
7
  type WheelEvent,
8
8
  } from 'react';
9
9
  import type { ViewTransform } from '../instrument/types';
10
+ import { normalize2DView } from '../state/view';
10
11
  import { zoomAt } from './camera';
11
12
  import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
12
13
 
@@ -28,7 +29,9 @@ export interface UsePanZoomOptions {
28
29
  }
29
30
 
30
31
  export interface PanZoomHandlers {
31
- onWheel: (e: WheelEvent<HTMLElement>) => void;
32
+ /** Zoom about the pointer. `anchor` is the element the pointer position is
33
+ * measured against, for an event whose `currentTarget` is some other box. */
34
+ onWheel: (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => void;
32
35
  onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
33
36
  isDragging: () => boolean;
34
37
  }
@@ -56,8 +59,8 @@ export function usePanZoom({
56
59
  }: UsePanZoomOptions): PanZoomHandlers {
57
60
  const dragRef = useRef<DragState | null>(null);
58
61
  const sessionRef = useRef<PointerSession | null>(null);
59
- const viewRef = useRef(view);
60
- viewRef.current = view;
62
+ const viewRef = useRef(normalize2DView(view));
63
+ viewRef.current = normalize2DView(view);
61
64
  const onViewChangeRef = useRef(onViewChange);
62
65
  onViewChangeRef.current = onViewChange;
63
66
  const onTapRef = useRef(onTap);
@@ -68,9 +71,11 @@ export function usePanZoom({
68
71
  const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
69
72
 
70
73
  const onWheel = useCallback(
71
- (e: WheelEvent<HTMLElement>) => {
74
+ (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => {
75
+ const el = anchor ?? (e.currentTarget as Element | null);
76
+ if (!el) return;
72
77
  e.preventDefault();
73
- const rect = e.currentTarget.getBoundingClientRect();
78
+ const rect = el.getBoundingClientRect();
74
79
  const initialZoom = initialZoomRef.current;
75
80
  onViewChange(
76
81
  zoomAt(
@@ -76,7 +76,6 @@ export const EveryRegion: Story = {
76
76
  instruments: [FullInstrument],
77
77
  defaultInstrument: 'Every Region',
78
78
  title: 'Chrome Regions',
79
- storage: null,
80
79
  },
81
80
  };
82
81
 
@@ -86,7 +85,6 @@ export const SuppressedAndReplaced: Story = {
86
85
  instruments: [FullInstrument],
87
86
  defaultInstrument: 'Every Region',
88
87
  title: 'Suppress + Replace',
89
- storage: null,
90
88
  suppress: ['snapshot'],
91
89
  chrome: [
92
90
  {
@@ -116,7 +114,6 @@ export const SubjectTitleAndLead: Story = {
116
114
  instruments: [FullInstrument],
117
115
  defaultInstrument: 'Every Region',
118
116
  title: 'Title Bar Lead',
119
- storage: null,
120
117
  suppress: ['snapshot'],
121
118
  chrome: [
122
119
  {