@weasel-js/labkit 1.4.4 → 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 +54 -6
- package/dist/_dts/{CanvasStackContext-kTEZvEgE.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-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
- package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
- package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
- package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
- package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
- package/dist/canvas/index.d.ts +18 -129
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +5 -93
- package/dist/chrome/index.js +10 -6
- package/dist/chunk-7PFH3CNM.js +150 -0
- package/dist/chunk-7PFH3CNM.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
- 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-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
- package/dist/chunk-EHGVROVV.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
- package/dist/chunk-ERFMC6WU.js.map +1 -0
- package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
- package/dist/chunk-H6ZAOWNE.js.map +1 -0
- package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
- package/dist/chunk-IHUVQ3TX.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
- package/dist/chunk-IJJUK4FU.js.map +1 -0
- package/dist/chunk-IXTQI7JX.js +737 -0
- package/dist/chunk-IXTQI7JX.js.map +1 -0
- package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
- package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
- package/dist/chunk-PHK4W562.js +72 -0
- package/dist/chunk-PHK4W562.js.map +1 -0
- package/dist/chunk-PJ7WEQBJ.js +18 -0
- package/dist/chunk-PJ7WEQBJ.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-T3EC2DXD.js +12628 -0
- package/dist/chunk-T3EC2DXD.js.map +1 -0
- package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
- package/dist/chunk-UN4U4P63.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/chunk-Z76KEZAX.js +15 -0
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/chunk-ZDHY47EV.js +363 -0
- package/dist/chunk-ZDHY47EV.js.map +1 -0
- package/dist/config/index.d.ts +276 -0
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +3 -4
- package/dist/controls/index.js +4 -3
- package/dist/dragdrop/index.d.ts +3 -5
- package/dist/index.d.ts +266 -413
- package/dist/index.js +1775 -2251
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -6
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +69 -20
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +175 -309
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +55 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +13 -8
- package/dist/state/index.js +18 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +223 -76
- package/dist/surface/index.d.ts +44 -7
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +3 -5
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +56 -5
- package/src/annotations/Annotations.less +4 -0
- package/src/annotations/Annotations.overlay.test.tsx +105 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/paint.test.ts +2 -2
- package/src/annotations/paint.ts +1 -1
- package/src/annotations/preload.ts +6 -0
- package/src/annotations/store.test.ts +18 -0
- package/src/annotations/store.ts +5 -4
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -0
- package/src/annotations/toolMap.test.ts +9 -5
- package/src/annotations/toolMap.ts +6 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.stories.tsx +1 -1
- package/src/canvas/CanvasStack.test.tsx +25 -0
- package/src/canvas/CanvasStack.tsx +31 -4
- package/src/canvas/Stage.less +18 -0
- package/src/canvas/Stage.test.tsx +186 -0
- package/src/canvas/Stage.tsx +134 -0
- package/src/canvas/camera.test.ts +17 -2
- package/src/canvas/camera.ts +9 -9
- package/src/canvas/index.ts +4 -0
- package/src/canvas/usePanZoom.test.ts +28 -0
- package/src/canvas/usePanZoom.ts +21 -9
- package/src/chrome/ChromeRegions.stories.tsx +1 -4
- package/src/chrome/LabChrome.tsx +130 -0
- package/src/chrome/builtins.test.ts +1 -0
- package/src/chrome/builtins.tsx +21 -31
- package/src/chrome/index.ts +16 -0
- package/src/chrome/labRegions.test.tsx +208 -0
- package/src/chrome/labTypes.ts +50 -0
- package/src/chrome/merge.ts +7 -9
- package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
- package/src/chrome/regions/PaletteRegion.tsx +16 -9
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +34 -16
- package/src/chrome/types.ts +50 -20
- 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 +42 -0
- package/src/config/builder.ts +68 -17
- package/src/config/declarationEmit.fixture.ts +11 -0
- package/src/config/declarationEmit.test.ts +51 -0
- package/src/config/entry.test.ts +46 -0
- package/src/config/index.ts +16 -1
- package/src/config/nodeClasses.test.ts +18 -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 +34 -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 +530 -4
- package/src/controls/ControlPanel.tsx +562 -82
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +66 -5
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +40 -1
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.chrome.test.tsx +16 -0
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +1 -3
- package/src/lab/Lab.surface.test.tsx +71 -3
- package/src/lab/Lab.test.tsx +168 -16
- package/src/lab/Lab.tsx +434 -129
- package/src/lab/LabContext.ts +8 -0
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +1 -4
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -14
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabRoot.test.tsx +56 -0
- package/src/lab/LabRoot.tsx +47 -0
- package/src/lab/LabShell.less +73 -15
- package/src/lab/LabShell.stories.tsx +50 -1
- package/src/lab/LabShell.tsx +18 -27
- package/src/lab/LabSwitcher.less +18 -11
- 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 -0
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/lab/index.ts +2 -0
- package/src/layers/LayerList.stories.tsx +1 -1
- package/src/loupe/AGENTS.md +17 -5
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/LoupeGestures.tsx +67 -0
- package/src/loupe/TrialLoupe.tsx +43 -28
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/index.ts +4 -0
- package/src/loupe/loupeActions.ts +98 -0
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +60 -3
- package/src/loupe/useLoupe.ts +29 -49
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +14 -2
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- 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.less +14 -0
- package/src/primitives/Split.test.tsx +133 -0
- package/src/primitives/Split.tsx +175 -0
- package/src/primitives/StatusBar.stories.tsx +1 -1
- package/src/primitives/Toolbar.stories.tsx +1 -1
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- 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 +75 -0
- package/src/state/Persistence.tsx +33 -0
- package/src/state/SingletonExperiment.test.tsx +47 -53
- package/src/state/SingletonExperiment.tsx +24 -13
- package/src/state/adapterContract.ts +116 -0
- package/src/state/adapters.test.ts +113 -61
- package/src/state/adapters.ts +328 -69
- package/src/state/document.test.ts +71 -96
- package/src/state/document.ts +66 -52
- package/src/state/helpers.test.ts +1 -1
- package/src/state/index.ts +8 -0
- package/src/state/labRecords.test.ts +105 -0
- package/src/state/labRecords.ts +160 -0
- package/src/state/openLabStore.test.ts +658 -0
- package/src/state/openLabStore.ts +403 -0
- package/src/state/records.test.ts +234 -0
- package/src/state/records.ts +219 -0
- package/src/state/store.test.ts +169 -635
- package/src/state/store.ts +146 -163
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +65 -17
- package/src/state/useOpenOnce.ts +68 -0
- package/src/state/usePersistedState.test.tsx +125 -0
- package/src/state/usePersistedState.ts +66 -0
- package/src/state/useTrialState.test.tsx +87 -3
- package/src/state/useTrialState.ts +9 -1
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +3 -0
- package/src/surface/AGENTS.md +39 -7
- package/src/surface/SurfaceContext.ts +26 -3
- package/src/surface/index.ts +2 -0
- package/src/surface/useSurfaceTile.test.tsx +1 -0
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +231 -1
- package/src/surface/useTiledSurface.ts +76 -15
- package/src/theme/Interstellar.stories.tsx +9 -9
- package/src/theme/interstellar.test.ts +17 -6
- package/src/theme/interstellar.theme.json +180 -0
- package/src/theme/interstellar.ts +8 -5
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +59 -7
- package/src/trial/Trial.canvas.test.tsx +32 -0
- package/src/trial/Trial.config.test.tsx +75 -2
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +5 -26
- package/src/trial/Trial.stories.tsx +5 -3
- package/src/trial/Trial.test.tsx +31 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +139 -52
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +24 -7
- package/src/trial/trialOps.test.ts +101 -1
- package/src/trial/trialOps.ts +67 -7
- package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
- package/dist/chunk-2P6PP5N4.js +0 -553
- package/dist/chunk-2P6PP5N4.js.map +0 -1
- package/dist/chunk-64ZCN3DA.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-E44SU6XS.js +0 -50
- package/dist/chunk-E44SU6XS.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MOM3GOVY.js.map +0 -1
- package/dist/chunk-SMHP6XZ4.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
- package/dist/chunk-U3IYHIAE.js +0 -343
- package/dist/chunk-U3IYHIAE.js.map +0 -1
- package/dist/chunk-W3ECWC2K.js +0 -7335
- package/dist/chunk-W3ECWC2K.js.map +0 -1
- package/src/theme/interstellar.tokens.json +0 -61
package/src/canvas/usePanZoom.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type WheelEvent,
|
|
8
8
|
} from 'react';
|
|
9
9
|
import type { ViewTransform } from '../instrument/types';
|
|
10
|
+
import { normalize2DView } from '../state/view';
|
|
10
11
|
import { zoomAt } from './camera';
|
|
11
12
|
import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
|
|
12
13
|
|
|
@@ -28,7 +29,9 @@ export interface UsePanZoomOptions {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export interface PanZoomHandlers {
|
|
31
|
-
|
|
32
|
+
/** Zoom about the pointer. `anchor` is the element the pointer position is
|
|
33
|
+
* measured against, for an event whose `currentTarget` is some other box. */
|
|
34
|
+
onWheel: (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => void;
|
|
32
35
|
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
|
|
33
36
|
isDragging: () => boolean;
|
|
34
37
|
}
|
|
@@ -56,8 +59,8 @@ export function usePanZoom({
|
|
|
56
59
|
}: UsePanZoomOptions): PanZoomHandlers {
|
|
57
60
|
const dragRef = useRef<DragState | null>(null);
|
|
58
61
|
const sessionRef = useRef<PointerSession | null>(null);
|
|
59
|
-
const viewRef = useRef(view);
|
|
60
|
-
viewRef.current = view;
|
|
62
|
+
const viewRef = useRef(normalize2DView(view));
|
|
63
|
+
viewRef.current = normalize2DView(view);
|
|
61
64
|
const onViewChangeRef = useRef(onViewChange);
|
|
62
65
|
onViewChangeRef.current = onViewChange;
|
|
63
66
|
const onTapRef = useRef(onTap);
|
|
@@ -67,12 +70,21 @@ export function usePanZoom({
|
|
|
67
70
|
// view unreachable behind it.
|
|
68
71
|
const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
|
|
69
72
|
|
|
73
|
+
// Written ahead of the render so a second event inside one commit steps
|
|
74
|
+
// from this one; the render-time assignment above still lets the prop win.
|
|
75
|
+
const commit = useCallback((next: ViewTransform) => {
|
|
76
|
+
viewRef.current = normalize2DView(next);
|
|
77
|
+
onViewChangeRef.current(next);
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
70
80
|
const onWheel = useCallback(
|
|
71
|
-
(e: WheelEvent<HTMLElement>) => {
|
|
81
|
+
(e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => {
|
|
82
|
+
const el = anchor ?? (e.currentTarget as Element | null);
|
|
83
|
+
if (!el) return;
|
|
72
84
|
e.preventDefault();
|
|
73
|
-
const rect =
|
|
85
|
+
const rect = el.getBoundingClientRect();
|
|
74
86
|
const initialZoom = initialZoomRef.current;
|
|
75
|
-
|
|
87
|
+
commit(
|
|
76
88
|
zoomAt(
|
|
77
89
|
viewRef.current,
|
|
78
90
|
Math.exp(-e.deltaY * 0.001),
|
|
@@ -85,7 +97,7 @@ export function usePanZoom({
|
|
|
85
97
|
),
|
|
86
98
|
);
|
|
87
99
|
},
|
|
88
|
-
[
|
|
100
|
+
[commit, minZoom, maxZoom, frame],
|
|
89
101
|
);
|
|
90
102
|
|
|
91
103
|
const clearDrag = useCallback(() => {
|
|
@@ -109,7 +121,7 @@ export function usePanZoom({
|
|
|
109
121
|
const dy = ev.clientY - drag.startScreenY;
|
|
110
122
|
if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
111
123
|
drag.moved = true;
|
|
112
|
-
|
|
124
|
+
commit({
|
|
113
125
|
zoom: viewRef.current.zoom,
|
|
114
126
|
pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },
|
|
115
127
|
});
|
|
@@ -124,7 +136,7 @@ export function usePanZoom({
|
|
|
124
136
|
onCancel: clearDrag,
|
|
125
137
|
});
|
|
126
138
|
},
|
|
127
|
-
[clearDrag],
|
|
139
|
+
[clearDrag, commit],
|
|
128
140
|
);
|
|
129
141
|
|
|
130
142
|
useEffect(() => () => sessionRef.current?.cancel(), []);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@
|
|
1
|
+
import type { Meta, StoryObj } from '@weasel-js/forge';
|
|
2
2
|
import { CrosshairIcon, HandIcon, PencilIcon, SnapshotIcon } from '@weasel-js/ui';
|
|
3
3
|
import type { ConfigField } from '../controls/types';
|
|
4
4
|
import type { Instrument } from '../instrument/types';
|
|
@@ -76,7 +76,6 @@ export const EveryRegion: Story = {
|
|
|
76
76
|
instruments: [FullInstrument],
|
|
77
77
|
defaultInstrument: 'Every Region',
|
|
78
78
|
title: 'Chrome Regions',
|
|
79
|
-
storage: null,
|
|
80
79
|
},
|
|
81
80
|
};
|
|
82
81
|
|
|
@@ -86,7 +85,6 @@ export const SuppressedAndReplaced: Story = {
|
|
|
86
85
|
instruments: [FullInstrument],
|
|
87
86
|
defaultInstrument: 'Every Region',
|
|
88
87
|
title: 'Suppress + Replace',
|
|
89
|
-
storage: null,
|
|
90
88
|
suppress: ['snapshot'],
|
|
91
89
|
chrome: [
|
|
92
90
|
{
|
|
@@ -116,7 +114,6 @@ export const SubjectTitleAndLead: Story = {
|
|
|
116
114
|
instruments: [FullInstrument],
|
|
117
115
|
defaultInstrument: 'Every Region',
|
|
118
116
|
title: 'Title Bar Lead',
|
|
119
|
-
storage: null,
|
|
120
117
|
suppress: ['snapshot'],
|
|
121
118
|
chrome: [
|
|
122
119
|
{
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useContext, useMemo } from 'react';
|
|
2
|
+
import { useStore } from 'zustand/react';
|
|
3
|
+
import { useLabContext } from '../lab/LabContext';
|
|
4
|
+
import { LabStoreContext } from '../state/context';
|
|
5
|
+
import { usePersistedState } from '../state/usePersistedState';
|
|
6
|
+
import { resolveLabTool } from '../tools/labTool';
|
|
7
|
+
import type { TrialTool } from '../tools/types';
|
|
8
|
+
import type { LabChromeContext, LabContribution, LabRegion } from './labTypes';
|
|
9
|
+
import { mergeContributions } from './merge';
|
|
10
|
+
import { SidebarRegion } from './regions/SidebarRegion';
|
|
11
|
+
import { StatusRegion } from './regions/StatusRegion';
|
|
12
|
+
import { ToolbarRegion } from './regions/ToolbarRegion';
|
|
13
|
+
|
|
14
|
+
/** The lab's chrome context: the lab itself, plus the lab's tool slot. Throws
|
|
15
|
+
* outside a `<Lab>`. */
|
|
16
|
+
export function useLabChromeContext(): LabChromeContext {
|
|
17
|
+
const lab = useLabContext();
|
|
18
|
+
const storeCtx = useContext(LabStoreContext);
|
|
19
|
+
if (!storeCtx) throw new Error('[labkit] lab chrome requires <LabStoreProvider>');
|
|
20
|
+
const slot = useStore(storeCtx.store, (s) => s.activeToolId);
|
|
21
|
+
const storeInstruments = useStore(storeCtx.store, (s) => s.instruments);
|
|
22
|
+
const activeToolId = resolveLabTool(slot, storeInstruments ?? lab.instruments);
|
|
23
|
+
const setLabTool = useStore(storeCtx.store, (s) => s.setLabTool);
|
|
24
|
+
return useMemo(
|
|
25
|
+
() => ({ ...lab, activeToolId, setActiveTool: setLabTool }),
|
|
26
|
+
[lab, activeToolId, setLabTool],
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The lab's contributions for one region, in declaration order. */
|
|
31
|
+
export function contributionsIn<TCtx = LabChromeContext>(
|
|
32
|
+
contributions: readonly LabContribution<TCtx>[],
|
|
33
|
+
region: LabRegion,
|
|
34
|
+
): LabContribution<TCtx>[] {
|
|
35
|
+
return contributions.filter((c) => c.region === region);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The palette contributions a lab's `tools` stand for. Declaring a tool is
|
|
39
|
+
* shorthand for contributing a palette item that writes the lab's tool
|
|
40
|
+
* slot. */
|
|
41
|
+
export function toolContributions(tools: readonly TrialTool[]): LabContribution[] {
|
|
42
|
+
return tools.map((t) => ({
|
|
43
|
+
id: t.id,
|
|
44
|
+
region: 'palette' as const,
|
|
45
|
+
...(t.group === undefined ? {} : { group: t.group }),
|
|
46
|
+
item: {
|
|
47
|
+
icon: t.icon,
|
|
48
|
+
label: t.label,
|
|
49
|
+
...(t.shortcut === undefined ? {} : { shortcut: t.shortcut }),
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Everything the lab's chrome renders, from both of the ways a lab declares
|
|
55
|
+
* it. Tools and contributions share one id namespace, so a tool colliding
|
|
56
|
+
* with a contribution throws here rather than one of them silently losing. */
|
|
57
|
+
export function labContributions(
|
|
58
|
+
tools: readonly TrialTool[] | undefined,
|
|
59
|
+
chrome: readonly LabContribution[] | undefined,
|
|
60
|
+
): LabContribution[] {
|
|
61
|
+
return mergeContributions<LabContribution>(toolContributions(tools ?? []), [...(chrome ?? [])]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Props shared by the lab's region mounts. Each takes the lab's whole
|
|
65
|
+
* contribution list and lays out the part addressed to it. */
|
|
66
|
+
export interface LabRegionProps {
|
|
67
|
+
contributions: readonly LabContribution[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The lab's action bar, in the shell header beside the title. */
|
|
71
|
+
export function LabHeaderRegion({ contributions }: LabRegionProps) {
|
|
72
|
+
const ctx = useLabChromeContext();
|
|
73
|
+
return (
|
|
74
|
+
<ToolbarRegion
|
|
75
|
+
region="header"
|
|
76
|
+
label="Lab actions"
|
|
77
|
+
contributions={contributionsIn(contributions, 'header')}
|
|
78
|
+
ctx={ctx}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function LabSections({ contributions, region }: LabRegionProps & { region: 'sidebar' | 'aside' }) {
|
|
84
|
+
const lab = useLabChromeContext();
|
|
85
|
+
const [collapsedSections, setFolds] = usePersistedState<Record<string, boolean>>(
|
|
86
|
+
`lk-lab-${region}-folds`,
|
|
87
|
+
{},
|
|
88
|
+
{ scope: 'lab' },
|
|
89
|
+
);
|
|
90
|
+
const ctx = useMemo(
|
|
91
|
+
() => ({
|
|
92
|
+
...lab,
|
|
93
|
+
collapsedSections,
|
|
94
|
+
setSectionCollapsed: (key: string, collapsed: boolean) =>
|
|
95
|
+
setFolds((f) => ({ ...f, [key]: collapsed })),
|
|
96
|
+
}),
|
|
97
|
+
[lab, collapsedSections, setFolds],
|
|
98
|
+
);
|
|
99
|
+
return (
|
|
100
|
+
<SidebarRegion
|
|
101
|
+
contributions={contributionsIn(contributions, region)}
|
|
102
|
+
ctx={ctx}
|
|
103
|
+
region={region}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The lab's sidebar sections. Fold state is a lab-scoped persisted value, so it
|
|
109
|
+
* survives a reload in a stored lab and lasts the session in an unstored one. */
|
|
110
|
+
export function LabSidebarRegion({ contributions }: LabRegionProps) {
|
|
111
|
+
return <LabSections contributions={contributions} region="sidebar" />;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The lab's aside sections, on the far side of the workspace from the sidebar.
|
|
115
|
+
* Fold state persists the way the sidebar's does. */
|
|
116
|
+
export function LabAsideRegion({ contributions }: LabRegionProps) {
|
|
117
|
+
return <LabSections contributions={contributions} region="aside" />;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The lab's readouts, in the shell footer below the workspace. */
|
|
121
|
+
export function LabFooterRegion({ contributions }: LabRegionProps) {
|
|
122
|
+
const ctx = useLabChromeContext();
|
|
123
|
+
return (
|
|
124
|
+
<StatusRegion
|
|
125
|
+
region="footer"
|
|
126
|
+
contributions={contributionsIn(contributions, 'footer')}
|
|
127
|
+
ctx={ctx}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
}
|
package/src/chrome/builtins.tsx
CHANGED
|
@@ -12,11 +12,10 @@ import {
|
|
|
12
12
|
} from '@weasel-js/ui';
|
|
13
13
|
import { ExportMenu } from '../annotations/ExportMenu';
|
|
14
14
|
import { MarkList } from '../annotations/MarkList';
|
|
15
|
-
import { ANNOTATION_TOOLS } from '../annotations/toolMap';
|
|
16
15
|
import type { ControlRenderer } from '../config/types';
|
|
17
16
|
import { ControlPanel } from '../controls/ControlPanel';
|
|
18
17
|
import type { Instrument } from '../instrument/types';
|
|
19
|
-
import {
|
|
18
|
+
import { MenuButton } from '../passthrough/weasel-ui';
|
|
20
19
|
import { FpsMeter } from '../primitives/FpsMeter';
|
|
21
20
|
import { ScaleIndicator } from '../primitives/ScaleIndicator';
|
|
22
21
|
import { ZoomControl } from '../primitives/ZoomControl';
|
|
@@ -82,16 +81,10 @@ export function builtinContributions(
|
|
|
82
81
|
});
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
// Declaring `annotations` provides the drawing palette, the way declaring
|
|
86
|
-
// `tools` provides the instrument's own; both write the same tool slot.
|
|
87
84
|
// A tool id shares the contribution namespace, so a tool called `close`
|
|
88
|
-
// collides with the built-in close button and throws
|
|
89
|
-
//
|
|
90
|
-
const tools
|
|
91
|
-
...(instrument.annotations ? ANNOTATION_TOOLS : []),
|
|
92
|
-
...(instrument.tools?.tools ?? []),
|
|
93
|
-
];
|
|
94
|
-
for (const t of tools) {
|
|
85
|
+
// collides with the built-in close button and throws. Annotation tools are
|
|
86
|
+
// not here: the lab's rail carries them.
|
|
87
|
+
for (const t of instrument.tools?.tools ?? []) {
|
|
95
88
|
out.push({
|
|
96
89
|
id: t.id,
|
|
97
90
|
region: 'palette',
|
|
@@ -116,7 +109,7 @@ export function builtinContributions(
|
|
|
116
109
|
|
|
117
110
|
// Zoom acts on the view of the trial, so it is a viewport control. A trial
|
|
118
111
|
// holding a non-2D view reports zoom as null and gets none of this.
|
|
119
|
-
if (instrument.canvas != null && zoom !== null) {
|
|
112
|
+
if ((instrument.canvas != null || instrument.stage != null) && zoom !== null) {
|
|
120
113
|
out.push({
|
|
121
114
|
id: 'zoom-out',
|
|
122
115
|
region: 'viewport',
|
|
@@ -153,15 +146,17 @@ export function builtinContributions(
|
|
|
153
146
|
id: 'scale',
|
|
154
147
|
region: 'status',
|
|
155
148
|
group: 'view',
|
|
156
|
-
render: () => <ScaleIndicator />,
|
|
157
|
-
});
|
|
158
|
-
out.push({
|
|
159
|
-
id: 'fps',
|
|
160
|
-
region: 'status',
|
|
161
|
-
group: 'view',
|
|
162
|
-
end: true,
|
|
163
|
-
render: () => <FpsMeter />,
|
|
149
|
+
render: (c) => <ScaleIndicator zoom={c.zoom ?? 1} />,
|
|
164
150
|
});
|
|
151
|
+
if (instrument.canvas != null) {
|
|
152
|
+
out.push({
|
|
153
|
+
id: 'fps',
|
|
154
|
+
region: 'status',
|
|
155
|
+
group: 'view',
|
|
156
|
+
end: true,
|
|
157
|
+
render: () => <FpsMeter />,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
165
160
|
}
|
|
166
161
|
|
|
167
162
|
if (instrument.annotations != null) {
|
|
@@ -194,6 +189,7 @@ export function builtinContributions(
|
|
|
194
189
|
<ControlPanel
|
|
195
190
|
schema={ctx.configSchema}
|
|
196
191
|
config={ctx.config as Record<string, unknown>}
|
|
192
|
+
auto={ctx.auto}
|
|
197
193
|
setConfig={(key, value) => ctx.setConfig(String(key), value)}
|
|
198
194
|
renderers={controls}
|
|
199
195
|
collapsed={groupFolds(ctx.collapsedSections, 'settings')}
|
|
@@ -205,24 +201,18 @@ export function builtinContributions(
|
|
|
205
201
|
}
|
|
206
202
|
|
|
207
203
|
if (ctx.savedSnapshots.length > 0) {
|
|
208
|
-
// A
|
|
209
|
-
// `selectedKey={null}` so it stays a "load one" action rather than
|
|
210
|
-
// drifting into a display of what was loaded last.
|
|
204
|
+
// A menu is not an icon button, so it takes the render escape.
|
|
211
205
|
out.push({
|
|
212
206
|
id: 'snapshot-load',
|
|
213
207
|
region: 'toolbar',
|
|
214
208
|
group: 'trial',
|
|
215
209
|
end: true,
|
|
216
210
|
render: (c) => (
|
|
217
|
-
<
|
|
218
|
-
className="lk-toolbar__load-select"
|
|
211
|
+
<MenuButton
|
|
219
212
|
aria-label="Load snapshot"
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
onSelectionChange={(id) => {
|
|
224
|
-
if (id != null) c.loadSnapshot(String(id));
|
|
225
|
-
}}
|
|
213
|
+
label="Load…"
|
|
214
|
+
items={c.savedSnapshots.map((sn) => ({ value: sn.id, label: sn.name }))}
|
|
215
|
+
onAction={(id) => c.loadSnapshot(id)}
|
|
226
216
|
/>
|
|
227
217
|
),
|
|
228
218
|
});
|
package/src/chrome/index.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
export { builtinContributions } from './builtins';
|
|
2
|
+
export type { LabRegionProps } from './LabChrome';
|
|
3
|
+
export {
|
|
4
|
+
contributionsIn,
|
|
5
|
+
LabAsideRegion,
|
|
6
|
+
LabFooterRegion,
|
|
7
|
+
LabHeaderRegion,
|
|
8
|
+
LabSidebarRegion,
|
|
9
|
+
labContributions,
|
|
10
|
+
toolContributions,
|
|
11
|
+
useLabChromeContext,
|
|
12
|
+
} from './LabChrome';
|
|
13
|
+
export type { LabChromeContext, LabContribution, LabRegion } from './labTypes';
|
|
2
14
|
export { mergeContributions, suppressContributions } from './merge';
|
|
3
15
|
export type { PaletteRegionProps } from './regions/PaletteRegion';
|
|
4
16
|
export { PaletteRegion } from './regions/PaletteRegion';
|
|
@@ -13,11 +25,15 @@ export { ToolbarRegion } from './regions/ToolbarRegion';
|
|
|
13
25
|
export type { ViewportRegionProps } from './regions/ViewportRegion';
|
|
14
26
|
export { ViewportRegion } from './regions/ViewportRegion';
|
|
15
27
|
export type {
|
|
28
|
+
ContributionBase,
|
|
16
29
|
IconComponent,
|
|
30
|
+
RegionContribution,
|
|
17
31
|
SidebarSection,
|
|
32
|
+
SidebarSlotContext,
|
|
18
33
|
StatusReadout,
|
|
19
34
|
ToolbarItem,
|
|
20
35
|
ToolItem,
|
|
36
|
+
ToolSlotContext,
|
|
21
37
|
TrialChromeContext,
|
|
22
38
|
TrialContribution,
|
|
23
39
|
TrialRegion,
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { Instrument } from '../instrument/types';
|
|
4
|
+
import { Lab } from '../lab/Lab';
|
|
5
|
+
import { LabShell } from '../lab/LabShell';
|
|
6
|
+
import type { LabChromeContext, LabContribution } from './labTypes';
|
|
7
|
+
import { ToolbarRegion } from './regions/ToolbarRegion';
|
|
8
|
+
|
|
9
|
+
const Glyph = () => <svg />;
|
|
10
|
+
const bare: Instrument = {
|
|
11
|
+
name: 'Bare',
|
|
12
|
+
defaultConfig: () => ({}),
|
|
13
|
+
initialState: () => ({}),
|
|
14
|
+
render: () => null,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function silenceRenderError(): void {
|
|
18
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('lab-level chrome', () => {
|
|
22
|
+
it('renders a header contribution beside the lab header', () => {
|
|
23
|
+
const contribution: LabContribution = {
|
|
24
|
+
id: 'publish',
|
|
25
|
+
region: 'header',
|
|
26
|
+
item: { icon: Glyph, label: 'Publish', onActivate: () => {} },
|
|
27
|
+
};
|
|
28
|
+
render(
|
|
29
|
+
<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
|
|
30
|
+
);
|
|
31
|
+
expect(screen.getByRole('button', { name: 'Publish' })).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('hands a header contribution the lab, not a trial', () => {
|
|
35
|
+
const contribution: LabContribution = {
|
|
36
|
+
id: 'another',
|
|
37
|
+
region: 'header',
|
|
38
|
+
item: {
|
|
39
|
+
icon: Glyph,
|
|
40
|
+
label: 'Another',
|
|
41
|
+
onActivate: (ctx) => ctx.addTrial('Bare'),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
render(
|
|
45
|
+
<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
|
|
46
|
+
);
|
|
47
|
+
expect(screen.getAllByLabelText(/^Trial /)).toHaveLength(1);
|
|
48
|
+
fireEvent.click(screen.getByRole('button', { name: 'Another' }));
|
|
49
|
+
expect(screen.getAllByLabelText(/^Trial /)).toHaveLength(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders a palette contribution in the lab rail and selects it', () => {
|
|
53
|
+
const contribution: LabContribution = {
|
|
54
|
+
id: 'measure',
|
|
55
|
+
region: 'palette',
|
|
56
|
+
item: { icon: Glyph, label: 'Measure' },
|
|
57
|
+
};
|
|
58
|
+
render(
|
|
59
|
+
<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
|
|
60
|
+
);
|
|
61
|
+
const button = screen.getByRole('button', { name: 'Measure' });
|
|
62
|
+
fireEvent.click(button);
|
|
63
|
+
expect(screen.getByRole('button', { name: 'Measure' })).toHaveAttribute('aria-current', 'true');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('gives a palette contribution the whole lab context, not a tool slot alone', () => {
|
|
67
|
+
// The shape the `as unknown as TrialChromeContext` cast used to fake: a
|
|
68
|
+
// contribution reading anything past the tool slot got `undefined`.
|
|
69
|
+
const seen: LabChromeContext[] = [];
|
|
70
|
+
const contribution: LabContribution = {
|
|
71
|
+
id: 'readout',
|
|
72
|
+
region: 'palette',
|
|
73
|
+
render: (ctx) => {
|
|
74
|
+
seen.push(ctx);
|
|
75
|
+
return <span>{`${ctx.trials.length} trial(s), mode ${ctx.mode}`}</span>;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
render(
|
|
79
|
+
<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
|
|
80
|
+
);
|
|
81
|
+
expect(screen.getByText('1 trial(s), mode auto')).toBeInTheDocument();
|
|
82
|
+
expect(seen[0]?.activeToolId).toBeNull();
|
|
83
|
+
expect(typeof seen[0]?.saveSnapshot).toBe('function');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('renders a footer contribution', () => {
|
|
87
|
+
const contribution: LabContribution = {
|
|
88
|
+
id: 'count',
|
|
89
|
+
region: 'footer',
|
|
90
|
+
item: { text: 'idle' },
|
|
91
|
+
};
|
|
92
|
+
render(
|
|
93
|
+
<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
|
|
94
|
+
);
|
|
95
|
+
expect(screen.getByText('idle')).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('throws when two lab contributions share an id', () => {
|
|
99
|
+
silenceRenderError();
|
|
100
|
+
const clash: LabContribution[] = [
|
|
101
|
+
{ id: 'same', region: 'header', item: { icon: Glyph, label: 'One', onActivate: () => {} } },
|
|
102
|
+
{ id: 'same', region: 'footer', item: { text: 'two' } },
|
|
103
|
+
];
|
|
104
|
+
expect(() =>
|
|
105
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={clash} />),
|
|
106
|
+
).toThrow(/duplicate contribution id "same"/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('keeps the lab tool slot and lab palette contributions in one namespace', () => {
|
|
110
|
+
silenceRenderError();
|
|
111
|
+
const clash: LabContribution = {
|
|
112
|
+
id: 'pick',
|
|
113
|
+
region: 'palette',
|
|
114
|
+
item: { icon: Glyph, label: 'Mine' },
|
|
115
|
+
};
|
|
116
|
+
expect(() =>
|
|
117
|
+
render(
|
|
118
|
+
<Lab
|
|
119
|
+
title="T"
|
|
120
|
+
instruments={[bare]}
|
|
121
|
+
defaultInstrument="Bare"
|
|
122
|
+
tools={[{ id: 'pick', label: 'Pick', icon: Glyph }]}
|
|
123
|
+
labChrome={[clash]}
|
|
124
|
+
/>,
|
|
125
|
+
),
|
|
126
|
+
).toThrow(/duplicate contribution id "pick"/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('lab sidebar region', () => {
|
|
131
|
+
const tree: LabContribution = {
|
|
132
|
+
id: 'tree',
|
|
133
|
+
region: 'sidebar',
|
|
134
|
+
item: { title: 'Stories', body: <p>story list</p> },
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
it('renders a section beside the workspace', () => {
|
|
138
|
+
const { container } = render(
|
|
139
|
+
<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />,
|
|
140
|
+
);
|
|
141
|
+
expect(screen.getByText('story list')).toBeInTheDocument();
|
|
142
|
+
expect(container.querySelector('.lk-lab__sidebar')).not.toBeNull();
|
|
143
|
+
expect(screen.getByRole('separator', { name: /lab sidebar/i })).toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('folds a section', () => {
|
|
147
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />);
|
|
148
|
+
fireEvent.click(screen.getByRole('button', { name: 'Stories' }));
|
|
149
|
+
expect(screen.queryByText('story list')).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('offers no tear-out at lab level', () => {
|
|
153
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />);
|
|
154
|
+
expect(screen.queryByRole('button', { name: 'Undock Stories' })).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('hands a render contribution the lab context', () => {
|
|
158
|
+
const seen: LabChromeContext[] = [];
|
|
159
|
+
const probe: LabContribution = {
|
|
160
|
+
id: 'probe',
|
|
161
|
+
region: 'sidebar',
|
|
162
|
+
render: (ctx) => {
|
|
163
|
+
seen.push(ctx);
|
|
164
|
+
return <p>probe</p>;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[probe]} />);
|
|
168
|
+
expect(screen.getByText('probe')).toBeInTheDocument();
|
|
169
|
+
expect(seen[0]?.trials).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('adds no strip to a lab without sidebar chrome', () => {
|
|
173
|
+
const { container } = render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" />);
|
|
174
|
+
expect(container.querySelector('.lk-lab__panes')).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('lab chrome without <Lab>', () => {
|
|
179
|
+
// The rail `apps/theme-editor` mounts: `<LabShell>` with no trial runtime
|
|
180
|
+
// above it, so the context handed to `onActivate` is the consumer's own.
|
|
181
|
+
interface RailCtx {
|
|
182
|
+
undo: () => void;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
it('renders a contribution typed against a consumer context and hands it back', () => {
|
|
186
|
+
const undo = vi.fn();
|
|
187
|
+
const ctx: RailCtx = { undo };
|
|
188
|
+
const rail: readonly LabContribution<RailCtx>[] = [
|
|
189
|
+
{
|
|
190
|
+
id: 'undo',
|
|
191
|
+
region: 'header',
|
|
192
|
+
item: { icon: Glyph, label: 'Undo', showLabel: true, onActivate: (c) => c.undo() },
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
render(
|
|
196
|
+
<LabShell
|
|
197
|
+
title="Palette lab"
|
|
198
|
+
header={
|
|
199
|
+
<ToolbarRegion region="header" label="Lab actions" contributions={rail} ctx={ctx} />
|
|
200
|
+
}
|
|
201
|
+
>
|
|
202
|
+
<div />
|
|
203
|
+
</LabShell>,
|
|
204
|
+
);
|
|
205
|
+
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
|
206
|
+
expect(undo).toHaveBeenCalledTimes(1);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { LabContextValue } from '../lab/LabContext';
|
|
3
|
+
import type {
|
|
4
|
+
ContributionBase,
|
|
5
|
+
SidebarSection,
|
|
6
|
+
StatusReadout,
|
|
7
|
+
ToolbarItem,
|
|
8
|
+
ToolItem,
|
|
9
|
+
ToolSlotContext,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A named position in the lab's own chrome — a box the shell owns, outside any
|
|
14
|
+
* one trial. `header` and `footer` are the shell's two bars, named as
|
|
15
|
+
* `<LabShell>` names them; `palette` is the tool rail down the side of the
|
|
16
|
+
* workspace; `sidebar` is a column of sections left of the tool rail.
|
|
17
|
+
*/
|
|
18
|
+
export type LabRegion = 'header' | 'palette' | 'sidebar' | 'aside' | 'footer';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Everything a lab-level contribution can read and command: the lab's own
|
|
22
|
+
* state and operations, plus the lab's tool slot — which every trial whose
|
|
23
|
+
* instrument declares no tools of its own reflects and writes.
|
|
24
|
+
*/
|
|
25
|
+
export interface LabChromeContext extends LabContextValue, ToolSlotContext {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A contribution to the lab's chrome. Shaped as `TrialContribution` is, and
|
|
29
|
+
* merged and suppressed by the same rules, but keyed to the lab's regions and
|
|
30
|
+
* rendered with the lab's context. Supplying `render` instead of `item` opts
|
|
31
|
+
* out of the region's layout.
|
|
32
|
+
*
|
|
33
|
+
* `TCtx` is what the chrome hands a handler. It defaults to `LabChromeContext`,
|
|
34
|
+
* which is what `<Lab>` renders its regions with; a consumer mounting the
|
|
35
|
+
* regions under a bare `<LabShell>` names whatever it passes as `ctx` instead.
|
|
36
|
+
*/
|
|
37
|
+
export type LabContribution<TCtx = LabChromeContext> =
|
|
38
|
+
| (ContributionBase & {
|
|
39
|
+
region: 'header';
|
|
40
|
+
item: ToolbarItem<TCtx>;
|
|
41
|
+
render?: never;
|
|
42
|
+
})
|
|
43
|
+
| (ContributionBase & { region: 'palette'; item: ToolItem; render?: never })
|
|
44
|
+
| (ContributionBase & { region: 'sidebar' | 'aside'; item: SidebarSection; render?: never })
|
|
45
|
+
| (ContributionBase & { region: 'footer'; item: StatusReadout; render?: never })
|
|
46
|
+
| (ContributionBase & {
|
|
47
|
+
region: LabRegion;
|
|
48
|
+
item?: never;
|
|
49
|
+
render: (ctx: TCtx) => ReactNode;
|
|
50
|
+
});
|
package/src/chrome/merge.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { TrialContribution } from './types';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Concatenate contribution bundles into one list, preserving order — order
|
|
5
3
|
* decides how a region lays its contributions out, so it is part of the
|
|
@@ -8,10 +6,10 @@ import type { TrialContribution } from './types';
|
|
|
8
6
|
* Throws on a duplicate id rather than dropping one: a contribution silently
|
|
9
7
|
* losing to a later bundle is the failure a registry exists to prevent.
|
|
10
8
|
*/
|
|
11
|
-
export function mergeContributions(
|
|
12
|
-
...bundles: readonly
|
|
13
|
-
):
|
|
14
|
-
const out:
|
|
9
|
+
export function mergeContributions<T extends { id: string }>(
|
|
10
|
+
...bundles: readonly (readonly T[])[]
|
|
11
|
+
): T[] {
|
|
12
|
+
const out: T[] = [];
|
|
15
13
|
const seen = new Set<string>();
|
|
16
14
|
for (const bundle of bundles) {
|
|
17
15
|
for (const entry of bundle) {
|
|
@@ -34,10 +32,10 @@ export function mergeContributions(
|
|
|
34
32
|
* silently suppresses nothing is the same class of bug as a duplicate id
|
|
35
33
|
* silently winning.
|
|
36
34
|
*/
|
|
37
|
-
export function suppressContributions(
|
|
38
|
-
bundle: readonly
|
|
35
|
+
export function suppressContributions<T extends { id: string }>(
|
|
36
|
+
bundle: readonly T[],
|
|
39
37
|
ids: readonly string[],
|
|
40
|
-
):
|
|
38
|
+
): T[] {
|
|
41
39
|
const present = new Set(bundle.map((c) => c.id));
|
|
42
40
|
for (const id of ids) {
|
|
43
41
|
if (!present.has(id)) {
|