@weasel-js/labkit 1.7.0 → 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 (208) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-D2jnIwfk.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-CCWfQQWM.d.ts → Stage-D6t7o0pF.d.ts} +3 -2
  4. package/dist/_dts/{index-CsYqUHD3.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-CSm9jgol.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-CRPSGFAp.d.ts → types-BUBSIZuV.d.ts} +8 -7
  7. package/dist/_dts/{useTrialState-CTZPgeZ7.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 +7 -5
  11. package/dist/chrome/index.js +10 -9
  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-7BO7HNGF.js → chunk-CEGFXTBD.js} +8 -61
  19. package/dist/chunk-CEGFXTBD.js.map +1 -0
  20. package/dist/{chunk-275JRRVD.js → chunk-E23Q6A3Y.js} +232 -37
  21. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  22. package/dist/{chunk-GOZLNM4L.js → chunk-E6NARGU7.js} +5 -5
  23. package/dist/{chunk-GOZLNM4L.js.map → chunk-E6NARGU7.js.map} +1 -1
  24. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  25. package/dist/chunk-EESJUSYR.js.map +1 -0
  26. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  27. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  28. package/dist/chunk-F4ACIK7Z.js +210 -0
  29. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  30. package/dist/{chunk-FMK7UQ6V.js → chunk-FB4MIZOO.js} +61 -44
  31. package/dist/chunk-FB4MIZOO.js.map +1 -0
  32. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  33. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  34. package/dist/{chunk-I5DUQ5HM.js → chunk-GAHELLJT.js} +75 -72
  35. package/dist/chunk-GAHELLJT.js.map +1 -0
  36. package/dist/{chunk-YT75VYRD.js → chunk-IBVRMHW5.js} +5 -5
  37. package/dist/{chunk-YT75VYRD.js.map → chunk-IBVRMHW5.js.map} +1 -1
  38. package/dist/{chunk-T5IBSPAM.js → chunk-JFOJESJF.js} +33 -23
  39. package/dist/chunk-JFOJESJF.js.map +1 -0
  40. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  41. package/dist/chunk-PMLZP7RT.js.map +1 -0
  42. package/dist/{chunk-Y23G2VZL.js → chunk-READTDQX.js} +2 -2
  43. package/dist/chunk-READTDQX.js.map +1 -0
  44. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  45. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  46. package/dist/{chunk-ZIFPHIWZ.js → chunk-T6DLGEJB.js} +12 -14
  47. package/dist/chunk-T6DLGEJB.js.map +1 -0
  48. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  49. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  50. package/dist/{chunk-UNAZSLWE.js → chunk-VYVGVBGY.js} +17 -20
  51. package/dist/chunk-VYVGVBGY.js.map +1 -0
  52. package/dist/{chunk-KMJ7RWD3.js → chunk-WLMDTI2Q.js} +3 -3
  53. package/dist/{chunk-KMJ7RWD3.js.map → chunk-WLMDTI2Q.js.map} +1 -1
  54. package/dist/{chunk-J7OWWTCW.js → chunk-XE5HJOPL.js} +5 -6
  55. package/dist/chunk-XE5HJOPL.js.map +1 -0
  56. package/dist/chunk-XVUKDSYV.js +38 -0
  57. package/dist/chunk-XVUKDSYV.js.map +1 -0
  58. package/dist/{chunk-FZ7YFQN7.js → chunk-YCFKW5RX.js} +3 -3
  59. package/dist/chunk-YCFKW5RX.js.map +1 -0
  60. package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
  61. package/dist/chunk-ZQIHJXP4.js.map +1 -0
  62. package/dist/config/index.d.ts +18 -10
  63. package/dist/config/index.js +4 -4
  64. package/dist/controls/index.d.ts +4 -7
  65. package/dist/controls/index.js +6 -5
  66. package/dist/dragdrop/index.d.ts +23 -3
  67. package/dist/dragdrop/index.js +4 -3
  68. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  69. package/dist/index.d.ts +60 -714
  70. package/dist/index.js +357 -334
  71. package/dist/index.js.map +1 -1
  72. package/dist/job/index.d.ts +8 -0
  73. package/dist/job/index.js +1 -1
  74. package/dist/layers/index.d.ts +6 -5
  75. package/dist/layers/index.js +3 -2
  76. package/dist/loupe/index.d.ts +100 -30
  77. package/dist/loupe/index.js +585 -6
  78. package/dist/loupe/index.js.map +1 -1
  79. package/dist/overview/index.d.ts +4 -2
  80. package/dist/overview/index.js +15 -14
  81. package/dist/overview/index.js.map +1 -1
  82. package/dist/passthrough/weasel-canvas.d.ts +2 -0
  83. package/dist/passthrough/weasel-ui.d.ts +2 -2871
  84. package/dist/passthrough/weasel-ui.js +3 -2
  85. package/dist/primitives/index.d.ts +213 -3
  86. package/dist/primitives/index.js +8 -7
  87. package/dist/state/index.d.ts +7 -5
  88. package/dist/state/index.js +6 -6
  89. package/dist/styles.css +60 -44
  90. package/dist/surface/index.d.ts +10 -2
  91. package/dist/surface/index.js +1 -1
  92. package/dist/undo/index.d.ts +5 -3
  93. package/package.json +11 -11
  94. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  95. package/src/annotations/AnnotationOverlay.tsx +50 -31
  96. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  97. package/src/annotations/capture.browser.test.ts +41 -0
  98. package/src/annotations/capture.ts +14 -7
  99. package/src/annotations/drawOne.ts +2 -2
  100. package/src/annotations/frac.test.ts +44 -1
  101. package/src/annotations/frac.ts +46 -18
  102. package/src/annotations/index.ts +1 -0
  103. package/src/annotations/paint.test.ts +74 -33
  104. package/src/annotations/paint.ts +70 -32
  105. package/src/annotations/store.test.ts +212 -2
  106. package/src/annotations/store.ts +130 -27
  107. package/src/annotations/svgNodes.test.ts +22 -10
  108. package/src/annotations/svgNodes.ts +3 -3
  109. package/src/annotations/types.ts +29 -10
  110. package/src/canvas/CameraInput.test.tsx +30 -0
  111. package/src/canvas/CameraInput.tsx +16 -15
  112. package/src/canvas/CanvasStack.tsx +5 -5
  113. package/src/canvas/CanvasStackContext.ts +4 -1
  114. package/src/canvas/Stage.tsx +3 -3
  115. package/src/canvas/cameraRegistry.ts +1 -0
  116. package/src/canvas/worldSpec.ts +3 -1
  117. package/src/chrome/builtins.test.ts +7 -6
  118. package/src/chrome/builtins.tsx +5 -1
  119. package/src/chrome/index.ts +1 -1
  120. package/src/chrome/merge.test.ts +5 -0
  121. package/src/chrome/merge.ts +4 -2
  122. package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
  123. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  124. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  125. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  126. package/src/chrome/regions/ViewportRegion.less +6 -0
  127. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  128. package/src/chrome/types.ts +4 -2
  129. package/src/config/auto.ts +1 -0
  130. package/src/config/builder.ts +6 -0
  131. package/src/config/types.ts +2 -1
  132. package/src/controls/ControlPanel.test.tsx +37 -3
  133. package/src/controls/ControlPanel.tsx +12 -3
  134. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  135. package/src/dragdrop/DragGhost.tsx +3 -0
  136. package/src/dragdrop/Palette.tsx +2 -0
  137. package/src/index.test.ts +5 -0
  138. package/src/index.ts +1 -1
  139. package/src/instrument/types.ts +0 -6
  140. package/src/job/useJob.ts +14 -9
  141. package/src/lab/Lab.tsx +5 -1
  142. package/src/lab/LabShell.less +1 -0
  143. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  144. package/src/lab/LabSwitcher.less +2 -0
  145. package/src/lab/LabZoom.tsx +4 -4
  146. package/src/lab/Workspace.tsx +48 -52
  147. package/src/loupe/AGENTS.md +28 -15
  148. package/src/loupe/CanvasLoupe.tsx +2 -3
  149. package/src/loupe/DomLoupe.tsx +1 -7
  150. package/src/loupe/Loupe.less +16 -0
  151. package/src/loupe/TrialLoupe.tsx +120 -57
  152. package/src/loupe/index.ts +1 -6
  153. package/src/loupe/types.test.ts +3 -3
  154. package/src/loupe/types.ts +24 -27
  155. package/src/loupe/useLoupe.test.tsx +29 -4
  156. package/src/loupe/useLoupe.ts +20 -17
  157. package/src/overview/OverviewMarks.tsx +12 -4
  158. package/src/overview/TrialOverview.tsx +3 -3
  159. package/src/passthrough/weasel-ui.test.ts +1 -1
  160. package/src/passthrough/weasel-ui.ts +1 -1
  161. package/src/primitives/FloatingPanel.tsx +7 -3
  162. package/src/primitives/JobProgress.less +1 -0
  163. package/src/primitives/Readout.test.tsx +2 -2
  164. package/src/primitives/Split.tsx +69 -41
  165. package/src/primitives/Toolbar.tsx +3 -3
  166. package/src/primitives/ZoomControl.less +1 -2
  167. package/src/specimen/Specimen.tsx +0 -1
  168. package/src/state/undock.ts +3 -0
  169. package/src/state/useOpenOnce.ts +20 -16
  170. package/src/state/view.ts +1 -0
  171. package/src/surface/index.ts +1 -1
  172. package/src/surface/useTiledSurface.test.tsx +14 -0
  173. package/src/surface/useTiledSurface.ts +11 -3
  174. package/src/trial/Trial.annotations.test.tsx +73 -0
  175. package/src/trial/Trial.less +0 -16
  176. package/src/trial/Trial.loupe.test.tsx +28 -11
  177. package/src/trial/Trial.tsx +74 -62
  178. package/src/trial/TrialChrome.tsx +6 -3
  179. package/src/trial/index.ts +2 -0
  180. package/src/trial/loupeSwitch.ts +15 -0
  181. package/dist/_dts/LayerList.d-DiuEArKq.d.ts +0 -83
  182. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +0 -79
  183. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +0 -141
  184. package/dist/_dts/Select.d-Bensr_MT.d.ts +0 -579
  185. package/dist/_dts/index-B2Yj5aA0.d.ts +0 -274
  186. package/dist/chunk-275JRRVD.js.map +0 -1
  187. package/dist/chunk-4LJSC7MB.js +0 -143
  188. package/dist/chunk-4LJSC7MB.js.map +0 -1
  189. package/dist/chunk-7BO7HNGF.js.map +0 -1
  190. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  191. package/dist/chunk-FMK7UQ6V.js.map +0 -1
  192. package/dist/chunk-FZ7YFQN7.js.map +0 -1
  193. package/dist/chunk-HXXTULDN.js.map +0 -1
  194. package/dist/chunk-I5DUQ5HM.js.map +0 -1
  195. package/dist/chunk-IJJUK4FU.js.map +0 -1
  196. package/dist/chunk-J7OWWTCW.js.map +0 -1
  197. package/dist/chunk-M7HP3FTK.js +0 -549
  198. package/dist/chunk-M7HP3FTK.js.map +0 -1
  199. package/dist/chunk-MIM5R4ZT.js +0 -14972
  200. package/dist/chunk-MIM5R4ZT.js.map +0 -1
  201. package/dist/chunk-RWNDDKOH.js.map +0 -1
  202. package/dist/chunk-T5IBSPAM.js.map +0 -1
  203. package/dist/chunk-UNAZSLWE.js.map +0 -1
  204. package/dist/chunk-WPLAANH4.js.map +0 -1
  205. package/dist/chunk-Y23G2VZL.js.map +0 -1
  206. package/dist/chunk-ZIFPHIWZ.js.map +0 -1
  207. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  208. package/src/primitives/useRovingTabIndex.ts +0 -88
@@ -8,7 +8,17 @@ import {
8
8
  import { captureTarget } from './capture';
9
9
  import { type MarkDrawOptions, resolveMarkStyle } from './drawOne';
10
10
  import type { WorldRect } from './frac';
11
- import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
11
+ import {
12
+ boundsOf,
13
+ fracContains,
14
+ fracEncloses,
15
+ fracToWorld,
16
+ fromShape,
17
+ roundFrac,
18
+ roundPoints,
19
+ toShape,
20
+ worldToFrac,
21
+ } from './frac';
12
22
  import { MarkHistory } from './history';
13
23
  import { isStale as isStaleAgainst, seenFrom } from './staleness';
14
24
  import type {
@@ -18,6 +28,7 @@ import type {
18
28
  AnnotationMeaning,
19
29
  AnnotationPatch,
20
30
  AnnotationQuery,
31
+ AnnotationSelectionMode,
21
32
  AnnotationsApi,
22
33
  AnnotationTargetInfo,
23
34
  CaptureOptions,
@@ -38,6 +49,7 @@ export function createAnnotationScene(): MarkScene {
38
49
  return createScene<AnnotationData, 'marks', MarkPose>({ systemLayers: [{ id: 'marks' }] });
39
50
  }
40
51
 
52
+ /** Inputs to `createAnnotationStore`. */
41
53
  export interface AnnotationStoreOptions {
42
54
  /** Re-read on every call, so a target resizing or gaining a dependency takes
43
55
  * effect without rebuilding the store. */
@@ -53,6 +65,10 @@ export interface AnnotationStoreOptions {
53
65
  config?: () => unknown;
54
66
  /** Notified after every finished export. */
55
67
  onCapture?: (result: CaptureResult) => void;
68
+ /** Whether a selection can stand in several targets at once. `'exclusive'`
69
+ * (the default) clears every other target when a mark is selected in one.
70
+ * A thunk is read at each selection change. */
71
+ selection?: AnnotationSelectionMode | (() => AnnotationSelectionMode | undefined);
56
72
  }
57
73
 
58
74
  /** Everything a capture needs that is not the store's own state. Split out so
@@ -66,6 +82,34 @@ export interface CaptureDeps {
66
82
 
67
83
  const NO_CONTENT = { w: 0, h: 0 };
68
84
 
85
+ /** Where a mark's vertices sit in its own bounds, from points in the content
86
+ * box. A point has none to store: its bounds are its position. */
87
+ function shapeFor(
88
+ kind: AnnotationData['kind'],
89
+ points: readonly FracPoint[] | undefined,
90
+ frac: FracRect,
91
+ ): readonly FracPoint[] | undefined {
92
+ if (!points || kind === 'point') return undefined;
93
+ return roundPoints(toShape(points, frac));
94
+ }
95
+
96
+ type SerializedMarks = SerializedScene<AnnotationData, 'marks', MarkPose>;
97
+
98
+ /** Scenes saved before vertices followed the bounds hold them as `points` in
99
+ * the content box. Restated against each mark's bounds as saved. */
100
+ function migrateLegacyPoints(
101
+ raw: SerializedMarks,
102
+ content: { w: number; h: number },
103
+ ): SerializedMarks {
104
+ const nodes = raw.nodes.map((n) => {
105
+ const { points, ...data } = n.data as AnnotationData & { points?: readonly FracPoint[] };
106
+ if (!points || data.shape) return n;
107
+ const shape = shapeFor(data.kind, points, worldToFrac(n.pose, content));
108
+ return { ...n, data: { ...data, ...(shape ? { shape } : {}) } };
109
+ });
110
+ return { ...raw, nodes };
111
+ }
112
+
69
113
  /** A mark's id is its target and its node. One scene per target, so a node id
70
114
  * is only unique within one — and an annotation is addressed by both. */
71
115
  function splitId(id: string): { target: string; node: string } | undefined {
@@ -92,21 +136,73 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
92
136
  for (const fn of subs) fn();
93
137
  };
94
138
 
139
+ // Scene changes made while held are told to subscribers as one, once the
140
+ // outermost hold ends — so a click that also clears another target, or a
141
+ // `setSelection` spanning several, is one event with the final answer.
142
+ let holding = 0;
143
+ let heldDirty = false;
144
+ const hold = (fn: () => void): void => {
145
+ holding += 1;
146
+ try {
147
+ fn();
148
+ } finally {
149
+ holding -= 1;
150
+ }
151
+ if (holding === 0 && heldDirty) {
152
+ heldDirty = false;
153
+ notify();
154
+ }
155
+ };
156
+
157
+ const exclusive = (): boolean =>
158
+ (typeof opts.selection === 'function' ? opts.selection() : opts.selection) !== 'per-target';
159
+
160
+ /** Each scene's selection as last seen, to tell a selection change from any
161
+ * other change arriving on the same channel. */
162
+ const lastSelection = new Map<string, string>();
163
+ const selectionKey = (scene: MarkScene): string => scene.getSelection().map(String).join(' ');
164
+
165
+ const clearOthers = (keep: string): void => {
166
+ for (const [target, scene] of scenes) {
167
+ if (target !== keep && scene.getSelection().length > 0) scene.setSelection([]);
168
+ }
169
+ };
170
+
171
+ const targetOf = (id: string): AnnotationTargetInfo | undefined =>
172
+ targets().find((t) => t.id === id);
173
+
174
+ const contentOf = (id: string) => targetOf(id)?.content ?? NO_CONTENT;
175
+
95
176
  const sceneFor = (target: string): MarkScene => {
96
177
  const known = scenes.get(target);
97
178
  if (known) return known;
98
179
  const raw = restore?.[target];
99
180
  const scene = raw
100
181
  ? sceneFromJSON<AnnotationData, 'marks', MarkPose>(
101
- raw as SerializedScene<AnnotationData, 'marks', MarkPose>,
182
+ migrateLegacyPoints(raw as SerializedMarks, contentOf(target)),
102
183
  {},
103
184
  )
104
185
  : createAnnotationScene();
105
186
  scenes.set(target, scene);
106
187
  history.track(target, scene);
188
+ lastSelection.set(target, selectionKey(scene));
107
189
  scene.subscribe(() => {
108
190
  history.observe(target, scene);
109
- notify();
191
+ const selected = selectionKey(scene);
192
+ const newlySelected = selected !== '' && selected !== lastSelection.get(target);
193
+ lastSelection.set(target, selected);
194
+ if (holding > 0) {
195
+ heldDirty = true;
196
+ return;
197
+ }
198
+ if (newlySelected && exclusive()) {
199
+ hold(() => {
200
+ heldDirty = true;
201
+ clearOthers(target);
202
+ });
203
+ } else {
204
+ notify();
205
+ }
110
206
  });
111
207
  return scene;
112
208
  };
@@ -120,10 +216,6 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
120
216
  // a mark a sidebar cannot list.
121
217
  for (const target of Object.keys(restore ?? {})) sceneFor(target);
122
218
 
123
- const targetOf = (id: string): AnnotationTargetInfo | undefined =>
124
- targets().find((t) => t.id === id);
125
-
126
- const contentOf = (id: string) => targetOf(id)?.content ?? NO_CONTENT;
127
219
  /** How a target's marks are drawn right now — one answer for the pane, an
128
220
  * export and an overview, so none can disagree on a color or a dash. */
129
221
  const drawOptionsFor = (info: AnnotationTargetInfo): MarkDrawOptions => ({
@@ -136,12 +228,13 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
136
228
  const project = (target: string, node: string): Annotation | undefined => {
137
229
  const found = scenes.get(target)?.get(asNodeId(node));
138
230
  if (!found) return undefined;
139
- return {
140
- ...found.data,
141
- target,
142
- id: `${target}/${node}`,
143
- frac: roundFrac(worldToFrac(found.pose as MarkPose, contentOf(target))),
144
- };
231
+ const { shape, ...data } = found.data;
232
+ const frac = roundFrac(worldToFrac(found.pose as MarkPose, contentOf(target)));
233
+ const points =
234
+ data.kind === 'point'
235
+ ? [{ x: frac.x, y: frac.y }]
236
+ : shape && roundPoints(fromShape(shape, frac));
237
+ return { ...data, target, id: `${target}/${node}`, frac, ...(points ? { points } : {}) };
145
238
  };
146
239
 
147
240
  /** Every target with a scene, in declaration order — then any whose target
@@ -245,20 +338,24 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
245
338
 
246
339
  setSelection(ids) {
247
340
  const wanted = new Map<string, string[]>();
341
+ const only = exclusive();
248
342
  for (const id of ids) {
249
343
  const found = nodeOf(id);
250
344
  if (!found) continue;
345
+ if (only && wanted.size > 0 && !wanted.has(found.target)) continue;
251
346
  const node = String(found.node.id);
252
347
  const on = wanted.get(found.target);
253
348
  if (!on) wanted.set(found.target, [node]);
254
349
  else if (!on.includes(node)) on.push(node);
255
350
  }
256
- for (const [target, scene] of scenes) {
257
- const next = wanted.get(target) ?? [];
258
- const now = scene.getSelection();
259
- if (now.length === next.length && next.every((n, i) => String(now[i]) === n)) continue;
260
- scene.setSelection(next.map(asNodeId));
261
- }
351
+ hold(() => {
352
+ for (const [target, scene] of scenes) {
353
+ const next = wanted.get(target) ?? [];
354
+ const now = scene.getSelection();
355
+ if (now.length === next.length && next.every((n, i) => String(now[i]) === n)) continue;
356
+ scene.setSelection(next.map(asNodeId));
357
+ }
358
+ });
262
359
  },
263
360
 
264
361
  subscribe(fn) {
@@ -270,10 +367,12 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
270
367
 
271
368
  add(init: AnnotationInit, config?: unknown) {
272
369
  const target = targetOf(init.target);
370
+ const frac = roundFrac(init.frac);
371
+ const shape = shapeFor(init.kind, init.points, frac);
273
372
  const data: AnnotationData = {
274
373
  target: init.target,
275
374
  kind: init.kind,
276
- ...(init.points ? { points: init.points } : {}),
375
+ ...(shape ? { shape } : {}),
277
376
  ...(init.title !== undefined ? { title: init.title } : {}),
278
377
  ...(init.status !== undefined ? { status: init.status } : {}),
279
378
  ...(init.tags !== undefined ? { tags: init.tags } : {}),
@@ -283,7 +382,7 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
283
382
  const node = sceneFor(init.target).add({
284
383
  kind: 'leaf',
285
384
  layer: 'marks',
286
- pose: fracToWorld(roundFrac(init.frac), target?.content ?? NO_CONTENT),
385
+ pose: fracToWorld(frac, target?.content ?? NO_CONTENT),
287
386
  data,
288
387
  });
289
388
  return `${init.target}/${String(node)}`;
@@ -292,14 +391,18 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
292
391
  update(id, patch: AnnotationPatch) {
293
392
  const found = nodeOf(id);
294
393
  if (!found) return;
295
- const { frac, ...meaning } = patch;
394
+ const { frac: fracPatch, points, ...meaning } = patch;
296
395
  const nid = asNodeId(found.node.id);
297
- if (Object.keys(meaning).length > 0) {
298
- found.scene.update(nid, { data: { ...found.node.data, ...meaning } });
299
- }
300
- if (frac) {
301
- found.scene.setPose(nid, fracToWorld(roundFrac(frac), contentOf(found.target)));
396
+ const content = contentOf(found.target);
397
+ const frac = fracPatch ?? (points ? boundsOf(points) : null);
398
+ const bounds = roundFrac(frac ?? worldToFrac(found.node.pose as MarkPose, content));
399
+ const shape = points ? shapeFor(found.node.data.kind, points, bounds) : undefined;
400
+ if (Object.keys(meaning).length > 0 || shape) {
401
+ found.scene.update(nid, {
402
+ data: { ...found.node.data, ...meaning, ...(shape ? { shape } : {}) },
403
+ });
302
404
  }
405
+ if (frac) found.scene.setPose(nid, fracToWorld(bounds, content));
303
406
  },
304
407
 
305
408
  setMeta(id, meta) {
@@ -1,17 +1,15 @@
1
- import { serializeSvg } from '@weasel-js/svg';
1
+ import { serializeSvg, svgNeeds } from '@weasel-js/svg';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import { markCommands } from './paint';
4
4
  import { markSvgNodes } from './svgNodes';
5
5
  import type { AnnotationData, AnnotationKind } from './types';
6
6
 
7
- const CONTENT = { w: 100, h: 60 };
8
-
9
7
  const mark = (kind: AnnotationKind, extra: Partial<AnnotationData> = {}) => ({
10
8
  pose: { x: 10, y: 12, width: 30, height: 20 },
11
9
  data: {
12
10
  target: 'flat',
13
11
  kind,
14
- points: [
12
+ shape: [
15
13
  { x: 0.1, y: 0.2 },
16
14
  { x: 0.5, y: 0.6 },
17
15
  ],
@@ -24,8 +22,8 @@ const KINDS: AnnotationKind[] = ['rect', 'ellipse', 'line', 'arrow', 'stroke'];
24
22
  describe('a mark as vector', () => {
25
23
  it.each(KINDS)('emits %s off the same geometry the screen draws', (kind) => {
26
24
  const m = mark(kind);
27
- const cmds = markCommands(m, CONTENT);
28
- const nodes = markSvgNodes(m, CONTENT);
25
+ const cmds = markCommands(m);
26
+ const nodes = markSvgNodes(m);
29
27
  expect(nodes).toHaveLength(cmds.length);
30
28
  for (const [i, cmd] of cmds.entries()) {
31
29
  if (cmd.kind !== 'path') throw new Error('expected path commands');
@@ -46,13 +44,13 @@ describe('a mark as vector', () => {
46
44
  });
47
45
 
48
46
  it('carries a stale mark′s dash through', () => {
49
- const [node] = markSvgNodes(mark('rect'), CONTENT, { stale: true });
47
+ const [node] = markSvgNodes(mark('rect'), { stale: true });
50
48
  if (node?.kind !== 'path') throw new Error('expected a path node');
51
49
  expect(node.stroke?.dash).toEqual([6, 4]);
52
50
  });
53
51
 
54
52
  it("keeps the arrow's marker, so the export draws its head", () => {
55
- const nodes = markSvgNodes(mark('arrow'), CONTENT);
53
+ const nodes = markSvgNodes(mark('arrow'));
56
54
  expect(nodes).toHaveLength(1);
57
55
  const [node] = nodes;
58
56
  if (node?.kind !== 'path') throw new Error('expected a path node');
@@ -64,11 +62,25 @@ describe('a mark as vector', () => {
64
62
 
65
63
  it('emits text as text, at the pose, in the mark color', () => {
66
64
  const m = mark('text', { title: 'missing edge' });
67
- const [node] = markSvgNodes(m, CONTENT);
65
+ const [node] = markSvgNodes(m);
68
66
  expect(node).toMatchObject({ kind: 'text', text: 'missing edge', x: 10, y: 12 });
69
67
  });
70
68
 
71
69
  it('emits nothing for a text mark with no words', () => {
72
- expect(markSvgNodes(mark('text'), CONTENT)).toEqual([]);
70
+ expect(markSvgNodes(mark('text'))).toEqual([]);
71
+ });
72
+
73
+ // Why `composeCaptureSvg` and `<OverviewMarks>` serialize without awaiting
74
+ // `warmSvg`: a mark exports nothing that loads on demand. A mark gaining a
75
+ // gradient paint or a script run fails this, and those two then need it.
76
+ it('needs nothing loaded to serialize, stale or not', () => {
77
+ const all: AnnotationKind[] = [...KINDS, 'point', 'text'];
78
+ for (const stale of [false, true]) {
79
+ const nodes = all.flatMap((k) =>
80
+ markSvgNodes(mark(k, { title: 'words' }), { stale, color: '#123456' }),
81
+ );
82
+ expect(nodes.length).toBeGreaterThan(all.length - 1);
83
+ expect(svgNeeds(nodes)).toEqual({ fonts: [], paintKinds: [] });
84
+ }
73
85
  });
74
86
  });
@@ -1,4 +1,4 @@
1
- import type { DrawCommand, FillStyle, Stroke } from '@weasel-js/core';
1
+ import type { DrawCommand, FillStyle, Stroke, View } from '@weasel-js/core';
2
2
  import type { SvgNode, SvgPaint, SvgStroke } from '@weasel-js/svg';
3
3
  import { type MarkStyle, markCommands, type PaintableMark } from './paint';
4
4
 
@@ -67,8 +67,8 @@ function toSvgNode(cmd: DrawCommand, m: PaintableMark): SvgNode {
67
67
  */
68
68
  export function markSvgNodes(
69
69
  m: PaintableMark,
70
- content: { w: number; h: number },
71
70
  style: MarkStyle = {},
71
+ scale?: View['scale'],
72
72
  ): SvgNode[] {
73
- return markCommands(m, content, style).map((cmd) => toSvgNode(cmd, m));
73
+ return markCommands(m, style, scale).map((cmd) => toSvgNode(cmd, m));
74
74
  }
@@ -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
@@ -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
  });
@@ -14,6 +14,7 @@ import {
14
14
  useActionsRegistry,
15
15
  useDepSource,
16
16
  useGestureDispatcher,
17
+ useLatest,
17
18
  usePointerContext,
18
19
  type View,
19
20
  type ViewApi,
@@ -26,6 +27,7 @@ import {
26
27
  type RefObject,
27
28
  useContext,
28
29
  useEffect,
30
+ useInsertionEffect,
29
31
  useMemo,
30
32
  useRef,
31
33
  } from 'react';
@@ -111,15 +113,18 @@ export function useCameraView({
111
113
  minZoom = 0.1,
112
114
  maxZoom = 32,
113
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
129
  return useMemo<CameraView>(() => {
125
130
  const get = (): View => toCameraView(live.current.view, live.current.frame);
@@ -140,7 +145,7 @@ export function useCameraView({
140
145
  },
141
146
  zoomRange: () => ({ ...bounds.current }),
142
147
  };
143
- }, [hostRef]);
148
+ }, [bounds, hostRef]);
144
149
  }
145
150
 
146
151
  const TAP_ID = 'camera.tap';
@@ -177,10 +182,8 @@ function CameraDispatch({
177
182
  onTap,
178
183
  registry,
179
184
  }: CameraInputProps & { registry: ActionsRegistry }) {
180
- const frameRef = useRef(frame);
181
- frameRef.current = frame;
182
- const onTapRef = useRef(onTap);
183
- onTapRef.current = onTap;
185
+ const frameRef = useLatest(frame);
186
+ const onTapRef = useLatest(onTap);
184
187
 
185
188
  useDepSource('view', () => camera);
186
189
  useDepSource('rootView', () => camera);
@@ -217,7 +220,7 @@ function CameraDispatch({
217
220
  { ...zoom, defaultBinding: wheelOnly as Action['defaultBinding'] },
218
221
  tap,
219
222
  ];
220
- }, []);
223
+ }, [frameRef, onTapRef]);
221
224
 
222
225
  useEffect(() => {
223
226
  const offs = actions.map((a) => registry.register(a));
@@ -284,10 +287,8 @@ export function usePublishPointer(
284
287
  viewId: string,
285
288
  ): void {
286
289
  const store = usePointerContext();
287
- const frameRef = useRef(frame);
288
- frameRef.current = frame;
289
- const toLocal = useRef(clientToLocal);
290
- toLocal.current = clientToLocal;
290
+ const frameRef = useLatest(frame);
291
+ const toLocal = useLatest(clientToLocal);
291
292
  useEffect(() => {
292
293
  const el = hostRef.current;
293
294
  if (!el || !store) return;
@@ -305,7 +306,7 @@ export function usePublishPointer(
305
306
  el.removeEventListener('pointermove', onMove);
306
307
  el.removeEventListener('pointerleave', onLeave);
307
308
  };
308
- }, [hostRef, store, viewId]);
309
+ }, [frameRef, hostRef, store, toLocal, viewId]);
309
310
  }
310
311
 
311
312
  /** The pointer as a frame-local point, or `null` when there is none or it is
@@ -1,4 +1,5 @@
1
1
  import type { Yoke } from '@weasel-js/core';
2
+ import { useLatest } from '@weasel-js/core';
2
3
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
3
4
  import type { Point, ViewTransform } from '../instrument/types';
4
5
  import { normalize2DView } from '../state/view';
@@ -80,12 +81,11 @@ export function CanvasStack({
80
81
  return () => ro.disconnect();
81
82
  }, []);
82
83
 
83
- const onResizeRef = useRef(onResize);
84
- onResizeRef.current = onResize;
84
+ const onResizeRef = useLatest(onResize);
85
85
  useEffect(() => {
86
86
  if (size.width === 0 && size.height === 0) return;
87
87
  onResizeRef.current?.({ width: size.width, height: size.height });
88
- }, [size.width, size.height]);
88
+ }, [size.width, size.height, onResizeRef]);
89
89
 
90
90
  const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
91
91
 
@@ -107,9 +107,9 @@ export function CanvasStack({
107
107
  () => ({
108
108
  view,
109
109
  frame,
110
- surface: { element: containerRef, size, canvases: canvasMap, layers },
110
+ surface: { element: containerRef, size, canvases: canvasMap, layers, worldSpec },
111
111
  }),
112
- [view, frame, size, layers],
112
+ [view, frame, size, layers, worldSpec],
113
113
  );
114
114
 
115
115
  const containerStyle: CSSProperties = { width, height };
@@ -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
@@ -1,4 +1,5 @@
1
1
  import type { Yoke } from '@weasel-js/core';
2
+ import { useLatest } from '@weasel-js/core';
2
3
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
3
4
  import type { ViewTransform } from '../instrument/types';
4
5
  import { normalize2DView } from '../state/view';
@@ -85,8 +86,7 @@ export function Stage({
85
86
  [camera, size.width, size.height],
86
87
  );
87
88
 
88
- const onResizeRef = useRef(onResize);
89
- onResizeRef.current = onResize;
89
+ const onResizeRef = useLatest(onResize);
90
90
  useEffect(() => {
91
91
  const el = host.current;
92
92
  if (!el) return;
@@ -101,7 +101,7 @@ export function Stage({
101
101
  const ro = new ResizeObserver(measure);
102
102
  ro.observe(el);
103
103
  return () => ro.disconnect();
104
- }, []);
104
+ }, [onResizeRef]);
105
105
 
106
106
  // A transform moves every tile inside the content without resizing anything,
107
107
  // and a ResizeObserver reports only resizes.