@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.
- package/README.md +226 -55
- package/dist/_dts/CanvasStackContext-LnCfqNBA.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-C-2c72Ij.d.ts +735 -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 +110 -26
- 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-6OEYTYML.js +669 -0
- package/dist/chunk-6OEYTYML.js.map +1 -0
- package/dist/{chunk-BMW4TDP5.js → chunk-AE5CNVRU.js} +3 -3
- package/dist/{chunk-BMW4TDP5.js.map → chunk-AE5CNVRU.js.map} +1 -1
- 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-E2UQVZ44.js +458 -0
- package/dist/chunk-E2UQVZ44.js.map +1 -0
- package/dist/{chunk-LGKRIUSW.js → chunk-ESIQNQ6K.js} +32 -7
- package/dist/chunk-ESIQNQ6K.js.map +1 -0
- package/dist/{chunk-JDROYQM3.js → chunk-HHGVISVZ.js} +130 -131
- package/dist/chunk-HHGVISVZ.js.map +1 -0
- package/dist/{chunk-BDJXIBRZ.js → chunk-HMFODCOX.js} +126 -29
- package/dist/chunk-HMFODCOX.js.map +1 -0
- package/dist/{chunk-QCICIHEO.js → chunk-L5QJOOLV.js} +839 -320
- package/dist/chunk-L5QJOOLV.js.map +1 -0
- package/dist/{chunk-3XXPU73K.js → chunk-M75ZU6ZZ.js} +9 -7
- package/dist/chunk-M75ZU6ZZ.js.map +1 -0
- package/dist/{chunk-QRYPXSGP.js → chunk-QB5SM4BM.js} +16 -8
- package/dist/chunk-QB5SM4BM.js.map +1 -0
- package/dist/chunk-RBGKL7NF.js +7128 -0
- package/dist/chunk-RBGKL7NF.js.map +1 -0
- package/dist/{chunk-WTI26YTM.js → chunk-TO2FUOKF.js} +40 -13
- package/dist/chunk-TO2FUOKF.js.map +1 -0
- package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
- package/dist/chunk-XJ6N32QP.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 +517 -234
- package/dist/index.js +1769 -143
- 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 +247 -0
- package/src/annotations/store.ts +347 -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 +229 -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 +16 -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 +23 -0
- package/src/index.ts +93 -4
- 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,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marks survive a reload. The payload goes in a trial slot of labkit's own —
|
|
3
|
+
* not in `record.state`, which is the instrument's and typed as such — and it
|
|
4
|
+
* is written on a trailing debounce: a write per scene notification re-renders
|
|
5
|
+
* every trial on every frame of a drag.
|
|
6
|
+
*/
|
|
7
|
+
import { act, render } from '@testing-library/react';
|
|
8
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { useAnnotations } from '../annotations/AnnotationsContext';
|
|
10
|
+
import type { AnnotationsApi, SerializedAnnotations } from '../annotations/types';
|
|
11
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
12
|
+
import type { InstrumentList } from '../instrument/types';
|
|
13
|
+
import { Lab } from '../lab/Lab';
|
|
14
|
+
import { createMemoryAdapter } from '../state/adapters';
|
|
15
|
+
import type { StorageAdapter } from '../state/types';
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
19
|
+
() => null,
|
|
20
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
let api: AnnotationsApi | null = null;
|
|
24
|
+
|
|
25
|
+
function Pane() {
|
|
26
|
+
api = useAnnotations();
|
|
27
|
+
return <div data-testid="pane" />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read through a call, so TypeScript does not narrow the module-level `api`
|
|
31
|
+
* to `null` across the remounts these tests are about. */
|
|
32
|
+
function marks(): AnnotationsApi {
|
|
33
|
+
if (!api) throw new Error('no annotations api — did the instrument render?');
|
|
34
|
+
return api;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TARGET = { id: 'pane', ref: { current: null }, content: { w: 200, h: 100 } };
|
|
38
|
+
|
|
39
|
+
const inspector = defineInstrument<Record<string, never>, { angle: number }>({
|
|
40
|
+
name: 'Inspector',
|
|
41
|
+
defaultConfig: () => ({ angle: 0 }),
|
|
42
|
+
initialState: () => ({}),
|
|
43
|
+
render: () => <Pane />,
|
|
44
|
+
annotations: { targets: () => [{ ...TARGET, positionDependsOn: ['angle'] }] },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** The instrument keeps its own marks, so labkit must not keep them too. */
|
|
48
|
+
let owned: SerializedAnnotations | null = null;
|
|
49
|
+
const selfStoring = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
50
|
+
name: 'SelfStoring',
|
|
51
|
+
defaultConfig: () => ({}),
|
|
52
|
+
initialState: () => ({}),
|
|
53
|
+
render: () => <Pane />,
|
|
54
|
+
annotations: {
|
|
55
|
+
targets: () => [TARGET],
|
|
56
|
+
storage: {
|
|
57
|
+
load: () => owned,
|
|
58
|
+
save: (doc) => {
|
|
59
|
+
owned = doc;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function mount(storage: StorageAdapter, which: InstrumentList[number] = inspector) {
|
|
66
|
+
return render(
|
|
67
|
+
<Lab
|
|
68
|
+
instruments={[which]}
|
|
69
|
+
defaultInstrument={which.name}
|
|
70
|
+
storage={storage}
|
|
71
|
+
storageKey="persist-test"
|
|
72
|
+
/>,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const MARK = { target: 'pane', kind: 'rect' as const, frac: { x: 0.2, y: 0.3, w: 0.1, h: 0.1 } };
|
|
77
|
+
|
|
78
|
+
describe('a trial that persists its marks', () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
api = null;
|
|
82
|
+
owned = null;
|
|
83
|
+
});
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
vi.useRealTimers();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('brings them back on a remount from the same storage', () => {
|
|
89
|
+
const storage = createMemoryAdapter();
|
|
90
|
+
const first = mount(storage);
|
|
91
|
+
const id = marks().add(MARK, { angle: 12 });
|
|
92
|
+
|
|
93
|
+
// Past both debounces: labkit's write-back, then the document flush.
|
|
94
|
+
act(() => {
|
|
95
|
+
vi.advanceTimersByTime(1000);
|
|
96
|
+
});
|
|
97
|
+
first.unmount();
|
|
98
|
+
|
|
99
|
+
api = null;
|
|
100
|
+
mount(storage);
|
|
101
|
+
const back = marks().get(id);
|
|
102
|
+
expect(back).toMatchObject({ id, target: 'pane', kind: 'rect', frac: MARK.frac });
|
|
103
|
+
// The staleness snapshot has to survive too, or every restored mark reads
|
|
104
|
+
// as fresh against whatever config it is opened under.
|
|
105
|
+
expect(back?.seen).toEqual({ angle: 12 });
|
|
106
|
+
if (!back) throw new Error('unreachable');
|
|
107
|
+
expect(marks().isStale(back, { angle: 45 })).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('writes nothing before the debounce elapses', () => {
|
|
111
|
+
const storage = createMemoryAdapter();
|
|
112
|
+
const first = mount(storage);
|
|
113
|
+
marks().add(MARK);
|
|
114
|
+
act(() => {
|
|
115
|
+
vi.advanceTimersByTime(20);
|
|
116
|
+
});
|
|
117
|
+
first.unmount();
|
|
118
|
+
|
|
119
|
+
api = null;
|
|
120
|
+
mount(storage);
|
|
121
|
+
// Not a claim about the debounce interval — a claim that one exists, and
|
|
122
|
+
// that a store write is not synchronous with a scene notification.
|
|
123
|
+
//
|
|
124
|
+
// It is also the no-migration claim: the document this remount reads was
|
|
125
|
+
// written with trials and no `annotations` field, exactly as every
|
|
126
|
+
// document from before this arc is, and the trial opens on an empty store
|
|
127
|
+
// rather than failing to load.
|
|
128
|
+
expect(marks().query()).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('flushes on unmount, so the last mark before a close is not lost', () => {
|
|
132
|
+
const storage = createMemoryAdapter();
|
|
133
|
+
const first = mount(storage);
|
|
134
|
+
const id = marks().add(MARK);
|
|
135
|
+
act(() => {
|
|
136
|
+
vi.advanceTimersByTime(500);
|
|
137
|
+
});
|
|
138
|
+
marks().add({ ...MARK, frac: { x: 0.6, y: 0.6, w: 0.1, h: 0.1 } });
|
|
139
|
+
first.unmount();
|
|
140
|
+
act(() => {
|
|
141
|
+
vi.advanceTimersByTime(1000);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
api = null;
|
|
145
|
+
mount(storage);
|
|
146
|
+
expect(marks().query()).toHaveLength(2);
|
|
147
|
+
expect(marks().get(id)).toBeDefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('leaves the slot alone when the instrument owns the storage', () => {
|
|
151
|
+
const storage = createMemoryAdapter();
|
|
152
|
+
const first = mount(storage, selfStoring);
|
|
153
|
+
marks().add(MARK);
|
|
154
|
+
act(() => {
|
|
155
|
+
vi.advanceTimersByTime(1000);
|
|
156
|
+
});
|
|
157
|
+
first.unmount();
|
|
158
|
+
|
|
159
|
+
expect(owned?.version).toBe(1);
|
|
160
|
+
expect(storage.read('lk:persist-test:doc')).not.toContain('"annotations"');
|
|
161
|
+
|
|
162
|
+
api = null;
|
|
163
|
+
mount(storage, selfStoring);
|
|
164
|
+
expect(marks().query()).toHaveLength(1);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declaring `annotations` is what provides the drawing palette and the hook —
|
|
3
|
+
* the same "a capability provides the chrome" rule the other capabilities
|
|
4
|
+
* follow. The overlay's own geometry is covered in
|
|
5
|
+
* `annotations/Annotations.overlay.test.tsx`.
|
|
6
|
+
*/
|
|
7
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
8
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { useAnnotations, useAnnotationsOptional } from '../annotations/AnnotationsContext';
|
|
10
|
+
import type { AnnotationsApi } from '../annotations/types';
|
|
11
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
12
|
+
import { Lab } from '../lab/Lab';
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
16
|
+
() => null,
|
|
17
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
let api: AnnotationsApi | null = null;
|
|
21
|
+
|
|
22
|
+
function Pane() {
|
|
23
|
+
api = useAnnotations();
|
|
24
|
+
return <div data-testid="pane" data-marks={api.query().length} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function marks(): AnnotationsApi {
|
|
28
|
+
if (!api) throw new Error('no annotations api');
|
|
29
|
+
return api;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const annotating = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
33
|
+
name: 'Annotating',
|
|
34
|
+
defaultConfig: () => ({}),
|
|
35
|
+
initialState: () => ({}),
|
|
36
|
+
render: () => <Pane />,
|
|
37
|
+
annotations: {
|
|
38
|
+
targets: () => [{ id: 'pane', ref: { current: null }, content: { w: 200, h: 100 } }],
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function PlainPane() {
|
|
43
|
+
const api = useAnnotationsOptional();
|
|
44
|
+
return <div data-testid="plain" data-has-api={String(api !== null)} />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const plain = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
48
|
+
name: 'Plain',
|
|
49
|
+
defaultConfig: () => ({}),
|
|
50
|
+
initialState: () => ({}),
|
|
51
|
+
render: () => <PlainPane />,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('an instrument that declares annotations', () => {
|
|
55
|
+
it('gets a drawing palette', () => {
|
|
56
|
+
render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
|
|
57
|
+
for (const label of ['Select', 'Freehand', 'Line', 'Arrow', 'Rectangle', 'Ellipse', 'Text']) {
|
|
58
|
+
expect(screen.getByRole('button', { name: label })).toBeTruthy();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('puts the store in reach of its own render', () => {
|
|
63
|
+
render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
|
|
64
|
+
expect(screen.getByTestId('pane').dataset.marks).toBe('0');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('starts in select, so a first click does not draw', () => {
|
|
68
|
+
render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
|
|
69
|
+
expect(screen.getByRole('button', { name: 'Select' }).getAttribute('aria-current')).toBe(
|
|
70
|
+
'true',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('the trial undo chrome over marks', () => {
|
|
76
|
+
it('gets undo and redo without the instrument declaring `undo`', () => {
|
|
77
|
+
// Weasel history is the authority for marks, so the capability that
|
|
78
|
+
// creates them is what earns the buttons.
|
|
79
|
+
render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
|
|
80
|
+
expect(screen.getByRole('button', { name: 'Undo' })).toBeTruthy();
|
|
81
|
+
expect(screen.getByRole('button', { name: 'Redo' })).toBeTruthy();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('takes back a mark, and puts it back', () => {
|
|
85
|
+
render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
|
|
86
|
+
const undo = screen.getByRole('button', { name: 'Undo' });
|
|
87
|
+
expect(undo.getAttribute('aria-disabled') ?? undo.getAttribute('disabled')).not.toBeNull();
|
|
88
|
+
|
|
89
|
+
act(() => {
|
|
90
|
+
marks().add({ target: 'pane', kind: 'rect', frac: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } });
|
|
91
|
+
});
|
|
92
|
+
expect(screen.getByTestId('pane').dataset.marks).toBe('1');
|
|
93
|
+
|
|
94
|
+
act(() => {
|
|
95
|
+
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
|
96
|
+
});
|
|
97
|
+
expect(screen.getByTestId('pane').dataset.marks).toBe('0');
|
|
98
|
+
|
|
99
|
+
act(() => {
|
|
100
|
+
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
|
|
101
|
+
});
|
|
102
|
+
expect(screen.getByTestId('pane').dataset.marks).toBe('1');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('lists the marks in a sidebar panel', () => {
|
|
106
|
+
render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
|
|
107
|
+
expect(screen.getByText('Marks')).toBeTruthy();
|
|
108
|
+
act(() => {
|
|
109
|
+
marks().add({ target: 'pane', kind: 'line', frac: { x: 0, y: 0, w: 0.5, h: 0 } });
|
|
110
|
+
});
|
|
111
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('an instrument that declares none', () => {
|
|
116
|
+
it('gets no store and no palette', () => {
|
|
117
|
+
render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
|
|
118
|
+
expect(screen.getByTestId('plain').dataset.hasApi).toBe('false');
|
|
119
|
+
expect(screen.queryByRole('button', { name: 'Rectangle' })).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
2
3
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
3
4
|
import { defineInstrument } from '../instrument/defineInstrument';
|
|
4
5
|
import { Lab } from '../lab/Lab';
|
|
@@ -22,7 +23,10 @@ beforeAll(() => {
|
|
|
22
23
|
) as unknown as HTMLCanvasElement['getContext'];
|
|
23
24
|
});
|
|
24
25
|
|
|
25
|
-
function makeInstrument(
|
|
26
|
+
function makeInstrument(
|
|
27
|
+
draw: (ctx: CanvasRenderingContext2D, args: unknown) => void,
|
|
28
|
+
canvasExtra: Record<string, unknown> = {},
|
|
29
|
+
) {
|
|
26
30
|
return defineInstrument<St, Record<string, never>>({
|
|
27
31
|
name: 'Probe',
|
|
28
32
|
defaultConfig: () => ({}),
|
|
@@ -31,6 +35,7 @@ function makeInstrument(draw: (ctx: CanvasRenderingContext2D, args: unknown) =>
|
|
|
31
35
|
canvas: {
|
|
32
36
|
initialView: { zoom: 2, pan: { x: 30, y: 40 } },
|
|
33
37
|
layers: [{ id: 'main', draw }],
|
|
38
|
+
...canvasExtra,
|
|
34
39
|
},
|
|
35
40
|
});
|
|
36
41
|
}
|
|
@@ -77,4 +82,74 @@ describe('LabProps.instruments', () => {
|
|
|
77
82
|
render(<Lab instruments={[typed]} defaultInstrument="Probe" />);
|
|
78
83
|
expect(screen.getByTestId('readout')).toBeInTheDocument();
|
|
79
84
|
});
|
|
85
|
+
|
|
86
|
+
it('draws through a declared world spec, not the top-left default', async () => {
|
|
87
|
+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
|
88
|
+
left: 0,
|
|
89
|
+
top: 0,
|
|
90
|
+
width: 800,
|
|
91
|
+
height: 600,
|
|
92
|
+
} as unknown as DOMRect);
|
|
93
|
+
ctxSpies.translate.mockClear();
|
|
94
|
+
ctxSpies.scale.mockClear();
|
|
95
|
+
|
|
96
|
+
const instrument = makeInstrument(vi.fn(), {
|
|
97
|
+
worldSpec: { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' },
|
|
98
|
+
});
|
|
99
|
+
render(<Lab instruments={[instrument]} defaultInstrument="Probe" />);
|
|
100
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
101
|
+
|
|
102
|
+
// Origin (400,300) plus the instrument's pan (30,40); y runs up.
|
|
103
|
+
expect(ctxSpies.translate).toHaveBeenCalledWith(430, 340);
|
|
104
|
+
expect(ctxSpies.scale).toHaveBeenCalledWith(2, -2);
|
|
105
|
+
vi.restoreAllMocks();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('places a size-aware initial view once the canvas has been measured', async () => {
|
|
109
|
+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
|
110
|
+
left: 0,
|
|
111
|
+
top: 0,
|
|
112
|
+
width: 800,
|
|
113
|
+
height: 600,
|
|
114
|
+
} as unknown as DOMRect);
|
|
115
|
+
ctxSpies.translate.mockClear();
|
|
116
|
+
|
|
117
|
+
const instrument = makeInstrument(vi.fn(), {
|
|
118
|
+
initialView: ({ width, height }: { width: number; height: number }) => ({
|
|
119
|
+
zoom: 1,
|
|
120
|
+
pan: { x: width / 4, y: height / 4 },
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
render(<Lab instruments={[instrument]} defaultInstrument="Probe" />);
|
|
124
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
125
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
126
|
+
|
|
127
|
+
expect(ctxSpies.translate).toHaveBeenCalledWith(200, 150);
|
|
128
|
+
vi.restoreAllMocks();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('tells the instrument which layers are currently visible', async () => {
|
|
132
|
+
let seen: readonly string[] = [];
|
|
133
|
+
const instrument = defineInstrument<St, Record<string, never>>({
|
|
134
|
+
name: 'Probe',
|
|
135
|
+
defaultConfig: () => ({}),
|
|
136
|
+
initialState: () => ({ n: 1 }),
|
|
137
|
+
render: ({ trial }) => {
|
|
138
|
+
seen = trial.visibleLayers;
|
|
139
|
+
return null;
|
|
140
|
+
},
|
|
141
|
+
canvas: {
|
|
142
|
+
layers: [
|
|
143
|
+
{ id: 'grid', draw: () => undefined },
|
|
144
|
+
{ id: 'glyph', draw: () => undefined },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
layers: { ids: ['grid', 'glyph'] },
|
|
148
|
+
});
|
|
149
|
+
render(<Lab instruments={[instrument]} defaultInstrument="Probe" />);
|
|
150
|
+
expect([...seen]).toEqual(['grid', 'glyph']);
|
|
151
|
+
|
|
152
|
+
await userEvent.click(screen.getByRole('checkbox', { name: 'Toggle grid' }));
|
|
153
|
+
expect([...seen]).toEqual(['glyph']);
|
|
154
|
+
});
|
|
80
155
|
});
|
package/src/trial/Trial.less
CHANGED
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
|
|
23
23
|
&__sidebar {
|
|
24
24
|
flex-shrink: 0;
|
|
25
|
+
// Stated, not content-derived: an auto-width sidebar is as wide as its
|
|
26
|
+
// widest label, so one verbose config key sets the width of the lab.
|
|
27
|
+
width: var(--lk-trial-sidebar-w, 20rem);
|
|
25
28
|
display: flex;
|
|
26
29
|
flex-direction: column;
|
|
27
30
|
min-height: 0;
|
|
@@ -62,6 +65,22 @@
|
|
|
62
65
|
height: 100%;
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
// A DOM instrument's own box, so the lens has something to be positioned
|
|
69
|
+
// against. Reproduces the content well's column so the instrument still
|
|
70
|
+
// stretches to it rather than collapsing to its intrinsic height.
|
|
71
|
+
&__loupe-host {
|
|
72
|
+
position: relative;
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
min-height: 0;
|
|
76
|
+
height: 100%;
|
|
77
|
+
|
|
78
|
+
> * {
|
|
79
|
+
flex: 1 1 auto;
|
|
80
|
+
min-height: 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
&__toolbar {
|
|
66
85
|
flex-shrink: 0;
|
|
67
86
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
5
|
+
import { Lab } from '../lab/Lab';
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
|
|
9
|
+
save: vi.fn(),
|
|
10
|
+
restore: vi.fn(),
|
|
11
|
+
setTransform: vi.fn(),
|
|
12
|
+
clearRect: vi.fn(),
|
|
13
|
+
translate: vi.fn(),
|
|
14
|
+
scale: vi.fn(),
|
|
15
|
+
drawImage: vi.fn(),
|
|
16
|
+
getImageData: () => ({ data: Uint8ClampedArray.from([0, 0, 0, 0]) }),
|
|
17
|
+
})) as unknown as HTMLCanvasElement['getContext'];
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const canvasInstrument = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
21
|
+
name: 'Drawn',
|
|
22
|
+
defaultConfig: () => ({}),
|
|
23
|
+
initialState: () => ({}),
|
|
24
|
+
render: () => null,
|
|
25
|
+
canvas: { layers: [{ id: 'main', draw: () => undefined }] },
|
|
26
|
+
loupe: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const domInstrument = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
30
|
+
name: 'Written',
|
|
31
|
+
defaultConfig: () => ({}),
|
|
32
|
+
initialState: () => ({}),
|
|
33
|
+
render: () => <p data-testid="body">the content</p>,
|
|
34
|
+
loupe: { render: ({ view }) => <p data-testid="lens-body">zoom {view.zoom}</p> },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const noLoupe = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
38
|
+
name: 'Plain',
|
|
39
|
+
defaultConfig: () => ({}),
|
|
40
|
+
initialState: () => ({}),
|
|
41
|
+
render: () => null,
|
|
42
|
+
canvas: { layers: [{ id: 'main', draw: () => undefined }] },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const lens = (c: HTMLElement): Element | null => c.querySelector('.lk-loupe');
|
|
46
|
+
|
|
47
|
+
/** jsdom lays nothing out, so a client coordinate is already host-relative. */
|
|
48
|
+
function pointAt(host: Element, x: number, y: number): void {
|
|
49
|
+
fireEvent.pointerMove(host, { clientX: x, clientY: y });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('the loupe capability', () => {
|
|
53
|
+
it('gives a trial a toolbar toggle, and no lens until it is on', () => {
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<Lab instruments={[canvasInstrument]} defaultInstrument="Drawn" />,
|
|
56
|
+
);
|
|
57
|
+
expect(screen.getByRole('button', { name: 'Loupe' })).toHaveAttribute('aria-pressed', 'false');
|
|
58
|
+
expect(lens(container)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('offers nothing to an instrument that declares none', () => {
|
|
62
|
+
render(<Lab instruments={[noLoupe]} defaultInstrument="Plain" />);
|
|
63
|
+
expect(screen.queryByRole('button', { name: 'Loupe' })).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('drops out of a trial that suppresses it', () => {
|
|
67
|
+
render(<Lab instruments={[canvasInstrument]} defaultInstrument="Drawn" suppress={['loupe']} />);
|
|
68
|
+
expect(screen.queryByRole('button', { name: 'Loupe' })).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('raises the lens over a canvas stack once it is on and aimed', async () => {
|
|
72
|
+
const user = userEvent.setup();
|
|
73
|
+
const { container } = render(
|
|
74
|
+
<Lab instruments={[canvasInstrument]} defaultInstrument="Drawn" />,
|
|
75
|
+
);
|
|
76
|
+
await user.click(screen.getByRole('button', { name: 'Loupe' }));
|
|
77
|
+
expect(screen.getByRole('button', { name: 'Loupe' })).toHaveAttribute('aria-pressed', 'true');
|
|
78
|
+
|
|
79
|
+
const stack = container.querySelector('.lk-canvas-stack');
|
|
80
|
+
if (!stack) throw new Error('no canvas stack');
|
|
81
|
+
pointAt(stack, 60, 40);
|
|
82
|
+
|
|
83
|
+
const bubble = lens(container);
|
|
84
|
+
expect(bubble).not.toBeNull();
|
|
85
|
+
// The lens is drawn inside the stack's overlay, so it moves with the canvas
|
|
86
|
+
// rather than with the page.
|
|
87
|
+
expect(container.querySelector('.lk-canvas-stack__overlay')).toContainElement(
|
|
88
|
+
bubble as HTMLElement,
|
|
89
|
+
);
|
|
90
|
+
expect(bubble?.querySelector('canvas')).not.toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('asks a DOM instrument to draw itself again at the magnified camera', async () => {
|
|
94
|
+
const user = userEvent.setup();
|
|
95
|
+
const { container } = render(<Lab instruments={[domInstrument]} defaultInstrument="Written" />);
|
|
96
|
+
await user.click(screen.getByRole('button', { name: 'Loupe' }));
|
|
97
|
+
|
|
98
|
+
const host = container.querySelector('.lk-trial__loupe-host');
|
|
99
|
+
if (!host) throw new Error('no loupe host');
|
|
100
|
+
pointAt(host, 60, 40);
|
|
101
|
+
|
|
102
|
+
// The trial opens at zoom 1 and the loupe at factor 6.
|
|
103
|
+
expect(screen.getByTestId('lens-body')).toHaveTextContent('zoom 6');
|
|
104
|
+
// The instrument's own body is still there, undisturbed.
|
|
105
|
+
expect(screen.getByTestId('body')).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('wraps a DOM instrument only when it declares a loupe', () => {
|
|
109
|
+
const { container } = render(<Lab instruments={[noLoupe]} defaultInstrument="Plain" />);
|
|
110
|
+
expect(container.querySelector('.lk-trial__loupe-host')).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('puts the lens away again when the toggle goes off', async () => {
|
|
114
|
+
const user = userEvent.setup();
|
|
115
|
+
const { container } = render(
|
|
116
|
+
<Lab instruments={[canvasInstrument]} defaultInstrument="Drawn" />,
|
|
117
|
+
);
|
|
118
|
+
const toggle = screen.getByRole('button', { name: 'Loupe' });
|
|
119
|
+
await user.click(toggle);
|
|
120
|
+
const stack = container.querySelector('.lk-canvas-stack');
|
|
121
|
+
if (!stack) throw new Error('no canvas stack');
|
|
122
|
+
pointAt(stack, 60, 40);
|
|
123
|
+
expect(lens(container)).not.toBeNull();
|
|
124
|
+
|
|
125
|
+
await user.click(toggle);
|
|
126
|
+
expect(lens(container)).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/trial/Trial.test.tsx
CHANGED
|
@@ -82,6 +82,18 @@ describe('<TrialChrome>', () => {
|
|
|
82
82
|
expect(screen.getByRole('button', { name: 'Clone trial' })).toBeInTheDocument();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
it('puts clone, reset and snapshot at the end of the title bar, beside close', () => {
|
|
86
|
+
const { container } = render(<ChromeHarness />);
|
|
87
|
+
const titlebar = container.querySelector('.lk-trial__titlebar');
|
|
88
|
+
if (!titlebar) throw new Error('no title bar');
|
|
89
|
+
expect(titlebar).toContainElement(screen.getByRole('button', { name: 'Clone trial' }));
|
|
90
|
+
expect(titlebar).toContainElement(screen.getByRole('button', { name: 'Reset trial' }));
|
|
91
|
+
const labels = [...titlebar.querySelectorAll('button')].map((b) =>
|
|
92
|
+
b.getAttribute('aria-label'),
|
|
93
|
+
);
|
|
94
|
+
expect(labels).toEqual(['Clone trial', 'Reset trial', 'Save snapshot', 'Close trial']);
|
|
95
|
+
});
|
|
96
|
+
|
|
85
97
|
it('disables close on the last trial', () => {
|
|
86
98
|
render(<ChromeHarness isLastTrial />);
|
|
87
99
|
expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An instrument hosted by a real <Lab> must be able to reach trial-scoped
|
|
3
|
+
* hooks. `<TrialIdProvider>` had exactly one production mount —
|
|
4
|
+
* `SingletonExperiment` — so `useTrialState()` threw everywhere else, which
|
|
5
|
+
* made the documented hook pattern unreachable from an instrument's `render`.
|
|
6
|
+
*/
|
|
7
|
+
import { render, screen } from '@testing-library/react';
|
|
8
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
10
|
+
import { Lab } from '../lab/Lab';
|
|
11
|
+
import { useTrialState } from '../state/useTrialState';
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
15
|
+
() => null,
|
|
16
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function Probe() {
|
|
20
|
+
const { state } = useTrialState<{ hits: number }, Record<string, never>>();
|
|
21
|
+
return <div data-testid="probe">hits:{state.hits}</div>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const reader = defineInstrument<{ hits: number }, Record<string, never>>({
|
|
25
|
+
name: 'Reader',
|
|
26
|
+
defaultConfig: () => ({}),
|
|
27
|
+
initialState: () => ({ hits: 7 }),
|
|
28
|
+
render: () => <Probe />,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('<Trial> trial-scoped hooks', () => {
|
|
32
|
+
it('lets an instrument read its own trial state through useTrialState', () => {
|
|
33
|
+
render(<Lab instruments={[reader]} defaultInstrument="Reader" />);
|
|
34
|
+
expect(screen.getByTestId('probe')).toHaveTextContent('hits:7');
|
|
35
|
+
});
|
|
36
|
+
});
|