@weasel-js/labkit 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
- package/dist/_dts/{LayerList.d-BoELAMsM.d.ts → LayerList.d-DiuEArKq.d.ts} +2 -2
- package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
- package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
- package/dist/_dts/{Select.d-Cr8-zoup.d.ts → Select.d-Bensr_MT.d.ts} +11 -1
- package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-CCWfQQWM.d.ts} +58 -18
- package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
- package/dist/_dts/{index-CKyX0ME_.d.ts → index-CsYqUHD3.d.ts} +2 -2
- package/dist/_dts/{types-DTuViBvc.d.ts → types-CRPSGFAp.d.ts} +13 -4
- package/dist/_dts/{types-B7yBTTF9.d.ts → types-CSm9jgol.d.ts} +2 -2
- package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-CTZPgeZ7.d.ts} +1 -1
- package/dist/canvas/index.d.ts +6 -6
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +5 -5
- package/dist/chrome/index.js +8 -8
- package/dist/{chunk-QGMOA2YE.js → chunk-275JRRVD.js} +67 -70
- package/dist/chunk-275JRRVD.js.map +1 -0
- package/dist/{chunk-KHK5FGER.js → chunk-4LJSC7MB.js} +8 -10
- package/dist/chunk-4LJSC7MB.js.map +1 -0
- package/dist/{chunk-SLAQPUI4.js → chunk-7BO7HNGF.js} +5 -5
- package/dist/{chunk-SLAQPUI4.js.map → chunk-7BO7HNGF.js.map} +1 -1
- package/dist/chunk-CZZPXUOU.js +3 -0
- package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
- package/dist/{chunk-6FLXL3I7.js → chunk-FMK7UQ6V.js} +19 -4
- package/dist/chunk-FMK7UQ6V.js.map +1 -0
- package/dist/{chunk-O6FFFOHS.js → chunk-FZ7YFQN7.js} +16 -8
- package/dist/chunk-FZ7YFQN7.js.map +1 -0
- package/dist/{chunk-GHGO52SO.js → chunk-GOZLNM4L.js} +5 -5
- package/dist/{chunk-GHGO52SO.js.map → chunk-GOZLNM4L.js.map} +1 -1
- package/dist/{chunk-4TT3PNPR.js → chunk-I5DUQ5HM.js} +33 -36
- package/dist/chunk-I5DUQ5HM.js.map +1 -0
- package/dist/{chunk-NEXR37YJ.js → chunk-J7OWWTCW.js} +4 -5
- package/dist/chunk-J7OWWTCW.js.map +1 -0
- package/dist/{chunk-IRTGUQ5O.js → chunk-KMJ7RWD3.js} +16 -6
- package/dist/chunk-KMJ7RWD3.js.map +1 -0
- package/dist/{chunk-S4NCI32V.js → chunk-M7HP3FTK.js} +4 -4
- package/dist/{chunk-S4NCI32V.js.map → chunk-M7HP3FTK.js.map} +1 -1
- package/dist/chunk-MIM5R4ZT.js +14972 -0
- package/dist/chunk-MIM5R4ZT.js.map +1 -0
- package/dist/{chunk-ASOK76ZH.js → chunk-T5IBSPAM.js} +59 -26
- package/dist/chunk-T5IBSPAM.js.map +1 -0
- package/dist/{chunk-DWMSB4CA.js → chunk-UNAZSLWE.js} +82 -16
- package/dist/chunk-UNAZSLWE.js.map +1 -0
- package/dist/{chunk-TJ77A4A3.js → chunk-Y23G2VZL.js} +5 -9
- package/dist/chunk-Y23G2VZL.js.map +1 -0
- package/dist/{chunk-HKV7LRHY.js → chunk-YT75VYRD.js} +4 -4
- package/dist/{chunk-HKV7LRHY.js.map → chunk-YT75VYRD.js.map} +1 -1
- package/dist/{chunk-CGHF6RZV.js → chunk-ZIFPHIWZ.js} +9 -7
- package/dist/chunk-ZIFPHIWZ.js.map +1 -0
- package/dist/config/index.d.ts +16 -9
- package/dist/config/index.js +4 -4
- package/dist/controls/index.d.ts +2 -2
- package/dist/controls/index.js +5 -5
- package/dist/dragdrop/index.d.ts +3 -3
- package/dist/index.d.ts +263 -24
- package/dist/index.js +376 -237
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -4
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +4 -4
- package/dist/loupe/index.js +2 -2
- package/dist/overview/index.d.ts +2 -2
- package/dist/overview/index.js +9 -8
- package/dist/overview/index.js.map +1 -1
- package/dist/passthrough/weasel-ui.d.ts +208 -38
- package/dist/passthrough/weasel-ui.js +3 -3
- package/dist/primitives/index.d.ts +3 -178
- package/dist/primitives/index.js +6 -6
- package/dist/state/index.d.ts +5 -5
- package/dist/state/index.js +4 -4
- package/dist/styles.css +91 -83
- package/dist/undo/index.d.ts +3 -3
- package/package.json +13 -12
- package/src/annotations/AnnotationOverlay.tsx +2 -2
- package/src/annotations/Annotations.less +0 -10
- package/src/annotations/MarkList.tsx +4 -8
- package/src/annotations/paint.test.ts +3 -14
- package/src/annotations/paint.ts +3 -19
- package/src/annotations/svgNodes.test.ts +6 -9
- package/src/annotations/svgNodes.ts +3 -3
- package/src/canvas/AGENTS.md +1 -0
- package/src/canvas/CameraInput.tsx +44 -33
- package/src/canvas/CanvasStack.tsx +6 -1
- package/src/canvas/LinkedCursor.less +1 -1
- package/src/canvas/Stage.test.tsx +33 -4
- package/src/canvas/Stage.tsx +6 -1
- package/src/canvas/cameraRegistry.test.ts +55 -0
- package/src/canvas/cameraRegistry.ts +86 -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/chrome/ChromeRegions.stories.tsx +1 -0
- package/src/chrome/regions/PaletteRegion.less +0 -7
- package/src/chrome/regions/SidebarRegion.less +12 -2
- package/src/config/builder.test.ts +2 -1
- package/src/config/builder.ts +12 -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 +13 -9
- package/src/controls/ControlPanel.less +1 -1
- package/src/controls/ControlPanel.stories.tsx +3 -2
- package/src/controls/ControlPanel.test.tsx +64 -11
- package/src/controls/ControlPanel.tsx +52 -2
- package/src/index.ts +15 -1
- package/src/lab/Lab.tsx +83 -72
- 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 +8 -17
- package/src/lab/LabSwitcher.less +6 -6
- package/src/lab/LabZoom.test.tsx +223 -0
- package/src/lab/LabZoom.tsx +170 -0
- package/src/lab/index.ts +1 -0
- package/src/loupe/LoupeGestures.tsx +1 -1
- package/src/overview/TrialOverview.tsx +7 -5
- package/src/passthrough/weasel-ui.test.ts +2 -0
- package/src/passthrough/weasel-ui.ts +16 -4
- package/src/primitives/FloatingPanel.test.tsx +11 -0
- package/src/primitives/FloatingPanel.tsx +2 -5
- package/src/primitives/JobProgress.less +1 -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/index.ts +2 -0
- package/src/specimen/Specimen.stories.tsx +1 -0
- package/src/specimen/Specimen.tsx +2 -2
- package/src/state/Persistence.browser.test.tsx +47 -0
- package/src/state/store.test.ts +5 -1
- package/src/state/usePersistedState.test.tsx +1 -1
- package/src/styles.less +1 -0
- 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.less +0 -13
- package/src/trial/Trial.tsx +12 -6
- package/src/trial/TrialChrome.tsx +21 -3
- package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
- package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
- 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-DWMSB4CA.js.map +0 -1
- package/dist/chunk-IRTGUQ5O.js.map +0 -1
- 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-SBXNQW4G.js +0 -3
- package/dist/chunk-TJ77A4A3.js.map +0 -1
- package/dist/chunk-TYN6MLXX.js +0 -13864
- package/dist/chunk-TYN6MLXX.js.map +0 -1
- package/src/state/Persistence.stories.tsx +0 -75
package/dist/undo/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export { az as UndoCapability } from '../_dts/types-
|
|
1
|
+
export { az as UndoCapability } from '../_dts/types-CSm9jgol.js';
|
|
2
2
|
import '@weasel-js/core';
|
|
3
3
|
import 'react';
|
|
4
|
-
import '../_dts/PrefsRow.d-
|
|
4
|
+
import '../_dts/PrefsRow.d-TOZ7BgD9.js';
|
|
5
5
|
import '@weasel-js/theme';
|
|
6
|
-
import '../_dts/types-
|
|
6
|
+
import '../_dts/types-CRPSGFAp.js';
|
|
7
7
|
import '../_dts/types-DJ79Tg5J.js';
|
|
8
8
|
|
|
9
9
|
/** A subscriber to a named event. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/labkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "React widgets for building self-contained interactive lab pages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "orochi235",
|
|
@@ -115,28 +115,29 @@
|
|
|
115
115
|
"dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
|
|
116
116
|
},
|
|
117
117
|
"peerDependencies": {
|
|
118
|
-
"@weasel-js/core": "1.
|
|
119
|
-
"@weasel-js/theme": "1.
|
|
118
|
+
"@weasel-js/core": "1.7.0",
|
|
119
|
+
"@weasel-js/theme": "1.7.0",
|
|
120
120
|
"react": "^19.0.0",
|
|
121
121
|
"react-dom": "^19.0.0"
|
|
122
122
|
},
|
|
123
123
|
"dependencies": {
|
|
124
|
-
"@weasel-js/geom": "1.
|
|
125
|
-
"@weasel-js/kernel3d": "1.
|
|
126
|
-
"@weasel-js/loupe": "1.
|
|
127
|
-
"@weasel-js/
|
|
128
|
-
"@weasel-js/
|
|
129
|
-
"
|
|
124
|
+
"@weasel-js/geom": "1.7.0",
|
|
125
|
+
"@weasel-js/kernel3d": "1.7.0",
|
|
126
|
+
"@weasel-js/loupe": "1.7.0",
|
|
127
|
+
"@weasel-js/quantity": "1.7.0",
|
|
128
|
+
"@weasel-js/svg": "1.7.0",
|
|
129
|
+
"@weasel-js/ui": "1.7.0",
|
|
130
|
+
"earcut": "3.2.3",
|
|
130
131
|
"polygon-clipping": "^0.15.7",
|
|
131
132
|
"react-aria-components": "^1.5.0",
|
|
132
|
-
"windease": "^
|
|
133
|
+
"windease": "^2.0.0",
|
|
133
134
|
"zustand": "^5.0.12"
|
|
134
135
|
},
|
|
135
136
|
"devDependencies": {
|
|
136
137
|
"@biomejs/biome": "^2.4.12",
|
|
137
138
|
"@rollup/plugin-alias": "^6.0.0",
|
|
138
|
-
"@testing-library/jest-dom": "^
|
|
139
|
-
"@weasel-js/modes": "1.
|
|
139
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
140
|
+
"@weasel-js/modes": "1.7.0",
|
|
140
141
|
"less": "^4.2.0",
|
|
141
142
|
"rollup-plugin-dts": "^6.4.1",
|
|
142
143
|
"tsx": "^4.19.0",
|
|
@@ -236,11 +236,11 @@ export function AnnotationOverlay({
|
|
|
236
236
|
style={box}
|
|
237
237
|
/>
|
|
238
238
|
{canvas && input ? (
|
|
239
|
-
//
|
|
240
|
-
// canvas under it respond to input, and the rest go silently dead.
|
|
239
|
+
// Isolated so each pane keeps its own selection and tool.
|
|
241
240
|
<WeaselProvider isolate>
|
|
242
241
|
<ToolBridge toolId={annotationToolInfo(activeToolId)?.weaselTool ?? 'select'} />
|
|
243
242
|
<SceneCanvas<AnnotationData, 'marks', WorldRect>
|
|
243
|
+
features={['pick', 'move', 'transform', 'edit']}
|
|
244
244
|
ref={attachSceneCanvas}
|
|
245
245
|
scene={scene}
|
|
246
246
|
width={rect.w}
|
|
@@ -59,16 +59,6 @@
|
|
|
59
59
|
|
|
60
60
|
.lk-mark-list__drop {
|
|
61
61
|
margin-left: auto;
|
|
62
|
-
background: none;
|
|
63
|
-
border: 0;
|
|
64
|
-
color: var(--wzl-fg-muted);
|
|
65
|
-
cursor: pointer;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Box and typography come from <Input>. This only says how the field shares
|
|
69
|
-
// the row.
|
|
70
|
-
.lk-mark-list__title {
|
|
71
|
-
width: 100%;
|
|
72
62
|
}
|
|
73
63
|
|
|
74
64
|
// The export panel: a target to export, how, and at what resolution. Held in
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Input } from '../passthrough/weasel-ui';
|
|
1
|
+
import { CloseButton, Input } from '../passthrough/weasel-ui';
|
|
2
2
|
import { useAnnotations } from './AnnotationsContext';
|
|
3
3
|
import type { AnnotationMeaning } from './types';
|
|
4
4
|
|
|
@@ -35,17 +35,13 @@ export function MarkList({ meaning, config }: MarkListProps) {
|
|
|
35
35
|
<span className="lk-mark-list__kind">{a.kind}</span>
|
|
36
36
|
<span className="lk-mark-list__target">{a.target}</span>
|
|
37
37
|
{stale ? <span className="lk-mark-list__stale">stale</span> : null}
|
|
38
|
-
<
|
|
39
|
-
type="button"
|
|
38
|
+
<CloseButton
|
|
40
39
|
className="lk-mark-list__drop"
|
|
41
|
-
|
|
40
|
+
ariaLabel={`Remove ${a.kind} on ${a.target}`}
|
|
42
41
|
onClick={() => marks.remove(a.id)}
|
|
43
|
-
|
|
44
|
-
×
|
|
45
|
-
</button>
|
|
42
|
+
/>
|
|
46
43
|
</div>
|
|
47
44
|
<Input
|
|
48
|
-
className="lk-mark-list__title"
|
|
49
45
|
aria-label={`Title of ${a.kind} on ${a.target}`}
|
|
50
46
|
value={a.title ?? ''}
|
|
51
47
|
placeholder="Untitled"
|
|
@@ -48,23 +48,12 @@ describe('markCommands', () => {
|
|
|
48
48
|
]);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
it('makes an arrow the same line,
|
|
51
|
+
it('makes an arrow the same line, carrying an end marker', () => {
|
|
52
52
|
const cmds = markCommands(mark('arrow', { points: ENDS }), CONTENT);
|
|
53
|
+
expect(cmds).toHaveLength(1);
|
|
53
54
|
const stroke = (cmds[0] as { stroke?: { markerEnd?: unknown } }).stroke;
|
|
54
55
|
expect(stroke?.markerEnd).toBe('arrow');
|
|
55
|
-
expect(anchors(
|
|
56
|
-
|
|
57
|
-
// The head has to be geometry, not just a field: `markerEnd` alone is
|
|
58
|
-
// inert outside the kit's own node painter, which is what shipped an
|
|
59
|
-
// arrow with no head.
|
|
60
|
-
expect(cmds).toHaveLength(2);
|
|
61
|
-
const head = anchors([cmds[1]]);
|
|
62
|
-
expect(head.length).toBeGreaterThan(0);
|
|
63
|
-
// Its vertex is the line's far end — computed, not eyeballed.
|
|
64
|
-
const near = (v: number, t: number) => Math.abs(v - t) < 1;
|
|
65
|
-
const xs = head.filter((_, i) => i % 2 === 0);
|
|
66
|
-
const ys = head.filter((_, i) => i % 2 === 1);
|
|
67
|
-
expect(xs.some((x, i) => near(x, 120) && near(ys[i] as number, 90))).toBe(true);
|
|
56
|
+
expect(anchors(cmds)).toEqual([0.1 * 200, 0.2 * 100, 0.6 * 200, 0.9 * 100]);
|
|
68
57
|
});
|
|
69
58
|
|
|
70
59
|
it('threads a freehand stroke through every point', () => {
|
package/src/annotations/paint.ts
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import type { DrawCommand, Path, Stroke } from '@weasel-js/core';
|
|
2
|
-
import {
|
|
3
|
-
ellipsePath,
|
|
4
|
-
linePath,
|
|
5
|
-
markerDrawCommands,
|
|
6
|
-
PathBuilder,
|
|
7
|
-
rectPath,
|
|
8
|
-
textCommand,
|
|
9
|
-
} from '@weasel-js/core';
|
|
2
|
+
import { ellipsePath, linePath, PathBuilder, rectPath, textCommand } from '@weasel-js/core';
|
|
10
3
|
import type { WorldRect } from './frac';
|
|
11
4
|
import type { AnnotationData, FracPoint } from './types';
|
|
12
5
|
|
|
13
6
|
/** One loud color, not a themed one: a mark sits over the instrument's own
|
|
14
7
|
* picture and has to be legible against whatever that picture is. */
|
|
15
8
|
const MARK_COLOR = '#e5484d';
|
|
16
|
-
/** World units, so a mark thickens with the picture it annotates
|
|
17
|
-
* marker geometry can be resolved here, which needs a resolved width. */
|
|
9
|
+
/** World units, so a mark thickens with the picture it annotates. */
|
|
18
10
|
const MARK_WIDTH = 2;
|
|
19
11
|
const TEXT_SIZE = 14;
|
|
20
12
|
const POINT_RADIUS = 4;
|
|
@@ -97,15 +89,7 @@ export function markCommands(
|
|
|
97
89
|
case 'arrow': {
|
|
98
90
|
const [a, b] = vertices(m, content);
|
|
99
91
|
// The spec's arrow: a line carrying an end marker, not its own geometry.
|
|
100
|
-
|
|
101
|
-
// something turns it into geometry, and only the kit's own node painter
|
|
102
|
-
// does that, for the nodes it owns.
|
|
103
|
-
const marked: Stroke = { ...stroke, markerEnd: 'arrow' };
|
|
104
|
-
const path = linePath(a, b);
|
|
105
|
-
return [
|
|
106
|
-
{ kind: 'path', path, stroke: marked },
|
|
107
|
-
...markerDrawCommands(path, marked, MARK_WIDTH, undefined),
|
|
108
|
-
];
|
|
92
|
+
return [{ kind: 'path', path: linePath(a, b), stroke: { ...stroke, markerEnd: 'arrow' } }];
|
|
109
93
|
}
|
|
110
94
|
case 'stroke':
|
|
111
95
|
return [{ kind: 'path', path: polyline(vertices(m, content)), stroke }];
|
|
@@ -51,16 +51,13 @@ describe('a mark as vector', () => {
|
|
|
51
51
|
expect(node.stroke?.dash).toEqual([6, 4]);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
it("
|
|
54
|
+
it("keeps the arrow's marker, so the export draws its head", () => {
|
|
55
55
|
const nodes = markSvgNodes(mark('arrow'), CONTENT);
|
|
56
|
-
expect(nodes
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
expect(node.stroke?.markerEnd).toBeUndefined();
|
|
62
|
-
}
|
|
63
|
-
expect(serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: 100, height: 60 } })).not.toContain(
|
|
56
|
+
expect(nodes).toHaveLength(1);
|
|
57
|
+
const [node] = nodes;
|
|
58
|
+
if (node?.kind !== 'path') throw new Error('expected a path node');
|
|
59
|
+
expect(node.stroke?.markerEnd).toBe('arrow');
|
|
60
|
+
expect(serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: 100, height: 60 } })).toContain(
|
|
64
61
|
'<marker',
|
|
65
62
|
);
|
|
66
63
|
});
|
|
@@ -16,9 +16,6 @@ function toSvgPaint(paint: FillStyle | undefined): SvgPaint {
|
|
|
16
16
|
return { kind: 'gradient', paint };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/** Marker references are deliberately dropped: `markCommands` already resolves
|
|
20
|
-
* a marker into its own path command, and keeping the reference would make
|
|
21
|
-
* the serializer emit a `<marker>` def that draws the head a second time. */
|
|
22
19
|
function toSvgStroke(stroke: Stroke): SvgStroke {
|
|
23
20
|
return {
|
|
24
21
|
paint: toSvgPaint(stroke.paint),
|
|
@@ -28,6 +25,9 @@ function toSvgStroke(stroke: Stroke): SvgStroke {
|
|
|
28
25
|
...(stroke.dash ? { dash: [...stroke.dash] } : {}),
|
|
29
26
|
...(stroke.miterLimit != null ? { miterLimit: stroke.miterLimit } : {}),
|
|
30
27
|
...(stroke.align ? { align: stroke.align } : {}),
|
|
28
|
+
...(stroke.markerStart ? { markerStart: stroke.markerStart } : {}),
|
|
29
|
+
...(stroke.markerMid ? { markerMid: stroke.markerMid } : {}),
|
|
30
|
+
...(stroke.markerEnd ? { markerEnd: stroke.markerEnd } : {}),
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
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) |
|
|
@@ -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,
|
|
@@ -20,6 +18,7 @@ import {
|
|
|
20
18
|
type View,
|
|
21
19
|
type ViewApi,
|
|
22
20
|
viewportDragPanAction,
|
|
21
|
+
type Yoke,
|
|
23
22
|
} from '@weasel-js/core';
|
|
24
23
|
import {
|
|
25
24
|
createContext,
|
|
@@ -33,6 +32,7 @@ import {
|
|
|
33
32
|
import type { Point, ViewTransform } from '../instrument/types';
|
|
34
33
|
import { normalize2DView } from '../state/view';
|
|
35
34
|
import { CameraWheelContext } from './CameraWheelContext';
|
|
35
|
+
import { usePublishCamera } from './cameraRegistry';
|
|
36
36
|
import { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
|
|
37
37
|
import type { ViewportSize, WorldFrame } from './worldSpec';
|
|
38
38
|
|
|
@@ -57,31 +57,20 @@ export interface CameraContextValue {
|
|
|
57
57
|
/** The camera around the caller, or `null` outside one. */
|
|
58
58
|
export const CameraContext = createContext<CameraContextValue | null>(null);
|
|
59
59
|
|
|
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
60
|
/**
|
|
65
|
-
* The
|
|
66
|
-
* an
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
61
|
+
* The input scope a camera routes through. Inside another camera — a loupe or
|
|
62
|
+
* an overlay laid over a canvas stack — it joins that camera's scope, so its
|
|
63
|
+
* actions reach the dispatcher already listening there. Anywhere else it
|
|
64
|
+
* mounts a scope of its own, which keeps its own tool unless it joins `yoke`:
|
|
65
|
+
* a camera beside another never takes that one's gestures. The pointer store is the exception — one
|
|
66
|
+
* already in scope is kept, since sharing it is the point.
|
|
70
67
|
*/
|
|
71
|
-
export function CameraScope({ children }: { children: ReactNode }) {
|
|
72
|
-
const
|
|
68
|
+
export function CameraScope({ yoke, children }: { yoke?: Yoke; children: ReactNode }) {
|
|
69
|
+
const around = useContext(CameraContext);
|
|
73
70
|
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
|
-
);
|
|
71
|
+
if (around) return <>{children}</>;
|
|
72
|
+
const scoped = <InputScope yoke={yoke}>{children}</InputScope>;
|
|
73
|
+
return pointer ? scoped : <PointerContextProvider>{scoped}</PointerContextProvider>;
|
|
85
74
|
}
|
|
86
75
|
|
|
87
76
|
/** Options for {@link useCameraView}. */
|
|
@@ -94,6 +83,17 @@ export interface CameraViewOptions {
|
|
|
94
83
|
maxZoom?: number;
|
|
95
84
|
}
|
|
96
85
|
|
|
86
|
+
/** A camera's zoom limits, after widening to admit its opening zoom. */
|
|
87
|
+
export interface ZoomRange {
|
|
88
|
+
min: number;
|
|
89
|
+
max: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A labkit camera: a weasel `ViewApi` that also says how far it zooms. */
|
|
93
|
+
export interface CameraView extends ViewApi {
|
|
94
|
+
zoomRange(): ZoomRange;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
97
|
function isPositiveFinite(n: number): boolean {
|
|
98
98
|
return Number.isFinite(n) && n > 0;
|
|
99
99
|
}
|
|
@@ -110,7 +110,7 @@ export function useCameraView({
|
|
|
110
110
|
hostRef,
|
|
111
111
|
minZoom = 0.1,
|
|
112
112
|
maxZoom = 32,
|
|
113
|
-
}: CameraViewOptions):
|
|
113
|
+
}: CameraViewOptions): CameraView {
|
|
114
114
|
const live = useRef({ view: normalize2DView(view), onViewChange, frame });
|
|
115
115
|
live.current = { view: normalize2DView(view), onViewChange, frame };
|
|
116
116
|
const initialZoom = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
|
|
@@ -121,7 +121,7 @@ export function useCameraView({
|
|
|
121
121
|
max: opening == null ? maxZoom : Math.max(maxZoom, opening),
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
-
return useMemo<
|
|
124
|
+
return useMemo<CameraView>(() => {
|
|
125
125
|
const get = (): View => toCameraView(live.current.view, live.current.frame);
|
|
126
126
|
return {
|
|
127
127
|
get,
|
|
@@ -138,6 +138,7 @@ export function useCameraView({
|
|
|
138
138
|
const r = hostRef.current?.getBoundingClientRect();
|
|
139
139
|
return r ? { width: r.width, height: r.height } : null;
|
|
140
140
|
},
|
|
141
|
+
zoomRange: () => ({ ...bounds.current }),
|
|
141
142
|
};
|
|
142
143
|
}, [hostRef]);
|
|
143
144
|
}
|
|
@@ -149,7 +150,9 @@ const CHANNELS = { contextMenu: false, ingest: false } as const;
|
|
|
149
150
|
/** Props for `<CameraInput>`. */
|
|
150
151
|
export interface CameraInputProps {
|
|
151
152
|
hostRef: RefObject<HTMLElement | null>;
|
|
152
|
-
camera
|
|
153
|
+
/** Published to the host's camera registry while its scope is the active
|
|
154
|
+
* one, so chrome acting on "the camera" acts on the one last used. */
|
|
155
|
+
camera: CameraView;
|
|
153
156
|
frame: WorldFrame;
|
|
154
157
|
/** A primary-button press released without crossing the drag threshold, at
|
|
155
158
|
* the world point it landed on. */
|
|
@@ -209,7 +212,8 @@ function CameraDispatch({
|
|
|
209
212
|
},
|
|
210
213
|
};
|
|
211
214
|
return [
|
|
212
|
-
|
|
215
|
+
// A trial pans on a plain drag; the kit action binds none of its own.
|
|
216
|
+
{ ...viewportDragPanAction, defaultBinding: { kind: 'drag' } },
|
|
213
217
|
{ ...zoom, defaultBinding: wheelOnly as Action['defaultBinding'] },
|
|
214
218
|
tap,
|
|
215
219
|
];
|
|
@@ -237,26 +241,33 @@ function CameraDispatch({
|
|
|
237
241
|
useGestureDispatcher({
|
|
238
242
|
canvasRef: hostRef,
|
|
239
243
|
actions: registry,
|
|
240
|
-
|
|
244
|
+
entriesById: NO_TOOLS,
|
|
241
245
|
clientToWorld,
|
|
242
246
|
channels: CHANNELS,
|
|
243
247
|
});
|
|
244
248
|
|
|
245
249
|
usePublishPointer(hostRef, clientToWorld, frame, STAGE_VIEW_ID);
|
|
250
|
+
usePublishCamera(camera);
|
|
246
251
|
|
|
247
252
|
// Input that lands outside the camera's element — an annotation target's
|
|
248
|
-
// box, portalled into the surface — reaches the
|
|
253
|
+
// box, portalled into the surface — reaches the active camera's dispatcher
|
|
254
|
+
// through here.
|
|
249
255
|
const wheelSlot = useContext(CameraWheelContext);
|
|
250
256
|
useEffect(() => {
|
|
251
257
|
if (!wheelSlot) return;
|
|
252
258
|
const forward = (e: WheelEvent): void => {
|
|
253
259
|
hostRef.current?.dispatchEvent(new WheelEvent('wheel', e));
|
|
254
260
|
};
|
|
255
|
-
|
|
261
|
+
const claim = (): void => {
|
|
262
|
+
if (registry.isActive()) wheelSlot.current = forward;
|
|
263
|
+
};
|
|
264
|
+
claim();
|
|
265
|
+
const off = registry.subscribe(claim);
|
|
256
266
|
return () => {
|
|
267
|
+
off();
|
|
257
268
|
if (wheelSlot.current === forward) wheelSlot.current = null;
|
|
258
269
|
};
|
|
259
|
-
}, [wheelSlot, hostRef]);
|
|
270
|
+
}, [wheelSlot, hostRef, registry]);
|
|
260
271
|
|
|
261
272
|
return null;
|
|
262
273
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Yoke } from '@weasel-js/core';
|
|
1
2
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
3
|
import type { Point, ViewTransform } from '../instrument/types';
|
|
3
4
|
import { normalize2DView } from '../state/view';
|
|
@@ -25,6 +26,9 @@ export interface CanvasStackProps {
|
|
|
25
26
|
onResize?: (size: ViewportSize) => void;
|
|
26
27
|
onHitTest?: (worldPos: Point) => void;
|
|
27
28
|
children?: ReactNode;
|
|
29
|
+
/** A yoke to join, from `useYoke()`: cameras on one yoke share their tool
|
|
30
|
+
* and gesture in flight. Omitted, this one keeps its own. */
|
|
31
|
+
yoke?: Yoke;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
/** Stacks one `<canvas>` per layer and drives them from a shared view, so a
|
|
@@ -44,6 +48,7 @@ export function CanvasStack({
|
|
|
44
48
|
onResize,
|
|
45
49
|
onHitTest,
|
|
46
50
|
children,
|
|
51
|
+
yoke,
|
|
47
52
|
}: CanvasStackProps) {
|
|
48
53
|
const view = normalize2DView(viewProp);
|
|
49
54
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -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,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,4 @@
|
|
|
1
|
+
import type { Yoke } from '@weasel-js/core';
|
|
1
2
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
2
3
|
import type { ViewTransform } from '../instrument/types';
|
|
3
4
|
import { normalize2DView } from '../state/view';
|
|
@@ -21,6 +22,9 @@ export interface StageProps {
|
|
|
21
22
|
/** Drawn over the content in viewport pixels, outside the camera. */
|
|
22
23
|
overlay?: ReactNode;
|
|
23
24
|
children?: ReactNode;
|
|
25
|
+
/** A yoke to join, from `useYoke()`: cameras on one yoke share their tool
|
|
26
|
+
* and gesture in flight. Omitted, this one keeps its own. */
|
|
27
|
+
yoke?: Yoke;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/**
|
|
@@ -58,6 +62,7 @@ export function Stage({
|
|
|
58
62
|
hostRef,
|
|
59
63
|
overlay,
|
|
60
64
|
children,
|
|
65
|
+
yoke,
|
|
61
66
|
}: StageProps) {
|
|
62
67
|
const view = normalize2DView(viewProp);
|
|
63
68
|
const host = useRef<HTMLDivElement | null>(null);
|
|
@@ -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
|
+
});
|