@weasel-js/labkit 1.6.1 → 1.7.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 (256) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-D6t7o0pF.d.ts} +59 -18
  4. package/dist/_dts/{index-CKyX0ME_.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-B7yBTTF9.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-DTuViBvc.d.ts → types-BUBSIZuV.d.ts} +18 -8
  7. package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
  8. package/dist/canvas/index.d.ts +8 -6
  9. package/dist/canvas/index.js +6 -6
  10. package/dist/chrome/index.d.ts +6 -4
  11. package/dist/chrome/index.js +11 -10
  12. package/dist/chunk-2ES4W47N.js +8 -0
  13. package/dist/chunk-2ES4W47N.js.map +1 -0
  14. package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
  15. package/dist/chunk-2N7UBSS2.js.map +1 -0
  16. package/dist/chunk-4KSC7NXL.js +15491 -0
  17. package/dist/chunk-4KSC7NXL.js.map +1 -0
  18. package/dist/{chunk-SLAQPUI4.js → chunk-CEGFXTBD.js} +10 -63
  19. package/dist/chunk-CEGFXTBD.js.map +1 -0
  20. package/dist/chunk-CZZPXUOU.js +3 -0
  21. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  22. package/dist/{chunk-QGMOA2YE.js → chunk-E23Q6A3Y.js} +296 -104
  23. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  24. package/dist/{chunk-GHGO52SO.js → chunk-E6NARGU7.js} +5 -5
  25. package/dist/{chunk-GHGO52SO.js.map → chunk-E6NARGU7.js.map} +1 -1
  26. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  27. package/dist/chunk-EESJUSYR.js.map +1 -0
  28. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  29. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  30. package/dist/chunk-F4ACIK7Z.js +210 -0
  31. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  32. package/dist/{chunk-6FLXL3I7.js → chunk-FB4MIZOO.js} +77 -45
  33. package/dist/chunk-FB4MIZOO.js.map +1 -0
  34. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  35. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  36. package/dist/{chunk-4TT3PNPR.js → chunk-GAHELLJT.js} +95 -95
  37. package/dist/chunk-GAHELLJT.js.map +1 -0
  38. package/dist/{chunk-HKV7LRHY.js → chunk-IBVRMHW5.js} +5 -5
  39. package/dist/{chunk-HKV7LRHY.js.map → chunk-IBVRMHW5.js.map} +1 -1
  40. package/dist/{chunk-ASOK76ZH.js → chunk-JFOJESJF.js} +72 -29
  41. package/dist/chunk-JFOJESJF.js.map +1 -0
  42. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  43. package/dist/chunk-PMLZP7RT.js.map +1 -0
  44. package/dist/{chunk-TJ77A4A3.js → chunk-READTDQX.js} +5 -9
  45. package/dist/chunk-READTDQX.js.map +1 -0
  46. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  47. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  48. package/dist/{chunk-CGHF6RZV.js → chunk-T6DLGEJB.js} +18 -18
  49. package/dist/chunk-T6DLGEJB.js.map +1 -0
  50. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  51. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  52. package/dist/{chunk-DWMSB4CA.js → chunk-VYVGVBGY.js} +95 -32
  53. package/dist/chunk-VYVGVBGY.js.map +1 -0
  54. package/dist/{chunk-IRTGUQ5O.js → chunk-WLMDTI2Q.js} +16 -6
  55. package/dist/chunk-WLMDTI2Q.js.map +1 -0
  56. package/dist/{chunk-NEXR37YJ.js → chunk-XE5HJOPL.js} +6 -8
  57. package/dist/chunk-XE5HJOPL.js.map +1 -0
  58. package/dist/chunk-XVUKDSYV.js +38 -0
  59. package/dist/chunk-XVUKDSYV.js.map +1 -0
  60. package/dist/{chunk-O6FFFOHS.js → chunk-YCFKW5RX.js} +16 -8
  61. package/dist/chunk-YCFKW5RX.js.map +1 -0
  62. package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
  63. package/dist/chunk-ZQIHJXP4.js.map +1 -0
  64. package/dist/config/index.d.ts +28 -13
  65. package/dist/config/index.js +4 -4
  66. package/dist/controls/index.d.ts +4 -7
  67. package/dist/controls/index.js +6 -5
  68. package/dist/dragdrop/index.d.ts +23 -3
  69. package/dist/dragdrop/index.js +4 -3
  70. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  71. package/dist/index.d.ts +72 -487
  72. package/dist/index.js +603 -441
  73. package/dist/index.js.map +1 -1
  74. package/dist/job/index.d.ts +8 -0
  75. package/dist/job/index.js +1 -1
  76. package/dist/layers/index.d.ts +6 -5
  77. package/dist/layers/index.js +3 -2
  78. package/dist/loupe/index.d.ts +100 -30
  79. package/dist/loupe/index.js +585 -6
  80. package/dist/loupe/index.js.map +1 -1
  81. package/dist/overview/index.d.ts +4 -2
  82. package/dist/overview/index.js +20 -18
  83. package/dist/overview/index.js.map +1 -1
  84. package/dist/passthrough/weasel-canvas.d.ts +2 -0
  85. package/dist/passthrough/weasel-ui.d.ts +2 -2701
  86. package/dist/passthrough/weasel-ui.js +4 -3
  87. package/dist/primitives/index.d.ts +37 -2
  88. package/dist/primitives/index.js +9 -8
  89. package/dist/state/index.d.ts +7 -5
  90. package/dist/state/index.js +6 -6
  91. package/dist/styles.css +150 -126
  92. package/dist/surface/index.d.ts +10 -2
  93. package/dist/surface/index.js +1 -1
  94. package/dist/undo/index.d.ts +5 -3
  95. package/package.json +14 -13
  96. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  97. package/src/annotations/AnnotationOverlay.tsx +52 -33
  98. package/src/annotations/Annotations.less +0 -10
  99. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  100. package/src/annotations/MarkList.tsx +4 -8
  101. package/src/annotations/capture.browser.test.ts +41 -0
  102. package/src/annotations/capture.ts +14 -7
  103. package/src/annotations/drawOne.ts +2 -2
  104. package/src/annotations/frac.test.ts +44 -1
  105. package/src/annotations/frac.ts +46 -18
  106. package/src/annotations/index.ts +1 -0
  107. package/src/annotations/paint.test.ts +76 -46
  108. package/src/annotations/paint.ts +65 -43
  109. package/src/annotations/store.test.ts +212 -2
  110. package/src/annotations/store.ts +130 -27
  111. package/src/annotations/svgNodes.test.ts +28 -19
  112. package/src/annotations/svgNodes.ts +6 -6
  113. package/src/annotations/types.ts +29 -10
  114. package/src/canvas/AGENTS.md +1 -0
  115. package/src/canvas/CameraInput.test.tsx +30 -0
  116. package/src/canvas/CameraInput.tsx +60 -48
  117. package/src/canvas/CanvasStack.tsx +11 -6
  118. package/src/canvas/CanvasStackContext.ts +4 -1
  119. package/src/canvas/LinkedCursor.less +1 -1
  120. package/src/canvas/Stage.test.tsx +33 -4
  121. package/src/canvas/Stage.tsx +9 -4
  122. package/src/canvas/cameraRegistry.test.ts +55 -0
  123. package/src/canvas/cameraRegistry.ts +87 -0
  124. package/src/canvas/cameraZoom.test.ts +46 -0
  125. package/src/canvas/cameraZoom.ts +24 -0
  126. package/src/canvas/index.ts +14 -2
  127. package/src/canvas/sharedScope.test.tsx +94 -0
  128. package/src/canvas/worldSpec.ts +3 -1
  129. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  130. package/src/chrome/builtins.test.ts +7 -6
  131. package/src/chrome/builtins.tsx +5 -1
  132. package/src/chrome/index.ts +1 -1
  133. package/src/chrome/merge.test.ts +5 -0
  134. package/src/chrome/merge.ts +4 -2
  135. package/src/chrome/regions/PaletteRegion.less +0 -7
  136. package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
  137. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  138. package/src/chrome/regions/SidebarRegion.less +12 -2
  139. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  140. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  141. package/src/chrome/regions/ViewportRegion.less +6 -0
  142. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  143. package/src/chrome/types.ts +4 -2
  144. package/src/config/auto.ts +1 -0
  145. package/src/config/builder.test.ts +2 -1
  146. package/src/config/builder.ts +18 -5
  147. package/src/config/fromConfigField.test.ts +14 -0
  148. package/src/config/fromConfigField.ts +18 -4
  149. package/src/config/path.ts +2 -5
  150. package/src/config/resolve.ts +4 -0
  151. package/src/config/types.ts +14 -9
  152. package/src/controls/ControlPanel.less +1 -1
  153. package/src/controls/ControlPanel.stories.tsx +3 -2
  154. package/src/controls/ControlPanel.test.tsx +98 -11
  155. package/src/controls/ControlPanel.tsx +61 -2
  156. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  157. package/src/dragdrop/DragGhost.tsx +3 -0
  158. package/src/dragdrop/Palette.tsx +2 -0
  159. package/src/index.test.ts +5 -0
  160. package/src/index.ts +16 -2
  161. package/src/instrument/types.ts +0 -6
  162. package/src/job/useJob.ts +14 -9
  163. package/src/lab/Lab.tsx +88 -73
  164. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  165. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  166. package/src/lab/LabFullChrome.stories.tsx +1 -0
  167. package/src/lab/LabHeader.test.tsx +24 -14
  168. package/src/lab/LabHeader.tsx +11 -12
  169. package/src/lab/LabShell.less +9 -17
  170. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  171. package/src/lab/LabSwitcher.less +8 -6
  172. package/src/lab/LabZoom.test.tsx +223 -0
  173. package/src/lab/LabZoom.tsx +170 -0
  174. package/src/lab/Workspace.tsx +48 -52
  175. package/src/lab/index.ts +1 -0
  176. package/src/loupe/AGENTS.md +28 -15
  177. package/src/loupe/CanvasLoupe.tsx +2 -3
  178. package/src/loupe/DomLoupe.tsx +1 -7
  179. package/src/loupe/Loupe.less +16 -0
  180. package/src/loupe/LoupeGestures.tsx +1 -1
  181. package/src/loupe/TrialLoupe.tsx +120 -57
  182. package/src/loupe/index.ts +1 -6
  183. package/src/loupe/types.test.ts +3 -3
  184. package/src/loupe/types.ts +24 -27
  185. package/src/loupe/useLoupe.test.tsx +29 -4
  186. package/src/loupe/useLoupe.ts +20 -17
  187. package/src/overview/OverviewMarks.tsx +12 -4
  188. package/src/overview/TrialOverview.tsx +10 -8
  189. package/src/passthrough/weasel-ui.test.ts +2 -0
  190. package/src/passthrough/weasel-ui.ts +17 -5
  191. package/src/primitives/FloatingPanel.test.tsx +11 -0
  192. package/src/primitives/FloatingPanel.tsx +8 -7
  193. package/src/primitives/JobProgress.less +2 -1
  194. package/src/primitives/Legend.stories.tsx +1 -0
  195. package/src/primitives/Readout.browser.test.tsx +137 -0
  196. package/src/primitives/Readout.less +12 -0
  197. package/src/primitives/Readout.stories.tsx +58 -0
  198. package/src/primitives/Readout.test.tsx +93 -0
  199. package/src/primitives/Readout.tsx +56 -0
  200. package/src/primitives/Split.tsx +69 -41
  201. package/src/primitives/Toolbar.tsx +3 -3
  202. package/src/primitives/ZoomControl.less +1 -2
  203. package/src/primitives/index.ts +2 -0
  204. package/src/specimen/Specimen.stories.tsx +1 -0
  205. package/src/specimen/Specimen.tsx +2 -3
  206. package/src/state/Persistence.browser.test.tsx +47 -0
  207. package/src/state/store.test.ts +5 -1
  208. package/src/state/undock.ts +3 -0
  209. package/src/state/useOpenOnce.ts +20 -16
  210. package/src/state/usePersistedState.test.tsx +1 -1
  211. package/src/state/view.ts +1 -0
  212. package/src/styles.less +1 -0
  213. package/src/surface/index.ts +1 -1
  214. package/src/surface/useTiledSurface.test.tsx +14 -0
  215. package/src/surface/useTiledSurface.ts +11 -3
  216. package/src/theme/Interstellar.stories.tsx +3 -1
  217. package/src/theme/base.less +21 -47
  218. package/src/theme/interstellar.test.ts +1 -1
  219. package/src/theme/interstellar.theme.json +10 -3
  220. package/src/trial/Trial.annotations.test.tsx +73 -0
  221. package/src/trial/Trial.less +0 -29
  222. package/src/trial/Trial.loupe.test.tsx +28 -11
  223. package/src/trial/Trial.tsx +85 -67
  224. package/src/trial/TrialChrome.tsx +27 -6
  225. package/src/trial/index.ts +2 -0
  226. package/src/trial/loupeSwitch.ts +15 -0
  227. package/dist/_dts/LayerList.d-BoELAMsM.d.ts +0 -83
  228. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  229. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
  230. package/dist/_dts/Select.d-Cr8-zoup.d.ts +0 -569
  231. package/dist/chunk-4TT3PNPR.js.map +0 -1
  232. package/dist/chunk-6FLXL3I7.js.map +0 -1
  233. package/dist/chunk-ASOK76ZH.js.map +0 -1
  234. package/dist/chunk-CGHF6RZV.js.map +0 -1
  235. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  236. package/dist/chunk-DWMSB4CA.js.map +0 -1
  237. package/dist/chunk-HXXTULDN.js.map +0 -1
  238. package/dist/chunk-IJJUK4FU.js.map +0 -1
  239. package/dist/chunk-IRTGUQ5O.js.map +0 -1
  240. package/dist/chunk-KHK5FGER.js +0 -145
  241. package/dist/chunk-KHK5FGER.js.map +0 -1
  242. package/dist/chunk-NEXR37YJ.js.map +0 -1
  243. package/dist/chunk-O6FFFOHS.js.map +0 -1
  244. package/dist/chunk-QGMOA2YE.js.map +0 -1
  245. package/dist/chunk-RWNDDKOH.js.map +0 -1
  246. package/dist/chunk-S4NCI32V.js +0 -549
  247. package/dist/chunk-S4NCI32V.js.map +0 -1
  248. package/dist/chunk-SBXNQW4G.js +0 -3
  249. package/dist/chunk-SLAQPUI4.js.map +0 -1
  250. package/dist/chunk-TJ77A4A3.js.map +0 -1
  251. package/dist/chunk-TYN6MLXX.js +0 -13864
  252. package/dist/chunk-TYN6MLXX.js.map +0 -1
  253. package/dist/chunk-WPLAANH4.js.map +0 -1
  254. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  255. package/src/primitives/useRovingTabIndex.ts +0 -88
  256. package/src/state/Persistence.stories.tsx +0 -75
@@ -22,8 +22,8 @@ export interface FracRect {
22
22
  }
23
23
 
24
24
  /** What shape a mark is. The kinds map onto weasel's own tools; an arrow is a
25
- * line carrying an end marker, not a separate geometry. A point holds one
26
- * entry in `points` and zero-size bounds at that position. */
25
+ * line carrying an end marker, not a separate geometry. A point is zero-size
26
+ * bounds at its position. */
27
27
  export type AnnotationKind = 'stroke' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'text' | 'point';
28
28
 
29
29
  /** One selectable status in an instrument's meaning tier. */
@@ -47,8 +47,11 @@ export interface AnnotationData {
47
47
  target: string;
48
48
  kind: AnnotationKind;
49
49
  /** Vertices for the kinds a bounding box cannot describe — a line's two
50
- * ends, a stroke's path. In fractions, like the bounds. */
51
- points?: readonly FracPoint[];
50
+ * ends, a stroke's path — in fractions of the mark's own bounds, so moving
51
+ * or resizing the bounds carries them along. A point stores none: it is its
52
+ * bounds' origin. `Annotation.points` is this restated in the target's
53
+ * content box. */
54
+ shape?: readonly FracPoint[];
52
55
  title?: string;
53
56
  status?: string;
54
57
  tags?: readonly string[];
@@ -61,10 +64,13 @@ export interface AnnotationData {
61
64
 
62
65
  /** A mark, as the store reports it. `id` is `<target>/<node>`: one scene per
63
66
  * target means a node id is only unique within one. */
64
- export interface Annotation extends AnnotationData {
67
+ export interface Annotation extends Omit<AnnotationData, 'shape'> {
65
68
  id: string;
66
69
  /** Bounds in fractions of the target's content box. */
67
70
  frac: FracRect;
71
+ /** Vertices in fractions of the target's content box, wherever the bounds
72
+ * now are. A point's one entry is its bounds' origin. */
73
+ points?: readonly FracPoint[];
68
74
  }
69
75
 
70
76
  /** A new mark, before the store gives it an id and dates it. */
@@ -72,6 +78,8 @@ export interface AnnotationInit {
72
78
  target: string;
73
79
  kind: AnnotationKind;
74
80
  frac: FracRect;
81
+ /** In the target's content box. Stored relative to `frac`, so they should
82
+ * lie inside it; a point takes its position from `frac` alone. */
75
83
  points?: readonly FracPoint[];
76
84
  title?: string;
77
85
  status?: string;
@@ -89,7 +97,8 @@ export interface AnnotationQuery {
89
97
  }
90
98
 
91
99
  /** What a mark's meaning can be patched to. Geometry moves through `frac` /
92
- * `points`; everything else is the meaning tier. */
100
+ * `points`; everything else is the meaning tier. `frac` alone carries the
101
+ * vertices with it; `points` alone moves the bounds to enclose them. */
93
102
  export type AnnotationPatch = Partial<
94
103
  Pick<Annotation, 'frac' | 'points' | 'title' | 'status' | 'tags' | 'meta'>
95
104
  >;
@@ -159,8 +168,6 @@ export interface AnnotationStorage {
159
168
  save: (doc: SerializedAnnotations) => void;
160
169
  }
161
170
 
162
- /** Declares that an instrument accepts marks: which regions take them,
163
- * optionally what a mark is allowed to mean, and optionally where they live. */
164
171
  /** The annotation tools a lab's rail can carry. */
165
172
  export type AnnotationToolId =
166
173
  | 'pointer'
@@ -172,6 +179,13 @@ export type AnnotationToolId =
172
179
  | 'ellipse'
173
180
  | 'text';
174
181
 
182
+ /** Whether a selection can stand in several of a trial's targets at once.
183
+ * `'exclusive'` clears every other target when a mark is selected in one;
184
+ * `'per-target'` lets each target keep its own. */
185
+ export type AnnotationSelectionMode = 'exclusive' | 'per-target';
186
+
187
+ /** Declares that an instrument accepts marks: which regions take them,
188
+ * optionally what a mark is allowed to mean, and optionally where they live. */
175
189
  export interface AnnotationsCapability<TS = unknown, TC = unknown> {
176
190
  /** `trial` is which trial is asking: a declaration made once per instrument
177
191
  * is called once per trial, and its targets are that trial's own. */
@@ -188,6 +202,9 @@ export interface AnnotationsCapability<TS = unknown, TC = unknown> {
188
202
  * notification, not an interception: a host wanting its own flow calls
189
203
  * `capture()` from its own UI, which is the surface the chrome uses. */
190
204
  onCapture?: (result: CaptureResult) => void;
205
+ /** Defaults to `'exclusive'`: selecting a mark in one target clears the
206
+ * selection in every other. */
207
+ selection?: AnnotationSelectionMode;
191
208
  }
192
209
 
193
210
  /** A persisted mark set. Versioned by this arc rather than by labkit's
@@ -229,11 +246,13 @@ export interface AnnotationsApi {
229
246
  * annotation ids. Every one resolves through `get`. */
230
247
  selection(): readonly string[];
231
248
  /** Replace the selection. An id naming a target or a mark that is not
232
- * there is dropped, the way `update` and `remove` ignore one. */
249
+ * there is dropped, the way `update` and `remove` ignore one. Under an
250
+ * `'exclusive'` selection, so is every id outside the first id's target. */
233
251
  setSelection(ids: readonly string[]): void;
234
252
  /** Fires after every mutation *and* after a selection change — weasel keeps
235
253
  * a canvas's selection on the scene, so both already arrive on this one
236
- * channel. No delta: re-query, and re-read `selection()`. */
254
+ * channel. A selection change touching several targets is one event. No
255
+ * delta: re-query, and re-read `selection()`. */
237
256
  subscribe(fn: () => void): () => void;
238
257
 
239
258
  /** Whether the last mark change on any target can be taken back. Weasel
@@ -14,6 +14,7 @@ schedules its own layers; a foreign renderer wants rects and dirtiness only.
14
14
  | `CanvasStackContext.ts` | React context exposing the current `view` to descendants |
15
15
  | `useLayerScheduler.ts` | DPR-aware rAF scheduler; redraws dirty layers on view/state changes |
16
16
  | `CameraInput.tsx` | Pan, zoom and tap routed through weasel's gesture dispatcher; `CameraContext`; publishes the pointer |
17
+ | `cameraRegistry.ts` | Cameras by trial id, so chrome outside a trial (the lab header's zoom) can drive the focused one |
17
18
  | `cameraView.ts` | The camera as a weasel `View` over frame-local coordinates, and the anchored zoom clamp |
18
19
  | `LinkedCursor.tsx` | The crosshair on the camera's element while the pointer is over another view |
19
20
  | `camera.ts` | `zoomAt` (fixed-point zoom) and `centerOn` (put a world point at a viewport's middle) |
@@ -6,11 +6,14 @@
6
6
 
7
7
  import { act, render } from '@testing-library/react';
8
8
  import { createPointerStore, PointerContextProvider } from '@weasel-js/core';
9
+ import { renderThenAbandon } from '@weasel-js/react/testing/abandonRender';
9
10
  import { useState } from 'react';
10
11
  import { beforeAll, describe, expect, it, vi } from 'vitest';
11
12
  import type { ViewTransform } from '../instrument/types';
13
+ import { type CameraView, useCameraView } from './CameraInput';
12
14
  import { CanvasStack } from './CanvasStack';
13
15
  import { Stage } from './Stage';
16
+ import { DEFAULT_FRAME } from './worldSpec';
14
17
 
15
18
  beforeAll(() => {
16
19
  HTMLCanvasElement.prototype.getContext = vi.fn(
@@ -133,4 +136,31 @@ describe('camera input', () => {
133
136
  act(() => pointer(host, 'pointermove', 40, 30, 0));
134
137
  expect(store.get()).toEqual({ worldX: 20, worldY: -15, viewId: 'stage' });
135
138
  });
139
+
140
+ it('reads, writes and clamps through the committed props, never an abandoned render', () => {
141
+ let camera: CameraView | null = null;
142
+ const hostRef = { current: null };
143
+ const committed = vi.fn();
144
+ const abandoned = vi.fn();
145
+ interface ProbeProps {
146
+ view: ViewTransform;
147
+ onViewChange: (v: ViewTransform) => void;
148
+ maxZoom: number;
149
+ }
150
+ function Probe(props: ProbeProps) {
151
+ camera = useCameraView({ ...props, frame: DEFAULT_FRAME, hostRef });
152
+ return null;
153
+ }
154
+ renderThenAbandon<ProbeProps>(
155
+ { view: { zoom: 1, pan: { x: 0, y: 0 } }, onViewChange: committed, maxZoom: 4 },
156
+ { view: { zoom: 2, pan: { x: 50, y: 50 } }, onViewChange: abandoned, maxZoom: 8 },
157
+ (p) => <Probe {...p} />,
158
+ );
159
+ const cam = camera as unknown as CameraView;
160
+ expect(cam.get().scale.x).toBe(1);
161
+ expect(cam.zoomRange().max).toBe(4);
162
+ act(() => cam.set(cam.get()));
163
+ expect(abandoned).not.toHaveBeenCalled();
164
+ expect(committed).toHaveBeenCalledTimes(1);
165
+ });
136
166
  });
@@ -5,10 +5,8 @@
5
5
  */
6
6
  import {
7
7
  type Action,
8
- ActionsProvider,
9
8
  type ActionsRegistry,
10
- ActiveToolContextProvider,
11
- DepRegistryProvider,
9
+ InputScope,
12
10
  makeViewportZoomAction,
13
11
  PointerContextProvider,
14
12
  type PointerWorldPos,
@@ -16,10 +14,12 @@ import {
16
14
  useActionsRegistry,
17
15
  useDepSource,
18
16
  useGestureDispatcher,
17
+ useLatest,
19
18
  usePointerContext,
20
19
  type View,
21
20
  type ViewApi,
22
21
  viewportDragPanAction,
22
+ type Yoke,
23
23
  } from '@weasel-js/core';
24
24
  import {
25
25
  createContext,
@@ -27,12 +27,14 @@ import {
27
27
  type RefObject,
28
28
  useContext,
29
29
  useEffect,
30
+ useInsertionEffect,
30
31
  useMemo,
31
32
  useRef,
32
33
  } from 'react';
33
34
  import type { Point, ViewTransform } from '../instrument/types';
34
35
  import { normalize2DView } from '../state/view';
35
36
  import { CameraWheelContext } from './CameraWheelContext';
37
+ import { usePublishCamera } from './cameraRegistry';
36
38
  import { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
37
39
  import type { ViewportSize, WorldFrame } from './worldSpec';
38
40
 
@@ -57,31 +59,20 @@ export interface CameraContextValue {
57
59
  /** The camera around the caller, or `null` outside one. */
58
60
  export const CameraContext = createContext<CameraContextValue | null>(null);
59
61
 
60
- /** Set by a host that already provides one input scope for everything inside
61
- * it — a trial — so the camera, the loupe and an overview share it. */
62
- export const CameraScopeContext = createContext(false);
63
-
64
62
  /**
65
- * The actions and deps a camera routes through: the ones a trial provides, or
66
- * an isolated set of its own. Isolated, because an actions registry holds one
67
- * dispatcher, and a camera must not take input from a canvas beside it. The
68
- * pointer store is the exception — one already in scope is kept, since sharing
69
- * it is the point.
63
+ * The input scope a camera routes through. Inside another camera — a loupe or
64
+ * an overlay laid over a canvas stack — it joins that camera's scope, so its
65
+ * actions reach the dispatcher already listening there. Anywhere else it
66
+ * mounts a scope of its own, which keeps its own tool unless it joins `yoke`:
67
+ * a camera beside another never takes that one's gestures. The pointer store is the exception — one
68
+ * already in scope is kept, since sharing it is the point.
70
69
  */
71
- export function CameraScope({ children }: { children: ReactNode }) {
72
- const inScope = useContext(CameraScopeContext);
70
+ export function CameraScope({ yoke, children }: { yoke?: Yoke; children: ReactNode }) {
71
+ const around = useContext(CameraContext);
73
72
  const pointer = usePointerContext();
74
- if (inScope) return <>{children}</>;
75
- const scoped = <CameraScopeContext.Provider value={true}>{children}</CameraScopeContext.Provider>;
76
- return (
77
- <DepRegistryProvider>
78
- <ActionsProvider>
79
- <ActiveToolContextProvider>
80
- {pointer ? scoped : <PointerContextProvider>{scoped}</PointerContextProvider>}
81
- </ActiveToolContextProvider>
82
- </ActionsProvider>
83
- </DepRegistryProvider>
84
- );
73
+ if (around) return <>{children}</>;
74
+ const scoped = <InputScope yoke={yoke}>{children}</InputScope>;
75
+ return pointer ? scoped : <PointerContextProvider>{scoped}</PointerContextProvider>;
85
76
  }
86
77
 
87
78
  /** Options for {@link useCameraView}. */
@@ -94,6 +85,17 @@ export interface CameraViewOptions {
94
85
  maxZoom?: number;
95
86
  }
96
87
 
88
+ /** A camera's zoom limits, after widening to admit its opening zoom. */
89
+ export interface ZoomRange {
90
+ min: number;
91
+ max: number;
92
+ }
93
+
94
+ /** A labkit camera: a weasel `ViewApi` that also says how far it zooms. */
95
+ export interface CameraView extends ViewApi {
96
+ zoomRange(): ZoomRange;
97
+ }
98
+
97
99
  function isPositiveFinite(n: number): boolean {
98
100
  return Number.isFinite(n) && n > 0;
99
101
  }
@@ -110,18 +112,21 @@ export function useCameraView({
110
112
  hostRef,
111
113
  minZoom = 0.1,
112
114
  maxZoom = 32,
113
- }: CameraViewOptions): ViewApi {
115
+ }: CameraViewOptions): CameraView {
116
+ // Written by each commit and by `set`, ahead of the render it asks for —
117
+ // two writers, so not `useLatest`.
114
118
  const live = useRef({ view: normalize2DView(view), onViewChange, frame });
115
- live.current = { view: normalize2DView(view), onViewChange, frame };
119
+ useInsertionEffect(() => {
120
+ live.current = { view: normalize2DView(view), onViewChange, frame };
121
+ });
116
122
  const initialZoom = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
117
- const bounds = useRef({ min: minZoom, max: maxZoom });
118
123
  const opening = initialZoom.current;
119
- bounds.current = {
124
+ const bounds = useLatest({
120
125
  min: opening == null ? minZoom : Math.min(minZoom, opening),
121
126
  max: opening == null ? maxZoom : Math.max(maxZoom, opening),
122
- };
127
+ });
123
128
 
124
- return useMemo<ViewApi>(() => {
129
+ return useMemo<CameraView>(() => {
125
130
  const get = (): View => toCameraView(live.current.view, live.current.frame);
126
131
  return {
127
132
  get,
@@ -138,8 +143,9 @@ export function useCameraView({
138
143
  const r = hostRef.current?.getBoundingClientRect();
139
144
  return r ? { width: r.width, height: r.height } : null;
140
145
  },
146
+ zoomRange: () => ({ ...bounds.current }),
141
147
  };
142
- }, [hostRef]);
148
+ }, [bounds, hostRef]);
143
149
  }
144
150
 
145
151
  const TAP_ID = 'camera.tap';
@@ -149,7 +155,9 @@ const CHANNELS = { contextMenu: false, ingest: false } as const;
149
155
  /** Props for `<CameraInput>`. */
150
156
  export interface CameraInputProps {
151
157
  hostRef: RefObject<HTMLElement | null>;
152
- camera: ViewApi;
158
+ /** Published to the host's camera registry while its scope is the active
159
+ * one, so chrome acting on "the camera" acts on the one last used. */
160
+ camera: CameraView;
153
161
  frame: WorldFrame;
154
162
  /** A primary-button press released without crossing the drag threshold, at
155
163
  * the world point it landed on. */
@@ -174,10 +182,8 @@ function CameraDispatch({
174
182
  onTap,
175
183
  registry,
176
184
  }: CameraInputProps & { registry: ActionsRegistry }) {
177
- const frameRef = useRef(frame);
178
- frameRef.current = frame;
179
- const onTapRef = useRef(onTap);
180
- onTapRef.current = onTap;
185
+ const frameRef = useLatest(frame);
186
+ const onTapRef = useLatest(onTap);
181
187
 
182
188
  useDepSource('view', () => camera);
183
189
  useDepSource('rootView', () => camera);
@@ -209,11 +215,12 @@ function CameraDispatch({
209
215
  },
210
216
  };
211
217
  return [
212
- viewportDragPanAction,
218
+ // A trial pans on a plain drag; the kit action binds none of its own.
219
+ { ...viewportDragPanAction, defaultBinding: { kind: 'drag' } },
213
220
  { ...zoom, defaultBinding: wheelOnly as Action['defaultBinding'] },
214
221
  tap,
215
222
  ];
216
- }, []);
223
+ }, [frameRef, onTapRef]);
217
224
 
218
225
  useEffect(() => {
219
226
  const offs = actions.map((a) => registry.register(a));
@@ -237,26 +244,33 @@ function CameraDispatch({
237
244
  useGestureDispatcher({
238
245
  canvasRef: hostRef,
239
246
  actions: registry,
240
- toolsById: NO_TOOLS,
247
+ entriesById: NO_TOOLS,
241
248
  clientToWorld,
242
249
  channels: CHANNELS,
243
250
  });
244
251
 
245
252
  usePublishPointer(hostRef, clientToWorld, frame, STAGE_VIEW_ID);
253
+ usePublishCamera(camera);
246
254
 
247
255
  // Input that lands outside the camera's element — an annotation target's
248
- // box, portalled into the surface — reaches the dispatcher through here.
256
+ // box, portalled into the surface — reaches the active camera's dispatcher
257
+ // through here.
249
258
  const wheelSlot = useContext(CameraWheelContext);
250
259
  useEffect(() => {
251
260
  if (!wheelSlot) return;
252
261
  const forward = (e: WheelEvent): void => {
253
262
  hostRef.current?.dispatchEvent(new WheelEvent('wheel', e));
254
263
  };
255
- wheelSlot.current = forward;
264
+ const claim = (): void => {
265
+ if (registry.isActive()) wheelSlot.current = forward;
266
+ };
267
+ claim();
268
+ const off = registry.subscribe(claim);
256
269
  return () => {
270
+ off();
257
271
  if (wheelSlot.current === forward) wheelSlot.current = null;
258
272
  };
259
- }, [wheelSlot, hostRef]);
273
+ }, [wheelSlot, hostRef, registry]);
260
274
 
261
275
  return null;
262
276
  }
@@ -273,10 +287,8 @@ export function usePublishPointer(
273
287
  viewId: string,
274
288
  ): void {
275
289
  const store = usePointerContext();
276
- const frameRef = useRef(frame);
277
- frameRef.current = frame;
278
- const toLocal = useRef(clientToLocal);
279
- toLocal.current = clientToLocal;
290
+ const frameRef = useLatest(frame);
291
+ const toLocal = useLatest(clientToLocal);
280
292
  useEffect(() => {
281
293
  const el = hostRef.current;
282
294
  if (!el || !store) return;
@@ -294,7 +306,7 @@ export function usePublishPointer(
294
306
  el.removeEventListener('pointermove', onMove);
295
307
  el.removeEventListener('pointerleave', onLeave);
296
308
  };
297
- }, [hostRef, store, viewId]);
309
+ }, [frameRef, hostRef, store, toLocal, viewId]);
298
310
  }
299
311
 
300
312
  /** The pointer as a frame-local point, or `null` when there is none or it is
@@ -1,3 +1,5 @@
1
+ import type { Yoke } from '@weasel-js/core';
2
+ import { useLatest } from '@weasel-js/core';
1
3
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
2
4
  import type { Point, ViewTransform } from '../instrument/types';
3
5
  import { normalize2DView } from '../state/view';
@@ -25,6 +27,9 @@ export interface CanvasStackProps {
25
27
  onResize?: (size: ViewportSize) => void;
26
28
  onHitTest?: (worldPos: Point) => void;
27
29
  children?: ReactNode;
30
+ /** A yoke to join, from `useYoke()`: cameras on one yoke share their tool
31
+ * and gesture in flight. Omitted, this one keeps its own. */
32
+ yoke?: Yoke;
28
33
  }
29
34
 
30
35
  /** Stacks one `<canvas>` per layer and drives them from a shared view, so a
@@ -44,6 +49,7 @@ export function CanvasStack({
44
49
  onResize,
45
50
  onHitTest,
46
51
  children,
52
+ yoke,
47
53
  }: CanvasStackProps) {
48
54
  const view = normalize2DView(viewProp);
49
55
  const containerRef = useRef<HTMLDivElement | null>(null);
@@ -75,12 +81,11 @@ export function CanvasStack({
75
81
  return () => ro.disconnect();
76
82
  }, []);
77
83
 
78
- const onResizeRef = useRef(onResize);
79
- onResizeRef.current = onResize;
84
+ const onResizeRef = useLatest(onResize);
80
85
  useEffect(() => {
81
86
  if (size.width === 0 && size.height === 0) return;
82
87
  onResizeRef.current?.({ width: size.width, height: size.height });
83
- }, [size.width, size.height]);
88
+ }, [size.width, size.height, onResizeRef]);
84
89
 
85
90
  const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
86
91
 
@@ -102,9 +107,9 @@ export function CanvasStack({
102
107
  () => ({
103
108
  view,
104
109
  frame,
105
- surface: { element: containerRef, size, canvases: canvasMap, layers },
110
+ surface: { element: containerRef, size, canvases: canvasMap, layers, worldSpec },
106
111
  }),
107
- [view, frame, size, layers],
112
+ [view, frame, size, layers, worldSpec],
108
113
  );
109
114
 
110
115
  const containerStyle: CSSProperties = { width, height };
@@ -118,7 +123,7 @@ export function CanvasStack({
118
123
  };
119
124
 
120
125
  return (
121
- <CameraScope>
126
+ <CameraScope yoke={yoke}>
122
127
  <CameraContext.Provider value={cameraCtx}>
123
128
  <CanvasStackContext.Provider value={ctxValue}>
124
129
  <CameraInput hostRef={containerRef} camera={camera} frame={frame} onTap={onHitTest} />
@@ -1,7 +1,7 @@
1
1
  import { createContext, type RefObject } from 'react';
2
2
  import type { ViewTransform } from '../instrument/types';
3
3
  import type { CanvasLayerDescriptor } from './useLayerScheduler';
4
- import type { WorldFrame } from './worldSpec';
4
+ import type { WorldFrame, WorldSpec } from './worldSpec';
5
5
 
6
6
  /** The stack's own drawing surface, for an overlay that has to re-draw it at
7
7
  * another camera or read back what it presented — a loupe. */
@@ -14,6 +14,9 @@ export interface CanvasStackSurface {
14
14
  canvases: RefObject<Map<string, HTMLCanvasElement>>;
15
15
  /** The layers as the stack is drawing them, bottom first. */
16
16
  layers: readonly CanvasLayerDescriptor[];
17
+ /** The coordinate system the stack was given, which `frame` resolves at
18
+ * its size — for a re-draw at another size. */
19
+ worldSpec?: WorldSpec;
17
20
  }
18
21
 
19
22
  /** What a canvas stack publishes to its children: the view, and the resolved
@@ -12,5 +12,5 @@
12
12
  }
13
13
 
14
14
  .lk-linked-cursor__bar {
15
- fill: var(--wzl-accent, #4c8dff);
15
+ fill: var(--wzl-accent);
16
16
  }
@@ -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,3 +1,5 @@
1
+ import type { Yoke } from '@weasel-js/core';
2
+ import { useLatest } from '@weasel-js/core';
1
3
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
2
4
  import type { ViewTransform } from '../instrument/types';
3
5
  import { normalize2DView } from '../state/view';
@@ -21,6 +23,9 @@ export interface StageProps {
21
23
  /** Drawn over the content in viewport pixels, outside the camera. */
22
24
  overlay?: ReactNode;
23
25
  children?: ReactNode;
26
+ /** A yoke to join, from `useYoke()`: cameras on one yoke share their tool
27
+ * and gesture in flight. Omitted, this one keeps its own. */
28
+ yoke?: Yoke;
24
29
  }
25
30
 
26
31
  /**
@@ -58,6 +63,7 @@ export function Stage({
58
63
  hostRef,
59
64
  overlay,
60
65
  children,
66
+ yoke,
61
67
  }: StageProps) {
62
68
  const view = normalize2DView(viewProp);
63
69
  const host = useRef<HTMLDivElement | null>(null);
@@ -80,8 +86,7 @@ export function Stage({
80
86
  [camera, size.width, size.height],
81
87
  );
82
88
 
83
- const onResizeRef = useRef(onResize);
84
- onResizeRef.current = onResize;
89
+ const onResizeRef = useLatest(onResize);
85
90
  useEffect(() => {
86
91
  const el = host.current;
87
92
  if (!el) return;
@@ -96,7 +101,7 @@ export function Stage({
96
101
  const ro = new ResizeObserver(measure);
97
102
  ro.observe(el);
98
103
  return () => ro.disconnect();
99
- }, []);
104
+ }, [onResizeRef]);
100
105
 
101
106
  // A transform moves every tile inside the content without resizing anything,
102
107
  // and a ResizeObserver reports only resizes.
@@ -117,7 +122,7 @@ export function Stage({
117
122
  } as CSSProperties;
118
123
 
119
124
  return (
120
- <CameraScope>
125
+ <CameraScope yoke={yoke}>
121
126
  <CameraContext.Provider value={cameraCtx}>
122
127
  <CameraInput hostRef={host} camera={camera} frame={DEFAULT_FRAME} />
123
128
  <div
@@ -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
+ });