@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.
- package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
- package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
- package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-BoELAMsM.d.ts} +2 -2
- package/dist/_dts/{index-DINmCuLA.d.ts → PropertyField.d-D0ndfIIt.d.ts} +34 -83
- package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Cr8-zoup.d.ts} +4 -5
- package/dist/_dts/{usePanZoom-Bq73pwFe.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
- package/dist/_dts/{index-B0--8PEM.d.ts → index-CKyX0ME_.d.ts} +12 -13
- package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-B7yBTTF9.d.ts} +699 -649
- package/dist/_dts/{types-B7y8hX1D.d.ts → types-DTuViBvc.d.ts} +20 -1
- package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
- package/dist/canvas/index.d.ts +39 -7
- package/dist/canvas/index.js +5 -3
- package/dist/chrome/index.d.ts +4 -5
- package/dist/chrome/index.js +8 -6
- package/dist/chunk-24W5VEJW.js +18 -0
- package/dist/chunk-24W5VEJW.js.map +1 -0
- package/dist/chunk-3RY6WL5T.js +38 -0
- package/dist/chunk-3RY6WL5T.js.map +1 -0
- package/dist/{chunk-H5VIV7MQ.js → chunk-4TT3PNPR.js} +60 -51
- package/dist/chunk-4TT3PNPR.js.map +1 -0
- package/dist/{chunk-ZORVNOKX.js → chunk-6FLXL3I7.js} +9 -145
- package/dist/chunk-6FLXL3I7.js.map +1 -0
- package/dist/{chunk-HFRE4QQV.js → chunk-ASOK76ZH.js} +53 -22
- package/dist/chunk-ASOK76ZH.js.map +1 -0
- package/dist/chunk-AWQG7FCC.js +19 -0
- package/dist/chunk-AWQG7FCC.js.map +1 -0
- package/dist/chunk-CGHF6RZV.js +300 -0
- package/dist/chunk-CGHF6RZV.js.map +1 -0
- package/dist/chunk-DWMSB4CA.js +228 -0
- package/dist/chunk-DWMSB4CA.js.map +1 -0
- package/dist/{chunk-USO6TERP.js → chunk-GHGO52SO.js} +11 -9
- package/dist/chunk-GHGO52SO.js.map +1 -0
- package/dist/{chunk-MOLIUNVH.js → chunk-HKV7LRHY.js} +4 -4
- package/dist/{chunk-MOLIUNVH.js.map → chunk-HKV7LRHY.js.map} +1 -1
- package/dist/{chunk-VQ47KWD5.js → chunk-IRTGUQ5O.js} +5 -4
- package/dist/chunk-IRTGUQ5O.js.map +1 -0
- package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
- package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
- package/dist/chunk-KHK5FGER.js +145 -0
- package/dist/chunk-KHK5FGER.js.map +1 -0
- package/dist/chunk-NEXR37YJ.js +144 -0
- package/dist/chunk-NEXR37YJ.js.map +1 -0
- package/dist/{chunk-BVL7Y574.js → chunk-O6FFFOHS.js} +14 -6
- package/dist/chunk-O6FFFOHS.js.map +1 -0
- package/dist/chunk-QGMOA2YE.js +731 -0
- package/dist/chunk-QGMOA2YE.js.map +1 -0
- package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
- package/dist/chunk-S4NCI32V.js.map +1 -0
- package/dist/{chunk-3FI7Y4R4.js → chunk-SLAQPUI4.js} +4 -4
- package/dist/{chunk-3FI7Y4R4.js.map → chunk-SLAQPUI4.js.map} +1 -1
- package/dist/{chunk-AWH3SRPW.js → chunk-TJ77A4A3.js} +3 -31
- package/dist/chunk-TJ77A4A3.js.map +1 -0
- package/dist/{chunk-WUJXJC5O.js → chunk-TYN6MLXX.js} +691 -1395
- package/dist/chunk-TYN6MLXX.js.map +1 -0
- package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
- package/dist/chunk-WPLAANH4.js.map +1 -0
- package/dist/config/index.d.ts +36 -35
- package/dist/config/index.js +4 -5
- package/dist/controls/index.d.ts +91 -7
- package/dist/controls/index.js +5 -4
- package/dist/dragdrop/index.d.ts +8 -6
- package/dist/dragdrop/index.js +3 -2
- package/dist/index.d.ts +50 -77
- package/dist/index.js +138 -254
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +3 -4
- package/dist/layers/index.js +2 -1
- package/dist/loupe/index.d.ts +19 -16
- package/dist/loupe/index.js +5 -3
- package/dist/overview/index.d.ts +56 -0
- package/dist/overview/index.js +288 -0
- package/dist/overview/index.js.map +1 -0
- package/dist/passthrough/weasel-ui.d.ts +38 -38
- package/dist/passthrough/weasel-ui.js +2 -1
- package/dist/primitives/index.d.ts +19 -43
- package/dist/primitives/index.js +5 -3
- package/dist/state/index.d.ts +7 -7
- package/dist/state/index.js +4 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +63 -1
- package/dist/undo/index.d.ts +3 -3
- package/package.json +13 -9
- package/src/annotations/index.ts +1 -0
- package/src/annotations/store.test.ts +26 -0
- package/src/annotations/store.ts +25 -6
- package/src/annotations/toolMap.test.ts +37 -2
- package/src/annotations/toolMap.ts +13 -0
- package/src/annotations/types.ts +27 -0
- package/src/canvas/AGENTS.md +8 -10
- package/src/canvas/CameraInput.test.tsx +136 -0
- package/src/canvas/CameraInput.tsx +311 -0
- package/src/canvas/CanvasStack.tsx +42 -58
- package/src/canvas/LinkedCursor.less +16 -0
- package/src/canvas/LinkedCursor.tsx +54 -0
- package/src/canvas/Stage.tsx +42 -37
- package/src/canvas/cameraView.test.ts +60 -0
- package/src/canvas/cameraView.ts +61 -0
- package/src/canvas/index.ts +11 -2
- package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
- package/src/config/builder.ts +3 -3
- package/src/config/entry.test.ts +3 -1
- package/src/config/fromConfigField.ts +1 -0
- package/src/config/index.ts +4 -1
- package/src/config/resolve.ts +11 -1
- package/src/config/sectionTree.test.ts +12 -6
- package/src/config/sectionTree.ts +15 -5
- package/src/config/types.ts +21 -0
- package/src/controls/ControlPanel.test.tsx +12 -2
- package/src/controls/ControlPanel.tsx +28 -9
- package/src/controls/inDialog.test.tsx +18 -0
- package/src/controls/inDialog.tsx +2 -10
- package/src/index.test.ts +1 -1
- package/src/index.ts +8 -4
- package/src/instrument/SineWave.smoke.test.tsx +2 -0
- package/src/instrument/types.ts +10 -1
- package/src/lab/Lab.nebula.test.tsx +11 -2
- package/src/lab/Lab.test.tsx +3 -1
- package/src/lab/Lab.tsx +4 -4
- package/src/loupe/AGENTS.md +7 -6
- package/src/loupe/LoupeGestures.tsx +14 -9
- package/src/loupe/TrialLoupe.tsx +5 -5
- package/src/loupe/index.ts +1 -0
- package/src/loupe/loupeActions.ts +6 -2
- package/src/overview/AGENTS.md +38 -0
- package/src/overview/OverviewMarks.test.tsx +100 -0
- package/src/overview/OverviewMarks.tsx +93 -0
- package/src/overview/TrialOverview.less +53 -0
- package/src/overview/TrialOverview.stories.tsx +50 -0
- package/src/overview/TrialOverview.test.tsx +116 -0
- package/src/overview/TrialOverview.tsx +374 -0
- package/src/overview/index.ts +2 -0
- package/src/passthrough/weasel-ui.ts +7 -0
- package/src/specimen/Specimen.tsx +19 -2
- package/src/state/labRecords.test.ts +4 -1
- package/src/state/store.test.ts +4 -4
- package/src/state/useTrialState.test.tsx +1 -1
- package/src/styles.less +2 -0
- package/src/theme/Interstellar.stories.tsx +1 -5
- package/src/tools/labTool.ts +6 -2
- package/src/trial/Trial.annotations.test.tsx +16 -0
- package/src/trial/Trial.loupe.test.tsx +73 -1
- package/src/trial/Trial.stories.tsx +2 -0
- package/src/trial/Trial.tsx +17 -1
- package/src/trial/trialOps.test.ts +4 -4
- package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
- package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
- package/dist/chunk-2AYGEN57.js +0 -32
- package/dist/chunk-2AYGEN57.js.map +0 -1
- package/dist/chunk-AWH3SRPW.js.map +0 -1
- package/dist/chunk-BVL7Y574.js.map +0 -1
- package/dist/chunk-ERFMC6WU.js.map +0 -1
- package/dist/chunk-H5VIV7MQ.js.map +0 -1
- package/dist/chunk-HFRE4QQV.js.map +0 -1
- package/dist/chunk-RS3HRQWU.js.map +0 -1
- package/dist/chunk-TT5C5S6R.js +0 -368
- package/dist/chunk-TT5C5S6R.js.map +0 -1
- package/dist/chunk-USO6TERP.js.map +0 -1
- package/dist/chunk-VQ47KWD5.js.map +0 -1
- package/dist/chunk-WUJXJC5O.js.map +0 -1
- package/dist/chunk-ZORVNOKX.js.map +0 -1
- package/src/canvas/usePanZoom.test.ts +0 -244
- 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
|
+
}
|
|
@@ -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
|
|
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
|
|
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 }[])
|
|
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', () => {
|
package/src/state/store.test.ts
CHANGED
|
@@ -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 })
|
|
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 })
|
|
103
|
-
expect((s.getState().trials[0]?.config as { y: number })
|
|
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 })
|
|
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 })
|
|
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 = {
|
package/src/tools/labTool.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
})}
|