@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,6 +1,8 @@
1
+ import { asNodeId } from '@weasel-js/core';
1
2
  import { describe, expect, it, vi } from 'vitest';
3
+ import { markCommands } from './paint';
2
4
  import { annotationsFromJSON, createAnnotationStore } from './store';
3
- import type { AnnotationInit, AnnotationTargetInfo } from './types';
5
+ import type { AnnotationInit, AnnotationsApi, AnnotationTargetInfo, FracPoint } from './types';
4
6
 
5
7
  const TARGETS: AnnotationTargetInfo[] = [
6
8
  { id: 'naive', content: { w: 256, h: 170 }, positionDependsOn: ['angle', 'shading'] },
@@ -162,7 +164,7 @@ describe('the annotation store', () => {
162
164
  // because jsdom has no WebGL2 and the overlay's canvas never paints or
163
165
  // hit-tests. That the canvas is bound to this scene is core's claim.
164
166
  it('merges the selection across targets, and maps it back to annotation ids', () => {
165
- const store = makeStore();
167
+ const store = createAnnotationStore({ targets: () => TARGETS, selection: 'per-target' });
166
168
  const onNaive = store.add(RING);
167
169
  const onOcct = store.add({ ...RING, target: 'occt' });
168
170
 
@@ -227,6 +229,86 @@ describe('the annotation store', () => {
227
229
  expect(fn.mock.calls.length).toBe(before);
228
230
  });
229
231
 
232
+ describe('across targets', () => {
233
+ /** Selects a mark the way its pane's canvas does: on that target's scene. */
234
+ const click = (store: AnnotationsApi, id: string): void => {
235
+ const [target, node] = id.split('/') as [string, string];
236
+ store.sceneFor(target).setSelection([asNodeId(node)]);
237
+ };
238
+
239
+ it('clears a selection standing in one target when a mark is selected in another', () => {
240
+ const store = makeStore();
241
+ const onNaive = store.add(RING);
242
+ const onOcct = store.add({ ...RING, target: 'occt' });
243
+
244
+ click(store, onNaive);
245
+ click(store, onOcct);
246
+ expect(store.selection()).toEqual([onOcct]);
247
+ expect(store.sceneFor('naive').getSelection()).toEqual([]);
248
+ });
249
+
250
+ it('tells subscribers once, after the other target is already cleared', () => {
251
+ const store = makeStore();
252
+ const onNaive = store.add(RING);
253
+ const onOcct = store.add({ ...RING, target: 'occt' });
254
+ click(store, onNaive);
255
+
256
+ const seen: (readonly string[])[] = [];
257
+ store.subscribe(() => seen.push(store.selection()));
258
+ click(store, onOcct);
259
+ expect(seen).toEqual([[onOcct]]);
260
+ });
261
+
262
+ it('keeps a setSelection naming two targets to the first one, in one event', () => {
263
+ const store = makeStore();
264
+ const onNaive = store.add(RING);
265
+ const onOcct = store.add({ ...RING, target: 'occt' });
266
+ click(store, onNaive);
267
+
268
+ const fn = vi.fn();
269
+ store.subscribe(fn);
270
+ store.setSelection([onOcct, onNaive]);
271
+ expect(store.selection()).toEqual([onOcct]);
272
+ expect(fn).toHaveBeenCalledTimes(1);
273
+ });
274
+
275
+ it('leaves the other target alone for a change that selects nothing new', () => {
276
+ const store = makeStore();
277
+ const onNaive = store.add(RING);
278
+ store.add({ ...RING, target: 'occt' });
279
+ click(store, onNaive);
280
+
281
+ store.add({ ...RING, target: 'occt' });
282
+ store.sceneFor('occt').setSelection([]);
283
+ expect(store.selection()).toEqual([onNaive]);
284
+ });
285
+
286
+ it("keeps each target's own selection under `selection: 'per-target'`", () => {
287
+ const store = createAnnotationStore({ targets: () => TARGETS, selection: 'per-target' });
288
+ const onNaive = store.add(RING);
289
+ const onOcct = store.add({ ...RING, target: 'occt' });
290
+
291
+ click(store, onNaive);
292
+ click(store, onOcct);
293
+ expect(store.selection()).toEqual([onNaive, onOcct]);
294
+
295
+ store.setSelection([onOcct, onNaive]);
296
+ expect(store.selection()).toEqual([onNaive, onOcct]);
297
+ });
298
+
299
+ it('reads the mode at each change, for a capability swapped under a live store', () => {
300
+ let mode: 'exclusive' | 'per-target' = 'per-target';
301
+ const store = createAnnotationStore({ targets: () => TARGETS, selection: () => mode });
302
+ const onNaive = store.add(RING);
303
+ const onOcct = store.add({ ...RING, target: 'occt' });
304
+
305
+ click(store, onNaive);
306
+ mode = 'exclusive';
307
+ click(store, onOcct);
308
+ expect(store.selection()).toEqual([onOcct]);
309
+ });
310
+ });
311
+
230
312
  it('round-trips through JSON, and the snapshot is JSON-clean', () => {
231
313
  const store = makeStore();
232
314
  const id = store.add(RING, { angle: 'iso', shading: 'outline' });
@@ -295,6 +377,134 @@ describe('the annotation store', () => {
295
377
  });
296
378
  });
297
379
 
380
+ /** What a target's first mark draws, as its path's points in fractions of the
381
+ * content box — the drawn side, to hold against what the store hit-tests. */
382
+ function drawn(store: AnnotationsApi, target = 'naive'): FracPoint[] {
383
+ const { w, h } = TARGETS.find((t) => t.id === target)?.content ?? { w: 0, h: 0 };
384
+ const [painted] = store.paintedMarks(target);
385
+ if (!painted) throw new Error('no mark painted');
386
+ const [cmd] = markCommands(painted.mark, painted.style);
387
+ if (cmd?.kind !== 'path') throw new Error('expected a path command');
388
+ const coords = [...((cmd.path as { coords?: Float32Array }).coords ?? [])];
389
+ const out: FracPoint[] = [];
390
+ for (let i = 0; i < coords.length; i += 2) {
391
+ out.push({ x: (coords[i] ?? 0) / w, y: (coords[i + 1] ?? 0) / h });
392
+ }
393
+ return out;
394
+ }
395
+
396
+ const centerOf = (pts: FracPoint[]): FracPoint => {
397
+ const xs = pts.map((p) => p.x);
398
+ const ys = pts.map((p) => p.y);
399
+ return {
400
+ x: (Math.min(...xs) + Math.max(...xs)) / 2,
401
+ y: (Math.min(...ys) + Math.max(...ys)) / 2,
402
+ };
403
+ };
404
+
405
+ const expectNear = (got: readonly FracPoint[], want: readonly FracPoint[]) => {
406
+ expect(got).toHaveLength(want.length);
407
+ got.forEach((p, i) => {
408
+ expect(p.x).toBeCloseTo(want[i]?.x ?? Number.NaN, 3);
409
+ expect(p.y).toBeCloseTo(want[i]?.y ?? Number.NaN, 3);
410
+ });
411
+ };
412
+
413
+ describe('a moved mark', () => {
414
+ const POINT: AnnotationInit = {
415
+ target: 'naive',
416
+ kind: 'point',
417
+ frac: { x: 0.25, y: 0.5, w: 0, h: 0 },
418
+ points: [{ x: 0.25, y: 0.5 }],
419
+ };
420
+ const LINE: AnnotationInit = {
421
+ target: 'naive',
422
+ kind: 'line',
423
+ frac: { x: 0.1, y: 0.2, w: 0.5, h: 0.4 },
424
+ points: [
425
+ { x: 0.1, y: 0.6 },
426
+ { x: 0.6, y: 0.2 },
427
+ ],
428
+ };
429
+
430
+ it('draws a point where the store hit-tests it after update moves it', () => {
431
+ const store = makeStore();
432
+ const id = store.add(POINT);
433
+ store.update(id, { frac: { x: 0.75, y: 0.25, w: 0, h: 0 } });
434
+
435
+ expectNear([centerOf(drawn(store))], [{ x: 0.75, y: 0.25 }]);
436
+ expect(store.hitTest('naive', { x: 0.75, y: 0.25 }, 0.001).map((a) => a.id)).toEqual([id]);
437
+ expect(store.get(id)?.points).toEqual([{ x: 0.75, y: 0.25 }]);
438
+ });
439
+
440
+ it('carries a line and a stroke along with their bounds', () => {
441
+ for (const kind of ['line', 'arrow', 'stroke'] as const) {
442
+ const store = makeStore();
443
+ const id = store.add({ ...LINE, kind });
444
+ store.update(id, { frac: { x: 0.3, y: 0.3, w: 0.5, h: 0.4 } });
445
+ const moved = [
446
+ { x: 0.3, y: 0.7 },
447
+ { x: 0.8, y: 0.3 },
448
+ ];
449
+ expectNear(drawn(store), moved);
450
+ expect(store.get(id)?.points).toEqual(moved);
451
+ }
452
+ });
453
+
454
+ it('stretches stored vertices with a resized box', () => {
455
+ const store = makeStore();
456
+ const id = store.add(LINE);
457
+ store.update(id, { frac: { x: 0.1, y: 0.2, w: 0.25, h: 0.8 } });
458
+ expectNear(drawn(store), [
459
+ { x: 0.1, y: 1 },
460
+ { x: 0.35, y: 0.2 },
461
+ ]);
462
+ });
463
+
464
+ it('follows a pose written straight to the scene, the way the pane moves a mark', () => {
465
+ const store = makeStore();
466
+ const id = store.add(POINT);
467
+ const node = asNodeId(id.slice(id.lastIndexOf('/') + 1));
468
+ store.sceneFor('naive').setPose(node, { x: 0.5 * 256, y: 0.1 * 170, width: 0, height: 0 });
469
+
470
+ expectNear([centerOf(drawn(store))], [{ x: 0.5, y: 0.1 }]);
471
+ expect(store.get(id)?.points).toEqual([{ x: 0.5, y: 0.1 }]);
472
+ });
473
+
474
+ it('moves the bounds when only the points are patched', () => {
475
+ const store = makeStore();
476
+ const id = store.add(POINT);
477
+ store.update(id, { points: [{ x: 0.9, y: 0.9 }] });
478
+
479
+ expect(store.get(id)?.frac).toEqual({ x: 0.9, y: 0.9, w: 0, h: 0 });
480
+ expectNear([centerOf(drawn(store))], [{ x: 0.9, y: 0.9 }]);
481
+ });
482
+
483
+ it('reads points a store saved in content fractions, before they followed the bounds', () => {
484
+ const legacy = {
485
+ version: 1,
486
+ scenes: {
487
+ naive: {
488
+ version: 1,
489
+ systemLayers: [{ id: 'marks' }],
490
+ nodes: [
491
+ {
492
+ id: 'n1',
493
+ kind: 'leaf',
494
+ layer: 'marks',
495
+ pose: { x: 0.1 * 256, y: 0.2 * 170, width: 0.5 * 256, height: 0.4 * 170 },
496
+ data: { target: 'naive', kind: 'line', points: LINE.points },
497
+ },
498
+ ],
499
+ },
500
+ },
501
+ };
502
+ const store = annotationsFromJSON(legacy, () => TARGETS);
503
+ expectNear(drawn(store), LINE.points ?? []);
504
+ expect(store.get('naive/n1')?.points).toEqual(LINE.points);
505
+ });
506
+ });
507
+
298
508
  describe('paintedMarks', () => {
299
509
  it("hands over a target's marks in world units with their resolved style", () => {
300
510
  let config: Record<string, string> = { angle: 'iso', shading: 'outline' };
@@ -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,32 +44,43 @@ 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
- it("drops the arrow's marker reference, because its head is already a node", () => {
55
- const nodes = markSvgNodes(mark('arrow'), CONTENT);
56
- expect(nodes.length).toBeGreaterThan(1);
57
- for (const node of nodes) {
58
- if (node.kind !== 'path') throw new Error('expected path nodes');
59
- // Keeping `markerEnd` would make the serializer emit a <marker> def as
60
- // well, and the head would be drawn twice.
61
- expect(node.stroke?.markerEnd).toBeUndefined();
62
- }
63
- expect(serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: 100, height: 60 } })).not.toContain(
52
+ it("keeps the arrow's marker, so the export draws its head", () => {
53
+ const nodes = markSvgNodes(mark('arrow'));
54
+ expect(nodes).toHaveLength(1);
55
+ const [node] = nodes;
56
+ if (node?.kind !== 'path') throw new Error('expected a path node');
57
+ expect(node.stroke?.markerEnd).toBe('arrow');
58
+ expect(serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: 100, height: 60 } })).toContain(
64
59
  '<marker',
65
60
  );
66
61
  });
67
62
 
68
63
  it('emits text as text, at the pose, in the mark color', () => {
69
64
  const m = mark('text', { title: 'missing edge' });
70
- const [node] = markSvgNodes(m, CONTENT);
65
+ const [node] = markSvgNodes(m);
71
66
  expect(node).toMatchObject({ kind: 'text', text: 'missing edge', x: 10, y: 12 });
72
67
  });
73
68
 
74
69
  it('emits nothing for a text mark with no words', () => {
75
- 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
+ }
76
85
  });
77
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
 
@@ -16,9 +16,6 @@ function toSvgPaint(paint: FillStyle | undefined): SvgPaint {
16
16
  return { kind: 'gradient', paint };
17
17
  }
18
18
 
19
- /** Marker references are deliberately dropped: `markCommands` already resolves
20
- * a marker into its own path command, and keeping the reference would make
21
- * the serializer emit a `<marker>` def that draws the head a second time. */
22
19
  function toSvgStroke(stroke: Stroke): SvgStroke {
23
20
  return {
24
21
  paint: toSvgPaint(stroke.paint),
@@ -28,6 +25,9 @@ function toSvgStroke(stroke: Stroke): SvgStroke {
28
25
  ...(stroke.dash ? { dash: [...stroke.dash] } : {}),
29
26
  ...(stroke.miterLimit != null ? { miterLimit: stroke.miterLimit } : {}),
30
27
  ...(stroke.align ? { align: stroke.align } : {}),
28
+ ...(stroke.markerStart ? { markerStart: stroke.markerStart } : {}),
29
+ ...(stroke.markerMid ? { markerMid: stroke.markerMid } : {}),
30
+ ...(stroke.markerEnd ? { markerEnd: stroke.markerEnd } : {}),
31
31
  };
32
32
  }
33
33
 
@@ -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
  }