@weasel-js/labkit 1.5.0 → 1.5.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 +41 -4
- package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
- package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
- package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4okC2lX.d.ts} +42 -4
- package/dist/_dts/{index-retnAut7.d.ts → index-BNdz-tNd.d.ts} +90 -10
- package/dist/_dts/{index-B8uu2ba7.d.ts → index-CSJoj4LC.d.ts} +17 -6
- package/dist/_dts/{types-Ca2LCnPq.d.ts → types-DzoNY1pO.d.ts} +28 -4
- package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-Pzf9f2jh.d.ts} +2 -2
- package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-uLlmaccn.d.ts} +1 -1
- package/dist/canvas/index.d.ts +5 -5
- package/dist/canvas/index.js +1 -1
- package/dist/chrome/index.d.ts +4 -4
- package/dist/chrome/index.js +7 -7
- package/dist/chunk-7PFH3CNM.js +150 -0
- package/dist/chunk-7PFH3CNM.js.map +1 -0
- package/dist/{chunk-PGETSEDK.js → chunk-CEIUBPPW.js} +10 -6
- package/dist/chunk-CEIUBPPW.js.map +1 -0
- package/dist/chunk-EH7UEQV7.js +590 -0
- package/dist/chunk-EH7UEQV7.js.map +1 -0
- package/dist/{chunk-EKIICY6X.js → chunk-EHGVROVV.js} +37 -25
- package/dist/chunk-EHGVROVV.js.map +1 -0
- package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
- package/dist/chunk-ERFMC6WU.js.map +1 -0
- package/dist/{chunk-NS64DXMN.js → chunk-IHUVQ3TX.js} +39 -16
- package/dist/chunk-IHUVQ3TX.js.map +1 -0
- package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
- package/dist/chunk-IJJUK4FU.js.map +1 -0
- package/dist/{chunk-67SJLMC7.js → chunk-IXTQI7JX.js} +45 -12
- package/dist/chunk-IXTQI7JX.js.map +1 -0
- package/dist/{chunk-MUKOW3TC.js → chunk-MDVTK5U3.js} +4 -4
- package/dist/{chunk-MUKOW3TC.js.map → chunk-MDVTK5U3.js.map} +1 -1
- package/dist/{chunk-UU3NJO6N.js → chunk-PHK4W562.js} +3 -3
- package/dist/{chunk-UU3NJO6N.js.map → chunk-PHK4W562.js.map} +1 -1
- package/dist/chunk-PJ7WEQBJ.js +18 -0
- package/dist/chunk-PJ7WEQBJ.js.map +1 -0
- package/dist/chunk-T3EC2DXD.js +12628 -0
- package/dist/chunk-T3EC2DXD.js.map +1 -0
- package/dist/{chunk-KJALCDWE.js → chunk-UN4U4P63.js} +4 -4
- package/dist/{chunk-KJALCDWE.js.map → chunk-UN4U4P63.js.map} +1 -1
- package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/{chunk-6ZDGOZQV.js → chunk-ZDHY47EV.js} +70 -17
- package/dist/chunk-ZDHY47EV.js.map +1 -0
- package/dist/config/index.d.ts +60 -16
- package/dist/config/index.js +6 -4
- package/dist/controls/index.d.ts +3 -3
- package/dist/controls/index.js +4 -4
- package/dist/dragdrop/index.d.ts +3 -3
- package/dist/index.d.ts +177 -53
- package/dist/index.js +1333 -1683
- 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 +67 -16
- package/dist/loupe/index.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +26 -202
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +3 -1
- package/dist/primitives/index.js +3 -3
- package/dist/state/index.d.ts +5 -5
- package/dist/state/index.js +5 -3
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +153 -40
- package/dist/surface/index.js +1 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +3 -3
- package/package.json +9 -9
- package/src/annotations/AnnotationOverlay.tsx +3 -0
- package/src/annotations/Annotations.less +4 -0
- package/src/annotations/Annotations.overlay.test.tsx +20 -0
- package/src/annotations/svgNodes.ts +1 -0
- package/src/annotations/toolMap.test.ts +9 -5
- package/src/annotations/toolMap.ts +3 -0
- package/src/canvas/CanvasStack.stories.tsx +1 -1
- package/src/canvas/usePanZoom.test.ts +28 -0
- package/src/canvas/usePanZoom.ts +11 -4
- package/src/chrome/ChromeRegions.stories.tsx +1 -1
- package/src/chrome/LabChrome.tsx +21 -5
- package/src/chrome/builtins.test.ts +1 -0
- package/src/chrome/builtins.tsx +1 -0
- package/src/chrome/index.ts +1 -0
- package/src/chrome/labTypes.ts +2 -2
- package/src/chrome/regions/PaletteRegion.tsx +1 -6
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/ToolbarRegion.tsx +11 -7
- package/src/chrome/types.ts +3 -0
- package/src/config/auto.test.ts +12 -0
- package/src/config/auto.ts +15 -0
- package/src/config/autoConfig.test.ts +107 -0
- package/src/config/autoConfig.ts +153 -0
- package/src/config/builder.test.ts +38 -0
- package/src/config/builder.ts +53 -8
- package/src/config/index.ts +3 -0
- package/src/config/path.ts +7 -2
- package/src/config/resolve.test.ts +25 -0
- package/src/config/resolve.ts +17 -1
- package/src/config/types.ts +32 -2
- package/src/config/useResolvedConfig.ts +26 -0
- package/src/controls/AutoControls.stories.tsx +88 -0
- package/src/controls/ControlPanel.stories.tsx +42 -1
- package/src/controls/ControlPanel.test.tsx +514 -4
- package/src/controls/ControlPanel.tsx +557 -80
- package/src/index.ts +29 -1
- package/src/instrument/serializers.ts +29 -1
- package/src/lab/Lab.chrome.test.tsx +16 -0
- package/src/lab/Lab.stories.tsx +1 -1
- package/src/lab/Lab.surface.test.tsx +7 -2
- package/src/lab/Lab.test.tsx +79 -1
- package/src/lab/Lab.tsx +105 -13
- package/src/lab/LabContext.ts +8 -0
- package/src/lab/LabFit.stories.tsx +4 -4
- package/src/lab/LabFullChrome.stories.tsx +1 -1
- package/src/lab/LabHeader.tsx +0 -1
- package/src/lab/LabRoot.test.tsx +56 -0
- package/src/lab/LabRoot.tsx +47 -0
- package/src/lab/LabShell.less +24 -3
- package/src/lab/LabShell.stories.tsx +50 -1
- package/src/lab/LabShell.tsx +18 -27
- package/src/lab/LabSwitcher.less +10 -10
- package/src/lab/LabSwitcher.test.tsx +14 -9
- package/src/lab/LabSwitcher.tsx +8 -4
- package/src/lab/Workspace.less +1 -1
- package/src/lab/Workspace.stories.tsx +1 -1
- package/src/lab/Workspace.surface.test.tsx +1 -1
- package/src/lab/index.ts +2 -0
- package/src/layers/LayerList.stories.tsx +1 -1
- package/src/loupe/AGENTS.md +16 -4
- package/src/loupe/LoupeGestures.tsx +67 -0
- package/src/loupe/TrialLoupe.tsx +43 -28
- package/src/loupe/index.ts +4 -0
- package/src/loupe/loupeActions.ts +98 -0
- package/src/loupe/useLoupe.test.tsx +59 -2
- package/src/loupe/useLoupe.ts +28 -48
- package/src/passthrough/weasel-ui.ts +3 -3
- package/src/primitives/FloatingPanel.stories.tsx +1 -1
- package/src/primitives/FpsMeter.stories.tsx +1 -1
- package/src/primitives/JobProgress.stories.tsx +1 -1
- package/src/primitives/Legend.stories.tsx +1 -1
- package/src/primitives/ScaleIndicator.stories.tsx +1 -1
- package/src/primitives/Sidebar.less +7 -0
- package/src/primitives/Sidebar.stories.tsx +1 -1
- package/src/primitives/Split.test.tsx +25 -0
- package/src/primitives/Split.tsx +36 -20
- package/src/primitives/StatusBar.stories.tsx +1 -1
- package/src/primitives/Toolbar.stories.tsx +1 -1
- package/src/specimen/Specimen.less +95 -0
- package/src/specimen/Specimen.stories.tsx +13 -0
- package/src/specimen/Specimen.test.tsx +13 -0
- package/src/specimen/Specimen.tsx +888 -0
- package/src/state/Persistence.stories.tsx +4 -4
- package/src/state/store.test.ts +65 -0
- package/src/state/store.ts +32 -8
- package/src/state/types.ts +26 -2
- package/src/state/useTrialState.test.tsx +85 -0
- package/src/state/useTrialState.ts +9 -1
- package/src/styles.less +1 -0
- package/src/surface/index.ts +1 -1
- package/src/surface/useSurfaceTile.test.tsx +1 -1
- package/src/surface/useTiledSurface.test.tsx +65 -10
- package/src/surface/useTiledSurface.ts +23 -11
- package/src/theme/Interstellar.stories.tsx +2 -2
- package/src/theme/interstellar.test.ts +16 -5
- package/src/theme/interstellar.theme.json +180 -0
- package/src/theme/interstellar.ts +8 -5
- package/src/tools/labTool.ts +3 -3
- package/src/trial/Trial.annotations.test.tsx +13 -4
- package/src/trial/Trial.canvas.test.tsx +32 -0
- package/src/trial/Trial.config.test.tsx +75 -2
- package/src/trial/Trial.less +4 -0
- package/src/trial/Trial.stories.tsx +4 -1
- package/src/trial/Trial.test.tsx +3 -0
- package/src/trial/Trial.tsx +21 -14
- package/src/trial/TrialChrome.tsx +17 -3
- package/src/trial/trialOps.test.ts +101 -1
- package/src/trial/trialOps.ts +62 -4
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
- package/dist/chunk-67SJLMC7.js.map +0 -1
- package/dist/chunk-6ZDGOZQV.js.map +0 -1
- package/dist/chunk-E44SU6XS.js +0 -50
- package/dist/chunk-E44SU6XS.js.map +0 -1
- package/dist/chunk-EKIICY6X.js.map +0 -1
- package/dist/chunk-NDRYLVOW.js.map +0 -1
- package/dist/chunk-NS64DXMN.js.map +0 -1
- package/dist/chunk-PGETSEDK.js.map +0 -1
- package/dist/chunk-QLSV2N3G.js.map +0 -1
- package/dist/chunk-S2SHZRD7.js +0 -7681
- package/dist/chunk-S2SHZRD7.js.map +0 -1
- package/dist/chunk-TQBBYTAF.js +0 -280
- package/dist/chunk-TQBBYTAF.js.map +0 -1
- package/dist/chunk-ZPH5WTWF.js.map +0 -1
- package/src/theme/interstellar.tokens.json +0 -61
package/src/lab/LabShell.less
CHANGED
|
@@ -39,12 +39,24 @@
|
|
|
39
39
|
.lk-shell-header-actions {
|
|
40
40
|
margin-left: auto;
|
|
41
41
|
display: flex;
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
42
|
+
// Every control here runs at the one control height and shares a middle.
|
|
43
|
+
// The row still hands the header its first item's baseline, which is what
|
|
44
|
+
// lines the controls' labels up with the title.
|
|
45
|
+
align-items: center;
|
|
45
46
|
gap: var(--wzl-space-sm);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// A toolbar dropped into the header is a run of header controls, not a strip of
|
|
50
|
+
// its own: it takes the header's control height rather than the compact one
|
|
51
|
+
// trial toolbars use, and none of the strip's box.
|
|
52
|
+
.lk-shell-header-actions .lk-toolbar {
|
|
53
|
+
--wzl-control-h: inherit;
|
|
54
|
+
min-height: 0;
|
|
55
|
+
padding: 0;
|
|
56
|
+
background: none;
|
|
57
|
+
border: 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
.lk-shell-body {
|
|
49
61
|
display: flex;
|
|
50
62
|
flex-direction: column;
|
|
@@ -81,6 +93,15 @@
|
|
|
81
93
|
border-right: 1px solid var(--wzl-border);
|
|
82
94
|
}
|
|
83
95
|
|
|
96
|
+
.lk-lab__aside {
|
|
97
|
+
display: flex;
|
|
98
|
+
flex-direction: column;
|
|
99
|
+
height: 100%;
|
|
100
|
+
overflow-y: auto;
|
|
101
|
+
background: var(--wzl-surface);
|
|
102
|
+
border-left: 1px solid var(--wzl-border);
|
|
103
|
+
}
|
|
104
|
+
|
|
84
105
|
.lk-lab__main {
|
|
85
106
|
display: flex;
|
|
86
107
|
min-width: 0;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@
|
|
1
|
+
import type { Meta, StoryObj } from '@weasel-js/forge';
|
|
2
|
+
import { expect, within } from '@weasel-js/forge/play';
|
|
3
|
+
import { Select, ToggleBar } from '../passthrough/weasel-ui';
|
|
4
|
+
import { Toolbar } from '../primitives/Toolbar';
|
|
2
5
|
import { LabShell } from './LabShell';
|
|
3
6
|
|
|
4
7
|
const meta: Meta<typeof LabShell> = {
|
|
@@ -37,3 +40,49 @@ export const WithFooter: Story = {
|
|
|
37
40
|
footer: <span>Status: ready</span>,
|
|
38
41
|
},
|
|
39
42
|
};
|
|
43
|
+
|
|
44
|
+
// jsdom has no layout, so this runs in the browser story projects.
|
|
45
|
+
export const HeaderControlsLineUp: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
title: 'My Lab',
|
|
48
|
+
header: (
|
|
49
|
+
<>
|
|
50
|
+
<button type="button">Add</button>
|
|
51
|
+
<ToggleBar
|
|
52
|
+
ariaLabel="Mode"
|
|
53
|
+
items={[
|
|
54
|
+
{ value: 'a', label: 'A' },
|
|
55
|
+
{ value: 'b', label: 'B' },
|
|
56
|
+
]}
|
|
57
|
+
value="a"
|
|
58
|
+
onChange={() => {}}
|
|
59
|
+
/>
|
|
60
|
+
<Select
|
|
61
|
+
aria-label="Font"
|
|
62
|
+
width="fit"
|
|
63
|
+
selectedKey="oswald"
|
|
64
|
+
options={[{ value: 'oswald', label: 'Oswald' }]}
|
|
65
|
+
/>
|
|
66
|
+
<Toolbar aria-label="Actions">
|
|
67
|
+
<Toolbar.Group>
|
|
68
|
+
<Toolbar.Button onClick={() => {}}>Undo</Toolbar.Button>
|
|
69
|
+
</Toolbar.Group>
|
|
70
|
+
</Toolbar>
|
|
71
|
+
</>
|
|
72
|
+
),
|
|
73
|
+
children: <p>Body content.</p>,
|
|
74
|
+
},
|
|
75
|
+
play: async ({ canvasElement }) => {
|
|
76
|
+
const canvas = within(canvasElement);
|
|
77
|
+
const boxes = [
|
|
78
|
+
canvas.getByRole('button', { name: 'Add' }),
|
|
79
|
+
canvas.getByRole('radiogroup', { name: 'Mode' }),
|
|
80
|
+
canvas.getByRole('button', { name: /Font/ }),
|
|
81
|
+
canvas.getByRole('button', { name: 'Undo' }),
|
|
82
|
+
].map((el) => el.getBoundingClientRect());
|
|
83
|
+
const heights = boxes.map((box) => Math.round(box.height * 2) / 2);
|
|
84
|
+
const middles = boxes.map((box) => Math.round((box.top + box.height / 2) * 2) / 2);
|
|
85
|
+
await expect(heights).toEqual(heights.map(() => heights[0]));
|
|
86
|
+
await expect(middles).toEqual(middles.map(() => middles[0]));
|
|
87
|
+
},
|
|
88
|
+
};
|
package/src/lab/LabShell.tsx
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
|
|
2
1
|
import type { ReactNode } from 'react';
|
|
3
2
|
import type { LabMode } from '../state/types';
|
|
4
|
-
import {
|
|
3
|
+
import { LabRoot } from './LabRoot';
|
|
5
4
|
import type { LabPage } from './LabSwitcher';
|
|
6
5
|
import { LabSwitcher } from './LabSwitcher';
|
|
7
|
-
import { useResolvedMode } from './useSystemMode';
|
|
8
6
|
|
|
9
7
|
/** Props for `<LabShell>`. */
|
|
10
8
|
export interface LabShellProps {
|
|
@@ -27,34 +25,27 @@ export interface LabShellProps {
|
|
|
27
25
|
/** Page frame for a lab: a titled header, a body, and an optional footer,
|
|
28
26
|
* themed for the resolved color mode. Presentational only — use `<Lab>` when
|
|
29
27
|
* the trial runtime is wanted too. */
|
|
30
|
-
export function LabShell({
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
export function LabShell({
|
|
29
|
+
title,
|
|
30
|
+
children,
|
|
31
|
+
header,
|
|
32
|
+
footer,
|
|
33
|
+
mode = 'auto',
|
|
34
|
+
pages,
|
|
35
|
+
path,
|
|
36
|
+
}: LabShellProps) {
|
|
37
|
+
return (
|
|
38
|
+
<LabRoot mode={mode} className="lk-shell">
|
|
39
39
|
<header className="lk-shell-header">
|
|
40
|
-
{pages && pages.length > 0
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
{pages && pages.length > 0 ? (
|
|
41
|
+
<LabSwitcher title={title} pages={pages} path={path} className="lk-shell-title" />
|
|
42
|
+
) : (
|
|
43
|
+
<h1 className="lk-shell-title">{title}</h1>
|
|
44
|
+
)}
|
|
44
45
|
{header && <div className="lk-shell-header-actions">{header}</div>}
|
|
45
46
|
</header>
|
|
46
47
|
<main className="lk-shell-body">{children}</main>
|
|
47
48
|
{footer && <footer className="lk-shell-footer">{footer}</footer>}
|
|
48
|
-
</
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
// Inside <Lab> the theme is already applied on `.lk-lab`; wrapping again
|
|
52
|
-
// would only add a div.
|
|
53
|
-
return outer ? (
|
|
54
|
-
shell
|
|
55
|
-
) : (
|
|
56
|
-
<ThemeProvider theme={interstellarTheme} mode={resolved}>
|
|
57
|
-
{shell}
|
|
58
|
-
</ThemeProvider>
|
|
49
|
+
</LabRoot>
|
|
59
50
|
);
|
|
60
51
|
}
|
package/src/lab/LabSwitcher.less
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
.lk-switcher-title {
|
|
7
7
|
margin: 0;
|
|
8
8
|
font-size: var(--wzl-font-size-xl);
|
|
9
|
-
line-height: var(--wzl-
|
|
9
|
+
line-height: var(--wzl-leading-snug);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
.lk-switcher-trigger {
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
// hyphen and the control becomes three ragged lines with a caret adrift.
|
|
27
27
|
white-space: nowrap;
|
|
28
28
|
|
|
29
|
-
&:hover { color: var(--wzl-
|
|
29
|
+
&:hover { color: var(--wzl-accent); }
|
|
30
30
|
|
|
31
31
|
&:focus-visible {
|
|
32
32
|
outline: 2px solid var(--wzl-focus-ring, currentColor);
|
|
33
33
|
outline-offset: 2px;
|
|
34
|
-
border-radius: var(--wzl-radius-sm,
|
|
34
|
+
border-radius: var(--wzl-radius-sm, 3px);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
position: absolute;
|
|
55
55
|
top: calc(100% + var(--wzl-space-xs));
|
|
56
56
|
left: 0;
|
|
57
|
-
z-index: var(--wzl-z-
|
|
57
|
+
z-index: var(--wzl-z-overlay);
|
|
58
58
|
min-width: max(100%, 11rem);
|
|
59
59
|
display: flex;
|
|
60
60
|
flex-direction: column;
|
|
@@ -64,15 +64,15 @@
|
|
|
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,
|
|
68
|
-
box-shadow:
|
|
67
|
+
border-radius: var(--wzl-radius-md, 5px);
|
|
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
73
|
border-radius: var(--wzl-radius-sm, 3px);
|
|
74
|
-
color: var(--wzl-
|
|
75
|
-
font-size: var(--wzl-font-size
|
|
74
|
+
color: var(--wzl-fg);
|
|
75
|
+
font-size: var(--wzl-font-size);
|
|
76
76
|
text-decoration: none;
|
|
77
77
|
white-space: nowrap;
|
|
78
78
|
|
|
@@ -87,6 +87,6 @@
|
|
|
87
87
|
// The open page stays in the list rather than being filtered out: a menu that
|
|
88
88
|
// drops its current entry shifts every other one as you move between pages.
|
|
89
89
|
.lk-switcher-item[aria-current='page'] {
|
|
90
|
-
color: var(--wzl-
|
|
91
|
-
font-weight: var(--wzl-font-weight-
|
|
90
|
+
color: var(--wzl-fg);
|
|
91
|
+
font-weight: var(--wzl-font-weight-bold);
|
|
92
92
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { currentPage, LabSwitcher } from './LabSwitcher';
|
|
4
3
|
import { LabShell } from './LabShell';
|
|
4
|
+
import { currentPage, LabSwitcher } from './LabSwitcher';
|
|
5
5
|
|
|
6
6
|
const PAGES = [
|
|
7
7
|
{ href: '/corpus', label: 'Wall' },
|
|
@@ -19,8 +19,7 @@ describe('currentPage', () => {
|
|
|
19
19
|
it('survives a query string, a trailing slash and a leftover extension', () => {
|
|
20
20
|
// A dev server maps `/stats` to `stats.html` and serves both spellings, so
|
|
21
21
|
// an old bookmark must still resolve to the same page.
|
|
22
|
-
for (const path of ['/stats?kind=base', '/stats/', '/stats.html',
|
|
23
|
-
'/lab/stats.html#top']) {
|
|
22
|
+
for (const path of ['/stats?kind=base', '/stats/', '/stats.html', '/lab/stats.html#top']) {
|
|
24
23
|
expect(currentPage(path, PAGES)).toBe(1);
|
|
25
24
|
}
|
|
26
25
|
});
|
|
@@ -34,7 +33,9 @@ describe('<LabSwitcher>', () => {
|
|
|
34
33
|
it('shows the title and nothing else until it is opened', () => {
|
|
35
34
|
render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
|
|
36
35
|
expect(screen.getByRole('button', { name: /corpus stats/ })).toHaveAttribute(
|
|
37
|
-
'aria-expanded',
|
|
36
|
+
'aria-expanded',
|
|
37
|
+
'false',
|
|
38
|
+
);
|
|
38
39
|
expect(screen.queryByRole('menu')).toBeNull();
|
|
39
40
|
});
|
|
40
41
|
|
|
@@ -54,8 +55,7 @@ describe('<LabSwitcher>', () => {
|
|
|
54
55
|
render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
|
|
55
56
|
open();
|
|
56
57
|
expect(screen.getByRole('menuitem', { name: 'Wall' }).tagName).toBe('A');
|
|
57
|
-
expect(screen.getByRole('menuitem', { name: 'Wall' })).toHaveAttribute(
|
|
58
|
-
'href', '/corpus');
|
|
58
|
+
expect(screen.getByRole('menuitem', { name: 'Wall' })).toHaveAttribute('href', '/corpus');
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it('closes on Escape and on a press outside it', () => {
|
|
@@ -63,7 +63,8 @@ describe('<LabSwitcher>', () => {
|
|
|
63
63
|
<div>
|
|
64
64
|
<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />
|
|
65
65
|
<button type="button">elsewhere</button>
|
|
66
|
-
</div
|
|
66
|
+
</div>,
|
|
67
|
+
);
|
|
67
68
|
open();
|
|
68
69
|
fireEvent.keyDown(document, { key: 'Escape' });
|
|
69
70
|
expect(screen.queryByRole('menu')).toBeNull();
|
|
@@ -76,7 +77,7 @@ describe('<LabSwitcher>', () => {
|
|
|
76
77
|
it('stays a plain heading when there is nowhere to switch to', () => {
|
|
77
78
|
// A disclosure arrow promising a menu of the page you are already on is
|
|
78
79
|
// worse than no control.
|
|
79
|
-
render(<LabSwitcher title="corpus stats" pages={
|
|
80
|
+
render(<LabSwitcher title="corpus stats" pages={PAGES.slice(1, 2)} path="/stats" />);
|
|
80
81
|
expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
|
|
81
82
|
expect(screen.queryByRole('button')).toBeNull();
|
|
82
83
|
});
|
|
@@ -84,7 +85,11 @@ describe('<LabSwitcher>', () => {
|
|
|
84
85
|
|
|
85
86
|
describe('<LabShell> with pages', () => {
|
|
86
87
|
it('turns its title into the switcher', () => {
|
|
87
|
-
render(
|
|
88
|
+
render(
|
|
89
|
+
<LabShell title="corpus stats" pages={PAGES} path="/stats">
|
|
90
|
+
body
|
|
91
|
+
</LabShell>,
|
|
92
|
+
);
|
|
88
93
|
expect(screen.getByRole('button', { name: /corpus stats/ })).toBeInTheDocument();
|
|
89
94
|
});
|
|
90
95
|
|
package/src/lab/LabSwitcher.tsx
CHANGED
|
@@ -25,8 +25,10 @@ export interface LabSwitcherProps {
|
|
|
25
25
|
* to `stats.html` serves both spellings, and a bookmark from before the URLs
|
|
26
26
|
* lost their extension still resolves to the same page. */
|
|
27
27
|
export function currentPage(path: string, pages: readonly LabPage[]): number {
|
|
28
|
-
const here = path
|
|
29
|
-
.replace(
|
|
28
|
+
const here = path
|
|
29
|
+
.replace(/[?#].*$/, '')
|
|
30
|
+
.replace(/\/$/, '')
|
|
31
|
+
.replace(/\.html$/, '');
|
|
30
32
|
return pages.findIndex((p) => here.endsWith(p.href.replace(/\.html$/, '')));
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -44,8 +46,10 @@ export function LabSwitcher({ title, pages, path, className }: LabSwitcherProps)
|
|
|
44
46
|
const [open, setOpen] = useState(false);
|
|
45
47
|
const root = useRef<HTMLDivElement>(null);
|
|
46
48
|
const menuId = useId();
|
|
47
|
-
const here = currentPage(
|
|
48
|
-
? '' : window.location.pathname),
|
|
49
|
+
const here = currentPage(
|
|
50
|
+
path ?? (typeof window === 'undefined' ? '' : window.location.pathname),
|
|
51
|
+
pages,
|
|
52
|
+
);
|
|
49
53
|
|
|
50
54
|
const close = useCallback(() => setOpen(false), []);
|
|
51
55
|
|
package/src/lab/Workspace.less
CHANGED
|
@@ -11,7 +11,7 @@ function fakeHandle(): SurfaceHandle {
|
|
|
11
11
|
invalidateRects: vi.fn(),
|
|
12
12
|
registerTile: vi.fn(),
|
|
13
13
|
registerClear: vi.fn(() => () => {}),
|
|
14
|
-
|
|
14
|
+
registerPainter: vi.fn(() => () => {}),
|
|
15
15
|
containerRef: vi.fn(),
|
|
16
16
|
getContainer: vi.fn(() => null),
|
|
17
17
|
};
|
package/src/lab/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ export type { LabContextValue } from './LabContext';
|
|
|
4
4
|
export { LabContext, useLabContext } from './LabContext';
|
|
5
5
|
export type { LabPaletteProps } from './LabPalette';
|
|
6
6
|
export { LabPalette } from './LabPalette';
|
|
7
|
+
export type { LabRootProps } from './LabRoot';
|
|
8
|
+
export { LabRoot } from './LabRoot';
|
|
7
9
|
export type { LabShellProps } from './LabShell';
|
|
8
10
|
export { LabShell } from './LabShell';
|
|
9
11
|
export type { LabPage, LabSwitcherProps } from './LabSwitcher';
|
package/src/loupe/AGENTS.md
CHANGED
|
@@ -12,7 +12,9 @@ This directory binds that model to a labkit trial and draws it.
|
|
|
12
12
|
| File | Role |
|
|
13
13
|
|---|---|
|
|
14
14
|
| `types.ts` | `LoupeCapability`, and `resolveLoupe` filling in every default |
|
|
15
|
-
| `useLoupe.ts` | The model over a host element,
|
|
15
|
+
| `useLoupe.ts` | The model over a host element, and pointer aiming |
|
|
16
|
+
| `loupeActions.ts` | `loupe.peek` and `loupe.magnify`, as `Action` descriptors |
|
|
17
|
+
| `LoupeGestures.tsx` | Registers those and mounts the dispatcher on the host |
|
|
16
18
|
| `TrialLoupe.tsx` | Picks the painter and mounts the lens |
|
|
17
19
|
| `LoupeBubble.tsx` | The circular clip, positioned on the aim |
|
|
18
20
|
| `CanvasLoupe.tsx` | Painter for a `<CanvasStack>` |
|
|
@@ -44,6 +46,16 @@ StrictMode mounts / unmounts / mounts every effect — so disposing in the
|
|
|
44
46
|
cleanup leaves a magnifier that draws but silently ignores every aim. It owns
|
|
45
47
|
no resources; unmounting only reports the lens gone.
|
|
46
48
|
|
|
47
|
-
**The wheel
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
**The wheel is taken from pan/zoom by declining it, not by capturing it.**
|
|
50
|
+
`loupe.magnify`'s `enabled` returns a disabled reason while the lens is down, so
|
|
51
|
+
the dispatcher leaves the event unhandled and `usePanZoom` — a React handler on
|
|
52
|
+
the same element — sees it as usual. While the lens is up the action fires, the
|
|
53
|
+
dispatcher stops propagation, and React's root listener never runs.
|
|
54
|
+
|
|
55
|
+
**Aiming is a plain listener because a hover is not a gesture.**
|
|
56
|
+
`GESTURE_DESCRIPTORS` names no continuous-motion gesture, so `pointermove` /
|
|
57
|
+
`pointerleave` stay hand-attached in `useLoupe`. Everything else routes.
|
|
58
|
+
|
|
59
|
+
**`<LoupeGestures>` mounts outside the visibility gate.** Hold-to-peek is what
|
|
60
|
+
raises a lens that is down; gate its registration on `loupe.visible` and the
|
|
61
|
+
peek key stops working entirely.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounts the gesture dispatcher on a loupe's host and registers the loupe's
|
|
3
|
+
* actions on it, so the peek key and the wheel route the way every other
|
|
4
|
+
* weasel binding does.
|
|
5
|
+
*
|
|
6
|
+
* Three of the dispatcher's four element channels are off. Only `wheel` is
|
|
7
|
+
* wanted here; `contextMenu` suppresses the native menu unconditionally and
|
|
8
|
+
* `ingest` makes the host a file-drop target, neither of which a lab asked
|
|
9
|
+
* for by turning a magnifier on.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
type ActionsRegistry,
|
|
13
|
+
type Tool,
|
|
14
|
+
useActionsRegistry,
|
|
15
|
+
useGestureDispatcher,
|
|
16
|
+
} from '@weasel-js/core';
|
|
17
|
+
import { type RefObject, useEffect, useMemo } from 'react';
|
|
18
|
+
import { createLoupeActions, type LoupeInputApi } from './loupeActions';
|
|
19
|
+
|
|
20
|
+
/** Props for `<LoupeGestures>`. */
|
|
21
|
+
export interface LoupeGesturesProps {
|
|
22
|
+
/** The element the dispatcher listens on — the same one the lens tracks. */
|
|
23
|
+
hostRef: RefObject<HTMLElement | null>;
|
|
24
|
+
input: LoupeInputApi;
|
|
25
|
+
/** `KeyboardEvent.key` for hold-to-peek, or `null` for no peek binding. */
|
|
26
|
+
peekKey: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
|
|
30
|
+
|
|
31
|
+
const CHANNELS = { pointer: false, contextMenu: false, ingest: false } as const;
|
|
32
|
+
|
|
33
|
+
function LoupeDispatch({
|
|
34
|
+
hostRef,
|
|
35
|
+
input,
|
|
36
|
+
peekKey,
|
|
37
|
+
registry,
|
|
38
|
+
}: LoupeGesturesProps & { registry: ActionsRegistry }) {
|
|
39
|
+
const actions = useMemo(() => createLoupeActions(input, peekKey), [input, peekKey]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const offs = actions.map((a) => registry.register(a));
|
|
43
|
+
return () => {
|
|
44
|
+
for (const off of offs) off();
|
|
45
|
+
};
|
|
46
|
+
}, [registry, actions]);
|
|
47
|
+
|
|
48
|
+
useGestureDispatcher({
|
|
49
|
+
canvasRef: hostRef,
|
|
50
|
+
actions: registry,
|
|
51
|
+
toolsById: NO_TOOLS,
|
|
52
|
+
channels: CHANNELS,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The loupe's input, routed. Render it inside a `<WeaselProvider isolate>` —
|
|
60
|
+
* with no registry in scope it renders nothing rather than registering
|
|
61
|
+
* actions that could never fire.
|
|
62
|
+
*/
|
|
63
|
+
export function LoupeGestures(props: LoupeGesturesProps) {
|
|
64
|
+
const registry = useActionsRegistry();
|
|
65
|
+
if (!registry) return null;
|
|
66
|
+
return <LoupeDispatch {...props} registry={registry} />;
|
|
67
|
+
}
|
package/src/loupe/TrialLoupe.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WeaselProvider } from '@weasel-js/core';
|
|
1
2
|
import { type RefObject, useContext, useMemo } from 'react';
|
|
2
3
|
import { CanvasStackContext } from '../canvas/CanvasStackContext';
|
|
3
4
|
import type { WorldSpec } from '../canvas/worldSpec';
|
|
@@ -6,6 +7,7 @@ import { CanvasLoupe } from './CanvasLoupe';
|
|
|
6
7
|
import { sampleStack } from './canvasLens';
|
|
7
8
|
import { DomLoupe } from './DomLoupe';
|
|
8
9
|
import { LoupeBubble } from './LoupeBubble';
|
|
10
|
+
import { LoupeGestures } from './LoupeGestures';
|
|
9
11
|
import type { ResolvedLoupe } from './types';
|
|
10
12
|
import { useHostSize } from './useHostSize';
|
|
11
13
|
import { useLoupe } from './useLoupe';
|
|
@@ -59,35 +61,48 @@ export function TrialLoupe({
|
|
|
59
61
|
const measured = useHostSize(host);
|
|
60
62
|
const size = surface?.size ?? measured;
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
// The gestures mount outside the visibility gate: hold-to-peek is what
|
|
65
|
+
// raises a lens that is down, so its binding has to be live while it is.
|
|
66
|
+
// One isolated scope per loupe, for the reason `<AnnotationOverlay>` gives
|
|
67
|
+
// — an actions registry holds one dispatcher.
|
|
68
|
+
const gestures = (
|
|
69
|
+
<WeaselProvider isolate>
|
|
70
|
+
<LoupeGestures hostRef={host} input={loupe.input} peekKey={capability.peekKey ?? null} />
|
|
71
|
+
</WeaselProvider>
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (!loupe.visible) return gestures;
|
|
63
75
|
|
|
64
76
|
return (
|
|
65
|
-
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
77
|
+
<>
|
|
78
|
+
{gestures}
|
|
79
|
+
<LoupeBubble aim={loupe.aim} diameter={capability.diameter}>
|
|
80
|
+
{capability.render ? (
|
|
81
|
+
<DomLoupe
|
|
82
|
+
aim={loupe.aim}
|
|
83
|
+
factor={loupe.factor}
|
|
84
|
+
mode={loupe.mode}
|
|
85
|
+
diameter={capability.diameter}
|
|
86
|
+
size={size}
|
|
87
|
+
view={view}
|
|
88
|
+
frame={stack?.frame}
|
|
89
|
+
state={state}
|
|
90
|
+
config={config}
|
|
91
|
+
render={capability.render}
|
|
92
|
+
/>
|
|
93
|
+
) : surface && stack ? (
|
|
94
|
+
<CanvasLoupe
|
|
95
|
+
aim={loupe.aim}
|
|
96
|
+
factor={loupe.factor}
|
|
97
|
+
mode={loupe.mode}
|
|
98
|
+
diameter={capability.diameter}
|
|
99
|
+
surface={surface}
|
|
100
|
+
view={stack.view}
|
|
101
|
+
frame={stack.frame}
|
|
102
|
+
worldSpec={worldSpec}
|
|
103
|
+
/>
|
|
104
|
+
) : null}
|
|
105
|
+
</LoupeBubble>
|
|
106
|
+
</>
|
|
92
107
|
);
|
|
93
108
|
}
|
package/src/loupe/index.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type { DomLoupeProps } from './DomLoupe';
|
|
|
6
6
|
export { DomLoupe } from './DomLoupe';
|
|
7
7
|
export type { LoupeBubbleProps } from './LoupeBubble';
|
|
8
8
|
export { LoupeBubble } from './LoupeBubble';
|
|
9
|
+
export type { LoupeGesturesProps } from './LoupeGestures';
|
|
10
|
+
export { LoupeGestures } from './LoupeGestures';
|
|
11
|
+
export type { LoupeInputApi } from './loupeActions';
|
|
12
|
+
export { createLoupeActions, LOUPE_MAGNIFY_ID, LOUPE_PEEK_ID } from './loupeActions';
|
|
9
13
|
export type { TrialLoupeProps } from './TrialLoupe';
|
|
10
14
|
export { TrialLoupe } from './TrialLoupe';
|
|
11
15
|
export type {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loupe's two bindable interactions, as `Action` descriptors the gesture
|
|
3
|
+
* dispatcher routes: hold a key to peek, and walk the magnification with the
|
|
4
|
+
* wheel.
|
|
5
|
+
*
|
|
6
|
+
* Free of React so the routing can be tested without a DOM.
|
|
7
|
+
*/
|
|
8
|
+
import type { Action } from '@weasel-js/core';
|
|
9
|
+
|
|
10
|
+
/** Canonical id of the hold-to-peek action. */
|
|
11
|
+
export const LOUPE_PEEK_ID = 'loupe.peek';
|
|
12
|
+
/** Canonical id of the wheel-magnify action. */
|
|
13
|
+
export const LOUPE_MAGNIFY_ID = 'loupe.magnify';
|
|
14
|
+
|
|
15
|
+
/** How fast the wheel walks the magnification. Gentler than the pan-zoom
|
|
16
|
+
* wheel: the lens' whole range is one order of magnitude. */
|
|
17
|
+
export const WHEEL_RATE = 0.002;
|
|
18
|
+
|
|
19
|
+
/** What the actions do to a live loupe. Every member is read through the
|
|
20
|
+
* object on each invocation, so one stable instance serves the life of the
|
|
21
|
+
* registration. */
|
|
22
|
+
export interface LoupeInputApi {
|
|
23
|
+
/** Whether the lens is up. The wheel belongs to the loupe only while it is;
|
|
24
|
+
* otherwise `loupe.magnify` declines and the dispatcher leaves the event
|
|
25
|
+
* unhandled, which is what hands it back to the lab's own pan/zoom. */
|
|
26
|
+
shown: () => boolean;
|
|
27
|
+
/** Raise or drop the hold-to-peek flag. */
|
|
28
|
+
setPeeking: (on: boolean) => void;
|
|
29
|
+
/** Multiply the magnification by one wheel notch of `deltaY`. */
|
|
30
|
+
magnifyBy: (deltaY: number) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the loupe's actions. `peekKey` is a `KeyboardEvent.key`; passing
|
|
35
|
+
* `null` omits `loupe.peek` entirely rather than registering a binding no key
|
|
36
|
+
* can match.
|
|
37
|
+
*/
|
|
38
|
+
export function createLoupeActions(
|
|
39
|
+
input: LoupeInputApi,
|
|
40
|
+
peekKey: string | null,
|
|
41
|
+
): readonly Action[] {
|
|
42
|
+
const magnify: Action = {
|
|
43
|
+
id: LOUPE_MAGNIFY_ID,
|
|
44
|
+
label: 'Magnify (loupe)',
|
|
45
|
+
group: 'loupe',
|
|
46
|
+
// Every modifier optional: the lens claims the wheel whenever it is up,
|
|
47
|
+
// which is what the hand-rolled capture-phase listener did. A bare spec
|
|
48
|
+
// would forbid modifiers and hand Cmd+wheel back to the lab mid-peek.
|
|
49
|
+
defaultBinding: [
|
|
50
|
+
{
|
|
51
|
+
spec: {
|
|
52
|
+
kind: 'wheel',
|
|
53
|
+
mods: { alt: 'optional', ctrl: 'optional', meta: 'optional', shift: 'optional' },
|
|
54
|
+
},
|
|
55
|
+
opts: {},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
enabled: () => (input.shown() ? true : 'not-applicable'),
|
|
59
|
+
invoker: {
|
|
60
|
+
timing: 'immediate',
|
|
61
|
+
run(_deps, params) {
|
|
62
|
+
input.magnifyBy((params?.deltaY as number | undefined) ?? 0);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (peekKey === null) return [magnify];
|
|
68
|
+
|
|
69
|
+
const peek: Action = {
|
|
70
|
+
id: LOUPE_PEEK_ID,
|
|
71
|
+
label: 'Peek (loupe)',
|
|
72
|
+
group: 'loupe',
|
|
73
|
+
// Hotkey scope: peeking must beat whatever the lab's active tool binds to
|
|
74
|
+
// the same key, the way every other held-key trigger does.
|
|
75
|
+
scope: 'hotkey',
|
|
76
|
+
// Every modifier optional because the peek key may BE one: the keydown
|
|
77
|
+
// that carries `Alt` — the default — also reports `altKey: true`, and the
|
|
78
|
+
// matcher forbids any modifier a spec does not name.
|
|
79
|
+
defaultBinding: [
|
|
80
|
+
{
|
|
81
|
+
spec: {
|
|
82
|
+
kind: 'key-held',
|
|
83
|
+
key: peekKey,
|
|
84
|
+
mods: { alt: 'optional', ctrl: 'optional', meta: 'optional', shift: 'optional' },
|
|
85
|
+
},
|
|
86
|
+
opts: {},
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
invoker: {
|
|
90
|
+
timing: 'ongoing',
|
|
91
|
+
start() {
|
|
92
|
+
input.setPeeking(true);
|
|
93
|
+
return { onEnd: () => input.setPeeking(false) };
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
return [peek, magnify];
|
|
98
|
+
}
|