@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.
- package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
- package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
- package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
- package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
- package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
- package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
- package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
- package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
- package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
- package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
- package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
- package/dist/canvas/index.d.ts +43 -25
- package/dist/canvas/index.js +5 -4
- package/dist/chrome/index.d.ts +9 -6
- package/dist/chrome/index.js +6 -6
- package/dist/chunk-2AYGEN57.js +32 -0
- package/dist/chunk-2AYGEN57.js.map +1 -0
- package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
- package/dist/chunk-3P5TTTA7.js.map +1 -0
- package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
- package/dist/chunk-3ZELRRBV.js.map +1 -0
- package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
- package/dist/chunk-4EY67BKJ.js.map +1 -0
- package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
- package/dist/chunk-5TCEVMPI.js.map +1 -0
- package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
- package/dist/chunk-6JMU56CU.js.map +1 -0
- package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
- package/dist/chunk-CQLQPQ4P.js.map +1 -0
- package/dist/chunk-CTRKTLYZ.js +33 -0
- package/dist/chunk-CTRKTLYZ.js.map +1 -0
- package/dist/chunk-EQIB2PGC.js +458 -0
- package/dist/chunk-EQIB2PGC.js.map +1 -0
- package/dist/chunk-HEORBLVT.js +7128 -0
- package/dist/chunk-HEORBLVT.js.map +1 -0
- package/dist/{chunk-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
- package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
- package/dist/chunk-LITE5YFB.js +669 -0
- package/dist/chunk-LITE5YFB.js.map +1 -0
- package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
- package/dist/chunk-ROQDRXKW.js.map +1 -0
- package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
- package/dist/chunk-XJ6N32QP.js.map +1 -0
- package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
- package/dist/chunk-YQJNT4XQ.js.map +1 -0
- package/dist/controls/index.d.ts +4 -28
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +9 -5
- package/dist/dragdrop/index.js +3 -3
- package/dist/index.d.ts +518 -190
- package/dist/index.js +1753 -75
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +6 -5
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +187 -0
- package/dist/loupe/index.js +7 -0
- package/dist/loupe/index.js.map +1 -0
- package/dist/passthrough/weasel-canvas.d.ts +3 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +138 -91
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +3 -1
- package/dist/primitives/index.js +5 -5
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/styles.css +229 -1
- package/dist/surface/index.d.ts +18 -2
- package/dist/surface/index.js +2 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +5 -4
- package/package.json +14 -7
- package/src/annotations/AnnotationOverlay.tsx +205 -0
- package/src/annotations/AnnotationTargets.tsx +37 -0
- package/src/annotations/Annotations.less +129 -0
- package/src/annotations/Annotations.meaning.test.tsx +111 -0
- package/src/annotations/Annotations.overlay.test.tsx +142 -0
- package/src/annotations/AnnotationsContext.ts +49 -0
- package/src/annotations/ExportMenu.test.tsx +86 -0
- package/src/annotations/ExportMenu.tsx +182 -0
- package/src/annotations/MarkList.tsx +75 -0
- package/src/annotations/capture.test.ts +140 -0
- package/src/annotations/capture.ts +237 -0
- package/src/annotations/drawOne.test.ts +56 -0
- package/src/annotations/drawOne.ts +47 -0
- package/src/annotations/frac.test.ts +59 -0
- package/src/annotations/frac.ts +66 -0
- package/src/annotations/history.test.ts +82 -0
- package/src/annotations/history.ts +118 -0
- package/src/annotations/index.ts +37 -0
- package/src/annotations/paint.test.ts +125 -0
- package/src/annotations/paint.ts +130 -0
- package/src/annotations/staleness.test.ts +56 -0
- package/src/annotations/staleness.ts +41 -0
- package/src/annotations/store.test.ts +175 -0
- package/src/annotations/store.ts +317 -0
- package/src/annotations/svgNodes.test.ts +77 -0
- package/src/annotations/svgNodes.ts +74 -0
- package/src/annotations/toolMap.test.ts +36 -0
- package/src/annotations/toolMap.ts +54 -0
- package/src/annotations/types.ts +222 -0
- package/src/annotations/view.test.ts +37 -0
- package/src/annotations/view.ts +40 -0
- package/src/canvas/AGENTS.md +55 -5
- package/src/canvas/CanvasStack.test.tsx +27 -1
- package/src/canvas/CanvasStack.tsx +29 -4
- package/src/canvas/CanvasStackContext.ts +23 -4
- package/src/canvas/camera.test.ts +78 -0
- package/src/canvas/camera.ts +62 -0
- package/src/canvas/canvasCoords.test.ts +26 -0
- package/src/canvas/canvasCoords.ts +20 -7
- package/src/canvas/index.ts +5 -1
- package/src/canvas/useLayerScheduler.ts +7 -3
- package/src/canvas/usePanZoom.test.ts +36 -5
- package/src/canvas/usePanZoom.ts +20 -15
- package/src/canvas/worldSpec.test.ts +63 -0
- package/src/canvas/worldSpec.ts +48 -0
- package/src/chrome/builtins.test.ts +31 -1
- package/src/chrome/builtins.tsx +63 -19
- package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
- package/src/chrome/regions/PaletteRegion.tsx +4 -0
- package/src/chrome/regions/SidebarRegion.tsx +37 -13
- package/src/chrome/regions/ToolbarRegion.tsx +2 -1
- package/src/chrome/regions/ViewportRegion.tsx +9 -1
- package/src/chrome/regions/regions.test.tsx +6 -1
- package/src/chrome/types.ts +20 -0
- package/src/controls/ControlPanel.test.tsx +38 -0
- package/src/controls/ControlPanel.tsx +50 -3
- package/src/dragdrop/DragDropRuntime.tsx +8 -2
- package/src/dragdrop/dragDrop.test.tsx +29 -2
- package/src/index.test.ts +22 -0
- package/src/index.ts +87 -2
- package/src/instrument/SineWave.smoke.test.tsx +1 -0
- package/src/instrument/types.ts +24 -1
- package/src/lab/Lab.surface.test.tsx +162 -0
- package/src/lab/Lab.tsx +102 -17
- package/src/lab/Lab.undock.test.tsx +85 -0
- package/src/lab/LabFullChrome.stories.tsx +20 -0
- package/src/lab/LabShell.less +11 -0
- package/src/lab/Workspace.less +73 -0
- package/src/lab/Workspace.surface.test.tsx +2 -0
- package/src/lab/Workspace.tsx +97 -8
- package/src/lab/index.ts +1 -1
- package/src/lab/panelHost.ts +37 -0
- package/src/loupe/AGENTS.md +49 -0
- package/src/loupe/CanvasLoupe.tsx +83 -0
- package/src/loupe/DomLoupe.tsx +62 -0
- package/src/loupe/Loupe.less +38 -0
- package/src/loupe/LoupeBubble.tsx +32 -0
- package/src/loupe/TrialLoupe.tsx +93 -0
- package/src/loupe/canvasLens.test.ts +205 -0
- package/src/loupe/canvasLens.ts +141 -0
- package/src/loupe/index.ts +19 -0
- package/src/loupe/types.test.ts +29 -0
- package/src/loupe/types.ts +90 -0
- package/src/loupe/useHostSize.ts +30 -0
- package/src/loupe/useLoupe.test.tsx +197 -0
- package/src/loupe/useLoupe.ts +189 -0
- package/src/passthrough/weasel-ui.ts +3 -0
- package/src/primitives/FloatingPanel.test.tsx +76 -1
- package/src/primitives/FloatingPanel.tsx +19 -3
- package/src/primitives/ScaleIndicator.test.tsx +7 -2
- package/src/primitives/Sidebar.less +18 -0
- package/src/primitives/Toolbar.less +8 -0
- package/src/primitives/Toolbar.tsx +5 -0
- package/src/primitives/useRovingTabIndex.test.ts +21 -0
- package/src/primitives/useRovingTabIndex.ts +26 -17
- package/src/state/document.test.ts +23 -0
- package/src/state/document.ts +14 -2
- package/src/state/index.ts +2 -0
- package/src/state/store.test.ts +24 -0
- package/src/state/store.ts +35 -1
- package/src/state/types.ts +11 -0
- package/src/state/undock.test.ts +40 -0
- package/src/state/undock.ts +39 -0
- package/src/styles.less +2 -0
- package/src/surface/SurfaceContext.ts +4 -0
- package/src/surface/index.ts +8 -3
- package/src/surface/useSurfaceTile.test.tsx +2 -0
- package/src/surface/useSurfaceTile.ts +7 -1
- package/src/surface/useTiledSurface.test.tsx +58 -0
- package/src/surface/useTiledSurface.ts +38 -3
- package/src/trial/Trial.annotations.persist.test.tsx +166 -0
- package/src/trial/Trial.annotations.test.tsx +121 -0
- package/src/trial/Trial.canvas.test.tsx +76 -1
- package/src/trial/Trial.less +19 -0
- package/src/trial/Trial.loupe.test.tsx +128 -0
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +12 -0
- package/src/trial/Trial.trialId.test.tsx +36 -0
- package/src/trial/Trial.tsx +215 -40
- package/src/trial/TrialChrome.tsx +35 -0
- package/src/trial/UndockedSections.tsx +65 -0
- package/src/trial/trialOps.test.ts +21 -0
- package/src/trial/trialOps.ts +5 -3
- package/dist/_dts/types-BttTIent.d.ts +0 -314
- package/dist/chunk-3XXPU73K.js.map +0 -1
- package/dist/chunk-4TMMVIDM.js.map +0 -1
- package/dist/chunk-6X5RBC6G.js +0 -366
- package/dist/chunk-6X5RBC6G.js.map +0 -1
- package/dist/chunk-BDJXIBRZ.js.map +0 -1
- package/dist/chunk-CPUJ3QXL.js.map +0 -1
- package/dist/chunk-JDROYQM3.js.map +0 -1
- package/dist/chunk-LGKRIUSW.js.map +0 -1
- package/dist/chunk-QCICIHEO.js.map +0 -1
- package/dist/chunk-QRYPXSGP.js.map +0 -1
- package/dist/chunk-RGT4EJIB.js +0 -6961
- package/dist/chunk-RGT4EJIB.js.map +0 -1
- package/dist/chunk-T66HU3HX.js +0 -17
- package/dist/chunk-T66HU3HX.js.map +0 -1
- 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
|
+
});
|