@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/dist/surface/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { composeRects, rectsEqual, toDeviceRect, useTiledSurface } from '../chunk-
|
|
1
|
+
export { composeRects, rectsEqual, toDeviceRect, useTiledSurface } from '../chunk-IJJUK4FU.js';
|
|
2
2
|
export { SurfaceCanvasContext, SurfaceContext, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId } from '../chunk-WIG6XHJ7.js';
|
|
3
3
|
import '../chunk-2YILQ7Y5.js';
|
|
4
4
|
//# sourceMappingURL=index.js.map
|
package/dist/ui/layers/index.js
CHANGED
package/dist/undo/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { at as UndoCapability } from '../_dts/frac-B4okC2lX.js';
|
|
2
2
|
import 'react';
|
|
3
|
-
import '../_dts/types-
|
|
3
|
+
import '../_dts/types-DzoNY1pO.js';
|
|
4
4
|
import '@weasel-js/core';
|
|
5
|
-
import '../_dts/PrefsForm.d-
|
|
5
|
+
import '../_dts/PrefsForm.d-DPy9I8It.js';
|
|
6
6
|
import '../_dts/types-DJ79Tg5J.js';
|
|
7
7
|
|
|
8
8
|
/** A subscriber to a named event. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/labkit",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "React widgets for building self-contained interactive lab pages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "orochi235",
|
|
@@ -115,17 +115,17 @@
|
|
|
115
115
|
"dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
|
|
116
116
|
},
|
|
117
117
|
"peerDependencies": {
|
|
118
|
-
"@weasel-js/core": "1.5.
|
|
118
|
+
"@weasel-js/core": "1.5.1",
|
|
119
|
+
"@weasel-js/theme": "1.5.1",
|
|
119
120
|
"react": "^19.0.0",
|
|
120
121
|
"react-dom": "^19.0.0"
|
|
121
122
|
},
|
|
122
123
|
"dependencies": {
|
|
123
|
-
"@weasel-js/geom": "1.5.
|
|
124
|
-
"@weasel-js/kernel3d": "1.5.
|
|
125
|
-
"@weasel-js/loupe": "1.5.
|
|
126
|
-
"@weasel-js/svg": "1.5.
|
|
127
|
-
"@weasel-js/
|
|
128
|
-
"@weasel-js/ui": "1.5.0",
|
|
124
|
+
"@weasel-js/geom": "1.5.1",
|
|
125
|
+
"@weasel-js/kernel3d": "1.5.1",
|
|
126
|
+
"@weasel-js/loupe": "1.5.1",
|
|
127
|
+
"@weasel-js/svg": "1.5.1",
|
|
128
|
+
"@weasel-js/ui": "1.5.1",
|
|
129
129
|
"earcut": "2.2.4",
|
|
130
130
|
"polygon-clipping": "^0.15.7",
|
|
131
131
|
"react-aria-components": "^1.5.0",
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
"@biomejs/biome": "^2.4.12",
|
|
137
137
|
"@rollup/plugin-alias": "^6.0.0",
|
|
138
138
|
"@testing-library/jest-dom": "^6.6.0",
|
|
139
|
-
"@weasel-js/modes": "1.5.
|
|
139
|
+
"@weasel-js/modes": "1.5.1",
|
|
140
140
|
"less": "^4.2.0",
|
|
141
141
|
"rollup-plugin-dts": "^6.4.1",
|
|
142
142
|
"tsx": "^4.19.0",
|
|
@@ -226,6 +226,9 @@ export function AnnotationOverlay({
|
|
|
226
226
|
ref={setInput}
|
|
227
227
|
className="lk-annotate__input"
|
|
228
228
|
data-annotation-target={id}
|
|
229
|
+
// Pass-through while the rail holds a tool that makes no marks, so the
|
|
230
|
+
// instrument under the overlay stays clickable.
|
|
231
|
+
data-idle={annotationToolInfo(activeToolId) ? undefined : ''}
|
|
229
232
|
role="application"
|
|
230
233
|
aria-label={`Annotations on ${id}`}
|
|
231
234
|
// biome-ignore lint/a11y/noNoninteractiveTabindex: role="application" IS the interactive case — a region with its own keymap. The rule's allowed-role list stops at the native widget roles and does not include it, and the gesture dispatcher listens on this element, so without focus no tool's keyboard binding ever fires.
|
|
@@ -146,6 +146,26 @@ describe('<AnnotationTargets>', () => {
|
|
|
146
146
|
expect(boxes[1]?.style.getPropertyValue('--lk-anno-h')).toBe('100px');
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
it('takes input while a mark tool is held, and passes it through otherwise', () => {
|
|
150
|
+
const drawing = render(<Harness toolId="rect" />, { wrapper: StrictMode });
|
|
151
|
+
act(() => {
|
|
152
|
+
vi.advanceTimersByTime(64);
|
|
153
|
+
});
|
|
154
|
+
expect(drawing.container.querySelector('.lk-annotate__input')?.hasAttribute('data-idle')).toBe(
|
|
155
|
+
false,
|
|
156
|
+
);
|
|
157
|
+
drawing.unmount();
|
|
158
|
+
|
|
159
|
+
// `pointer` is in the rail but not in the tool map: the instrument under the overlay stays clickable.
|
|
160
|
+
const idle = render(<Harness toolId="pointer" />, { wrapper: StrictMode });
|
|
161
|
+
act(() => {
|
|
162
|
+
vi.advanceTimersByTime(64);
|
|
163
|
+
});
|
|
164
|
+
expect(idle.container.querySelector('.lk-annotate__input')?.hasAttribute('data-idle')).toBe(
|
|
165
|
+
true,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
149
169
|
it('lands the boxes in the surface container, not among the instrument DOM', () => {
|
|
150
170
|
const { container } = render(<Harness />, { wrapper: StrictMode });
|
|
151
171
|
act(() => {
|
|
@@ -27,6 +27,7 @@ function toSvgStroke(stroke: Stroke): SvgStroke {
|
|
|
27
27
|
...(stroke.join ? { join: stroke.join } : {}),
|
|
28
28
|
...(stroke.dash ? { dash: [...stroke.dash] } : {}),
|
|
29
29
|
...(stroke.miterLimit != null ? { miterLimit: stroke.miterLimit } : {}),
|
|
30
|
+
...(stroke.align ? { align: stroke.align } : {}),
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -6,8 +6,12 @@ import type { AnnotationKind } from './types';
|
|
|
6
6
|
const KINDS: AnnotationKind[] = ['stroke', 'line', 'arrow', 'rect', 'ellipse', 'text'];
|
|
7
7
|
|
|
8
8
|
describe('the annotation tool table', () => {
|
|
9
|
-
it('offers one tool per mark kind, plus select', () => {
|
|
10
|
-
expect(ANNOTATION_TOOLS.map((t) => t.id)).toEqual(['select', ...KINDS]);
|
|
9
|
+
it('offers one tool per mark kind, plus pointer and select', () => {
|
|
10
|
+
expect(ANNOTATION_TOOLS.map((t) => t.id)).toEqual(['pointer', 'select', ...KINDS]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('gives pointer no entry at all, which is what leaves the overlay idle', () => {
|
|
14
|
+
expect(annotationToolInfo('pointer')).toBeUndefined();
|
|
11
15
|
});
|
|
12
16
|
|
|
13
17
|
it('only names weasel tools SceneCanvas will mount', () => {
|
|
@@ -15,9 +19,9 @@ describe('the annotation tool table', () => {
|
|
|
15
19
|
// being built in should fail here rather than in a silent no-op palette.
|
|
16
20
|
const mountable = new Set<string>([...KIT_SHAPE_KINDS, 'select']);
|
|
17
21
|
for (const t of ANNOTATION_TOOLS) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
);
|
|
22
|
+
const info = annotationToolInfo(t.id);
|
|
23
|
+
if (!info) continue;
|
|
24
|
+
expect(mountable, `${t.id} maps to a tool the kit does not mount`).toContain(info.weaselTool);
|
|
21
25
|
}
|
|
22
26
|
});
|
|
23
27
|
|
|
@@ -2,6 +2,7 @@ import type { BuiltinToolId } from '@weasel-js/core';
|
|
|
2
2
|
import {
|
|
3
3
|
ArrowIcon,
|
|
4
4
|
EllipseIcon,
|
|
5
|
+
HandIcon,
|
|
5
6
|
LineIcon,
|
|
6
7
|
PencilIcon,
|
|
7
8
|
RectIcon,
|
|
@@ -34,6 +35,8 @@ const TOOLS: Record<string, AnnotationToolInfo> = {
|
|
|
34
35
|
* Ids share the lab's contribution namespace with its own `tools`, and the
|
|
35
36
|
* lab's tool slot holds whichever is active. */
|
|
36
37
|
export const ANNOTATION_TOOLS: readonly TrialTool[] = [
|
|
38
|
+
// Not in `TOOLS`: an id the map does not carry is what leaves the overlay idle.
|
|
39
|
+
{ id: 'pointer', label: 'Interact', icon: HandIcon, group: 'annotate' },
|
|
37
40
|
{ id: 'select', label: 'Select', icon: SelectIcon, group: 'annotate' },
|
|
38
41
|
{ id: 'stroke', label: 'Freehand', icon: PencilIcon, group: 'annotate' },
|
|
39
42
|
{ id: 'line', label: 'Line', icon: LineIcon, group: 'annotate' },
|
|
@@ -37,6 +37,34 @@ function setup(initialView: ViewTransform, options?: Partial<UsePanZoomOptions>)
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
describe('usePanZoom', () => {
|
|
40
|
+
it('stacks two wheel events that land before a re-render', () => {
|
|
41
|
+
const { wheel } = setup({ zoom: 1, pan: { x: 0, y: 0 } });
|
|
42
|
+
const once = wheel(-100).zoom;
|
|
43
|
+
|
|
44
|
+
const views: ViewTransform[] = [];
|
|
45
|
+
const { result } = renderHook(() =>
|
|
46
|
+
usePanZoom({ view: { zoom: 1, pan: { x: 0, y: 0 } }, onViewChange: (v) => views.push(v) }),
|
|
47
|
+
);
|
|
48
|
+
act(() => {
|
|
49
|
+
result.current.onWheel(wheelEvent(-100));
|
|
50
|
+
result.current.onWheel(wheelEvent(-100));
|
|
51
|
+
});
|
|
52
|
+
expect(views[1].zoom).toBeCloseTo(once * once, 10);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('lets the view prop win at the next render', () => {
|
|
56
|
+
const views: ViewTransform[] = [];
|
|
57
|
+
const { result, rerender } = renderHook(
|
|
58
|
+
(props: { view: ViewTransform }) =>
|
|
59
|
+
usePanZoom({ view: props.view, onViewChange: (v) => views.push(v) }),
|
|
60
|
+
{ initialProps: { view: { zoom: 1, pan: { x: 0, y: 0 } } } },
|
|
61
|
+
);
|
|
62
|
+
act(() => result.current.onWheel(wheelEvent(-100)));
|
|
63
|
+
rerender({ view: { zoom: 2, pan: { x: 0, y: 0 } } });
|
|
64
|
+
act(() => result.current.onWheel(wheelEvent(0)));
|
|
65
|
+
expect(views[1].zoom).toBeCloseTo(2, 10);
|
|
66
|
+
});
|
|
67
|
+
|
|
40
68
|
it('does not collapse a canvas opening far outside the default range on the first wheel event', () => {
|
|
41
69
|
const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } });
|
|
42
70
|
const next = wheel(-1); // zoom in slightly
|
package/src/canvas/usePanZoom.ts
CHANGED
|
@@ -70,6 +70,13 @@ export function usePanZoom({
|
|
|
70
70
|
// view unreachable behind it.
|
|
71
71
|
const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
|
|
72
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
|
+
|
|
73
80
|
const onWheel = useCallback(
|
|
74
81
|
(e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => {
|
|
75
82
|
const el = anchor ?? (e.currentTarget as Element | null);
|
|
@@ -77,7 +84,7 @@ export function usePanZoom({
|
|
|
77
84
|
e.preventDefault();
|
|
78
85
|
const rect = el.getBoundingClientRect();
|
|
79
86
|
const initialZoom = initialZoomRef.current;
|
|
80
|
-
|
|
87
|
+
commit(
|
|
81
88
|
zoomAt(
|
|
82
89
|
viewRef.current,
|
|
83
90
|
Math.exp(-e.deltaY * 0.001),
|
|
@@ -90,7 +97,7 @@ export function usePanZoom({
|
|
|
90
97
|
),
|
|
91
98
|
);
|
|
92
99
|
},
|
|
93
|
-
[
|
|
100
|
+
[commit, minZoom, maxZoom, frame],
|
|
94
101
|
);
|
|
95
102
|
|
|
96
103
|
const clearDrag = useCallback(() => {
|
|
@@ -114,7 +121,7 @@ export function usePanZoom({
|
|
|
114
121
|
const dy = ev.clientY - drag.startScreenY;
|
|
115
122
|
if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
116
123
|
drag.moved = true;
|
|
117
|
-
|
|
124
|
+
commit({
|
|
118
125
|
zoom: viewRef.current.zoom,
|
|
119
126
|
pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },
|
|
120
127
|
});
|
|
@@ -129,7 +136,7 @@ export function usePanZoom({
|
|
|
129
136
|
onCancel: clearDrag,
|
|
130
137
|
});
|
|
131
138
|
},
|
|
132
|
-
[clearDrag],
|
|
139
|
+
[clearDrag, commit],
|
|
133
140
|
);
|
|
134
141
|
|
|
135
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';
|
package/src/chrome/LabChrome.tsx
CHANGED
|
@@ -80,12 +80,10 @@ export function LabHeaderRegion({ contributions }: LabRegionProps) {
|
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
* survives a reload in a stored lab and lasts the session in an unstored one. */
|
|
85
|
-
export function LabSidebarRegion({ contributions }: LabRegionProps) {
|
|
83
|
+
function LabSections({ contributions, region }: LabRegionProps & { region: 'sidebar' | 'aside' }) {
|
|
86
84
|
const lab = useLabChromeContext();
|
|
87
85
|
const [collapsedSections, setFolds] = usePersistedState<Record<string, boolean>>(
|
|
88
|
-
|
|
86
|
+
`lk-lab-${region}-folds`,
|
|
89
87
|
{},
|
|
90
88
|
{ scope: 'lab' },
|
|
91
89
|
);
|
|
@@ -98,7 +96,25 @@ export function LabSidebarRegion({ contributions }: LabRegionProps) {
|
|
|
98
96
|
}),
|
|
99
97
|
[lab, collapsedSections, setFolds],
|
|
100
98
|
);
|
|
101
|
-
return
|
|
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" />;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
/** The lab's readouts, in the shell footer below the workspace. */
|
package/src/chrome/builtins.tsx
CHANGED
|
@@ -189,6 +189,7 @@ export function builtinContributions(
|
|
|
189
189
|
<ControlPanel
|
|
190
190
|
schema={ctx.configSchema}
|
|
191
191
|
config={ctx.config as Record<string, unknown>}
|
|
192
|
+
auto={ctx.auto}
|
|
192
193
|
setConfig={(key, value) => ctx.setConfig(String(key), value)}
|
|
193
194
|
renderers={controls}
|
|
194
195
|
collapsed={groupFolds(ctx.collapsedSections, 'settings')}
|
package/src/chrome/index.ts
CHANGED
package/src/chrome/labTypes.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
* `<LabShell>` names them; `palette` is the tool rail down the side of the
|
|
16
16
|
* workspace; `sidebar` is a column of sections left of the tool rail.
|
|
17
17
|
*/
|
|
18
|
-
export type LabRegion = 'header' | 'palette' | 'sidebar' | 'footer';
|
|
18
|
+
export type LabRegion = 'header' | 'palette' | 'sidebar' | 'aside' | 'footer';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Everything a lab-level contribution can read and command: the lab's own
|
|
@@ -41,7 +41,7 @@ export type LabContribution<TCtx = LabChromeContext> =
|
|
|
41
41
|
render?: never;
|
|
42
42
|
})
|
|
43
43
|
| (ContributionBase & { region: 'palette'; item: ToolItem; render?: never })
|
|
44
|
-
| (ContributionBase & { region: 'sidebar'; item: SidebarSection; render?: never })
|
|
44
|
+
| (ContributionBase & { region: 'sidebar' | 'aside'; item: SidebarSection; render?: never })
|
|
45
45
|
| (ContributionBase & { region: 'footer'; item: StatusReadout; render?: never })
|
|
46
46
|
| (ContributionBase & {
|
|
47
47
|
region: LabRegion;
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { ToolButton, ToolGroup } from '../../passthrough/weasel-ui';
|
|
2
2
|
import { useRovingTabIndex } from '../../primitives/useRovingTabIndex';
|
|
3
|
-
import type {
|
|
4
|
-
RegionContribution,
|
|
5
|
-
ToolItem,
|
|
6
|
-
ToolSlotContext,
|
|
7
|
-
TrialChromeContext,
|
|
8
|
-
} from '../types';
|
|
3
|
+
import type { RegionContribution, ToolItem, ToolSlotContext, TrialChromeContext } from '../types';
|
|
9
4
|
|
|
10
5
|
/** Props for `<PaletteRegion>`. */
|
|
11
6
|
export interface PaletteRegionProps<TCtx extends ToolSlotContext = TrialChromeContext> {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@weasel-js/forge';
|
|
2
|
+
import { expect } from '@weasel-js/forge/play';
|
|
3
|
+
import { LabRoot } from '../../lab/LabRoot';
|
|
4
|
+
import type { SidebarSlotContext } from '../types';
|
|
5
|
+
import { SidebarRegion } from './SidebarRegion';
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof SidebarRegion> = {
|
|
8
|
+
title: 'labkit/Chrome/SidebarRegion',
|
|
9
|
+
component: SidebarRegion,
|
|
10
|
+
parameters: { layout: 'fullscreen' },
|
|
11
|
+
};
|
|
12
|
+
export default meta;
|
|
13
|
+
|
|
14
|
+
type Story = StoryObj<typeof SidebarRegion>;
|
|
15
|
+
|
|
16
|
+
const ctx: SidebarSlotContext = { collapsedSections: {}, setSectionCollapsed: () => {} };
|
|
17
|
+
|
|
18
|
+
// jsdom has no layout, so this runs in the browser story projects.
|
|
19
|
+
export const LastSectionFillsItsPane: Story = {
|
|
20
|
+
render: () => (
|
|
21
|
+
<LabRoot>
|
|
22
|
+
<div className="lk-lab__aside" data-testid="pane" style={{ height: 400 }}>
|
|
23
|
+
<SidebarRegion
|
|
24
|
+
ctx={ctx}
|
|
25
|
+
contributions={[
|
|
26
|
+
{ id: 'first', region: 'sidebar', item: { title: 'First', body: <p>one</p> } },
|
|
27
|
+
{ id: 'last', region: 'sidebar', item: { title: 'Last', body: <p>two</p> } },
|
|
28
|
+
]}
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
</LabRoot>
|
|
32
|
+
),
|
|
33
|
+
play: async ({ canvasElement }) => {
|
|
34
|
+
const pane = canvasElement.querySelector('[data-testid="pane"]') as HTMLElement;
|
|
35
|
+
const sections = [...pane.querySelectorAll<HTMLElement>('.lk-sidebar-section')];
|
|
36
|
+
const first = sections[0] as HTMLElement;
|
|
37
|
+
const last = sections[sections.length - 1] as HTMLElement;
|
|
38
|
+
await expect(Math.round(last.getBoundingClientRect().bottom)).toBe(
|
|
39
|
+
Math.round(pane.getBoundingClientRect().bottom),
|
|
40
|
+
);
|
|
41
|
+
await expect(getComputedStyle(last).borderBottomWidth).toBe('0px');
|
|
42
|
+
await expect(getComputedStyle(first).borderBottomWidth).not.toBe('0px');
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -39,15 +39,19 @@ function groupsOf<TCtx>(contributions: readonly RegionContribution<TCtx>[]): Gro
|
|
|
39
39
|
return groups;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function renderEntry<TCtx>(
|
|
43
|
-
c: RegionContribution<TCtx>,
|
|
44
|
-
ctx: TCtx,
|
|
45
|
-
region: string,
|
|
46
|
-
): ReactNode {
|
|
42
|
+
function renderEntry<TCtx>(c: RegionContribution<TCtx>, ctx: TCtx, region: string): ReactNode {
|
|
47
43
|
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
48
44
|
if (c.region !== region || !c.item) return null;
|
|
49
|
-
const {
|
|
50
|
-
|
|
45
|
+
const {
|
|
46
|
+
icon: Icon,
|
|
47
|
+
label,
|
|
48
|
+
shortcut,
|
|
49
|
+
disabled,
|
|
50
|
+
danger,
|
|
51
|
+
showLabel,
|
|
52
|
+
pressed,
|
|
53
|
+
onActivate,
|
|
54
|
+
} = c.item as ToolbarItem<TCtx>;
|
|
51
55
|
return (
|
|
52
56
|
<Toolbar.Button
|
|
53
57
|
key={c.id}
|
package/src/chrome/types.ts
CHANGED
|
@@ -165,6 +165,9 @@ export interface TrialChromeContext extends ToolSlotContext, SidebarSlotContext
|
|
|
165
165
|
* `config`, since a builder schema has no `ConfigField[]` form. */
|
|
166
166
|
configFields: ConfigField[];
|
|
167
167
|
config: unknown;
|
|
168
|
+
/** The trial's unpinned paths. `config` stays raw, so a control drawn from
|
|
169
|
+
* it sits at the value un-pinning writes back. */
|
|
170
|
+
auto: ReadonlySet<string>;
|
|
168
171
|
setConfig: (key: string, value: unknown) => void;
|
|
169
172
|
|
|
170
173
|
/** Which of this trial's collapsible sections are folded. A sidebar
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { auto, isAuto } from './auto';
|
|
3
|
+
|
|
4
|
+
describe('auto', () => {
|
|
5
|
+
it('is a symbol distinct from every ordinary config value', () => {
|
|
6
|
+
expect(typeof auto).toBe('symbol');
|
|
7
|
+
expect(isAuto(auto)).toBe(true);
|
|
8
|
+
for (const v of [undefined, null, 0, '', 'auto', false, Symbol('auto'), {}]) {
|
|
9
|
+
expect(isAuto(v)).toBe(false);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Written at a config path to mean "do not pin this — let the instrument
|
|
3
|
+
* decide". Accepted by `setConfig`, by a trial's `configSeed`, and by
|
|
4
|
+
* `.initial()` in a schema. It is normalized into the trial's set of unpinned
|
|
5
|
+
* paths on the way into the store and is never itself stored, so a serialized
|
|
6
|
+
* trial contains no sentinel and the last pinned value survives.
|
|
7
|
+
*/
|
|
8
|
+
export const auto: unique symbol = Symbol('weasel.auto');
|
|
9
|
+
|
|
10
|
+
/** The type of the `auto` sentinel, for widening a value parameter. */
|
|
11
|
+
export type Auto = typeof auto;
|
|
12
|
+
|
|
13
|
+
export function isAuto(value: unknown): value is Auto {
|
|
14
|
+
return value === auto;
|
|
15
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { auto } from './auto';
|
|
3
|
+
import { autoPathsOf, resolveAutoConfig } from './autoConfig';
|
|
4
|
+
import { f } from './builder';
|
|
5
|
+
import { resolveConfigSchema } from './resolve';
|
|
6
|
+
|
|
7
|
+
const schema = f.schema({
|
|
8
|
+
width: f.number(432),
|
|
9
|
+
gap: f.number(12).auto((c) => (c.width as number) / 24),
|
|
10
|
+
cols: f.number(3).initial(auto),
|
|
11
|
+
grid: f.group({ size: f.number(8).auto(() => 99) }),
|
|
12
|
+
});
|
|
13
|
+
const resolved = resolveConfigSchema(schema);
|
|
14
|
+
const raw = { width: 432, gap: 12, cols: 3, grid: { size: 8 } };
|
|
15
|
+
|
|
16
|
+
describe('autoPathsOf', () => {
|
|
17
|
+
it('lists the dotted paths a schema starts auto', () => {
|
|
18
|
+
expect(autoPathsOf(resolved)).toEqual(['cols']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('reaches leaves nested under a group', () => {
|
|
22
|
+
const nested = resolveConfigSchema(
|
|
23
|
+
f.schema({ grid: f.group({ size: f.number(8).initial(auto) }) }),
|
|
24
|
+
);
|
|
25
|
+
expect(autoPathsOf(nested)).toEqual(['grid.size']);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('resolveAutoConfig', () => {
|
|
30
|
+
it('leaves a config with nothing auto exactly as it was', () => {
|
|
31
|
+
expect(resolveAutoConfig(resolved, raw, new Set())).toEqual(raw);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('replaces an auto path that has a resolver with the computed value', () => {
|
|
35
|
+
const out = resolveAutoConfig(resolved, raw, new Set(['gap']));
|
|
36
|
+
expect(out.gap).toBe(18);
|
|
37
|
+
expect(out.width).toBe(432);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('deletes an auto path that has no resolver', () => {
|
|
41
|
+
const out = resolveAutoConfig(resolved, raw, new Set(['cols'])) as Record<string, unknown>;
|
|
42
|
+
expect('cols' in out).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('resolves a leaf nested under a group', () => {
|
|
46
|
+
const out = resolveAutoConfig(resolved, raw, new Set(['grid.size'])) as {
|
|
47
|
+
grid: { size: number };
|
|
48
|
+
};
|
|
49
|
+
expect(out.grid.size).toBe(99);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('lets one resolver read another auto path, already resolved', () => {
|
|
53
|
+
const chained = resolveConfigSchema(
|
|
54
|
+
f.schema({
|
|
55
|
+
a: f.number(1).auto(() => 10),
|
|
56
|
+
b: f.number(2).auto((c) => (c.a as number) * 3),
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
const out = resolveAutoConfig(chained, { a: 1, b: 2 }, new Set(['a', 'b'])) as {
|
|
60
|
+
a: number;
|
|
61
|
+
b: number;
|
|
62
|
+
};
|
|
63
|
+
expect(out).toEqual({ a: 10, b: 30 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('resolves a dependency declared after its dependent', () => {
|
|
67
|
+
const backward = resolveConfigSchema(
|
|
68
|
+
f.schema({
|
|
69
|
+
b: f.number(2).auto((c) => (c.a as number) * 3),
|
|
70
|
+
a: f.number(1).auto(() => 10),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
const out = resolveAutoConfig(backward, { a: 1, b: 2 }, new Set(['a', 'b'])) as {
|
|
74
|
+
a: number;
|
|
75
|
+
b: number;
|
|
76
|
+
};
|
|
77
|
+
expect(out).toEqual({ a: 10, b: 30 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('throws naming the path when resolvers cycle', () => {
|
|
81
|
+
const cyclic = resolveConfigSchema(
|
|
82
|
+
f.schema({
|
|
83
|
+
a: f.number(1).auto((c) => (c.b as number) + 1),
|
|
84
|
+
b: f.number(2).auto((c) => (c.a as number) + 1),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
expect(() => resolveAutoConfig(cyclic, { a: 1, b: 2 }, new Set(['a', 'b']))).toThrow(/cycle/i);
|
|
88
|
+
expect(() => resolveAutoConfig(cyclic, { a: 1, b: 2 }, new Set(['a', 'b']))).toThrow(/"a"/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('throws on a resolver that reads its own path', () => {
|
|
92
|
+
const selfish = resolveConfigSchema(
|
|
93
|
+
f.schema({ a: f.number(1).auto((c) => (c.a as number) + 1) }),
|
|
94
|
+
);
|
|
95
|
+
expect(() => resolveAutoConfig(selfish, { a: 1 }, new Set(['a']))).toThrow(/cycle/i);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('ignores an auto path the schema does not have', () => {
|
|
99
|
+
expect(resolveAutoConfig(resolved, raw, new Set(['gone']))).toEqual(raw);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('does not mutate the config it was given', () => {
|
|
103
|
+
const before = structuredClone(raw);
|
|
104
|
+
resolveAutoConfig(resolved, raw, new Set(['gap', 'grid.size']));
|
|
105
|
+
expect(raw).toEqual(before);
|
|
106
|
+
});
|
|
107
|
+
});
|