@weasel-js/labkit 1.5.0 → 1.5.2
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-CMMo180l.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
- package/dist/_dts/Select.d-ZTiwzYyT.d.ts +479 -0
- package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4hFJMiN.d.ts} +54 -6
- package/dist/_dts/{index-retnAut7.d.ts → index-8LiKe3A-.d.ts} +77 -10
- package/dist/_dts/{index-B8uu2ba7.d.ts → index-DlKolEIZ.d.ts} +20 -8
- package/dist/_dts/{types-Ca2LCnPq.d.ts → types-Db-3NNk1.d.ts} +28 -4
- package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-SSTZe410.d.ts} +4 -47
- package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-pnAE-UBD.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-6BUQ77ZV.js +18 -0
- package/dist/chunk-6BUQ77ZV.js.map +1 -0
- package/dist/{chunk-KJALCDWE.js → chunk-753Y6HE6.js} +9 -9
- package/dist/chunk-753Y6HE6.js.map +1 -0
- package/dist/{chunk-6ZDGOZQV.js → chunk-A2ACAGW6.js} +72 -20
- package/dist/chunk-A2ACAGW6.js.map +1 -0
- package/dist/{chunk-NS64DXMN.js → chunk-CKUKF72R.js} +44 -21
- package/dist/chunk-CKUKF72R.js.map +1 -0
- package/dist/{chunk-MUKOW3TC.js → chunk-D35PFTS7.js} +4 -4
- package/dist/{chunk-MUKOW3TC.js.map → chunk-D35PFTS7.js.map} +1 -1
- package/dist/{chunk-UU3NJO6N.js → chunk-EMXAIRUJ.js} +3 -3
- package/dist/{chunk-UU3NJO6N.js.map → chunk-EMXAIRUJ.js.map} +1 -1
- package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
- package/dist/chunk-ERFMC6WU.js.map +1 -0
- package/dist/chunk-FKLT3ITC.js +590 -0
- package/dist/chunk-FKLT3ITC.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-EKIICY6X.js → chunk-KJCUERQF.js} +37 -25
- package/dist/chunk-KJCUERQF.js.map +1 -0
- package/dist/chunk-LASCBWW7.js +13456 -0
- package/dist/chunk-LASCBWW7.js.map +1 -0
- package/dist/{chunk-67SJLMC7.js → chunk-LRI3MBGR.js} +45 -12
- package/dist/chunk-LRI3MBGR.js.map +1 -0
- package/dist/chunk-REOBGYMC.js +150 -0
- package/dist/chunk-REOBGYMC.js.map +1 -0
- package/dist/{chunk-PGETSEDK.js → chunk-TT5C5S6R.js} +11 -90
- package/dist/chunk-TT5C5S6R.js.map +1 -0
- package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/config/index.d.ts +62 -19
- 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 +230 -62
- package/dist/index.js +1367 -1698
- 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 +57 -230
- 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 +266 -57
- 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/index.ts +0 -9
- 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 +3 -3
- package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
- package/src/chrome/regions/PaletteRegion.tsx +6 -10
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/TitleBarRegion.tsx +1 -1
- package/src/chrome/regions/ToolbarRegion.tsx +11 -7
- package/src/chrome/types.ts +11 -2
- 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 +55 -11
- 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.less +23 -0
- package/src/controls/ControlPanel.stories.tsx +83 -1
- package/src/controls/ControlPanel.test.tsx +457 -4
- package/src/controls/ControlPanel.tsx +534 -73
- package/src/index.test.ts +0 -2
- package/src/index.ts +33 -11
- 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 +124 -16
- 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.less +1 -1
- package/src/layers/LayerList.stories.tsx +2 -2
- 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.less +6 -1
- package/src/primitives/FpsMeter.stories.tsx +1 -1
- package/src/primitives/FpsMeter.test.tsx +12 -4
- package/src/primitives/FpsMeter.tsx +6 -1
- package/src/primitives/JobProgress.stories.tsx +2 -2
- 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.less +6 -0
- 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 +31 -2
- package/src/state/useTrialState.test.tsx +85 -0
- package/src/state/useTrialState.ts +9 -1
- package/src/state/view.test.ts +4 -4
- 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 +36 -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-KJALCDWE.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/canvas/useOrbit.test.ts +0 -167
- package/src/canvas/useOrbit.ts +0 -156
- 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 { au as UndoCapability } from '../_dts/frac-B4hFJMiN.js';
|
|
2
2
|
import 'react';
|
|
3
|
-
import '../_dts/types-
|
|
3
|
+
import '../_dts/types-Db-3NNk1.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.2",
|
|
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.2",
|
|
119
|
+
"@weasel-js/theme": "1.5.2",
|
|
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.2",
|
|
125
|
+
"@weasel-js/kernel3d": "1.5.2",
|
|
126
|
+
"@weasel-js/loupe": "1.5.2",
|
|
127
|
+
"@weasel-js/svg": "1.5.2",
|
|
128
|
+
"@weasel-js/ui": "1.5.2",
|
|
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.2",
|
|
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' },
|
package/src/canvas/index.ts
CHANGED
|
@@ -10,15 +10,6 @@ export { screenToWorld, worldToScreen } from './canvasCoords';
|
|
|
10
10
|
export type { StageProps } from './Stage';
|
|
11
11
|
export { fitStage, Stage } from './Stage';
|
|
12
12
|
export type { CanvasLayerDescriptor } from './useLayerScheduler';
|
|
13
|
-
export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './useOrbit';
|
|
14
|
-
export {
|
|
15
|
-
clampPitch,
|
|
16
|
-
orbitAfterDrag,
|
|
17
|
-
orbitAfterWheel,
|
|
18
|
-
PITCH_LIMIT,
|
|
19
|
-
useOrbit,
|
|
20
|
-
wrapYaw,
|
|
21
|
-
} from './useOrbit';
|
|
22
13
|
export type { PanZoomHandlers, UsePanZoomOptions } from './usePanZoom';
|
|
23
14
|
export { usePanZoom } from './usePanZoom';
|
|
24
15
|
export type { ViewportSize, WorldFrame, WorldSpec } from './worldSpec';
|
|
@@ -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
|
|
@@ -40,8 +40,8 @@ export type LabContribution<TCtx = LabChromeContext> =
|
|
|
40
40
|
item: ToolbarItem<TCtx>;
|
|
41
41
|
render?: never;
|
|
42
42
|
})
|
|
43
|
-
| (ContributionBase & { region: 'palette'; item: ToolItem
|
|
44
|
-
| (ContributionBase & { region: 'sidebar'; item: SidebarSection; render?: never })
|
|
43
|
+
| (ContributionBase & { region: 'palette'; item: ToolItem<TCtx>; 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;
|
|
@@ -36,6 +36,24 @@ describe('PaletteRegion', () => {
|
|
|
36
36
|
expect(setActiveTool).toHaveBeenCalledWith('brush');
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
+
it('runs a command tool instead of selecting it, and never marks it current', () => {
|
|
40
|
+
const onActivate = vi.fn();
|
|
41
|
+
const setActiveTool = vi.fn();
|
|
42
|
+
const ctx = ctxWith('info', setActiveTool);
|
|
43
|
+
const info: RegionContribution<ToolSlotContext> = {
|
|
44
|
+
id: 'info',
|
|
45
|
+
region: 'palette',
|
|
46
|
+
item: { icon: Glyph, label: 'Info', onActivate },
|
|
47
|
+
};
|
|
48
|
+
render(<PaletteRegion contributions={[info]} ctx={ctx} />);
|
|
49
|
+
const button = screen.getByRole('button', { name: 'Info' });
|
|
50
|
+
button.click();
|
|
51
|
+
expect(onActivate).toHaveBeenCalledWith(ctx);
|
|
52
|
+
expect(setActiveTool).not.toHaveBeenCalled();
|
|
53
|
+
// The slot names this id, and a command still must not read as held down.
|
|
54
|
+
expect(button).not.toHaveAttribute('aria-current');
|
|
55
|
+
});
|
|
56
|
+
|
|
39
57
|
it('puts exactly one tool in the tab order', () => {
|
|
40
58
|
render(<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith(null)} />);
|
|
41
59
|
expect(screen.getByRole('button', { name: 'brush' })).toHaveAttribute('tabindex', '0');
|
|
@@ -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> {
|
|
@@ -17,7 +12,8 @@ export interface PaletteRegionProps<TCtx extends ToolSlotContext = TrialChromeCo
|
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
/** A tool strip: a trial's, or the lab's. Selection lives in whichever tool
|
|
20
|
-
* slot the context carries; this region only reflects it.
|
|
15
|
+
* slot the context carries; this region only reflects it. An item carrying
|
|
16
|
+
* `onActivate` is a command instead — it presses, and never latches. */
|
|
21
17
|
export function PaletteRegion<TCtx extends ToolSlotContext = TrialChromeContext>({
|
|
22
18
|
contributions,
|
|
23
19
|
ctx,
|
|
@@ -38,16 +34,16 @@ export function PaletteRegion<TCtx extends ToolSlotContext = TrialChromeContext>
|
|
|
38
34
|
{contributions.map((c) => {
|
|
39
35
|
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
40
36
|
if (c.region !== region || !c.item) return null;
|
|
41
|
-
const { icon: Icon, label, shortcut, disabled } = c.item as ToolItem
|
|
37
|
+
const { icon: Icon, label, shortcut, disabled, onActivate } = c.item as ToolItem<TCtx>;
|
|
42
38
|
return (
|
|
43
39
|
<ToolButton
|
|
44
40
|
key={c.id}
|
|
45
41
|
icon={<Icon size={16} />}
|
|
46
42
|
label={label}
|
|
47
43
|
shortcut={shortcut}
|
|
48
|
-
active={ctx.activeToolId === c.id}
|
|
44
|
+
active={onActivate ? false : ctx.activeToolId === c.id}
|
|
49
45
|
disabled={disabled}
|
|
50
|
-
onClick={() => ctx.setActiveTool(c.id)}
|
|
46
|
+
onClick={onActivate ? () => onActivate(ctx) : () => ctx.setActiveTool(c.id)}
|
|
51
47
|
/>
|
|
52
48
|
);
|
|
53
49
|
})}
|
|
@@ -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
|
@@ -31,12 +31,18 @@ export interface ToolbarItem<TCtx = TrialChromeContext> {
|
|
|
31
31
|
onActivate: (ctx: TCtx) => void;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** A
|
|
35
|
-
|
|
34
|
+
/** A tool in the palette region: a mode by default, a command when it carries
|
|
35
|
+
* an `onActivate`. `TCtx` is the chrome context that handler is handed — a
|
|
36
|
+
* trial's, or the lab's for a lab-level rail. */
|
|
37
|
+
export interface ToolItem<TCtx = TrialChromeContext> {
|
|
36
38
|
icon: IconComponent;
|
|
37
39
|
label: string;
|
|
40
|
+
/** Shown in the tooltip. Not bound here — the trial owns its keymap. */
|
|
38
41
|
shortcut?: string;
|
|
39
42
|
disabled?: boolean;
|
|
43
|
+
/** Runs on press instead of selecting. An item with one is a command: it
|
|
44
|
+
* never writes the tool slot, and never reads as the current tool. */
|
|
45
|
+
onActivate?: (ctx: TCtx) => void;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/** A titled block in the sidebar. */
|
|
@@ -165,6 +171,9 @@ export interface TrialChromeContext extends ToolSlotContext, SidebarSlotContext
|
|
|
165
171
|
* `config`, since a builder schema has no `ConfigField[]` form. */
|
|
166
172
|
configFields: ConfigField[];
|
|
167
173
|
config: unknown;
|
|
174
|
+
/** The trial's unpinned paths. `config` stays raw, so a control drawn from
|
|
175
|
+
* it sits at the value un-pinning writes back. */
|
|
176
|
+
auto: ReadonlySet<string>;
|
|
168
177
|
setConfig: (key: string, value: unknown) => void;
|
|
169
178
|
|
|
170
179
|
/** 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
|
+
}
|