@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
@@ -1,14 +1,29 @@
1
- import { createPointerStore, PointerContextProvider, WeaselProvider } from '@weasel-js/core';
2
- import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react';
1
+ import {
2
+ createPointerStore,
3
+ PointerContextProvider,
4
+ useLatest,
5
+ WeaselProvider,
6
+ } from '@weasel-js/core';
7
+ import {
8
+ type ReactNode,
9
+ useCallback,
10
+ useContext,
11
+ useEffect,
12
+ useLayoutEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from 'react';
3
17
  import { useStore } from 'zustand/react';
4
18
  import { AnnotationsContext } from '../annotations/AnnotationsContext';
5
19
  import { AnnotationTargets } from '../annotations/AnnotationTargets';
6
20
  import { AnnotationPreloadContext } from '../annotations/preload';
7
21
  import { annotationsFromJSON } from '../annotations/store';
8
- import type { AnnotationStorage, AnnotationTargetInfo } from '../annotations/types';
9
- import { CameraScopeContext } from '../canvas/CameraInput';
22
+ import type { AnnotationStorage, AnnotationsApi, AnnotationTargetInfo } from '../annotations/types';
23
+ import type { CameraView } from '../canvas/CameraInput';
10
24
  import { CameraWheelContext, type CameraWheelSlot } from '../canvas/CameraWheelContext';
11
25
  import { CanvasStack } from '../canvas/CanvasStack';
26
+ import { CameraPublishContext, CameraRegistryContext } from '../canvas/cameraRegistry';
12
27
  import { fitStage, Stage } from '../canvas/Stage';
13
28
  import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
14
29
  import { applyCamera, type ViewportSize } from '../canvas/worldSpec';
@@ -26,8 +41,6 @@ import type {
26
41
  } from '../instrument/types';
27
42
  import { useJob } from '../job/useJob';
28
43
  import { useLabContext } from '../lab/LabContext';
29
- import { TrialLoupe } from '../loupe/TrialLoupe';
30
- import { resolveLoupe } from '../loupe/types';
31
44
  import { LayerList, type LayerListItem, moveLayers } from '../passthrough/weasel-ui';
32
45
  import { LabStoreContext, TrialIdProvider } from '../state/context';
33
46
  import type { LabStore } from '../state/store';
@@ -36,6 +49,7 @@ import { as2DView, DEFAULT_VIEW, withZoom } from '../state/view';
36
49
  import { resolveLabTool } from '../tools/labTool';
37
50
  import { createEventBus, type EventBus } from '../undo/eventBus';
38
51
  import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
52
+ import { LoupeSwitchContext } from './loupeSwitch';
39
53
  import type { LoupeBindings, UndoBindings } from './TrialChrome';
40
54
  import { TrialChrome } from './TrialChrome';
41
55
 
@@ -136,9 +150,14 @@ function TrialRuntime({
136
150
  suppress,
137
151
  }: TrialRuntimeProps) {
138
152
  const wheelSlot = useRef<CameraWheelSlot['current']>(null);
153
+ const cameras = useContext(CameraRegistryContext);
154
+ const trialId = record.id;
155
+ const publishCamera = useMemo(
156
+ () => (cameras ? (camera: CameraView) => cameras.register(trialId, camera) : null),
157
+ [cameras, trialId],
158
+ );
139
159
  const [pointer] = useState(createPointerStore);
140
160
  const canvasContainerRef = useRef<HTMLDivElement | null>(null);
141
- const loupeHostRef = useRef<HTMLDivElement | null>(null);
142
161
  const updateTrialState = useStore(store, (s) => s.updateTrialState);
143
162
  const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
144
163
  const updateTrialView = useStore(store, (s) => s.updateTrialView);
@@ -160,6 +179,12 @@ function TrialRuntime({
160
179
  const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>({});
161
180
  const [layerOrder, setLayerOrder] = useState<string[] | null>(null);
162
181
  const [loupeOn, setLoupeOn] = useState(false);
182
+ const [lenses, setLenses] = useState(0);
183
+ const mountLens = useCallback(() => {
184
+ setLenses((n) => n + 1);
185
+ return () => setLenses((n) => n - 1);
186
+ }, []);
187
+ const loupeSwitch = useMemo(() => ({ on: loupeOn, mount: mountLens }), [loupeOn, mountLens]);
163
188
 
164
189
  const visibleLayers = useMemo(
165
190
  () =>
@@ -200,43 +225,69 @@ function TrialRuntime({
200
225
  // resizing or gaining a dependency must not need the store rebuilt. Held in
201
226
  // a ref because the store is built once and closes over it.
202
227
  const annotationsCap = instrument.annotations;
203
- const targetsRef = useRef<() => readonly AnnotationTargetInfo[]>(() => []);
204
- targetsRef.current = () =>
228
+ const targetsRef = useLatest((): readonly AnnotationTargetInfo[] =>
205
229
  annotationsCap
206
230
  ? annotationsCap.targets(record.state, config, { id: record.id, view: record.view })
207
- : [];
231
+ : [],
232
+ );
208
233
 
209
234
  // One store for the trial's lifetime.
210
235
  const annotationsRef = useRef<ReturnType<typeof annotationsFromJSON> | null>(null);
211
236
  // Read through refs for the same reason `targets` is: the store is built
212
237
  // once, and an export must draw against the config the trial holds now.
213
- const configRef = useRef<unknown>(config);
214
- configRef.current = config;
238
+ const configRef = useLatest(config);
215
239
  // The instrument can be swapped under a live trial, so the capability is read at use too.
216
- const capRef = useRef(annotationsCap);
217
- capRef.current = annotationsCap;
240
+ const capRef = useLatest(annotationsCap);
218
241
  if (annotationsRef.current === null) {
219
242
  // Seeded from wherever the marks were kept: the instrument's own store if
220
243
  // it declared one, else this trial's slot.
221
- const kept = annotationsCap?.storage ? keptMarks : record.annotations;
222
- annotationsRef.current = annotationsFromJSON(kept, () => targetsRef.current(), {
223
- meaning: () => capRef.current?.meaning,
224
- config: () => configRef.current,
225
- onCapture: (result) => capRef.current?.onCapture?.(result),
226
- });
244
+ annotationsRef.current = annotationsFromJSON(
245
+ annotationsCap?.storage ? keptMarks : record.annotations,
246
+ () => targetsRef.current(),
247
+ {
248
+ meaning: () => capRef.current?.meaning,
249
+ config: () => configRef.current,
250
+ onCapture: (result) => capRef.current?.onCapture?.(result),
251
+ selection: () => capRef.current?.selection,
252
+ },
253
+ );
227
254
  }
228
255
  const annotations = annotationsRef.current;
229
256
 
257
+ // Chrome reads the store while it renders — a mark list, the export menu,
258
+ // the overview — and the store reads the refs above, which hold the last
259
+ // commit. So once a change to them commits, those readers are told to read
260
+ // again. Through the context's `subscribe` alone: the store's own would have
261
+ // the debounce below save marks that did not change.
262
+ const inputsCommitted = useRef(new Set<() => void>());
263
+ // biome-ignore lint/correctness/useExhaustiveDependencies: these are what `targets` and `config` answer from — the signal, not values this reads
264
+ useLayoutEffect(() => {
265
+ for (const fn of inputsCommitted.current) fn();
266
+ }, [record.state, record.view, config, annotationsCap]);
267
+ const provided = useMemo<AnnotationsApi>(
268
+ () => ({
269
+ ...annotations,
270
+ subscribe: (fn) => {
271
+ const off = annotations.subscribe(fn);
272
+ inputsCommitted.current.add(fn);
273
+ return () => {
274
+ off();
275
+ inputsCommitted.current.delete(fn);
276
+ };
277
+ },
278
+ }),
279
+ [annotations],
280
+ );
281
+
230
282
  // Written on a trailing debounce rather than per notification: a scene
231
283
  // mutates every frame of a drag, and a zustand write per frame re-renders
232
284
  // every trial in the lab. The unmount flush is not an optimization — the
233
285
  // last mark before a trial closes is otherwise lost.
234
286
  const saveCap = annotationsCap?.storage;
235
- const saveRef = useRef<(doc: unknown) => void>(() => {});
236
- saveRef.current = (doc) => {
287
+ const saveRef = useLatest((doc: unknown): void => {
237
288
  if (saveCap) saveCap.save(doc as never);
238
289
  else updateTrialAnnotations(record.id, doc);
239
- };
290
+ });
240
291
  // Only when the pair flips, which is rare: bumping React state on every
241
292
  // scene notification would re-render the trial on every frame of a drag,
242
293
  // which is the thing the debounce below exists to avoid.
@@ -273,7 +324,7 @@ function TrialRuntime({
273
324
  if (timer) clearTimeout(timer);
274
325
  flush();
275
326
  };
276
- }, [annotations, annotationsCap]);
327
+ }, [annotations, annotationsCap, saveRef]);
277
328
 
278
329
  // A trial gets its own slot when its instrument declares tools; otherwise it
279
330
  // reads the lab's. Which slot a change writes follows from the same thing.
@@ -373,14 +424,8 @@ function TrialRuntime({
373
424
  }
374
425
  : undefined;
375
426
 
376
- const loupeCap = useMemo(() => {
377
- const declared = instrument.loupe;
378
- if (declared == null) return null;
379
- return resolveLoupe(typeof declared === 'function' ? declared(config) : declared);
380
- }, [instrument.loupe, config]);
381
- const loupeBindings: LoupeBindings | undefined = loupeCap
382
- ? { on: loupeOn, toggle: () => setLoupeOn((v) => !v) }
383
- : undefined;
427
+ const loupeBindings: LoupeBindings | undefined =
428
+ lenses > 0 ? { on: loupeOn, toggle: () => setLoupeOn((v) => !v) } : undefined;
384
429
 
385
430
  const canvasLayers: CanvasLayerDescriptor[] = useMemo(() => {
386
431
  if (!instrument.canvas) return [];
@@ -471,20 +516,6 @@ function TrialRuntime({
471
516
  ];
472
517
  }, [canvasLayers, dragDropResult.drag]);
473
518
 
474
- // The lens tracks the pointer over whatever box holds the content: the canvas
475
- // stack's own element, which it takes from context, or the wrapper below.
476
- const lens = loupeCap ? (
477
- <TrialLoupe
478
- capability={loupeCap}
479
- enabled={loupeOn}
480
- state={record.state}
481
- config={config}
482
- view={view2d ?? DEFAULT_VIEW}
483
- worldSpec={instrument.canvas?.worldSpec}
484
- hostRef={loupeHostRef}
485
- />
486
- ) : null;
487
-
488
519
  let body: ReactNode;
489
520
  if (instrument.canvas) {
490
521
  body = (
@@ -499,7 +530,6 @@ function TrialRuntime({
499
530
  maxZoom={instrument.canvas.maxZoom}
500
531
  >
501
532
  {instrument.render(renderCtx)}
502
- {lens}
503
533
  </CanvasStack>
504
534
  <DragOverlay drag={dragDropResult.drag} />
505
535
  </div>
@@ -513,24 +543,11 @@ function TrialRuntime({
513
543
  onResize={placeView}
514
544
  minZoom={stage.minZoom}
515
545
  maxZoom={stage.maxZoom}
516
- hostRef={loupeHostRef}
517
- overlay={
518
- <>
519
- {stage.overlay?.(renderCtx)}
520
- {lens}
521
- </>
522
- }
546
+ overlay={stage.overlay?.(renderCtx)}
523
547
  >
524
548
  {instrument.render(renderCtx)}
525
549
  </Stage>
526
550
  );
527
- } else if (lens) {
528
- body = (
529
- <div ref={loupeHostRef} className="lk-trial__loupe-host">
530
- {instrument.render(renderCtx)}
531
- {lens}
532
- </div>
533
- );
534
551
  } else {
535
552
  body = instrument.render(renderCtx);
536
553
  }
@@ -593,21 +610,22 @@ function TrialRuntime({
593
610
  />
594
611
  ) : null;
595
612
 
596
- // One input scope for the trial's body: the camera, the loupe and an
597
- // overview share its actions, its deps and its pointer. Isolated, because an
598
- // actions registry holds one dispatcher and the trial beside this one has
599
- // its own.
613
+ // The trial's root registries, isolated from the trial beside it. Each
614
+ // camera inside mounts an input scope under them; chrome here reaches the
615
+ // one last used. The pointer store is the trial's, shared by all of them.
600
616
  const scopedBody = (
601
617
  <WeaselProvider isolate>
602
618
  <PointerContextProvider store={pointer}>
603
- <CameraScopeContext.Provider value={true}>{body}</CameraScopeContext.Provider>
619
+ <CameraPublishContext.Provider value={publishCamera}>
620
+ <LoupeSwitchContext.Provider value={loupeSwitch}>{body}</LoupeSwitchContext.Provider>
621
+ </CameraPublishContext.Provider>
604
622
  </PointerContextProvider>
605
623
  </WeaselProvider>
606
624
  );
607
625
 
608
626
  return (
609
627
  <TrialIdProvider trialId={record.id}>
610
- <AnnotationsContext.Provider value={annotationsCap ? annotations : null}>
628
+ <AnnotationsContext.Provider value={annotationsCap ? provided : null}>
611
629
  <CameraWheelContext.Provider value={wheelSlot}>
612
630
  <TrialChrome
613
631
  job={jobCap ? job : undefined}
@@ -1,6 +1,14 @@
1
- import { type KeyboardEvent, type ReactNode, useContext, useMemo } from 'react';
1
+ import {
2
+ type KeyboardEvent,
3
+ type ReactNode,
4
+ useContext,
5
+ useMemo,
6
+ useSyncExternalStore,
7
+ } from 'react';
2
8
  import { useStore } from 'zustand/react';
3
- import { builtinContributions } from '../chrome/builtins';
9
+ import { CameraRegistryContext } from '../canvas/cameraRegistry';
10
+ import { zoomCameraTo } from '../canvas/cameraZoom';
11
+ import { builtinContributions, TRANSIENT_BUILTINS } from '../chrome/builtins';
4
12
  import { mergeContributions, suppressContributions } from '../chrome/merge';
5
13
  import { PaletteRegion } from '../chrome/regions/PaletteRegion';
6
14
  import { SidebarRegion } from '../chrome/regions/SidebarRegion';
@@ -30,6 +38,7 @@ export interface LoupeBindings {
30
38
  toggle: () => void;
31
39
  }
32
40
 
41
+ /** The trial's undo history as its chrome sees it. */
33
42
  export interface UndoBindings {
34
43
  canUndo: boolean;
35
44
  canRedo: boolean;
@@ -46,8 +55,8 @@ export interface TrialChromeProps {
46
55
  undoBindings?: UndoBindings;
47
56
  /** Supplied when the instrument declares a `job`. */
48
57
  job?: JobHandle;
49
- /** Supplied when the instrument declares a `loupe`. The lens itself is the
50
- * trial's to mount; the chrome only offers the switch. */
58
+ /** Supplied while a lens is mounted in the trial. The lens itself is the
59
+ * instrument's to mount; the chrome only offers the switch. */
51
60
  loupe?: LoupeBindings;
52
61
  /** Contributions the trial runtime itself adds — the drag palette and the
53
62
  * layer list, which depend on runtime state the instrument cannot reach. */
@@ -68,6 +77,7 @@ const NO_OP = (): void => {};
68
77
  /** One frozen empty map, so a trial that has folded nothing keeps a stable
69
78
  * context identity across renders. */
70
79
  const NO_SECTIONS: Readonly<Record<string, boolean>> = Object.freeze({});
80
+ const noSubscribe = (): (() => void) => NO_OP;
71
81
 
72
82
  /** The frame around a running instrument. Builds one chrome context, assembles
73
83
  * the contributions the instrument, the runtime and the lab declare, and hands
@@ -123,10 +133,18 @@ export function TrialChrome({
123
133
  const title = record.title ?? instrument.title ?? instrument.name;
124
134
  const collapsedSections = record.collapsedSections ?? NO_SECTIONS;
125
135
 
136
+ // The trial's own camera, where it has one: zoom goes through it, about the
137
+ // middle of the view and inside the instrument's range, as the lab's does.
138
+ const cameras = useContext(CameraRegistryContext);
139
+ const camera = useSyncExternalStore(
140
+ cameras?.subscribe ?? noSubscribe,
141
+ () => cameras?.get(trialId) ?? null,
142
+ );
143
+
126
144
  const ctx = useMemo<TrialChromeContext>(() => {
127
145
  const setZoom = (z: number): void => {
128
- if (!view2d) return;
129
- updateTrialView(trialId, withZoom(view2d, z));
146
+ if (camera) zoomCameraTo(camera, z);
147
+ else if (view2d) updateTrialView(trialId, withZoom(view2d, z));
130
148
  };
131
149
  return {
132
150
  trialId,
@@ -140,6 +158,7 @@ export function TrialChrome({
140
158
  canRedo: undoBindings?.canRedo ?? false,
141
159
  undo: undoBindings?.undo ?? NO_OP,
142
160
  redo: undoBindings?.redo ?? NO_OP,
161
+ hasLoupe: loupe != null,
143
162
  loupeOn: loupe?.on ?? false,
144
163
  toggleLoupe: loupe?.toggle ?? NO_OP,
145
164
  configSchema,
@@ -203,6 +222,7 @@ export function TrialChrome({
203
222
  setTrialTitle,
204
223
  setTrialSectionCollapsed,
205
224
  collapsedSections,
225
+ camera,
206
226
  ]);
207
227
 
208
228
  const contributions = useMemo(
@@ -215,6 +235,7 @@ export function TrialChrome({
215
235
  [...(chrome ?? [])],
216
236
  ),
217
237
  suppress ?? [],
238
+ TRANSIENT_BUILTINS,
218
239
  ),
219
240
  [instrument, ctx, trialChrome, chrome, suppress, lab.controls],
220
241
  );
@@ -1,3 +1,5 @@
1
+ export type { LoupeSwitch } from './loupeSwitch';
2
+ export { LoupeSwitchContext } from './loupeSwitch';
1
3
  export type { TrialProps } from './Trial';
2
4
  export { Trial } from './Trial';
3
5
  export type { TrialBodyProps } from './TrialBody';
@@ -0,0 +1,15 @@
1
+ import { createContext } from 'react';
2
+
3
+ /**
4
+ * A trial's loupe switch, as a lens mounted inside the trial finds it. The
5
+ * trial offers its toolbar toggle while at least one lens has called `mount`,
6
+ * and every lens reads `on` as whether it is turned on.
7
+ */
8
+ export interface LoupeSwitch {
9
+ on: boolean;
10
+ /** Reports a lens mounted; the returned function reports it gone. */
11
+ mount: () => () => void;
12
+ }
13
+
14
+ /** The loupe switch of the trial around the caller, or `null` outside one. */
15
+ export const LoupeSwitchContext = createContext<LoupeSwitch | null>(null);
@@ -1,83 +0,0 @@
1
- import * as react from 'react';
2
- import { ReactNode } from 'react';
3
- import { S as StanceProps } from './PrefsRow.d-DFpPIeIC.js';
4
-
5
- /** One layer. With a body (see `renderBody`) it draws as a card that folds its
6
- * body away; without one, as a one-line row. Either can hold child layers. */
7
- interface LayerListItem {
8
- id: string;
9
- /** The layer's name. Also what its controls are labeled by. */
10
- label: string;
11
- /** Shown in the header in place of `label` — a select that switches the
12
- * layer's kind, say. */
13
- title?: ReactNode;
14
- /** Fixed-size content before the label: a color swatch, a kind icon. */
15
- leading?: ReactNode;
16
- /** Drawn in the drag handle in place of the grip — an ordinal. */
17
- badge?: ReactNode;
18
- /** Which of its list this layer is: an index into the theme's tone list, or
19
- * a color. A card draws its edge and handle in it and recolors its controls. */
20
- tone?: StanceProps['tone'];
21
- /** Cannot be dragged, hidden or removed, walls off drops on either side, and
22
- * selects alone rather than joining a multi-selection. */
23
- locked?: boolean;
24
- /** Shown or not, when the list has `onVisibilityChange`. Default true. */
25
- visible?: boolean;
26
- children?: LayerListItem[];
27
- /** Folds the body and children away until the user opens them.
28
- * `collapsedIds` overrides it. */
29
- defaultCollapsed?: boolean;
30
- }
31
- /**
32
- * A reorder: `ids`, siblings under `parentId` (`null` at the top level), now
33
- * sit as one block in list order starting at `index` of the parent's children.
34
- * A drag never moves a layer to another parent.
35
- */
36
- interface LayerMove {
37
- ids: string[];
38
- parentId: string | null;
39
- index: number;
40
- }
41
- /** Props for `<LayerList>`. */
42
- interface LayerListProps {
43
- items: LayerListItem[];
44
- /** Omit for a list the user cannot reorder. */
45
- onReorder?: (move: LayerMove) => void;
46
- /** Giving both makes rows selectable: a click selects one, shift-click adds
47
- * or removes one, and dragging a selected row drags its selected siblings. */
48
- selectedIds?: readonly string[];
49
- onSelect?: (ids: string[]) => void;
50
- /** Adds a visibility checkbox to every unlocked layer. */
51
- onVisibilityChange?: (id: string, visible: boolean) => void;
52
- /** Adds a ✕ to every unlocked layer. */
53
- onRemove?: (id: string) => void;
54
- /** A layer's controls. Returning `null` leaves that layer a one-line row. */
55
- renderBody?: (item: LayerListItem) => ReactNode;
56
- /** Ids whose body and children are folded away. Giving it makes folding
57
- * controlled; `onCollapsedChange` fires either way. */
58
- collapsedIds?: readonly string[];
59
- onCollapsedChange?: (ids: string[]) => void;
60
- /** Heading above the list. */
61
- title?: string;
62
- /** Names the list for assistive tech when it has no `title`. */
63
- label?: string;
64
- /** Kinds the user can add, as buttons beside the title. Shown only with `onAdd`. */
65
- addKinds?: string[];
66
- onAdd?: (kind: string) => void;
67
- /** Shown in place of the list when `items` is empty. */
68
- empty?: ReactNode;
69
- /** Appended to the root's class list — the module's own names are hashed. */
70
- className?: string;
71
- }
72
- /** Returns `items` with `move` applied, for a consumer that holds the tree as state. */
73
- declare function moveLayers(items: LayerListItem[], move: LayerMove): LayerListItem[];
74
- /**
75
- * An ordered, drag-reorderable list of layers — a document's objects, a lab's
76
- * canvases, a fill's effects. Each layer is a one-line row or, with a body, a
77
- * card whose controls fold away; either can nest child layers. Selection,
78
- * visibility, removal and an add palette are each on when their handler is given.
79
- */
80
- declare function LayerList(props: LayerListProps): react.JSX.Element;
81
-
82
- export { LayerList as L, moveLayers as m };
83
- export type { LayerListItem as a, LayerListProps as b, LayerMove as c };
@@ -1,39 +0,0 @@
1
- import { Stance } from '@weasel-js/theme';
2
- import { ReactNode } from 'react';
3
- import { ToolPrefLeaf } from '@weasel-js/core';
4
-
5
- /**
6
- * What a surface holds, and which of its peers it is. Every surface that takes
7
- * these takes both, and the theme decides how each looks.
8
- */
9
- interface StanceProps {
10
- /** The class of content the surface holds. Omitted, it draws as it always has. */
11
- stance?: Stance;
12
- /**
13
- * Which of its peers this is: an index into the theme's tone list, wrapping,
14
- * or a color given directly. Independent of `stance`, and allowed without one.
15
- */
16
- tone?: number | string;
17
- }
18
-
19
- /** What a {@link PrefRenderer} is given for the leaf it is rendering. */
20
- interface PrefRenderContext {
21
- /** Dotted path of the leaf within the schema root. */
22
- path: string;
23
- /** The schema node. App renderers narrow this to their own kind shape. */
24
- pref: ToolPrefLeaf;
25
- /** Current value — `values` at `path`, falling back to `pref.default`. */
26
- value: unknown;
27
- setValue: (value: unknown) => void;
28
- /** Whether this leaf is currently auto — not pinned, computed by the owner. */
29
- auto: boolean;
30
- /** Toggle this leaf's auto state. */
31
- setAuto: (next: boolean) => void;
32
- }
33
- /**
34
- * Renders the control cell for one preference leaf. Returning `null`
35
- * collapses the row.
36
- */
37
- type PrefRenderer = (ctx: PrefRenderContext) => ReactNode;
38
-
39
- export type { PrefRenderer as P, StanceProps as S, PrefRenderContext as a };