@weasel-js/labkit 1.4.3 → 1.5.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 +13 -2
- package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
- package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
- package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
- package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
- package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
- package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
- package/dist/canvas/index.d.ts +17 -128
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +4 -92
- package/dist/chrome/index.js +9 -5
- package/dist/chunk-67SJLMC7.js +704 -0
- package/dist/chunk-67SJLMC7.js.map +1 -0
- package/dist/chunk-6ZDGOZQV.js +310 -0
- package/dist/chunk-6ZDGOZQV.js.map +1 -0
- package/dist/{chunk-QAGAHJKZ.js → chunk-EKIICY6X.js} +111 -25
- package/dist/chunk-EKIICY6X.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-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
- package/dist/chunk-KJALCDWE.js.map +1 -0
- package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
- package/dist/{chunk-ISCOFAUO.js.map → chunk-MUKOW3TC.js.map} +1 -1
- package/dist/chunk-NDRYLVOW.js +15 -0
- package/dist/chunk-NDRYLVOW.js.map +1 -0
- package/dist/{chunk-C42T6DJT.js → chunk-NS64DXMN.js} +226 -159
- package/dist/chunk-NS64DXMN.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
- package/dist/chunk-PGETSEDK.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
- package/dist/chunk-QLSV2N3G.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-S2SHZRD7.js +7681 -0
- package/dist/chunk-S2SHZRD7.js.map +1 -0
- package/dist/{chunk-ASWKLRKJ.js → chunk-TQBBYTAF.js} +19 -82
- package/dist/chunk-TQBBYTAF.js.map +1 -0
- package/dist/chunk-UU3NJO6N.js +72 -0
- package/dist/chunk-UU3NJO6N.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
- package/dist/chunk-ZPH5WTWF.js.map +1 -0
- package/dist/config/index.d.ts +232 -0
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +2 -3
- package/dist/controls/index.js +3 -2
- package/dist/dragdrop/index.d.ts +2 -4
- package/dist/index.d.ts +123 -348
- package/dist/index.js +794 -837
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +3 -5
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +6 -8
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +169 -117
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +53 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +12 -7
- package/dist/state/index.js +16 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +163 -62
- 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 +2 -4
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +53 -5
- package/src/annotations/Annotations.overlay.test.tsx +85 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/frac.test.ts +4 -4
- package/src/annotations/frac.ts +3 -2
- package/src/annotations/index.ts +1 -1
- 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 +7 -6
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -2
- package/src/annotations/toolMap.ts +3 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- 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.ts +10 -5
- package/src/chrome/ChromeRegions.stories.tsx +0 -3
- package/src/chrome/LabChrome.tsx +114 -0
- package/src/chrome/builtins.tsx +20 -31
- package/src/chrome/index.ts +15 -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 +21 -9
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +30 -16
- package/src/chrome/types.ts +47 -20
- package/src/config/builder.test.ts +4 -0
- package/src/config/builder.ts +16 -10
- 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 +13 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/types.ts +3 -1
- package/src/controls/ControlPanel.test.tsx +16 -0
- package/src/controls/ControlPanel.tsx +5 -2
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +39 -6
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +11 -0
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +0 -2
- package/src/lab/Lab.surface.test.tsx +66 -3
- package/src/lab/Lab.test.tsx +91 -17
- package/src/lab/Lab.tsx +337 -124
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +0 -3
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -13
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabShell.less +49 -12
- package/src/lab/LabShell.tsx +14 -2
- package/src/lab/LabSwitcher.less +92 -0
- package/src/lab/LabSwitcher.test.tsx +96 -0
- package/src/lab/LabSwitcher.tsx +112 -0
- package/src/lab/Workspace.surface.test.tsx +2 -1
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/lab/index.ts +2 -0
- package/src/loupe/AGENTS.md +1 -1
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +1 -1
- package/src/loupe/useLoupe.ts +1 -1
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +13 -1
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +108 -0
- package/src/primitives/Split.tsx +159 -0
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -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 +20 -0
- package/src/state/helpers.ts +16 -0
- 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 +104 -635
- package/src/state/store.ts +117 -158
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +39 -15
- 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 +2 -3
- 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 +2 -1
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +175 -0
- package/src/surface/useTiledSurface.ts +54 -5
- package/src/theme/Interstellar.stories.tsx +7 -7
- package/src/theme/base.less +4 -4
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.tokens.json +2 -2
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +47 -4
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +1 -26
- package/src/trial/Trial.stories.tsx +1 -2
- package/src/trial/Trial.test.tsx +28 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +118 -38
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +7 -4
- package/src/trial/trialOps.ts +7 -4
- 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-2RQPGOQF.js.map +0 -1
- package/dist/chunk-5TROQVQ2.js +0 -547
- package/dist/chunk-5TROQVQ2.js.map +0 -1
- package/dist/chunk-ASWKLRKJ.js.map +0 -1
- package/dist/chunk-C42T6DJT.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MHAGC6VD.js +0 -7320
- package/dist/chunk-MHAGC6VD.js.map +0 -1
- package/dist/chunk-QAGAHJKZ.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
|
@@ -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 {
|
package/src/canvas/usePanZoom.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type WheelEvent,
|
|
8
8
|
} from 'react';
|
|
9
9
|
import type { ViewTransform } from '../instrument/types';
|
|
10
|
+
import { normalize2DView } from '../state/view';
|
|
10
11
|
import { zoomAt } from './camera';
|
|
11
12
|
import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
|
|
12
13
|
|
|
@@ -28,7 +29,9 @@ export interface UsePanZoomOptions {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export interface PanZoomHandlers {
|
|
31
|
-
|
|
32
|
+
/** Zoom about the pointer. `anchor` is the element the pointer position is
|
|
33
|
+
* measured against, for an event whose `currentTarget` is some other box. */
|
|
34
|
+
onWheel: (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => void;
|
|
32
35
|
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
|
|
33
36
|
isDragging: () => boolean;
|
|
34
37
|
}
|
|
@@ -56,8 +59,8 @@ export function usePanZoom({
|
|
|
56
59
|
}: UsePanZoomOptions): PanZoomHandlers {
|
|
57
60
|
const dragRef = useRef<DragState | null>(null);
|
|
58
61
|
const sessionRef = useRef<PointerSession | null>(null);
|
|
59
|
-
const viewRef = useRef(view);
|
|
60
|
-
viewRef.current = view;
|
|
62
|
+
const viewRef = useRef(normalize2DView(view));
|
|
63
|
+
viewRef.current = normalize2DView(view);
|
|
61
64
|
const onViewChangeRef = useRef(onViewChange);
|
|
62
65
|
onViewChangeRef.current = onViewChange;
|
|
63
66
|
const onTapRef = useRef(onTap);
|
|
@@ -68,9 +71,11 @@ export function usePanZoom({
|
|
|
68
71
|
const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
|
|
69
72
|
|
|
70
73
|
const onWheel = useCallback(
|
|
71
|
-
(e: WheelEvent<HTMLElement>) => {
|
|
74
|
+
(e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => {
|
|
75
|
+
const el = anchor ?? (e.currentTarget as Element | null);
|
|
76
|
+
if (!el) return;
|
|
72
77
|
e.preventDefault();
|
|
73
|
-
const rect =
|
|
78
|
+
const rect = el.getBoundingClientRect();
|
|
74
79
|
const initialZoom = initialZoomRef.current;
|
|
75
80
|
onViewChange(
|
|
76
81
|
zoomAt(
|
|
@@ -76,7 +76,6 @@ export const EveryRegion: Story = {
|
|
|
76
76
|
instruments: [FullInstrument],
|
|
77
77
|
defaultInstrument: 'Every Region',
|
|
78
78
|
title: 'Chrome Regions',
|
|
79
|
-
storage: null,
|
|
80
79
|
},
|
|
81
80
|
};
|
|
82
81
|
|
|
@@ -86,7 +85,6 @@ export const SuppressedAndReplaced: Story = {
|
|
|
86
85
|
instruments: [FullInstrument],
|
|
87
86
|
defaultInstrument: 'Every Region',
|
|
88
87
|
title: 'Suppress + Replace',
|
|
89
|
-
storage: null,
|
|
90
88
|
suppress: ['snapshot'],
|
|
91
89
|
chrome: [
|
|
92
90
|
{
|
|
@@ -116,7 +114,6 @@ export const SubjectTitleAndLead: Story = {
|
|
|
116
114
|
instruments: [FullInstrument],
|
|
117
115
|
defaultInstrument: 'Every Region',
|
|
118
116
|
title: 'Title Bar Lead',
|
|
119
|
-
storage: null,
|
|
120
117
|
suppress: ['snapshot'],
|
|
121
118
|
chrome: [
|
|
122
119
|
{
|