@weasel-js/labkit 1.3.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 (210) 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-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
  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 -4
  14. package/dist/chrome/index.d.ts +9 -6
  15. package/dist/chrome/index.js +6 -6
  16. package/dist/chunk-2AYGEN57.js +32 -0
  17. package/dist/chunk-2AYGEN57.js.map +1 -0
  18. package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
  19. package/dist/chunk-3P5TTTA7.js.map +1 -0
  20. package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
  21. package/dist/chunk-3ZELRRBV.js.map +1 -0
  22. package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
  23. package/dist/chunk-4EY67BKJ.js.map +1 -0
  24. package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
  25. package/dist/chunk-5TCEVMPI.js.map +1 -0
  26. package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
  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-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
  37. package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
  38. package/dist/chunk-LITE5YFB.js +669 -0
  39. package/dist/chunk-LITE5YFB.js.map +1 -0
  40. package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
  41. package/dist/chunk-ROQDRXKW.js.map +1 -0
  42. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  43. package/dist/chunk-XJ6N32QP.js.map +1 -0
  44. package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
  45. package/dist/chunk-YQJNT4XQ.js.map +1 -0
  46. package/dist/controls/index.d.ts +4 -28
  47. package/dist/controls/index.js +3 -3
  48. package/dist/dragdrop/index.d.ts +9 -5
  49. package/dist/dragdrop/index.js +3 -3
  50. package/dist/index.d.ts +518 -190
  51. package/dist/index.js +1753 -75
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +6 -5
  54. package/dist/layers/index.js +3 -3
  55. package/dist/loupe/index.d.ts +187 -0
  56. package/dist/loupe/index.js +7 -0
  57. package/dist/loupe/index.js.map +1 -0
  58. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  59. package/dist/passthrough/weasel-canvas.js +1 -1
  60. package/dist/passthrough/weasel-ui.d.ts +138 -91
  61. package/dist/passthrough/weasel-ui.js +2 -2
  62. package/dist/primitives/index.d.ts +3 -1
  63. package/dist/primitives/index.js +5 -5
  64. package/dist/state/index.d.ts +3 -3
  65. package/dist/state/index.js +2 -2
  66. package/dist/styles.css +229 -1
  67. package/dist/surface/index.d.ts +18 -2
  68. package/dist/surface/index.js +2 -2
  69. package/dist/ui/layers/index.js +2 -2
  70. package/dist/undo/index.d.ts +5 -4
  71. package/package.json +14 -7
  72. package/src/annotations/AnnotationOverlay.tsx +205 -0
  73. package/src/annotations/AnnotationTargets.tsx +37 -0
  74. package/src/annotations/Annotations.less +129 -0
  75. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  76. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  77. package/src/annotations/AnnotationsContext.ts +49 -0
  78. package/src/annotations/ExportMenu.test.tsx +86 -0
  79. package/src/annotations/ExportMenu.tsx +182 -0
  80. package/src/annotations/MarkList.tsx +75 -0
  81. package/src/annotations/capture.test.ts +140 -0
  82. package/src/annotations/capture.ts +237 -0
  83. package/src/annotations/drawOne.test.ts +56 -0
  84. package/src/annotations/drawOne.ts +47 -0
  85. package/src/annotations/frac.test.ts +59 -0
  86. package/src/annotations/frac.ts +66 -0
  87. package/src/annotations/history.test.ts +82 -0
  88. package/src/annotations/history.ts +118 -0
  89. package/src/annotations/index.ts +37 -0
  90. package/src/annotations/paint.test.ts +125 -0
  91. package/src/annotations/paint.ts +130 -0
  92. package/src/annotations/staleness.test.ts +56 -0
  93. package/src/annotations/staleness.ts +41 -0
  94. package/src/annotations/store.test.ts +175 -0
  95. package/src/annotations/store.ts +317 -0
  96. package/src/annotations/svgNodes.test.ts +77 -0
  97. package/src/annotations/svgNodes.ts +74 -0
  98. package/src/annotations/toolMap.test.ts +36 -0
  99. package/src/annotations/toolMap.ts +54 -0
  100. package/src/annotations/types.ts +222 -0
  101. package/src/annotations/view.test.ts +37 -0
  102. package/src/annotations/view.ts +40 -0
  103. package/src/canvas/AGENTS.md +55 -5
  104. package/src/canvas/CanvasStack.test.tsx +27 -1
  105. package/src/canvas/CanvasStack.tsx +29 -4
  106. package/src/canvas/CanvasStackContext.ts +23 -4
  107. package/src/canvas/camera.test.ts +78 -0
  108. package/src/canvas/camera.ts +62 -0
  109. package/src/canvas/canvasCoords.test.ts +26 -0
  110. package/src/canvas/canvasCoords.ts +20 -7
  111. package/src/canvas/index.ts +5 -1
  112. package/src/canvas/useLayerScheduler.ts +7 -3
  113. package/src/canvas/usePanZoom.test.ts +36 -5
  114. package/src/canvas/usePanZoom.ts +20 -15
  115. package/src/canvas/worldSpec.test.ts +63 -0
  116. package/src/canvas/worldSpec.ts +48 -0
  117. package/src/chrome/builtins.test.ts +31 -1
  118. package/src/chrome/builtins.tsx +63 -19
  119. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  120. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  121. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  122. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  123. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  124. package/src/chrome/regions/regions.test.tsx +6 -1
  125. package/src/chrome/types.ts +20 -0
  126. package/src/controls/ControlPanel.test.tsx +38 -0
  127. package/src/controls/ControlPanel.tsx +50 -3
  128. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  129. package/src/dragdrop/dragDrop.test.tsx +29 -2
  130. package/src/index.test.ts +22 -0
  131. package/src/index.ts +87 -2
  132. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  133. package/src/instrument/types.ts +24 -1
  134. package/src/lab/Lab.surface.test.tsx +162 -0
  135. package/src/lab/Lab.tsx +102 -17
  136. package/src/lab/Lab.undock.test.tsx +85 -0
  137. package/src/lab/LabFullChrome.stories.tsx +20 -0
  138. package/src/lab/LabShell.less +11 -0
  139. package/src/lab/Workspace.less +73 -0
  140. package/src/lab/Workspace.surface.test.tsx +2 -0
  141. package/src/lab/Workspace.tsx +97 -8
  142. package/src/lab/index.ts +1 -1
  143. package/src/lab/panelHost.ts +37 -0
  144. package/src/loupe/AGENTS.md +49 -0
  145. package/src/loupe/CanvasLoupe.tsx +83 -0
  146. package/src/loupe/DomLoupe.tsx +62 -0
  147. package/src/loupe/Loupe.less +38 -0
  148. package/src/loupe/LoupeBubble.tsx +32 -0
  149. package/src/loupe/TrialLoupe.tsx +93 -0
  150. package/src/loupe/canvasLens.test.ts +205 -0
  151. package/src/loupe/canvasLens.ts +141 -0
  152. package/src/loupe/index.ts +19 -0
  153. package/src/loupe/types.test.ts +29 -0
  154. package/src/loupe/types.ts +90 -0
  155. package/src/loupe/useHostSize.ts +30 -0
  156. package/src/loupe/useLoupe.test.tsx +197 -0
  157. package/src/loupe/useLoupe.ts +189 -0
  158. package/src/passthrough/weasel-ui.ts +3 -0
  159. package/src/primitives/FloatingPanel.test.tsx +76 -1
  160. package/src/primitives/FloatingPanel.tsx +19 -3
  161. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  162. package/src/primitives/Sidebar.less +18 -0
  163. package/src/primitives/Toolbar.less +8 -0
  164. package/src/primitives/Toolbar.tsx +5 -0
  165. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  166. package/src/primitives/useRovingTabIndex.ts +26 -17
  167. package/src/state/document.test.ts +23 -0
  168. package/src/state/document.ts +14 -2
  169. package/src/state/index.ts +2 -0
  170. package/src/state/store.test.ts +24 -0
  171. package/src/state/store.ts +35 -1
  172. package/src/state/types.ts +11 -0
  173. package/src/state/undock.test.ts +40 -0
  174. package/src/state/undock.ts +39 -0
  175. package/src/styles.less +2 -0
  176. package/src/surface/SurfaceContext.ts +4 -0
  177. package/src/surface/index.ts +8 -3
  178. package/src/surface/useSurfaceTile.test.tsx +2 -0
  179. package/src/surface/useSurfaceTile.ts +7 -1
  180. package/src/surface/useTiledSurface.test.tsx +58 -0
  181. package/src/surface/useTiledSurface.ts +38 -3
  182. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  183. package/src/trial/Trial.annotations.test.tsx +121 -0
  184. package/src/trial/Trial.canvas.test.tsx +76 -1
  185. package/src/trial/Trial.less +19 -0
  186. package/src/trial/Trial.loupe.test.tsx +128 -0
  187. package/src/trial/Trial.stories.tsx +1 -0
  188. package/src/trial/Trial.test.tsx +12 -0
  189. package/src/trial/Trial.trialId.test.tsx +36 -0
  190. package/src/trial/Trial.tsx +215 -40
  191. package/src/trial/TrialChrome.tsx +35 -0
  192. package/src/trial/UndockedSections.tsx +65 -0
  193. package/src/trial/trialOps.test.ts +21 -0
  194. package/src/trial/trialOps.ts +5 -3
  195. package/dist/_dts/types-BttTIent.d.ts +0 -314
  196. package/dist/chunk-3XXPU73K.js.map +0 -1
  197. package/dist/chunk-4TMMVIDM.js.map +0 -1
  198. package/dist/chunk-6X5RBC6G.js +0 -366
  199. package/dist/chunk-6X5RBC6G.js.map +0 -1
  200. package/dist/chunk-BDJXIBRZ.js.map +0 -1
  201. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  202. package/dist/chunk-JDROYQM3.js.map +0 -1
  203. package/dist/chunk-LGKRIUSW.js.map +0 -1
  204. package/dist/chunk-QCICIHEO.js.map +0 -1
  205. package/dist/chunk-QRYPXSGP.js.map +0 -1
  206. package/dist/chunk-RGT4EJIB.js +0 -6961
  207. package/dist/chunk-RGT4EJIB.js.map +0 -1
  208. package/dist/chunk-T66HU3HX.js +0 -17
  209. package/dist/chunk-T66HU3HX.js.map +0 -1
  210. package/dist/chunk-WTI26YTM.js.map +0 -1
@@ -0,0 +1,237 @@
1
+ import { asNodeId, renderSceneToPixels } from '@weasel-js/core';
2
+ import { serializeSvg } from '@weasel-js/svg';
3
+ import { createMarkDrawOne, type MarkDrawOptions, resolveMarkStyle } from './drawOne';
4
+ import type { MarkScene } from './store';
5
+ import { markSvgNodes } from './svgNodes';
6
+ import type { AnnotationData, CaptureOptions, CaptureResult, CaptureSource } from './types';
7
+
8
+ const SVG_NS = 'http://www.w3.org/2000/svg';
9
+
10
+ /** Which of the two composition routes an export takes. */
11
+ export type CapturePlan = 'svg-document' | 'raster-stack';
12
+
13
+ /**
14
+ * Vector all the way through, or both sides rasterized and stacked.
15
+ *
16
+ * An SVG base is the only one that nests: the marks serialize beside the
17
+ * artifact's own markup and the whole document rasterizes once at the end. A
18
+ * raster base, or none at all, takes the renderer's own path instead — which
19
+ * is also what draws marks onto transparency for a target that declares no
20
+ * base.
21
+ */
22
+ export function capturePlan(base: CaptureSource | undefined, format: 'png' | 'svg'): CapturePlan {
23
+ if (format === 'svg') return 'svg-document';
24
+ return base?.kind === 'svg' ? 'svg-document' : 'raster-stack';
25
+ }
26
+
27
+ /** Re-frame a base's own `<svg>` onto the content box, keeping its `viewBox`.
28
+ * Parsed rather than string-spliced: a base whose declared width disagrees
29
+ * with its viewBox would otherwise land at the wrong scale. */
30
+ function nestBaseSvg(markup: string, w: number, h: number): string {
31
+ const doc = new DOMParser().parseFromString(markup, 'image/svg+xml');
32
+ const root = doc.documentElement;
33
+ if (doc.getElementsByTagName('parsererror').length > 0 || root.localName !== 'svg') {
34
+ throw new Error("[labkit] a target's `base()` returned markup that is not an <svg> document");
35
+ }
36
+ root.setAttribute('x', '0');
37
+ root.setAttribute('y', '0');
38
+ root.setAttribute('width', String(w));
39
+ root.setAttribute('height', String(h));
40
+ return new XMLSerializer().serializeToString(root);
41
+ }
42
+
43
+ /** A raster base as an `<image>` filling the content box. An external `src`
44
+ * round-trips as a reference, so the export is only self-contained when it
45
+ * is already a `data:` URI. */
46
+ function embedRasterBase(
47
+ base: CaptureSource,
48
+ w: number,
49
+ h: number,
50
+ onWarn?: (m: string) => void,
51
+ ): string {
52
+ const href =
53
+ base.kind === 'canvas' ? base.canvas.toDataURL() : base.kind === 'image' ? base.src : '';
54
+ if (base.kind === 'image' && !href.startsWith('data:')) {
55
+ onWarn?.(`base image '${href}' is a reference, so this SVG is not self-contained`);
56
+ }
57
+ return serializeSvg([{ kind: 'image', href, x: 0, y: 0, width: w, height: h }], {
58
+ viewBox: { x: 0, y: 0, width: w, height: h },
59
+ });
60
+ }
61
+
62
+ /** Every mark on a scene, as SVG nodes, styled the way the pane styles them. */
63
+ function markNodesOf(scene: MarkScene, draw: MarkDrawOptions) {
64
+ return [...scene.renderOrder()].flatMap((id) => {
65
+ const node = scene.get(asNodeId(String(id)));
66
+ if (!node) return [];
67
+ return markSvgNodes(
68
+ { pose: node.pose, data: node.data as AnnotationData },
69
+ draw.content,
70
+ resolveMarkStyle(node.data as AnnotationData, draw),
71
+ );
72
+ });
73
+ }
74
+
75
+ export interface ComposeSvgArgs {
76
+ base?: CaptureSource;
77
+ scene: MarkScene;
78
+ draw: MarkDrawOptions;
79
+ scale: number;
80
+ onWarn?: (message: string) => void;
81
+ }
82
+
83
+ /**
84
+ * The base and the marks in one SVG document.
85
+ *
86
+ * The outer `viewBox` is the content box, so marks — which are already in it —
87
+ * need no transform, and `width`/`height` carry the export scale. Both halves
88
+ * nest as child `<svg>` elements, which each establish their own viewport and
89
+ * so keep their own `viewBox` maths out of this function.
90
+ */
91
+ export function composeCaptureSvg(args: ComposeSvgArgs): string {
92
+ const { base, scene, draw, scale, onWarn } = args;
93
+ const { w, h } = draw.content;
94
+ const baseXml =
95
+ base === undefined
96
+ ? ''
97
+ : base.kind === 'svg'
98
+ ? nestBaseSvg(base.markup, w, h)
99
+ : embedRasterBase(base, w, h, onWarn);
100
+ // No width/height on the marks document: a nested <svg> without them fills
101
+ // the outer viewport exactly, which is the alignment this relies on.
102
+ const marksXml = serializeSvg(markNodesOf(scene, draw), {
103
+ viewBox: { x: 0, y: 0, width: w, height: h },
104
+ onWarn,
105
+ });
106
+ const outW = Math.max(1, Math.round(w * scale));
107
+ const outH = Math.max(1, Math.round(h * scale));
108
+ return `<svg xmlns="${SVG_NS}" width="${outW}" height="${outH}" viewBox="0 0 ${w} ${h}">${baseXml}${marksXml}</svg>`;
109
+ }
110
+
111
+ function context2d(width: number, height: number): CanvasRenderingContext2D {
112
+ const canvas = document.createElement('canvas');
113
+ canvas.width = width;
114
+ canvas.height = height;
115
+ const ctx = canvas.getContext('2d');
116
+ if (!ctx) throw new Error('[labkit] capture needs a 2D canvas context, and this one has none');
117
+ return ctx;
118
+ }
119
+
120
+ function toPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
121
+ return new Promise((resolve, reject) => {
122
+ let done: (b: Blob | null) => void;
123
+ try {
124
+ done = (blob) =>
125
+ blob ? resolve(blob) : reject(new Error('[labkit] the browser produced no PNG'));
126
+ canvas.toBlob(done, 'image/png');
127
+ } catch (err) {
128
+ // A base referencing an image from another origin taints the canvas and
129
+ // `toBlob` throws SecurityError. Name the cause: the DOM exception on
130
+ // its own sends people looking at labkit.
131
+ reject(
132
+ new Error(
133
+ '[labkit] the capture canvas is tainted, so it cannot be read back — a base referencing a cross-origin image does this. Embed it as a data: URI.',
134
+ { cause: err },
135
+ ),
136
+ );
137
+ }
138
+ });
139
+ }
140
+
141
+ function loadImage(src: string): Promise<HTMLImageElement> {
142
+ return new Promise((resolve, reject) => {
143
+ const img = new Image();
144
+ img.onload = () => resolve(img);
145
+ img.onerror = () => reject(new Error(`[labkit] a capture base failed to load: ${src}`));
146
+ img.src = src;
147
+ });
148
+ }
149
+
150
+ /** Rasterize a composed document by handing it to the browser's own SVG
151
+ * renderer — one pass over base and marks together. */
152
+ async function rasterizeSvg(svg: string, width: number, height: number): Promise<Blob> {
153
+ const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }));
154
+ try {
155
+ const img = await loadImage(url);
156
+ const ctx = context2d(width, height);
157
+ ctx.drawImage(img, 0, 0, width, height);
158
+ return await toPngBlob(ctx.canvas);
159
+ } finally {
160
+ URL.revokeObjectURL(url);
161
+ }
162
+ }
163
+
164
+ /** Base pixels, then mark pixels over them. The marks come from
165
+ * `renderSceneToPixels` rather than a readback of the live surface, so a
166
+ * capture neither depends on nor disturbs what is on screen. */
167
+ async function stackRaster(
168
+ args: ComposeSvgArgs & { width: number; height: number },
169
+ ): Promise<Blob> {
170
+ const { base, scene, draw, scale, width, height } = args;
171
+ const ctx = context2d(width, height);
172
+ if (base?.kind === 'image') ctx.drawImage(await loadImage(base.src), 0, 0, width, height);
173
+ else if (base?.kind === 'canvas') ctx.drawImage(base.canvas, 0, 0, width, height);
174
+
175
+ const raster = renderSceneToPixels({
176
+ scene,
177
+ sourceRect: { x: 0, y: 0, width: draw.content.w, height: draw.content.h },
178
+ scale: { x: scale, y: scale },
179
+ drawOne: createMarkDrawOne(draw),
180
+ });
181
+ // Through a second canvas rather than `putImageData` on this one, which
182
+ // replaces pixels instead of compositing and would erase the base.
183
+ const marks = context2d(raster.width, raster.height);
184
+ marks.putImageData(
185
+ new ImageData(Uint8ClampedArray.from(raster.data), raster.width, raster.height),
186
+ 0,
187
+ 0,
188
+ );
189
+ ctx.drawImage(marks.canvas, 0, 0, width, height);
190
+ return toPngBlob(ctx.canvas);
191
+ }
192
+
193
+ /** What a capture needs about the target it is exporting. */
194
+ export interface CaptureArgs {
195
+ target: string;
196
+ scene: MarkScene;
197
+ draw: MarkDrawOptions;
198
+ base?: () => CaptureSource | Promise<CaptureSource>;
199
+ onWarn?: (message: string) => void;
200
+ }
201
+
202
+ /** Export one target's picture with its marks on it. */
203
+ export async function captureTarget(
204
+ args: CaptureArgs,
205
+ opts: CaptureOptions = {},
206
+ ): Promise<CaptureResult> {
207
+ const format = opts.format ?? 'png';
208
+ const scale = opts.scale ?? 2;
209
+ const { content } = args.draw;
210
+ const width = Math.max(1, Math.round(content.w * scale));
211
+ const height = Math.max(1, Math.round(content.h * scale));
212
+ const base = await args.base?.();
213
+ const compose: ComposeSvgArgs = {
214
+ base,
215
+ scene: args.scene,
216
+ draw: args.draw,
217
+ scale,
218
+ onWarn: args.onWarn,
219
+ };
220
+
221
+ if (capturePlan(base, format) === 'raster-stack') {
222
+ return {
223
+ target: args.target,
224
+ blob: await stackRaster({ ...compose, width, height }),
225
+ format,
226
+ width,
227
+ height,
228
+ };
229
+ }
230
+
231
+ const svg = composeCaptureSvg(compose);
232
+ const blob =
233
+ format === 'svg'
234
+ ? new Blob([svg], { type: 'image/svg+xml;charset=utf-8' })
235
+ : await rasterizeSvg(svg, width, height);
236
+ return { target: args.target, blob, format, width, height };
237
+ }
@@ -0,0 +1,56 @@
1
+ import type { SceneNode, View } from '@weasel-js/core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createMarkDrawOne } from './drawOne';
4
+ import type { WorldRect } from './frac';
5
+ import type { AnnotationData } from './types';
6
+
7
+ const CONTENT = { w: 100, h: 60 };
8
+ const VIEW = { x: 0, y: 0, zoom: 1 } as unknown as View;
9
+
10
+ const node = (data: Partial<AnnotationData>, pose: WorldRect) =>
11
+ ({
12
+ id: 'n1',
13
+ kind: 'leaf',
14
+ layer: 'marks',
15
+ pose,
16
+ data: { target: 'flat', kind: 'rect', ...data } as AnnotationData,
17
+ }) as unknown as SceneNode<AnnotationData, 'marks', WorldRect>;
18
+
19
+ const strokeOf = (cmds: ReturnType<ReturnType<typeof createMarkDrawOne>>) => {
20
+ const first = cmds[0];
21
+ if (first?.kind !== 'path') throw new Error('expected a path command');
22
+ return first.stroke;
23
+ };
24
+
25
+ describe('the draw callback marks share between the screen and an export', () => {
26
+ it("paints a mark in its status's colour", () => {
27
+ const drawOne = createMarkDrawOne({
28
+ content: CONTENT,
29
+ config: {},
30
+ meaning: { statuses: [{ id: 'fixed', label: 'Fixed', color: '#30a46c' }] },
31
+ });
32
+ const n = node({ status: 'fixed' }, { x: 1, y: 2, width: 3, height: 4 });
33
+ expect(strokeOf(drawOne(n, n.pose, VIEW))?.paint).toEqual({ color: '#30a46c' });
34
+ });
35
+
36
+ it('dashes a mark whose position no longer describes the picture', () => {
37
+ const drawOne = createMarkDrawOne({
38
+ content: CONTENT,
39
+ positionDependsOn: ['angle'],
40
+ config: { angle: 30 },
41
+ });
42
+ const fresh = node({ seen: { angle: 30 } }, { x: 1, y: 2, width: 3, height: 4 });
43
+ const stale = node({ seen: { angle: 0 } }, { x: 1, y: 2, width: 3, height: 4 });
44
+ expect(strokeOf(drawOne(fresh, fresh.pose, VIEW))?.dash).toBeUndefined();
45
+ expect(strokeOf(drawOne(stale, stale.pose, VIEW))?.dash).toEqual([6, 4]);
46
+ });
47
+
48
+ it('draws at the pose the walk resolved, not the one the node was stored with', () => {
49
+ const drawOne = createMarkDrawOne({ content: CONTENT, config: {} });
50
+ const n = node({}, { x: 1, y: 2, width: 3, height: 4 });
51
+ const cmds = drawOne(n, { x: 50, y: 60, width: 10, height: 10 }, VIEW);
52
+ const first = cmds[0];
53
+ if (first?.kind !== 'path') throw new Error('expected a path command');
54
+ expect(JSON.stringify(first.path)).toContain('50');
55
+ });
56
+ });
@@ -0,0 +1,47 @@
1
+ import type { DrawCommand, SceneNode, View } from '@weasel-js/core';
2
+ import type { WorldRect } from './frac';
3
+ import { type MarkStyle, markCommands } from './paint';
4
+ import { isStale } from './staleness';
5
+ import type { AnnotationData, AnnotationMeaning } from './types';
6
+
7
+ /** What resolving a mark's appearance needs beyond the mark itself. */
8
+ export interface MarkDrawOptions {
9
+ /** The target's content box — the world a mark's geometry is in. */
10
+ content: { w: number; h: number };
11
+ /** The target's `positionDependsOn`, for staleness. */
12
+ positionDependsOn?: readonly string[];
13
+ /** The trial's config at the time of the draw. */
14
+ config: unknown;
15
+ /** The instrument's vocabulary, for a status's colour. */
16
+ meaning?: AnnotationMeaning;
17
+ }
18
+
19
+ /**
20
+ * How one mark is drawn — shared by the pane on screen and by an export.
21
+ *
22
+ * One function rather than two call sites building their own, because an
23
+ * export that resolves a status colour or a stale dash differently from the
24
+ * screen produces a picture nobody was looking at.
25
+ */
26
+ export function createMarkDrawOne(
27
+ opts: MarkDrawOptions,
28
+ ): (
29
+ node: SceneNode<AnnotationData, 'marks', WorldRect>,
30
+ pose: WorldRect,
31
+ view: View,
32
+ ) => DrawCommand[] {
33
+ return (node, pose) =>
34
+ markCommands({ pose, data: node.data }, opts.content, resolveMarkStyle(node.data, opts));
35
+ }
36
+
37
+ /** How a mark looks, as opposed to where it is. Separate from the callback
38
+ * above because an SVG export resolves the same appearance without ever
39
+ * building a draw command. */
40
+ export function resolveMarkStyle(data: AnnotationData, opts: MarkDrawOptions): MarkStyle {
41
+ return {
42
+ color: opts.meaning?.statuses?.find((s) => s.id === data.status)?.color,
43
+ // Read off the node rather than through the store's `isStale`, which
44
+ // wants a projected Annotation this path does not have.
45
+ stale: isStale(data.seen, opts.config, opts.positionDependsOn ?? []),
46
+ };
47
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fracContains, fracIntersects, fracToWorld, roundFrac, worldToFrac } from './frac';
3
+ import type { FracRect } from './types';
4
+
5
+ const CONTENT = { w: 256, h: 170 };
6
+
7
+ describe('fraction ↔ world', () => {
8
+ it('scales a fraction onto the content box', () => {
9
+ expect(fracToWorld({ x: 0.25, y: 0.5, w: 0.5, h: 0.25 }, CONTENT)).toEqual({
10
+ x: 64,
11
+ y: 85,
12
+ width: 128,
13
+ height: 42.5,
14
+ });
15
+ });
16
+
17
+ it('round-trips', () => {
18
+ const f: FracRect = { x: 0.3322, y: 0.3581, w: 0.02, h: 0.0861 };
19
+ expect(roundFrac(worldToFrac(fracToWorld(f, CONTENT), CONTENT))).toEqual(f);
20
+ });
21
+
22
+ it('gives zeros, not NaN, for a content box with a zero side', () => {
23
+ // A pane measured before layout is 0×0. NaN here would propagate silently
24
+ // through every later comparison — a stale mark would neither hit-test nor
25
+ // report itself as broken.
26
+ const got = worldToFrac({ x: 10, y: 10, width: 5, height: 5 }, { w: 0, h: 0 });
27
+ expect(got).toEqual({ x: 0, y: 0, w: 0, h: 0 });
28
+ for (const v of Object.values(got)) expect(Number.isFinite(v)).toBe(true);
29
+ });
30
+
31
+ it('rounds to 4dp, so a stored diff shows meaning and not float noise', () => {
32
+ expect(roundFrac({ x: 0.333333333, y: 0.1, w: 0.66666666, h: 0.2 })).toEqual({
33
+ x: 0.3333,
34
+ y: 0.1,
35
+ w: 0.6667,
36
+ h: 0.2,
37
+ });
38
+ });
39
+ });
40
+
41
+ describe('fraction hit geometry', () => {
42
+ const box: FracRect = { x: 0.2, y: 0.2, w: 0.4, h: 0.4 };
43
+
44
+ it('contains a point inside and rejects one outside', () => {
45
+ expect(fracContains(box, { x: 0.3, y: 0.3 })).toBe(true);
46
+ expect(fracContains(box, { x: 0.9, y: 0.3 })).toBe(false);
47
+ });
48
+
49
+ it('widens the hit by the tolerance', () => {
50
+ expect(fracContains(box, { x: 0.18, y: 0.3 })).toBe(false);
51
+ expect(fracContains(box, { x: 0.18, y: 0.3 }, 0.05)).toBe(true);
52
+ });
53
+
54
+ it('intersects only a box that wholly contains it', () => {
55
+ expect(fracIntersects({ x: 0, y: 0, w: 1, h: 1 }, box)).toBe(true);
56
+ // Overlapping is not containing: a marquee takes what it encloses.
57
+ expect(fracIntersects({ x: 0.4, y: 0.4, w: 0.4, h: 0.4 }, box)).toBe(false);
58
+ });
59
+ });
@@ -0,0 +1,66 @@
1
+ import type { FracPoint, FracRect } from './types';
2
+
3
+ /** A mark's box in its target's world: CSS pixels at zoom 1. Matches weasel's
4
+ * default `RectPose`, which is what the scene stores. */
5
+ export interface WorldRect {
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+
12
+ /** Where a fraction lands on a content box of this size. */
13
+ export function fracToWorld(f: FracRect, content: { w: number; h: number }): WorldRect {
14
+ return {
15
+ x: f.x * content.w,
16
+ y: f.y * content.h,
17
+ width: f.w * content.w,
18
+ height: f.h * content.h,
19
+ };
20
+ }
21
+
22
+ /** What fraction of the content box a world rect covers.
23
+ *
24
+ * A pane measured before layout is 0×0, and dividing by that would seed every
25
+ * stored position with `NaN` — which compares false against everything and so
26
+ * fails silently rather than loudly. Zero sides give zeros. */
27
+ export function worldToFrac(r: WorldRect, content: { w: number; h: number }): FracRect {
28
+ const sx = content.w === 0 ? 0 : 1 / content.w;
29
+ const sy = content.h === 0 ? 0 : 1 / content.h;
30
+ return { x: r.x * sx, y: r.y * sy, w: r.width * sx, h: r.height * sy };
31
+ }
32
+
33
+ const DP = 10_000;
34
+
35
+ /** Snap to 4dp. Positions are diffed by humans in stored documents, so the
36
+ * last digits of a float are noise that hides the change that matters. */
37
+ export function roundFrac(f: FracRect): FracRect {
38
+ return {
39
+ x: Math.round(f.x * DP) / DP,
40
+ y: Math.round(f.y * DP) / DP,
41
+ w: Math.round(f.w * DP) / DP,
42
+ h: Math.round(f.h * DP) / DP,
43
+ };
44
+ }
45
+
46
+ /** Whether `pt` falls in `box`, widened by `tol` on every side so a hairline
47
+ * mark stays reachable. */
48
+ export function fracContains(box: FracRect, pt: FracPoint, tol = 0): boolean {
49
+ return (
50
+ pt.x >= box.x - tol &&
51
+ pt.x <= box.x + box.w + tol &&
52
+ pt.y >= box.y - tol &&
53
+ pt.y <= box.y + box.h + tol
54
+ );
55
+ }
56
+
57
+ /** Whether `outer` wholly encloses `inner`. A marquee takes what it encloses,
58
+ * not what it grazes — brushing selection is a different gesture. */
59
+ export function fracIntersects(outer: FracRect, inner: FracRect): boolean {
60
+ return (
61
+ inner.x >= outer.x &&
62
+ inner.y >= outer.y &&
63
+ inner.x + inner.w <= outer.x + outer.w &&
64
+ inner.y + inner.h <= outer.y + outer.h
65
+ );
66
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Undo across targets. Each target's marks live in their own scene, and each
3
+ * scene owns its own undo stack — so "take back the last thing I did" is an
4
+ * ordering question labkit has to answer, not something a single scene knows.
5
+ */
6
+ import { describe, expect, it } from 'vitest';
7
+ import { createAnnotationStore } from './store';
8
+ import type { AnnotationInit, AnnotationTargetInfo } from './types';
9
+
10
+ const TARGETS: AnnotationTargetInfo[] = [
11
+ { id: 'left', content: { w: 200, h: 100 } },
12
+ { id: 'right', content: { w: 200, h: 100 } },
13
+ ];
14
+
15
+ const MARK: Omit<AnnotationInit, 'target'> = {
16
+ kind: 'rect',
17
+ frac: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 },
18
+ };
19
+
20
+ const store = () => createAnnotationStore({ targets: () => TARGETS });
21
+
22
+ describe('undo across targets', () => {
23
+ it('takes back the last change wherever it was made', () => {
24
+ const s = store();
25
+ s.add({ ...MARK, target: 'left' });
26
+ s.add({ ...MARK, target: 'right' });
27
+ const third = s.add({ ...MARK, target: 'left' });
28
+
29
+ expect(s.undo()).toBe(true);
30
+ expect(s.get(third)).toBeUndefined();
31
+ expect(s.query({ target: 'right' })).toHaveLength(1);
32
+
33
+ // Now the most recent surviving change is on the *other* target.
34
+ expect(s.undo()).toBe(true);
35
+ expect(s.query({ target: 'right' })).toHaveLength(0);
36
+ expect(s.query({ target: 'left' })).toHaveLength(1);
37
+ });
38
+
39
+ it('redoes in the reverse order, and bottoms out', () => {
40
+ const s = store();
41
+ s.add({ ...MARK, target: 'left' });
42
+ s.add({ ...MARK, target: 'right' });
43
+
44
+ expect(s.canUndo()).toBe(true);
45
+ expect(s.canRedo()).toBe(false);
46
+ s.undo();
47
+ s.undo();
48
+ expect(s.canUndo()).toBe(false);
49
+ expect(s.undo()).toBe(false);
50
+ expect(s.query()).toHaveLength(0);
51
+
52
+ expect(s.redo()).toBe(true);
53
+ expect(s.query({ target: 'left' })).toHaveLength(1);
54
+ expect(s.redo()).toBe(true);
55
+ expect(s.query({ target: 'right' })).toHaveLength(1);
56
+ expect(s.canRedo()).toBe(false);
57
+ });
58
+
59
+ it('drops the redo stack when a new mark lands after an undo', () => {
60
+ const s = store();
61
+ s.add({ ...MARK, target: 'left' });
62
+ s.undo();
63
+ expect(s.canRedo()).toBe(true);
64
+ s.add({ ...MARK, target: 'right' });
65
+ expect(s.canRedo()).toBe(false);
66
+ });
67
+
68
+ it('follows an undo driven from inside a pane, not just its own', () => {
69
+ // A pane's own Cmd+Z calls scene.undo() directly. Tracking the ordering
70
+ // off the subscribe callback alone would read that as one more change and
71
+ // append to the undo stack, leaving redo dead.
72
+ const s = store();
73
+ s.add({ ...MARK, target: 'left' });
74
+ s.sceneFor('left').undo();
75
+
76
+ expect(s.query()).toHaveLength(0);
77
+ expect(s.canRedo()).toBe(true);
78
+ expect(s.canUndo()).toBe(false);
79
+ expect(s.redo()).toBe(true);
80
+ expect(s.query({ target: 'left' })).toHaveLength(1);
81
+ });
82
+ });
@@ -0,0 +1,118 @@
1
+ import type { Scene } from '@weasel-js/core';
2
+
3
+ /** What the ordering needs of a scene: its history depth, and the two moves. */
4
+ export interface HistoryScene {
5
+ historyIndex(): number;
6
+ undo(): boolean;
7
+ redo(): boolean;
8
+ canUndo(): boolean;
9
+ canRedo(): boolean;
10
+ }
11
+
12
+ /**
13
+ * One undo ordering over several scenes.
14
+ *
15
+ * Each target's marks live in their own scene and each scene owns its own undo
16
+ * stack, so "take back the last thing I did" is a question no single scene can
17
+ * answer. This keeps the order changes arrived in.
18
+ *
19
+ * The order is maintained from each scene's `historyIndex()`, not from its
20
+ * subscribe callback: a scene notifies on ephemeral changes that are not
21
+ * history at all, and an undo driven from inside a pane's own keymap has to
22
+ * *move* this ordering rather than append to it.
23
+ */
24
+ export class MarkHistory {
25
+ private readonly depth = new Map<string, number>();
26
+ private undoable: string[] = [];
27
+ private redoable: string[] = [];
28
+ /** Set while this class is driving, so a redo is not read as a new change. */
29
+ private replaying: 'undo' | 'redo' | null = null;
30
+
31
+ /** Start following `scene` under `target`. Idempotent per target. */
32
+ track(target: string, scene: HistoryScene): void {
33
+ if (this.depth.has(target)) return;
34
+ this.depth.set(target, scene.historyIndex());
35
+ }
36
+
37
+ /** Call on every notification from `target`'s scene. */
38
+ observe(target: string, scene: HistoryScene): void {
39
+ const now = scene.historyIndex();
40
+ const before = this.depth.get(target) ?? 0;
41
+ this.depth.set(target, now);
42
+ if (now > before) {
43
+ if (this.replaying === 'redo') {
44
+ drop(this.redoable, target);
45
+ } else {
46
+ // A change dropped whatever was redoable — weasel's own scene did the
47
+ // same to its stack.
48
+ this.redoable = [];
49
+ }
50
+ this.undoable.push(target);
51
+ } else if (now < before) {
52
+ drop(this.undoable, target);
53
+ this.redoable.push(target);
54
+ }
55
+ }
56
+
57
+ /** The target `undo` would act on, skipping any whose scene has nothing
58
+ * left — a scene can drop entries this ordering still lists. */
59
+ private next(
60
+ stack: string[],
61
+ can: (s: HistoryScene) => boolean,
62
+ at: (t: string) => HistoryScene | undefined,
63
+ ): string | null {
64
+ for (let i = stack.length - 1; i >= 0; i--) {
65
+ const target = stack[i];
66
+ if (target === undefined) continue;
67
+ const scene = at(target);
68
+ if (scene && can(scene)) return target;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ canUndo(at: (t: string) => HistoryScene | undefined): boolean {
74
+ return this.next(this.undoable, (s) => s.canUndo(), at) !== null;
75
+ }
76
+
77
+ canRedo(at: (t: string) => HistoryScene | undefined): boolean {
78
+ return this.next(this.redoable, (s) => s.canRedo(), at) !== null;
79
+ }
80
+
81
+ undo(at: (t: string) => HistoryScene | undefined): boolean {
82
+ const target = this.next(this.undoable, (s) => s.canUndo(), at);
83
+ const scene = target === null ? undefined : at(target);
84
+ if (!scene) return false;
85
+ this.replaying = 'undo';
86
+ try {
87
+ return scene.undo();
88
+ } finally {
89
+ this.replaying = null;
90
+ }
91
+ }
92
+
93
+ redo(at: (t: string) => HistoryScene | undefined): boolean {
94
+ const target = this.next(this.redoable, (s) => s.canRedo(), at);
95
+ const scene = target === null ? undefined : at(target);
96
+ if (!scene) return false;
97
+ this.replaying = 'redo';
98
+ try {
99
+ return scene.redo();
100
+ } finally {
101
+ this.replaying = null;
102
+ }
103
+ }
104
+ }
105
+
106
+ /** Remove the last occurrence of `value`, in place. */
107
+ function drop(stack: string[], value: string): void {
108
+ for (let i = stack.length - 1; i >= 0; i--) {
109
+ if (stack[i] === value) {
110
+ stack.splice(i, 1);
111
+ return;
112
+ }
113
+ }
114
+ }
115
+
116
+ /** A `Scene` satisfies `HistoryScene`; this names the narrowing once. */
117
+ export const asHistoryScene = (scene: Scene<never, never, never> | HistoryScene): HistoryScene =>
118
+ scene as HistoryScene;