@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
@@ -18,7 +18,6 @@ interface Box {
18
18
 
19
19
  /** Every tile's box expressed against the container's origin. */
20
20
  declare function composeRects(container: Box, tiles: ReadonlyMap<string, Box>): Map<string, Rect>;
21
- declare function rectsEqual(a: Rect | undefined, b: Rect): boolean;
22
21
 
23
22
  /**
24
23
  * A DOM rect as a GL viewport rect: origin at the bottom-left, every edge snapped
@@ -82,9 +81,18 @@ type SurfaceClear = (size: {
82
81
  width: number;
83
82
  height: number;
84
83
  }, dpr: number) => void;
84
+ /** Inputs to `useTiledSurface`. `onFrame` runs once per frame that has dirty
85
+ * tiles, before any tile paints — the place to size the shared buffer. */
85
86
  interface UseTiledSurfaceOptions {
86
87
  onFrame: (frame: SurfaceFrame) => void;
87
88
  }
89
+ /**
90
+ * Share one drawing buffer among tiles laid out by the DOM. Tiles register
91
+ * their elements; the hook measures them against the container, re-measures on
92
+ * resize, and batches invalidations into one visible-only frame that calls
93
+ * `onFrame`, then (on a retiled frame) the registered clears, then each dirty
94
+ * tile's painter.
95
+ */
88
96
  declare function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHandle;
89
97
 
90
98
  /** Null when no surface owner is above — a lab with no shared surface at all. */
@@ -141,5 +149,5 @@ declare function useTileId(id: string): string;
141
149
  */
142
150
  declare function useSurfaceTile(id: string): (el: HTMLElement | null) => void;
143
151
 
144
- export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId, useTiledSurface };
152
+ export { SurfaceCanvasContext, SurfaceContext, composeRects, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId, useTiledSurface };
145
153
  export type { Box, Rect, SurfaceCanvases, SurfaceClear, SurfaceFrame, SurfaceHandle, SurfaceLayer, TilePainter, UseTiledSurfaceOptions };
@@ -1,4 +1,4 @@
1
- export { composeRects, rectsEqual, toDeviceRect, useTiledSurface } from '../chunk-IJJUK4FU.js';
1
+ export { composeRects, toDeviceRect, useTiledSurface } from '../chunk-EESJUSYR.js';
2
2
  export { SurfaceCanvasContext, SurfaceContext, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId } from '../chunk-WIG6XHJ7.js';
3
3
  import '../chunk-2YILQ7Y5.js';
4
4
  //# sourceMappingURL=index.js.map
@@ -1,9 +1,11 @@
1
- export { az as UndoCapability } from '../_dts/types-CSm9jgol.js';
1
+ export { at as UndoCapability } from '../_dts/index-DCMA4Fy1.js';
2
+ import '@weasel-js/ui';
2
3
  import '@weasel-js/core';
3
4
  import 'react';
4
- import '../_dts/PrefsRow.d-TOZ7BgD9.js';
5
5
  import '@weasel-js/theme';
6
- import '../_dts/types-CRPSGFAp.js';
6
+ import '../_dts/types-BUBSIZuV.js';
7
+ import '@weasel-js/quantity';
8
+ import '@weasel-js/geom';
7
9
  import '../_dts/types-DJ79Tg5J.js';
8
10
 
9
11
  /** A subscriber to a named event. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -115,18 +115,18 @@
115
115
  "dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
116
116
  },
117
117
  "peerDependencies": {
118
- "@weasel-js/core": "1.7.0",
119
- "@weasel-js/theme": "1.7.0",
118
+ "@weasel-js/core": "1.7.1",
119
+ "@weasel-js/theme": "1.7.1",
120
120
  "react": "^19.0.0",
121
121
  "react-dom": "^19.0.0"
122
122
  },
123
123
  "dependencies": {
124
- "@weasel-js/geom": "1.7.0",
125
- "@weasel-js/kernel3d": "1.7.0",
126
- "@weasel-js/loupe": "1.7.0",
127
- "@weasel-js/quantity": "1.7.0",
128
- "@weasel-js/svg": "1.7.0",
129
- "@weasel-js/ui": "1.7.0",
124
+ "@weasel-js/geom": "1.7.1",
125
+ "@weasel-js/kernel3d": "1.7.1",
126
+ "@weasel-js/loupe": "1.7.1",
127
+ "@weasel-js/quantity": "1.7.1",
128
+ "@weasel-js/svg": "1.7.1",
129
+ "@weasel-js/ui": "1.7.1",
130
130
  "earcut": "3.2.3",
131
131
  "polygon-clipping": "^0.15.7",
132
132
  "react-aria-components": "^1.5.0",
@@ -135,9 +135,9 @@
135
135
  },
136
136
  "devDependencies": {
137
137
  "@biomejs/biome": "^2.4.12",
138
- "@rollup/plugin-alias": "^6.0.0",
139
138
  "@testing-library/jest-dom": "^7.0.1",
140
- "@weasel-js/modes": "1.7.0",
139
+ "@weasel-js/guides": "1.7.1",
140
+ "@weasel-js/modes": "1.7.1",
141
141
  "less": "^4.2.0",
142
142
  "rollup-plugin-dts": "^6.4.1",
143
143
  "tsx": "^4.19.0",
@@ -0,0 +1,143 @@
1
+ /**
2
+ * What an overlay reads outside render — the insert factory's tool and config,
3
+ * and the buffer its clear opens — comes from the render that committed, never
4
+ * from one React started and threw away.
5
+ *
6
+ * `<SceneCanvas>` is stubbed: jsdom has no WebGL2, so the real one never
7
+ * paints, and a pane that has not painted never clears. The stub hands the
8
+ * overlay a frame subscription the test can fire, and keeps the committed
9
+ * insert factories.
10
+ */
11
+ import type { InsertNodeFactory, SceneCanvasApi } from '@weasel-js/core';
12
+ import { renderThenAbandon } from '@weasel-js/react/testing/abandonRender';
13
+ import { type Ref, useLayoutEffect } from 'react';
14
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
15
+ import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
16
+ import type { SurfaceClear, SurfaceHandle, TilePainter } from '../surface/useTiledSurface';
17
+ import { AnnotationOverlay } from './AnnotationOverlay';
18
+ import { seenFrom } from './staleness';
19
+ import { createAnnotationScene } from './store';
20
+ import type { AnnotationTarget } from './types';
21
+
22
+ const frames = new Set<() => void>();
23
+ let factories: Record<string, InsertNodeFactory> = {};
24
+
25
+ vi.mock('@weasel-js/core', async (importOriginal) => {
26
+ const actual = await importOriginal<typeof import('@weasel-js/core')>();
27
+ function SceneCanvas(props: {
28
+ ref?: Ref<SceneCanvasApi | null>;
29
+ insertNodeFactories: Record<string, InsertNodeFactory>;
30
+ }) {
31
+ const { ref, insertNodeFactories } = props;
32
+ useLayoutEffect(() => {
33
+ factories = insertNodeFactories;
34
+ });
35
+ useLayoutEffect(() => {
36
+ if (typeof ref !== 'function') return;
37
+ const api = {
38
+ subscribeFrame: (fn: () => void) => {
39
+ frames.add(fn);
40
+ return () => frames.delete(fn);
41
+ },
42
+ requestRedraw: () => {},
43
+ } as unknown as SceneCanvasApi;
44
+ ref(api);
45
+ return () => {
46
+ ref(null);
47
+ };
48
+ }, [ref]);
49
+ return null;
50
+ }
51
+ return { ...actual, SceneCanvas };
52
+ });
53
+
54
+ beforeAll(() => {
55
+ HTMLCanvasElement.prototype.getContext = vi.fn(
56
+ () => null,
57
+ ) as unknown as HTMLCanvasElement['getContext'];
58
+ });
59
+
60
+ /** A surface that measures every tile at once, so the pane mounts its canvas
61
+ * inside the first commit. */
62
+ function surfaceFor(clears: SurfaceClear[]): SurfaceHandle {
63
+ const container = document.createElement('div');
64
+ return {
65
+ invalidate: () => {},
66
+ invalidateAll: () => {},
67
+ invalidateRects: () => {},
68
+ registerTile: () => {},
69
+ registerClear: (_id, clear) => {
70
+ clears.push(clear);
71
+ return () => {};
72
+ },
73
+ registerPainter: (_id, paint: TilePainter) => {
74
+ paint({ x: 0, y: 0, w: 100, h: 50 }, {} as never);
75
+ return () => {};
76
+ },
77
+ containerRef: () => {},
78
+ getContainer: () => container,
79
+ } as SurfaceHandle;
80
+ }
81
+
82
+ const target: AnnotationTarget = {
83
+ id: 'pane',
84
+ ref: { current: null },
85
+ content: { w: 100, h: 50 },
86
+ positionDependsOn: ['k'],
87
+ };
88
+
89
+ interface Props {
90
+ toolId: string;
91
+ config: { k: number };
92
+ canvas: HTMLCanvasElement;
93
+ }
94
+
95
+ const committed = document.createElement('canvas');
96
+ const abandoned = document.createElement('canvas');
97
+
98
+ /** Commits a pane holding `line` on `committed`, then abandons one holding
99
+ * `arrow` on `abandoned`. Returns the clears the pane registered. */
100
+ function mountThenAbandon(): SurfaceClear[] {
101
+ const clears: SurfaceClear[] = [];
102
+ const surface = surfaceFor(clears);
103
+ const scene = createAnnotationScene();
104
+ renderThenAbandon<Props>(
105
+ { toolId: 'line', config: { k: 1 }, canvas: committed },
106
+ { toolId: 'arrow', config: { k: 2 }, canvas: abandoned },
107
+ (p) => (
108
+ <SurfaceContext.Provider value={surface}>
109
+ <SurfaceCanvasContext.Provider value={{ over: p.canvas, under: null }}>
110
+ <AnnotationOverlay
111
+ target={target}
112
+ scene={scene}
113
+ config={p.config}
114
+ activeToolId={p.toolId}
115
+ />
116
+ </SurfaceCanvasContext.Provider>
117
+ </SurfaceContext.Provider>
118
+ ),
119
+ );
120
+ return clears;
121
+ }
122
+
123
+ describe('<AnnotationOverlay> across an abandoned render', () => {
124
+ it('mints a mark with the committed tool and config', () => {
125
+ mountThenAbandon();
126
+ // `line` and `arrow` drive the same weasel tool; only the tool id the
127
+ // factory reads tells them apart.
128
+ const made = factories.line?.({ x: 0, y: 0, width: 1, height: 1 }, {} as never);
129
+ expect(made?.data).toMatchObject({ kind: 'line', seen: seenFrom({ k: 1 }, ['k']) });
130
+ });
131
+
132
+ it('clears the committed buffer', () => {
133
+ const clears = mountThenAbandon();
134
+ for (const fn of frames) fn();
135
+ // One mock on the prototype serves every canvas; `this` tells them apart.
136
+ const open = vi.mocked(HTMLCanvasElement.prototype.getContext);
137
+ open.mockClear();
138
+ for (const clear of clears) clear({ width: 100, height: 50 }, 1);
139
+ // By identity: two blank canvases are deeply equal.
140
+ expect(open.mock.contexts).toHaveLength(1);
141
+ expect(open.mock.contexts[0]).toBe(committed);
142
+ });
143
+ });
@@ -1,18 +1,29 @@
1
1
  import {
2
2
  type InsertNodeFactory,
3
+ registerNodeShape,
3
4
  SceneCanvas,
4
5
  type SceneCanvasApi,
5
6
  useActiveToolContext,
7
+ useLatest,
6
8
  type View,
7
9
  WeaselProvider,
8
10
  } from '@weasel-js/core';
9
- import { type CSSProperties, useCallback, useContext, useEffect, useRef, useState } from 'react';
11
+ import {
12
+ type CSSProperties,
13
+ useCallback,
14
+ useContext,
15
+ useEffect,
16
+ useInsertionEffect,
17
+ useRef,
18
+ useState,
19
+ } from 'react';
10
20
  import { createPortal, flushSync } from 'react-dom';
11
21
  import { CameraWheelContext } from '../canvas/CameraWheelContext';
12
22
  import type { Rect } from '../surface/rect';
13
23
  import { useSurfaceCanvas, useSurfaceOptional, useTileId } from '../surface/useSurfaceTile';
14
24
  import { createMarkDrawOne } from './drawOne';
15
- import type { WorldRect } from './frac';
25
+ import { boundsOf, roundPoints, toShape, type WorldRect } from './frac';
26
+ import { POINT_MARK_SHAPE } from './paint';
16
27
  import { seenFrom } from './staleness';
17
28
  import type { MarkScene } from './store';
18
29
  import { ANNOTATION_WEASEL_TOOLS, annotationToolInfo } from './toolMap';
@@ -53,24 +64,32 @@ function ToolBridge({ toolId }: { toolId: string }) {
53
64
  return null;
54
65
  }
55
66
 
56
- /** Every vertex the insert carried, in fractions of the content box. */
57
- function pointsOf(
58
- extras: Record<string, unknown>,
59
- content: { w: number; h: number },
60
- ): readonly FracPoint[] | undefined {
61
- const toFrac = (p: { x: number; y: number }): FracPoint => ({
62
- x: content.w === 0 ? 0 : p.x / content.w,
63
- y: content.h === 0 ? 0 : p.y / content.h,
64
- });
65
- if (extras.kind === 'line') {
66
- return [toFrac(extras.a as FracPoint), toFrac(extras.b as FracPoint)];
67
- }
67
+ /** Every vertex the insert carried, in world units. */
68
+ function verticesOf(extras: Record<string, unknown>): readonly FracPoint[] | undefined {
69
+ if (extras.kind === 'line') return [extras.a as FracPoint, extras.b as FracPoint];
68
70
  if (extras.kind === 'pencil') {
69
- return (extras.samples as { x: number; y: number }[]).map(toFrac);
71
+ const samples = extras.samples as FracPoint[];
72
+ return samples.length > 0 ? samples : undefined;
70
73
  }
71
74
  return undefined;
72
75
  }
73
76
 
77
+ /** The pose and shape an insert's vertices make: the box that holds them, and
78
+ * the vertices stored against that very box. Undefined for an insert that
79
+ * carries no vertices, whose drag bounds are the whole geometry. */
80
+ export function insertedGeometry(
81
+ extras: Record<string, unknown>,
82
+ ): { pose: WorldRect; shape: readonly FracPoint[] } | undefined {
83
+ const vertices = verticesOf(extras);
84
+ if (!vertices) return undefined;
85
+ const box = boundsOf(vertices);
86
+ if (!box) return undefined;
87
+ return {
88
+ pose: { x: box.x, y: box.y, width: box.w, height: box.h },
89
+ shape: roundPoints(toShape(vertices, box)),
90
+ };
91
+ }
92
+
74
93
  /**
75
94
  * One target's drawing surface: a transparent box over the target's element
76
95
  * taking the input, and a `<SceneCanvas>` painting into the lab's shared
@@ -92,14 +111,13 @@ export function AnnotationOverlay({
92
111
  const [rect, setRect] = useState<Rect | null>(null);
93
112
  const [input, setInput] = useState<HTMLDivElement | null>(null);
94
113
  const sceneCanvas = useRef<SceneCanvasApi | null>(null);
114
+ useEffect(() => registerNodeShape(POINT_MARK_SHAPE, { priority: 'high' }), []);
95
115
 
96
116
  // Read in the insert factory, which is built once and must see the tool the
97
117
  // pane is holding now — `arrow` and `stroke` are indistinguishable from
98
118
  // `line` and `pencil` at the weasel end.
99
- const toolRef = useRef(activeToolId);
100
- toolRef.current = activeToolId;
101
- const configRef = useRef(config);
102
- configRef.current = config;
119
+ const toolRef = useLatest(activeToolId);
120
+ const configRef = useLatest(config);
103
121
 
104
122
  const id = target.id;
105
123
  // The surface's tile namespace is the whole lab's, and every trial of an
@@ -132,9 +150,13 @@ export function AnnotationOverlay({
132
150
  // renderer has opened creates the context without the stencil buffer marks
133
151
  // need, and every later request gets that context back.
134
152
  const painted = useRef(false);
135
- const canvasRef = useRef(canvas);
136
- if (canvasRef.current !== canvas) painted.current = false;
137
- canvasRef.current = canvas;
153
+ const canvasRef = useLatest(canvas);
154
+ // A new buffer has not been painted into, whatever the old one had. Reset
155
+ // with the commit that brings it, ahead of any paint that commit triggers.
156
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `canvas` is the signal, not a value this reads
157
+ useInsertionEffect(() => {
158
+ painted.current = false;
159
+ }, [canvas]);
138
160
  const unsubscribeFrame = useRef<(() => void) | null>(null);
139
161
  const attachSceneCanvas = useCallback((api: SceneCanvasApi | null) => {
140
162
  sceneCanvas.current = api;
@@ -156,7 +178,7 @@ export function AnnotationOverlay({
156
178
  gl.clearColor(0, 0, 0, 0);
157
179
  gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
158
180
  });
159
- }, [surface, tileId]);
181
+ }, [canvasRef, surface, tileId]);
160
182
 
161
183
  // The box is portalled out of whatever the picture sits in, so a wheel over
162
184
  // a mark would never reach the trial's camera without being handed to it.
@@ -200,14 +222,11 @@ export function AnnotationOverlay({
200
222
  const info = annotationToolInfo(toolRef.current);
201
223
  const kind: AnnotationKind | undefined = info?.kind;
202
224
  if (!kind) return null;
203
- const points = pointsOf(extras as unknown as Record<string, unknown>, target.content);
204
- const data: AnnotationData = {
205
- target: id,
206
- kind,
207
- ...(points ? { points } : {}),
208
- seen: seenFrom(configRef.current, target.positionDependsOn ?? []),
209
- };
210
- return { data };
225
+ const seen = seenFrom(configRef.current, target.positionDependsOn ?? []);
226
+ const geometry = insertedGeometry(extras as unknown as Record<string, unknown>);
227
+ if (!geometry) return { data: { target: id, kind, seen } satisfies AnnotationData };
228
+ const data: AnnotationData = { target: id, kind, shape: geometry.shape, seen };
229
+ return { data, pose: geometry.pose };
211
230
  };
212
231
  }
213
232
 
@@ -15,6 +15,7 @@ import { TrialIdProvider } from '../state/context';
15
15
  import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
16
16
  import type { SurfaceClear, SurfaceHandle } from '../surface/useTiledSurface';
17
17
  import { useTiledSurface } from '../surface/useTiledSurface';
18
+ import { insertedGeometry } from './AnnotationOverlay';
18
19
  import { AnnotationTargets } from './AnnotationTargets';
19
20
  import { createAnnotationStore } from './store';
20
21
  import type { AnnotationsCapability, AnnotationTarget } from './types';
@@ -354,3 +355,33 @@ describe('two trials of one instrument', () => {
354
355
  ]);
355
356
  });
356
357
  });
358
+
359
+ describe('the geometry a drawn mark is inserted with', () => {
360
+ it("boxes a line's ends and stores them against that box", () => {
361
+ const got = insertedGeometry({ kind: 'line', a: { x: 10, y: 40 }, b: { x: 50, y: 20 } });
362
+ expect(got).toEqual({
363
+ pose: { x: 10, y: 20, width: 40, height: 20 },
364
+ shape: [
365
+ { x: 0, y: 1 },
366
+ { x: 1, y: 0 },
367
+ ],
368
+ });
369
+ });
370
+
371
+ it('boxes every freehand sample', () => {
372
+ const samples = [
373
+ { x: 0, y: 0 },
374
+ { x: 20, y: 10 },
375
+ { x: 40, y: 0 },
376
+ ];
377
+ expect(insertedGeometry({ kind: 'pencil', samples })?.shape).toEqual([
378
+ { x: 0, y: 0 },
379
+ { x: 0.5, y: 1 },
380
+ { x: 1, y: 0 },
381
+ ]);
382
+ });
383
+
384
+ it('leaves a box-shaped insert to its drag bounds', () => {
385
+ expect(insertedGeometry({ kind: 'rect' })).toBeUndefined();
386
+ });
387
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A raster capture against real WebGL2 and the real Inter files, with the
3
+ * default family registered lazily the way an app registers it: a text mark
4
+ * captured before anything has drawn text still carries its glyphs.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { registerFont } from '@weasel-js/font';
8
+ import { _resetFontRegistryForTests } from '@weasel-js/font/test-seams';
9
+ import { createAnnotationStore } from './store';
10
+ import metricsUrl from '../../../../assets/fonts/inter/inter.json?url';
11
+ import atlasUrl from '../../../../assets/fonts/inter/inter.png?url';
12
+
13
+ /** Pixels where the mark's red ink landed on the transparent export. */
14
+ async function inkPixels(blob: Blob): Promise<number> {
15
+ const bitmap = await createImageBitmap(blob);
16
+ const ctx = new OffscreenCanvas(bitmap.width, bitmap.height).getContext('2d')!;
17
+ ctx.drawImage(bitmap, 0, 0);
18
+ const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
19
+ let n = 0;
20
+ for (let i = 0; i < data.length; i += 4) {
21
+ if (data[i + 3] > 128 && data[i] > 150 && data[i + 1] < 120) n++;
22
+ }
23
+ return n;
24
+ }
25
+
26
+ beforeEach(() => _resetFontRegistryForTests());
27
+ afterEach(() => _resetFontRegistryForTests());
28
+
29
+ describe('a raster capture of a text mark', () => {
30
+ it('draws its glyphs when the font was registered lazily and never fetched', async () => {
31
+ void registerFont('sans-serif', {}, metricsUrl, atlasUrl, { lazy: true });
32
+ const store = createAnnotationStore({
33
+ targets: () => [{ id: 'bare', content: { w: 120, h: 40 } }],
34
+ });
35
+ store.add({ target: 'bare', kind: 'text', title: 'Hello', frac: { x: 0.1, y: 0.2, w: 0.5, h: 0.5 } });
36
+
37
+ const result = await store.capture('bare', { format: 'png', scale: 2 });
38
+
39
+ expect(await inkPixels(result.blob)).toBeGreaterThan(40);
40
+ });
41
+ });
@@ -1,4 +1,4 @@
1
- import { asNodeId, renderSceneToPixels } from '@weasel-js/core';
1
+ import { asNodeId, renderSceneToPixels, warmRender } from '@weasel-js/core';
2
2
  import { serializeSvg } from '@weasel-js/svg';
3
3
  import { createMarkDrawOne, type MarkDrawOptions, resolveMarkStyle } from './drawOne';
4
4
  import type { MarkScene } from './store';
@@ -60,18 +60,20 @@ function embedRasterBase(
60
60
  }
61
61
 
62
62
  /** Every mark on a scene, as SVG nodes, styled the way the pane styles them. */
63
- function markNodesOf(scene: MarkScene, draw: MarkDrawOptions) {
63
+ function markNodesOf(scene: MarkScene, draw: MarkDrawOptions, scale: number) {
64
64
  return [...scene.renderOrder()].flatMap((id) => {
65
65
  const node = scene.get(asNodeId(String(id)));
66
66
  if (!node) return [];
67
67
  return markSvgNodes(
68
68
  { pose: node.pose, data: node.data as AnnotationData },
69
- draw.content,
70
69
  resolveMarkStyle(node.data as AnnotationData, draw),
70
+ { x: scale, y: scale },
71
71
  );
72
72
  });
73
73
  }
74
74
 
75
+ /** Inputs to `composeCaptureSvg`. `scale` is output pixels per content-box
76
+ * unit; `onWarn` receives non-fatal export warnings. */
75
77
  export interface ComposeSvgArgs {
76
78
  base?: CaptureSource;
77
79
  scene: MarkScene;
@@ -86,7 +88,10 @@ export interface ComposeSvgArgs {
86
88
  * The outer `viewBox` is the content box, so marks — which are already in it —
87
89
  * need no transform, and `width`/`height` carry the export scale. Both halves
88
90
  * nest as child `<svg>` elements, which each establish their own viewport and
89
- * so keep their own `viewBox` maths out of this function.
91
+ * so keep their own `viewBox` math out of this function.
92
+ *
93
+ * Synchronous with no `warmSvg`: marks export nothing that loads on demand,
94
+ * which `svgNodes.test.ts` pins.
90
95
  */
91
96
  export function composeCaptureSvg(args: ComposeSvgArgs): string {
92
97
  const { base, scene, draw, scale, onWarn } = args;
@@ -99,7 +104,7 @@ export function composeCaptureSvg(args: ComposeSvgArgs): string {
99
104
  : embedRasterBase(base, w, h, onWarn);
100
105
  // No width/height on the marks document: a nested <svg> without them fills
101
106
  // the outer viewport exactly, which is the alignment this relies on.
102
- const marksXml = serializeSvg(markNodesOf(scene, draw), {
107
+ const marksXml = serializeSvg(markNodesOf(scene, draw, scale), {
103
108
  viewBox: { x: 0, y: 0, width: w, height: h },
104
109
  onWarn,
105
110
  });
@@ -172,12 +177,14 @@ async function stackRaster(
172
177
  if (base?.kind === 'image') ctx.drawImage(await loadImage(base.src), 0, 0, width, height);
173
178
  else if (base?.kind === 'canvas') ctx.drawImage(base.canvas, 0, 0, width, height);
174
179
 
175
- const raster = renderSceneToPixels({
180
+ const render = {
176
181
  scene,
177
182
  sourceRect: { x: 0, y: 0, width: draw.content.w, height: draw.content.h },
178
183
  scale: { x: scale, y: scale },
179
184
  drawOne: createMarkDrawOne(draw),
180
- });
185
+ };
186
+ await warmRender({ render });
187
+ const raster = renderSceneToPixels(render);
181
188
  // Through a second canvas rather than `putImageData` on this one, which
182
189
  // replaces pixels instead of compositing and would erase the base.
183
190
  const marks = context2d(raster.width, raster.height);
@@ -30,8 +30,8 @@ export function createMarkDrawOne(
30
30
  pose: WorldRect,
31
31
  view: View,
32
32
  ) => DrawCommand[] {
33
- return (node, pose) =>
34
- markCommands({ pose, data: node.data }, opts.content, resolveMarkStyle(node.data, opts));
33
+ return (node, pose, view) =>
34
+ markCommands({ pose, data: node.data }, resolveMarkStyle(node.data, opts), view.scale);
35
35
  }
36
36
 
37
37
  /** How a mark looks, as opposed to where it is. Separate from the callback
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
2
+ import {
3
+ boundsOf,
4
+ fracContains,
5
+ fracEncloses,
6
+ fracToWorld,
7
+ fromShape,
8
+ roundFrac,
9
+ toShape,
10
+ worldToFrac,
11
+ } from './frac';
3
12
  import type { FracRect } from './types';
4
13
 
5
14
  const CONTENT = { w: 256, h: 170 };
@@ -64,3 +73,37 @@ describe('fraction hit geometry', () => {
64
73
  expect(fracEncloses({ x: 0.4, y: 0.4, w: 0.4, h: 0.4 }, box)).toBe(false);
65
74
  });
66
75
  });
76
+
77
+ describe('a shape', () => {
78
+ const pts = [
79
+ { x: 0.1, y: 0.6 },
80
+ { x: 0.6, y: 0.2 },
81
+ ];
82
+
83
+ it('has no bounds without points', () => {
84
+ expect(boundsOf([])).toBeNull();
85
+ });
86
+
87
+ it('round-trips through the box that bounds it', () => {
88
+ const box = boundsOf(pts);
89
+ if (!box) throw new Error('no bounds');
90
+ const back = fromShape(toShape(pts, box), box);
91
+ back.forEach((p, i) => {
92
+ expect(p.x).toBeCloseTo(pts[i]?.x ?? Number.NaN, 9);
93
+ expect(p.y).toBeCloseTo(pts[i]?.y ?? Number.NaN, 9);
94
+ });
95
+ });
96
+
97
+ it('puts every point on the edge of an axis the box has no extent along', () => {
98
+ const flat = [
99
+ { x: 0.1, y: 0.5 },
100
+ { x: 0.4, y: 0.5 },
101
+ ];
102
+ const box = boundsOf(flat);
103
+ if (!box) throw new Error('no bounds');
104
+ expect(toShape(flat, box)).toEqual([
105
+ { x: 0, y: 0 },
106
+ { x: 1, y: 0 },
107
+ ]);
108
+ });
109
+ });