@weasel-js/labkit 1.6.0 → 1.6.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.
Files changed (162) hide show
  1. package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
  2. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  3. package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-BoELAMsM.d.ts} +2 -2
  4. package/dist/_dts/{index-DINmCuLA.d.ts → PropertyField.d-D0ndfIIt.d.ts} +34 -83
  5. package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Cr8-zoup.d.ts} +4 -5
  6. package/dist/_dts/{usePanZoom-Bq73pwFe.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
  7. package/dist/_dts/{index-B0--8PEM.d.ts → index-CKyX0ME_.d.ts} +12 -13
  8. package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-B7yBTTF9.d.ts} +699 -649
  9. package/dist/_dts/{types-B7y8hX1D.d.ts → types-DTuViBvc.d.ts} +20 -1
  10. package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
  11. package/dist/canvas/index.d.ts +39 -7
  12. package/dist/canvas/index.js +5 -3
  13. package/dist/chrome/index.d.ts +4 -5
  14. package/dist/chrome/index.js +8 -6
  15. package/dist/chunk-24W5VEJW.js +18 -0
  16. package/dist/chunk-24W5VEJW.js.map +1 -0
  17. package/dist/chunk-3RY6WL5T.js +38 -0
  18. package/dist/chunk-3RY6WL5T.js.map +1 -0
  19. package/dist/{chunk-H5VIV7MQ.js → chunk-4TT3PNPR.js} +60 -51
  20. package/dist/chunk-4TT3PNPR.js.map +1 -0
  21. package/dist/{chunk-ZORVNOKX.js → chunk-6FLXL3I7.js} +9 -145
  22. package/dist/chunk-6FLXL3I7.js.map +1 -0
  23. package/dist/{chunk-HFRE4QQV.js → chunk-ASOK76ZH.js} +53 -22
  24. package/dist/chunk-ASOK76ZH.js.map +1 -0
  25. package/dist/chunk-AWQG7FCC.js +19 -0
  26. package/dist/chunk-AWQG7FCC.js.map +1 -0
  27. package/dist/chunk-CGHF6RZV.js +300 -0
  28. package/dist/chunk-CGHF6RZV.js.map +1 -0
  29. package/dist/chunk-DWMSB4CA.js +228 -0
  30. package/dist/chunk-DWMSB4CA.js.map +1 -0
  31. package/dist/{chunk-USO6TERP.js → chunk-GHGO52SO.js} +11 -9
  32. package/dist/chunk-GHGO52SO.js.map +1 -0
  33. package/dist/{chunk-MOLIUNVH.js → chunk-HKV7LRHY.js} +4 -4
  34. package/dist/{chunk-MOLIUNVH.js.map → chunk-HKV7LRHY.js.map} +1 -1
  35. package/dist/{chunk-VQ47KWD5.js → chunk-IRTGUQ5O.js} +5 -4
  36. package/dist/chunk-IRTGUQ5O.js.map +1 -0
  37. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  38. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  39. package/dist/chunk-KHK5FGER.js +145 -0
  40. package/dist/chunk-KHK5FGER.js.map +1 -0
  41. package/dist/chunk-NEXR37YJ.js +144 -0
  42. package/dist/chunk-NEXR37YJ.js.map +1 -0
  43. package/dist/{chunk-BVL7Y574.js → chunk-O6FFFOHS.js} +14 -6
  44. package/dist/chunk-O6FFFOHS.js.map +1 -0
  45. package/dist/chunk-QGMOA2YE.js +731 -0
  46. package/dist/chunk-QGMOA2YE.js.map +1 -0
  47. package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
  48. package/dist/chunk-S4NCI32V.js.map +1 -0
  49. package/dist/{chunk-3FI7Y4R4.js → chunk-SLAQPUI4.js} +4 -4
  50. package/dist/{chunk-3FI7Y4R4.js.map → chunk-SLAQPUI4.js.map} +1 -1
  51. package/dist/{chunk-AWH3SRPW.js → chunk-TJ77A4A3.js} +3 -31
  52. package/dist/chunk-TJ77A4A3.js.map +1 -0
  53. package/dist/{chunk-WUJXJC5O.js → chunk-TYN6MLXX.js} +691 -1395
  54. package/dist/chunk-TYN6MLXX.js.map +1 -0
  55. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  56. package/dist/chunk-WPLAANH4.js.map +1 -0
  57. package/dist/config/index.d.ts +36 -35
  58. package/dist/config/index.js +4 -5
  59. package/dist/controls/index.d.ts +91 -7
  60. package/dist/controls/index.js +5 -4
  61. package/dist/dragdrop/index.d.ts +8 -6
  62. package/dist/dragdrop/index.js +3 -2
  63. package/dist/index.d.ts +50 -77
  64. package/dist/index.js +138 -254
  65. package/dist/index.js.map +1 -1
  66. package/dist/layers/index.d.ts +3 -4
  67. package/dist/layers/index.js +2 -1
  68. package/dist/loupe/index.d.ts +19 -16
  69. package/dist/loupe/index.js +5 -3
  70. package/dist/overview/index.d.ts +56 -0
  71. package/dist/overview/index.js +288 -0
  72. package/dist/overview/index.js.map +1 -0
  73. package/dist/passthrough/weasel-ui.d.ts +38 -38
  74. package/dist/passthrough/weasel-ui.js +2 -1
  75. package/dist/primitives/index.d.ts +19 -43
  76. package/dist/primitives/index.js +5 -3
  77. package/dist/state/index.d.ts +7 -7
  78. package/dist/state/index.js +4 -5
  79. package/dist/state/index.js.map +1 -1
  80. package/dist/styles.css +63 -1
  81. package/dist/undo/index.d.ts +3 -3
  82. package/package.json +13 -9
  83. package/src/annotations/index.ts +1 -0
  84. package/src/annotations/store.test.ts +26 -0
  85. package/src/annotations/store.ts +25 -6
  86. package/src/annotations/toolMap.test.ts +37 -2
  87. package/src/annotations/toolMap.ts +13 -0
  88. package/src/annotations/types.ts +27 -0
  89. package/src/canvas/AGENTS.md +8 -10
  90. package/src/canvas/CameraInput.test.tsx +136 -0
  91. package/src/canvas/CameraInput.tsx +311 -0
  92. package/src/canvas/CanvasStack.tsx +42 -58
  93. package/src/canvas/LinkedCursor.less +16 -0
  94. package/src/canvas/LinkedCursor.tsx +54 -0
  95. package/src/canvas/Stage.tsx +42 -37
  96. package/src/canvas/cameraView.test.ts +60 -0
  97. package/src/canvas/cameraView.ts +61 -0
  98. package/src/canvas/index.ts +11 -2
  99. package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
  100. package/src/config/builder.ts +3 -3
  101. package/src/config/entry.test.ts +3 -1
  102. package/src/config/fromConfigField.ts +1 -0
  103. package/src/config/index.ts +4 -1
  104. package/src/config/resolve.ts +11 -1
  105. package/src/config/sectionTree.test.ts +12 -6
  106. package/src/config/sectionTree.ts +15 -5
  107. package/src/config/types.ts +21 -0
  108. package/src/controls/ControlPanel.test.tsx +12 -2
  109. package/src/controls/ControlPanel.tsx +28 -9
  110. package/src/controls/inDialog.test.tsx +18 -0
  111. package/src/controls/inDialog.tsx +2 -10
  112. package/src/index.test.ts +1 -1
  113. package/src/index.ts +8 -4
  114. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  115. package/src/instrument/types.ts +10 -1
  116. package/src/lab/Lab.nebula.test.tsx +11 -2
  117. package/src/lab/Lab.test.tsx +3 -1
  118. package/src/lab/Lab.tsx +4 -4
  119. package/src/loupe/AGENTS.md +7 -6
  120. package/src/loupe/LoupeGestures.tsx +14 -9
  121. package/src/loupe/TrialLoupe.tsx +5 -5
  122. package/src/loupe/index.ts +1 -0
  123. package/src/loupe/loupeActions.ts +6 -2
  124. package/src/overview/AGENTS.md +38 -0
  125. package/src/overview/OverviewMarks.test.tsx +100 -0
  126. package/src/overview/OverviewMarks.tsx +93 -0
  127. package/src/overview/TrialOverview.less +53 -0
  128. package/src/overview/TrialOverview.stories.tsx +50 -0
  129. package/src/overview/TrialOverview.test.tsx +116 -0
  130. package/src/overview/TrialOverview.tsx +374 -0
  131. package/src/overview/index.ts +2 -0
  132. package/src/passthrough/weasel-ui.ts +7 -0
  133. package/src/specimen/Specimen.tsx +19 -2
  134. package/src/state/labRecords.test.ts +4 -1
  135. package/src/state/store.test.ts +4 -4
  136. package/src/state/useTrialState.test.tsx +1 -1
  137. package/src/styles.less +2 -0
  138. package/src/theme/Interstellar.stories.tsx +1 -5
  139. package/src/tools/labTool.ts +6 -2
  140. package/src/trial/Trial.annotations.test.tsx +16 -0
  141. package/src/trial/Trial.loupe.test.tsx +73 -1
  142. package/src/trial/Trial.stories.tsx +2 -0
  143. package/src/trial/Trial.tsx +17 -1
  144. package/src/trial/trialOps.test.ts +4 -4
  145. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
  146. package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
  147. package/dist/chunk-2AYGEN57.js +0 -32
  148. package/dist/chunk-2AYGEN57.js.map +0 -1
  149. package/dist/chunk-AWH3SRPW.js.map +0 -1
  150. package/dist/chunk-BVL7Y574.js.map +0 -1
  151. package/dist/chunk-ERFMC6WU.js.map +0 -1
  152. package/dist/chunk-H5VIV7MQ.js.map +0 -1
  153. package/dist/chunk-HFRE4QQV.js.map +0 -1
  154. package/dist/chunk-RS3HRQWU.js.map +0 -1
  155. package/dist/chunk-TT5C5S6R.js +0 -368
  156. package/dist/chunk-TT5C5S6R.js.map +0 -1
  157. package/dist/chunk-USO6TERP.js.map +0 -1
  158. package/dist/chunk-VQ47KWD5.js.map +0 -1
  159. package/dist/chunk-WUJXJC5O.js.map +0 -1
  160. package/dist/chunk-ZORVNOKX.js.map +0 -1
  161. package/src/canvas/usePanZoom.test.ts +0 -244
  162. package/src/canvas/usePanZoom.ts +0 -147
@@ -0,0 +1,374 @@
1
+ /**
2
+ * `<TrialOverview>` — the whole of a trial's content at a glance, in a panel
3
+ * that floats over the stage: the visible rect, a crosshair where the pointer
4
+ * is on the stage, and a press or drag that moves the stage's camera there.
5
+ *
6
+ * It is the labkit form of core's minimap contribution: the same
7
+ * `minimap.center` / `minimap.pan` actions, run on a dispatcher of its own with
8
+ * the trial's camera as `rootView`, and the same crosshair geometry.
9
+ */
10
+ import {
11
+ ActionsProvider,
12
+ ActiveToolContextProvider,
13
+ crosshairRects,
14
+ DepRegistryProvider,
15
+ MINIMAP_CENTER,
16
+ MINIMAP_PAN,
17
+ minimapCenterAction,
18
+ minimapPanAction,
19
+ type Tool,
20
+ useActionsRegistry,
21
+ useDepSource,
22
+ useGestureDispatcher,
23
+ usePointerPosition,
24
+ type View,
25
+ type ViewApi,
26
+ } from '@weasel-js/core';
27
+ import {
28
+ type CSSProperties,
29
+ type ReactNode,
30
+ type RefObject,
31
+ useContext,
32
+ useEffect,
33
+ useLayoutEffect,
34
+ useMemo,
35
+ useRef,
36
+ } from 'react';
37
+ import {
38
+ CameraContext,
39
+ type CameraContextValue,
40
+ pointerElsewhere,
41
+ usePublishPointer,
42
+ } from '../canvas/CameraInput';
43
+ import { CanvasStackContext } from '../canvas/CanvasStackContext';
44
+ import { fromCameraView } from '../canvas/cameraView';
45
+ import type { ViewportSize, WorldFrame } from '../canvas/worldSpec';
46
+ import { FloatingPanel, type FloatingPanelProps } from '../primitives/FloatingPanel';
47
+ import { OverviewMarks } from './OverviewMarks';
48
+
49
+ /** The view id the overview publishes its pointer under. */
50
+ export const OVERVIEW_VIEW_ID = 'overview';
51
+
52
+ /** A rect in the instrument's world. */
53
+ export interface OverviewBounds {
54
+ x: number;
55
+ y: number;
56
+ width: number;
57
+ height: number;
58
+ }
59
+
60
+ /** What an overview's `render` is handed. */
61
+ export interface OverviewRenderArgs {
62
+ /** The content's own size in CSS pixels, at zoom 1 — lay out at this size
63
+ * and the overview scales it to fit. */
64
+ size: ViewportSize;
65
+ }
66
+
67
+ /** Props for `<TrialOverview>`. */
68
+ export interface TrialOverviewProps {
69
+ /** The overview box, in CSS pixels. */
70
+ width: number;
71
+ height: number;
72
+ /**
73
+ * DOM content for the overview, laid out at the content's own size — for a
74
+ * `stage` instrument, typically a lighter copy of what its `render` draws.
75
+ * Never the instrument's own `render`: its effects would run twice. A canvas
76
+ * instrument omits it, and the overview redraws the canvas's layers.
77
+ */
78
+ render?: (args: OverviewRenderArgs) => ReactNode;
79
+ /** What to frame, in the instrument's world. Defaults to a stage's content
80
+ * rect; a canvas instrument, whose world has no edge, names one. */
81
+ bounds?: OverviewBounds;
82
+ /** Panel title. */
83
+ title?: string;
84
+ anchor?: FloatingPanelProps['anchor'];
85
+ persist?: string;
86
+ className?: string;
87
+ }
88
+
89
+ const PADDING = 6;
90
+ const OVERVIEW_INPUT: Tool = {
91
+ id: 'overview',
92
+ eligibility: { always: true },
93
+ bindings: [
94
+ { spec: { kind: 'pointerDown' }, actionId: MINIMAP_CENTER },
95
+ { spec: { kind: 'drag' }, actionId: MINIMAP_PAN },
96
+ ],
97
+ };
98
+ const TOOLS_BY_ID: ReadonlyMap<string, Tool> = new Map([[OVERVIEW_INPUT.id, OVERVIEW_INPUT]]);
99
+ const CHANNELS = { wheel: false, pinch: false, contextMenu: false, ingest: false } as const;
100
+
101
+ /** A world rect as a frame-local one. */
102
+ function toLocalRect(b: OverviewBounds, frame: WorldFrame): OverviewBounds {
103
+ if (frame.yDir === 1) return b;
104
+ return { x: b.x, y: -(b.y + b.height), width: b.width, height: b.height };
105
+ }
106
+
107
+ /** The camera that fits `rect` into `size`, `PADDING` in from every edge. */
108
+ function fitRect(rect: OverviewBounds, size: ViewportSize): View {
109
+ const s = Math.min(
110
+ Math.max(1, size.width - 2 * PADDING) / Math.max(rect.width, 1e-9),
111
+ Math.max(1, size.height - 2 * PADDING) / Math.max(rect.height, 1e-9),
112
+ );
113
+ return {
114
+ x: rect.x + rect.width / 2 - size.width / (2 * s),
115
+ y: rect.y + rect.height / 2 - size.height / (2 * s),
116
+ scale: { x: s, y: s },
117
+ };
118
+ }
119
+
120
+ /** A canvas's 2D context sized to `size` at the device pixel ratio. */
121
+ function prepare(
122
+ canvas: HTMLCanvasElement | null,
123
+ size: ViewportSize,
124
+ ): CanvasRenderingContext2D | null {
125
+ const ctx = canvas?.getContext('2d') ?? null;
126
+ if (!canvas || !ctx) return null;
127
+ const dpr = window.devicePixelRatio || 1;
128
+ const w = Math.round(size.width * dpr);
129
+ const h = Math.round(size.height * dpr);
130
+ if (canvas.width !== w) canvas.width = w;
131
+ if (canvas.height !== h) canvas.height = h;
132
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
133
+ ctx.clearRect(0, 0, size.width, size.height);
134
+ return ctx;
135
+ }
136
+
137
+ function cssVar(el: Element | null, name: string, fallback: string): string {
138
+ if (!el) return fallback;
139
+ return getComputedStyle(el).getPropertyValue(name).trim() || fallback;
140
+ }
141
+
142
+ /**
143
+ * A trial's overview. Mount it from an instrument's `render` (a canvas
144
+ * instrument) or `stage.overlay` (a DOM stage) — anywhere inside the trial's
145
+ * camera.
146
+ */
147
+ export function TrialOverview(props: TrialOverviewProps) {
148
+ const camera = useContext(CameraContext);
149
+ if (!camera) return null;
150
+ return <Overview {...props} camera={camera} />;
151
+ }
152
+
153
+ function Overview({
154
+ width,
155
+ height,
156
+ render,
157
+ bounds,
158
+ title = 'Overview',
159
+ anchor = 'bottom-left',
160
+ persist,
161
+ className,
162
+ camera,
163
+ }: TrialOverviewProps & { camera: CameraContextValue }) {
164
+ const size = useMemo(() => ({ width, height }), [width, height]);
165
+ const content = bounds ? toLocalRect(bounds, camera.frame) : camera.content;
166
+ const contentKey = content ? `${content.x},${content.y},${content.width},${content.height}` : '';
167
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `content` is rebuilt every render; its numbers, in `contentKey`, are what the fit depends on.
168
+ const fit = useMemo(
169
+ () => (content ? fitRect(content, size) : { x: 0, y: 0, scale: { x: 1, y: 1 } }),
170
+ [contentKey, size],
171
+ );
172
+ const boxRef = useRef<HTMLDivElement | null>(null);
173
+
174
+ return (
175
+ <FloatingPanel
176
+ anchor={anchor}
177
+ className={className ? `lk-overview ${className}` : 'lk-overview'}
178
+ {...(persist ? { persist } : {})}
179
+ >
180
+ <div className="lk-overview__title">{title}</div>
181
+ <div ref={boxRef} className="lk-overview__box" data-no-drag style={boxVars(size)}>
182
+ {render && content ? (
183
+ <div className="lk-overview__content" style={contentVars(fit, content)}>
184
+ {render({ size: { width: content.width, height: content.height } })}
185
+ </div>
186
+ ) : null}
187
+ {render ? null : <LayersCanvas fit={fit} size={size} frame={camera.frame} />}
188
+ <OverviewMarks fit={fit} camera={camera} />
189
+ <ChromeCanvas fit={fit} size={size} camera={camera} />
190
+ <OverviewInput boxRef={boxRef} fit={fit} camera={camera} />
191
+ </div>
192
+ </FloatingPanel>
193
+ );
194
+ }
195
+
196
+ // The box's size and the content's camera change with props, and a transform
197
+ // has nowhere to live but the element's own style: set as custom properties
198
+ // the stylesheet reads, as `<Stage>` does.
199
+ function boxVars(size: ViewportSize): CSSProperties {
200
+ return {
201
+ ['--lk-overview-w' as string]: `${size.width}px`,
202
+ ['--lk-overview-h' as string]: `${size.height}px`,
203
+ } as CSSProperties;
204
+ }
205
+
206
+ function contentVars(fit: View, content: OverviewBounds): CSSProperties {
207
+ const s = fit.scale.x;
208
+ return {
209
+ ['--lk-overview-x' as string]: `${(content.x - fit.x) * s}px`,
210
+ ['--lk-overview-y' as string]: `${(content.y - fit.y) * s}px`,
211
+ ['--lk-overview-zoom' as string]: String(s),
212
+ ['--lk-overview-cw' as string]: `${content.width}px`,
213
+ ['--lk-overview-ch' as string]: `${content.height}px`,
214
+ } as CSSProperties;
215
+ }
216
+
217
+ /** A canvas instrument's layers, redrawn through the fit camera. */
218
+ function LayersCanvas({ fit, size, frame }: { fit: View; size: ViewportSize; frame: WorldFrame }) {
219
+ const stack = useContext(CanvasStackContext);
220
+ const ref = useRef<HTMLCanvasElement | null>(null);
221
+ const layers = stack?.surface?.layers;
222
+ useLayoutEffect(() => {
223
+ const ctx = prepare(ref.current, size);
224
+ if (!ctx || !layers) return;
225
+ const local: WorldFrame = { originPx: { x: 0, y: 0 }, yDir: frame.yDir };
226
+ const view = fromCameraView(fit, local);
227
+ for (const layer of layers) {
228
+ if (!layer.visible || layer.id.startsWith('__lk_')) continue;
229
+ ctx.save();
230
+ layer.render(ctx, view, local);
231
+ ctx.restore();
232
+ }
233
+ }, [layers, fit, size, frame.yDir]);
234
+ return <canvas ref={ref} className="lk-overview__canvas" />;
235
+ }
236
+
237
+ /** The visible-rect indicator and the linked crosshair. */
238
+ function ChromeCanvas({
239
+ fit,
240
+ size,
241
+ camera,
242
+ }: {
243
+ fit: View;
244
+ size: ViewportSize;
245
+ camera: CameraContextValue;
246
+ }) {
247
+ const ref = useRef<HTMLCanvasElement | null>(null);
248
+ const pointer = usePointerPosition();
249
+ const root = camera.view.get();
250
+ const host = camera.view.hostSize?.() ?? null;
251
+ // No dependency list: this renders only when the pointer or the trial's
252
+ // camera moved, and either one moves the chrome.
253
+ useLayoutEffect(() => {
254
+ const ctx = prepare(ref.current, size);
255
+ if (!ctx) return;
256
+ const s = fit.scale.x;
257
+ const px = (x: number, y: number) => ({ x: (x - fit.x) * s, y: (y - fit.y) * s });
258
+ if (host) {
259
+ const a = px(root.x, root.y);
260
+ ctx.strokeStyle = cssVar(ref.current, '--wzl-fg', '#ffffff');
261
+ ctx.lineWidth = 1;
262
+ ctx.setLineDash([2, 3]);
263
+ ctx.strokeRect(
264
+ a.x + 0.5,
265
+ a.y + 0.5,
266
+ (host.width / root.scale.x) * s,
267
+ (host.height / root.scale.y) * s,
268
+ );
269
+ ctx.setLineDash([]);
270
+ }
271
+ const world = pointerElsewhere(pointer, OVERVIEW_VIEW_ID, camera.frame);
272
+ if (world) {
273
+ // Back to frame-local: the map is its own inverse.
274
+ const at = px(world.x, world.y * camera.frame.yDir);
275
+ const { halo, bars } = crosshairRects(at.x, at.y, 1);
276
+ ctx.fillStyle = 'rgb(0 0 0 / 60%)';
277
+ for (const r of halo) ctx.fillRect(r.x, r.y, r.w, r.h);
278
+ ctx.fillStyle = cssVar(ref.current, '--wzl-accent', '#4c8dff');
279
+ for (const r of bars) ctx.fillRect(r.x, r.y, r.w, r.h);
280
+ }
281
+ });
282
+ return <canvas ref={ref} className="lk-overview__canvas lk-overview__chrome" />;
283
+ }
284
+
285
+ /**
286
+ * The overview's input, on a dispatcher of its own: its actions and deps are
287
+ * isolated from the trial's, its pointer store is shared with it.
288
+ */
289
+ function OverviewInput(props: {
290
+ boxRef: RefObject<HTMLDivElement | null>;
291
+ fit: View;
292
+ camera: CameraContextValue;
293
+ }) {
294
+ return (
295
+ <DepRegistryProvider>
296
+ <ActionsProvider>
297
+ <ActiveToolContextProvider>
298
+ <OverviewDispatch {...props} />
299
+ </ActiveToolContextProvider>
300
+ </ActionsProvider>
301
+ </DepRegistryProvider>
302
+ );
303
+ }
304
+
305
+ function OverviewDispatch({
306
+ boxRef,
307
+ fit,
308
+ camera,
309
+ }: {
310
+ boxRef: RefObject<HTMLDivElement | null>;
311
+ fit: View;
312
+ camera: CameraContextValue;
313
+ }) {
314
+ const registry = useActionsRegistry();
315
+ const fitRef = useRef(fit);
316
+ fitRef.current = fit;
317
+ const root = camera.view;
318
+ useDepSource('rootView', () => root as ViewApi);
319
+
320
+ useEffect(() => {
321
+ if (!registry) return;
322
+ const offs = [registry.register(minimapCenterAction()), registry.register(minimapPanAction())];
323
+ return () => {
324
+ for (const off of offs) off();
325
+ };
326
+ }, [registry]);
327
+
328
+ const clientToLocal = useMemo(
329
+ () => (cx: number, cy: number) => {
330
+ const r = boxRef.current?.getBoundingClientRect();
331
+ const f = fitRef.current;
332
+ return {
333
+ x: (cx - (r?.left ?? 0)) / f.scale.x + f.x,
334
+ y: (cy - (r?.top ?? 0)) / f.scale.y + f.y,
335
+ };
336
+ },
337
+ [boxRef],
338
+ );
339
+
340
+ useGestureDispatcher({
341
+ canvasRef: boxRef,
342
+ // Always present: `OverviewInput` mounts the provider directly above.
343
+ actions: registry as NonNullable<typeof registry>,
344
+ toolsById: TOOLS_BY_ID,
345
+ clientToWorld: clientToLocal,
346
+ keyboard: false,
347
+ channels: CHANNELS,
348
+ });
349
+
350
+ usePublishPointer(boxRef, clientToLocal, camera.frame, OVERVIEW_VIEW_ID);
351
+
352
+ // The box sits inside the stage's element, whose own dispatcher, loupe and
353
+ // pointer publisher would otherwise take every event that bubbles out of it.
354
+ useEffect(() => {
355
+ const el = boxRef.current;
356
+ if (!el) return;
357
+ const stop = (e: Event): void => e.stopPropagation();
358
+ const kinds = [
359
+ 'pointerdown',
360
+ 'pointermove',
361
+ 'pointerup',
362
+ 'click',
363
+ 'wheel',
364
+ 'dblclick',
365
+ 'contextmenu',
366
+ ];
367
+ for (const k of kinds) el.addEventListener(k, stop);
368
+ return () => {
369
+ for (const k of kinds) el.removeEventListener(k, stop);
370
+ };
371
+ }, [boxRef]);
372
+
373
+ return null;
374
+ }
@@ -0,0 +1,2 @@
1
+ export type { OverviewBounds, OverviewRenderArgs, TrialOverviewProps } from './TrialOverview';
2
+ export { OVERVIEW_VIEW_ID, TrialOverview } from './TrialOverview';
@@ -124,6 +124,7 @@ export {
124
124
  type OptionsBarProps,
125
125
  type OptionsBarSize,
126
126
  type OptionsBarVariant,
127
+ type OverlayPortalProps,
127
128
  oklchToHex,
128
129
  type Platform,
129
130
  Plot2D,
@@ -159,6 +160,9 @@ export {
159
160
  PrefsForm,
160
161
  type PrefsFormProps,
161
162
  type PrefsLayout,
163
+ type PropertyAlign,
164
+ type PropertyDensity,
165
+ type PropertyMetricProps,
162
166
  paintGradientTrack,
163
167
  prefValueAtPath,
164
168
  Radio,
@@ -172,6 +176,7 @@ export {
172
176
  type ReorderGhost,
173
177
  type RovingItem,
174
178
  type RovingTabIndex,
179
+ type SegmentTooltipFields,
175
180
  Select,
176
181
  SelectItem,
177
182
  type SelectItemProps,
@@ -183,6 +188,7 @@ export {
183
188
  type SidebarProps,
184
189
  Slider,
185
190
  type SliderProps,
191
+ type StanceProps,
186
192
  Switch,
187
193
  type SwitchProps,
188
194
  Tab,
@@ -215,4 +221,5 @@ export {
215
221
  useReorderDragList,
216
222
  useRovingTabIndex,
217
223
  visiblePrefSubtree,
224
+ type WithoutPortalTarget,
218
225
  } from '@weasel-js/ui';
@@ -468,7 +468,15 @@ function PanelsAndRows() {
468
468
  onAlphaChange={setAlpha}
469
469
  />
470
470
  <PropertyField kind="color" label="Stroke" value={stroke} onChange={setStroke} />
471
- <PropertyField kind="number" label="Count" value={count} min={0} max={100} step={1} onChange={setCount} />
471
+ <PropertyField
472
+ kind="number"
473
+ label="Count"
474
+ value={count}
475
+ min={0}
476
+ max={100}
477
+ step={1}
478
+ onChange={setCount}
479
+ />
472
480
  <PropertyField
473
481
  kind="enum"
474
482
  label="Line cap"
@@ -494,7 +502,16 @@ function PanelsAndRows() {
494
502
  <PropertyField kind="string" label="Label" value={label} onChange={setLabel} />
495
503
  <PropertyField kind="boolean" label="Visible" value={visible} onChange={setVisible} />
496
504
  <PropertyGroup title="Bevel" defaultCollapsed>
497
- <PropertyField kind="number" control="slider" label="Rings" value={32} min={4} max={96} step={1} onChange={() => {}} />
505
+ <PropertyField
506
+ kind="number"
507
+ control="slider"
508
+ label="Rings"
509
+ value={32}
510
+ min={4}
511
+ max={96}
512
+ step={1}
513
+ onChange={() => {}}
514
+ />
498
515
  </PropertyGroup>
499
516
  </PropertyList>
500
517
  </PropertyPanel>
@@ -96,7 +96,10 @@ describe('documents and records', () => {
96
96
  ['trial:z', { instrumentName: 'T', order: 0 }],
97
97
  ['trial:m', { instrumentName: 'T', order: 0 }],
98
98
  ]);
99
- expect((joined?.doc.trials as { id: string }[]).map((t) => t.id)).toEqual(['m', 'z']);
99
+ expect((joined?.doc.trials as { id: string }[] | undefined)?.map((t) => t.id)).toEqual([
100
+ 'm',
101
+ 'z',
102
+ ]);
100
103
  });
101
104
 
102
105
  it('is null without a meta record', () => {
@@ -84,7 +84,7 @@ describe('updateTrialState', () => {
84
84
  s.getState().updateTrialState('w1', (prev: unknown) => ({
85
85
  n: (prev as { n: number }).n + 1,
86
86
  }));
87
- expect((s.getState().trials[0]?.state as { n: number }).n).toBe(2);
87
+ expect((s.getState().trials[0]?.state as { n: number } | undefined)?.n).toBe(2);
88
88
  });
89
89
  });
90
90
 
@@ -99,8 +99,8 @@ describe('updateTrialConfig', () => {
99
99
  view: { zoom: 1, pan: { x: 0, y: 0 } },
100
100
  });
101
101
  s.getState().updateTrialConfig('w1', 'x', 99);
102
- expect((s.getState().trials[0]?.config as { x: number }).x).toBe(99);
103
- expect((s.getState().trials[0]?.config as { y: number }).y).toBe(2);
102
+ expect((s.getState().trials[0]?.config as { x: number } | undefined)?.x).toBe(99);
103
+ expect((s.getState().trials[0]?.config as { y: number } | undefined)?.y).toBe(2);
104
104
  });
105
105
 
106
106
  it('writes down a dotted path without disturbing its siblings', () => {
@@ -161,7 +161,7 @@ describe('save/load/delete snapshots', () => {
161
161
  s.getState().updateTrialState('w1', { n: 99 });
162
162
  const snapId = s.getState().savedSnapshots[0]?.id ?? '';
163
163
  s.getState().loadSnapshot(snapId, 'w1');
164
- expect((s.getState().trials[0]?.state as { n: number }).n).toBe(5);
164
+ expect((s.getState().trials[0]?.state as { n: number } | undefined)?.n).toBe(5);
165
165
  });
166
166
 
167
167
  it('loadSnapshot restores the auto paths the trial had when saved', () => {
@@ -173,7 +173,7 @@ describe('auto paths', () => {
173
173
  store.getState().updateTrialConfig('w1', 'gap', 30);
174
174
  const rec = store.getState().trials[0];
175
175
  expect(rec?.auto).toBeUndefined();
176
- expect((rec?.config as { gap: number }).gap).toBe(30);
176
+ expect((rec?.config as { gap: number } | undefined)?.gap).toBe(30);
177
177
  });
178
178
 
179
179
  it('does not allocate a new record when the path is already auto', () => {
package/src/styles.less CHANGED
@@ -23,6 +23,8 @@
23
23
  @import './controls/ControlPanel.less';
24
24
  @import './canvas/CanvasStack.less';
25
25
  @import './canvas/Stage.less';
26
+ @import './canvas/LinkedCursor.less';
27
+ @import './overview/TrialOverview.less';
26
28
  @import './loupe/Loupe.less';
27
29
  @import './annotations/Annotations.less';
28
30
  @import './dragdrop/Palette.less';
@@ -1,11 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import type { TokenName } from '@weasel-js/theme';
3
3
  import { useTheme } from '@weasel-js/theme/react';
4
- import {
5
- PropertyField,
6
- PropertyList,
7
- PropertyPanel,
8
- } from '@weasel-js/ui';
4
+ import { PropertyField, PropertyList, PropertyPanel } from '@weasel-js/ui';
9
5
  import './Interstellar.stories.less';
10
6
 
11
7
  const meta: Meta = {
@@ -1,11 +1,15 @@
1
+ import { labAnnotationTools } from '../annotations/toolMap';
1
2
  import type { InstrumentList } from '../instrument/types';
2
3
 
3
4
  /**
4
5
  * The tool the lab's slot holds, once its default is applied: a lab with any
5
6
  * annotating instrument starts in `pointer`, so a first click reaches the
6
- * instrument rather than the marks over it.
7
+ * instrument rather than the marks over it, or in the first annotation tool
8
+ * its rail carries when `pointer` is not among them.
7
9
  */
8
10
  export function resolveLabTool(slot: string | null, instruments: InstrumentList): string | null {
9
11
  if (slot !== null) return slot;
10
- return instruments.some((i) => i.annotations != null) ? 'pointer' : null;
12
+ const tools = labAnnotationTools(instruments);
13
+ if (tools.some((t) => t.id === 'pointer')) return 'pointer';
14
+ return tools[0]?.id ?? null;
11
15
  }
@@ -69,6 +69,22 @@ describe('an instrument that declares annotations', () => {
69
69
  }
70
70
  });
71
71
 
72
+ it('gets only the tools it names', () => {
73
+ const narrow = defineInstrument<Record<string, never>, Record<string, never>>({
74
+ ...annotating,
75
+ name: 'Narrow',
76
+ annotations: {
77
+ targets: () => [{ id: 'pane', ref: { current: null }, content: { w: 200, h: 100 } }],
78
+ tools: ['pointer', 'select'],
79
+ },
80
+ });
81
+ render(<Lab instruments={[narrow]} defaultInstrument="Narrow" />);
82
+ expect(screen.getByRole('button', { name: 'Interact' })).toBeTruthy();
83
+ expect(screen.getByRole('button', { name: 'Select' })).toBeTruthy();
84
+ expect(screen.queryByRole('button', { name: 'Freehand' })).toBeNull();
85
+ expect(screen.queryByRole('button', { name: 'Rectangle' })).toBeNull();
86
+ });
87
+
72
88
  it('puts the store in reach of its own render', () => {
73
89
  render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
74
90
  expect(screen.getByTestId('pane').dataset.marks).toBe('0');
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { beforeAll, describe, expect, it, vi } from 'vitest';
4
4
  import { defineInstrument } from '../instrument/defineInstrument';
@@ -126,3 +126,75 @@ describe('the loupe capability', () => {
126
126
  expect(lens(container)).toBeNull();
127
127
  });
128
128
  });
129
+
130
+ describe('the loupe and the trial camera share one dispatcher', () => {
131
+ const frame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
132
+
133
+ function mountDrawn() {
134
+ const zooms: number[] = [];
135
+ const drawn = defineInstrument<Record<string, never>, Record<string, never>>({
136
+ name: 'Zoomed',
137
+ defaultConfig: () => ({}),
138
+ initialState: () => ({}),
139
+ render: () => null,
140
+ canvas: {
141
+ layers: [
142
+ {
143
+ id: 'main',
144
+ draw: (_ctx, { zoom }) => {
145
+ zooms.push(zoom);
146
+ },
147
+ },
148
+ ],
149
+ },
150
+ loupe: true,
151
+ });
152
+ const r = render(<Lab instruments={[drawn]} defaultInstrument="Zoomed" />);
153
+ const stack = r.container.querySelector('.lk-canvas-stack');
154
+ if (!stack) throw new Error('no canvas stack');
155
+ return { ...r, stack, zooms };
156
+ }
157
+
158
+ const wheel = (el: Element) => {
159
+ const e = new WheelEvent('wheel', {
160
+ deltaY: -300,
161
+ clientX: 50,
162
+ clientY: 50,
163
+ bubbles: true,
164
+ cancelable: true,
165
+ });
166
+ el.dispatchEvent(e);
167
+ return e;
168
+ };
169
+
170
+ it('zooms the camera while the lens is down', async () => {
171
+ const h = mountDrawn();
172
+ await act(async () => {
173
+ await frame();
174
+ });
175
+ await act(async () => {
176
+ wheel(h.stack);
177
+ await frame();
178
+ });
179
+ expect(h.zooms.at(-1)).toBeGreaterThan(1);
180
+ });
181
+
182
+ it('gives the wheel to the lens while it is up, and leaves the camera alone', async () => {
183
+ const user = userEvent.setup();
184
+ const h = mountDrawn();
185
+ await user.click(screen.getByRole('button', { name: 'Loupe' }));
186
+ pointAt(h.stack, 60, 40);
187
+ expect(lens(h.container)).not.toBeNull();
188
+ await act(async () => {
189
+ await frame();
190
+ });
191
+ const before = h.zooms.at(-1);
192
+ let e!: WheelEvent;
193
+ await act(async () => {
194
+ e = wheel(h.stack);
195
+ await frame();
196
+ });
197
+ expect(e.defaultPrevented).toBe(true);
198
+ expect(h.zooms.at(-1)).toBe(before);
199
+ });
200
+ });
@@ -1,3 +1,4 @@
1
+ import { createPointerStore } from '@weasel-js/core';
1
2
  import type { Meta, StoryObj } from '@weasel-js/forge';
2
3
  import type { Instrument } from '../instrument/types';
3
4
  import { LabContext, type LabContextValue } from '../lab/LabContext';
@@ -63,6 +64,7 @@ function Harness() {
63
64
  setZoom: () => {},
64
65
  activeToolId: null,
65
66
  visibleLayers: [],
67
+ pointer: createPointerStore(),
66
68
  },
67
69
  emit: () => {},
68
70
  })}