@weasel-js/labkit 1.3.0 → 1.4.0-pre.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 (211) hide show
  1. package/README.md +226 -55
  2. package/dist/_dts/CanvasStackContext-LnCfqNBA.d.ts +42 -0
  3. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  4. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  5. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  6. package/dist/_dts/frac-C-2c72Ij.d.ts +735 -0
  7. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  8. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  9. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  10. package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
  11. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  12. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  13. package/dist/canvas/index.d.ts +110 -26
  14. package/dist/canvas/index.js +5 -4
  15. package/dist/chrome/index.d.ts +9 -6
  16. package/dist/chrome/index.js +6 -6
  17. package/dist/chunk-2AYGEN57.js +32 -0
  18. package/dist/chunk-2AYGEN57.js.map +1 -0
  19. package/dist/chunk-6OEYTYML.js +669 -0
  20. package/dist/chunk-6OEYTYML.js.map +1 -0
  21. package/dist/{chunk-BMW4TDP5.js → chunk-AE5CNVRU.js} +3 -3
  22. package/dist/{chunk-BMW4TDP5.js.map → chunk-AE5CNVRU.js.map} +1 -1
  23. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  24. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  25. package/dist/chunk-CTRKTLYZ.js +33 -0
  26. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  27. package/dist/chunk-E2UQVZ44.js +458 -0
  28. package/dist/chunk-E2UQVZ44.js.map +1 -0
  29. package/dist/{chunk-LGKRIUSW.js → chunk-ESIQNQ6K.js} +32 -7
  30. package/dist/chunk-ESIQNQ6K.js.map +1 -0
  31. package/dist/{chunk-JDROYQM3.js → chunk-HHGVISVZ.js} +130 -131
  32. package/dist/chunk-HHGVISVZ.js.map +1 -0
  33. package/dist/{chunk-BDJXIBRZ.js → chunk-HMFODCOX.js} +126 -29
  34. package/dist/chunk-HMFODCOX.js.map +1 -0
  35. package/dist/{chunk-QCICIHEO.js → chunk-L5QJOOLV.js} +839 -320
  36. package/dist/chunk-L5QJOOLV.js.map +1 -0
  37. package/dist/{chunk-3XXPU73K.js → chunk-M75ZU6ZZ.js} +9 -7
  38. package/dist/chunk-M75ZU6ZZ.js.map +1 -0
  39. package/dist/{chunk-QRYPXSGP.js → chunk-QB5SM4BM.js} +16 -8
  40. package/dist/chunk-QB5SM4BM.js.map +1 -0
  41. package/dist/chunk-RBGKL7NF.js +7128 -0
  42. package/dist/chunk-RBGKL7NF.js.map +1 -0
  43. package/dist/{chunk-WTI26YTM.js → chunk-TO2FUOKF.js} +40 -13
  44. package/dist/chunk-TO2FUOKF.js.map +1 -0
  45. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  46. package/dist/chunk-XJ6N32QP.js.map +1 -0
  47. package/dist/controls/index.d.ts +4 -28
  48. package/dist/controls/index.js +3 -3
  49. package/dist/dragdrop/index.d.ts +9 -5
  50. package/dist/dragdrop/index.js +3 -3
  51. package/dist/index.d.ts +517 -234
  52. package/dist/index.js +1769 -143
  53. package/dist/index.js.map +1 -1
  54. package/dist/layers/index.d.ts +6 -5
  55. package/dist/layers/index.js +3 -3
  56. package/dist/loupe/index.d.ts +187 -0
  57. package/dist/loupe/index.js +7 -0
  58. package/dist/loupe/index.js.map +1 -0
  59. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  60. package/dist/passthrough/weasel-canvas.js +1 -1
  61. package/dist/passthrough/weasel-ui.d.ts +138 -91
  62. package/dist/passthrough/weasel-ui.js +2 -2
  63. package/dist/primitives/index.d.ts +3 -1
  64. package/dist/primitives/index.js +5 -5
  65. package/dist/state/index.d.ts +3 -3
  66. package/dist/state/index.js +2 -2
  67. package/dist/styles.css +229 -1
  68. package/dist/surface/index.d.ts +18 -2
  69. package/dist/surface/index.js +2 -2
  70. package/dist/ui/layers/index.js +2 -2
  71. package/dist/undo/index.d.ts +5 -4
  72. package/package.json +14 -7
  73. package/src/annotations/AnnotationOverlay.tsx +205 -0
  74. package/src/annotations/AnnotationTargets.tsx +37 -0
  75. package/src/annotations/Annotations.less +129 -0
  76. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  77. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  78. package/src/annotations/AnnotationsContext.ts +49 -0
  79. package/src/annotations/ExportMenu.test.tsx +86 -0
  80. package/src/annotations/ExportMenu.tsx +182 -0
  81. package/src/annotations/MarkList.tsx +75 -0
  82. package/src/annotations/capture.test.ts +140 -0
  83. package/src/annotations/capture.ts +237 -0
  84. package/src/annotations/drawOne.test.ts +56 -0
  85. package/src/annotations/drawOne.ts +47 -0
  86. package/src/annotations/frac.test.ts +59 -0
  87. package/src/annotations/frac.ts +66 -0
  88. package/src/annotations/history.test.ts +82 -0
  89. package/src/annotations/history.ts +118 -0
  90. package/src/annotations/index.ts +37 -0
  91. package/src/annotations/paint.test.ts +125 -0
  92. package/src/annotations/paint.ts +130 -0
  93. package/src/annotations/staleness.test.ts +56 -0
  94. package/src/annotations/staleness.ts +41 -0
  95. package/src/annotations/store.test.ts +247 -0
  96. package/src/annotations/store.ts +347 -0
  97. package/src/annotations/svgNodes.test.ts +77 -0
  98. package/src/annotations/svgNodes.ts +74 -0
  99. package/src/annotations/toolMap.test.ts +36 -0
  100. package/src/annotations/toolMap.ts +54 -0
  101. package/src/annotations/types.ts +229 -0
  102. package/src/annotations/view.test.ts +37 -0
  103. package/src/annotations/view.ts +40 -0
  104. package/src/canvas/AGENTS.md +55 -5
  105. package/src/canvas/CanvasStack.test.tsx +27 -1
  106. package/src/canvas/CanvasStack.tsx +29 -4
  107. package/src/canvas/CanvasStackContext.ts +23 -4
  108. package/src/canvas/camera.test.ts +78 -0
  109. package/src/canvas/camera.ts +62 -0
  110. package/src/canvas/canvasCoords.test.ts +26 -0
  111. package/src/canvas/canvasCoords.ts +20 -7
  112. package/src/canvas/index.ts +16 -1
  113. package/src/canvas/useLayerScheduler.ts +7 -3
  114. package/src/canvas/usePanZoom.test.ts +36 -5
  115. package/src/canvas/usePanZoom.ts +20 -15
  116. package/src/canvas/worldSpec.test.ts +63 -0
  117. package/src/canvas/worldSpec.ts +48 -0
  118. package/src/chrome/builtins.test.ts +31 -1
  119. package/src/chrome/builtins.tsx +63 -19
  120. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  121. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  122. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  123. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  124. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  125. package/src/chrome/regions/regions.test.tsx +6 -1
  126. package/src/chrome/types.ts +20 -0
  127. package/src/controls/ControlPanel.test.tsx +38 -0
  128. package/src/controls/ControlPanel.tsx +50 -3
  129. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  130. package/src/dragdrop/dragDrop.test.tsx +29 -2
  131. package/src/index.test.ts +23 -0
  132. package/src/index.ts +93 -4
  133. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  134. package/src/instrument/types.ts +24 -1
  135. package/src/lab/Lab.surface.test.tsx +162 -0
  136. package/src/lab/Lab.tsx +102 -17
  137. package/src/lab/Lab.undock.test.tsx +85 -0
  138. package/src/lab/LabFullChrome.stories.tsx +20 -0
  139. package/src/lab/LabShell.less +11 -0
  140. package/src/lab/Workspace.less +73 -0
  141. package/src/lab/Workspace.surface.test.tsx +2 -0
  142. package/src/lab/Workspace.tsx +97 -8
  143. package/src/lab/index.ts +1 -1
  144. package/src/lab/panelHost.ts +37 -0
  145. package/src/loupe/AGENTS.md +49 -0
  146. package/src/loupe/CanvasLoupe.tsx +83 -0
  147. package/src/loupe/DomLoupe.tsx +62 -0
  148. package/src/loupe/Loupe.less +38 -0
  149. package/src/loupe/LoupeBubble.tsx +32 -0
  150. package/src/loupe/TrialLoupe.tsx +93 -0
  151. package/src/loupe/canvasLens.test.ts +205 -0
  152. package/src/loupe/canvasLens.ts +141 -0
  153. package/src/loupe/index.ts +19 -0
  154. package/src/loupe/types.test.ts +29 -0
  155. package/src/loupe/types.ts +90 -0
  156. package/src/loupe/useHostSize.ts +30 -0
  157. package/src/loupe/useLoupe.test.tsx +197 -0
  158. package/src/loupe/useLoupe.ts +189 -0
  159. package/src/passthrough/weasel-ui.ts +3 -0
  160. package/src/primitives/FloatingPanel.test.tsx +76 -1
  161. package/src/primitives/FloatingPanel.tsx +19 -3
  162. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  163. package/src/primitives/Sidebar.less +18 -0
  164. package/src/primitives/Toolbar.less +8 -0
  165. package/src/primitives/Toolbar.tsx +5 -0
  166. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  167. package/src/primitives/useRovingTabIndex.ts +26 -17
  168. package/src/state/document.test.ts +23 -0
  169. package/src/state/document.ts +14 -2
  170. package/src/state/index.ts +2 -0
  171. package/src/state/store.test.ts +24 -0
  172. package/src/state/store.ts +35 -1
  173. package/src/state/types.ts +11 -0
  174. package/src/state/undock.test.ts +40 -0
  175. package/src/state/undock.ts +39 -0
  176. package/src/styles.less +2 -0
  177. package/src/surface/SurfaceContext.ts +4 -0
  178. package/src/surface/index.ts +8 -3
  179. package/src/surface/useSurfaceTile.test.tsx +2 -0
  180. package/src/surface/useSurfaceTile.ts +7 -1
  181. package/src/surface/useTiledSurface.test.tsx +58 -0
  182. package/src/surface/useTiledSurface.ts +38 -3
  183. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  184. package/src/trial/Trial.annotations.test.tsx +121 -0
  185. package/src/trial/Trial.canvas.test.tsx +76 -1
  186. package/src/trial/Trial.less +19 -0
  187. package/src/trial/Trial.loupe.test.tsx +128 -0
  188. package/src/trial/Trial.stories.tsx +1 -0
  189. package/src/trial/Trial.test.tsx +12 -0
  190. package/src/trial/Trial.trialId.test.tsx +36 -0
  191. package/src/trial/Trial.tsx +215 -40
  192. package/src/trial/TrialChrome.tsx +35 -0
  193. package/src/trial/UndockedSections.tsx +65 -0
  194. package/src/trial/trialOps.test.ts +21 -0
  195. package/src/trial/trialOps.ts +5 -3
  196. package/dist/_dts/types-BttTIent.d.ts +0 -314
  197. package/dist/chunk-3XXPU73K.js.map +0 -1
  198. package/dist/chunk-4TMMVIDM.js.map +0 -1
  199. package/dist/chunk-6X5RBC6G.js +0 -366
  200. package/dist/chunk-6X5RBC6G.js.map +0 -1
  201. package/dist/chunk-BDJXIBRZ.js.map +0 -1
  202. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  203. package/dist/chunk-JDROYQM3.js.map +0 -1
  204. package/dist/chunk-LGKRIUSW.js.map +0 -1
  205. package/dist/chunk-QCICIHEO.js.map +0 -1
  206. package/dist/chunk-QRYPXSGP.js.map +0 -1
  207. package/dist/chunk-RGT4EJIB.js +0 -6961
  208. package/dist/chunk-RGT4EJIB.js.map +0 -1
  209. package/dist/chunk-T66HU3HX.js +0 -17
  210. package/dist/chunk-T66HU3HX.js.map +0 -1
  211. package/dist/chunk-WTI26YTM.js.map +0 -1
@@ -0,0 +1,142 @@
1
+ /**
2
+ * What jsdom can honestly see of the overlay: which tiles get registered,
3
+ * where the input boxes land, and that both come back out on unmount.
4
+ *
5
+ * **GL is not exercised here.** jsdom's canvas has no WebGL2, so `<Canvas>`'s
6
+ * paint bails silently and nothing reaches the shared buffer — an assertion
7
+ * about pixels in this file would be an assertion about the emulation. The
8
+ * marks themselves are proved by `paint.test.ts` (pure) and by a screenshot.
9
+ */
10
+ import { act, render } from '@testing-library/react';
11
+ import { StrictMode, useRef } from 'react';
12
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
14
+ import { useTiledSurface } from '../surface/useTiledSurface';
15
+ import { AnnotationTargets } from './AnnotationTargets';
16
+ import { createAnnotationStore } from './store';
17
+ import type { AnnotationsCapability, AnnotationTarget } from './types';
18
+
19
+ function stubBox(el: HTMLElement, left: number, top: number, width: number, height: number): void {
20
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
21
+ left,
22
+ top,
23
+ width,
24
+ height,
25
+ right: left + width,
26
+ bottom: top + height,
27
+ x: left,
28
+ y: top,
29
+ toJSON: () => ({}),
30
+ } as DOMRect);
31
+ }
32
+
33
+ beforeAll(() => {
34
+ HTMLCanvasElement.prototype.getContext = vi.fn(
35
+ () => null,
36
+ ) as unknown as HTMLCanvasElement['getContext'];
37
+ });
38
+
39
+ function Harness({ toolId = 'rect' }: { toolId?: string }) {
40
+ const a = useRef<HTMLDivElement | null>(null);
41
+ const b = useRef<HTMLDivElement | null>(null);
42
+ const surface = useTiledSurface({ onFrame: () => {} });
43
+ const annotations = useRef(createAnnotationStore({ targets: () => [] })).current;
44
+
45
+ const capability: AnnotationsCapability = {
46
+ targets: (): AnnotationTarget[] => [
47
+ { id: 'pane:a', ref: a, content: { w: 200, h: 100 } },
48
+ { id: 'pane:b', ref: b, content: { w: 200, h: 100 } },
49
+ ],
50
+ };
51
+
52
+ return (
53
+ <SurfaceContext.Provider value={surface}>
54
+ <SurfaceCanvasContext.Provider value={null}>
55
+ <div
56
+ data-testid="stage"
57
+ ref={(el) => {
58
+ if (!el) return;
59
+ // A non-zero container origin, so a rect composed against the
60
+ // wrong one is visible rather than accidentally right.
61
+ stubBox(el, 12, 7, 800, 600);
62
+ surface.containerRef(el);
63
+ }}
64
+ >
65
+ <div
66
+ data-testid="a"
67
+ ref={(el) => {
68
+ a.current = el;
69
+ if (el) stubBox(el, 40, 20, 200, 100);
70
+ }}
71
+ />
72
+ <div
73
+ data-testid="b"
74
+ ref={(el) => {
75
+ b.current = el;
76
+ if (el) stubBox(el, 400, 300, 200, 100);
77
+ }}
78
+ />
79
+ <AnnotationTargets
80
+ capability={capability}
81
+ state={{}}
82
+ config={{}}
83
+ annotations={annotations}
84
+ activeToolId={toolId}
85
+ />
86
+ </div>
87
+ </SurfaceCanvasContext.Provider>
88
+ </SurfaceContext.Provider>
89
+ );
90
+ }
91
+
92
+ describe('<AnnotationTargets>', () => {
93
+ beforeEach(() => {
94
+ vi.useFakeTimers();
95
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) =>
96
+ setTimeout(() => cb(0), 16),
97
+ );
98
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
99
+ });
100
+ afterEach(() => {
101
+ vi.useRealTimers();
102
+ vi.unstubAllGlobals();
103
+ vi.restoreAllMocks();
104
+ });
105
+
106
+ it('puts an input box over each target, at the rect the surface measured', () => {
107
+ const { container } = render(<Harness />, { wrapper: StrictMode });
108
+ act(() => {
109
+ vi.advanceTimersByTime(64);
110
+ });
111
+
112
+ const boxes = [...container.querySelectorAll<HTMLElement>('.lk-annotate__input')];
113
+ expect(boxes.map((el) => el.dataset.annotationTarget)).toEqual(['pane:a', 'pane:b']);
114
+ // The rect, not merely its existence: a box positioned against the wrong
115
+ // origin takes input for the wrong part of the picture.
116
+ expect(boxes[0]?.style.getPropertyValue('--lk-anno-x')).toBe('28px');
117
+ expect(boxes[0]?.style.getPropertyValue('--lk-anno-y')).toBe('13px');
118
+ expect(boxes[1]?.style.getPropertyValue('--lk-anno-x')).toBe('388px');
119
+ expect(boxes[1]?.style.getPropertyValue('--lk-anno-h')).toBe('100px');
120
+ });
121
+
122
+ it('lands the boxes in the surface container, not among the instrument DOM', () => {
123
+ const { container } = render(<Harness />, { wrapper: StrictMode });
124
+ act(() => {
125
+ vi.advanceTimersByTime(64);
126
+ });
127
+ const stage = container.querySelector('[data-testid="stage"]');
128
+ for (const el of container.querySelectorAll('.lk-annotate__input')) {
129
+ expect(el.parentElement).toBe(stage);
130
+ }
131
+ });
132
+
133
+ it('takes its tiles back out on unmount', () => {
134
+ const { container, unmount } = render(<Harness />, { wrapper: StrictMode });
135
+ act(() => {
136
+ vi.advanceTimersByTime(64);
137
+ });
138
+ expect(container.querySelectorAll('.lk-annotate__input')).toHaveLength(2);
139
+ unmount();
140
+ expect(document.querySelectorAll('.lk-annotate__input')).toHaveLength(0);
141
+ });
142
+ });
@@ -0,0 +1,49 @@
1
+ import { createContext, useCallback, useContext, useRef, useSyncExternalStore } from 'react';
2
+ import type { AnnotationsApi } from './types';
3
+
4
+ /** Null in a trial whose instrument declares no `annotations`. */
5
+ export const AnnotationsContext = createContext<AnnotationsApi | null>(null);
6
+
7
+ /** Re-render the caller whenever any of the store's scenes changes.
8
+ *
9
+ * The API is a stable facade over mutable scenes, so nothing about its
10
+ * identity says a mark was added — a component reading `query()` without this
11
+ * renders one answer and never revises it. */
12
+ function useMarkVersion(api: AnnotationsApi | null): void {
13
+ const version = useRef(0);
14
+ useSyncExternalStore(
15
+ useCallback(
16
+ (fn: () => void) =>
17
+ api?.subscribe(() => {
18
+ version.current += 1;
19
+ fn();
20
+ }) ?? (() => {}),
21
+ [api],
22
+ ),
23
+ useCallback(() => version.current, []),
24
+ () => 0,
25
+ );
26
+ }
27
+
28
+ /** The marks on this trial's targets. Reached through a hook rather than the
29
+ * chrome context, which carries no instrument state — a sidebar panel listing
30
+ * marks pulls it the way `useTrialState()` does. Throws outside a trial whose
31
+ * instrument declares the capability. */
32
+ export function useAnnotations(): AnnotationsApi {
33
+ const api = useContext(AnnotationsContext);
34
+ useMarkVersion(api);
35
+ if (!api) {
36
+ throw new Error(
37
+ '[labkit] useAnnotations requires an instrument that declares the `annotations` capability',
38
+ );
39
+ }
40
+ return api;
41
+ }
42
+
43
+ /** The marks on this trial's targets, or null. For chrome that renders in
44
+ * every trial and does something else where there are none. */
45
+ export function useAnnotationsOptional(): AnnotationsApi | null {
46
+ const api = useContext(AnnotationsContext);
47
+ useMarkVersion(api);
48
+ return api;
49
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The toolbar's export popover. Marks belong to a target, so any export has to
3
+ * name one — which is why this is a panel and not a bare button.
4
+ */
5
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
6
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
7
+ import { AnnotationsContext } from './AnnotationsContext';
8
+ import { ExportMenu } from './ExportMenu';
9
+ import { createAnnotationStore } from './store';
10
+ import type { AnnotationsApi, AnnotationTargetInfo, CaptureOptions } from './types';
11
+
12
+ const TARGETS: AnnotationTargetInfo[] = [
13
+ { id: 'flat', content: { w: 200, h: 100 } },
14
+ { id: 'shaded', content: { w: 200, h: 100 } },
15
+ ];
16
+
17
+ function mount(store: AnnotationsApi | null) {
18
+ return render(
19
+ <AnnotationsContext.Provider value={store}>
20
+ <ExportMenu />
21
+ </AnnotationsContext.Provider>,
22
+ );
23
+ }
24
+
25
+ /** A store whose capture is a spy — the real one needs a browser. */
26
+ function spied(): { store: AnnotationsApi; capture: ReturnType<typeof vi.fn> } {
27
+ const store = createAnnotationStore({ targets: () => TARGETS });
28
+ const capture = vi.fn(async (target: string, opts?: CaptureOptions) => ({
29
+ target,
30
+ blob: new Blob(['x'], { type: 'image/png' }),
31
+ format: opts?.format ?? ('png' as const),
32
+ width: 1,
33
+ height: 1,
34
+ }));
35
+ return { store: { ...store, capture } as AnnotationsApi, capture };
36
+ }
37
+
38
+ const open = () => fireEvent.click(screen.getByRole('button', { name: /export/i }));
39
+
40
+ // Downloading clicks an anchor at an object URL, which jsdom answers with
41
+ // "Not implemented: navigation". The real save is asserted in the browser
42
+ // spec; here the claim is only which capture the panel asked for.
43
+ beforeAll(() => {
44
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
45
+ });
46
+
47
+ describe('<ExportMenu>', () => {
48
+ it('renders nothing for a trial whose instrument declares no annotations', () => {
49
+ const { container } = mount(null);
50
+ expect(container).toBeEmptyDOMElement();
51
+ });
52
+
53
+ it('offers every target, both formats and the scales', () => {
54
+ mount(spied().store);
55
+ open();
56
+ const targets = screen.getByRole('combobox', { name: /target/i }) as HTMLSelectElement;
57
+ expect([...targets.options].map((o) => o.value)).toEqual(['flat', 'shaded']);
58
+ expect(screen.getByRole('radio', { name: 'PNG' })).toBeTruthy();
59
+ expect(screen.getByRole('radio', { name: 'SVG' })).toBeTruthy();
60
+ expect(screen.getByRole('radio', { name: '4×' })).toBeTruthy();
61
+ });
62
+
63
+ it('captures the target, format and scale the panel is holding', async () => {
64
+ const { store, capture } = spied();
65
+ mount(store);
66
+ open();
67
+ fireEvent.change(screen.getByRole('combobox', { name: /target/i }), {
68
+ target: { value: 'shaded' },
69
+ });
70
+ fireEvent.click(screen.getByRole('radio', { name: 'SVG' }));
71
+ fireEvent.click(screen.getByRole('radio', { name: '4×' }));
72
+ fireEvent.click(screen.getByRole('button', { name: /download/i }));
73
+ await waitFor(() =>
74
+ expect(capture).toHaveBeenCalledWith('shaded', { format: 'svg', scale: 4 }),
75
+ );
76
+ });
77
+
78
+ it('says so when a capture fails rather than doing nothing visible', async () => {
79
+ const { store, capture } = spied();
80
+ capture.mockRejectedValueOnce(new Error('the capture canvas is tainted'));
81
+ mount(store);
82
+ open();
83
+ fireEvent.click(screen.getByRole('button', { name: /download/i }));
84
+ expect(await screen.findByRole('alert')).toHaveTextContent(/tainted/);
85
+ });
86
+ });
@@ -0,0 +1,182 @@
1
+ import { ExportIcon } from '@weasel-js/ui';
2
+ import { useId, useState } from 'react';
3
+ import { Callout } from '../passthrough/weasel-ui';
4
+ import { Toolbar } from '../primitives/Toolbar';
5
+ import { useAnnotationsOptional } from './AnnotationsContext';
6
+ import type { CaptureResult } from './types';
7
+
8
+ const SCALES = [1, 2, 4] as const;
9
+ const FORMATS = [
10
+ { id: 'png', label: 'PNG' },
11
+ { id: 'svg', label: 'SVG' },
12
+ ] as const;
13
+
14
+ type Format = (typeof FORMATS)[number]['id'];
15
+
16
+ function fileName(result: CaptureResult): string {
17
+ return `${result.target.replace(/[^a-z0-9._-]+/gi, '-')}.${result.format}`;
18
+ }
19
+
20
+ /** A blob the person asked for, handed to the browser's own save flow. The
21
+ * object URL is revoked on the next turn — revoking it synchronously races
22
+ * the navigation the click starts. */
23
+ function download(result: CaptureResult): void {
24
+ const url = URL.createObjectURL(result.blob);
25
+ const a = document.createElement('a');
26
+ a.href = url;
27
+ a.download = fileName(result);
28
+ a.click();
29
+ setTimeout(() => URL.revokeObjectURL(url), 0);
30
+ }
31
+
32
+ /** PNG goes to the clipboard as an image. SVG goes as text: `image/svg+xml`
33
+ * needs Chromium's `web ` prefix and nothing else reads it, while the markup
34
+ * is what a person pasting a vector export actually wants. */
35
+ async function copy(result: CaptureResult): Promise<void> {
36
+ if (result.format === 'svg') {
37
+ await navigator.clipboard.writeText(await result.blob.text());
38
+ return;
39
+ }
40
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': result.blob })]);
41
+ }
42
+
43
+ /**
44
+ * Export a target's picture with its marks on it.
45
+ *
46
+ * A panel rather than a button because a mark belongs to a target: with more
47
+ * than one pane, "export" on its own does not say what.
48
+ *
49
+ * Reads the store through the hook the way `<MarkList>` does — the chrome
50
+ * context carries no instrument state and is not the route to it.
51
+ */
52
+ export function ExportMenu() {
53
+ const marks = useAnnotationsOptional();
54
+ // The anchor doubles as the portal target lookup: a React Aria popover
55
+ // portals to `document.body` by default, which is outside the element
56
+ // labkit paints its theme tokens onto — `--wzl-surface` does not exist
57
+ // there and the panel renders unthemed.
58
+ const [anchor, setAnchor] = useState<HTMLSpanElement | null>(null);
59
+ const [open, setOpen] = useState(false);
60
+ const [target, setTarget] = useState<string | null>(null);
61
+ const [format, setFormat] = useState<Format>('png');
62
+ const [scale, setScale] = useState<number>(2);
63
+ const [busy, setBusy] = useState(false);
64
+ const [error, setError] = useState<string | null>(null);
65
+ const titleId = useId();
66
+
67
+ if (!marks) return null;
68
+ const targets = marks.targets();
69
+ const chosen = target ?? targets[0]?.id ?? null;
70
+
71
+ const run = async (then: (r: CaptureResult) => void | Promise<void>): Promise<void> => {
72
+ if (!chosen) return;
73
+ setBusy(true);
74
+ setError(null);
75
+ try {
76
+ await then(await marks.capture(chosen, { format, scale }));
77
+ setOpen(false);
78
+ } catch (err) {
79
+ setError(err instanceof Error ? err.message : String(err));
80
+ } finally {
81
+ setBusy(false);
82
+ }
83
+ };
84
+
85
+ return (
86
+ <span className="lk-export" ref={setAnchor}>
87
+ <Toolbar.Button
88
+ iconOnly
89
+ pressed={open}
90
+ aria-label="Export"
91
+ title="Export"
92
+ onClick={() => setOpen((was) => !was)}
93
+ >
94
+ <ExportIcon size={16} />
95
+ </Toolbar.Button>
96
+ <Callout
97
+ triggerRef={{ current: anchor }}
98
+ isOpen={open}
99
+ onOpenChange={setOpen}
100
+ onDismiss={() => setOpen(false)}
101
+ placement="bottom end"
102
+ UNSTABLE_portalContainer={anchor?.closest('.lk-root') ?? undefined}
103
+ aria-labelledby={titleId}
104
+ >
105
+ <div className="lk-export__panel">
106
+ <span className="lk-export__title" id={titleId}>
107
+ Export
108
+ </span>
109
+
110
+ <label className="lk-export__row">
111
+ <span>Target</span>
112
+ {/* Native controls: role, name and keyboard operability for free,
113
+ and a five-control panel is not the place to rebuild them. */}
114
+ <select
115
+ aria-label="Target"
116
+ value={chosen ?? ''}
117
+ onChange={(e) => setTarget(e.target.value)}
118
+ >
119
+ {targets.map((t) => (
120
+ <option key={t.id} value={t.id}>
121
+ {t.id}
122
+ </option>
123
+ ))}
124
+ </select>
125
+ </label>
126
+
127
+ {/* A <fieldset>/<legend> cannot do this: a legend is outside its
128
+ fieldset's grid formatting context, so it will not take a grid
129
+ column and every choice row drops below its own label. */}
130
+ <div className="lk-export__row">
131
+ <span>Format</span>
132
+ <span className="lk-export__choices" role="radiogroup" aria-label="Format">
133
+ {FORMATS.map((f) => (
134
+ <label key={f.id}>
135
+ <input
136
+ type="radio"
137
+ name="lk-export-format"
138
+ checked={format === f.id}
139
+ onChange={() => setFormat(f.id)}
140
+ />
141
+ {f.label}
142
+ </label>
143
+ ))}
144
+ </span>
145
+ </div>
146
+
147
+ <div className="lk-export__row">
148
+ <span>Scale</span>
149
+ <span className="lk-export__choices" role="radiogroup" aria-label="Scale">
150
+ {SCALES.map((s) => (
151
+ <label key={s}>
152
+ <input
153
+ type="radio"
154
+ name="lk-export-scale"
155
+ checked={scale === s}
156
+ onChange={() => setScale(s)}
157
+ />
158
+ {s}×
159
+ </label>
160
+ ))}
161
+ </span>
162
+ </div>
163
+
164
+ <div className="lk-export__actions">
165
+ <button type="button" disabled={busy || !chosen} onClick={() => run(download)}>
166
+ Download
167
+ </button>
168
+ <button type="button" disabled={busy || !chosen} onClick={() => run(copy)}>
169
+ Copy
170
+ </button>
171
+ </div>
172
+
173
+ {error ? (
174
+ <p className="lk-export__error" role="alert">
175
+ {error}
176
+ </p>
177
+ ) : null}
178
+ </div>
179
+ </Callout>
180
+ </span>
181
+ );
182
+ }
@@ -0,0 +1,75 @@
1
+ import { useAnnotations } from './AnnotationsContext';
2
+ import type { AnnotationMeaning } from './types';
3
+
4
+ /** Props for `<MarkList>`. */
5
+ export interface MarkListProps {
6
+ /** The instrument's vocabulary. Omitted, rows carry no status control —
7
+ * a host that declared none owns what a mark means. */
8
+ meaning?: AnnotationMeaning;
9
+ /** The live config, for answering staleness. */
10
+ config: unknown;
11
+ }
12
+
13
+ /** Every mark on this trial's targets: what it says, and whether it still
14
+ * describes the picture under it.
15
+ *
16
+ * Reads the store through the hook, which re-renders on every mutation — so a
17
+ * mark drawn on a pane appears here without anything wiring the two. */
18
+ export function MarkList({ meaning, config }: MarkListProps) {
19
+ const marks = useAnnotations();
20
+ const all = marks.query();
21
+ const statuses = meaning?.statuses ?? [];
22
+
23
+ if (all.length === 0) {
24
+ return <div className="lk-mark-list__empty">No marks yet</div>;
25
+ }
26
+
27
+ return (
28
+ <ul className="lk-mark-list">
29
+ {all.map((a) => {
30
+ const stale = marks.isStale(a, config);
31
+ return (
32
+ <li className="lk-mark-list__row" key={a.id}>
33
+ <div className="lk-mark-list__head">
34
+ <span className="lk-mark-list__kind">{a.kind}</span>
35
+ <span className="lk-mark-list__target">{a.target}</span>
36
+ {stale ? <span className="lk-mark-list__stale">stale</span> : null}
37
+ <button
38
+ type="button"
39
+ className="lk-mark-list__drop"
40
+ aria-label={`Remove ${a.kind} on ${a.target}`}
41
+ onClick={() => marks.remove(a.id)}
42
+ >
43
+ ×
44
+ </button>
45
+ </div>
46
+ <input
47
+ className="lk-mark-list__title"
48
+ aria-label={`Title of ${a.kind} on ${a.target}`}
49
+ value={a.title ?? ''}
50
+ placeholder="Untitled"
51
+ onChange={(e) => marks.update(a.id, { title: e.target.value })}
52
+ />
53
+ {statuses.length > 0 ? (
54
+ // A native select: it carries role, name and keyboard operability
55
+ // for free, and a sidebar row is not the place to rebuild them.
56
+ <select
57
+ className="lk-mark-list__status"
58
+ aria-label={`Status of ${a.kind} on ${a.target}`}
59
+ value={a.status ?? ''}
60
+ onChange={(e) => marks.update(a.id, { status: e.target.value })}
61
+ >
62
+ <option value="">—</option>
63
+ {statuses.map((s) => (
64
+ <option key={s.id} value={s.id}>
65
+ {s.label}
66
+ </option>
67
+ ))}
68
+ </select>
69
+ ) : null}
70
+ </li>
71
+ );
72
+ })}
73
+ </ul>
74
+ );
75
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { capturePlan, composeCaptureSvg } from './capture';
3
+ import { createAnnotationStore } from './store';
4
+ import type { AnnotationTargetInfo, CaptureSource } from './types';
5
+
6
+ const SVG_BASE = '<svg viewBox="0 0 100 60"><rect width="100" height="60" fill="#f00"/></svg>';
7
+
8
+ function targets(): AnnotationTargetInfo[] {
9
+ return [
10
+ {
11
+ id: 'flat',
12
+ content: { w: 100, h: 60 },
13
+ base: (): CaptureSource => ({ kind: 'svg', markup: SVG_BASE }),
14
+ },
15
+ { id: 'bare', content: { w: 100, h: 60 } },
16
+ ];
17
+ }
18
+
19
+ describe('a target that can be captured', () => {
20
+ it('reports every target, in declaration order, base or no base', () => {
21
+ const store = createAnnotationStore({ targets });
22
+ expect(store.targets().map((t) => t.id)).toEqual(['flat', 'bare']);
23
+ expect(store.targets()[1]?.base).toBeUndefined();
24
+ });
25
+
26
+ it('does not call a target base until a capture asks for it', () => {
27
+ const base = vi.fn((): CaptureSource => ({ kind: 'svg', markup: SVG_BASE }));
28
+ const store = createAnnotationStore({
29
+ targets: () => [{ id: 'flat', content: { w: 100, h: 60 }, base }],
30
+ });
31
+ store.add({ target: 'flat', kind: 'rect', frac: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } });
32
+ store.query();
33
+ store.targets();
34
+ expect(base).not.toHaveBeenCalled();
35
+ });
36
+ });
37
+
38
+ describe('which route an export takes', () => {
39
+ const svg: CaptureSource = { kind: 'svg', markup: SVG_BASE };
40
+ const image: CaptureSource = { kind: 'image', src: 'data:image/png;base64,AA' };
41
+
42
+ // The two rasterizing halves are browser-only and are asserted in
43
+ // `tests/visual/annotations-capture.spec.ts`. jsdom's canvas has no 2D
44
+ // context, so a test of them here could not fail for the right reason.
45
+ it('stays vector for an SVG format, whatever the base is', () => {
46
+ expect(capturePlan(svg, 'svg')).toBe('svg-document');
47
+ expect(capturePlan(image, 'svg')).toBe('svg-document');
48
+ expect(capturePlan(undefined, 'svg')).toBe('svg-document');
49
+ });
50
+
51
+ it('nests an SVG base rather than rasterizing it twice', () => {
52
+ expect(capturePlan(svg, 'png')).toBe('svg-document');
53
+ });
54
+
55
+ it('stacks rasters for a raster base, or for no base at all', () => {
56
+ expect(capturePlan(image, 'png')).toBe('raster-stack');
57
+ expect(capturePlan({ kind: 'canvas', canvas: {} as HTMLCanvasElement }, 'png')).toBe(
58
+ 'raster-stack',
59
+ );
60
+ expect(capturePlan(undefined, 'png')).toBe('raster-stack');
61
+ });
62
+ });
63
+
64
+ describe('the composed document', () => {
65
+ const draw = { content: { w: 100, h: 60 }, config: {} };
66
+
67
+ function sceneWithMark() {
68
+ const store = createAnnotationStore({ targets });
69
+ store.add({ target: 'flat', kind: 'rect', frac: { x: 0.1, y: 0.2, w: 0.3, h: 0.4 } });
70
+ return store.sceneFor('flat');
71
+ }
72
+
73
+ it('frames the content box, and carries the scale as width and height', () => {
74
+ const out = composeCaptureSvg({ scene: sceneWithMark(), draw, scale: 4 });
75
+ expect(out).toContain('viewBox="0 0 100 60"');
76
+ expect(out).toContain('width="400"');
77
+ expect(out).toContain('height="240"');
78
+ });
79
+
80
+ it('nests the base and re-frames it onto the content box', () => {
81
+ const out = composeCaptureSvg({
82
+ base: { kind: 'svg', markup: SVG_BASE },
83
+ scene: sceneWithMark(),
84
+ draw,
85
+ scale: 2,
86
+ });
87
+ // Scale 2, so the outer viewport is 200x120 and the base's own 100x60 is
88
+ // unambiguously the re-framing rather than a coincidence.
89
+ expect(out).toContain('fill="#f00"');
90
+ expect(out).toMatch(/<svg[^>]*x="0"[^>]*y="0"[^>]*width="100"[^>]*height="60"/);
91
+ expect(out).toContain('#e5484d');
92
+ });
93
+
94
+ it('re-frames a base whose declared size disagrees with its viewBox', () => {
95
+ const out = composeCaptureSvg({
96
+ base: { kind: 'svg', markup: '<svg viewBox="0 0 100 60" width="520"><rect/></svg>' },
97
+ scene: sceneWithMark(),
98
+ draw,
99
+ scale: 1,
100
+ });
101
+ expect(out).not.toContain('width="520"');
102
+ });
103
+
104
+ it('exports marks on transparency when a target hands over no base', () => {
105
+ const out = composeCaptureSvg({ scene: sceneWithMark(), draw, scale: 1 });
106
+ expect(out).toContain('#e5484d');
107
+ expect(out.match(/<svg/g)).toHaveLength(2);
108
+ });
109
+
110
+ it('embeds a raster base as an image, warning when it is only a reference', () => {
111
+ const warn = vi.fn();
112
+ const out = composeCaptureSvg({
113
+ base: { kind: 'image', src: 'https://example.test/brick.png' },
114
+ scene: sceneWithMark(),
115
+ draw,
116
+ scale: 1,
117
+ onWarn: warn,
118
+ });
119
+ expect(out).toContain('https://example.test/brick.png');
120
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('not self-contained'));
121
+ });
122
+
123
+ it('refuses markup that is not an SVG document', () => {
124
+ expect(() =>
125
+ composeCaptureSvg({
126
+ base: { kind: 'svg', markup: '<div>not svg</div>' },
127
+ scene: sceneWithMark(),
128
+ draw,
129
+ scale: 1,
130
+ }),
131
+ ).toThrow(/not an <svg> document/);
132
+ });
133
+ });
134
+
135
+ describe('capturing through the store', () => {
136
+ it('rejects a target the instrument does not declare', async () => {
137
+ const store = createAnnotationStore({ targets });
138
+ await expect(store.capture('nope')).rejects.toThrow(/no annotation target/);
139
+ });
140
+ });