@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.
Files changed (162) hide show
  1. package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
  2. package/dist/_dts/{LayerList.d-BoELAMsM.d.ts → LayerList.d-DiuEArKq.d.ts} +2 -2
  3. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
  4. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
  5. package/dist/_dts/{Select.d-Cr8-zoup.d.ts → Select.d-Bensr_MT.d.ts} +11 -1
  6. package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-CCWfQQWM.d.ts} +58 -18
  7. package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
  8. package/dist/_dts/{index-CKyX0ME_.d.ts → index-CsYqUHD3.d.ts} +2 -2
  9. package/dist/_dts/{types-DTuViBvc.d.ts → types-CRPSGFAp.d.ts} +13 -4
  10. package/dist/_dts/{types-B7yBTTF9.d.ts → types-CSm9jgol.d.ts} +2 -2
  11. package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-CTZPgeZ7.d.ts} +1 -1
  12. package/dist/canvas/index.d.ts +6 -6
  13. package/dist/canvas/index.js +2 -2
  14. package/dist/chrome/index.d.ts +5 -5
  15. package/dist/chrome/index.js +8 -8
  16. package/dist/{chunk-QGMOA2YE.js → chunk-275JRRVD.js} +67 -70
  17. package/dist/chunk-275JRRVD.js.map +1 -0
  18. package/dist/{chunk-KHK5FGER.js → chunk-4LJSC7MB.js} +8 -10
  19. package/dist/chunk-4LJSC7MB.js.map +1 -0
  20. package/dist/{chunk-SLAQPUI4.js → chunk-7BO7HNGF.js} +5 -5
  21. package/dist/{chunk-SLAQPUI4.js.map → chunk-7BO7HNGF.js.map} +1 -1
  22. package/dist/chunk-CZZPXUOU.js +3 -0
  23. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  24. package/dist/{chunk-6FLXL3I7.js → chunk-FMK7UQ6V.js} +19 -4
  25. package/dist/chunk-FMK7UQ6V.js.map +1 -0
  26. package/dist/{chunk-O6FFFOHS.js → chunk-FZ7YFQN7.js} +16 -8
  27. package/dist/chunk-FZ7YFQN7.js.map +1 -0
  28. package/dist/{chunk-GHGO52SO.js → chunk-GOZLNM4L.js} +5 -5
  29. package/dist/{chunk-GHGO52SO.js.map → chunk-GOZLNM4L.js.map} +1 -1
  30. package/dist/{chunk-4TT3PNPR.js → chunk-I5DUQ5HM.js} +33 -36
  31. package/dist/chunk-I5DUQ5HM.js.map +1 -0
  32. package/dist/{chunk-NEXR37YJ.js → chunk-J7OWWTCW.js} +4 -5
  33. package/dist/chunk-J7OWWTCW.js.map +1 -0
  34. package/dist/{chunk-IRTGUQ5O.js → chunk-KMJ7RWD3.js} +16 -6
  35. package/dist/chunk-KMJ7RWD3.js.map +1 -0
  36. package/dist/{chunk-S4NCI32V.js → chunk-M7HP3FTK.js} +4 -4
  37. package/dist/{chunk-S4NCI32V.js.map → chunk-M7HP3FTK.js.map} +1 -1
  38. package/dist/chunk-MIM5R4ZT.js +14972 -0
  39. package/dist/chunk-MIM5R4ZT.js.map +1 -0
  40. package/dist/{chunk-ASOK76ZH.js → chunk-T5IBSPAM.js} +59 -26
  41. package/dist/chunk-T5IBSPAM.js.map +1 -0
  42. package/dist/{chunk-DWMSB4CA.js → chunk-UNAZSLWE.js} +82 -16
  43. package/dist/chunk-UNAZSLWE.js.map +1 -0
  44. package/dist/{chunk-TJ77A4A3.js → chunk-Y23G2VZL.js} +5 -9
  45. package/dist/chunk-Y23G2VZL.js.map +1 -0
  46. package/dist/{chunk-HKV7LRHY.js → chunk-YT75VYRD.js} +4 -4
  47. package/dist/{chunk-HKV7LRHY.js.map → chunk-YT75VYRD.js.map} +1 -1
  48. package/dist/{chunk-CGHF6RZV.js → chunk-ZIFPHIWZ.js} +9 -7
  49. package/dist/chunk-ZIFPHIWZ.js.map +1 -0
  50. package/dist/config/index.d.ts +16 -9
  51. package/dist/config/index.js +4 -4
  52. package/dist/controls/index.d.ts +2 -2
  53. package/dist/controls/index.js +5 -5
  54. package/dist/dragdrop/index.d.ts +3 -3
  55. package/dist/index.d.ts +263 -24
  56. package/dist/index.js +376 -237
  57. package/dist/index.js.map +1 -1
  58. package/dist/layers/index.d.ts +4 -4
  59. package/dist/layers/index.js +2 -2
  60. package/dist/loupe/index.d.ts +4 -4
  61. package/dist/loupe/index.js +2 -2
  62. package/dist/overview/index.d.ts +2 -2
  63. package/dist/overview/index.js +9 -8
  64. package/dist/overview/index.js.map +1 -1
  65. package/dist/passthrough/weasel-ui.d.ts +208 -38
  66. package/dist/passthrough/weasel-ui.js +3 -3
  67. package/dist/primitives/index.d.ts +3 -178
  68. package/dist/primitives/index.js +6 -6
  69. package/dist/state/index.d.ts +5 -5
  70. package/dist/state/index.js +4 -4
  71. package/dist/styles.css +91 -83
  72. package/dist/undo/index.d.ts +3 -3
  73. package/package.json +13 -12
  74. package/src/annotations/AnnotationOverlay.tsx +2 -2
  75. package/src/annotations/Annotations.less +0 -10
  76. package/src/annotations/MarkList.tsx +4 -8
  77. package/src/annotations/paint.test.ts +3 -14
  78. package/src/annotations/paint.ts +3 -19
  79. package/src/annotations/svgNodes.test.ts +6 -9
  80. package/src/annotations/svgNodes.ts +3 -3
  81. package/src/canvas/AGENTS.md +1 -0
  82. package/src/canvas/CameraInput.tsx +44 -33
  83. package/src/canvas/CanvasStack.tsx +6 -1
  84. package/src/canvas/LinkedCursor.less +1 -1
  85. package/src/canvas/Stage.test.tsx +33 -4
  86. package/src/canvas/Stage.tsx +6 -1
  87. package/src/canvas/cameraRegistry.test.ts +55 -0
  88. package/src/canvas/cameraRegistry.ts +86 -0
  89. package/src/canvas/cameraZoom.test.ts +46 -0
  90. package/src/canvas/cameraZoom.ts +24 -0
  91. package/src/canvas/index.ts +14 -2
  92. package/src/canvas/sharedScope.test.tsx +94 -0
  93. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  94. package/src/chrome/regions/PaletteRegion.less +0 -7
  95. package/src/chrome/regions/SidebarRegion.less +12 -2
  96. package/src/config/builder.test.ts +2 -1
  97. package/src/config/builder.ts +12 -5
  98. package/src/config/fromConfigField.test.ts +14 -0
  99. package/src/config/fromConfigField.ts +18 -4
  100. package/src/config/path.ts +2 -5
  101. package/src/config/resolve.ts +4 -0
  102. package/src/config/types.ts +13 -9
  103. package/src/controls/ControlPanel.less +1 -1
  104. package/src/controls/ControlPanel.stories.tsx +3 -2
  105. package/src/controls/ControlPanel.test.tsx +64 -11
  106. package/src/controls/ControlPanel.tsx +52 -2
  107. package/src/index.ts +15 -1
  108. package/src/lab/Lab.tsx +83 -72
  109. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  110. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  111. package/src/lab/LabFullChrome.stories.tsx +1 -0
  112. package/src/lab/LabHeader.test.tsx +24 -14
  113. package/src/lab/LabHeader.tsx +11 -12
  114. package/src/lab/LabShell.less +8 -17
  115. package/src/lab/LabSwitcher.less +6 -6
  116. package/src/lab/LabZoom.test.tsx +223 -0
  117. package/src/lab/LabZoom.tsx +170 -0
  118. package/src/lab/index.ts +1 -0
  119. package/src/loupe/LoupeGestures.tsx +1 -1
  120. package/src/overview/TrialOverview.tsx +7 -5
  121. package/src/passthrough/weasel-ui.test.ts +2 -0
  122. package/src/passthrough/weasel-ui.ts +16 -4
  123. package/src/primitives/FloatingPanel.test.tsx +11 -0
  124. package/src/primitives/FloatingPanel.tsx +2 -5
  125. package/src/primitives/JobProgress.less +1 -1
  126. package/src/primitives/Legend.stories.tsx +1 -0
  127. package/src/primitives/Readout.browser.test.tsx +137 -0
  128. package/src/primitives/Readout.less +12 -0
  129. package/src/primitives/Readout.stories.tsx +58 -0
  130. package/src/primitives/Readout.test.tsx +93 -0
  131. package/src/primitives/Readout.tsx +56 -0
  132. package/src/primitives/index.ts +2 -0
  133. package/src/specimen/Specimen.stories.tsx +1 -0
  134. package/src/specimen/Specimen.tsx +2 -2
  135. package/src/state/Persistence.browser.test.tsx +47 -0
  136. package/src/state/store.test.ts +5 -1
  137. package/src/state/usePersistedState.test.tsx +1 -1
  138. package/src/styles.less +1 -0
  139. package/src/theme/Interstellar.stories.tsx +3 -1
  140. package/src/theme/base.less +21 -47
  141. package/src/theme/interstellar.test.ts +1 -1
  142. package/src/theme/interstellar.theme.json +10 -3
  143. package/src/trial/Trial.less +0 -13
  144. package/src/trial/Trial.tsx +12 -6
  145. package/src/trial/TrialChrome.tsx +21 -3
  146. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  147. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
  148. package/dist/chunk-4TT3PNPR.js.map +0 -1
  149. package/dist/chunk-6FLXL3I7.js.map +0 -1
  150. package/dist/chunk-ASOK76ZH.js.map +0 -1
  151. package/dist/chunk-CGHF6RZV.js.map +0 -1
  152. package/dist/chunk-DWMSB4CA.js.map +0 -1
  153. package/dist/chunk-IRTGUQ5O.js.map +0 -1
  154. package/dist/chunk-KHK5FGER.js.map +0 -1
  155. package/dist/chunk-NEXR37YJ.js.map +0 -1
  156. package/dist/chunk-O6FFFOHS.js.map +0 -1
  157. package/dist/chunk-QGMOA2YE.js.map +0 -1
  158. package/dist/chunk-SBXNQW4G.js +0 -3
  159. package/dist/chunk-TJ77A4A3.js.map +0 -1
  160. package/dist/chunk-TYN6MLXX.js +0 -13864
  161. package/dist/chunk-TYN6MLXX.js.map +0 -1
  162. package/src/state/Persistence.stories.tsx +0 -75
@@ -1,9 +1,9 @@
1
- export { az as UndoCapability } from '../_dts/types-B7yBTTF9.js';
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-DFpPIeIC.js';
4
+ import '../_dts/PrefsRow.d-TOZ7BgD9.js';
5
5
  import '@weasel-js/theme';
6
- import '../_dts/types-DTuViBvc.js';
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.6.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.6.1",
119
- "@weasel-js/theme": "1.6.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.6.1",
125
- "@weasel-js/kernel3d": "1.6.1",
126
- "@weasel-js/loupe": "1.6.1",
127
- "@weasel-js/svg": "1.6.1",
128
- "@weasel-js/ui": "1.6.1",
129
- "earcut": "2.2.4",
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": "^1.3.0",
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": "^6.6.0",
139
- "@weasel-js/modes": "1.6.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
- // One scope per pane: a shared <ActionsProvider> lets only the newest
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
- <button
39
- type="button"
38
+ <CloseButton
40
39
  className="lk-mark-list__drop"
41
- aria-label={`Remove ${a.kind} on ${a.target}`}
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, plus the head as its own command', () => {
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([cmds[0]])).toEqual([0.1 * 200, 0.2 * 100, 0.6 * 200, 0.9 * 100]);
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', () => {
@@ -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 — and so the
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
- // Markers are separate commands — a stroke's `markerEnd` is inert unless
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("drops the arrow's marker reference, because its head is already a node", () => {
54
+ it("keeps the arrow's marker, so the export draws its head", () => {
55
55
  const nodes = markSvgNodes(mark('arrow'), CONTENT);
56
- expect(nodes.length).toBeGreaterThan(1);
57
- for (const node of nodes) {
58
- if (node.kind !== 'path') throw new Error('expected path nodes');
59
- // Keeping `markerEnd` would make the serializer emit a <marker> def as
60
- // well, and the head would be drawn twice.
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
 
@@ -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
- ActiveToolContextProvider,
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 actions and deps a camera routes through: the ones a trial provides, or
66
- * an isolated set of its own. Isolated, because an actions registry holds one
67
- * dispatcher, and a camera must not take input from a canvas beside it. The
68
- * pointer store is the exception — one already in scope is kept, since sharing
69
- * it is the point.
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 inScope = useContext(CameraScopeContext);
68
+ export function CameraScope({ yoke, children }: { yoke?: Yoke; children: ReactNode }) {
69
+ const around = useContext(CameraContext);
73
70
  const pointer = usePointerContext();
74
- if (inScope) return <>{children}</>;
75
- const scoped = <CameraScopeContext.Provider value={true}>{children}</CameraScopeContext.Provider>;
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): ViewApi {
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<ViewApi>(() => {
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: ViewApi;
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
- viewportDragPanAction,
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
- toolsById: NO_TOOLS,
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 dispatcher through here.
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
- wheelSlot.current = forward;
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} />
@@ -12,5 +12,5 @@
12
12
  }
13
13
 
14
14
  .lk-linked-cursor__bar {
15
- fill: var(--wzl-accent, #4c8dff);
15
+ fill: var(--wzl-accent);
16
16
  }
@@ -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(screen.getByRole('button', { name: 'Zoom in' }));
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(screen.getByRole('button', { name: 'Actual size' }));
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('button', { name: 'Zoom in' })).toBeNull();
213
+ expect(screen.queryByRole('toolbar', { name: 'View' })).toBeNull();
185
214
  });
186
215
 
187
216
  it('draws its overlay on the stage, outside the camera', () => {
@@ -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
+ });