@weasel-js/labkit 1.4.4 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -6
- package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
- package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
- package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
- package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
- package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
- package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
- package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
- package/dist/canvas/index.d.ts +18 -129
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +5 -93
- package/dist/chrome/index.js +10 -6
- package/dist/chunk-7PFH3CNM.js +150 -0
- package/dist/chunk-7PFH3CNM.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
- package/dist/chunk-CEIUBPPW.js.map +1 -0
- package/dist/chunk-EH7UEQV7.js +590 -0
- package/dist/chunk-EH7UEQV7.js.map +1 -0
- package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
- package/dist/chunk-EHGVROVV.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
- package/dist/chunk-ERFMC6WU.js.map +1 -0
- package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
- package/dist/chunk-H6ZAOWNE.js.map +1 -0
- package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
- package/dist/chunk-IHUVQ3TX.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
- package/dist/chunk-IJJUK4FU.js.map +1 -0
- package/dist/chunk-IXTQI7JX.js +737 -0
- package/dist/chunk-IXTQI7JX.js.map +1 -0
- package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
- package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
- package/dist/chunk-PHK4W562.js +72 -0
- package/dist/chunk-PHK4W562.js.map +1 -0
- package/dist/chunk-PJ7WEQBJ.js +18 -0
- package/dist/chunk-PJ7WEQBJ.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-T3EC2DXD.js +12628 -0
- package/dist/chunk-T3EC2DXD.js.map +1 -0
- package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
- package/dist/chunk-UN4U4P63.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/chunk-Z76KEZAX.js +15 -0
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/chunk-ZDHY47EV.js +363 -0
- package/dist/chunk-ZDHY47EV.js.map +1 -0
- package/dist/config/index.d.ts +276 -0
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +3 -4
- package/dist/controls/index.js +4 -3
- package/dist/dragdrop/index.d.ts +3 -5
- package/dist/index.d.ts +266 -413
- package/dist/index.js +1775 -2251
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -6
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +69 -20
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +175 -309
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +55 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +13 -8
- package/dist/state/index.js +18 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +223 -76
- package/dist/surface/index.d.ts +44 -7
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +3 -5
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +56 -5
- package/src/annotations/Annotations.less +4 -0
- package/src/annotations/Annotations.overlay.test.tsx +105 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/paint.test.ts +2 -2
- package/src/annotations/paint.ts +1 -1
- package/src/annotations/preload.ts +6 -0
- package/src/annotations/store.test.ts +18 -0
- package/src/annotations/store.ts +5 -4
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -0
- package/src/annotations/toolMap.test.ts +9 -5
- package/src/annotations/toolMap.ts +6 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.stories.tsx +1 -1
- package/src/canvas/CanvasStack.test.tsx +25 -0
- package/src/canvas/CanvasStack.tsx +31 -4
- package/src/canvas/Stage.less +18 -0
- package/src/canvas/Stage.test.tsx +186 -0
- package/src/canvas/Stage.tsx +134 -0
- package/src/canvas/camera.test.ts +17 -2
- package/src/canvas/camera.ts +9 -9
- package/src/canvas/index.ts +4 -0
- package/src/canvas/usePanZoom.test.ts +28 -0
- package/src/canvas/usePanZoom.ts +21 -9
- package/src/chrome/ChromeRegions.stories.tsx +1 -4
- package/src/chrome/LabChrome.tsx +130 -0
- package/src/chrome/builtins.test.ts +1 -0
- package/src/chrome/builtins.tsx +21 -31
- package/src/chrome/index.ts +16 -0
- package/src/chrome/labRegions.test.tsx +208 -0
- package/src/chrome/labTypes.ts +50 -0
- package/src/chrome/merge.ts +7 -9
- package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
- package/src/chrome/regions/PaletteRegion.tsx +16 -9
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +34 -16
- package/src/chrome/types.ts +50 -20
- package/src/config/auto.test.ts +12 -0
- package/src/config/auto.ts +15 -0
- package/src/config/autoConfig.test.ts +107 -0
- package/src/config/autoConfig.ts +153 -0
- package/src/config/builder.test.ts +42 -0
- package/src/config/builder.ts +68 -17
- package/src/config/declarationEmit.fixture.ts +11 -0
- package/src/config/declarationEmit.test.ts +51 -0
- package/src/config/entry.test.ts +46 -0
- package/src/config/index.ts +16 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/path.ts +7 -2
- package/src/config/resolve.test.ts +25 -0
- package/src/config/resolve.ts +17 -1
- package/src/config/types.ts +34 -2
- package/src/config/useResolvedConfig.ts +26 -0
- package/src/controls/AutoControls.stories.tsx +88 -0
- package/src/controls/ControlPanel.stories.tsx +42 -1
- package/src/controls/ControlPanel.test.tsx +530 -4
- package/src/controls/ControlPanel.tsx +562 -82
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +66 -5
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +40 -1
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.chrome.test.tsx +16 -0
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +1 -3
- package/src/lab/Lab.surface.test.tsx +71 -3
- package/src/lab/Lab.test.tsx +168 -16
- package/src/lab/Lab.tsx +434 -129
- package/src/lab/LabContext.ts +8 -0
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +1 -4
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -14
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabRoot.test.tsx +56 -0
- package/src/lab/LabRoot.tsx +47 -0
- package/src/lab/LabShell.less +73 -15
- package/src/lab/LabShell.stories.tsx +50 -1
- package/src/lab/LabShell.tsx +18 -27
- package/src/lab/LabSwitcher.less +18 -11
- package/src/lab/LabSwitcher.test.tsx +14 -9
- package/src/lab/LabSwitcher.tsx +8 -4
- package/src/lab/Workspace.less +1 -1
- package/src/lab/Workspace.stories.tsx +1 -1
- package/src/lab/Workspace.surface.test.tsx +1 -0
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/lab/index.ts +2 -0
- package/src/layers/LayerList.stories.tsx +1 -1
- package/src/loupe/AGENTS.md +17 -5
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/LoupeGestures.tsx +67 -0
- package/src/loupe/TrialLoupe.tsx +43 -28
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/index.ts +4 -0
- package/src/loupe/loupeActions.ts +98 -0
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +60 -3
- package/src/loupe/useLoupe.ts +29 -49
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +14 -2
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/FpsMeter.stories.tsx +1 -1
- package/src/primitives/JobProgress.stories.tsx +1 -1
- package/src/primitives/Legend.stories.tsx +1 -1
- package/src/primitives/ScaleIndicator.stories.tsx +1 -1
- package/src/primitives/Sidebar.less +7 -0
- package/src/primitives/Sidebar.stories.tsx +1 -1
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +133 -0
- package/src/primitives/Split.tsx +175 -0
- package/src/primitives/StatusBar.stories.tsx +1 -1
- package/src/primitives/Toolbar.stories.tsx +1 -1
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- package/src/specimen/Specimen.less +95 -0
- package/src/specimen/Specimen.stories.tsx +13 -0
- package/src/specimen/Specimen.test.tsx +13 -0
- package/src/specimen/Specimen.tsx +888 -0
- package/src/state/Persistence.stories.tsx +75 -0
- package/src/state/Persistence.tsx +33 -0
- package/src/state/SingletonExperiment.test.tsx +47 -53
- package/src/state/SingletonExperiment.tsx +24 -13
- package/src/state/adapterContract.ts +116 -0
- package/src/state/adapters.test.ts +113 -61
- package/src/state/adapters.ts +328 -69
- package/src/state/document.test.ts +71 -96
- package/src/state/document.ts +66 -52
- package/src/state/helpers.test.ts +1 -1
- package/src/state/index.ts +8 -0
- package/src/state/labRecords.test.ts +105 -0
- package/src/state/labRecords.ts +160 -0
- package/src/state/openLabStore.test.ts +658 -0
- package/src/state/openLabStore.ts +403 -0
- package/src/state/records.test.ts +234 -0
- package/src/state/records.ts +219 -0
- package/src/state/store.test.ts +169 -635
- package/src/state/store.ts +146 -163
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +65 -17
- package/src/state/useOpenOnce.ts +68 -0
- package/src/state/usePersistedState.test.tsx +125 -0
- package/src/state/usePersistedState.ts +66 -0
- package/src/state/useTrialState.test.tsx +87 -3
- package/src/state/useTrialState.ts +9 -1
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +3 -0
- package/src/surface/AGENTS.md +39 -7
- package/src/surface/SurfaceContext.ts +26 -3
- package/src/surface/index.ts +2 -0
- package/src/surface/useSurfaceTile.test.tsx +1 -0
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +231 -1
- package/src/surface/useTiledSurface.ts +76 -15
- package/src/theme/Interstellar.stories.tsx +9 -9
- package/src/theme/interstellar.test.ts +17 -6
- package/src/theme/interstellar.theme.json +180 -0
- package/src/theme/interstellar.ts +8 -5
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +59 -7
- package/src/trial/Trial.canvas.test.tsx +32 -0
- package/src/trial/Trial.config.test.tsx +75 -2
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +5 -26
- package/src/trial/Trial.stories.tsx +5 -3
- package/src/trial/Trial.test.tsx +31 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +139 -52
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +24 -7
- package/src/trial/trialOps.test.ts +101 -1
- package/src/trial/trialOps.ts +67 -7
- package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
- package/dist/chunk-2P6PP5N4.js +0 -553
- package/dist/chunk-2P6PP5N4.js.map +0 -1
- package/dist/chunk-64ZCN3DA.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-E44SU6XS.js +0 -50
- package/dist/chunk-E44SU6XS.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MOM3GOVY.js.map +0 -1
- package/dist/chunk-SMHP6XZ4.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
- package/dist/chunk-U3IYHIAE.js +0 -343
- package/dist/chunk-U3IYHIAE.js.map +0 -1
- package/dist/chunk-W3ECWC2K.js +0 -7335
- package/dist/chunk-W3ECWC2K.js.map +0 -1
- package/src/theme/interstellar.tokens.json +0 -61
package/src/annotations/types.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface AnnotationStatus {
|
|
|
29
29
|
id: string;
|
|
30
30
|
label: string;
|
|
31
31
|
/** What a mark in this status is drawn in. Omitted, it takes the default
|
|
32
|
-
* mark
|
|
32
|
+
* mark color — a status is allowed to be a label and nothing more. */
|
|
33
33
|
color?: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -150,9 +150,10 @@ export interface AnnotationTarget extends AnnotationTargetInfo {
|
|
|
150
150
|
/** Where an instrument keeps its own marks. Declaring this means labkit never
|
|
151
151
|
* writes its trial slot — for an instrument whose marks belong in a format it
|
|
152
152
|
* already owns. Both halves are called outside React; `save` is already
|
|
153
|
-
* debounced by the time it arrives.
|
|
153
|
+
* debounced by the time it arrives. A trial shows an empty body until `load`
|
|
154
|
+
* settles. */
|
|
154
155
|
export interface AnnotationStorage {
|
|
155
|
-
load: () => SerializedAnnotations | null | undefined
|
|
156
|
+
load: () => Promise<SerializedAnnotations | null | undefined>;
|
|
156
157
|
save: (doc: SerializedAnnotations) => void;
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -163,6 +164,8 @@ export interface AnnotationsCapability<TS = unknown, TC = unknown> {
|
|
|
163
164
|
* is called once per trial, and its targets are that trial's own. */
|
|
164
165
|
targets: (state: TS, config: TC, trial: TrialInfo) => readonly AnnotationTarget[];
|
|
165
166
|
meaning?: AnnotationMeaning;
|
|
167
|
+
/** An instrument replacing this one under a live trial must pass the same
|
|
168
|
+
* object: the trial keeps the marks it loaded from the first. */
|
|
166
169
|
storage?: AnnotationStorage;
|
|
167
170
|
/** Fires after every finished export, labkit's own chrome included. A
|
|
168
171
|
* notification, not an interception: a host wanting its own flow calls
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Where a trial's camera takes a wheel event from outside its own element. */
|
|
4
|
+
export type CameraWheelSlot = { current: ((e: WheelEvent) => void) | null };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The trial's camera, for input that lands outside it. An annotation target's
|
|
8
|
+
* input box is portalled into the surface container, so a wheel over a mark
|
|
9
|
+
* never bubbles through the stage or canvas stack it sits on; the box hands
|
|
10
|
+
* the event here instead.
|
|
11
|
+
*/
|
|
12
|
+
export const CameraWheelContext = createContext<CameraWheelSlot | null>(null);
|
|
@@ -106,3 +106,28 @@ describe('<CanvasStack>', () => {
|
|
|
106
106
|
vi.restoreAllMocks();
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
describe('<CanvasStack> zoom invariant', () => {
|
|
111
|
+
it('hit-tests a view that arrived with zoom 0 to a finite world point', () => {
|
|
112
|
+
const rect = { left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 };
|
|
113
|
+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
|
|
114
|
+
rect as unknown as DOMRect,
|
|
115
|
+
);
|
|
116
|
+
const onHitTest = vi.fn();
|
|
117
|
+
const { container } = render(
|
|
118
|
+
<CanvasStack
|
|
119
|
+
layers={makeLayers(1)}
|
|
120
|
+
view={{ zoom: 0, pan: { x: 0, y: 0 } }}
|
|
121
|
+
onViewChange={vi.fn()}
|
|
122
|
+
onHitTest={onHitTest}
|
|
123
|
+
/>,
|
|
124
|
+
);
|
|
125
|
+
const host = container.querySelector('.lk-canvas-stack');
|
|
126
|
+
if (!host) throw new Error('no stack host');
|
|
127
|
+
fireEvent.pointerDown(host, { button: 0, pointerId: 1, clientX: 500, clientY: 200 });
|
|
128
|
+
fireEvent.pointerUp(host, { pointerId: 1, clientX: 500, clientY: 200 });
|
|
129
|
+
const world = onHitTest.mock.calls.at(-1)?.[0] as { x: number; y: number };
|
|
130
|
+
expect(Number.isFinite(world.x) && Number.isFinite(world.y)).toBe(true);
|
|
131
|
+
vi.restoreAllMocks();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type CSSProperties,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
2
10
|
import type { Point, ViewTransform } from '../instrument/types';
|
|
11
|
+
import { normalize2DView } from '../state/view';
|
|
12
|
+
import { CameraWheelContext } from './CameraWheelContext';
|
|
3
13
|
import { CanvasStackContext } from './CanvasStackContext';
|
|
4
14
|
import { screenToWorld } from './canvasCoords';
|
|
5
15
|
import { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';
|
|
@@ -31,7 +41,7 @@ export interface CanvasStackProps {
|
|
|
31
41
|
* Handles sizing, device pixel ratio, and pan/zoom. */
|
|
32
42
|
export function CanvasStack({
|
|
33
43
|
layers,
|
|
34
|
-
view,
|
|
44
|
+
view: viewProp,
|
|
35
45
|
onViewChange,
|
|
36
46
|
worldSpec,
|
|
37
47
|
minZoom,
|
|
@@ -43,6 +53,7 @@ export function CanvasStack({
|
|
|
43
53
|
onHitTest,
|
|
44
54
|
children,
|
|
45
55
|
}: CanvasStackProps) {
|
|
56
|
+
const view = normalize2DView(viewProp);
|
|
46
57
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
47
58
|
const canvasMap = useRef<Map<string, HTMLCanvasElement>>(new Map());
|
|
48
59
|
const [size, setSize] = useState({ width: 0, height: 0, dpr: 1 });
|
|
@@ -88,7 +99,24 @@ export function CanvasStack({
|
|
|
88
99
|
onHitTest(screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, view, frame));
|
|
89
100
|
};
|
|
90
101
|
|
|
91
|
-
const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame, onTap });
|
|
102
|
+
const handlers = usePanZoom({ view: viewProp, onViewChange, minZoom, maxZoom, frame, onTap });
|
|
103
|
+
const onWheelRef = useRef(handlers.onWheel);
|
|
104
|
+
onWheelRef.current = handlers.onWheel;
|
|
105
|
+
const wheelSlot = useContext(CameraWheelContext);
|
|
106
|
+
|
|
107
|
+
// Bound by hand because React registers wheel listeners passive, where
|
|
108
|
+
// `preventDefault` is refused and the page scrolls under the zoom.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
const el = containerRef.current;
|
|
111
|
+
if (!el) return;
|
|
112
|
+
const wheel = (e: WheelEvent): void => onWheelRef.current(e, el);
|
|
113
|
+
el.addEventListener('wheel', wheel, { passive: false });
|
|
114
|
+
if (wheelSlot) wheelSlot.current = wheel;
|
|
115
|
+
return () => {
|
|
116
|
+
el.removeEventListener('wheel', wheel);
|
|
117
|
+
if (wheelSlot?.current === wheel) wheelSlot.current = null;
|
|
118
|
+
};
|
|
119
|
+
}, [wheelSlot]);
|
|
92
120
|
useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });
|
|
93
121
|
|
|
94
122
|
const ctxValue = useMemo(
|
|
@@ -116,7 +144,6 @@ export function CanvasStack({
|
|
|
116
144
|
ref={containerRef}
|
|
117
145
|
className={className ? `lk-canvas-stack ${className}` : 'lk-canvas-stack'}
|
|
118
146
|
style={containerStyle}
|
|
119
|
-
onWheel={handlers.onWheel}
|
|
120
147
|
onPointerDown={handlers.onPointerDown}
|
|
121
148
|
>
|
|
122
149
|
{layers.map((layer) => (
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.lk-stage {
|
|
2
|
+
position: relative;
|
|
3
|
+
width: 100%;
|
|
4
|
+
height: 100%;
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
background: var(--wzl-surface-sunken);
|
|
7
|
+
touch-action: none;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.lk-stage__content {
|
|
11
|
+
position: absolute;
|
|
12
|
+
top: 0;
|
|
13
|
+
left: 0;
|
|
14
|
+
width: var(--lk-stage-w);
|
|
15
|
+
height: var(--lk-stage-h);
|
|
16
|
+
transform-origin: 0 0;
|
|
17
|
+
transform: translate(var(--lk-stage-x), var(--lk-stage-y)) scale(var(--lk-stage-zoom));
|
|
18
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
5
|
+
import type { ViewTransform } from '../instrument/types';
|
|
6
|
+
import { Lab } from '../lab/Lab';
|
|
7
|
+
import { SurfaceContext } from '../surface/SurfaceContext';
|
|
8
|
+
import type { SurfaceHandle } from '../surface/useTiledSurface';
|
|
9
|
+
import { CameraWheelContext, type CameraWheelSlot } from './CameraWheelContext';
|
|
10
|
+
import { fitStage, Stage } from './Stage';
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
14
|
+
() => null,
|
|
15
|
+
) as unknown as HTMLCanvasElement['getContext'];
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function content(container: HTMLElement): HTMLElement {
|
|
19
|
+
const el = container.querySelector<HTMLElement>('.lk-stage__content');
|
|
20
|
+
if (!el) throw new Error('no stage content');
|
|
21
|
+
return el;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const zoomOf = (el: HTMLElement): number => Number(el.style.getPropertyValue('--lk-stage-zoom'));
|
|
25
|
+
|
|
26
|
+
describe('fitStage', () => {
|
|
27
|
+
it('shrinks content that does not fit, and centers it on the short axis', () => {
|
|
28
|
+
expect(fitStage({ width: 400, height: 200 }, { width: 200, height: 200 })).toEqual({
|
|
29
|
+
zoom: 0.5,
|
|
30
|
+
pan: { x: 0, y: 50 },
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('never enlarges content past its own size', () => {
|
|
35
|
+
expect(fitStage({ width: 100, height: 50 }, { width: 300, height: 150 })).toEqual({
|
|
36
|
+
zoom: 1,
|
|
37
|
+
pan: { x: 100, y: 50 },
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('answers the identity for an unmeasured viewport', () => {
|
|
42
|
+
expect(fitStage({ width: 100, height: 50 }, { width: 0, height: 0 })).toEqual({
|
|
43
|
+
zoom: 1,
|
|
44
|
+
pan: { x: 0, y: 0 },
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('<Stage>', () => {
|
|
50
|
+
it('draws its content through the camera', () => {
|
|
51
|
+
const { container } = render(
|
|
52
|
+
<Stage
|
|
53
|
+
size={{ width: 300, height: 100 }}
|
|
54
|
+
view={{ zoom: 2, pan: { x: 10, y: 20 } }}
|
|
55
|
+
onViewChange={() => {}}
|
|
56
|
+
>
|
|
57
|
+
<div data-testid="art" />
|
|
58
|
+
</Stage>,
|
|
59
|
+
);
|
|
60
|
+
const el = content(container);
|
|
61
|
+
expect(el).toContainElement(screen.getByTestId('art'));
|
|
62
|
+
expect(zoomOf(el)).toBe(2);
|
|
63
|
+
expect(el.style.getPropertyValue('--lk-stage-x')).toBe('10px');
|
|
64
|
+
expect(el.style.getPropertyValue('--lk-stage-y')).toBe('20px');
|
|
65
|
+
expect(el.style.getPropertyValue('--lk-stage-w')).toBe('300px');
|
|
66
|
+
expect(el.style.getPropertyValue('--lk-stage-h')).toBe('100px');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('zooms on the wheel', () => {
|
|
70
|
+
function Host() {
|
|
71
|
+
const [view, setView] = useState<ViewTransform>({ zoom: 1, pan: { x: 0, y: 0 } });
|
|
72
|
+
return (
|
|
73
|
+
<Stage size={{ width: 100, height: 100 }} view={view} onViewChange={setView}>
|
|
74
|
+
<div />
|
|
75
|
+
</Stage>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const { container } = render(<Host />);
|
|
79
|
+
const host = container.querySelector('.lk-stage');
|
|
80
|
+
if (!host) throw new Error('no stage');
|
|
81
|
+
fireEvent.wheel(host, { deltaY: -200, clientX: 0, clientY: 0 });
|
|
82
|
+
expect(zoomOf(content(container))).toBeGreaterThan(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('zooms on a wheel handed to its camera from outside the stage', () => {
|
|
86
|
+
// What an annotation target's input box does: it is portalled out of the
|
|
87
|
+
// stage, so its wheel never bubbles here.
|
|
88
|
+
const slot: CameraWheelSlot = { current: null };
|
|
89
|
+
function Host() {
|
|
90
|
+
const [view, setView] = useState<ViewTransform>({ zoom: 1, pan: { x: 0, y: 0 } });
|
|
91
|
+
return (
|
|
92
|
+
<CameraWheelContext.Provider value={slot}>
|
|
93
|
+
<Stage size={{ width: 100, height: 100 }} view={view} onViewChange={setView}>
|
|
94
|
+
<div />
|
|
95
|
+
</Stage>
|
|
96
|
+
</CameraWheelContext.Provider>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const { container } = render(<Host />);
|
|
100
|
+
act(() => {
|
|
101
|
+
slot.current?.(new WheelEvent('wheel', { deltaY: -200, cancelable: true }));
|
|
102
|
+
});
|
|
103
|
+
expect(zoomOf(content(container))).toBeGreaterThan(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('listens for the wheel actively, so it can keep the page from scrolling', () => {
|
|
107
|
+
// A proxy: jsdom does not enforce passive listeners, so a preventDefault in
|
|
108
|
+
// a passive one succeeds here and fails in a browser. React registers its
|
|
109
|
+
// wheel listeners passive; asserting the registration is what can fail.
|
|
110
|
+
const add = vi.spyOn(HTMLElement.prototype, 'addEventListener');
|
|
111
|
+
const { container } = render(
|
|
112
|
+
<Stage
|
|
113
|
+
size={{ width: 100, height: 100 }}
|
|
114
|
+
view={{ zoom: 1, pan: { x: 0, y: 0 } }}
|
|
115
|
+
onViewChange={() => {}}
|
|
116
|
+
>
|
|
117
|
+
<div />
|
|
118
|
+
</Stage>,
|
|
119
|
+
);
|
|
120
|
+
const host = container.querySelector('.lk-stage');
|
|
121
|
+
const wheel = add.mock.calls.filter(
|
|
122
|
+
(call, i) => call[0] === 'wheel' && add.mock.contexts[i] === host,
|
|
123
|
+
);
|
|
124
|
+
expect(wheel.map((call) => call[2])).toContainEqual(
|
|
125
|
+
expect.objectContaining({ passive: false }),
|
|
126
|
+
);
|
|
127
|
+
add.mockRestore();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('tells the surface its tiles moved when the camera does', () => {
|
|
131
|
+
// A transform moves a tile without resizing anything, which is the one
|
|
132
|
+
// kind of move a ResizeObserver never reports.
|
|
133
|
+
const invalidateRects = vi.fn();
|
|
134
|
+
const surface = { invalidateRects } as unknown as SurfaceHandle;
|
|
135
|
+
const at = (view: ViewTransform) => (
|
|
136
|
+
<SurfaceContext.Provider value={surface}>
|
|
137
|
+
<Stage size={{ width: 100, height: 100 }} view={view} onViewChange={() => {}}>
|
|
138
|
+
<div />
|
|
139
|
+
</Stage>
|
|
140
|
+
</SurfaceContext.Provider>
|
|
141
|
+
);
|
|
142
|
+
const { rerender } = render(at({ zoom: 1, pan: { x: 0, y: 0 } }));
|
|
143
|
+
invalidateRects.mockClear();
|
|
144
|
+
rerender(at({ zoom: 1.5, pan: { x: 0, y: 0 } }));
|
|
145
|
+
expect(invalidateRects).toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const staged = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
150
|
+
name: 'Staged',
|
|
151
|
+
defaultConfig: () => ({}),
|
|
152
|
+
initialState: () => ({}),
|
|
153
|
+
render: () => <div data-testid="art" />,
|
|
154
|
+
stage: { size: { width: 240, height: 160 }, initialView: { zoom: 1, pan: { x: 0, y: 0 } } },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const plain = defineInstrument<Record<string, never>, Record<string, never>>({
|
|
158
|
+
name: 'Plain',
|
|
159
|
+
defaultConfig: () => ({}),
|
|
160
|
+
initialState: () => ({}),
|
|
161
|
+
render: () => <div data-testid="art" />,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('an instrument that declares a stage', () => {
|
|
165
|
+
it('renders its DOM on the stage', () => {
|
|
166
|
+
const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
|
|
167
|
+
expect(content(container)).toContainElement(screen.getByTestId('art'));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('gets the zoom controls, and they move the stage', () => {
|
|
171
|
+
const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
|
|
172
|
+
act(() => {
|
|
173
|
+
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
|
|
174
|
+
});
|
|
175
|
+
expect(zoomOf(content(container))).toBeCloseTo(1.25);
|
|
176
|
+
act(() => {
|
|
177
|
+
fireEvent.click(screen.getByRole('button', { name: 'Actual size' }));
|
|
178
|
+
});
|
|
179
|
+
expect(zoomOf(content(container))).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('is what earns them: DOM content without one gets none', () => {
|
|
183
|
+
render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
|
|
184
|
+
expect(screen.queryByRole('button', { name: 'Zoom in' })).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode, useContext, useEffect, useRef } from 'react';
|
|
2
|
+
import type { ViewTransform } from '../instrument/types';
|
|
3
|
+
import { normalize2DView } from '../state/view';
|
|
4
|
+
import { useSurfaceOptional } from '../surface/useSurfaceTile';
|
|
5
|
+
import { CameraWheelContext } from './CameraWheelContext';
|
|
6
|
+
import { usePanZoom } from './usePanZoom';
|
|
7
|
+
import type { ViewportSize } from './worldSpec';
|
|
8
|
+
|
|
9
|
+
/** Props for `<Stage>`. */
|
|
10
|
+
export interface StageProps {
|
|
11
|
+
/** The content's own size in CSS pixels, at zoom 1. */
|
|
12
|
+
size: ViewportSize;
|
|
13
|
+
view: ViewTransform;
|
|
14
|
+
onViewChange: (v: ViewTransform) => void;
|
|
15
|
+
minZoom?: number;
|
|
16
|
+
maxZoom?: number;
|
|
17
|
+
/** Fired whenever the stage's viewport is measured at a non-zero size. */
|
|
18
|
+
onResize?: (viewport: ViewportSize) => void;
|
|
19
|
+
/** Handed the viewport element, for a consumer tracking the pointer over it. */
|
|
20
|
+
hostRef?: { current: HTMLDivElement | null };
|
|
21
|
+
/** Drawn over the content in viewport pixels, outside the camera. */
|
|
22
|
+
overlay?: ReactNode;
|
|
23
|
+
children?: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The camera a stage opens at: the content centered in its viewport, shrunk to
|
|
28
|
+
* fit if it has to be and never enlarged past its own size.
|
|
29
|
+
*/
|
|
30
|
+
export function fitStage(size: ViewportSize, viewport: ViewportSize): ViewTransform {
|
|
31
|
+
if (!(size.width > 0 && size.height > 0 && viewport.width > 0 && viewport.height > 0)) {
|
|
32
|
+
return { zoom: 1, pan: { x: 0, y: 0 } };
|
|
33
|
+
}
|
|
34
|
+
const zoom = Math.min(1, viewport.width / size.width, viewport.height / size.height);
|
|
35
|
+
return {
|
|
36
|
+
zoom,
|
|
37
|
+
pan: {
|
|
38
|
+
x: (viewport.width - size.width * zoom) / 2,
|
|
39
|
+
y: (viewport.height - size.height * zoom) / 2,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* DOM content of a fixed size, panned and zoomed by a camera — what
|
|
46
|
+
* `<CanvasStack>` is for layers drawn to a canvas. The content is laid out at
|
|
47
|
+
* its own size and transformed, so everything inside keeps its layout and a
|
|
48
|
+
* measurement of any element in it reports where it is actually drawn.
|
|
49
|
+
*/
|
|
50
|
+
export function Stage({
|
|
51
|
+
size,
|
|
52
|
+
view: viewProp,
|
|
53
|
+
onViewChange,
|
|
54
|
+
minZoom,
|
|
55
|
+
maxZoom,
|
|
56
|
+
onResize,
|
|
57
|
+
hostRef,
|
|
58
|
+
overlay,
|
|
59
|
+
children,
|
|
60
|
+
}: StageProps) {
|
|
61
|
+
const view = normalize2DView(viewProp);
|
|
62
|
+
const host = useRef<HTMLDivElement | null>(null);
|
|
63
|
+
const surface = useSurfaceOptional();
|
|
64
|
+
const handlers = usePanZoom({ view: viewProp, onViewChange, minZoom, maxZoom });
|
|
65
|
+
const onWheelRef = useRef(handlers.onWheel);
|
|
66
|
+
onWheelRef.current = handlers.onWheel;
|
|
67
|
+
const wheelSlot = useContext(CameraWheelContext);
|
|
68
|
+
|
|
69
|
+
// Bound by hand because React registers wheel listeners passive, where
|
|
70
|
+
// `preventDefault` is refused and the page scrolls under the zoom.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const el = host.current;
|
|
73
|
+
if (!el) return;
|
|
74
|
+
const wheel = (e: WheelEvent): void => onWheelRef.current(e, el);
|
|
75
|
+
el.addEventListener('wheel', wheel, { passive: false });
|
|
76
|
+
if (wheelSlot) wheelSlot.current = wheel;
|
|
77
|
+
return () => {
|
|
78
|
+
el.removeEventListener('wheel', wheel);
|
|
79
|
+
if (wheelSlot?.current === wheel) wheelSlot.current = null;
|
|
80
|
+
};
|
|
81
|
+
}, [wheelSlot]);
|
|
82
|
+
|
|
83
|
+
const onResizeRef = useRef(onResize);
|
|
84
|
+
onResizeRef.current = onResize;
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const el = host.current;
|
|
87
|
+
if (!el) return;
|
|
88
|
+
const measure = () => {
|
|
89
|
+
const rect = el.getBoundingClientRect();
|
|
90
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
91
|
+
onResizeRef.current?.({ width: rect.width, height: rect.height });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
measure();
|
|
95
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
96
|
+
const ro = new ResizeObserver(measure);
|
|
97
|
+
ro.observe(el);
|
|
98
|
+
return () => ro.disconnect();
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
// A transform moves every tile inside the content without resizing anything,
|
|
102
|
+
// and a ResizeObserver reports only resizes.
|
|
103
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the camera is the trigger, not an input — the effect re-measures whenever it moves.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
surface?.invalidateRects();
|
|
106
|
+
}, [surface, view.zoom, view.pan.x, view.pan.y]);
|
|
107
|
+
|
|
108
|
+
// The camera changes every frame of a pan, and a transform has nowhere to
|
|
109
|
+
// live but the element's own style: set as custom properties the stylesheet
|
|
110
|
+
// reads.
|
|
111
|
+
const camera = {
|
|
112
|
+
['--lk-stage-x' as string]: `${view.pan.x}px`,
|
|
113
|
+
['--lk-stage-y' as string]: `${view.pan.y}px`,
|
|
114
|
+
['--lk-stage-zoom' as string]: String(view.zoom),
|
|
115
|
+
['--lk-stage-w' as string]: `${size.width}px`,
|
|
116
|
+
['--lk-stage-h' as string]: `${size.height}px`,
|
|
117
|
+
} as CSSProperties;
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
ref={(el) => {
|
|
122
|
+
host.current = el;
|
|
123
|
+
if (hostRef) hostRef.current = el;
|
|
124
|
+
}}
|
|
125
|
+
className="lk-stage"
|
|
126
|
+
onPointerDown={handlers.onPointerDown}
|
|
127
|
+
>
|
|
128
|
+
<div className="lk-stage__content" style={camera}>
|
|
129
|
+
{children}
|
|
130
|
+
</div>
|
|
131
|
+
{overlay}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -41,11 +41,18 @@ describe('zoomAt', () => {
|
|
|
41
41
|
expect(screenToWorld(at, next).x).toBeCloseTo(before.x, 10);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
it('
|
|
44
|
+
it('keeps a view that arrived with a non-finite zoom finite', () => {
|
|
45
45
|
const next = zoomAt({ zoom: Number.NaN, pan: { x: 0, y: 0 } }, 2, { x: 10, y: 10 });
|
|
46
|
-
expect(next.zoom).toBe(
|
|
46
|
+
expect(next.zoom > 0 && Number.isFinite(next.zoom)).toBe(true);
|
|
47
47
|
expect(Number.isFinite(next.pan.x)).toBe(true);
|
|
48
48
|
});
|
|
49
|
+
|
|
50
|
+
it('keeps the zoom positive when a factor and bound would drive it to 0', () => {
|
|
51
|
+
const at = { x: 10, y: 10 };
|
|
52
|
+
const next = zoomAt(view, 0, at, { min: 0 });
|
|
53
|
+
expect(next.zoom > 0 && Number.isFinite(next.zoom)).toBe(true);
|
|
54
|
+
expect(Number.isFinite(screenToWorld(at, next).x)).toBe(true);
|
|
55
|
+
});
|
|
49
56
|
});
|
|
50
57
|
|
|
51
58
|
describe('centerOn', () => {
|
|
@@ -76,3 +83,11 @@ describe('centerOn', () => {
|
|
|
76
83
|
expect(centerOn({ x: 0, y: 0 }, 6, { width: 10, height: 10 }, DEFAULT_FRAME).zoom).toBe(6);
|
|
77
84
|
});
|
|
78
85
|
});
|
|
86
|
+
|
|
87
|
+
describe('centerOn zoom invariant', () => {
|
|
88
|
+
it('builds a camera with a positive zoom from a zoom of 0', () => {
|
|
89
|
+
const v = centerOn({ x: 1, y: 1 }, 0, { width: 100, height: 100 });
|
|
90
|
+
expect(v.zoom > 0 && Number.isFinite(v.zoom)).toBe(true);
|
|
91
|
+
expect(Number.isFinite(screenToWorld({ x: 5, y: 5 }, v).x)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/src/canvas/camera.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeZoom } from '@weasel-js/core';
|
|
1
2
|
import type { Point, ViewTransform } from '../instrument/types';
|
|
2
3
|
import { DEFAULT_FRAME, type ViewportSize, type WorldFrame } from './worldSpec';
|
|
3
4
|
|
|
@@ -10,10 +11,6 @@ export interface ZoomAtOptions {
|
|
|
10
11
|
max?: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
function usableZoom(zoom: number): number {
|
|
14
|
-
return Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
14
|
/**
|
|
18
15
|
* Zoom a view by `factor` about a fixed point, so whatever world coordinate
|
|
19
16
|
* `at` was over stays under it.
|
|
@@ -32,8 +29,10 @@ export function zoomAt(
|
|
|
32
29
|
// drifts by `(1 - ratio) * originPx` per step on any frame that moves it.
|
|
33
30
|
const anchorX = at.x - frame.originPx.x;
|
|
34
31
|
const anchorY = at.y - frame.originPx.y;
|
|
35
|
-
const current =
|
|
36
|
-
const zoom =
|
|
32
|
+
const current = normalizeZoom(view.zoom);
|
|
33
|
+
const zoom = normalizeZoom(
|
|
34
|
+
Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? 0, current * factor)),
|
|
35
|
+
);
|
|
37
36
|
const ratio = zoom / current;
|
|
38
37
|
return {
|
|
39
38
|
zoom,
|
|
@@ -52,11 +51,12 @@ export function centerOn(
|
|
|
52
51
|
size: ViewportSize,
|
|
53
52
|
frame: WorldFrame = DEFAULT_FRAME,
|
|
54
53
|
): ViewTransform {
|
|
54
|
+
const z = normalizeZoom(zoom);
|
|
55
55
|
return {
|
|
56
|
-
zoom,
|
|
56
|
+
zoom: z,
|
|
57
57
|
pan: {
|
|
58
|
-
x: size.width / 2 - frame.originPx.x - world.x *
|
|
59
|
-
y: size.height / 2 - frame.originPx.y - world.y *
|
|
58
|
+
x: size.width / 2 - frame.originPx.x - world.x * z,
|
|
59
|
+
y: size.height / 2 - frame.originPx.y - world.y * z * frame.yDir,
|
|
60
60
|
},
|
|
61
61
|
};
|
|
62
62
|
}
|
package/src/canvas/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type { CameraWheelSlot } from './CameraWheelContext';
|
|
2
|
+
export { CameraWheelContext } from './CameraWheelContext';
|
|
1
3
|
export type { CanvasStackProps } from './CanvasStack';
|
|
2
4
|
export { CanvasStack } from './CanvasStack';
|
|
3
5
|
export type { CanvasStackContextValue, CanvasStackSurface } from './CanvasStackContext';
|
|
@@ -5,6 +7,8 @@ export { CanvasStackContext } from './CanvasStackContext';
|
|
|
5
7
|
export type { ZoomAtOptions } from './camera';
|
|
6
8
|
export { centerOn, zoomAt } from './camera';
|
|
7
9
|
export { screenToWorld, worldToScreen } from './canvasCoords';
|
|
10
|
+
export type { StageProps } from './Stage';
|
|
11
|
+
export { fitStage, Stage } from './Stage';
|
|
8
12
|
export type { CanvasLayerDescriptor } from './useLayerScheduler';
|
|
9
13
|
export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './useOrbit';
|
|
10
14
|
export {
|
|
@@ -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
|