@weasel-js/labkit 1.3.0-pre.0 → 1.4.0-pre.0

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 (259) hide show
  1. package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
  2. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  3. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  4. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  5. package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
  6. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  7. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  8. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  9. package/dist/_dts/{types-AHlQxBNN.d.ts → types-D6s4b7if.d.ts} +2 -2
  10. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  11. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  12. package/dist/canvas/index.d.ts +43 -25
  13. package/dist/canvas/index.js +5 -3
  14. package/dist/chrome/index.d.ts +9 -6
  15. package/dist/chrome/index.js +7 -6
  16. package/dist/chunk-2AYGEN57.js +32 -0
  17. package/dist/chunk-2AYGEN57.js.map +1 -0
  18. package/dist/{chunk-RVNN4CHQ.js → chunk-3P5TTTA7.js} +16 -8
  19. package/dist/chunk-3P5TTTA7.js.map +1 -0
  20. package/dist/{chunk-NS54R52R.js → chunk-3ZELRRBV.js} +45 -40
  21. package/dist/chunk-3ZELRRBV.js.map +1 -0
  22. package/dist/{chunk-HFDVGF4X.js → chunk-4EY67BKJ.js} +32 -7
  23. package/dist/chunk-4EY67BKJ.js.map +1 -0
  24. package/dist/chunk-5TCEVMPI.js +140 -0
  25. package/dist/chunk-5TCEVMPI.js.map +1 -0
  26. package/dist/{chunk-O7NVSCLN.js → chunk-6JMU56CU.js} +133 -134
  27. package/dist/chunk-6JMU56CU.js.map +1 -0
  28. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  29. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  30. package/dist/chunk-CTRKTLYZ.js +33 -0
  31. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  32. package/dist/chunk-EQIB2PGC.js +458 -0
  33. package/dist/chunk-EQIB2PGC.js.map +1 -0
  34. package/dist/chunk-HEORBLVT.js +7128 -0
  35. package/dist/chunk-HEORBLVT.js.map +1 -0
  36. package/dist/{chunk-V7PRSIRQ.js → chunk-JDDIZSLL.js} +4 -4
  37. package/dist/chunk-JDDIZSLL.js.map +1 -0
  38. package/dist/chunk-LITE5YFB.js +669 -0
  39. package/dist/chunk-LITE5YFB.js.map +1 -0
  40. package/dist/{chunk-Z6HJ5FEM.js → chunk-ROQDRXKW.js} +732 -315
  41. package/dist/chunk-ROQDRXKW.js.map +1 -0
  42. package/dist/chunk-SBXNQW4G.js +3 -0
  43. package/dist/chunk-SBXNQW4G.js.map +1 -0
  44. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  45. package/dist/chunk-XJ6N32QP.js.map +1 -0
  46. package/dist/chunk-YQJNT4XQ.js +293 -0
  47. package/dist/chunk-YQJNT4XQ.js.map +1 -0
  48. package/dist/controls/index.d.ts +4 -28
  49. package/dist/controls/index.js +3 -3
  50. package/dist/dragdrop/index.d.ts +50 -5
  51. package/dist/dragdrop/index.js +3 -1
  52. package/dist/index.d.ts +660 -302
  53. package/dist/index.js +1793 -587
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +6 -5
  56. package/dist/layers/index.js +4 -2
  57. package/dist/loupe/index.d.ts +187 -0
  58. package/dist/loupe/index.js +7 -0
  59. package/dist/loupe/index.js.map +1 -0
  60. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  61. package/dist/passthrough/weasel-canvas.js +1 -1
  62. package/dist/passthrough/weasel-ui.d.ts +151 -91
  63. package/dist/passthrough/weasel-ui.js +3 -2
  64. package/dist/primitives/index.d.ts +3 -1
  65. package/dist/primitives/index.js +6 -5
  66. package/dist/state/index.d.ts +3 -3
  67. package/dist/state/index.js +2 -2
  68. package/dist/styles.css +229 -703
  69. package/dist/surface/index.d.ts +18 -2
  70. package/dist/surface/index.js +2 -2
  71. package/dist/ui/layers/index.d.ts +8 -2
  72. package/dist/ui/layers/index.js +2 -4
  73. package/dist/undo/index.d.ts +5 -4
  74. package/package.json +14 -7
  75. package/src/annotations/AnnotationOverlay.tsx +205 -0
  76. package/src/annotations/AnnotationTargets.tsx +37 -0
  77. package/src/annotations/Annotations.less +129 -0
  78. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  80. package/src/annotations/AnnotationsContext.ts +49 -0
  81. package/src/annotations/ExportMenu.test.tsx +86 -0
  82. package/src/annotations/ExportMenu.tsx +182 -0
  83. package/src/annotations/MarkList.tsx +75 -0
  84. package/src/annotations/capture.test.ts +140 -0
  85. package/src/annotations/capture.ts +237 -0
  86. package/src/annotations/drawOne.test.ts +56 -0
  87. package/src/annotations/drawOne.ts +47 -0
  88. package/src/annotations/frac.test.ts +59 -0
  89. package/src/annotations/frac.ts +66 -0
  90. package/src/annotations/history.test.ts +82 -0
  91. package/src/annotations/history.ts +118 -0
  92. package/src/annotations/index.ts +37 -0
  93. package/src/annotations/paint.test.ts +125 -0
  94. package/src/annotations/paint.ts +130 -0
  95. package/src/annotations/staleness.test.ts +56 -0
  96. package/src/annotations/staleness.ts +41 -0
  97. package/src/annotations/store.test.ts +175 -0
  98. package/src/annotations/store.ts +317 -0
  99. package/src/annotations/svgNodes.test.ts +77 -0
  100. package/src/annotations/svgNodes.ts +74 -0
  101. package/src/annotations/toolMap.test.ts +36 -0
  102. package/src/annotations/toolMap.ts +54 -0
  103. package/src/annotations/types.ts +222 -0
  104. package/src/annotations/view.test.ts +37 -0
  105. package/src/annotations/view.ts +40 -0
  106. package/src/canvas/AGENTS.md +55 -5
  107. package/src/canvas/CanvasStack.test.tsx +27 -1
  108. package/src/canvas/CanvasStack.tsx +29 -4
  109. package/src/canvas/CanvasStackContext.ts +23 -4
  110. package/src/canvas/camera.test.ts +78 -0
  111. package/src/canvas/camera.ts +62 -0
  112. package/src/canvas/canvasCoords.test.ts +26 -0
  113. package/src/canvas/canvasCoords.ts +20 -7
  114. package/src/canvas/index.ts +5 -1
  115. package/src/canvas/useLayerScheduler.ts +7 -3
  116. package/src/canvas/usePanZoom.test.ts +36 -5
  117. package/src/canvas/usePanZoom.ts +20 -15
  118. package/src/canvas/worldSpec.test.ts +63 -0
  119. package/src/canvas/worldSpec.ts +48 -0
  120. package/src/chrome/builtins.test.ts +31 -1
  121. package/src/chrome/builtins.tsx +63 -19
  122. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  123. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  124. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  125. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  126. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  127. package/src/chrome/regions/regions.test.tsx +6 -1
  128. package/src/chrome/types.ts +20 -0
  129. package/src/config/builder.test.ts +7 -1
  130. package/src/config/builder.ts +1 -5
  131. package/src/config/resolve.test.ts +1 -2
  132. package/src/controls/ControlPanel.stories.tsx +41 -2
  133. package/src/controls/ControlPanel.test.tsx +69 -9
  134. package/src/controls/ControlPanel.tsx +106 -25
  135. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  136. package/src/dragdrop/dragDrop.test.tsx +29 -2
  137. package/src/dragdrop/index.ts +11 -0
  138. package/src/index.test.ts +22 -0
  139. package/src/index.ts +136 -4
  140. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  141. package/src/instrument/types.ts +24 -1
  142. package/src/lab/Lab.surface.test.tsx +162 -0
  143. package/src/lab/Lab.tsx +102 -17
  144. package/src/lab/Lab.undock.test.tsx +85 -0
  145. package/src/lab/LabFullChrome.stories.tsx +20 -0
  146. package/src/lab/LabShell.less +11 -0
  147. package/src/lab/Workspace.less +73 -0
  148. package/src/lab/Workspace.surface.test.tsx +2 -0
  149. package/src/lab/Workspace.tsx +97 -8
  150. package/src/lab/index.ts +1 -1
  151. package/src/lab/panelHost.ts +37 -0
  152. package/src/layers/AGENTS.md +1 -1
  153. package/src/layers/LayerList.tsx +1 -1
  154. package/src/loupe/AGENTS.md +49 -0
  155. package/src/loupe/CanvasLoupe.tsx +83 -0
  156. package/src/loupe/DomLoupe.tsx +62 -0
  157. package/src/loupe/Loupe.less +38 -0
  158. package/src/loupe/LoupeBubble.tsx +32 -0
  159. package/src/loupe/TrialLoupe.tsx +93 -0
  160. package/src/loupe/canvasLens.test.ts +205 -0
  161. package/src/loupe/canvasLens.ts +141 -0
  162. package/src/loupe/index.ts +19 -0
  163. package/src/loupe/types.test.ts +29 -0
  164. package/src/loupe/types.ts +90 -0
  165. package/src/loupe/useHostSize.ts +30 -0
  166. package/src/loupe/useLoupe.test.tsx +197 -0
  167. package/src/loupe/useLoupe.ts +189 -0
  168. package/src/passthrough/weasel-ui.ts +25 -17
  169. package/src/primitives/FloatingPanel.test.tsx +76 -1
  170. package/src/primitives/FloatingPanel.tsx +19 -3
  171. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  172. package/src/primitives/Sidebar.less +18 -0
  173. package/src/primitives/Toolbar.less +8 -0
  174. package/src/primitives/Toolbar.tsx +5 -0
  175. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  176. package/src/primitives/useRovingTabIndex.ts +26 -17
  177. package/src/state/document.test.ts +23 -0
  178. package/src/state/document.ts +14 -2
  179. package/src/state/index.ts +2 -0
  180. package/src/state/store.test.ts +24 -0
  181. package/src/state/store.ts +35 -1
  182. package/src/state/types.ts +11 -0
  183. package/src/state/undock.test.ts +40 -0
  184. package/src/state/undock.ts +39 -0
  185. package/src/styles.less +2 -4
  186. package/src/surface/SurfaceContext.ts +4 -0
  187. package/src/surface/index.ts +8 -3
  188. package/src/surface/useSurfaceTile.test.tsx +2 -0
  189. package/src/surface/useSurfaceTile.ts +7 -1
  190. package/src/surface/useTiledSurface.test.tsx +58 -0
  191. package/src/surface/useTiledSurface.ts +38 -3
  192. package/src/theme/Interstellar.stories.tsx +1 -1
  193. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  194. package/src/trial/Trial.annotations.test.tsx +121 -0
  195. package/src/trial/Trial.canvas.test.tsx +76 -1
  196. package/src/trial/Trial.config.test.tsx +3 -8
  197. package/src/trial/Trial.less +19 -0
  198. package/src/trial/Trial.loupe.test.tsx +128 -0
  199. package/src/trial/Trial.stories.tsx +1 -0
  200. package/src/trial/Trial.test.tsx +12 -0
  201. package/src/trial/Trial.trialId.test.tsx +36 -0
  202. package/src/trial/Trial.tsx +215 -40
  203. package/src/trial/TrialChrome.tsx +35 -0
  204. package/src/trial/UndockedSections.tsx +65 -0
  205. package/src/trial/trialOps.test.ts +21 -0
  206. package/src/trial/trialOps.ts +5 -3
  207. package/src/ui/layers/index.ts +5 -1
  208. package/dist/_dts/index-DgcNdEdh.d.ts +0 -314
  209. package/dist/chunk-4TMMVIDM.js.map +0 -1
  210. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  211. package/dist/chunk-HFDVGF4X.js.map +0 -1
  212. package/dist/chunk-NS54R52R.js.map +0 -1
  213. package/dist/chunk-O2BULFHX.js +0 -152
  214. package/dist/chunk-O2BULFHX.js.map +0 -1
  215. package/dist/chunk-O7NVSCLN.js.map +0 -1
  216. package/dist/chunk-PO6L3NSH.js +0 -366
  217. package/dist/chunk-PO6L3NSH.js.map +0 -1
  218. package/dist/chunk-PQJ5B2U2.js +0 -27
  219. package/dist/chunk-PQJ5B2U2.js.map +0 -1
  220. package/dist/chunk-RVNN4CHQ.js.map +0 -1
  221. package/dist/chunk-TLGRYALP.js +0 -6151
  222. package/dist/chunk-TLGRYALP.js.map +0 -1
  223. package/dist/chunk-V7PRSIRQ.js.map +0 -1
  224. package/dist/chunk-Z6HJ5FEM.js.map +0 -1
  225. package/dist/chunk-ZZAYVX4X.js +0 -505
  226. package/dist/chunk-ZZAYVX4X.js.map +0 -1
  227. package/src/primitives/DragHandleGlyph.tsx +0 -29
  228. package/src/ui/format.test.ts +0 -33
  229. package/src/ui/format.ts +0 -30
  230. package/src/ui/layers/LayerStack.less +0 -143
  231. package/src/ui/layers/LayerStack.stories.tsx +0 -45
  232. package/src/ui/layers/LayerStack.test.tsx +0 -170
  233. package/src/ui/layers/LayerStack.tsx +0 -208
  234. package/src/ui/properties/CheckboxRow.stories.tsx +0 -27
  235. package/src/ui/properties/ColorRow.stories.tsx +0 -106
  236. package/src/ui/properties/CurveField.less +0 -48
  237. package/src/ui/properties/CurveField.stories.tsx +0 -20
  238. package/src/ui/properties/CurveField.test.tsx +0 -127
  239. package/src/ui/properties/CurveField.tsx +0 -184
  240. package/src/ui/properties/EffectCard.tsx +0 -324
  241. package/src/ui/properties/Gallery.stories.tsx +0 -178
  242. package/src/ui/properties/NumberRow.stories.tsx +0 -65
  243. package/src/ui/properties/PropertyGroup.less +0 -50
  244. package/src/ui/properties/PropertyGroup.stories.tsx +0 -34
  245. package/src/ui/properties/PropertyGroup.test.tsx +0 -33
  246. package/src/ui/properties/PropertyGroup.tsx +0 -45
  247. package/src/ui/properties/PropertyList.stories.tsx +0 -83
  248. package/src/ui/properties/PropertyPanel.less +0 -606
  249. package/src/ui/properties/PropertyPanel.stories.tsx +0 -190
  250. package/src/ui/properties/PropertyPanel.test.tsx +0 -193
  251. package/src/ui/properties/PropertyPanel.tsx +0 -469
  252. package/src/ui/properties/PropertyRow.stories.tsx +0 -53
  253. package/src/ui/properties/SelectRow.stories.tsx +0 -58
  254. package/src/ui/properties/SliderRow.stories.tsx +0 -74
  255. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +0 -398
  256. package/src/ui/properties/TextRow.stories.tsx +0 -62
  257. package/src/ui/properties/ToggleRow.stories.tsx +0 -57
  258. package/src/ui/properties/index.ts +0 -39
  259. package/src/ui/properties/storyLayouts.tsx +0 -36
@@ -0,0 +1,317 @@
1
+ import {
2
+ asNodeId,
3
+ createScene,
4
+ type Scene,
5
+ type SerializedScene,
6
+ sceneFromJSON,
7
+ } from '@weasel-js/core';
8
+ import { captureTarget } from './capture';
9
+ import type { WorldRect } from './frac';
10
+ import { fracContains, fracIntersects, fracToWorld, roundFrac, worldToFrac } from './frac';
11
+ import { MarkHistory } from './history';
12
+ import { isStale as isStaleAgainst, seenFrom } from './staleness';
13
+ import type {
14
+ Annotation,
15
+ AnnotationData,
16
+ AnnotationInit,
17
+ AnnotationMeaning,
18
+ AnnotationPatch,
19
+ AnnotationQuery,
20
+ AnnotationsApi,
21
+ AnnotationTargetInfo,
22
+ CaptureOptions,
23
+ CaptureResult,
24
+ FracPoint,
25
+ FracRect,
26
+ SerializedAnnotations,
27
+ } from './types';
28
+
29
+ /** A mark's box in its target's world. Matches weasel's default `RectPose`. */
30
+ type MarkPose = WorldRect;
31
+
32
+ /** The scene one target's marks live in. */
33
+ export type MarkScene = Scene<AnnotationData, 'marks', MarkPose>;
34
+
35
+ /** A mark scene: one layer, because marks do not stack in tiers. */
36
+ export function createAnnotationScene(): MarkScene {
37
+ return createScene<AnnotationData, 'marks', MarkPose>({ systemLayers: [{ id: 'marks' }] });
38
+ }
39
+
40
+ export interface AnnotationStoreOptions {
41
+ /** Re-read on every call, so a target resizing or gaining a dependency takes
42
+ * effect without rebuilding the store. */
43
+ targets: () => readonly AnnotationTargetInfo[];
44
+ /** Serialized scenes from a previous `toJSON`, keyed by target. */
45
+ restore?: Readonly<Record<string, unknown>>;
46
+ /** The instrument's vocabulary, so an export draws a mark in the colour its
47
+ * status gives it. */
48
+ meaning?: AnnotationMeaning;
49
+ /** The trial's live config. A getter for the same reason `targets` is: the
50
+ * store is built once and the config changes under it. */
51
+ config?: () => unknown;
52
+ /** Notified after every finished export. */
53
+ onCapture?: (result: CaptureResult) => void;
54
+ }
55
+
56
+ /** Everything a capture needs that is not the store's own state. Split out so
57
+ * `capture.ts` takes data rather than reaching back into the store. */
58
+ export interface CaptureDeps {
59
+ scene: MarkScene;
60
+ target: AnnotationTargetInfo;
61
+ meaning?: AnnotationMeaning;
62
+ config: unknown;
63
+ }
64
+
65
+ const NO_CONTENT = { w: 0, h: 0 };
66
+
67
+ /** A mark's id is its target and its node. One scene per target, so a node id
68
+ * is only unique within one — and an annotation is addressed by both. */
69
+ function splitId(id: string): { target: string; node: string } | undefined {
70
+ const at = id.lastIndexOf('/');
71
+ return at <= 0 ? undefined : { target: id.slice(0, at), node: id.slice(at + 1) };
72
+ }
73
+
74
+ /**
75
+ * A facade over one weasel scene per target: the scenes are the truth, this
76
+ * answers questions about them. Everything crossing this boundary is in
77
+ * fractions of a target's content box; the scenes hold world units.
78
+ *
79
+ * A scene per target rather than one scene with a `target` field, because a
80
+ * pane's hit-test, marquee and paint all walk the whole scene it is given and
81
+ * take no filter — one shared scene puts every other pane's marks under the
82
+ * pointer.
83
+ */
84
+ export function createAnnotationStore(opts: AnnotationStoreOptions): AnnotationsApi {
85
+ const { targets, restore } = opts;
86
+ const scenes = new Map<string, MarkScene>();
87
+ const subs = new Set<() => void>();
88
+ const history = new MarkHistory();
89
+ const notify = (): void => {
90
+ for (const fn of subs) fn();
91
+ };
92
+
93
+ const sceneFor = (target: string): MarkScene => {
94
+ const known = scenes.get(target);
95
+ if (known) return known;
96
+ const raw = restore?.[target];
97
+ const scene = raw
98
+ ? sceneFromJSON<AnnotationData, 'marks', MarkPose>(
99
+ raw as SerializedScene<AnnotationData, 'marks', MarkPose>,
100
+ {},
101
+ )
102
+ : createAnnotationScene();
103
+ scenes.set(target, scene);
104
+ history.track(target, scene);
105
+ scene.subscribe(() => {
106
+ history.observe(target, scene);
107
+ notify();
108
+ });
109
+ return scene;
110
+ };
111
+
112
+ /** Only for scenes that exist: asking for one would create it, and the
113
+ * ordering never names a target it has not seen. */
114
+ const historySceneAt = (target: string) => scenes.get(target);
115
+
116
+ // Restored scenes are materialized up front: everything below reads
117
+ // `scenes`, and a mark that only appears once its pane asks for its scene is
118
+ // a mark a sidebar cannot list.
119
+ for (const target of Object.keys(restore ?? {})) sceneFor(target);
120
+
121
+ const targetOf = (id: string): AnnotationTargetInfo | undefined =>
122
+ targets().find((t) => t.id === id);
123
+
124
+ const contentOf = (id: string) => targetOf(id)?.content ?? NO_CONTENT;
125
+
126
+ const project = (target: string, node: string): Annotation | undefined => {
127
+ const found = scenes.get(target)?.get(asNodeId(node));
128
+ if (!found) return undefined;
129
+ return {
130
+ ...found.data,
131
+ target,
132
+ id: `${target}/${node}`,
133
+ frac: roundFrac(worldToFrac(found.pose as MarkPose, contentOf(target))),
134
+ };
135
+ };
136
+
137
+ /** Every target with a scene, in declaration order — then any whose target
138
+ * the instrument has since stopped declaring, so its marks are not lost. */
139
+ const liveTargets = (): string[] => {
140
+ const declared = targets().map((t) => t.id);
141
+ const rest = [...scenes.keys()].filter((id) => !declared.includes(id));
142
+ return [...declared, ...rest];
143
+ };
144
+
145
+ const marksOn = (target: string): Annotation[] => {
146
+ const scene = scenes.get(target);
147
+ if (!scene) return [];
148
+ const out: Annotation[] = [];
149
+ for (const id of scene.renderOrder()) {
150
+ const a = project(target, String(id));
151
+ if (a) out.push(a);
152
+ }
153
+ return out;
154
+ };
155
+
156
+ const all = (): Annotation[] => liveTargets().flatMap(marksOn);
157
+
158
+ const matches = (a: Annotation, q: AnnotationQuery): boolean => {
159
+ if (q.target !== undefined && a.target !== q.target) return false;
160
+ if (q.kind !== undefined && a.kind !== q.kind) return false;
161
+ if (q.status !== undefined && a.status !== q.status) return false;
162
+ if (q.tags !== undefined) {
163
+ const have = a.tags ?? [];
164
+ for (const t of q.tags) if (!have.includes(t)) return false;
165
+ }
166
+ if (q.where && !q.where(a)) return false;
167
+ return true;
168
+ };
169
+
170
+ /** The node behind a public id, or undefined. */
171
+ const nodeOf = (id: string) => {
172
+ const parts = splitId(id);
173
+ if (!parts) return undefined;
174
+ const scene = scenes.get(parts.target);
175
+ const node = scene?.get(asNodeId(parts.node));
176
+ return node && scene ? { ...parts, scene, node } : undefined;
177
+ };
178
+
179
+ return {
180
+ sceneFor,
181
+
182
+ targets,
183
+
184
+ get(id) {
185
+ const parts = splitId(id);
186
+ return parts ? project(parts.target, parts.node) : undefined;
187
+ },
188
+
189
+ query(q) {
190
+ return q ? all().filter((a) => matches(a, q)) : all();
191
+ },
192
+
193
+ hitTest(target, pt: FracPoint, tol = 0) {
194
+ return marksOn(target)
195
+ .filter((a) => fracContains(a.frac, pt, tol))
196
+ .reverse();
197
+ },
198
+
199
+ within(target, box: FracRect) {
200
+ return marksOn(target).filter((a) => fracIntersects(box, a.frac));
201
+ },
202
+
203
+ isStale(a, config) {
204
+ const keys = targetOf(a.target)?.positionDependsOn ?? [];
205
+ return isStaleAgainst(a.seen, config, keys);
206
+ },
207
+
208
+ canUndo() {
209
+ return history.canUndo(historySceneAt);
210
+ },
211
+
212
+ canRedo() {
213
+ return history.canRedo(historySceneAt);
214
+ },
215
+
216
+ undo() {
217
+ return history.undo(historySceneAt);
218
+ },
219
+
220
+ redo() {
221
+ return history.redo(historySceneAt);
222
+ },
223
+
224
+ subscribe(fn) {
225
+ subs.add(fn);
226
+ return () => {
227
+ subs.delete(fn);
228
+ };
229
+ },
230
+
231
+ add(init: AnnotationInit, config?: unknown) {
232
+ const target = targetOf(init.target);
233
+ const data: AnnotationData = {
234
+ target: init.target,
235
+ kind: init.kind,
236
+ ...(init.points ? { points: init.points } : {}),
237
+ ...(init.title !== undefined ? { title: init.title } : {}),
238
+ ...(init.status !== undefined ? { status: init.status } : {}),
239
+ ...(init.tags !== undefined ? { tags: init.tags } : {}),
240
+ ...(init.meta !== undefined ? { meta: init.meta } : {}),
241
+ seen: seenFrom(config, target?.positionDependsOn ?? []),
242
+ };
243
+ const node = sceneFor(init.target).add({
244
+ kind: 'leaf',
245
+ layer: 'marks',
246
+ pose: fracToWorld(roundFrac(init.frac), target?.content ?? NO_CONTENT),
247
+ data,
248
+ });
249
+ return `${init.target}/${String(node)}`;
250
+ },
251
+
252
+ update(id, patch: AnnotationPatch) {
253
+ const found = nodeOf(id);
254
+ if (!found) return;
255
+ const { frac, ...meaning } = patch;
256
+ const nid = asNodeId(found.node.id);
257
+ if (Object.keys(meaning).length > 0) {
258
+ found.scene.update(nid, { data: { ...found.node.data, ...meaning } });
259
+ }
260
+ if (frac) {
261
+ found.scene.setPose(nid, fracToWorld(roundFrac(frac), contentOf(found.target)));
262
+ }
263
+ },
264
+
265
+ setMeta(id, meta) {
266
+ const found = nodeOf(id);
267
+ if (!found) return;
268
+ found.scene.update(asNodeId(found.node.id), { data: { ...found.node.data, meta } });
269
+ },
270
+
271
+ remove(id) {
272
+ const found = nodeOf(id);
273
+ if (found) found.scene.remove(asNodeId(found.node.id));
274
+ },
275
+
276
+ async capture(target: string, captureOpts?: CaptureOptions) {
277
+ const info = targetOf(target);
278
+ if (!info) throw new Error(`[labkit] no annotation target called '${target}'`);
279
+ const result = await captureTarget(
280
+ {
281
+ target,
282
+ scene: sceneFor(target),
283
+ draw: {
284
+ content: info.content,
285
+ positionDependsOn: info.positionDependsOn,
286
+ config: opts.config?.(),
287
+ meaning: opts.meaning,
288
+ },
289
+ base: info.base,
290
+ onWarn: (message) => console.warn(`[labkit] capture: ${message}`),
291
+ },
292
+ captureOpts,
293
+ );
294
+ opts.onCapture?.(result);
295
+ return result;
296
+ },
297
+
298
+ toJSON(): SerializedAnnotations {
299
+ const out: Record<string, unknown> = {};
300
+ for (const [target, scene] of scenes) out[target] = scene.toJSON();
301
+ return { version: 1, scenes: out };
302
+ },
303
+ };
304
+ }
305
+
306
+ /** Rebuild a store from what `toJSON` wrote. Unknown or future versions give
307
+ * an empty store rather than throwing: a lab that cannot read its marks
308
+ * should still open. */
309
+ export function annotationsFromJSON(
310
+ raw: unknown,
311
+ targets: () => readonly AnnotationTargetInfo[],
312
+ rest: Omit<AnnotationStoreOptions, 'targets' | 'restore'> = {},
313
+ ): AnnotationsApi {
314
+ const doc = raw as Partial<SerializedAnnotations> | null;
315
+ if (doc?.version !== 1 || !doc.scenes) return createAnnotationStore({ targets, ...rest });
316
+ return createAnnotationStore({ targets, restore: doc.scenes, ...rest });
317
+ }
@@ -0,0 +1,77 @@
1
+ import { serializeSvg } from '@weasel-js/svg';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { markCommands } from './paint';
4
+ import { markSvgNodes } from './svgNodes';
5
+ import type { AnnotationData, AnnotationKind } from './types';
6
+
7
+ const CONTENT = { w: 100, h: 60 };
8
+
9
+ const mark = (kind: AnnotationKind, extra: Partial<AnnotationData> = {}) => ({
10
+ pose: { x: 10, y: 12, width: 30, height: 20 },
11
+ data: {
12
+ target: 'flat',
13
+ kind,
14
+ points: [
15
+ { x: 0.1, y: 0.2 },
16
+ { x: 0.5, y: 0.6 },
17
+ ],
18
+ ...extra,
19
+ } as AnnotationData,
20
+ });
21
+
22
+ const KINDS: AnnotationKind[] = ['rect', 'ellipse', 'line', 'arrow', 'stroke'];
23
+
24
+ describe('a mark as vector', () => {
25
+ it.each(KINDS)('emits %s off the same geometry the screen draws', (kind) => {
26
+ const m = mark(kind);
27
+ const cmds = markCommands(m, CONTENT);
28
+ const nodes = markSvgNodes(m, CONTENT);
29
+ expect(nodes).toHaveLength(cmds.length);
30
+ for (const [i, cmd] of cmds.entries()) {
31
+ if (cmd.kind !== 'path') throw new Error('expected path commands');
32
+ const node = nodes[i];
33
+ if (node?.kind !== 'path') throw new Error('expected path nodes');
34
+ // Same geometry, kind for kind. A second switch over `data.kind` would
35
+ // drift from this one the moment either changed, which is what the
36
+ // emitter mapping over `markCommands` output rules out.
37
+ expect(node.path).toEqual(cmd.path);
38
+ expect(node.stroke?.width).toBe(cmd.stroke?.width);
39
+ expect(node.stroke?.cap).toBe(cmd.stroke?.cap);
40
+ expect(node.stroke?.join).toBe(cmd.stroke?.join);
41
+ // An arrow's head arrives filled and unstroked; the shaft is the other
42
+ // way round. Whichever paint the command carries is the mark colour.
43
+ const paint = node.stroke?.paint ?? node.fill;
44
+ expect(paint).toEqual({ kind: 'solid', color: '#e5484d' });
45
+ }
46
+ });
47
+
48
+ it('carries a stale mark′s dash through', () => {
49
+ const [node] = markSvgNodes(mark('rect'), CONTENT, { stale: true });
50
+ if (node?.kind !== 'path') throw new Error('expected a path node');
51
+ expect(node.stroke?.dash).toEqual([6, 4]);
52
+ });
53
+
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(
64
+ '<marker',
65
+ );
66
+ });
67
+
68
+ it('emits text as text, at the pose, in the mark colour', () => {
69
+ const m = mark('text', { title: 'missing edge' });
70
+ const [node] = markSvgNodes(m, CONTENT);
71
+ expect(node).toMatchObject({ kind: 'text', text: 'missing edge', x: 10, y: 12 });
72
+ });
73
+
74
+ it('emits nothing for a text mark with no words', () => {
75
+ expect(markSvgNodes(mark('text'), CONTENT)).toEqual([]);
76
+ });
77
+ });
@@ -0,0 +1,74 @@
1
+ import type { DrawCommand, FillStyle, Stroke } from '@weasel-js/core';
2
+ import { resolveStrokeWidth } from '@weasel-js/core';
3
+ import type { SvgNode, SvgPaint, SvgStroke } from '@weasel-js/svg';
4
+ import { type MarkStyle, markCommands, type PaintableMark } from './paint';
5
+
6
+ const NO_PAINT: SvgPaint = { kind: 'none' };
7
+
8
+ function toSvgPaint(paint: FillStyle | undefined): SvgPaint {
9
+ if (!paint) return NO_PAINT;
10
+ if ('color' in paint && typeof paint.color === 'string') {
11
+ return {
12
+ kind: 'solid',
13
+ color: paint.color,
14
+ ...(paint.opacity != null ? { opacity: paint.opacity } : {}),
15
+ };
16
+ }
17
+ return { kind: 'gradient', paint };
18
+ }
19
+
20
+ /** Marker references are deliberately dropped: `markCommands` already resolves
21
+ * a marker into its own path command, and keeping the reference would make
22
+ * the serializer emit a `<marker>` def that draws the head a second time. */
23
+ function toSvgStroke(stroke: Stroke): SvgStroke {
24
+ return {
25
+ paint: toSvgPaint(stroke.paint),
26
+ width: resolveStrokeWidth(stroke.width ?? 1, 1),
27
+ ...(stroke.cap ? { cap: stroke.cap } : {}),
28
+ ...(stroke.join ? { join: stroke.join } : {}),
29
+ ...(stroke.dash ? { dash: [...stroke.dash] } : {}),
30
+ ...(stroke.miterLimit != null ? { miterLimit: stroke.miterLimit } : {}),
31
+ };
32
+ }
33
+
34
+ function toSvgNode(cmd: DrawCommand, m: PaintableMark): SvgNode {
35
+ if (cmd.kind === 'path') {
36
+ return {
37
+ kind: 'path',
38
+ path: cmd.path,
39
+ fill: toSvgPaint(cmd.fill),
40
+ ...(cmd.stroke ? { stroke: toSvgStroke(cmd.stroke) } : {}),
41
+ };
42
+ }
43
+ if (cmd.kind === 'text') {
44
+ return {
45
+ kind: 'text',
46
+ x: cmd.x,
47
+ y: cmd.y,
48
+ width: m.pose.width,
49
+ height: m.pose.height,
50
+ text: cmd.runs.map((run) => run.text).join(''),
51
+ style: cmd.style,
52
+ ...(cmd.runs[0]?.fill ? { fill: cmd.runs[0].fill } : {}),
53
+ };
54
+ }
55
+ // Unreachable while `markCommands` emits only paths and text. A throw rather
56
+ // than a skip: a new mark kind silently vanishing from every export is a
57
+ // worse afternoon than a loud one.
58
+ throw new Error(`[labkit] a mark drew a '${cmd.kind}' command, which has no vector form`);
59
+ }
60
+
61
+ /**
62
+ * One mark as SVG nodes, converted from the very commands the pane paints.
63
+ *
64
+ * Deliberately not a second switch over `data.kind`: geometry has one source,
65
+ * and this is a translation of it. `serializeSvg` turns the result into a
66
+ * document.
67
+ */
68
+ export function markSvgNodes(
69
+ m: PaintableMark,
70
+ content: { w: number; h: number },
71
+ style: MarkStyle = {},
72
+ ): SvgNode[] {
73
+ return markCommands(m, content, style).map((cmd) => toSvgNode(cmd, m));
74
+ }
@@ -0,0 +1,36 @@
1
+ import { KIT_SHAPE_KINDS } from '@weasel-js/core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ANNOTATION_TOOLS, annotationToolInfo } from './toolMap';
4
+ import type { AnnotationKind } from './types';
5
+
6
+ const KINDS: AnnotationKind[] = ['stroke', 'line', 'arrow', 'rect', 'ellipse', 'text'];
7
+
8
+ describe('the annotation tool table', () => {
9
+ it('offers one tool per mark kind, plus select', () => {
10
+ expect(ANNOTATION_TOOLS.map((t) => t.id)).toEqual(['select', ...KINDS]);
11
+ });
12
+
13
+ it('only names weasel tools SceneCanvas will mount', () => {
14
+ // Against the kit's own list, not a retyped copy: a tool id that stops
15
+ // being built in should fail here rather than in a silent no-op palette.
16
+ const mountable = new Set<string>([...KIT_SHAPE_KINDS, 'select']);
17
+ for (const t of ANNOTATION_TOOLS) {
18
+ expect(mountable, `${t.id} maps to a tool the kit does not mount`).toContain(
19
+ annotationToolInfo(t.id)?.weaselTool,
20
+ );
21
+ }
22
+ });
23
+
24
+ it('gives select no kind, because it makes no mark', () => {
25
+ expect(annotationToolInfo('select')).toEqual({ weaselTool: 'select' });
26
+ });
27
+
28
+ it('rides line and pencil for the two kinds with no tool of their own', () => {
29
+ expect(annotationToolInfo('arrow')).toEqual({ weaselTool: 'line', kind: 'arrow' });
30
+ expect(annotationToolInfo('stroke')).toEqual({ weaselTool: 'pencil', kind: 'stroke' });
31
+ });
32
+
33
+ it('answers undefined for a tool id that is not one of ours', () => {
34
+ expect(annotationToolInfo('hand')).toBeUndefined();
35
+ });
36
+ });
@@ -0,0 +1,54 @@
1
+ import type { BuiltinToolId } from '@weasel-js/core';
2
+ import {
3
+ ArrowIcon,
4
+ EllipseIcon,
5
+ LineIcon,
6
+ PencilIcon,
7
+ RectIcon,
8
+ SelectIcon,
9
+ TextIcon,
10
+ } from '@weasel-js/ui';
11
+ import type { TrialTool } from '../tools/types';
12
+ import type { AnnotationKind } from './types';
13
+
14
+ /** Which weasel tool an annotation tool drives, and what kind of mark that
15
+ * makes. `select` makes none. */
16
+ export interface AnnotationToolInfo {
17
+ weaselTool: BuiltinToolId;
18
+ kind?: AnnotationKind;
19
+ }
20
+
21
+ // `arrow` and `stroke` have no weasel tool of their own, so the kind cannot be
22
+ // read back from the insert — the overlay takes it from the tool id it holds.
23
+ const TOOLS: Record<string, AnnotationToolInfo> = {
24
+ select: { weaselTool: 'select' },
25
+ stroke: { weaselTool: 'pencil', kind: 'stroke' },
26
+ line: { weaselTool: 'line', kind: 'line' },
27
+ arrow: { weaselTool: 'line', kind: 'arrow' },
28
+ rect: { weaselTool: 'rect', kind: 'rect' },
29
+ ellipse: { weaselTool: 'ellipse', kind: 'ellipse' },
30
+ text: { weaselTool: 'text', kind: 'text' },
31
+ };
32
+
33
+ /** The palette an instrument gets for declaring `annotations`. Ids share the
34
+ * trial's contribution namespace with `instrument.tools`, and one tool slot
35
+ * holds whichever is active. */
36
+ export const ANNOTATION_TOOLS: readonly TrialTool[] = [
37
+ { id: 'select', label: 'Select', icon: SelectIcon, group: 'annotate' },
38
+ { id: 'stroke', label: 'Freehand', icon: PencilIcon, group: 'annotate' },
39
+ { id: 'line', label: 'Line', icon: LineIcon, group: 'annotate' },
40
+ { id: 'arrow', label: 'Arrow', icon: ArrowIcon, group: 'annotate' },
41
+ { id: 'rect', label: 'Rectangle', icon: RectIcon, group: 'annotate' },
42
+ { id: 'ellipse', label: 'Ellipse', icon: EllipseIcon, group: 'annotate' },
43
+ { id: 'text', label: 'Text', icon: TextIcon, group: 'annotate' },
44
+ ];
45
+
46
+ /** What `id` drives, or undefined for a tool that is not one of these. */
47
+ export function annotationToolInfo(id: string | null): AnnotationToolInfo | undefined {
48
+ return id === null ? undefined : TOOLS[id];
49
+ }
50
+
51
+ /** Every weasel tool the overlay has to mount for this palette to work. */
52
+ export const ANNOTATION_WEASEL_TOOLS: readonly BuiltinToolId[] = [
53
+ ...new Set(Object.values(TOOLS).map((t) => t.weaselTool)),
54
+ ];