@weasel-js/labkit 1.1.0 → 1.3.0-pre.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
- package/dist/canvas/index.d.ts +8 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/chunk-HFDVGF4X.js +170 -0
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +5 -2
- package/dist/index.d.ts +307 -107
- package/dist/index.js +797 -540
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +6 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3804 -3661
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +543 -144
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +4 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +5 -2
- package/package.json +24 -7
- package/src/canvas/AGENTS.md +12 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.test.ts +18 -0
- package/src/index.ts +38 -3
- package/src/instrument/SineWave.smoke.test.tsx +9 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +38 -3
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.stories.tsx +1 -2
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +19 -9
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +16 -2
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +8 -2
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +6 -0
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +161 -0
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +73 -0
- package/src/trial/Trial.less +121 -4
- package/src/trial/Trial.stories.tsx +9 -1
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +112 -45
- package/src/trial/TrialChrome.tsx +110 -64
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-3TYUJR7Z.js.map +0 -1
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-C6GJKPUI.js +0 -5986
- package/dist/chunk-C6GJKPUI.js.map +0 -1
- package/dist/chunk-DJLDIRFN.js.map +0 -1
- package/dist/chunk-NRKWVTVT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- package/src/trial/slotTypes.ts +0 -53
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
/** A box in CSS pixels, measured from the surface's own top-left corner. */
|
|
4
|
+
interface Rect {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
w: number;
|
|
8
|
+
h: number;
|
|
9
|
+
}
|
|
10
|
+
/** The subset of `DOMRect` this package reads. Accepting the subset rather than
|
|
11
|
+
* `DOMRect` is what lets the pure functions be tested without a DOM. */
|
|
12
|
+
interface Box {
|
|
13
|
+
left: number;
|
|
14
|
+
top: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Every tile's box expressed against the container's origin. */
|
|
20
|
+
declare function composeRects(container: Box, tiles: ReadonlyMap<string, Box>): Map<string, Rect>;
|
|
21
|
+
declare function rectsEqual(a: Rect | undefined, b: Rect): boolean;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A DOM rect as a GL viewport rect: origin at the bottom-left, every edge snapped
|
|
25
|
+
* to the device-pixel grid. Still CSS pixels, because three.js applies its own
|
|
26
|
+
* pixel ratio — snapping here is what stops a tile and its neighbour rounding
|
|
27
|
+
* apart and leaving a hairline column between them.
|
|
28
|
+
*/
|
|
29
|
+
declare function toDeviceRect(rect: Rect, surfaceHeight: number, dpr: number): Rect;
|
|
30
|
+
|
|
31
|
+
/** What a surface owner is handed once per animation frame. `rects` carries every
|
|
32
|
+
* tile, not only the dirty ones: a scissored draw has to know where it is drawing
|
|
33
|
+
* relative to a surface that may have resized under it. */
|
|
34
|
+
interface SurfaceFrame {
|
|
35
|
+
dirty: ReadonlySet<string>;
|
|
36
|
+
rects: ReadonlyMap<string, Rect>;
|
|
37
|
+
dpr: number;
|
|
38
|
+
size: {
|
|
39
|
+
width: number;
|
|
40
|
+
height: number;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** The invalidators and the two ref callbacks that publish geometry. */
|
|
44
|
+
interface SurfaceHandle {
|
|
45
|
+
/** Mark one tile for redraw. */
|
|
46
|
+
invalidate: (id: string) => void;
|
|
47
|
+
/** Mark every tile — what a resize or a tile-set change means. */
|
|
48
|
+
invalidateAll: () => void;
|
|
49
|
+
/** Re-measure before the next frame. The escape hatch for a host that knows it
|
|
50
|
+
* moved something a ResizeObserver cannot see. */
|
|
51
|
+
invalidateRects: () => void;
|
|
52
|
+
registerTile: (id: string, el: HTMLElement | null) => void;
|
|
53
|
+
containerRef: (el: HTMLElement | null) => void;
|
|
54
|
+
}
|
|
55
|
+
interface UseTiledSurfaceOptions {
|
|
56
|
+
onFrame: (frame: SurfaceFrame) => void;
|
|
57
|
+
}
|
|
58
|
+
declare function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHandle;
|
|
59
|
+
|
|
60
|
+
/** Null when no surface owner is above — a lab with no shared surface at all. */
|
|
61
|
+
declare const SurfaceContext: react.Context<SurfaceHandle | null>;
|
|
62
|
+
|
|
63
|
+
/** The surface above, or null. Use this where a surface is genuinely optional. */
|
|
64
|
+
declare function useSurfaceOptional(): SurfaceHandle | null;
|
|
65
|
+
/** The surface above. Throws where a caller cannot work without one. */
|
|
66
|
+
declare function useSurface(): SurfaceHandle;
|
|
67
|
+
/**
|
|
68
|
+
* A ref callback that publishes this element's rect to the surface under `id`.
|
|
69
|
+
*
|
|
70
|
+
* Attach it to whichever element the surface should draw into — that is not
|
|
71
|
+
* necessarily the trial's own element, since a trial may hold a drawn pane beside
|
|
72
|
+
* an undrawn one, or none at all.
|
|
73
|
+
*/
|
|
74
|
+
declare function useSurfaceTile(id: string): (el: HTMLElement | null) => void;
|
|
75
|
+
|
|
76
|
+
export { SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceOptional, useSurfaceTile, useTiledSurface };
|
|
77
|
+
export type { Box, Rect, SurfaceFrame, SurfaceHandle, UseTiledSurfaceOptions };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
package/dist/ui/layers/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { LayerStack } from '../../chunk-
|
|
2
|
-
import '../../chunk-
|
|
3
|
-
import '../../chunk-
|
|
1
|
+
export { LayerStack } from '../../chunk-O2BULFHX.js';
|
|
2
|
+
import '../../chunk-TLGRYALP.js';
|
|
3
|
+
import '../../chunk-Z6HJ5FEM.js';
|
|
4
|
+
import '../../chunk-PQJ5B2U2.js';
|
|
4
5
|
//# sourceMappingURL=index.js.map
|
|
5
6
|
//# sourceMappingURL=index.js.map
|
package/dist/undo/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
export { U as UndoCapability } from '../_dts/index-
|
|
1
|
+
export { U as UndoCapability } from '../_dts/index-DgcNdEdh.js';
|
|
2
2
|
import 'react';
|
|
3
|
-
import '../_dts/types-
|
|
3
|
+
import '../_dts/types-AHlQxBNN.js';
|
|
4
|
+
import '../_dts/PrefsForm-BYa6cWnO.js';
|
|
5
|
+
import '../_dts/types-1Sdxy_Pv.js';
|
|
6
|
+
import '../_dts/types-DJ79Tg5J.js';
|
|
4
7
|
|
|
5
8
|
/** A subscriber to a named event. */
|
|
6
9
|
type EventListener = () => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/labkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0-pre.0",
|
|
4
4
|
"description": "React widgets for building self-contained interactive lab pages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "orochi235",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"url": "https://github.com/orochi235/weasel/issues"
|
|
15
15
|
},
|
|
16
16
|
"type": "module",
|
|
17
|
+
"sideEffects": [
|
|
18
|
+
"*.css"
|
|
19
|
+
],
|
|
17
20
|
"engines": {
|
|
18
21
|
"node": ">=22"
|
|
19
22
|
},
|
|
@@ -27,6 +30,10 @@
|
|
|
27
30
|
"types": "./dist/index.d.ts",
|
|
28
31
|
"import": "./dist/index.js"
|
|
29
32
|
},
|
|
33
|
+
"./chrome": {
|
|
34
|
+
"types": "./dist/chrome/index.d.ts",
|
|
35
|
+
"import": "./dist/chrome/index.js"
|
|
36
|
+
},
|
|
30
37
|
"./primitives": {
|
|
31
38
|
"types": "./dist/primitives/index.d.ts",
|
|
32
39
|
"import": "./dist/primitives/index.js"
|
|
@@ -55,6 +62,14 @@
|
|
|
55
62
|
"types": "./dist/state/index.d.ts",
|
|
56
63
|
"import": "./dist/state/index.js"
|
|
57
64
|
},
|
|
65
|
+
"./surface": {
|
|
66
|
+
"types": "./dist/surface/index.d.ts",
|
|
67
|
+
"import": "./dist/surface/index.js"
|
|
68
|
+
},
|
|
69
|
+
"./job": {
|
|
70
|
+
"types": "./dist/job/index.d.ts",
|
|
71
|
+
"import": "./dist/job/index.js"
|
|
72
|
+
},
|
|
58
73
|
"./weasel-ui": {
|
|
59
74
|
"types": "./dist/passthrough/weasel-ui.d.ts",
|
|
60
75
|
"import": "./dist/passthrough/weasel-ui.js"
|
|
@@ -81,31 +96,33 @@
|
|
|
81
96
|
"test": "vitest run",
|
|
82
97
|
"test:watch": "vitest",
|
|
83
98
|
"test:smoke:consumer": "node scripts/smoke-consumer-bundle.mjs",
|
|
84
|
-
"lint": "biome check . && tsx scripts/check-class-prefix.ts",
|
|
99
|
+
"lint": "biome check . && tsx scripts/check-class-prefix.ts && tsx scripts/check-design-tokens.ts",
|
|
85
100
|
"lint:fix": "biome check --write .",
|
|
86
101
|
"format": "biome format --write .",
|
|
87
102
|
"docs:dev": "vitepress dev docs",
|
|
88
103
|
"docs:build": "vitepress build docs",
|
|
89
|
-
"docs:preview": "vitepress preview docs"
|
|
104
|
+
"docs:preview": "vitepress preview docs",
|
|
105
|
+
"dev:schema": "LABKIT_EXAMPLE=schema-lab vite"
|
|
90
106
|
},
|
|
91
107
|
"peerDependencies": {
|
|
92
108
|
"react": "^19.0.0",
|
|
93
109
|
"react-dom": "^19.0.0"
|
|
94
110
|
},
|
|
95
111
|
"dependencies": {
|
|
96
|
-
"@weasel-js/
|
|
112
|
+
"@weasel-js/core": "1.3.0-pre.0",
|
|
113
|
+
"@weasel-js/theme": "1.3.0-pre.0",
|
|
114
|
+
"@weasel-js/ui": "1.3.0-pre.0",
|
|
97
115
|
"earcut": "2.2.4",
|
|
98
116
|
"polygon-clipping": "^0.15.7",
|
|
99
117
|
"react-aria-components": "^1.5.0",
|
|
100
|
-
"windease": "^1.
|
|
118
|
+
"windease": "^1.3.0",
|
|
101
119
|
"zustand": "^5.0.12"
|
|
102
120
|
},
|
|
103
121
|
"devDependencies": {
|
|
104
122
|
"@biomejs/biome": "^2.4.12",
|
|
105
123
|
"@rollup/plugin-alias": "^6.0.0",
|
|
106
124
|
"@testing-library/jest-dom": "^6.6.0",
|
|
107
|
-
"@weasel-js/modes": "
|
|
108
|
-
"@weasel-js/ui": "*",
|
|
125
|
+
"@weasel-js/modes": "1.3.0-pre.0",
|
|
109
126
|
"less": "^4.2.0",
|
|
110
127
|
"rollup-plugin-dts": "^6.4.1",
|
|
111
128
|
"tsx": "^4.19.0",
|
package/src/canvas/AGENTS.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
The `src/canvas/` directory implements the layered canvas primitive used by the `canvas` instrument capability.
|
|
4
4
|
|
|
5
|
+
For a renderer labkit does not own — three.js, raw WebGL, anything with its own
|
|
6
|
+
render loop — see `src/surface/AGENTS.md` instead. `CanvasStack` is 2D and
|
|
7
|
+
schedules its own layers; a foreign renderer wants rects and dirtiness only.
|
|
8
|
+
|
|
9
|
+
`useOrbit` also lives here, as the 3D peer of `usePanZoom`. It produces a trial
|
|
10
|
+
view (`{ yaw, pitch, distance, target }`) rather than a matrix — turning that
|
|
11
|
+
into a camera is the host's job.
|
|
12
|
+
|
|
5
13
|
## Files
|
|
6
14
|
|
|
7
15
|
| File | Role |
|
|
@@ -20,6 +28,8 @@ interface CanvasStackProps {
|
|
|
20
28
|
layers: CanvasLayerDescriptor[]; // { id, visible, render(ctx, view) }
|
|
21
29
|
view: ViewTransform; // { zoom, pan: { x, y } }
|
|
22
30
|
onViewChange: (v: ViewTransform) => void;
|
|
31
|
+
minZoom?: number; // default 0.1, forwarded to usePanZoom
|
|
32
|
+
maxZoom?: number; // default 32, forwarded to usePanZoom
|
|
23
33
|
width?: number | string; // default '100%'
|
|
24
34
|
height?: number | string; // default '100%'
|
|
25
35
|
className?: string;
|
|
@@ -71,6 +81,8 @@ Both are pure. Pan is applied in screen space; zoom multiplies world coordinates
|
|
|
71
81
|
|
|
72
82
|
Implemented in `usePanZoom`. Mouse-wheel zoom is anchored at the cursor; primary-button drag pans. `isDragging()` is exposed so consumers can suppress click handlers during a pan.
|
|
73
83
|
|
|
84
|
+
Zoom is clamped to `[minZoom, maxZoom]` (defaults 0.1 / 32), settable via `CanvasStack`'s props and, for an instrument's own canvas, `CanvasCapability.minZoom` / `maxZoom`. The clamp always widens to admit whatever zoom the canvas opened at — an instrument declaring `initialView.zoom: 1600` with the default range is not clamped down to 32 on the first wheel event.
|
|
85
|
+
|
|
74
86
|
## Testing notes
|
|
75
87
|
|
|
76
88
|
JSDOM does not provide a real `CanvasRenderingContext2D`. Tests in `CanvasStack.test.tsx` rely on the scheduler's defensive checks; they do not assert pixels. For pixel testing, use Storybook's `chromatic` or visual snapshots in a real browser.
|
|
@@ -10,6 +10,8 @@ export interface CanvasStackProps {
|
|
|
10
10
|
layers: CanvasLayerDescriptor[];
|
|
11
11
|
view: ViewTransform;
|
|
12
12
|
onViewChange: (v: ViewTransform) => void;
|
|
13
|
+
minZoom?: number;
|
|
14
|
+
maxZoom?: number;
|
|
13
15
|
width?: number | string;
|
|
14
16
|
height?: number | string;
|
|
15
17
|
className?: string;
|
|
@@ -24,6 +26,8 @@ export function CanvasStack({
|
|
|
24
26
|
layers,
|
|
25
27
|
view,
|
|
26
28
|
onViewChange,
|
|
29
|
+
minZoom,
|
|
30
|
+
maxZoom,
|
|
27
31
|
width = '100%',
|
|
28
32
|
height = '100%',
|
|
29
33
|
className,
|
|
@@ -59,8 +63,8 @@ export function CanvasStack({
|
|
|
59
63
|
return () => ro.disconnect();
|
|
60
64
|
}, []);
|
|
61
65
|
|
|
62
|
-
const handlers = usePanZoom({ view, onViewChange });
|
|
63
|
-
useLayerScheduler({ layers, view, canvasRefs: canvasMap, size });
|
|
66
|
+
const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom });
|
|
67
|
+
useLayerScheduler({ layers, view, canvasRefs: canvasMap, size, host: containerRef });
|
|
64
68
|
|
|
65
69
|
const ctxValue = useMemo(() => ({ view }), [view]);
|
|
66
70
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useVisibleRaf } from '@weasel-js/core';
|
|
1
2
|
import { type RefObject, useEffect, useRef } from 'react';
|
|
2
3
|
import type { ViewTransform } from '../instrument/types';
|
|
3
4
|
|
|
@@ -14,11 +15,19 @@ interface SchedulerOptions {
|
|
|
14
15
|
view: ViewTransform;
|
|
15
16
|
canvasRefs: RefObject<Map<string, HTMLCanvasElement>>;
|
|
16
17
|
size: { width: number; height: number; dpr: number };
|
|
18
|
+
/** The element the stack occupies. Given one, the scheduler also stops while
|
|
19
|
+
* the stack sits outside the viewport. */
|
|
20
|
+
host?: RefObject<Element | null>;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
export function useLayerScheduler({
|
|
23
|
+
export function useLayerScheduler({
|
|
24
|
+
layers,
|
|
25
|
+
view,
|
|
26
|
+
canvasRefs,
|
|
27
|
+
size,
|
|
28
|
+
host,
|
|
29
|
+
}: SchedulerOptions): void {
|
|
20
30
|
const dirty = useRef<Set<string>>(new Set());
|
|
21
|
-
const rafId = useRef<number | null>(null);
|
|
22
31
|
const lastRenderRef = useRef<Map<string, CanvasLayerDescriptor['render']>>(new Map());
|
|
23
32
|
|
|
24
33
|
useEffect(() => {
|
|
@@ -37,9 +46,11 @@ export function useLayerScheduler({ layers, view, canvasRefs, size }: SchedulerO
|
|
|
37
46
|
for (const layer of layers) dirty.current.add(layer.id);
|
|
38
47
|
}, [view, size, layers]);
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
// Painting only what is dirty is not the same as doing nothing: a hidden tab
|
|
50
|
+
// still commits React updates, and the effect above marks every layer dirty
|
|
51
|
+
// on any view or size change. The gate is what actually stops the work.
|
|
52
|
+
const frameLoop = useVisibleRaf(
|
|
53
|
+
() => {
|
|
43
54
|
if (dirty.current.size === 0) return;
|
|
44
55
|
const map = canvasRefs.current;
|
|
45
56
|
if (!map) {
|
|
@@ -60,15 +71,11 @@ export function useLayerScheduler({ layers, view, canvasRefs, size }: SchedulerO
|
|
|
60
71
|
ctx.restore();
|
|
61
72
|
}
|
|
62
73
|
dirty.current.clear();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
cancelAnimationFrame(rafId.current);
|
|
70
|
-
rafId.current = null;
|
|
71
|
-
}
|
|
72
|
-
};
|
|
74
|
+
},
|
|
75
|
+
{ target: host },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (dirty.current.size > 0) frameLoop.request();
|
|
73
80
|
});
|
|
74
81
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { clampPitch, orbitAfterDrag, orbitAfterWheel, PITCH_LIMIT, wrapYaw } from './useOrbit';
|
|
3
|
+
|
|
4
|
+
const view = { yaw: 0, pitch: 0, distance: 5, target: { x: 0, y: 0, z: 0 } };
|
|
5
|
+
|
|
6
|
+
describe('clampPitch', () => {
|
|
7
|
+
it('stops just short of the poles, where azimuth becomes undefined', () => {
|
|
8
|
+
expect(clampPitch(Math.PI)).toBeCloseTo(PITCH_LIMIT);
|
|
9
|
+
expect(clampPitch(-Math.PI)).toBeCloseTo(-PITCH_LIMIT);
|
|
10
|
+
expect(PITCH_LIMIT).toBeLessThan(Math.PI / 2);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('leaves an in-range pitch alone', () => {
|
|
14
|
+
expect(clampPitch(0.3)).toBe(0.3);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('wrapYaw', () => {
|
|
19
|
+
it('wraps into (-PI, PI] so a value cannot drift without bound', () => {
|
|
20
|
+
expect(wrapYaw(3 * Math.PI)).toBeCloseTo(Math.PI);
|
|
21
|
+
expect(wrapYaw(-3 * Math.PI)).toBeCloseTo(Math.PI);
|
|
22
|
+
expect(wrapYaw(0.5)).toBeCloseTo(0.5);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('keeps a full turn equivalent to no turn', () => {
|
|
26
|
+
expect(wrapYaw(2 * Math.PI)).toBeCloseTo(0);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('orbitAfterDrag', () => {
|
|
31
|
+
it('turns horizontal movement into yaw and vertical into pitch', () => {
|
|
32
|
+
const next = orbitAfterDrag(view, 100, 50);
|
|
33
|
+
expect(next.yaw).not.toBe(view.yaw);
|
|
34
|
+
expect(next.pitch).not.toBe(view.pitch);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is absolute against the drag start, so re-applying does not compound', () => {
|
|
38
|
+
const once = orbitAfterDrag(view, 100, 50);
|
|
39
|
+
const twice = orbitAfterDrag(view, 100, 50);
|
|
40
|
+
expect(twice).toEqual(once);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('clamps pitch rather than tumbling past the pole', () => {
|
|
44
|
+
const next = orbitAfterDrag(view, 0, 100_000);
|
|
45
|
+
expect(Math.abs(next.pitch)).toBeLessThanOrEqual(PITCH_LIMIT);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('leaves distance and target untouched', () => {
|
|
49
|
+
const next = orbitAfterDrag(view, 100, 50);
|
|
50
|
+
expect(next.distance).toBe(view.distance);
|
|
51
|
+
expect(next.target).toEqual(view.target);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('orbitAfterWheel', () => {
|
|
56
|
+
it('moves the camera in and out', () => {
|
|
57
|
+
expect(orbitAfterWheel(view, 100, 0.5, 50).distance).toBeGreaterThan(view.distance);
|
|
58
|
+
expect(orbitAfterWheel(view, -100, 0.5, 50).distance).toBeLessThan(view.distance);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is multiplicative, so a step feels the same at every distance', () => {
|
|
62
|
+
const near = orbitAfterWheel({ ...view, distance: 2 }, 100, 0.5, 50);
|
|
63
|
+
const far = orbitAfterWheel({ ...view, distance: 20 }, 100, 0.5, 50);
|
|
64
|
+
expect(far.distance / 20).toBeCloseTo(near.distance / 2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('honours its bounds', () => {
|
|
68
|
+
expect(orbitAfterWheel(view, 100_000, 0.5, 50).distance).toBe(50);
|
|
69
|
+
expect(orbitAfterWheel(view, -100_000, 0.5, 50).distance).toBe(0.5);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type PointerEvent, useCallback, useRef, type WheelEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
/** A point in the space the instrument works in. */
|
|
4
|
+
export interface Vec3 {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
z: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** An orbit camera as a trial view: where it looks from, and what it looks at.
|
|
11
|
+
* labkit does not turn this into a matrix — the host's renderer does. */
|
|
12
|
+
export interface OrbitView {
|
|
13
|
+
yaw: number;
|
|
14
|
+
pitch: number;
|
|
15
|
+
distance: number;
|
|
16
|
+
target: Vec3;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Just short of the pole. At exactly ±PI/2 the azimuth is undefined and the
|
|
20
|
+
* camera rolls, which reads as the model jumping rather than as a limit. */
|
|
21
|
+
export const PITCH_LIMIT = Math.PI / 2 - 0.01;
|
|
22
|
+
|
|
23
|
+
const YAW_PER_PX = 0.008;
|
|
24
|
+
const PITCH_PER_PX = 0.008;
|
|
25
|
+
const DISTANCE_PER_NOTCH = 0.0015;
|
|
26
|
+
const DRAG_THRESHOLD = 3;
|
|
27
|
+
|
|
28
|
+
export function clampPitch(pitch: number): number {
|
|
29
|
+
return Math.min(PITCH_LIMIT, Math.max(-PITCH_LIMIT, pitch));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function wrapYaw(yaw: number): number {
|
|
33
|
+
const wrapped = ((((yaw + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) - Math.PI;
|
|
34
|
+
return wrapped === -Math.PI ? Math.PI : wrapped;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The view a drag of (dx, dy) from `start` produces. Absolute against the drag
|
|
38
|
+
* start, so it can be re-applied any number of times without compounding. */
|
|
39
|
+
export function orbitAfterDrag(start: OrbitView, dx: number, dy: number): OrbitView {
|
|
40
|
+
return {
|
|
41
|
+
...start,
|
|
42
|
+
yaw: wrapYaw(start.yaw + dx * YAW_PER_PX),
|
|
43
|
+
pitch: clampPitch(start.pitch + dy * PITCH_PER_PX),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Multiplicative, so one notch covers the same proportion of the distance
|
|
48
|
+
* whether the camera is near or far. */
|
|
49
|
+
export function orbitAfterWheel(
|
|
50
|
+
view: OrbitView,
|
|
51
|
+
deltaY: number,
|
|
52
|
+
minDistance: number,
|
|
53
|
+
maxDistance: number,
|
|
54
|
+
): OrbitView {
|
|
55
|
+
const factor = Math.exp(deltaY * DISTANCE_PER_NOTCH);
|
|
56
|
+
return {
|
|
57
|
+
...view,
|
|
58
|
+
distance: Math.min(maxDistance, Math.max(minDistance, view.distance * factor)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UseOrbitOptions {
|
|
63
|
+
view: OrbitView;
|
|
64
|
+
onViewChange: (v: OrbitView) => void;
|
|
65
|
+
/** Restored on double-click. Defaults to the view the hook first saw. */
|
|
66
|
+
home?: OrbitView;
|
|
67
|
+
minDistance?: number;
|
|
68
|
+
maxDistance?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface OrbitHandlers {
|
|
72
|
+
onWheel: (e: WheelEvent<HTMLElement>) => void;
|
|
73
|
+
onPointerDown: (e: PointerEvent<HTMLElement>) => void;
|
|
74
|
+
onPointerMove: (e: PointerEvent<HTMLElement>) => void;
|
|
75
|
+
onPointerUp: (e: PointerEvent<HTMLElement>) => void;
|
|
76
|
+
onDoubleClick: () => void;
|
|
77
|
+
isDragging: () => boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface DragState {
|
|
81
|
+
pointerId: number;
|
|
82
|
+
startX: number;
|
|
83
|
+
startY: number;
|
|
84
|
+
startView: OrbitView;
|
|
85
|
+
moved: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Pointer gestures over an orbit view: drag to turn, wheel or pinch to dolly,
|
|
89
|
+
* double-click to go home. The 3D peer of `usePanZoom`. */
|
|
90
|
+
export function useOrbit({
|
|
91
|
+
view,
|
|
92
|
+
onViewChange,
|
|
93
|
+
home,
|
|
94
|
+
minDistance = 0.1,
|
|
95
|
+
maxDistance = 1000,
|
|
96
|
+
}: UseOrbitOptions): OrbitHandlers {
|
|
97
|
+
const dragRef = useRef<DragState | null>(null);
|
|
98
|
+
const viewRef = useRef(view);
|
|
99
|
+
viewRef.current = view;
|
|
100
|
+
const homeRef = useRef(home ?? view);
|
|
101
|
+
if (home) homeRef.current = home;
|
|
102
|
+
|
|
103
|
+
const onWheel = useCallback(
|
|
104
|
+
(e: WheelEvent<HTMLElement>) => {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
onViewChange(orbitAfterWheel(viewRef.current, e.deltaY, minDistance, maxDistance));
|
|
107
|
+
},
|
|
108
|
+
[onViewChange, minDistance, maxDistance],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
|
|
112
|
+
if (e.button !== 0) return;
|
|
113
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
114
|
+
dragRef.current = {
|
|
115
|
+
pointerId: e.pointerId,
|
|
116
|
+
startX: e.clientX,
|
|
117
|
+
startY: e.clientY,
|
|
118
|
+
startView: viewRef.current,
|
|
119
|
+
moved: false,
|
|
120
|
+
};
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
const onPointerMove = useCallback(
|
|
124
|
+
(e: PointerEvent<HTMLElement>) => {
|
|
125
|
+
const drag = dragRef.current;
|
|
126
|
+
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
127
|
+
const dx = e.clientX - drag.startX;
|
|
128
|
+
const dy = e.clientY - drag.startY;
|
|
129
|
+
if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
130
|
+
drag.moved = true;
|
|
131
|
+
onViewChange(orbitAfterDrag(drag.startView, dx, dy));
|
|
132
|
+
},
|
|
133
|
+
[onViewChange],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {
|
|
137
|
+
const drag = dragRef.current;
|
|
138
|
+
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
139
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
140
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
141
|
+
}
|
|
142
|
+
dragRef.current = null;
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const onDoubleClick = useCallback(() => onViewChange(homeRef.current), [onViewChange]);
|
|
146
|
+
const isDragging = useCallback(() => dragRef.current?.moved === true, []);
|
|
147
|
+
|
|
148
|
+
return { onWheel, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, isDragging };
|
|
149
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import type { WheelEvent } from 'react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import type { ViewTransform } from '../instrument/types';
|
|
5
|
+
import { type UsePanZoomOptions, usePanZoom } from './usePanZoom';
|
|
6
|
+
|
|
7
|
+
function wheelEvent(deltaY: number): WheelEvent<HTMLElement> {
|
|
8
|
+
return {
|
|
9
|
+
deltaY,
|
|
10
|
+
clientX: 0,
|
|
11
|
+
clientY: 0,
|
|
12
|
+
preventDefault: () => {},
|
|
13
|
+
currentTarget: { getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }) },
|
|
14
|
+
} as unknown as WheelEvent<HTMLElement>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Renders the hook the way `CanvasStack` drives it: `view` lives in the
|
|
18
|
+
* test, `onViewChange` writes it, and each wheel call re-renders the hook
|
|
19
|
+
* with the updated view before returning it. */
|
|
20
|
+
function setup(initialView: ViewTransform, options?: Partial<UsePanZoomOptions>) {
|
|
21
|
+
let view = initialView;
|
|
22
|
+
const onViewChange = (v: ViewTransform) => {
|
|
23
|
+
view = v;
|
|
24
|
+
};
|
|
25
|
+
const { result, rerender } = renderHook(
|
|
26
|
+
(props: { view: ViewTransform }) => usePanZoom({ ...options, view: props.view, onViewChange }),
|
|
27
|
+
{ initialProps: { view: initialView } },
|
|
28
|
+
);
|
|
29
|
+
const wheel = (deltaY: number): ViewTransform => {
|
|
30
|
+
act(() => result.current.onWheel(wheelEvent(deltaY)));
|
|
31
|
+
rerender({ view });
|
|
32
|
+
return view;
|
|
33
|
+
};
|
|
34
|
+
return { wheel, getView: () => view };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('usePanZoom', () => {
|
|
38
|
+
it('does not collapse a canvas opening far outside the default range on the first wheel event', () => {
|
|
39
|
+
const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } });
|
|
40
|
+
const next = wheel(-1); // zoom in slightly
|
|
41
|
+
expect(next.zoom).toBeCloseTo(1600, 0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('keeps the opening zoom reachable after zooming away from it', () => {
|
|
45
|
+
const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } });
|
|
46
|
+
// Zoom far out.
|
|
47
|
+
for (let i = 0; i < 20; i++) wheel(1000);
|
|
48
|
+
const zoomedOut = wheel(1000);
|
|
49
|
+
expect(zoomedOut.zoom).toBeLessThan(1600);
|
|
50
|
+
// Zoom back in — the opening view must still be reachable.
|
|
51
|
+
for (let i = 0; i < 40; i++) wheel(-1000);
|
|
52
|
+
const zoomedIn = wheel(-1000);
|
|
53
|
+
expect(zoomedIn.zoom).toBeCloseTo(1600, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('admits the opening zoom even when an explicit maxZoom is lower than it', () => {
|
|
57
|
+
const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } }, { maxZoom: 32 });
|
|
58
|
+
const next = wheel(-1);
|
|
59
|
+
expect(next.zoom).toBeCloseTo(1600, 0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('falls back to the plain defaults when the opening zoom is non-finite or zero', () => {
|
|
63
|
+
const nanCase = setup({ zoom: Number.NaN, pan: { x: 0, y: 0 } });
|
|
64
|
+
const nanResult = nanCase.wheel(-100000);
|
|
65
|
+
expect(nanResult.zoom).toBeCloseTo(32, 5);
|
|
66
|
+
expect(Number.isNaN(nanResult.zoom)).toBe(false);
|
|
67
|
+
|
|
68
|
+
const zeroCase = setup({ zoom: 0, pan: { x: 0, y: 0 } });
|
|
69
|
+
const zeroResult = zeroCase.wheel(100000);
|
|
70
|
+
expect(zeroResult.zoom).toBeCloseTo(0.1, 5);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('still clamps ordinary views to the default range', () => {
|
|
74
|
+
const { wheel } = setup({ zoom: 1, pan: { x: 0, y: 0 } });
|
|
75
|
+
const zoomedIn = wheel(-1_000_000);
|
|
76
|
+
expect(zoomedIn.zoom).toBeCloseTo(32, 5);
|
|
77
|
+
const zoomedOut = wheel(1_000_000);
|
|
78
|
+
expect(zoomedOut.zoom).toBeCloseTo(0.1, 5);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/canvas/usePanZoom.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { ViewTransform } from '../instrument/types';
|
|
|
4
4
|
export interface UsePanZoomOptions {
|
|
5
5
|
view: ViewTransform;
|
|
6
6
|
onViewChange: (v: ViewTransform) => void;
|
|
7
|
+
/** The opening `view.zoom` always stays reachable, widening these past
|
|
8
|
+
* whatever is passed here if it would otherwise exclude it. */
|
|
7
9
|
minZoom?: number;
|
|
8
10
|
maxZoom?: number;
|
|
9
11
|
}
|
|
@@ -26,6 +28,10 @@ interface DragState {
|
|
|
26
28
|
|
|
27
29
|
const DRAG_THRESHOLD = 3;
|
|
28
30
|
|
|
31
|
+
function isPositiveFinite(n: number): boolean {
|
|
32
|
+
return Number.isFinite(n) && n > 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
export function usePanZoom({
|
|
30
36
|
view,
|
|
31
37
|
onViewChange,
|
|
@@ -35,6 +41,10 @@ export function usePanZoom({
|
|
|
35
41
|
const dragRef = useRef<DragState | null>(null);
|
|
36
42
|
const viewRef = useRef(view);
|
|
37
43
|
viewRef.current = view;
|
|
44
|
+
// Captured once, from the view the canvas opened at — not the current view,
|
|
45
|
+
// which would let a prior zoom-out shrink the range and trap the opening
|
|
46
|
+
// view unreachable behind it.
|
|
47
|
+
const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
|
|
38
48
|
|
|
39
49
|
const onWheel = useCallback(
|
|
40
50
|
(e: WheelEvent<HTMLElement>) => {
|
|
@@ -43,9 +53,13 @@ export function usePanZoom({
|
|
|
43
53
|
const cursorX = e.clientX - rect.left;
|
|
44
54
|
const cursorY = e.clientY - rect.top;
|
|
45
55
|
const v = viewRef.current;
|
|
56
|
+
const initialZoom = initialZoomRef.current;
|
|
57
|
+
const effectiveMin = initialZoom == null ? minZoom : Math.min(minZoom, initialZoom);
|
|
58
|
+
const effectiveMax = initialZoom == null ? maxZoom : Math.max(maxZoom, initialZoom);
|
|
46
59
|
const factor = Math.exp(-e.deltaY * 0.001);
|
|
47
|
-
const
|
|
48
|
-
const
|
|
60
|
+
const rawZoom = isPositiveFinite(v.zoom) ? v.zoom : 1;
|
|
61
|
+
const nextZoom = Math.min(effectiveMax, Math.max(effectiveMin, rawZoom * factor));
|
|
62
|
+
const ratio = nextZoom / rawZoom;
|
|
49
63
|
const nextPan = {
|
|
50
64
|
x: cursorX - (cursorX - v.pan.x) * ratio,
|
|
51
65
|
y: cursorY - (cursorY - v.pan.y) * ratio,
|