@weasel-js/labkit 1.6.1 → 1.7.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 +3 -3
- package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
- package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-D6t7o0pF.d.ts} +59 -18
- package/dist/_dts/{index-CKyX0ME_.d.ts → index-BTmaRy6a.d.ts} +10 -6
- package/dist/_dts/{types-B7yBTTF9.d.ts → index-DCMA4Fy1.d.ts} +59 -113
- package/dist/_dts/{types-DTuViBvc.d.ts → types-BUBSIZuV.d.ts} +18 -8
- package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
- package/dist/canvas/index.d.ts +8 -6
- package/dist/canvas/index.js +6 -6
- package/dist/chrome/index.d.ts +6 -4
- package/dist/chrome/index.js +11 -10
- package/dist/chunk-2ES4W47N.js +8 -0
- package/dist/chunk-2ES4W47N.js.map +1 -0
- package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
- package/dist/chunk-2N7UBSS2.js.map +1 -0
- package/dist/chunk-4KSC7NXL.js +15491 -0
- package/dist/chunk-4KSC7NXL.js.map +1 -0
- package/dist/{chunk-SLAQPUI4.js → chunk-CEGFXTBD.js} +10 -63
- package/dist/chunk-CEGFXTBD.js.map +1 -0
- package/dist/chunk-CZZPXUOU.js +3 -0
- package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
- package/dist/{chunk-QGMOA2YE.js → chunk-E23Q6A3Y.js} +296 -104
- package/dist/chunk-E23Q6A3Y.js.map +1 -0
- package/dist/{chunk-GHGO52SO.js → chunk-E6NARGU7.js} +5 -5
- package/dist/{chunk-GHGO52SO.js.map → chunk-E6NARGU7.js.map} +1 -1
- package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
- package/dist/chunk-EESJUSYR.js.map +1 -0
- package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
- package/dist/chunk-EFXRM6XQ.js.map +1 -0
- package/dist/chunk-F4ACIK7Z.js +210 -0
- package/dist/chunk-F4ACIK7Z.js.map +1 -0
- package/dist/{chunk-6FLXL3I7.js → chunk-FB4MIZOO.js} +77 -45
- package/dist/chunk-FB4MIZOO.js.map +1 -0
- package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
- package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
- package/dist/{chunk-4TT3PNPR.js → chunk-GAHELLJT.js} +95 -95
- package/dist/chunk-GAHELLJT.js.map +1 -0
- package/dist/{chunk-HKV7LRHY.js → chunk-IBVRMHW5.js} +5 -5
- package/dist/{chunk-HKV7LRHY.js.map → chunk-IBVRMHW5.js.map} +1 -1
- package/dist/{chunk-ASOK76ZH.js → chunk-JFOJESJF.js} +72 -29
- package/dist/chunk-JFOJESJF.js.map +1 -0
- package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
- package/dist/chunk-PMLZP7RT.js.map +1 -0
- package/dist/{chunk-TJ77A4A3.js → chunk-READTDQX.js} +5 -9
- package/dist/chunk-READTDQX.js.map +1 -0
- package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
- package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
- package/dist/{chunk-CGHF6RZV.js → chunk-T6DLGEJB.js} +18 -18
- package/dist/chunk-T6DLGEJB.js.map +1 -0
- package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
- package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
- package/dist/{chunk-DWMSB4CA.js → chunk-VYVGVBGY.js} +95 -32
- package/dist/chunk-VYVGVBGY.js.map +1 -0
- package/dist/{chunk-IRTGUQ5O.js → chunk-WLMDTI2Q.js} +16 -6
- package/dist/chunk-WLMDTI2Q.js.map +1 -0
- package/dist/{chunk-NEXR37YJ.js → chunk-XE5HJOPL.js} +6 -8
- package/dist/chunk-XE5HJOPL.js.map +1 -0
- package/dist/chunk-XVUKDSYV.js +38 -0
- package/dist/chunk-XVUKDSYV.js.map +1 -0
- package/dist/{chunk-O6FFFOHS.js → chunk-YCFKW5RX.js} +16 -8
- package/dist/chunk-YCFKW5RX.js.map +1 -0
- package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
- package/dist/chunk-ZQIHJXP4.js.map +1 -0
- package/dist/config/index.d.ts +28 -13
- package/dist/config/index.js +4 -4
- package/dist/controls/index.d.ts +4 -7
- package/dist/controls/index.js +6 -5
- package/dist/dragdrop/index.d.ts +23 -3
- package/dist/dragdrop/index.js +4 -3
- package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
- package/dist/index.d.ts +72 -487
- package/dist/index.js +603 -441
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +8 -0
- package/dist/job/index.js +1 -1
- package/dist/layers/index.d.ts +6 -5
- package/dist/layers/index.js +3 -2
- package/dist/loupe/index.d.ts +100 -30
- package/dist/loupe/index.js +585 -6
- package/dist/loupe/index.js.map +1 -1
- package/dist/overview/index.d.ts +4 -2
- package/dist/overview/index.js +20 -18
- package/dist/overview/index.js.map +1 -1
- package/dist/passthrough/weasel-canvas.d.ts +2 -0
- package/dist/passthrough/weasel-ui.d.ts +2 -2701
- package/dist/passthrough/weasel-ui.js +4 -3
- package/dist/primitives/index.d.ts +37 -2
- package/dist/primitives/index.js +9 -8
- package/dist/state/index.d.ts +7 -5
- package/dist/state/index.js +6 -6
- package/dist/styles.css +150 -126
- package/dist/surface/index.d.ts +10 -2
- package/dist/surface/index.js +1 -1
- package/dist/undo/index.d.ts +5 -3
- package/package.json +14 -13
- package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
- package/src/annotations/AnnotationOverlay.tsx +52 -33
- package/src/annotations/Annotations.less +0 -10
- package/src/annotations/Annotations.overlay.test.tsx +31 -0
- package/src/annotations/MarkList.tsx +4 -8
- package/src/annotations/capture.browser.test.ts +41 -0
- package/src/annotations/capture.ts +14 -7
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/frac.test.ts +44 -1
- package/src/annotations/frac.ts +46 -18
- package/src/annotations/index.ts +1 -0
- package/src/annotations/paint.test.ts +76 -46
- package/src/annotations/paint.ts +65 -43
- package/src/annotations/store.test.ts +212 -2
- package/src/annotations/store.ts +130 -27
- package/src/annotations/svgNodes.test.ts +28 -19
- package/src/annotations/svgNodes.ts +6 -6
- package/src/annotations/types.ts +29 -10
- package/src/canvas/AGENTS.md +1 -0
- package/src/canvas/CameraInput.test.tsx +30 -0
- package/src/canvas/CameraInput.tsx +60 -48
- package/src/canvas/CanvasStack.tsx +11 -6
- package/src/canvas/CanvasStackContext.ts +4 -1
- package/src/canvas/LinkedCursor.less +1 -1
- package/src/canvas/Stage.test.tsx +33 -4
- package/src/canvas/Stage.tsx +9 -4
- package/src/canvas/cameraRegistry.test.ts +55 -0
- package/src/canvas/cameraRegistry.ts +87 -0
- package/src/canvas/cameraZoom.test.ts +46 -0
- package/src/canvas/cameraZoom.ts +24 -0
- package/src/canvas/index.ts +14 -2
- package/src/canvas/sharedScope.test.tsx +94 -0
- package/src/canvas/worldSpec.ts +3 -1
- package/src/chrome/ChromeRegions.stories.tsx +1 -0
- package/src/chrome/builtins.test.ts +7 -6
- package/src/chrome/builtins.tsx +5 -1
- package/src/chrome/index.ts +1 -1
- package/src/chrome/merge.test.ts +5 -0
- package/src/chrome/merge.ts +4 -2
- package/src/chrome/regions/PaletteRegion.less +0 -7
- package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
- package/src/chrome/regions/PaletteRegion.tsx +3 -4
- package/src/chrome/regions/SidebarRegion.less +12 -2
- package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
- package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
- package/src/chrome/regions/ViewportRegion.less +6 -0
- package/src/chrome/regions/ViewportRegion.tsx +10 -4
- package/src/chrome/types.ts +4 -2
- package/src/config/auto.ts +1 -0
- package/src/config/builder.test.ts +2 -1
- package/src/config/builder.ts +18 -5
- package/src/config/fromConfigField.test.ts +14 -0
- package/src/config/fromConfigField.ts +18 -4
- package/src/config/path.ts +2 -5
- package/src/config/resolve.ts +4 -0
- package/src/config/types.ts +14 -9
- package/src/controls/ControlPanel.less +1 -1
- package/src/controls/ControlPanel.stories.tsx +3 -2
- package/src/controls/ControlPanel.test.tsx +98 -11
- package/src/controls/ControlPanel.tsx +61 -2
- package/src/dragdrop/DragDropRuntime.tsx +22 -13
- package/src/dragdrop/DragGhost.tsx +3 -0
- package/src/dragdrop/Palette.tsx +2 -0
- package/src/index.test.ts +5 -0
- package/src/index.ts +16 -2
- package/src/instrument/types.ts +0 -6
- package/src/job/useJob.ts +14 -9
- package/src/lab/Lab.tsx +88 -73
- package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
- package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
- package/src/lab/LabFullChrome.stories.tsx +1 -0
- package/src/lab/LabHeader.test.tsx +24 -14
- package/src/lab/LabHeader.tsx +11 -12
- package/src/lab/LabShell.less +9 -17
- package/src/lab/LabSwitcher.browser.test.tsx +27 -0
- package/src/lab/LabSwitcher.less +8 -6
- package/src/lab/LabZoom.test.tsx +223 -0
- package/src/lab/LabZoom.tsx +170 -0
- package/src/lab/Workspace.tsx +48 -52
- package/src/lab/index.ts +1 -0
- package/src/loupe/AGENTS.md +28 -15
- package/src/loupe/CanvasLoupe.tsx +2 -3
- package/src/loupe/DomLoupe.tsx +1 -7
- package/src/loupe/Loupe.less +16 -0
- package/src/loupe/LoupeGestures.tsx +1 -1
- package/src/loupe/TrialLoupe.tsx +120 -57
- package/src/loupe/index.ts +1 -6
- package/src/loupe/types.test.ts +3 -3
- package/src/loupe/types.ts +24 -27
- package/src/loupe/useLoupe.test.tsx +29 -4
- package/src/loupe/useLoupe.ts +20 -17
- package/src/overview/OverviewMarks.tsx +12 -4
- package/src/overview/TrialOverview.tsx +10 -8
- package/src/passthrough/weasel-ui.test.ts +2 -0
- package/src/passthrough/weasel-ui.ts +17 -5
- package/src/primitives/FloatingPanel.test.tsx +11 -0
- package/src/primitives/FloatingPanel.tsx +8 -7
- package/src/primitives/JobProgress.less +2 -1
- package/src/primitives/Legend.stories.tsx +1 -0
- package/src/primitives/Readout.browser.test.tsx +137 -0
- package/src/primitives/Readout.less +12 -0
- package/src/primitives/Readout.stories.tsx +58 -0
- package/src/primitives/Readout.test.tsx +93 -0
- package/src/primitives/Readout.tsx +56 -0
- package/src/primitives/Split.tsx +69 -41
- package/src/primitives/Toolbar.tsx +3 -3
- package/src/primitives/ZoomControl.less +1 -2
- package/src/primitives/index.ts +2 -0
- package/src/specimen/Specimen.stories.tsx +1 -0
- package/src/specimen/Specimen.tsx +2 -3
- package/src/state/Persistence.browser.test.tsx +47 -0
- package/src/state/store.test.ts +5 -1
- package/src/state/undock.ts +3 -0
- package/src/state/useOpenOnce.ts +20 -16
- package/src/state/usePersistedState.test.tsx +1 -1
- package/src/state/view.ts +1 -0
- package/src/styles.less +1 -0
- package/src/surface/index.ts +1 -1
- package/src/surface/useTiledSurface.test.tsx +14 -0
- package/src/surface/useTiledSurface.ts +11 -3
- package/src/theme/Interstellar.stories.tsx +3 -1
- package/src/theme/base.less +21 -47
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.theme.json +10 -3
- package/src/trial/Trial.annotations.test.tsx +73 -0
- package/src/trial/Trial.less +0 -29
- package/src/trial/Trial.loupe.test.tsx +28 -11
- package/src/trial/Trial.tsx +85 -67
- package/src/trial/TrialChrome.tsx +27 -6
- package/src/trial/index.ts +2 -0
- package/src/trial/loupeSwitch.ts +15 -0
- package/dist/_dts/LayerList.d-BoELAMsM.d.ts +0 -83
- package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
- package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
- package/dist/_dts/Select.d-Cr8-zoup.d.ts +0 -569
- package/dist/chunk-4TT3PNPR.js.map +0 -1
- package/dist/chunk-6FLXL3I7.js.map +0 -1
- package/dist/chunk-ASOK76ZH.js.map +0 -1
- package/dist/chunk-CGHF6RZV.js.map +0 -1
- package/dist/chunk-CQLQPQ4P.js.map +0 -1
- package/dist/chunk-DWMSB4CA.js.map +0 -1
- package/dist/chunk-HXXTULDN.js.map +0 -1
- package/dist/chunk-IJJUK4FU.js.map +0 -1
- package/dist/chunk-IRTGUQ5O.js.map +0 -1
- package/dist/chunk-KHK5FGER.js +0 -145
- package/dist/chunk-KHK5FGER.js.map +0 -1
- package/dist/chunk-NEXR37YJ.js.map +0 -1
- package/dist/chunk-O6FFFOHS.js.map +0 -1
- package/dist/chunk-QGMOA2YE.js.map +0 -1
- package/dist/chunk-RWNDDKOH.js.map +0 -1
- package/dist/chunk-S4NCI32V.js +0 -549
- package/dist/chunk-S4NCI32V.js.map +0 -1
- package/dist/chunk-SBXNQW4G.js +0 -3
- package/dist/chunk-SLAQPUI4.js.map +0 -1
- package/dist/chunk-TJ77A4A3.js.map +0 -1
- package/dist/chunk-TYN6MLXX.js +0 -13864
- package/dist/chunk-TYN6MLXX.js.map +0 -1
- package/dist/chunk-WPLAANH4.js.map +0 -1
- package/src/primitives/useRovingTabIndex.test.ts +0 -48
- package/src/primitives/useRovingTabIndex.ts +0 -88
- package/src/state/Persistence.stories.tsx +0 -75
package/src/annotations/types.ts
CHANGED
|
@@ -22,8 +22,8 @@ export interface FracRect {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** What shape a mark is. The kinds map onto weasel's own tools; an arrow is a
|
|
25
|
-
* line carrying an end marker, not a separate geometry. A point
|
|
26
|
-
*
|
|
25
|
+
* line carrying an end marker, not a separate geometry. A point is zero-size
|
|
26
|
+
* bounds at its position. */
|
|
27
27
|
export type AnnotationKind = 'stroke' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'text' | 'point';
|
|
28
28
|
|
|
29
29
|
/** One selectable status in an instrument's meaning tier. */
|
|
@@ -47,8 +47,11 @@ export interface AnnotationData {
|
|
|
47
47
|
target: string;
|
|
48
48
|
kind: AnnotationKind;
|
|
49
49
|
/** Vertices for the kinds a bounding box cannot describe — a line's two
|
|
50
|
-
* ends, a stroke's path
|
|
51
|
-
|
|
50
|
+
* ends, a stroke's path — in fractions of the mark's own bounds, so moving
|
|
51
|
+
* or resizing the bounds carries them along. A point stores none: it is its
|
|
52
|
+
* bounds' origin. `Annotation.points` is this restated in the target's
|
|
53
|
+
* content box. */
|
|
54
|
+
shape?: readonly FracPoint[];
|
|
52
55
|
title?: string;
|
|
53
56
|
status?: string;
|
|
54
57
|
tags?: readonly string[];
|
|
@@ -61,10 +64,13 @@ export interface AnnotationData {
|
|
|
61
64
|
|
|
62
65
|
/** A mark, as the store reports it. `id` is `<target>/<node>`: one scene per
|
|
63
66
|
* target means a node id is only unique within one. */
|
|
64
|
-
export interface Annotation extends AnnotationData {
|
|
67
|
+
export interface Annotation extends Omit<AnnotationData, 'shape'> {
|
|
65
68
|
id: string;
|
|
66
69
|
/** Bounds in fractions of the target's content box. */
|
|
67
70
|
frac: FracRect;
|
|
71
|
+
/** Vertices in fractions of the target's content box, wherever the bounds
|
|
72
|
+
* now are. A point's one entry is its bounds' origin. */
|
|
73
|
+
points?: readonly FracPoint[];
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/** A new mark, before the store gives it an id and dates it. */
|
|
@@ -72,6 +78,8 @@ export interface AnnotationInit {
|
|
|
72
78
|
target: string;
|
|
73
79
|
kind: AnnotationKind;
|
|
74
80
|
frac: FracRect;
|
|
81
|
+
/** In the target's content box. Stored relative to `frac`, so they should
|
|
82
|
+
* lie inside it; a point takes its position from `frac` alone. */
|
|
75
83
|
points?: readonly FracPoint[];
|
|
76
84
|
title?: string;
|
|
77
85
|
status?: string;
|
|
@@ -89,7 +97,8 @@ export interface AnnotationQuery {
|
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
/** What a mark's meaning can be patched to. Geometry moves through `frac` /
|
|
92
|
-
* `points`; everything else is the meaning tier.
|
|
100
|
+
* `points`; everything else is the meaning tier. `frac` alone carries the
|
|
101
|
+
* vertices with it; `points` alone moves the bounds to enclose them. */
|
|
93
102
|
export type AnnotationPatch = Partial<
|
|
94
103
|
Pick<Annotation, 'frac' | 'points' | 'title' | 'status' | 'tags' | 'meta'>
|
|
95
104
|
>;
|
|
@@ -159,8 +168,6 @@ export interface AnnotationStorage {
|
|
|
159
168
|
save: (doc: SerializedAnnotations) => void;
|
|
160
169
|
}
|
|
161
170
|
|
|
162
|
-
/** Declares that an instrument accepts marks: which regions take them,
|
|
163
|
-
* optionally what a mark is allowed to mean, and optionally where they live. */
|
|
164
171
|
/** The annotation tools a lab's rail can carry. */
|
|
165
172
|
export type AnnotationToolId =
|
|
166
173
|
| 'pointer'
|
|
@@ -172,6 +179,13 @@ export type AnnotationToolId =
|
|
|
172
179
|
| 'ellipse'
|
|
173
180
|
| 'text';
|
|
174
181
|
|
|
182
|
+
/** Whether a selection can stand in several of a trial's targets at once.
|
|
183
|
+
* `'exclusive'` clears every other target when a mark is selected in one;
|
|
184
|
+
* `'per-target'` lets each target keep its own. */
|
|
185
|
+
export type AnnotationSelectionMode = 'exclusive' | 'per-target';
|
|
186
|
+
|
|
187
|
+
/** Declares that an instrument accepts marks: which regions take them,
|
|
188
|
+
* optionally what a mark is allowed to mean, and optionally where they live. */
|
|
175
189
|
export interface AnnotationsCapability<TS = unknown, TC = unknown> {
|
|
176
190
|
/** `trial` is which trial is asking: a declaration made once per instrument
|
|
177
191
|
* is called once per trial, and its targets are that trial's own. */
|
|
@@ -188,6 +202,9 @@ export interface AnnotationsCapability<TS = unknown, TC = unknown> {
|
|
|
188
202
|
* notification, not an interception: a host wanting its own flow calls
|
|
189
203
|
* `capture()` from its own UI, which is the surface the chrome uses. */
|
|
190
204
|
onCapture?: (result: CaptureResult) => void;
|
|
205
|
+
/** Defaults to `'exclusive'`: selecting a mark in one target clears the
|
|
206
|
+
* selection in every other. */
|
|
207
|
+
selection?: AnnotationSelectionMode;
|
|
191
208
|
}
|
|
192
209
|
|
|
193
210
|
/** A persisted mark set. Versioned by this arc rather than by labkit's
|
|
@@ -229,11 +246,13 @@ export interface AnnotationsApi {
|
|
|
229
246
|
* annotation ids. Every one resolves through `get`. */
|
|
230
247
|
selection(): readonly string[];
|
|
231
248
|
/** Replace the selection. An id naming a target or a mark that is not
|
|
232
|
-
* there is dropped, the way `update` and `remove` ignore one.
|
|
249
|
+
* there is dropped, the way `update` and `remove` ignore one. Under an
|
|
250
|
+
* `'exclusive'` selection, so is every id outside the first id's target. */
|
|
233
251
|
setSelection(ids: readonly string[]): void;
|
|
234
252
|
/** Fires after every mutation *and* after a selection change — weasel keeps
|
|
235
253
|
* a canvas's selection on the scene, so both already arrive on this one
|
|
236
|
-
* channel.
|
|
254
|
+
* channel. A selection change touching several targets is one event. No
|
|
255
|
+
* delta: re-query, and re-read `selection()`. */
|
|
237
256
|
subscribe(fn: () => void): () => void;
|
|
238
257
|
|
|
239
258
|
/** Whether the last mark change on any target can be taken back. Weasel
|
package/src/canvas/AGENTS.md
CHANGED
|
@@ -14,6 +14,7 @@ schedules its own layers; a foreign renderer wants rects and dirtiness only.
|
|
|
14
14
|
| `CanvasStackContext.ts` | React context exposing the current `view` to descendants |
|
|
15
15
|
| `useLayerScheduler.ts` | DPR-aware rAF scheduler; redraws dirty layers on view/state changes |
|
|
16
16
|
| `CameraInput.tsx` | Pan, zoom and tap routed through weasel's gesture dispatcher; `CameraContext`; publishes the pointer |
|
|
17
|
+
| `cameraRegistry.ts` | Cameras by trial id, so chrome outside a trial (the lab header's zoom) can drive the focused one |
|
|
17
18
|
| `cameraView.ts` | The camera as a weasel `View` over frame-local coordinates, and the anchored zoom clamp |
|
|
18
19
|
| `LinkedCursor.tsx` | The crosshair on the camera's element while the pointer is over another view |
|
|
19
20
|
| `camera.ts` | `zoomAt` (fixed-point zoom) and `centerOn` (put a world point at a viewport's middle) |
|
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { act, render } from '@testing-library/react';
|
|
8
8
|
import { createPointerStore, PointerContextProvider } from '@weasel-js/core';
|
|
9
|
+
import { renderThenAbandon } from '@weasel-js/react/testing/abandonRender';
|
|
9
10
|
import { useState } from 'react';
|
|
10
11
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
11
12
|
import type { ViewTransform } from '../instrument/types';
|
|
13
|
+
import { type CameraView, useCameraView } from './CameraInput';
|
|
12
14
|
import { CanvasStack } from './CanvasStack';
|
|
13
15
|
import { Stage } from './Stage';
|
|
16
|
+
import { DEFAULT_FRAME } from './worldSpec';
|
|
14
17
|
|
|
15
18
|
beforeAll(() => {
|
|
16
19
|
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
@@ -133,4 +136,31 @@ describe('camera input', () => {
|
|
|
133
136
|
act(() => pointer(host, 'pointermove', 40, 30, 0));
|
|
134
137
|
expect(store.get()).toEqual({ worldX: 20, worldY: -15, viewId: 'stage' });
|
|
135
138
|
});
|
|
139
|
+
|
|
140
|
+
it('reads, writes and clamps through the committed props, never an abandoned render', () => {
|
|
141
|
+
let camera: CameraView | null = null;
|
|
142
|
+
const hostRef = { current: null };
|
|
143
|
+
const committed = vi.fn();
|
|
144
|
+
const abandoned = vi.fn();
|
|
145
|
+
interface ProbeProps {
|
|
146
|
+
view: ViewTransform;
|
|
147
|
+
onViewChange: (v: ViewTransform) => void;
|
|
148
|
+
maxZoom: number;
|
|
149
|
+
}
|
|
150
|
+
function Probe(props: ProbeProps) {
|
|
151
|
+
camera = useCameraView({ ...props, frame: DEFAULT_FRAME, hostRef });
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
renderThenAbandon<ProbeProps>(
|
|
155
|
+
{ view: { zoom: 1, pan: { x: 0, y: 0 } }, onViewChange: committed, maxZoom: 4 },
|
|
156
|
+
{ view: { zoom: 2, pan: { x: 50, y: 50 } }, onViewChange: abandoned, maxZoom: 8 },
|
|
157
|
+
(p) => <Probe {...p} />,
|
|
158
|
+
);
|
|
159
|
+
const cam = camera as unknown as CameraView;
|
|
160
|
+
expect(cam.get().scale.x).toBe(1);
|
|
161
|
+
expect(cam.zoomRange().max).toBe(4);
|
|
162
|
+
act(() => cam.set(cam.get()));
|
|
163
|
+
expect(abandoned).not.toHaveBeenCalled();
|
|
164
|
+
expect(committed).toHaveBeenCalledTimes(1);
|
|
165
|
+
});
|
|
136
166
|
});
|
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import {
|
|
7
7
|
type Action,
|
|
8
|
-
ActionsProvider,
|
|
9
8
|
type ActionsRegistry,
|
|
10
|
-
|
|
11
|
-
DepRegistryProvider,
|
|
9
|
+
InputScope,
|
|
12
10
|
makeViewportZoomAction,
|
|
13
11
|
PointerContextProvider,
|
|
14
12
|
type PointerWorldPos,
|
|
@@ -16,10 +14,12 @@ import {
|
|
|
16
14
|
useActionsRegistry,
|
|
17
15
|
useDepSource,
|
|
18
16
|
useGestureDispatcher,
|
|
17
|
+
useLatest,
|
|
19
18
|
usePointerContext,
|
|
20
19
|
type View,
|
|
21
20
|
type ViewApi,
|
|
22
21
|
viewportDragPanAction,
|
|
22
|
+
type Yoke,
|
|
23
23
|
} from '@weasel-js/core';
|
|
24
24
|
import {
|
|
25
25
|
createContext,
|
|
@@ -27,12 +27,14 @@ import {
|
|
|
27
27
|
type RefObject,
|
|
28
28
|
useContext,
|
|
29
29
|
useEffect,
|
|
30
|
+
useInsertionEffect,
|
|
30
31
|
useMemo,
|
|
31
32
|
useRef,
|
|
32
33
|
} from 'react';
|
|
33
34
|
import type { Point, ViewTransform } from '../instrument/types';
|
|
34
35
|
import { normalize2DView } from '../state/view';
|
|
35
36
|
import { CameraWheelContext } from './CameraWheelContext';
|
|
37
|
+
import { usePublishCamera } from './cameraRegistry';
|
|
36
38
|
import { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
|
|
37
39
|
import type { ViewportSize, WorldFrame } from './worldSpec';
|
|
38
40
|
|
|
@@ -57,31 +59,20 @@ export interface CameraContextValue {
|
|
|
57
59
|
/** The camera around the caller, or `null` outside one. */
|
|
58
60
|
export const CameraContext = createContext<CameraContextValue | null>(null);
|
|
59
61
|
|
|
60
|
-
/** Set by a host that already provides one input scope for everything inside
|
|
61
|
-
* it — a trial — so the camera, the loupe and an overview share it. */
|
|
62
|
-
export const CameraScopeContext = createContext(false);
|
|
63
|
-
|
|
64
62
|
/**
|
|
65
|
-
* The
|
|
66
|
-
* an
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
63
|
+
* The input scope a camera routes through. Inside another camera — a loupe or
|
|
64
|
+
* an overlay laid over a canvas stack — it joins that camera's scope, so its
|
|
65
|
+
* actions reach the dispatcher already listening there. Anywhere else it
|
|
66
|
+
* mounts a scope of its own, which keeps its own tool unless it joins `yoke`:
|
|
67
|
+
* a camera beside another never takes that one's gestures. The pointer store is the exception — one
|
|
68
|
+
* already in scope is kept, since sharing it is the point.
|
|
70
69
|
*/
|
|
71
|
-
export function CameraScope({ children }: { children: ReactNode }) {
|
|
72
|
-
const
|
|
70
|
+
export function CameraScope({ yoke, children }: { yoke?: Yoke; children: ReactNode }) {
|
|
71
|
+
const around = useContext(CameraContext);
|
|
73
72
|
const pointer = usePointerContext();
|
|
74
|
-
if (
|
|
75
|
-
const scoped = <
|
|
76
|
-
return
|
|
77
|
-
<DepRegistryProvider>
|
|
78
|
-
<ActionsProvider>
|
|
79
|
-
<ActiveToolContextProvider>
|
|
80
|
-
{pointer ? scoped : <PointerContextProvider>{scoped}</PointerContextProvider>}
|
|
81
|
-
</ActiveToolContextProvider>
|
|
82
|
-
</ActionsProvider>
|
|
83
|
-
</DepRegistryProvider>
|
|
84
|
-
);
|
|
73
|
+
if (around) return <>{children}</>;
|
|
74
|
+
const scoped = <InputScope yoke={yoke}>{children}</InputScope>;
|
|
75
|
+
return pointer ? scoped : <PointerContextProvider>{scoped}</PointerContextProvider>;
|
|
85
76
|
}
|
|
86
77
|
|
|
87
78
|
/** Options for {@link useCameraView}. */
|
|
@@ -94,6 +85,17 @@ export interface CameraViewOptions {
|
|
|
94
85
|
maxZoom?: number;
|
|
95
86
|
}
|
|
96
87
|
|
|
88
|
+
/** A camera's zoom limits, after widening to admit its opening zoom. */
|
|
89
|
+
export interface ZoomRange {
|
|
90
|
+
min: number;
|
|
91
|
+
max: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A labkit camera: a weasel `ViewApi` that also says how far it zooms. */
|
|
95
|
+
export interface CameraView extends ViewApi {
|
|
96
|
+
zoomRange(): ZoomRange;
|
|
97
|
+
}
|
|
98
|
+
|
|
97
99
|
function isPositiveFinite(n: number): boolean {
|
|
98
100
|
return Number.isFinite(n) && n > 0;
|
|
99
101
|
}
|
|
@@ -110,18 +112,21 @@ export function useCameraView({
|
|
|
110
112
|
hostRef,
|
|
111
113
|
minZoom = 0.1,
|
|
112
114
|
maxZoom = 32,
|
|
113
|
-
}: CameraViewOptions):
|
|
115
|
+
}: CameraViewOptions): CameraView {
|
|
116
|
+
// Written by each commit and by `set`, ahead of the render it asks for —
|
|
117
|
+
// two writers, so not `useLatest`.
|
|
114
118
|
const live = useRef({ view: normalize2DView(view), onViewChange, frame });
|
|
115
|
-
|
|
119
|
+
useInsertionEffect(() => {
|
|
120
|
+
live.current = { view: normalize2DView(view), onViewChange, frame };
|
|
121
|
+
});
|
|
116
122
|
const initialZoom = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
|
|
117
|
-
const bounds = useRef({ min: minZoom, max: maxZoom });
|
|
118
123
|
const opening = initialZoom.current;
|
|
119
|
-
bounds
|
|
124
|
+
const bounds = useLatest({
|
|
120
125
|
min: opening == null ? minZoom : Math.min(minZoom, opening),
|
|
121
126
|
max: opening == null ? maxZoom : Math.max(maxZoom, opening),
|
|
122
|
-
};
|
|
127
|
+
});
|
|
123
128
|
|
|
124
|
-
return useMemo<
|
|
129
|
+
return useMemo<CameraView>(() => {
|
|
125
130
|
const get = (): View => toCameraView(live.current.view, live.current.frame);
|
|
126
131
|
return {
|
|
127
132
|
get,
|
|
@@ -138,8 +143,9 @@ export function useCameraView({
|
|
|
138
143
|
const r = hostRef.current?.getBoundingClientRect();
|
|
139
144
|
return r ? { width: r.width, height: r.height } : null;
|
|
140
145
|
},
|
|
146
|
+
zoomRange: () => ({ ...bounds.current }),
|
|
141
147
|
};
|
|
142
|
-
}, [hostRef]);
|
|
148
|
+
}, [bounds, hostRef]);
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
const TAP_ID = 'camera.tap';
|
|
@@ -149,7 +155,9 @@ const CHANNELS = { contextMenu: false, ingest: false } as const;
|
|
|
149
155
|
/** Props for `<CameraInput>`. */
|
|
150
156
|
export interface CameraInputProps {
|
|
151
157
|
hostRef: RefObject<HTMLElement | null>;
|
|
152
|
-
camera
|
|
158
|
+
/** Published to the host's camera registry while its scope is the active
|
|
159
|
+
* one, so chrome acting on "the camera" acts on the one last used. */
|
|
160
|
+
camera: CameraView;
|
|
153
161
|
frame: WorldFrame;
|
|
154
162
|
/** A primary-button press released without crossing the drag threshold, at
|
|
155
163
|
* the world point it landed on. */
|
|
@@ -174,10 +182,8 @@ function CameraDispatch({
|
|
|
174
182
|
onTap,
|
|
175
183
|
registry,
|
|
176
184
|
}: CameraInputProps & { registry: ActionsRegistry }) {
|
|
177
|
-
const frameRef =
|
|
178
|
-
|
|
179
|
-
const onTapRef = useRef(onTap);
|
|
180
|
-
onTapRef.current = onTap;
|
|
185
|
+
const frameRef = useLatest(frame);
|
|
186
|
+
const onTapRef = useLatest(onTap);
|
|
181
187
|
|
|
182
188
|
useDepSource('view', () => camera);
|
|
183
189
|
useDepSource('rootView', () => camera);
|
|
@@ -209,11 +215,12 @@ function CameraDispatch({
|
|
|
209
215
|
},
|
|
210
216
|
};
|
|
211
217
|
return [
|
|
212
|
-
|
|
218
|
+
// A trial pans on a plain drag; the kit action binds none of its own.
|
|
219
|
+
{ ...viewportDragPanAction, defaultBinding: { kind: 'drag' } },
|
|
213
220
|
{ ...zoom, defaultBinding: wheelOnly as Action['defaultBinding'] },
|
|
214
221
|
tap,
|
|
215
222
|
];
|
|
216
|
-
}, []);
|
|
223
|
+
}, [frameRef, onTapRef]);
|
|
217
224
|
|
|
218
225
|
useEffect(() => {
|
|
219
226
|
const offs = actions.map((a) => registry.register(a));
|
|
@@ -237,26 +244,33 @@ function CameraDispatch({
|
|
|
237
244
|
useGestureDispatcher({
|
|
238
245
|
canvasRef: hostRef,
|
|
239
246
|
actions: registry,
|
|
240
|
-
|
|
247
|
+
entriesById: NO_TOOLS,
|
|
241
248
|
clientToWorld,
|
|
242
249
|
channels: CHANNELS,
|
|
243
250
|
});
|
|
244
251
|
|
|
245
252
|
usePublishPointer(hostRef, clientToWorld, frame, STAGE_VIEW_ID);
|
|
253
|
+
usePublishCamera(camera);
|
|
246
254
|
|
|
247
255
|
// Input that lands outside the camera's element — an annotation target's
|
|
248
|
-
// box, portalled into the surface — reaches the
|
|
256
|
+
// box, portalled into the surface — reaches the active camera's dispatcher
|
|
257
|
+
// through here.
|
|
249
258
|
const wheelSlot = useContext(CameraWheelContext);
|
|
250
259
|
useEffect(() => {
|
|
251
260
|
if (!wheelSlot) return;
|
|
252
261
|
const forward = (e: WheelEvent): void => {
|
|
253
262
|
hostRef.current?.dispatchEvent(new WheelEvent('wheel', e));
|
|
254
263
|
};
|
|
255
|
-
|
|
264
|
+
const claim = (): void => {
|
|
265
|
+
if (registry.isActive()) wheelSlot.current = forward;
|
|
266
|
+
};
|
|
267
|
+
claim();
|
|
268
|
+
const off = registry.subscribe(claim);
|
|
256
269
|
return () => {
|
|
270
|
+
off();
|
|
257
271
|
if (wheelSlot.current === forward) wheelSlot.current = null;
|
|
258
272
|
};
|
|
259
|
-
}, [wheelSlot, hostRef]);
|
|
273
|
+
}, [wheelSlot, hostRef, registry]);
|
|
260
274
|
|
|
261
275
|
return null;
|
|
262
276
|
}
|
|
@@ -273,10 +287,8 @@ export function usePublishPointer(
|
|
|
273
287
|
viewId: string,
|
|
274
288
|
): void {
|
|
275
289
|
const store = usePointerContext();
|
|
276
|
-
const frameRef =
|
|
277
|
-
|
|
278
|
-
const toLocal = useRef(clientToLocal);
|
|
279
|
-
toLocal.current = clientToLocal;
|
|
290
|
+
const frameRef = useLatest(frame);
|
|
291
|
+
const toLocal = useLatest(clientToLocal);
|
|
280
292
|
useEffect(() => {
|
|
281
293
|
const el = hostRef.current;
|
|
282
294
|
if (!el || !store) return;
|
|
@@ -294,7 +306,7 @@ export function usePublishPointer(
|
|
|
294
306
|
el.removeEventListener('pointermove', onMove);
|
|
295
307
|
el.removeEventListener('pointerleave', onLeave);
|
|
296
308
|
};
|
|
297
|
-
}, [hostRef, store, viewId]);
|
|
309
|
+
}, [frameRef, hostRef, store, toLocal, viewId]);
|
|
298
310
|
}
|
|
299
311
|
|
|
300
312
|
/** The pointer as a frame-local point, or `null` when there is none or it is
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Yoke } from '@weasel-js/core';
|
|
2
|
+
import { useLatest } from '@weasel-js/core';
|
|
1
3
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
4
|
import type { Point, ViewTransform } from '../instrument/types';
|
|
3
5
|
import { normalize2DView } from '../state/view';
|
|
@@ -25,6 +27,9 @@ export interface CanvasStackProps {
|
|
|
25
27
|
onResize?: (size: ViewportSize) => void;
|
|
26
28
|
onHitTest?: (worldPos: Point) => void;
|
|
27
29
|
children?: ReactNode;
|
|
30
|
+
/** A yoke to join, from `useYoke()`: cameras on one yoke share their tool
|
|
31
|
+
* and gesture in flight. Omitted, this one keeps its own. */
|
|
32
|
+
yoke?: Yoke;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/** Stacks one `<canvas>` per layer and drives them from a shared view, so a
|
|
@@ -44,6 +49,7 @@ export function CanvasStack({
|
|
|
44
49
|
onResize,
|
|
45
50
|
onHitTest,
|
|
46
51
|
children,
|
|
52
|
+
yoke,
|
|
47
53
|
}: CanvasStackProps) {
|
|
48
54
|
const view = normalize2DView(viewProp);
|
|
49
55
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -75,12 +81,11 @@ export function CanvasStack({
|
|
|
75
81
|
return () => ro.disconnect();
|
|
76
82
|
}, []);
|
|
77
83
|
|
|
78
|
-
const onResizeRef =
|
|
79
|
-
onResizeRef.current = onResize;
|
|
84
|
+
const onResizeRef = useLatest(onResize);
|
|
80
85
|
useEffect(() => {
|
|
81
86
|
if (size.width === 0 && size.height === 0) return;
|
|
82
87
|
onResizeRef.current?.({ width: size.width, height: size.height });
|
|
83
|
-
}, [size.width, size.height]);
|
|
88
|
+
}, [size.width, size.height, onResizeRef]);
|
|
84
89
|
|
|
85
90
|
const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
|
|
86
91
|
|
|
@@ -102,9 +107,9 @@ export function CanvasStack({
|
|
|
102
107
|
() => ({
|
|
103
108
|
view,
|
|
104
109
|
frame,
|
|
105
|
-
surface: { element: containerRef, size, canvases: canvasMap, layers },
|
|
110
|
+
surface: { element: containerRef, size, canvases: canvasMap, layers, worldSpec },
|
|
106
111
|
}),
|
|
107
|
-
[view, frame, size, layers],
|
|
112
|
+
[view, frame, size, layers, worldSpec],
|
|
108
113
|
);
|
|
109
114
|
|
|
110
115
|
const containerStyle: CSSProperties = { width, height };
|
|
@@ -118,7 +123,7 @@ export function CanvasStack({
|
|
|
118
123
|
};
|
|
119
124
|
|
|
120
125
|
return (
|
|
121
|
-
<CameraScope>
|
|
126
|
+
<CameraScope yoke={yoke}>
|
|
122
127
|
<CameraContext.Provider value={cameraCtx}>
|
|
123
128
|
<CanvasStackContext.Provider value={ctxValue}>
|
|
124
129
|
<CameraInput hostRef={containerRef} camera={camera} frame={frame} onTap={onHitTest} />
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createContext, type RefObject } from 'react';
|
|
2
2
|
import type { ViewTransform } from '../instrument/types';
|
|
3
3
|
import type { CanvasLayerDescriptor } from './useLayerScheduler';
|
|
4
|
-
import type { WorldFrame } from './worldSpec';
|
|
4
|
+
import type { WorldFrame, WorldSpec } from './worldSpec';
|
|
5
5
|
|
|
6
6
|
/** The stack's own drawing surface, for an overlay that has to re-draw it at
|
|
7
7
|
* another camera or read back what it presented — a loupe. */
|
|
@@ -14,6 +14,9 @@ export interface CanvasStackSurface {
|
|
|
14
14
|
canvases: RefObject<Map<string, HTMLCanvasElement>>;
|
|
15
15
|
/** The layers as the stack is drawing them, bottom first. */
|
|
16
16
|
layers: readonly CanvasLayerDescriptor[];
|
|
17
|
+
/** The coordinate system the stack was given, which `frame` resolves at
|
|
18
|
+
* its size — for a re-draw at another size. */
|
|
19
|
+
worldSpec?: WorldSpec;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
/** What a canvas stack publishes to its children: the view, and the resolved
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
1
|
+
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { defineInstrument } from '../instrument/defineInstrument';
|
|
@@ -169,19 +169,48 @@ describe('an instrument that declares a stage', () => {
|
|
|
169
169
|
|
|
170
170
|
it('gets the zoom controls, and they move the stage', () => {
|
|
171
171
|
const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
|
|
172
|
+
const trialView = within(screen.getByRole('toolbar', { name: 'View' }));
|
|
172
173
|
act(() => {
|
|
173
|
-
fireEvent.click(
|
|
174
|
+
fireEvent.click(trialView.getByRole('button', { name: 'Zoom in' }));
|
|
174
175
|
});
|
|
175
176
|
expect(zoomOf(content(container))).toBeCloseTo(1.25);
|
|
176
177
|
act(() => {
|
|
177
|
-
fireEvent.click(
|
|
178
|
+
fireEvent.click(trialView.getByRole('button', { name: 'Actual size' }));
|
|
178
179
|
});
|
|
179
180
|
expect(zoomOf(content(container))).toBe(1);
|
|
180
181
|
});
|
|
181
182
|
|
|
183
|
+
it('zooms about the middle of the view, not the stage origin', () => {
|
|
184
|
+
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
|
185
|
+
x: 0,
|
|
186
|
+
y: 0,
|
|
187
|
+
left: 0,
|
|
188
|
+
top: 0,
|
|
189
|
+
right: 240,
|
|
190
|
+
bottom: 160,
|
|
191
|
+
width: 240,
|
|
192
|
+
height: 160,
|
|
193
|
+
toJSON: () => ({}),
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
|
|
197
|
+
const trialView = within(screen.getByRole('toolbar', { name: 'View' }));
|
|
198
|
+
act(() => {
|
|
199
|
+
fireEvent.click(trialView.getByRole('button', { name: 'Zoom in' }));
|
|
200
|
+
});
|
|
201
|
+
const el = content(container);
|
|
202
|
+
expect(zoomOf(el)).toBeCloseTo(1.25);
|
|
203
|
+
// The screen middle (120, 80) still shows world (120, 80): pan = 120 - 120 * 1.25.
|
|
204
|
+
expect(el.style.getPropertyValue('--lk-stage-x')).toBe('-30px');
|
|
205
|
+
expect(el.style.getPropertyValue('--lk-stage-y')).toBe('-20px');
|
|
206
|
+
} finally {
|
|
207
|
+
rect.mockRestore();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
182
211
|
it('is what earns them: DOM content without one gets none', () => {
|
|
183
212
|
render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
|
|
184
|
-
expect(screen.queryByRole('
|
|
213
|
+
expect(screen.queryByRole('toolbar', { name: 'View' })).toBeNull();
|
|
185
214
|
});
|
|
186
215
|
|
|
187
216
|
it('draws its overlay on the stage, outside the camera', () => {
|
package/src/canvas/Stage.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Yoke } from '@weasel-js/core';
|
|
2
|
+
import { useLatest } from '@weasel-js/core';
|
|
1
3
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
2
4
|
import type { ViewTransform } from '../instrument/types';
|
|
3
5
|
import { normalize2DView } from '../state/view';
|
|
@@ -21,6 +23,9 @@ export interface StageProps {
|
|
|
21
23
|
/** Drawn over the content in viewport pixels, outside the camera. */
|
|
22
24
|
overlay?: ReactNode;
|
|
23
25
|
children?: ReactNode;
|
|
26
|
+
/** A yoke to join, from `useYoke()`: cameras on one yoke share their tool
|
|
27
|
+
* and gesture in flight. Omitted, this one keeps its own. */
|
|
28
|
+
yoke?: Yoke;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/**
|
|
@@ -58,6 +63,7 @@ export function Stage({
|
|
|
58
63
|
hostRef,
|
|
59
64
|
overlay,
|
|
60
65
|
children,
|
|
66
|
+
yoke,
|
|
61
67
|
}: StageProps) {
|
|
62
68
|
const view = normalize2DView(viewProp);
|
|
63
69
|
const host = useRef<HTMLDivElement | null>(null);
|
|
@@ -80,8 +86,7 @@ export function Stage({
|
|
|
80
86
|
[camera, size.width, size.height],
|
|
81
87
|
);
|
|
82
88
|
|
|
83
|
-
const onResizeRef =
|
|
84
|
-
onResizeRef.current = onResize;
|
|
89
|
+
const onResizeRef = useLatest(onResize);
|
|
85
90
|
useEffect(() => {
|
|
86
91
|
const el = host.current;
|
|
87
92
|
if (!el) return;
|
|
@@ -96,7 +101,7 @@ export function Stage({
|
|
|
96
101
|
const ro = new ResizeObserver(measure);
|
|
97
102
|
ro.observe(el);
|
|
98
103
|
return () => ro.disconnect();
|
|
99
|
-
}, []);
|
|
104
|
+
}, [onResizeRef]);
|
|
100
105
|
|
|
101
106
|
// A transform moves every tile inside the content without resizing anything,
|
|
102
107
|
// and a ResizeObserver reports only resizes.
|
|
@@ -117,7 +122,7 @@ export function Stage({
|
|
|
117
122
|
} as CSSProperties;
|
|
118
123
|
|
|
119
124
|
return (
|
|
120
|
-
<CameraScope>
|
|
125
|
+
<CameraScope yoke={yoke}>
|
|
121
126
|
<CameraContext.Provider value={cameraCtx}>
|
|
122
127
|
<CameraInput hostRef={host} camera={camera} frame={DEFAULT_FRAME} />
|
|
123
128
|
<div
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { CameraView } from './CameraInput';
|
|
3
|
+
import { createCameraRegistry } from './cameraRegistry';
|
|
4
|
+
|
|
5
|
+
const camera = (): CameraView => ({
|
|
6
|
+
get: () => ({ x: 0, y: 0, scale: { x: 1, y: 1 } }),
|
|
7
|
+
set: () => {},
|
|
8
|
+
zoomRange: () => ({ min: 0.1, max: 32 }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('createCameraRegistry', () => {
|
|
12
|
+
it('answers null for a key nothing registered', () => {
|
|
13
|
+
expect(createCameraRegistry().get('a')).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('holds a camera until its release', () => {
|
|
17
|
+
const reg = createCameraRegistry();
|
|
18
|
+
const cam = camera();
|
|
19
|
+
const off = reg.register('a', cam);
|
|
20
|
+
expect(reg.get('a')).toBe(cam);
|
|
21
|
+
off();
|
|
22
|
+
expect(reg.get('a')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('keeps the newest of a key live, and a release uncovers the one it displaced', () => {
|
|
26
|
+
const reg = createCameraRegistry();
|
|
27
|
+
const first = camera();
|
|
28
|
+
const second = camera();
|
|
29
|
+
const offFirst = reg.register('a', first);
|
|
30
|
+
const offSecond = reg.register('a', second);
|
|
31
|
+
expect(reg.get('a')).toBe(second);
|
|
32
|
+
offFirst();
|
|
33
|
+
expect(reg.get('a')).toBe(second);
|
|
34
|
+
offSecond();
|
|
35
|
+
expect(reg.get('a')).toBeNull();
|
|
36
|
+
reg.register('a', first);
|
|
37
|
+
const offAgain = reg.register('a', second);
|
|
38
|
+
offAgain();
|
|
39
|
+
expect(reg.get('a')).toBe(first);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('tells subscribers when a key changes hands, and stops when they leave', () => {
|
|
43
|
+
const reg = createCameraRegistry();
|
|
44
|
+
const listener = vi.fn();
|
|
45
|
+
const unsubscribe = reg.subscribe(listener);
|
|
46
|
+
const off = reg.register('a', camera());
|
|
47
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
48
|
+
off();
|
|
49
|
+
off();
|
|
50
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
51
|
+
unsubscribe();
|
|
52
|
+
reg.register('b', camera());
|
|
53
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
54
|
+
});
|
|
55
|
+
});
|