@weasel-js/labkit 1.6.0 → 1.6.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/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
- package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
- package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-BoELAMsM.d.ts} +2 -2
- package/dist/_dts/{index-DINmCuLA.d.ts → PropertyField.d-D0ndfIIt.d.ts} +34 -83
- package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Cr8-zoup.d.ts} +4 -5
- package/dist/_dts/{usePanZoom-Bq73pwFe.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
- package/dist/_dts/{index-B0--8PEM.d.ts → index-CKyX0ME_.d.ts} +12 -13
- package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-B7yBTTF9.d.ts} +699 -649
- package/dist/_dts/{types-B7y8hX1D.d.ts → types-DTuViBvc.d.ts} +20 -1
- package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
- package/dist/canvas/index.d.ts +39 -7
- package/dist/canvas/index.js +5 -3
- package/dist/chrome/index.d.ts +4 -5
- package/dist/chrome/index.js +8 -6
- package/dist/chunk-24W5VEJW.js +18 -0
- package/dist/chunk-24W5VEJW.js.map +1 -0
- package/dist/chunk-3RY6WL5T.js +38 -0
- package/dist/chunk-3RY6WL5T.js.map +1 -0
- package/dist/{chunk-H5VIV7MQ.js → chunk-4TT3PNPR.js} +60 -51
- package/dist/chunk-4TT3PNPR.js.map +1 -0
- package/dist/{chunk-ZORVNOKX.js → chunk-6FLXL3I7.js} +9 -145
- package/dist/chunk-6FLXL3I7.js.map +1 -0
- package/dist/{chunk-HFRE4QQV.js → chunk-ASOK76ZH.js} +53 -22
- package/dist/chunk-ASOK76ZH.js.map +1 -0
- package/dist/chunk-AWQG7FCC.js +19 -0
- package/dist/chunk-AWQG7FCC.js.map +1 -0
- package/dist/chunk-CGHF6RZV.js +300 -0
- package/dist/chunk-CGHF6RZV.js.map +1 -0
- package/dist/chunk-DWMSB4CA.js +228 -0
- package/dist/chunk-DWMSB4CA.js.map +1 -0
- package/dist/{chunk-USO6TERP.js → chunk-GHGO52SO.js} +11 -9
- package/dist/chunk-GHGO52SO.js.map +1 -0
- package/dist/{chunk-MOLIUNVH.js → chunk-HKV7LRHY.js} +4 -4
- package/dist/{chunk-MOLIUNVH.js.map → chunk-HKV7LRHY.js.map} +1 -1
- package/dist/{chunk-VQ47KWD5.js → chunk-IRTGUQ5O.js} +5 -4
- package/dist/chunk-IRTGUQ5O.js.map +1 -0
- package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
- package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
- package/dist/chunk-KHK5FGER.js +145 -0
- package/dist/chunk-KHK5FGER.js.map +1 -0
- package/dist/chunk-NEXR37YJ.js +144 -0
- package/dist/chunk-NEXR37YJ.js.map +1 -0
- package/dist/{chunk-BVL7Y574.js → chunk-O6FFFOHS.js} +14 -6
- package/dist/chunk-O6FFFOHS.js.map +1 -0
- package/dist/chunk-QGMOA2YE.js +731 -0
- package/dist/chunk-QGMOA2YE.js.map +1 -0
- package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
- package/dist/chunk-S4NCI32V.js.map +1 -0
- package/dist/{chunk-3FI7Y4R4.js → chunk-SLAQPUI4.js} +4 -4
- package/dist/{chunk-3FI7Y4R4.js.map → chunk-SLAQPUI4.js.map} +1 -1
- package/dist/{chunk-AWH3SRPW.js → chunk-TJ77A4A3.js} +3 -31
- package/dist/chunk-TJ77A4A3.js.map +1 -0
- package/dist/{chunk-WUJXJC5O.js → chunk-TYN6MLXX.js} +691 -1395
- package/dist/chunk-TYN6MLXX.js.map +1 -0
- package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
- package/dist/chunk-WPLAANH4.js.map +1 -0
- package/dist/config/index.d.ts +36 -35
- package/dist/config/index.js +4 -5
- package/dist/controls/index.d.ts +91 -7
- package/dist/controls/index.js +5 -4
- package/dist/dragdrop/index.d.ts +8 -6
- package/dist/dragdrop/index.js +3 -2
- package/dist/index.d.ts +50 -77
- package/dist/index.js +138 -254
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +3 -4
- package/dist/layers/index.js +2 -1
- package/dist/loupe/index.d.ts +19 -16
- package/dist/loupe/index.js +5 -3
- package/dist/overview/index.d.ts +56 -0
- package/dist/overview/index.js +288 -0
- package/dist/overview/index.js.map +1 -0
- package/dist/passthrough/weasel-ui.d.ts +38 -38
- package/dist/passthrough/weasel-ui.js +2 -1
- package/dist/primitives/index.d.ts +19 -43
- package/dist/primitives/index.js +5 -3
- package/dist/state/index.d.ts +7 -7
- package/dist/state/index.js +4 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +63 -1
- package/dist/undo/index.d.ts +3 -3
- package/package.json +13 -9
- package/src/annotations/index.ts +1 -0
- package/src/annotations/store.test.ts +26 -0
- package/src/annotations/store.ts +25 -6
- package/src/annotations/toolMap.test.ts +37 -2
- package/src/annotations/toolMap.ts +13 -0
- package/src/annotations/types.ts +27 -0
- package/src/canvas/AGENTS.md +8 -10
- package/src/canvas/CameraInput.test.tsx +136 -0
- package/src/canvas/CameraInput.tsx +311 -0
- package/src/canvas/CanvasStack.tsx +42 -58
- package/src/canvas/LinkedCursor.less +16 -0
- package/src/canvas/LinkedCursor.tsx +54 -0
- package/src/canvas/Stage.tsx +42 -37
- package/src/canvas/cameraView.test.ts +60 -0
- package/src/canvas/cameraView.ts +61 -0
- package/src/canvas/index.ts +11 -2
- package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
- package/src/config/builder.ts +3 -3
- package/src/config/entry.test.ts +3 -1
- package/src/config/fromConfigField.ts +1 -0
- package/src/config/index.ts +4 -1
- package/src/config/resolve.ts +11 -1
- package/src/config/sectionTree.test.ts +12 -6
- package/src/config/sectionTree.ts +15 -5
- package/src/config/types.ts +21 -0
- package/src/controls/ControlPanel.test.tsx +12 -2
- package/src/controls/ControlPanel.tsx +28 -9
- package/src/controls/inDialog.test.tsx +18 -0
- package/src/controls/inDialog.tsx +2 -10
- package/src/index.test.ts +1 -1
- package/src/index.ts +8 -4
- package/src/instrument/SineWave.smoke.test.tsx +2 -0
- package/src/instrument/types.ts +10 -1
- package/src/lab/Lab.nebula.test.tsx +11 -2
- package/src/lab/Lab.test.tsx +3 -1
- package/src/lab/Lab.tsx +4 -4
- package/src/loupe/AGENTS.md +7 -6
- package/src/loupe/LoupeGestures.tsx +14 -9
- package/src/loupe/TrialLoupe.tsx +5 -5
- package/src/loupe/index.ts +1 -0
- package/src/loupe/loupeActions.ts +6 -2
- package/src/overview/AGENTS.md +38 -0
- package/src/overview/OverviewMarks.test.tsx +100 -0
- package/src/overview/OverviewMarks.tsx +93 -0
- package/src/overview/TrialOverview.less +53 -0
- package/src/overview/TrialOverview.stories.tsx +50 -0
- package/src/overview/TrialOverview.test.tsx +116 -0
- package/src/overview/TrialOverview.tsx +374 -0
- package/src/overview/index.ts +2 -0
- package/src/passthrough/weasel-ui.ts +7 -0
- package/src/specimen/Specimen.tsx +19 -2
- package/src/state/labRecords.test.ts +4 -1
- package/src/state/store.test.ts +4 -4
- package/src/state/useTrialState.test.tsx +1 -1
- package/src/styles.less +2 -0
- package/src/theme/Interstellar.stories.tsx +1 -5
- package/src/tools/labTool.ts +6 -2
- package/src/trial/Trial.annotations.test.tsx +16 -0
- package/src/trial/Trial.loupe.test.tsx +73 -1
- package/src/trial/Trial.stories.tsx +2 -0
- package/src/trial/Trial.tsx +17 -1
- package/src/trial/trialOps.test.ts +4 -4
- package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
- package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
- package/dist/chunk-2AYGEN57.js +0 -32
- package/dist/chunk-2AYGEN57.js.map +0 -1
- package/dist/chunk-AWH3SRPW.js.map +0 -1
- package/dist/chunk-BVL7Y574.js.map +0 -1
- package/dist/chunk-ERFMC6WU.js.map +0 -1
- package/dist/chunk-H5VIV7MQ.js.map +0 -1
- package/dist/chunk-HFRE4QQV.js.map +0 -1
- package/dist/chunk-RS3HRQWU.js.map +0 -1
- package/dist/chunk-TT5C5S6R.js +0 -368
- package/dist/chunk-TT5C5S6R.js.map +0 -1
- package/dist/chunk-USO6TERP.js.map +0 -1
- package/dist/chunk-VQ47KWD5.js.map +0 -1
- package/dist/chunk-WUJXJC5O.js.map +0 -1
- package/dist/chunk-ZORVNOKX.js.map +0 -1
- package/src/canvas/usePanZoom.test.ts +0 -244
- package/src/canvas/usePanZoom.ts +0 -147
|
@@ -19,12 +19,21 @@ const backdrop = (container: HTMLElement) =>
|
|
|
19
19
|
|
|
20
20
|
describe('<Lab nebula>', () => {
|
|
21
21
|
it('draws one blob per literal color', () => {
|
|
22
|
-
const { container } = render(
|
|
22
|
+
const { container } = render(
|
|
23
|
+
<Lab
|
|
24
|
+
instruments={[bare]}
|
|
25
|
+
defaultInstrument="Bare"
|
|
26
|
+
mode="dark"
|
|
27
|
+
nebula={['#ff0000', '#00ff00']}
|
|
28
|
+
/>,
|
|
29
|
+
);
|
|
23
30
|
expect(backdrop(container).match(/radial-gradient/g)).toHaveLength(3);
|
|
24
31
|
});
|
|
25
32
|
|
|
26
33
|
it('resolves a theme ramp against the lab’s theme', () => {
|
|
27
|
-
const { container } = render(
|
|
34
|
+
const { container } = render(
|
|
35
|
+
<Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={{ ramp: 'swatch' }} />,
|
|
36
|
+
);
|
|
28
37
|
const sky = resolveTheme(interstellarTheme, { mode: 'dark' })['--wzl-swatch-sky'];
|
|
29
38
|
expect(backdrop(container)).toContain(sky);
|
|
30
39
|
});
|
package/src/lab/Lab.test.tsx
CHANGED
|
@@ -165,7 +165,9 @@ describe('<Lab>', () => {
|
|
|
165
165
|
storageKey: 's',
|
|
166
166
|
});
|
|
167
167
|
await waitFor(() => expect(labRef?.trials[0]).toBeDefined());
|
|
168
|
-
expect((labRef?.trials[0]?.state as { seen: Set<string> })
|
|
168
|
+
expect((labRef?.trials[0]?.state as { seen: Set<string> } | undefined)?.seen).toEqual(
|
|
169
|
+
new Set(['a']),
|
|
170
|
+
);
|
|
169
171
|
});
|
|
170
172
|
});
|
|
171
173
|
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from 'react';
|
|
13
13
|
import { useStore } from 'zustand/react';
|
|
14
14
|
import { AnnotationPreloadContext } from '../annotations/preload';
|
|
15
|
-
import {
|
|
15
|
+
import { labAnnotationTools } from '../annotations/toolMap';
|
|
16
16
|
import {
|
|
17
17
|
LabAsideRegion,
|
|
18
18
|
LabFooterRegion,
|
|
@@ -478,10 +478,10 @@ function LabRuntime({
|
|
|
478
478
|
// of them silently losing.
|
|
479
479
|
// Declaring `annotations` on any instrument puts the drawing tools here, in
|
|
480
480
|
// the lab's rail: one tool is armed across every trial.
|
|
481
|
-
const
|
|
481
|
+
const annotationTools = useMemo(() => labAnnotationTools(instruments), [instruments]);
|
|
482
482
|
const labChromeAll = useMemo(
|
|
483
|
-
() => labContributions([...
|
|
484
|
-
[
|
|
483
|
+
() => labContributions([...annotationTools, ...(tools ?? [])], labChrome),
|
|
484
|
+
[annotationTools, tools, labChrome],
|
|
485
485
|
);
|
|
486
486
|
const hasFooterChrome = labChromeAll.some((c) => c.region === 'footer');
|
|
487
487
|
const hasPaneChrome = labChromeAll.some((c) => c.region === 'sidebar' || c.region === 'aside');
|
package/src/loupe/AGENTS.md
CHANGED
|
@@ -14,7 +14,7 @@ This directory binds that model to a labkit trial and draws it.
|
|
|
14
14
|
| `types.ts` | `LoupeCapability`, and `resolveLoupe` filling in every default |
|
|
15
15
|
| `useLoupe.ts` | The model over a host element, and pointer aiming |
|
|
16
16
|
| `loupeActions.ts` | `loupe.peek` and `loupe.magnify`, as `Action` descriptors |
|
|
17
|
-
| `LoupeGestures.tsx` | Registers those and mounts
|
|
17
|
+
| `LoupeGestures.tsx` | Registers those, and mounts a dispatcher on the host when no camera has one there |
|
|
18
18
|
| `TrialLoupe.tsx` | Picks the painter and mounts the lens |
|
|
19
19
|
| `LoupeBubble.tsx` | The circular clip, positioned on the aim |
|
|
20
20
|
| `CanvasLoupe.tsx` | Painter for a `<CanvasStack>` |
|
|
@@ -46,11 +46,12 @@ StrictMode mounts / unmounts / mounts every effect — so disposing in the
|
|
|
46
46
|
cleanup leaves a magnifier that draws but silently ignores every aim. It owns
|
|
47
47
|
no resources; unmounting only reports the lens gone.
|
|
48
48
|
|
|
49
|
-
**The wheel is taken from
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
the
|
|
53
|
-
dispatcher
|
|
49
|
+
**The wheel is taken from the camera by rank, and handed back by declining.**
|
|
50
|
+
In a trial the loupe's actions join the camera's dispatcher (`CameraInput`).
|
|
51
|
+
`loupe.magnify` sits in the hotkey tier, so while the lens is up it outranks
|
|
52
|
+
the camera's wheel zoom; while it is down its `enabled` returns a disabled
|
|
53
|
+
reason and the dispatcher falls through to the zoom. Outside a camera — the DOM
|
|
54
|
+
`.lk-trial__loupe-host` — `<LoupeGestures>` mounts a dispatcher of its own.
|
|
54
55
|
|
|
55
56
|
**Aiming is a plain listener because a hover is not a gesture.**
|
|
56
57
|
`GESTURE_DESCRIPTORS` names no continuous-motion gesture, so `pointermove` /
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* weasel binding does.
|
|
2
|
+
* Registers the loupe's actions, and mounts the gesture dispatcher on the
|
|
3
|
+
* loupe's host when no camera already has one there, so the peek key and the
|
|
4
|
+
* wheel route the way every other weasel binding does.
|
|
5
5
|
*
|
|
6
6
|
* Three of the dispatcher's four element channels are off. Only `wheel` is
|
|
7
7
|
* wanted here; `contextMenu` suppresses the native menu unconditionally and
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
useActionsRegistry,
|
|
15
15
|
useGestureDispatcher,
|
|
16
16
|
} from '@weasel-js/core';
|
|
17
|
-
import { type RefObject, useEffect, useMemo } from 'react';
|
|
17
|
+
import { type RefObject, useContext, useEffect, useMemo } from 'react';
|
|
18
|
+
import { CameraContext } from '../canvas/CameraInput';
|
|
18
19
|
import { createLoupeActions, type LoupeInputApi } from './loupeActions';
|
|
19
20
|
|
|
20
21
|
/** Props for `<LoupeGestures>`. */
|
|
@@ -35,7 +36,8 @@ function LoupeDispatch({
|
|
|
35
36
|
input,
|
|
36
37
|
peekKey,
|
|
37
38
|
registry,
|
|
38
|
-
|
|
39
|
+
ownDispatcher,
|
|
40
|
+
}: LoupeGesturesProps & { registry: ActionsRegistry; ownDispatcher: boolean }) {
|
|
39
41
|
const actions = useMemo(() => createLoupeActions(input, peekKey), [input, peekKey]);
|
|
40
42
|
|
|
41
43
|
useEffect(() => {
|
|
@@ -50,18 +52,21 @@ function LoupeDispatch({
|
|
|
50
52
|
actions: registry,
|
|
51
53
|
toolsById: NO_TOOLS,
|
|
52
54
|
channels: CHANNELS,
|
|
55
|
+
enabled: ownDispatcher,
|
|
53
56
|
});
|
|
54
57
|
|
|
55
58
|
return null;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/**
|
|
59
|
-
* The loupe's input, routed.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
+
* The loupe's input, routed. Inside a camera (`<CanvasStack>`, `<Stage>`) its
|
|
63
|
+
* actions join the camera's dispatcher, which already listens on the host;
|
|
64
|
+
* elsewhere it mounts a dispatcher of its own. With no registry in scope it
|
|
65
|
+
* renders nothing rather than registering actions that could never fire.
|
|
62
66
|
*/
|
|
63
67
|
export function LoupeGestures(props: LoupeGesturesProps) {
|
|
64
68
|
const registry = useActionsRegistry();
|
|
69
|
+
const camera = useContext(CameraContext);
|
|
65
70
|
if (!registry) return null;
|
|
66
|
-
return <LoupeDispatch {...props} registry={registry} />;
|
|
71
|
+
return <LoupeDispatch {...props} registry={registry} ownDispatcher={camera === null} />;
|
|
67
72
|
}
|
package/src/loupe/TrialLoupe.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { WeaselProvider } from '@weasel-js/core';
|
|
2
1
|
import { type RefObject, useContext, useMemo } from 'react';
|
|
2
|
+
import { CameraScope } from '../canvas/CameraInput';
|
|
3
3
|
import { CanvasStackContext } from '../canvas/CanvasStackContext';
|
|
4
4
|
import type { WorldSpec } from '../canvas/worldSpec';
|
|
5
5
|
import type { ViewTransform } from '../instrument/types';
|
|
@@ -63,12 +63,12 @@ export function TrialLoupe({
|
|
|
63
63
|
|
|
64
64
|
// The gestures mount outside the visibility gate: hold-to-peek is what
|
|
65
65
|
// raises a lens that is down, so its binding has to be live while it is.
|
|
66
|
-
//
|
|
67
|
-
//
|
|
66
|
+
// In a trial they join the trial's scope, and with it the camera's
|
|
67
|
+
// dispatcher; outside one, `<CameraScope>` gives them an isolated scope.
|
|
68
68
|
const gestures = (
|
|
69
|
-
<
|
|
69
|
+
<CameraScope>
|
|
70
70
|
<LoupeGestures hostRef={host} input={loupe.input} peekKey={capability.peekKey ?? null} />
|
|
71
|
-
</
|
|
71
|
+
</CameraScope>
|
|
72
72
|
);
|
|
73
73
|
|
|
74
74
|
if (!loupe.visible) return gestures;
|
package/src/loupe/index.ts
CHANGED
|
@@ -21,8 +21,8 @@ export const WHEEL_RATE = 0.002;
|
|
|
21
21
|
* registration. */
|
|
22
22
|
export interface LoupeInputApi {
|
|
23
23
|
/** Whether the lens is up. The wheel belongs to the loupe only while it is;
|
|
24
|
-
* otherwise `loupe.magnify` declines and the dispatcher
|
|
25
|
-
*
|
|
24
|
+
* otherwise `loupe.magnify` declines and the dispatcher falls through to
|
|
25
|
+
* the trial camera's zoom. */
|
|
26
26
|
shown: () => boolean;
|
|
27
27
|
/** Raise or drop the hold-to-peek flag. */
|
|
28
28
|
setPeeking: (on: boolean) => void;
|
|
@@ -43,6 +43,10 @@ export function createLoupeActions(
|
|
|
43
43
|
id: LOUPE_MAGNIFY_ID,
|
|
44
44
|
label: 'Magnify (loupe)',
|
|
45
45
|
group: 'loupe',
|
|
46
|
+
// Hotkey scope, so while the lens is up it outranks the trial camera's
|
|
47
|
+
// wheel zoom on the same dispatcher; while it is down `enabled` declines
|
|
48
|
+
// and the zoom runs.
|
|
49
|
+
scope: 'hotkey',
|
|
46
50
|
// Every modifier optional: the lens claims the wheel whenever it is up,
|
|
47
51
|
// which is what the hand-rolled capture-phase listener did. A bare spec
|
|
48
52
|
// would forbid modifiers and hand Cmd+wheel back to the lab mid-peek.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Overview — Agent Guide
|
|
2
|
+
|
|
3
|
+
`src/overview/` is `@weasel-js/labkit/overview`: `<TrialOverview>`, a trial's
|
|
4
|
+
whole content in a floating panel. Its own bundle entry; nothing in the main
|
|
5
|
+
bundle imports it.
|
|
6
|
+
|
|
7
|
+
| File | Role |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `TrialOverview.tsx` | The panel: content, chrome canvas, and its own dispatcher |
|
|
10
|
+
| `OverviewMarks.tsx` | Every annotation target's marks, read-only, as SVG |
|
|
11
|
+
|
|
12
|
+
## How it reads the trial
|
|
13
|
+
|
|
14
|
+
Only through `CameraContext`, which `<CanvasStack>` and `<Stage>` set: the
|
|
15
|
+
camera as a weasel `ViewApi` over frame-local coordinates, the frame, the
|
|
16
|
+
camera's element, a stage's content rect. A canvas instrument's layers come
|
|
17
|
+
from `CanvasStackContext`, marks from `AnnotationsContext`. So it works
|
|
18
|
+
anywhere inside a camera — `stage.overlay`, or a canvas instrument's `render`.
|
|
19
|
+
|
|
20
|
+
Input is core's `minimap.center` / `minimap.pan` on a dispatcher of its own,
|
|
21
|
+
with the camera as `rootView`; the pointer store is the trial's, shared.
|
|
22
|
+
|
|
23
|
+
## Traps
|
|
24
|
+
|
|
25
|
+
**It sits inside the stage's element.** Every event that bubbles out of the box
|
|
26
|
+
reaches the stage's dispatcher, the loupe's aim and the stage's pointer
|
|
27
|
+
publisher. The box stops them; drop that and a drag in the overview also pans
|
|
28
|
+
the stage, and the pointer flips between `'overview'` and `'stage'` each move.
|
|
29
|
+
|
|
30
|
+
**Marks are placed by measuring.** A target's rect on the overview is its
|
|
31
|
+
element's rect against the camera's element, through the camera — never the
|
|
32
|
+
target's content size, which is the mark's world and not where it is drawn.
|
|
33
|
+
jsdom measures zero; a test mocks `getBoundingClientRect`, and the picture is a
|
|
34
|
+
screenshot.
|
|
35
|
+
|
|
36
|
+
**Never render the instrument's own `render` in it.** Its effects — a pane
|
|
37
|
+
ref, an annotation target — would run twice. `render` is a separate, lighter
|
|
38
|
+
copy the instrument supplies.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overview draws every target's marks where that target sits on the stage,
|
|
3
|
+
* and redraws when one is added. jsdom lays nothing out, so the target's rect
|
|
4
|
+
* and the stage's are mocked; the stage's camera is the identity there.
|
|
5
|
+
*/
|
|
6
|
+
import { act, render } from '@testing-library/react';
|
|
7
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { useAnnotations } from '../annotations/AnnotationsContext';
|
|
9
|
+
import type { AnnotationsApi } from '../annotations/types';
|
|
10
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
11
|
+
import { Lab } from '../lab/Lab';
|
|
12
|
+
import { TrialOverview } from './TrialOverview';
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
16
|
+
() => null,
|
|
17
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const rect = (left: number, top: number, width: number, height: number) =>
|
|
21
|
+
({
|
|
22
|
+
left,
|
|
23
|
+
top,
|
|
24
|
+
width,
|
|
25
|
+
height,
|
|
26
|
+
right: left + width,
|
|
27
|
+
bottom: top + height,
|
|
28
|
+
x: left,
|
|
29
|
+
y: top,
|
|
30
|
+
}) as DOMRect;
|
|
31
|
+
|
|
32
|
+
let restore: (() => void) | null = null;
|
|
33
|
+
afterEach(() => restore?.());
|
|
34
|
+
|
|
35
|
+
/** Stage 400×300, the pane at stage (50, 20) and 200×100. */
|
|
36
|
+
function mount() {
|
|
37
|
+
const pane = { current: null as HTMLDivElement | null };
|
|
38
|
+
let api: AnnotationsApi | null = null;
|
|
39
|
+
function Body() {
|
|
40
|
+
api = useAnnotations();
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
ref={(el) => {
|
|
44
|
+
pane.current = el;
|
|
45
|
+
}}
|
|
46
|
+
data-testid="pane"
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const marked = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
51
|
+
name: 'Marked',
|
|
52
|
+
defaultConfig: () => ({}),
|
|
53
|
+
initialState: () => ({}),
|
|
54
|
+
render: () => <Body />,
|
|
55
|
+
stage: {
|
|
56
|
+
size: { width: 400, height: 300 },
|
|
57
|
+
overlay: () => <TrialOverview width={200} height={150} render={() => <div />} />,
|
|
58
|
+
},
|
|
59
|
+
annotations: {
|
|
60
|
+
targets: () => [{ id: 'pane', ref: pane, content: { w: 200, h: 100 } }],
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const original = HTMLElement.prototype.getBoundingClientRect;
|
|
64
|
+
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
|
|
65
|
+
if (this === pane.current) return rect(50, 20, 200, 100);
|
|
66
|
+
return rect(0, 0, 0, 0);
|
|
67
|
+
};
|
|
68
|
+
restore = () => {
|
|
69
|
+
HTMLElement.prototype.getBoundingClientRect = original;
|
|
70
|
+
};
|
|
71
|
+
const r = render(<Lab instruments={[marked]} defaultInstrument="Marked" />);
|
|
72
|
+
return { ...r, api: () => api as AnnotationsApi };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const px = (el: HTMLElement, name: string) => Number.parseFloat(el.style.getPropertyValue(name));
|
|
76
|
+
|
|
77
|
+
describe('marks on the overview', () => {
|
|
78
|
+
it('draws nothing for a target with no marks', () => {
|
|
79
|
+
const h = mount();
|
|
80
|
+
expect(h.container.querySelector('.lk-overview__marks')).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("places a target's marks at the target's rect, through the fit", () => {
|
|
84
|
+
const h = mount();
|
|
85
|
+
act(() => {
|
|
86
|
+
h.api().add({ target: 'pane', kind: 'rect', frac: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } });
|
|
87
|
+
});
|
|
88
|
+
const marks = h.container.querySelector<HTMLElement>('.lk-overview__marks[data-target="pane"]');
|
|
89
|
+
expect(marks).not.toBeNull();
|
|
90
|
+
if (!marks) return;
|
|
91
|
+
// Content 400×300 into 200×150, 6px in: scale 0.46, camera at
|
|
92
|
+
// (200 - 100 / 0.46, 150 - 75 / 0.46).
|
|
93
|
+
const s = 0.46;
|
|
94
|
+
expect(px(marks, '--lk-overview-mx')).toBeCloseTo((50 - (200 - 100 / s)) * s);
|
|
95
|
+
expect(px(marks, '--lk-overview-my')).toBeCloseTo((20 - (150 - 75 / s)) * s);
|
|
96
|
+
expect(px(marks, '--lk-overview-mw')).toBeCloseTo(200 * s);
|
|
97
|
+
expect(px(marks, '--lk-overview-mh')).toBeCloseTo(100 * s);
|
|
98
|
+
expect(marks.querySelector('svg path')).not.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every annotation target's marks, drawn read-only on the overview through the
|
|
3
|
+
* export's own vector path: `markSvgNodes`, then `serializeSvg`.
|
|
4
|
+
*/
|
|
5
|
+
import type { View } from '@weasel-js/core';
|
|
6
|
+
import { serializeSvg } from '@weasel-js/svg';
|
|
7
|
+
import { type CSSProperties, type RefObject, useEffect, useReducer } from 'react';
|
|
8
|
+
import { useAnnotationsOptional } from '../annotations/AnnotationsContext';
|
|
9
|
+
import { markSvgNodes } from '../annotations/svgNodes';
|
|
10
|
+
import type { AnnotationsApi } from '../annotations/types';
|
|
11
|
+
import type { CameraContextValue } from '../canvas/CameraInput';
|
|
12
|
+
|
|
13
|
+
/** A target's box, in the camera's frame-local units. */
|
|
14
|
+
interface LocalRect {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
w: number;
|
|
18
|
+
h: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Where `el` sits in the camera's frame-local units: its screen rect,
|
|
22
|
+
* measured against the camera's element, through the camera. */
|
|
23
|
+
function localRectOf(el: HTMLElement, camera: CameraContextValue): LocalRect | null {
|
|
24
|
+
const host = camera.element();
|
|
25
|
+
if (!host) return null;
|
|
26
|
+
const r = el.getBoundingClientRect();
|
|
27
|
+
const h = host.getBoundingClientRect();
|
|
28
|
+
const v = camera.view.get();
|
|
29
|
+
return {
|
|
30
|
+
x: (r.left - h.left) / v.scale.x + v.x,
|
|
31
|
+
y: (r.top - h.top) / v.scale.y + v.y,
|
|
32
|
+
w: r.width / v.scale.x,
|
|
33
|
+
h: r.height / v.scale.y,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The element a target's marks sit on. The capability's targets carry their
|
|
38
|
+
* `ref`; the store's type names only the half it reads. */
|
|
39
|
+
function elementOf(target: object): HTMLElement | null {
|
|
40
|
+
return (target as { ref?: RefObject<HTMLElement | null> }).ref?.current ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function markupOf(api: AnnotationsApi, target: string, content: { w: number; h: number }): string {
|
|
44
|
+
const painted = api.paintedMarks(target);
|
|
45
|
+
if (painted.length === 0) return '';
|
|
46
|
+
const nodes = painted.flatMap(({ mark, style }) => markSvgNodes(mark, content, style));
|
|
47
|
+
return serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: content.w, height: content.h } });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Props for `<OverviewMarks>`. */
|
|
51
|
+
export interface OverviewMarksProps {
|
|
52
|
+
fit: View;
|
|
53
|
+
camera: CameraContextValue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The marks of every target in the annotations store in scope, placed where
|
|
58
|
+
* each target's element sits on the stage. Draws nothing outside an annotating
|
|
59
|
+
* trial, and takes no input.
|
|
60
|
+
*/
|
|
61
|
+
export function OverviewMarks({ fit, camera }: OverviewMarksProps) {
|
|
62
|
+
const api = useAnnotationsOptional();
|
|
63
|
+
const [, redraw] = useReducer((n: number) => n + 1, 0);
|
|
64
|
+
useEffect(() => api?.subscribe(redraw), [api]);
|
|
65
|
+
if (!api) return null;
|
|
66
|
+
const s = fit.scale.x;
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
{api.targets().map((t) => {
|
|
70
|
+
const el = elementOf(t);
|
|
71
|
+
const box = el ? localRectOf(el, camera) : null;
|
|
72
|
+
const markup = box ? markupOf(api, t.id, t.content) : '';
|
|
73
|
+
if (!box || !markup) return null;
|
|
74
|
+
const place = {
|
|
75
|
+
['--lk-overview-mx' as string]: `${(box.x - fit.x) * s}px`,
|
|
76
|
+
['--lk-overview-my' as string]: `${(box.y - fit.y) * s}px`,
|
|
77
|
+
['--lk-overview-mw' as string]: `${box.w * s}px`,
|
|
78
|
+
['--lk-overview-mh' as string]: `${box.h * s}px`,
|
|
79
|
+
} as CSSProperties;
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
key={t.id}
|
|
83
|
+
className="lk-overview__marks"
|
|
84
|
+
style={place}
|
|
85
|
+
data-target={t.id}
|
|
86
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: the export's SVG serializer is the one vector path marks have; re-deriving React nodes would be a second.
|
|
87
|
+
dangerouslySetInnerHTML={{ __html: markup }}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
.lk-overview__title {
|
|
2
|
+
padding: 2px 6px 4px;
|
|
3
|
+
font-size: var(--wzl-font-size-sm);
|
|
4
|
+
color: var(--wzl-fg-muted);
|
|
5
|
+
text-transform: uppercase;
|
|
6
|
+
letter-spacing: 0.05em;
|
|
7
|
+
cursor: grab;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.lk-overview__box {
|
|
11
|
+
position: relative;
|
|
12
|
+
width: var(--lk-overview-w);
|
|
13
|
+
height: var(--lk-overview-h);
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
background: var(--wzl-surface-sunken);
|
|
16
|
+
touch-action: none;
|
|
17
|
+
cursor: crosshair;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.lk-overview__content {
|
|
21
|
+
position: absolute;
|
|
22
|
+
top: 0;
|
|
23
|
+
left: 0;
|
|
24
|
+
width: var(--lk-overview-cw);
|
|
25
|
+
height: var(--lk-overview-ch);
|
|
26
|
+
transform-origin: 0 0;
|
|
27
|
+
transform: translate(var(--lk-overview-x), var(--lk-overview-y)) scale(var(--lk-overview-zoom));
|
|
28
|
+
pointer-events: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.lk-overview__canvas {
|
|
32
|
+
position: absolute;
|
|
33
|
+
inset: 0;
|
|
34
|
+
width: 100%;
|
|
35
|
+
height: 100%;
|
|
36
|
+
pointer-events: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.lk-overview__marks {
|
|
40
|
+
position: absolute;
|
|
41
|
+
left: var(--lk-overview-mx);
|
|
42
|
+
top: var(--lk-overview-my);
|
|
43
|
+
width: var(--lk-overview-mw);
|
|
44
|
+
height: var(--lk-overview-mh);
|
|
45
|
+
pointer-events: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.lk-overview__marks > svg {
|
|
49
|
+
display: block;
|
|
50
|
+
width: 100%;
|
|
51
|
+
height: 100%;
|
|
52
|
+
overflow: visible;
|
|
53
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@weasel-js/forge';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Stage } from '../canvas/Stage';
|
|
4
|
+
import type { ViewTransform } from '../instrument/types';
|
|
5
|
+
import { TrialOverview } from './TrialOverview';
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof TrialOverview> = {
|
|
8
|
+
title: 'labkit/Overview/TrialOverview',
|
|
9
|
+
component: TrialOverview,
|
|
10
|
+
parameters: { layout: 'fullscreen' },
|
|
11
|
+
};
|
|
12
|
+
export default meta;
|
|
13
|
+
|
|
14
|
+
type Story = StoryObj<typeof TrialOverview>;
|
|
15
|
+
|
|
16
|
+
const SIZE = { width: 640, height: 420 };
|
|
17
|
+
const DOTS = [40, 120, 200, 280, 360, 440, 520, 600];
|
|
18
|
+
|
|
19
|
+
/** A picture bigger than the stage at 2×, so there is somewhere to go. */
|
|
20
|
+
function Picture() {
|
|
21
|
+
return (
|
|
22
|
+
<svg viewBox="0 0 640 420" width={SIZE.width} height={SIZE.height} role="img" aria-label="Grid">
|
|
23
|
+
<title>Grid</title>
|
|
24
|
+
<rect width="640" height="420" fill="#f4f1ea" />
|
|
25
|
+
{DOTS.map((x, i) => (
|
|
26
|
+
<circle key={x} cx={x} cy={40 + i * 48} r="24" fill="#8b98a8" />
|
|
27
|
+
))}
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Harness() {
|
|
33
|
+
const [view, setView] = useState<ViewTransform>({ zoom: 2, pan: { x: -200, y: -120 } });
|
|
34
|
+
return (
|
|
35
|
+
<div style={{ width: '100vw', height: '100vh' }}>
|
|
36
|
+
<Stage
|
|
37
|
+
size={SIZE}
|
|
38
|
+
view={view}
|
|
39
|
+
onViewChange={setView}
|
|
40
|
+
overlay={<TrialOverview width={200} height={140} render={() => <Picture />} />}
|
|
41
|
+
>
|
|
42
|
+
<Picture />
|
|
43
|
+
</Stage>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Default: Story = {
|
|
49
|
+
render: () => <Harness />,
|
|
50
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A trial's overview: a press in it moves the stage's camera there, and the
|
|
3
|
+
* pointer over it is published as the overview's, which the stage then draws.
|
|
4
|
+
*
|
|
5
|
+
* jsdom lays nothing out, so every rect measures zero: a client point is
|
|
6
|
+
* already box-relative, and the stage's viewport has no size to center in —
|
|
7
|
+
* centering puts the pressed world point at the stage's origin instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { act, render } from '@testing-library/react';
|
|
11
|
+
import type { PointerContextValue } from '@weasel-js/core';
|
|
12
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
14
|
+
import { Lab } from '../lab/Lab';
|
|
15
|
+
import { TrialOverview } from './TrialOverview';
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
19
|
+
() => null,
|
|
20
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
21
|
+
const proto = HTMLElement.prototype as unknown as Record<string, unknown>;
|
|
22
|
+
proto.setPointerCapture = vi.fn();
|
|
23
|
+
proto.releasePointerCapture = vi.fn();
|
|
24
|
+
proto.hasPointerCapture = vi.fn(() => true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function pointer(el: Element, type: string, x: number, y: number, buttons = 1) {
|
|
28
|
+
el.dispatchEvent(
|
|
29
|
+
new PointerEvent(type, {
|
|
30
|
+
bubbles: true,
|
|
31
|
+
cancelable: true,
|
|
32
|
+
pointerId: 1,
|
|
33
|
+
clientX: x,
|
|
34
|
+
clientY: y,
|
|
35
|
+
button: 0,
|
|
36
|
+
buttons,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Content 400×300 in a 200×150 box, 6px in: scale 0.46, so box (100, 75)
|
|
42
|
+
* is content (200, 150). */
|
|
43
|
+
function mount() {
|
|
44
|
+
let store: PointerContextValue | null = null;
|
|
45
|
+
const pictured = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
46
|
+
name: 'Pictured',
|
|
47
|
+
defaultConfig: () => ({}),
|
|
48
|
+
initialState: () => ({}),
|
|
49
|
+
render: (ctx) => {
|
|
50
|
+
store = ctx.trial.pointer;
|
|
51
|
+
return <div data-testid="picture" />;
|
|
52
|
+
},
|
|
53
|
+
stage: {
|
|
54
|
+
size: { width: 400, height: 300 },
|
|
55
|
+
overlay: () => (
|
|
56
|
+
<TrialOverview width={200} height={150} render={() => <div data-testid="thumb" />} />
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const r = render(<Lab instruments={[pictured]} defaultInstrument="Pictured" />);
|
|
61
|
+
const box = r.container.querySelector('.lk-overview__box');
|
|
62
|
+
const content = r.container.querySelector('.lk-stage__content') as HTMLElement | null;
|
|
63
|
+
if (!box || !content) throw new Error('no overview or stage');
|
|
64
|
+
return { ...r, box, content, store: () => store as PointerContextValue | null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const px = (el: HTMLElement, name: string) => Number.parseFloat(el.style.getPropertyValue(name));
|
|
68
|
+
|
|
69
|
+
describe('<TrialOverview>', () => {
|
|
70
|
+
it('draws the instrument-supplied content, not the instrument itself', () => {
|
|
71
|
+
const h = mount();
|
|
72
|
+
expect(h.box.querySelector('[data-testid="thumb"]')).not.toBeNull();
|
|
73
|
+
expect(h.box.querySelector('[data-testid="picture"]')).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('moves the stage camera to the pressed point', () => {
|
|
77
|
+
const h = mount();
|
|
78
|
+
act(() => {
|
|
79
|
+
pointer(h.box, 'pointerdown', 100, 75);
|
|
80
|
+
pointer(h.box, 'pointerup', 100, 75, 0);
|
|
81
|
+
});
|
|
82
|
+
expect(px(h.content, '--lk-stage-x')).toBeCloseTo(-200);
|
|
83
|
+
expect(px(h.content, '--lk-stage-y')).toBeCloseTo(-150);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('follows a drag with the camera, and never pans the stage by it', () => {
|
|
87
|
+
const h = mount();
|
|
88
|
+
act(() => {
|
|
89
|
+
pointer(h.box, 'pointerdown', 100, 75);
|
|
90
|
+
pointer(h.box, 'pointermove', 110, 75);
|
|
91
|
+
pointer(h.box, 'pointermove', 123, 75);
|
|
92
|
+
pointer(h.box, 'pointerup', 123, 75, 0);
|
|
93
|
+
});
|
|
94
|
+
// Box x 123 is content x 250 — a stage pan by the 23px drag would land
|
|
95
|
+
// elsewhere.
|
|
96
|
+
expect(px(h.content, '--lk-stage-x')).toBeCloseTo(-250);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("publishes the pointer over it as the overview's, and the stage draws it", () => {
|
|
100
|
+
const h = mount();
|
|
101
|
+
act(() => pointer(h.box, 'pointermove', 100, 75, 0));
|
|
102
|
+
const p = h.store()?.get();
|
|
103
|
+
expect(p?.viewId).toBe('overview');
|
|
104
|
+
expect(p?.worldX).toBeCloseTo(200);
|
|
105
|
+
expect(p?.worldY).toBeCloseTo(150);
|
|
106
|
+
expect(h.container.querySelector('.lk-stage .lk-linked-cursor')).not.toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("publishes the stage's pointer as the stage's, so the stage draws none", () => {
|
|
110
|
+
const h = mount();
|
|
111
|
+
const host = h.container.querySelector('.lk-stage') as HTMLElement;
|
|
112
|
+
act(() => pointer(host, 'pointermove', 10, 10, 0));
|
|
113
|
+
expect(h.store()?.get()?.viewId).toBe('stage');
|
|
114
|
+
expect(h.container.querySelector('.lk-stage .lk-linked-cursor')).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|