@weasel-js/labkit 1.6.1 → 1.7.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-Wo6OwIVM.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
- package/dist/_dts/{LayerList.d-BoELAMsM.d.ts → LayerList.d-DiuEArKq.d.ts} +2 -2
- package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
- package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
- package/dist/_dts/{Select.d-Cr8-zoup.d.ts → Select.d-Bensr_MT.d.ts} +11 -1
- package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-CCWfQQWM.d.ts} +58 -18
- package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
- package/dist/_dts/{index-CKyX0ME_.d.ts → index-CsYqUHD3.d.ts} +2 -2
- package/dist/_dts/{types-DTuViBvc.d.ts → types-CRPSGFAp.d.ts} +13 -4
- package/dist/_dts/{types-B7yBTTF9.d.ts → types-CSm9jgol.d.ts} +2 -2
- package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-CTZPgeZ7.d.ts} +1 -1
- package/dist/canvas/index.d.ts +6 -6
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +5 -5
- package/dist/chrome/index.js +8 -8
- package/dist/{chunk-QGMOA2YE.js → chunk-275JRRVD.js} +67 -70
- package/dist/chunk-275JRRVD.js.map +1 -0
- package/dist/{chunk-KHK5FGER.js → chunk-4LJSC7MB.js} +8 -10
- package/dist/chunk-4LJSC7MB.js.map +1 -0
- package/dist/{chunk-SLAQPUI4.js → chunk-7BO7HNGF.js} +5 -5
- package/dist/{chunk-SLAQPUI4.js.map → chunk-7BO7HNGF.js.map} +1 -1
- package/dist/chunk-CZZPXUOU.js +3 -0
- package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
- package/dist/{chunk-6FLXL3I7.js → chunk-FMK7UQ6V.js} +19 -4
- package/dist/chunk-FMK7UQ6V.js.map +1 -0
- package/dist/{chunk-O6FFFOHS.js → chunk-FZ7YFQN7.js} +16 -8
- package/dist/chunk-FZ7YFQN7.js.map +1 -0
- package/dist/{chunk-GHGO52SO.js → chunk-GOZLNM4L.js} +5 -5
- package/dist/{chunk-GHGO52SO.js.map → chunk-GOZLNM4L.js.map} +1 -1
- package/dist/{chunk-4TT3PNPR.js → chunk-I5DUQ5HM.js} +33 -36
- package/dist/chunk-I5DUQ5HM.js.map +1 -0
- package/dist/{chunk-NEXR37YJ.js → chunk-J7OWWTCW.js} +4 -5
- package/dist/chunk-J7OWWTCW.js.map +1 -0
- package/dist/{chunk-IRTGUQ5O.js → chunk-KMJ7RWD3.js} +16 -6
- package/dist/chunk-KMJ7RWD3.js.map +1 -0
- package/dist/{chunk-S4NCI32V.js → chunk-M7HP3FTK.js} +4 -4
- package/dist/{chunk-S4NCI32V.js.map → chunk-M7HP3FTK.js.map} +1 -1
- package/dist/chunk-MIM5R4ZT.js +14972 -0
- package/dist/chunk-MIM5R4ZT.js.map +1 -0
- package/dist/{chunk-ASOK76ZH.js → chunk-T5IBSPAM.js} +59 -26
- package/dist/chunk-T5IBSPAM.js.map +1 -0
- package/dist/{chunk-DWMSB4CA.js → chunk-UNAZSLWE.js} +82 -16
- package/dist/chunk-UNAZSLWE.js.map +1 -0
- package/dist/{chunk-TJ77A4A3.js → chunk-Y23G2VZL.js} +5 -9
- package/dist/chunk-Y23G2VZL.js.map +1 -0
- package/dist/{chunk-HKV7LRHY.js → chunk-YT75VYRD.js} +4 -4
- package/dist/{chunk-HKV7LRHY.js.map → chunk-YT75VYRD.js.map} +1 -1
- package/dist/{chunk-CGHF6RZV.js → chunk-ZIFPHIWZ.js} +9 -7
- package/dist/chunk-ZIFPHIWZ.js.map +1 -0
- package/dist/config/index.d.ts +16 -9
- package/dist/config/index.js +4 -4
- package/dist/controls/index.d.ts +2 -2
- package/dist/controls/index.js +5 -5
- package/dist/dragdrop/index.d.ts +3 -3
- package/dist/index.d.ts +263 -24
- package/dist/index.js +376 -237
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -4
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +4 -4
- package/dist/loupe/index.js +2 -2
- package/dist/overview/index.d.ts +2 -2
- package/dist/overview/index.js +9 -8
- package/dist/overview/index.js.map +1 -1
- package/dist/passthrough/weasel-ui.d.ts +208 -38
- package/dist/passthrough/weasel-ui.js +3 -3
- package/dist/primitives/index.d.ts +3 -178
- package/dist/primitives/index.js +6 -6
- package/dist/state/index.d.ts +5 -5
- package/dist/state/index.js +4 -4
- package/dist/styles.css +91 -83
- package/dist/undo/index.d.ts +3 -3
- package/package.json +13 -12
- package/src/annotations/AnnotationOverlay.tsx +2 -2
- package/src/annotations/Annotations.less +0 -10
- package/src/annotations/MarkList.tsx +4 -8
- package/src/annotations/paint.test.ts +3 -14
- package/src/annotations/paint.ts +3 -19
- package/src/annotations/svgNodes.test.ts +6 -9
- package/src/annotations/svgNodes.ts +3 -3
- package/src/canvas/AGENTS.md +1 -0
- package/src/canvas/CameraInput.tsx +44 -33
- package/src/canvas/CanvasStack.tsx +6 -1
- package/src/canvas/LinkedCursor.less +1 -1
- package/src/canvas/Stage.test.tsx +33 -4
- package/src/canvas/Stage.tsx +6 -1
- package/src/canvas/cameraRegistry.test.ts +55 -0
- package/src/canvas/cameraRegistry.ts +86 -0
- package/src/canvas/cameraZoom.test.ts +46 -0
- package/src/canvas/cameraZoom.ts +24 -0
- package/src/canvas/index.ts +14 -2
- package/src/canvas/sharedScope.test.tsx +94 -0
- package/src/chrome/ChromeRegions.stories.tsx +1 -0
- package/src/chrome/regions/PaletteRegion.less +0 -7
- package/src/chrome/regions/SidebarRegion.less +12 -2
- package/src/config/builder.test.ts +2 -1
- package/src/config/builder.ts +12 -5
- package/src/config/fromConfigField.test.ts +14 -0
- package/src/config/fromConfigField.ts +18 -4
- package/src/config/path.ts +2 -5
- package/src/config/resolve.ts +4 -0
- package/src/config/types.ts +13 -9
- package/src/controls/ControlPanel.less +1 -1
- package/src/controls/ControlPanel.stories.tsx +3 -2
- package/src/controls/ControlPanel.test.tsx +64 -11
- package/src/controls/ControlPanel.tsx +52 -2
- package/src/index.ts +15 -1
- package/src/lab/Lab.tsx +83 -72
- package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
- package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
- package/src/lab/LabFullChrome.stories.tsx +1 -0
- package/src/lab/LabHeader.test.tsx +24 -14
- package/src/lab/LabHeader.tsx +11 -12
- package/src/lab/LabShell.less +8 -17
- package/src/lab/LabSwitcher.less +6 -6
- package/src/lab/LabZoom.test.tsx +223 -0
- package/src/lab/LabZoom.tsx +170 -0
- package/src/lab/index.ts +1 -0
- package/src/loupe/LoupeGestures.tsx +1 -1
- package/src/overview/TrialOverview.tsx +7 -5
- package/src/passthrough/weasel-ui.test.ts +2 -0
- package/src/passthrough/weasel-ui.ts +16 -4
- package/src/primitives/FloatingPanel.test.tsx +11 -0
- package/src/primitives/FloatingPanel.tsx +2 -5
- package/src/primitives/JobProgress.less +1 -1
- package/src/primitives/Legend.stories.tsx +1 -0
- package/src/primitives/Readout.browser.test.tsx +137 -0
- package/src/primitives/Readout.less +12 -0
- package/src/primitives/Readout.stories.tsx +58 -0
- package/src/primitives/Readout.test.tsx +93 -0
- package/src/primitives/Readout.tsx +56 -0
- package/src/primitives/index.ts +2 -0
- package/src/specimen/Specimen.stories.tsx +1 -0
- package/src/specimen/Specimen.tsx +2 -2
- package/src/state/Persistence.browser.test.tsx +47 -0
- package/src/state/store.test.ts +5 -1
- package/src/state/usePersistedState.test.tsx +1 -1
- package/src/styles.less +1 -0
- package/src/theme/Interstellar.stories.tsx +3 -1
- package/src/theme/base.less +21 -47
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.theme.json +10 -3
- package/src/trial/Trial.less +0 -13
- package/src/trial/Trial.tsx +12 -6
- package/src/trial/TrialChrome.tsx +21 -3
- package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
- package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
- package/dist/chunk-4TT3PNPR.js.map +0 -1
- package/dist/chunk-6FLXL3I7.js.map +0 -1
- package/dist/chunk-ASOK76ZH.js.map +0 -1
- package/dist/chunk-CGHF6RZV.js.map +0 -1
- package/dist/chunk-DWMSB4CA.js.map +0 -1
- package/dist/chunk-IRTGUQ5O.js.map +0 -1
- package/dist/chunk-KHK5FGER.js.map +0 -1
- package/dist/chunk-NEXR37YJ.js.map +0 -1
- package/dist/chunk-O6FFFOHS.js.map +0 -1
- package/dist/chunk-QGMOA2YE.js.map +0 -1
- package/dist/chunk-SBXNQW4G.js +0 -3
- package/dist/chunk-TJ77A4A3.js.map +0 -1
- package/dist/chunk-TYN6MLXX.js +0 -13864
- package/dist/chunk-TYN6MLXX.js.map +0 -1
- package/src/state/Persistence.stories.tsx +0 -75
|
@@ -58,28 +58,38 @@ describe('<LabHeader>', () => {
|
|
|
58
58
|
<Lab instruments={[Stub, Other]} defaultInstrument="Stub" addTrial={false} />,
|
|
59
59
|
);
|
|
60
60
|
expect(screen.queryByRole('button', { name: /add trial/i })).toBeNull();
|
|
61
|
-
expect(screen.getByRole('
|
|
61
|
+
expect(screen.getByRole('button', { name: /^Theme: Auto/ })).toBeInTheDocument();
|
|
62
62
|
unmount();
|
|
63
63
|
render(<Lab instruments={[Stub]} defaultInstrument="Stub" addTrial={false} />);
|
|
64
64
|
expect(screen.queryByRole('button', { name: /add trial/i })).toBeNull();
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
it('
|
|
67
|
+
it('rotates the color mode from one button', async () => {
|
|
68
|
+
const { default: userEvent } = await import('@testing-library/user-event');
|
|
69
|
+
const user = userEvent.setup();
|
|
68
70
|
render(<Lab instruments={[Stub]} defaultInstrument="Stub" />);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
expect(screen.queryByRole('radiogroup')).toBeNull();
|
|
72
|
+
for (const [now, next] of [
|
|
73
|
+
['Auto', 'Light'],
|
|
74
|
+
['Light', 'Dark'],
|
|
75
|
+
['Dark', 'Auto'],
|
|
76
|
+
]) {
|
|
77
|
+
const button = screen.getByRole('button', { name: `Theme: ${now} — click for ${next}` });
|
|
78
|
+
expect(button.querySelector('svg')).not.toBeNull();
|
|
79
|
+
expect(button).toHaveTextContent('');
|
|
80
|
+
await user.click(button);
|
|
71
81
|
}
|
|
82
|
+
expect(screen.getByRole('button', { name: /^Theme: Auto/ })).toBeInTheDocument();
|
|
72
83
|
});
|
|
73
84
|
|
|
74
|
-
it('
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
85
|
+
it('puts the theme switch after the header content a consumer passes', () => {
|
|
86
|
+
render(
|
|
87
|
+
<Lab instruments={[Stub]} defaultInstrument="Stub">
|
|
88
|
+
<button type="button">Consumer</button>
|
|
89
|
+
</Lab>,
|
|
90
|
+
);
|
|
91
|
+
const consumer = screen.getByRole('button', { name: 'Consumer' });
|
|
92
|
+
const theme = screen.getByRole('button', { name: /^Theme: / });
|
|
93
|
+
expect(consumer.compareDocumentPosition(theme) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
84
94
|
});
|
|
85
95
|
});
|
package/src/lab/LabHeader.tsx
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { AddIcon } from '@weasel-js/ui';
|
|
2
|
-
import {
|
|
2
|
+
import { MenuButton, ThemeSwitcher } from '../passthrough/weasel-ui';
|
|
3
3
|
import { useLabContext } from './LabContext';
|
|
4
4
|
|
|
5
|
-
/** The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* content, which still lands beside them. */
|
|
5
|
+
/** The control `<Lab>` puts at the start of its header: add a trial. It drives
|
|
6
|
+
* `LabContext`, which carried it with no UI at all — so every consumer rebuilt
|
|
7
|
+
* it. Rendered before a consumer's own header content. */
|
|
9
8
|
export function LabHeader({ addTrial = true }: { addTrial?: boolean }) {
|
|
10
9
|
const lab = useLabContext();
|
|
11
10
|
const only = lab.instruments.length === 1 ? lab.instruments[0] : null;
|
|
@@ -29,13 +28,13 @@ export function LabHeader({ addTrial = true }: { addTrial?: boolean }) {
|
|
|
29
28
|
onAction={(name) => lab.addTrial(name)}
|
|
30
29
|
/>
|
|
31
30
|
)}
|
|
32
|
-
|
|
33
|
-
<ColorModeControl
|
|
34
|
-
className="lk-lab-header__mode"
|
|
35
|
-
variant="flat"
|
|
36
|
-
value={lab.mode}
|
|
37
|
-
onChange={lab.setMode}
|
|
38
|
-
/>
|
|
39
31
|
</>
|
|
40
32
|
);
|
|
41
33
|
}
|
|
34
|
+
|
|
35
|
+
/** The lab's color-mode switch, which `<Lab>` puts at the end of its header,
|
|
36
|
+
* after a consumer's own header content. */
|
|
37
|
+
export function LabThemeSwitcher() {
|
|
38
|
+
const lab = useLabContext();
|
|
39
|
+
return <ThemeSwitcher value={lab.mode} onChange={lab.setMode} />;
|
|
40
|
+
}
|
package/src/lab/LabShell.less
CHANGED
|
@@ -155,21 +155,12 @@
|
|
|
155
155
|
align-self: center;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// A consumer's own header content shares this row and does not shrink, so
|
|
167
|
-
// without this the segments compress past their labels and the pill track
|
|
168
|
-
// disappears entirely.
|
|
169
|
-
flex-shrink: 0;
|
|
170
|
-
|
|
171
|
-
&:hover,
|
|
172
|
-
&:focus-within {
|
|
173
|
-
opacity: 1;
|
|
174
|
-
}
|
|
158
|
+
// The readout's digit count changes as the zoom does (80% to 125%), so it sits
|
|
159
|
+
// in a cell sized for the widest value the default range reaches, and the
|
|
160
|
+
// buttons either side of it stay put.
|
|
161
|
+
.lk-lab-zoom__readout {
|
|
162
|
+
display: inline-block;
|
|
163
|
+
min-width: 5ch;
|
|
164
|
+
text-align: center;
|
|
165
|
+
font-variant-numeric: tabular-nums;
|
|
175
166
|
}
|
package/src/lab/LabSwitcher.less
CHANGED
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
&:hover { color: var(--wzl-accent-fg); }
|
|
30
30
|
|
|
31
31
|
&:focus-visible {
|
|
32
|
-
outline: 2px solid var(--wzl-focus-ring
|
|
32
|
+
outline: 2px solid var(--wzl-focus-ring);
|
|
33
33
|
outline-offset: 2px;
|
|
34
|
-
border-radius: var(--wzl-radius-sm
|
|
34
|
+
border-radius: var(--wzl-radius-sm);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -64,22 +64,22 @@
|
|
|
64
64
|
// surface, the way `FloatingPanel` does.
|
|
65
65
|
background: var(--wzl-surface);
|
|
66
66
|
border: 1px solid var(--wzl-border);
|
|
67
|
-
border-radius: var(--wzl-radius-md
|
|
67
|
+
border-radius: var(--wzl-radius-md);
|
|
68
68
|
box-shadow: 0 6px 20px var(--wzl-shadow);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
.lk-switcher-item {
|
|
72
72
|
padding: var(--wzl-space-xs) var(--wzl-space-sm);
|
|
73
|
-
border-radius: var(--wzl-radius-sm
|
|
73
|
+
border-radius: var(--wzl-radius-sm);
|
|
74
74
|
color: var(--wzl-fg);
|
|
75
75
|
font-size: var(--wzl-font-size);
|
|
76
76
|
text-decoration: none;
|
|
77
77
|
white-space: nowrap;
|
|
78
78
|
|
|
79
|
-
&:hover { background: var(--wzl-surface-hover
|
|
79
|
+
&:hover { background: var(--wzl-surface-hover); }
|
|
80
80
|
|
|
81
81
|
&:focus-visible {
|
|
82
|
-
outline: 2px solid var(--wzl-focus-ring
|
|
82
|
+
outline: 2px solid var(--wzl-focus-ring);
|
|
83
83
|
outline-offset: -2px;
|
|
84
84
|
}
|
|
85
85
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
|
+
import {
|
|
3
|
+
ActionsProvider,
|
|
4
|
+
type ActionsRegistry,
|
|
5
|
+
ActiveToolContextProvider,
|
|
6
|
+
DepRegistryProvider,
|
|
7
|
+
type Tool,
|
|
8
|
+
useActionsRegistry,
|
|
9
|
+
useGestureDispatcher,
|
|
10
|
+
} from '@weasel-js/core';
|
|
11
|
+
import { type ReactNode, useEffect } from 'react';
|
|
12
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
14
|
+
import { Lab } from './Lab';
|
|
15
|
+
import { LabContext, type LabContextValue } from './LabContext';
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
19
|
+
() => null,
|
|
20
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const staged = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
24
|
+
name: 'Staged',
|
|
25
|
+
defaultConfig: () => ({}),
|
|
26
|
+
initialState: () => ({}),
|
|
27
|
+
render: () => <div data-testid="art" />,
|
|
28
|
+
stage: {
|
|
29
|
+
size: { width: 240, height: 160 },
|
|
30
|
+
initialView: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
31
|
+
maxZoom: 1.5,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const plain = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
36
|
+
name: 'Plain',
|
|
37
|
+
defaultConfig: () => ({}),
|
|
38
|
+
initialState: () => ({}),
|
|
39
|
+
render: () => <div data-testid="plain" />,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
let lab: LabContextValue | null = null;
|
|
43
|
+
function Capture({ children }: { children?: ReactNode }) {
|
|
44
|
+
return (
|
|
45
|
+
<LabContext.Consumer>
|
|
46
|
+
{(value) => {
|
|
47
|
+
lab = value;
|
|
48
|
+
return children ?? null;
|
|
49
|
+
}}
|
|
50
|
+
</LabContext.Consumer>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const zoomBar = () => within(screen.getByRole('toolbar', { name: 'Zoom' }));
|
|
55
|
+
const readout = () => zoomBar().getByRole('button', { name: /^Zoom \d+%|^Zoom unavailable/ });
|
|
56
|
+
const press = (name: string) =>
|
|
57
|
+
act(() => {
|
|
58
|
+
fireEvent.click(zoomBar().getByRole('button', { name }));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const MOD = /mac/i.test(navigator.platform || navigator.userAgent)
|
|
62
|
+
? { metaKey: true }
|
|
63
|
+
: { ctrlKey: true };
|
|
64
|
+
function key(k: string): boolean {
|
|
65
|
+
let prevented = false;
|
|
66
|
+
act(() => {
|
|
67
|
+
prevented = !fireEvent.keyDown(window, { key: k, ...MOD });
|
|
68
|
+
});
|
|
69
|
+
return prevented;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
|
|
73
|
+
|
|
74
|
+
/** A story's own dispatcher, binding Mod+= the way a `<SceneCanvas>` with keyboard zoom does. */
|
|
75
|
+
function StoryZoomKey({ run }: { run: () => void }) {
|
|
76
|
+
function Bound() {
|
|
77
|
+
const registry = useActionsRegistry() as ActionsRegistry;
|
|
78
|
+
useEffect(
|
|
79
|
+
() =>
|
|
80
|
+
registry.register({
|
|
81
|
+
id: 'story.zoom',
|
|
82
|
+
label: 'Story zoom',
|
|
83
|
+
defaultBinding: { kind: 'key', key: '=', mods: { mod: true } },
|
|
84
|
+
invoker: { timing: 'immediate', run: () => run() },
|
|
85
|
+
}),
|
|
86
|
+
[registry],
|
|
87
|
+
);
|
|
88
|
+
useGestureDispatcher({
|
|
89
|
+
canvasRef: { current: null },
|
|
90
|
+
actions: registry,
|
|
91
|
+
entriesById: NO_TOOLS,
|
|
92
|
+
channels: { contextMenu: false, ingest: false },
|
|
93
|
+
});
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return (
|
|
97
|
+
<DepRegistryProvider>
|
|
98
|
+
<ActionsProvider>
|
|
99
|
+
<ActiveToolContextProvider>
|
|
100
|
+
<Bound />
|
|
101
|
+
</ActiveToolContextProvider>
|
|
102
|
+
</ActionsProvider>
|
|
103
|
+
</DepRegistryProvider>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stageZoom(container: HTMLElement, index = 0): number {
|
|
108
|
+
const el = container.querySelectorAll<HTMLElement>('.lk-stage__content')[index];
|
|
109
|
+
if (!el) throw new Error('no stage content');
|
|
110
|
+
return Number(el.style.getPropertyValue('--lk-stage-zoom'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("the lab header's zoom controls", () => {
|
|
114
|
+
it('step the focused trial through its camera and read its zoom back', () => {
|
|
115
|
+
const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
|
|
116
|
+
expect(readout()).toHaveTextContent('100%');
|
|
117
|
+
press('Zoom in');
|
|
118
|
+
expect(stageZoom(container)).toBeCloseTo(1.25);
|
|
119
|
+
expect(readout()).toHaveTextContent('125%');
|
|
120
|
+
press('Zoom out');
|
|
121
|
+
expect(stageZoom(container)).toBeCloseTo(1);
|
|
122
|
+
press('Zoom out');
|
|
123
|
+
expect(readout()).toHaveTextContent('80%');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('reset to actual size from the readout', () => {
|
|
127
|
+
const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
|
|
128
|
+
press('Zoom out');
|
|
129
|
+
press('Zoom out');
|
|
130
|
+
act(() => {
|
|
131
|
+
fireEvent.click(readout());
|
|
132
|
+
});
|
|
133
|
+
expect(stageZoom(container)).toBe(1);
|
|
134
|
+
expect(readout()).toHaveTextContent('100%');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("stop at the camera's own limit, and say so", () => {
|
|
138
|
+
const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
|
|
139
|
+
press('Zoom in');
|
|
140
|
+
press('Zoom in');
|
|
141
|
+
expect(stageZoom(container)).toBe(1.5);
|
|
142
|
+
expect(zoomBar().getByRole('button', { name: 'Zoom in' })).toBeDisabled();
|
|
143
|
+
expect(zoomBar().getByRole('button', { name: 'Zoom out' })).toBeEnabled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('answer Mod+=, Mod+- and Mod+0, claiming the key from the browser', () => {
|
|
147
|
+
const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
|
|
148
|
+
expect(key('=')).toBe(true);
|
|
149
|
+
expect(stageZoom(container)).toBeCloseTo(1.25);
|
|
150
|
+
key('-');
|
|
151
|
+
key('-');
|
|
152
|
+
expect(stageZoom(container)).toBeCloseTo(0.8);
|
|
153
|
+
key('0');
|
|
154
|
+
expect(stageZoom(container)).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('go inert over a trial with no camera, and leave its keys to the browser', () => {
|
|
158
|
+
render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
|
|
159
|
+
for (const name of ['Zoom out', 'Zoom in']) {
|
|
160
|
+
expect(zoomBar().getByRole('button', { name })).toBeDisabled();
|
|
161
|
+
}
|
|
162
|
+
expect(readout()).toBeDisabled();
|
|
163
|
+
expect(readout()).toHaveTextContent('–');
|
|
164
|
+
expect(key('=')).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("take the key ahead of a story's own binding in a trial with a camera", () => {
|
|
168
|
+
const story = vi.fn();
|
|
169
|
+
const zooming = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
170
|
+
...staged,
|
|
171
|
+
name: 'Zooming',
|
|
172
|
+
render: () => <StoryZoomKey run={story} />,
|
|
173
|
+
});
|
|
174
|
+
const { container } = render(<Lab instruments={[zooming]} defaultInstrument="Zooming" />);
|
|
175
|
+
key('=');
|
|
176
|
+
expect(stageZoom(container)).toBeCloseTo(1.25);
|
|
177
|
+
expect(story).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("leave the key to a story's own binding in a trial with no camera", () => {
|
|
181
|
+
const story = vi.fn();
|
|
182
|
+
const zooming = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
183
|
+
...plain,
|
|
184
|
+
name: 'ZoomingPlain',
|
|
185
|
+
render: () => <StoryZoomKey run={story} />,
|
|
186
|
+
});
|
|
187
|
+
render(<Lab instruments={[zooming]} defaultInstrument="ZoomingPlain" />);
|
|
188
|
+
key('=');
|
|
189
|
+
expect(story).toHaveBeenCalledTimes(1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('follow the focus from trial to trial', () => {
|
|
193
|
+
const { container } = render(
|
|
194
|
+
<Lab instruments={[staged, plain]} defaultInstrument="Staged">
|
|
195
|
+
<Capture />
|
|
196
|
+
</Lab>,
|
|
197
|
+
);
|
|
198
|
+
act(() => lab?.addTrial('Staged'));
|
|
199
|
+
act(() => lab?.addTrial('Plain'));
|
|
200
|
+
const [first, second, third] = lab?.trials.map((t) => t.id) ?? [];
|
|
201
|
+
expect(zoomBar().getByRole('button', { name: 'Zoom in' })).toBeDisabled();
|
|
202
|
+
|
|
203
|
+
act(() => lab?.focusTrial(second ?? ''));
|
|
204
|
+
press('Zoom out');
|
|
205
|
+
expect(stageZoom(container, 1)).toBeCloseTo(0.8);
|
|
206
|
+
expect(stageZoom(container, 0)).toBe(1);
|
|
207
|
+
expect(readout()).toHaveTextContent('80%');
|
|
208
|
+
|
|
209
|
+
act(() => lab?.focusTrial(first ?? ''));
|
|
210
|
+
expect(readout()).toHaveTextContent('100%');
|
|
211
|
+
key('=');
|
|
212
|
+
expect(stageZoom(container, 0)).toBeCloseTo(1.25);
|
|
213
|
+
expect(stageZoom(container, 1)).toBeCloseTo(0.8);
|
|
214
|
+
|
|
215
|
+
act(() => lab?.focusTrial(third ?? ''));
|
|
216
|
+
expect(readout()).toBeDisabled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('are left out when the lab says so', () => {
|
|
220
|
+
render(<Lab instruments={[staged]} defaultInstrument="Staged" zoom={false} />);
|
|
221
|
+
expect(screen.queryByRole('toolbar', { name: 'Zoom' })).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Action,
|
|
3
|
+
ActionDisabledReason,
|
|
4
|
+
ActionsProvider,
|
|
5
|
+
type ActionsRegistry,
|
|
6
|
+
ActiveToolContextProvider,
|
|
7
|
+
DepRegistryProvider,
|
|
8
|
+
makeViewportZoomAction,
|
|
9
|
+
type Tool,
|
|
10
|
+
useActionsRegistry,
|
|
11
|
+
useDepSource,
|
|
12
|
+
useGestureDispatcher,
|
|
13
|
+
type ViewApi,
|
|
14
|
+
} from '@weasel-js/core';
|
|
15
|
+
import { ZoomInIcon, ZoomOutIcon } from '@weasel-js/ui';
|
|
16
|
+
import { useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
17
|
+
import { useStore } from 'zustand/react';
|
|
18
|
+
import type { CameraView } from '../canvas/CameraInput';
|
|
19
|
+
import { CameraRegistryContext } from '../canvas/cameraRegistry';
|
|
20
|
+
import { atScale, viewMiddle } from '../canvas/cameraZoom';
|
|
21
|
+
import { formatShortcut } from '../passthrough/weasel-ui';
|
|
22
|
+
import { Toolbar } from '../primitives/Toolbar';
|
|
23
|
+
import { LabStoreContext } from '../state/context';
|
|
24
|
+
import { as2DView } from '../state/view';
|
|
25
|
+
import { useLabContext } from './LabContext';
|
|
26
|
+
|
|
27
|
+
const ZOOM_ID = 'viewport.zoom';
|
|
28
|
+
const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
|
|
29
|
+
const CHANNELS = { contextMenu: false, ingest: false } as const;
|
|
30
|
+
const NO_ELEMENT = { current: null };
|
|
31
|
+
|
|
32
|
+
type ZoomKind = 'in' | 'out' | 'reset';
|
|
33
|
+
|
|
34
|
+
interface KeyBinding {
|
|
35
|
+
spec: { kind: string; key?: string; mods?: { mod?: boolean; shift?: boolean | 'optional' } };
|
|
36
|
+
opts?: { params?: { kind?: string } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Core's zoom with only its keys: the wheel and pinch belong to each trial's
|
|
40
|
+
// own dispatcher. The clamp is left to the camera, which knows its range.
|
|
41
|
+
const BASE = makeViewportZoomAction({ min: 1e-9, max: Number.POSITIVE_INFINITY });
|
|
42
|
+
const KEY_BINDINGS = (BASE.defaultBinding as KeyBinding[]).filter((b) => b.spec.kind === 'key');
|
|
43
|
+
|
|
44
|
+
function shortcutFor(kind: ZoomKind): string | undefined {
|
|
45
|
+
const spec = KEY_BINDINGS.find((b) => b.opts?.params?.kind === kind)?.spec;
|
|
46
|
+
return spec?.key ? formatShortcut({ key: spec.key, mod: spec.mods?.mod }) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The focused trial's camera, with the zoom's reset meaning actual size about
|
|
50
|
+
* the middle of the view. */
|
|
51
|
+
function forZoom(camera: CameraView): ViewApi {
|
|
52
|
+
return {
|
|
53
|
+
get: camera.get,
|
|
54
|
+
set: camera.set,
|
|
55
|
+
hostSize: camera.hostSize,
|
|
56
|
+
recenter: () => atScale(camera.get(), 1, viewMiddle(camera)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const percent = new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 0 });
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Zoom out, the zoom as a percentage, and zoom in, for whichever trial has the
|
|
64
|
+
* focus. The percentage resets to actual size. They route through core's
|
|
65
|
+
* `viewport.zoom` action, on a dispatcher of their own that listens for its
|
|
66
|
+
* keys — Mod+=, Mod+- and Mod+0 — so the buttons and the keys are one path.
|
|
67
|
+
* Over a trial with no camera the group stays, disabled, so the header does not
|
|
68
|
+
* reflow as the focus moves.
|
|
69
|
+
*/
|
|
70
|
+
export function LabZoom() {
|
|
71
|
+
return (
|
|
72
|
+
<DepRegistryProvider>
|
|
73
|
+
<ActionsProvider>
|
|
74
|
+
<ActiveToolContextProvider>
|
|
75
|
+
<LabZoomControls />
|
|
76
|
+
</ActiveToolContextProvider>
|
|
77
|
+
</ActionsProvider>
|
|
78
|
+
</DepRegistryProvider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function LabZoomControls() {
|
|
83
|
+
const registry = useActionsRegistry() as ActionsRegistry;
|
|
84
|
+
const { focusedTrialId } = useLabContext();
|
|
85
|
+
const cameras = useContext(CameraRegistryContext);
|
|
86
|
+
const storeCtx = useContext(LabStoreContext);
|
|
87
|
+
if (!storeCtx) throw new Error('[labkit] <LabZoom> requires <LabStoreProvider>');
|
|
88
|
+
|
|
89
|
+
const camera = useSyncExternalStore(cameras?.subscribe ?? noSubscribe, () =>
|
|
90
|
+
cameras && focusedTrialId ? cameras.get(focusedTrialId) : null,
|
|
91
|
+
);
|
|
92
|
+
const zoom = useStore(storeCtx.store, (s) => {
|
|
93
|
+
const record = s.trials.find((t) => t.id === focusedTrialId);
|
|
94
|
+
return as2DView(record?.view)?.zoom ?? null;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const live = zoom !== null && camera !== null;
|
|
98
|
+
const view = useMemo(() => (camera ? forZoom(camera) : null), [camera]);
|
|
99
|
+
const viewRef = useRef(view);
|
|
100
|
+
viewRef.current = live ? view : null;
|
|
101
|
+
|
|
102
|
+
useDepSource('view', () => viewRef.current as ViewApi);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const action: Action = {
|
|
106
|
+
...BASE,
|
|
107
|
+
defaultBinding: KEY_BINDINGS as Action['defaultBinding'],
|
|
108
|
+
enabled: () => (viewRef.current ? true : ActionDisabledReason.NotApplicable),
|
|
109
|
+
};
|
|
110
|
+
return registry.register(action);
|
|
111
|
+
}, [registry]);
|
|
112
|
+
|
|
113
|
+
// First, so a story's own zoom keys do not also fire over a trial with a
|
|
114
|
+
// camera; over one without, the action is disabled and the keys pass on.
|
|
115
|
+
useGestureDispatcher({
|
|
116
|
+
canvasRef: NO_ELEMENT,
|
|
117
|
+
actions: registry,
|
|
118
|
+
entriesById: NO_TOOLS,
|
|
119
|
+
channels: CHANNELS,
|
|
120
|
+
keyboard: 'first',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const range = camera?.zoomRange();
|
|
124
|
+
// A step that lands within rounding of a limit has reached it.
|
|
125
|
+
const canIn = zoom !== null && range !== undefined && zoom < range.max * (1 - 1e-9);
|
|
126
|
+
const canOut = zoom !== null && range !== undefined && zoom > range.min * (1 + 1e-9);
|
|
127
|
+
const run = (kind: ZoomKind) => () => {
|
|
128
|
+
registry.trigger(ZOOM_ID, { kind });
|
|
129
|
+
};
|
|
130
|
+
const hint = (label: string, kind: ZoomKind): string => {
|
|
131
|
+
const keys = shortcutFor(kind);
|
|
132
|
+
return keys ? `${label} (${keys})` : label;
|
|
133
|
+
};
|
|
134
|
+
const shown = live && zoom !== null ? percent.format(zoom) : null;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Toolbar aria-label="Zoom">
|
|
138
|
+
<Toolbar.Group aria-label="Zoom">
|
|
139
|
+
<Toolbar.Button
|
|
140
|
+
iconOnly
|
|
141
|
+
aria-label="Zoom out"
|
|
142
|
+
title={hint('Zoom out', 'out')}
|
|
143
|
+
disabled={!canOut}
|
|
144
|
+
onClick={run('out')}
|
|
145
|
+
>
|
|
146
|
+
<ZoomOutIcon size={16} />
|
|
147
|
+
</Toolbar.Button>
|
|
148
|
+
<Toolbar.Button
|
|
149
|
+
aria-label={shown ? `Zoom ${shown} — reset to actual size` : 'Zoom unavailable'}
|
|
150
|
+
title={live ? hint('Actual size', 'reset') : 'This trial has no camera'}
|
|
151
|
+
disabled={!live}
|
|
152
|
+
onClick={run('reset')}
|
|
153
|
+
>
|
|
154
|
+
<span className="lk-lab-zoom__readout">{shown ?? '–'}</span>
|
|
155
|
+
</Toolbar.Button>
|
|
156
|
+
<Toolbar.Button
|
|
157
|
+
iconOnly
|
|
158
|
+
aria-label="Zoom in"
|
|
159
|
+
title={hint('Zoom in', 'in')}
|
|
160
|
+
disabled={!canIn}
|
|
161
|
+
onClick={run('in')}
|
|
162
|
+
>
|
|
163
|
+
<ZoomInIcon size={16} />
|
|
164
|
+
</Toolbar.Button>
|
|
165
|
+
</Toolbar.Group>
|
|
166
|
+
</Toolbar>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const noSubscribe = (): (() => void) => () => {};
|
package/src/lab/index.ts
CHANGED
|
@@ -11,5 +11,6 @@ export type { LabShellProps } from './LabShell';
|
|
|
11
11
|
export { LabShell } from './LabShell';
|
|
12
12
|
export type { LabPage, LabSwitcherProps } from './LabSwitcher';
|
|
13
13
|
export { currentPage, LabSwitcher } from './LabSwitcher';
|
|
14
|
+
export { LabZoom } from './LabZoom';
|
|
14
15
|
export type { PanelDescriptor, TrialLayout, WorkspaceProps } from './Workspace';
|
|
15
16
|
export { Workspace } from './Workspace';
|
|
@@ -163,11 +163,13 @@ function Overview({
|
|
|
163
163
|
}: TrialOverviewProps & { camera: CameraContextValue }) {
|
|
164
164
|
const size = useMemo(() => ({ width, height }), [width, height]);
|
|
165
165
|
const content = bounds ? toLocalRect(bounds, camera.frame) : camera.content;
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
// `content` is rebuilt every render, so the fit keys on its numbers.
|
|
167
|
+
const framed = content !== null && content !== undefined;
|
|
168
|
+
const { x = 0, y = 0, width: w = 0, height: h = 0 } = content ?? {};
|
|
168
169
|
const fit = useMemo(
|
|
169
|
-
() =>
|
|
170
|
-
|
|
170
|
+
() =>
|
|
171
|
+
framed ? fitRect({ x, y, width: w, height: h }, size) : { x: 0, y: 0, scale: { x: 1, y: 1 } },
|
|
172
|
+
[framed, x, y, w, h, size],
|
|
171
173
|
);
|
|
172
174
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
173
175
|
|
|
@@ -341,7 +343,7 @@ function OverviewDispatch({
|
|
|
341
343
|
canvasRef: boxRef,
|
|
342
344
|
// Always present: `OverviewInput` mounts the provider directly above.
|
|
343
345
|
actions: registry as NonNullable<typeof registry>,
|
|
344
|
-
|
|
346
|
+
entriesById: TOOLS_BY_ID,
|
|
345
347
|
clientToWorld: clientToLocal,
|
|
346
348
|
keyboard: false,
|
|
347
349
|
channels: CHANNELS,
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
paintGradientTrack,
|
|
21
21
|
RadioGroup,
|
|
22
22
|
RangeSlider,
|
|
23
|
+
sampleByInterpolation,
|
|
23
24
|
Select,
|
|
24
25
|
Sidebar,
|
|
25
26
|
Slider,
|
|
@@ -38,6 +39,7 @@ describe('weasel-ui passthrough', () => {
|
|
|
38
39
|
expect(typeof useReorderDragList).toBe('function');
|
|
39
40
|
expect(typeof paintGradientTrack).toBe('function');
|
|
40
41
|
expect(typeof oklchToHex).toBe('function');
|
|
42
|
+
expect(typeof sampleByInterpolation).toBe('function');
|
|
41
43
|
expect(typeof chromaAt).toBe('function');
|
|
42
44
|
});
|
|
43
45
|
|