@weasel-js/labkit 1.3.0 → 1.4.0-pre.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 (210) hide show
  1. package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
  2. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  3. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  4. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  5. package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
  6. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  7. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  8. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  9. package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
  10. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  11. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  12. package/dist/canvas/index.d.ts +43 -25
  13. package/dist/canvas/index.js +5 -4
  14. package/dist/chrome/index.d.ts +9 -6
  15. package/dist/chrome/index.js +6 -6
  16. package/dist/chunk-2AYGEN57.js +32 -0
  17. package/dist/chunk-2AYGEN57.js.map +1 -0
  18. package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
  19. package/dist/chunk-3P5TTTA7.js.map +1 -0
  20. package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
  21. package/dist/chunk-3ZELRRBV.js.map +1 -0
  22. package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
  23. package/dist/chunk-4EY67BKJ.js.map +1 -0
  24. package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
  25. package/dist/chunk-5TCEVMPI.js.map +1 -0
  26. package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
  27. package/dist/chunk-6JMU56CU.js.map +1 -0
  28. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  29. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  30. package/dist/chunk-CTRKTLYZ.js +33 -0
  31. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  32. package/dist/chunk-EQIB2PGC.js +458 -0
  33. package/dist/chunk-EQIB2PGC.js.map +1 -0
  34. package/dist/chunk-HEORBLVT.js +7128 -0
  35. package/dist/chunk-HEORBLVT.js.map +1 -0
  36. package/dist/{chunk-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
  37. package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
  38. package/dist/chunk-LITE5YFB.js +669 -0
  39. package/dist/chunk-LITE5YFB.js.map +1 -0
  40. package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
  41. package/dist/chunk-ROQDRXKW.js.map +1 -0
  42. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  43. package/dist/chunk-XJ6N32QP.js.map +1 -0
  44. package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
  45. package/dist/chunk-YQJNT4XQ.js.map +1 -0
  46. package/dist/controls/index.d.ts +4 -28
  47. package/dist/controls/index.js +3 -3
  48. package/dist/dragdrop/index.d.ts +9 -5
  49. package/dist/dragdrop/index.js +3 -3
  50. package/dist/index.d.ts +518 -190
  51. package/dist/index.js +1753 -75
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +6 -5
  54. package/dist/layers/index.js +3 -3
  55. package/dist/loupe/index.d.ts +187 -0
  56. package/dist/loupe/index.js +7 -0
  57. package/dist/loupe/index.js.map +1 -0
  58. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  59. package/dist/passthrough/weasel-canvas.js +1 -1
  60. package/dist/passthrough/weasel-ui.d.ts +138 -91
  61. package/dist/passthrough/weasel-ui.js +2 -2
  62. package/dist/primitives/index.d.ts +3 -1
  63. package/dist/primitives/index.js +5 -5
  64. package/dist/state/index.d.ts +3 -3
  65. package/dist/state/index.js +2 -2
  66. package/dist/styles.css +229 -1
  67. package/dist/surface/index.d.ts +18 -2
  68. package/dist/surface/index.js +2 -2
  69. package/dist/ui/layers/index.js +2 -2
  70. package/dist/undo/index.d.ts +5 -4
  71. package/package.json +14 -7
  72. package/src/annotations/AnnotationOverlay.tsx +205 -0
  73. package/src/annotations/AnnotationTargets.tsx +37 -0
  74. package/src/annotations/Annotations.less +129 -0
  75. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  76. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  77. package/src/annotations/AnnotationsContext.ts +49 -0
  78. package/src/annotations/ExportMenu.test.tsx +86 -0
  79. package/src/annotations/ExportMenu.tsx +182 -0
  80. package/src/annotations/MarkList.tsx +75 -0
  81. package/src/annotations/capture.test.ts +140 -0
  82. package/src/annotations/capture.ts +237 -0
  83. package/src/annotations/drawOne.test.ts +56 -0
  84. package/src/annotations/drawOne.ts +47 -0
  85. package/src/annotations/frac.test.ts +59 -0
  86. package/src/annotations/frac.ts +66 -0
  87. package/src/annotations/history.test.ts +82 -0
  88. package/src/annotations/history.ts +118 -0
  89. package/src/annotations/index.ts +37 -0
  90. package/src/annotations/paint.test.ts +125 -0
  91. package/src/annotations/paint.ts +130 -0
  92. package/src/annotations/staleness.test.ts +56 -0
  93. package/src/annotations/staleness.ts +41 -0
  94. package/src/annotations/store.test.ts +175 -0
  95. package/src/annotations/store.ts +317 -0
  96. package/src/annotations/svgNodes.test.ts +77 -0
  97. package/src/annotations/svgNodes.ts +74 -0
  98. package/src/annotations/toolMap.test.ts +36 -0
  99. package/src/annotations/toolMap.ts +54 -0
  100. package/src/annotations/types.ts +222 -0
  101. package/src/annotations/view.test.ts +37 -0
  102. package/src/annotations/view.ts +40 -0
  103. package/src/canvas/AGENTS.md +55 -5
  104. package/src/canvas/CanvasStack.test.tsx +27 -1
  105. package/src/canvas/CanvasStack.tsx +29 -4
  106. package/src/canvas/CanvasStackContext.ts +23 -4
  107. package/src/canvas/camera.test.ts +78 -0
  108. package/src/canvas/camera.ts +62 -0
  109. package/src/canvas/canvasCoords.test.ts +26 -0
  110. package/src/canvas/canvasCoords.ts +20 -7
  111. package/src/canvas/index.ts +5 -1
  112. package/src/canvas/useLayerScheduler.ts +7 -3
  113. package/src/canvas/usePanZoom.test.ts +36 -5
  114. package/src/canvas/usePanZoom.ts +20 -15
  115. package/src/canvas/worldSpec.test.ts +63 -0
  116. package/src/canvas/worldSpec.ts +48 -0
  117. package/src/chrome/builtins.test.ts +31 -1
  118. package/src/chrome/builtins.tsx +63 -19
  119. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  120. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  121. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  122. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  123. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  124. package/src/chrome/regions/regions.test.tsx +6 -1
  125. package/src/chrome/types.ts +20 -0
  126. package/src/controls/ControlPanel.test.tsx +38 -0
  127. package/src/controls/ControlPanel.tsx +50 -3
  128. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  129. package/src/dragdrop/dragDrop.test.tsx +29 -2
  130. package/src/index.test.ts +22 -0
  131. package/src/index.ts +87 -2
  132. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  133. package/src/instrument/types.ts +24 -1
  134. package/src/lab/Lab.surface.test.tsx +162 -0
  135. package/src/lab/Lab.tsx +102 -17
  136. package/src/lab/Lab.undock.test.tsx +85 -0
  137. package/src/lab/LabFullChrome.stories.tsx +20 -0
  138. package/src/lab/LabShell.less +11 -0
  139. package/src/lab/Workspace.less +73 -0
  140. package/src/lab/Workspace.surface.test.tsx +2 -0
  141. package/src/lab/Workspace.tsx +97 -8
  142. package/src/lab/index.ts +1 -1
  143. package/src/lab/panelHost.ts +37 -0
  144. package/src/loupe/AGENTS.md +49 -0
  145. package/src/loupe/CanvasLoupe.tsx +83 -0
  146. package/src/loupe/DomLoupe.tsx +62 -0
  147. package/src/loupe/Loupe.less +38 -0
  148. package/src/loupe/LoupeBubble.tsx +32 -0
  149. package/src/loupe/TrialLoupe.tsx +93 -0
  150. package/src/loupe/canvasLens.test.ts +205 -0
  151. package/src/loupe/canvasLens.ts +141 -0
  152. package/src/loupe/index.ts +19 -0
  153. package/src/loupe/types.test.ts +29 -0
  154. package/src/loupe/types.ts +90 -0
  155. package/src/loupe/useHostSize.ts +30 -0
  156. package/src/loupe/useLoupe.test.tsx +197 -0
  157. package/src/loupe/useLoupe.ts +189 -0
  158. package/src/passthrough/weasel-ui.ts +3 -0
  159. package/src/primitives/FloatingPanel.test.tsx +76 -1
  160. package/src/primitives/FloatingPanel.tsx +19 -3
  161. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  162. package/src/primitives/Sidebar.less +18 -0
  163. package/src/primitives/Toolbar.less +8 -0
  164. package/src/primitives/Toolbar.tsx +5 -0
  165. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  166. package/src/primitives/useRovingTabIndex.ts +26 -17
  167. package/src/state/document.test.ts +23 -0
  168. package/src/state/document.ts +14 -2
  169. package/src/state/index.ts +2 -0
  170. package/src/state/store.test.ts +24 -0
  171. package/src/state/store.ts +35 -1
  172. package/src/state/types.ts +11 -0
  173. package/src/state/undock.test.ts +40 -0
  174. package/src/state/undock.ts +39 -0
  175. package/src/styles.less +2 -0
  176. package/src/surface/SurfaceContext.ts +4 -0
  177. package/src/surface/index.ts +8 -3
  178. package/src/surface/useSurfaceTile.test.tsx +2 -0
  179. package/src/surface/useSurfaceTile.ts +7 -1
  180. package/src/surface/useTiledSurface.test.tsx +58 -0
  181. package/src/surface/useTiledSurface.ts +38 -3
  182. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  183. package/src/trial/Trial.annotations.test.tsx +121 -0
  184. package/src/trial/Trial.canvas.test.tsx +76 -1
  185. package/src/trial/Trial.less +19 -0
  186. package/src/trial/Trial.loupe.test.tsx +128 -0
  187. package/src/trial/Trial.stories.tsx +1 -0
  188. package/src/trial/Trial.test.tsx +12 -0
  189. package/src/trial/Trial.trialId.test.tsx +36 -0
  190. package/src/trial/Trial.tsx +215 -40
  191. package/src/trial/TrialChrome.tsx +35 -0
  192. package/src/trial/UndockedSections.tsx +65 -0
  193. package/src/trial/trialOps.test.ts +21 -0
  194. package/src/trial/trialOps.ts +5 -3
  195. package/dist/_dts/types-BttTIent.d.ts +0 -314
  196. package/dist/chunk-3XXPU73K.js.map +0 -1
  197. package/dist/chunk-4TMMVIDM.js.map +0 -1
  198. package/dist/chunk-6X5RBC6G.js +0 -366
  199. package/dist/chunk-6X5RBC6G.js.map +0 -1
  200. package/dist/chunk-BDJXIBRZ.js.map +0 -1
  201. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  202. package/dist/chunk-JDROYQM3.js.map +0 -1
  203. package/dist/chunk-LGKRIUSW.js.map +0 -1
  204. package/dist/chunk-QCICIHEO.js.map +0 -1
  205. package/dist/chunk-QRYPXSGP.js.map +0 -1
  206. package/dist/chunk-RGT4EJIB.js +0 -6961
  207. package/dist/chunk-RGT4EJIB.js.map +0 -1
  208. package/dist/chunk-T66HU3HX.js +0 -17
  209. package/dist/chunk-T66HU3HX.js.map +0 -1
  210. package/dist/chunk-WTI26YTM.js.map +0 -1
@@ -0,0 +1,62 @@
1
+ import type { Point, ViewTransform } from '../instrument/types';
2
+ import { DEFAULT_FRAME, type ViewportSize, type WorldFrame } from './worldSpec';
3
+
4
+ /** Bounds and coordinate system a {@link zoomAt} works in. */
5
+ export interface ZoomAtOptions {
6
+ /** The instrument's coordinate system, resolved against the viewport.
7
+ * Omitted, `at` is measured from the element's top-left. */
8
+ frame?: WorldFrame;
9
+ min?: number;
10
+ max?: number;
11
+ }
12
+
13
+ function usableZoom(zoom: number): number {
14
+ return Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
15
+ }
16
+
17
+ /**
18
+ * Zoom a view by `factor` about a fixed point, so whatever world coordinate
19
+ * `at` was over stays under it.
20
+ *
21
+ * `at` is in the element's own pixels — a cursor position, or the centre of a
22
+ * lens. The clamp is applied before the pan is solved, so a zoom that hits a
23
+ * bound still anchors on the zoom it actually reached.
24
+ */
25
+ export function zoomAt(
26
+ view: ViewTransform,
27
+ factor: number,
28
+ at: Point,
29
+ { frame = DEFAULT_FRAME, min, max }: ZoomAtOptions = {},
30
+ ): ViewTransform {
31
+ // `pan` is measured from the frame's origin, so anchoring at the raw point
32
+ // drifts by `(1 - ratio) * originPx` per step on any frame that moves it.
33
+ const anchorX = at.x - frame.originPx.x;
34
+ const anchorY = at.y - frame.originPx.y;
35
+ const current = usableZoom(view.zoom);
36
+ const zoom = Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? 0, current * factor));
37
+ const ratio = zoom / current;
38
+ return {
39
+ zoom,
40
+ pan: {
41
+ x: anchorX - (anchorX - view.pan.x) * ratio,
42
+ y: anchorY - (anchorY - view.pan.y) * ratio,
43
+ },
44
+ };
45
+ }
46
+
47
+ /** The view that puts a world point at the centre of a `size` viewport, at
48
+ * `zoom`. What a lens showing a region around one point renders through. */
49
+ export function centerOn(
50
+ world: Point,
51
+ zoom: number,
52
+ size: ViewportSize,
53
+ frame: WorldFrame = DEFAULT_FRAME,
54
+ ): ViewTransform {
55
+ return {
56
+ zoom,
57
+ pan: {
58
+ x: size.width / 2 - frame.originPx.x - world.x * zoom,
59
+ y: size.height / 2 - frame.originPx.y - world.y * zoom * frame.yDir,
60
+ },
61
+ };
62
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { screenToWorld, worldToScreen } from './canvasCoords';
3
+ import { resolveFrame } from './worldSpec';
3
4
 
4
5
  describe('canvasCoords', () => {
5
6
  it('identity: zoom=1 pan=0 leaves coords unchanged', () => {
@@ -27,4 +28,29 @@ describe('canvasCoords', () => {
27
28
  expect(back.x).toBeCloseTo(p.x, 10);
28
29
  expect(back.y).toBeCloseTo(p.y, 10);
29
30
  });
31
+
32
+ describe('under a declared frame', () => {
33
+ const size = { width: 800, height: 600 };
34
+
35
+ it('centres world (0,0) when the origin is the middle of the viewport', () => {
36
+ const frame = resolveFrame({ origin: { x: 0.5, y: 0.5 } }, size);
37
+ const v = { zoom: 1, pan: { x: 0, y: 0 } };
38
+ expect(worldToScreen({ x: 0, y: 0 }, v, frame)).toEqual({ x: 400, y: 300 });
39
+ });
40
+
41
+ it('runs y upward when the frame says so', () => {
42
+ const frame = resolveFrame({ yAxis: 'up' }, size);
43
+ const v = { zoom: 2, pan: { x: 0, y: 0 } };
44
+ expect(worldToScreen({ x: 3, y: 5 }, v, frame)).toEqual({ x: 6, y: -10 });
45
+ });
46
+
47
+ it('round-trips through a centred, y-up frame', () => {
48
+ const frame = resolveFrame({ origin: { x: 0.5, y: 0.5 }, yAxis: 'up' }, size);
49
+ const v = { zoom: 1.5, pan: { x: 7, y: -3 } };
50
+ const p = { x: 12.34, y: 56.78 };
51
+ const back = screenToWorld(worldToScreen(p, v, frame), v, frame);
52
+ expect(back.x).toBeCloseTo(p.x, 10);
53
+ expect(back.y).toBeCloseTo(p.y, 10);
54
+ });
55
+ });
30
56
  });
@@ -1,17 +1,30 @@
1
1
  import type { Point, ViewTransform } from '../instrument/types';
2
+ import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
2
3
 
3
- /** Project a world point into screen coordinates under a view. */
4
- export function worldToScreen(world: Point, view: ViewTransform): Point {
4
+ /** Project a world point into screen coordinates under a view.
5
+ *
6
+ * `frame` is the instrument's declared coordinate system, resolved against the
7
+ * viewport; omitting it gives labkit's original one — origin at the top-left,
8
+ * y downward. */
9
+ export function worldToScreen(
10
+ world: Point,
11
+ view: ViewTransform,
12
+ frame: WorldFrame = DEFAULT_FRAME,
13
+ ): Point {
5
14
  return {
6
- x: world.x * view.zoom + view.pan.x,
7
- y: world.y * view.zoom + view.pan.y,
15
+ x: frame.originPx.x + view.pan.x + world.x * view.zoom,
16
+ y: frame.originPx.y + view.pan.y + world.y * view.zoom * frame.yDir,
8
17
  };
9
18
  }
10
19
 
11
20
  /** Unproject a screen point back to world coordinates under a view. */
12
- export function screenToWorld(screen: Point, view: ViewTransform): Point {
21
+ export function screenToWorld(
22
+ screen: Point,
23
+ view: ViewTransform,
24
+ frame: WorldFrame = DEFAULT_FRAME,
25
+ ): Point {
13
26
  return {
14
- x: (screen.x - view.pan.x) / view.zoom,
15
- y: (screen.y - view.pan.y) / view.zoom,
27
+ x: (screen.x - frame.originPx.x - view.pan.x) / view.zoom,
28
+ y: (screen.y - frame.originPx.y - view.pan.y) / (view.zoom * frame.yDir),
16
29
  };
17
30
  }
@@ -1,6 +1,10 @@
1
1
  export type { CanvasStackProps } from './CanvasStack';
2
2
  export { CanvasStack } from './CanvasStack';
3
- export type { CanvasStackContextValue } from './CanvasStackContext';
3
+ export type { CanvasStackContextValue, CanvasStackSurface } from './CanvasStackContext';
4
4
  export { CanvasStackContext } from './CanvasStackContext';
5
+ export type { ZoomAtOptions } from './camera';
6
+ export { centerOn, zoomAt } from './camera';
5
7
  export { screenToWorld, worldToScreen } from './canvasCoords';
6
8
  export type { CanvasLayerDescriptor } from './useLayerScheduler';
9
+ export type { ViewportSize, WorldFrame, WorldSpec } from './worldSpec';
10
+ export { applyCamera, DEFAULT_FRAME, resolveFrame } from './worldSpec';
@@ -1,18 +1,21 @@
1
1
  import { useVisibleRaf } from '@weasel-js/core';
2
2
  import { type RefObject, useEffect, useRef } from 'react';
3
3
  import type { ViewTransform } from '../instrument/types';
4
+ import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
4
5
 
5
6
  /** One layer of a canvas stack: its id, whether it is currently shown, and how
6
7
  * it paints itself. */
7
8
  export interface CanvasLayerDescriptor {
8
9
  id: string;
9
10
  visible: boolean;
10
- render: (ctx: CanvasRenderingContext2D, view: ViewTransform) => void;
11
+ render: (ctx: CanvasRenderingContext2D, view: ViewTransform, frame: WorldFrame) => void;
11
12
  }
12
13
 
13
14
  interface SchedulerOptions {
14
15
  layers: CanvasLayerDescriptor[];
15
16
  view: ViewTransform;
17
+ /** The instrument's coordinate system, resolved against `size`. */
18
+ frame?: WorldFrame;
16
19
  canvasRefs: RefObject<Map<string, HTMLCanvasElement>>;
17
20
  size: { width: number; height: number; dpr: number };
18
21
  /** The element the stack occupies. Given one, the scheduler also stops while
@@ -23,6 +26,7 @@ interface SchedulerOptions {
23
26
  export function useLayerScheduler({
24
27
  layers,
25
28
  view,
29
+ frame = DEFAULT_FRAME,
26
30
  canvasRefs,
27
31
  size,
28
32
  host,
@@ -44,7 +48,7 @@ export function useLayerScheduler({
44
48
  // biome-ignore lint/correctness/useExhaustiveDependencies: re-mark dirty when view or size changes
45
49
  useEffect(() => {
46
50
  for (const layer of layers) dirty.current.add(layer.id);
47
- }, [view, size, layers]);
51
+ }, [view, frame, size, layers]);
48
52
 
49
53
  // Painting only what is dirty is not the same as doing nothing: a hidden tab
50
54
  // still commits React updates, and the effect above marks every layer dirty
@@ -67,7 +71,7 @@ export function useLayerScheduler({
67
71
  ctx.save();
68
72
  ctx.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);
69
73
  ctx.clearRect(0, 0, size.width, size.height);
70
- layer.render(ctx, view);
74
+ layer.render(ctx, view, frame);
71
75
  ctx.restore();
72
76
  }
73
77
  dirty.current.clear();
@@ -2,13 +2,15 @@ import { act, renderHook } from '@testing-library/react';
2
2
  import type { WheelEvent } from 'react';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import type { ViewTransform } from '../instrument/types';
5
+ import { screenToWorld } from './canvasCoords';
5
6
  import { type UsePanZoomOptions, usePanZoom } from './usePanZoom';
7
+ import { resolveFrame } from './worldSpec';
6
8
 
7
- function wheelEvent(deltaY: number): WheelEvent<HTMLElement> {
9
+ function wheelEvent(deltaY: number, cursor = { x: 0, y: 0 }): WheelEvent<HTMLElement> {
8
10
  return {
9
11
  deltaY,
10
- clientX: 0,
11
- clientY: 0,
12
+ clientX: cursor.x,
13
+ clientY: cursor.y,
12
14
  preventDefault: () => {},
13
15
  currentTarget: { getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }) },
14
16
  } as unknown as WheelEvent<HTMLElement>;
@@ -26,8 +28,8 @@ function setup(initialView: ViewTransform, options?: Partial<UsePanZoomOptions>)
26
28
  (props: { view: ViewTransform }) => usePanZoom({ ...options, view: props.view, onViewChange }),
27
29
  { initialProps: { view: initialView } },
28
30
  );
29
- const wheel = (deltaY: number): ViewTransform => {
30
- act(() => result.current.onWheel(wheelEvent(deltaY)));
31
+ const wheel = (deltaY: number, cursor?: { x: number; y: number }): ViewTransform => {
32
+ act(() => result.current.onWheel(wheelEvent(deltaY, cursor)));
31
33
  rerender({ view });
32
34
  return view;
33
35
  };
@@ -77,4 +79,33 @@ describe('usePanZoom', () => {
77
79
  const zoomedOut = wheel(1_000_000);
78
80
  expect(zoomedOut.zoom).toBeCloseTo(0.1, 5);
79
81
  });
82
+
83
+ describe('under a declared frame', () => {
84
+ const size = { width: 1430, height: 870 };
85
+
86
+ it('keeps the world point under the cursor fixed in a centred, y-up frame', () => {
87
+ const frame = resolveFrame({ origin: { x: 0.5, y: 0.5 }, yAxis: 'up' }, size);
88
+ const start: ViewTransform = { zoom: 1600, pan: { x: 0, y: 0 } };
89
+ const { wheel } = setup(start, { frame });
90
+ const cursor = { x: 900, y: 200 };
91
+
92
+ const before = screenToWorld(cursor, start, frame);
93
+ const after = screenToWorld(cursor, wheel(120, cursor), frame);
94
+
95
+ expect(after.x).toBeCloseTo(before.x, 6);
96
+ expect(after.y).toBeCloseTo(before.y, 6);
97
+ });
98
+
99
+ it('anchors on the element top-left when no frame is declared', () => {
100
+ const start: ViewTransform = { zoom: 4, pan: { x: 0, y: 0 } };
101
+ const { wheel } = setup(start);
102
+ const cursor = { x: 300, y: 120 };
103
+
104
+ const before = screenToWorld(cursor, start);
105
+ const after = screenToWorld(cursor, wheel(-120, cursor));
106
+
107
+ expect(after.x).toBeCloseTo(before.x, 6);
108
+ expect(after.y).toBeCloseTo(before.y, 6);
109
+ });
110
+ });
80
111
  });
@@ -1,5 +1,7 @@
1
1
  import { type PointerEvent, useCallback, useRef, type WheelEvent } from 'react';
2
2
  import type { ViewTransform } from '../instrument/types';
3
+ import { zoomAt } from './camera';
4
+ import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
3
5
 
4
6
  export interface UsePanZoomOptions {
5
7
  view: ViewTransform;
@@ -8,6 +10,10 @@ export interface UsePanZoomOptions {
8
10
  * whatever is passed here if it would otherwise exclude it. */
9
11
  minZoom?: number;
10
12
  maxZoom?: number;
13
+ /** The instrument's coordinate system, resolved against the viewport. The
14
+ * wheel anchors in this frame; omitting it anchors at the element's
15
+ * top-left, which is what labkit did before a frame could be declared. */
16
+ frame?: WorldFrame;
11
17
  }
12
18
 
13
19
  export interface PanZoomHandlers {
@@ -37,6 +43,7 @@ export function usePanZoom({
37
43
  onViewChange,
38
44
  minZoom = 0.1,
39
45
  maxZoom = 32,
46
+ frame = DEFAULT_FRAME,
40
47
  }: UsePanZoomOptions): PanZoomHandlers {
41
48
  const dragRef = useRef<DragState | null>(null);
42
49
  const viewRef = useRef(view);
@@ -50,23 +57,21 @@ export function usePanZoom({
50
57
  (e: WheelEvent<HTMLElement>) => {
51
58
  e.preventDefault();
52
59
  const rect = e.currentTarget.getBoundingClientRect();
53
- const cursorX = e.clientX - rect.left;
54
- const cursorY = e.clientY - rect.top;
55
- const v = viewRef.current;
56
60
  const initialZoom = initialZoomRef.current;
57
- const effectiveMin = initialZoom == null ? minZoom : Math.min(minZoom, initialZoom);
58
- const effectiveMax = initialZoom == null ? maxZoom : Math.max(maxZoom, initialZoom);
59
- const factor = Math.exp(-e.deltaY * 0.001);
60
- const rawZoom = isPositiveFinite(v.zoom) ? v.zoom : 1;
61
- const nextZoom = Math.min(effectiveMax, Math.max(effectiveMin, rawZoom * factor));
62
- const ratio = nextZoom / rawZoom;
63
- const nextPan = {
64
- x: cursorX - (cursorX - v.pan.x) * ratio,
65
- y: cursorY - (cursorY - v.pan.y) * ratio,
66
- };
67
- onViewChange({ zoom: nextZoom, pan: nextPan });
61
+ onViewChange(
62
+ zoomAt(
63
+ viewRef.current,
64
+ Math.exp(-e.deltaY * 0.001),
65
+ { x: e.clientX - rect.left, y: e.clientY - rect.top },
66
+ {
67
+ frame,
68
+ min: initialZoom == null ? minZoom : Math.min(minZoom, initialZoom),
69
+ max: initialZoom == null ? maxZoom : Math.max(maxZoom, initialZoom),
70
+ },
71
+ ),
72
+ );
68
73
  },
69
- [onViewChange, minZoom, maxZoom],
74
+ [onViewChange, minZoom, maxZoom, frame],
70
75
  );
71
76
 
72
77
  const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { worldToScreen } from './canvasCoords';
3
+ import { applyCamera, DEFAULT_FRAME, resolveFrame } from './worldSpec';
4
+
5
+ const SIZE = { width: 800, height: 600 };
6
+
7
+ describe('resolveFrame', () => {
8
+ it('defaults to labkit original convention: origin top-left, y down', () => {
9
+ expect(resolveFrame(undefined, SIZE)).toEqual(DEFAULT_FRAME);
10
+ });
11
+
12
+ it('places a fractional origin at that fraction of the viewport', () => {
13
+ const frame = resolveFrame({ origin: { x: 0.5, y: 0.5 } }, SIZE);
14
+ expect(frame.originPx).toEqual({ x: 400, y: 300 });
15
+ });
16
+
17
+ it('reads yAxis up as a negated y direction', () => {
18
+ expect(resolveFrame({ yAxis: 'up' }, SIZE).yDir).toBe(-1);
19
+ expect(resolveFrame({ yAxis: 'down' }, SIZE).yDir).toBe(1);
20
+ });
21
+ });
22
+
23
+ /** Records the transform calls and reports where a world point lands, so the
24
+ * camera can be checked against `worldToScreen` without a real 2D context. */
25
+ function recordingCtx() {
26
+ let m = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
27
+ return {
28
+ translate(x: number, y: number) {
29
+ m = { ...m, e: m.e + m.a * x + m.c * y, f: m.f + m.b * x + m.d * y };
30
+ },
31
+ scale(x: number, y: number) {
32
+ m = { ...m, a: m.a * x, b: m.b * x, c: m.c * y, d: m.d * y };
33
+ },
34
+ project(p: { x: number; y: number }) {
35
+ return { x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f };
36
+ },
37
+ };
38
+ }
39
+
40
+ describe('applyCamera', () => {
41
+ const size = { width: 800, height: 600 };
42
+ const view = { zoom: 1.5, pan: { x: 7, y: -3 } };
43
+ const world = { x: 12, y: 34 };
44
+
45
+ it('lands a world point where worldToScreen says, with no frame', () => {
46
+ const ctx = recordingCtx();
47
+ applyCamera(ctx as unknown as CanvasRenderingContext2D, view);
48
+ const got = ctx.project(world);
49
+ const want = worldToScreen(world, view);
50
+ expect(got.x).toBeCloseTo(want.x, 10);
51
+ expect(got.y).toBeCloseTo(want.y, 10);
52
+ });
53
+
54
+ it('lands a world point where worldToScreen says, in a centred y-up frame', () => {
55
+ const frame = resolveFrame({ origin: { x: 0.5, y: 0.5 }, yAxis: 'up' }, size);
56
+ const ctx = recordingCtx();
57
+ applyCamera(ctx as unknown as CanvasRenderingContext2D, view, frame);
58
+ const got = ctx.project(world);
59
+ const want = worldToScreen(world, view, frame);
60
+ expect(got.x).toBeCloseTo(want.x, 10);
61
+ expect(got.y).toBeCloseTo(want.y, 10);
62
+ });
63
+ });
@@ -0,0 +1,48 @@
1
+ import type { Point, ViewTransform } from '../instrument/types';
2
+
3
+ /** How an instrument's world sits on the canvas, independent of the camera.
4
+ * The defaults reproduce the convention labkit shipped before this existed:
5
+ * world (0,0) at the element's top-left, y growing downward. */
6
+ export interface WorldSpec {
7
+ /** Where world (0,0) sits at `pan` zero, as a fraction of the viewport.
8
+ * `{x:0,y:0}` top-left (default); `{x:0.5,y:0.5}` centre. */
9
+ origin?: Point;
10
+ /** Which way the world y axis runs on screen. Default `'down'`. */
11
+ yAxis?: 'down' | 'up';
12
+ }
13
+
14
+ /** A viewport's CSS-pixel size. */
15
+ export interface ViewportSize {
16
+ width: number;
17
+ height: number;
18
+ }
19
+
20
+ /** A `WorldSpec` resolved against a viewport: what the coordinate helpers,
21
+ * the camera and the wheel all read. */
22
+ export interface WorldFrame {
23
+ /** Screen position of world (0,0) at `pan` zero. */
24
+ originPx: Point;
25
+ yDir: 1 | -1;
26
+ }
27
+
28
+ export const DEFAULT_FRAME: WorldFrame = { originPx: { x: 0, y: 0 }, yDir: 1 };
29
+
30
+ export function resolveFrame(spec: WorldSpec | undefined, size: ViewportSize): WorldFrame {
31
+ if (!spec) return DEFAULT_FRAME;
32
+ const origin = spec.origin ?? { x: 0, y: 0 };
33
+ return {
34
+ originPx: { x: origin.x * size.width, y: origin.y * size.height },
35
+ yDir: spec.yAxis === 'up' ? -1 : 1,
36
+ };
37
+ }
38
+
39
+ /** Put a 2D context into the instrument's world coordinates, so a layer's
40
+ * `draw` works in them. Must stay the exact inverse of `screenToWorld`. */
41
+ export function applyCamera(
42
+ ctx: CanvasRenderingContext2D,
43
+ view: ViewTransform,
44
+ frame: WorldFrame = DEFAULT_FRAME,
45
+ ): void {
46
+ ctx.translate(frame.originPx.x + view.pan.x, frame.originPx.y + view.pan.y);
47
+ ctx.scale(view.zoom, view.zoom * frame.yDir);
48
+ }
@@ -8,6 +8,9 @@ import type { TrialChromeContext } from './types';
8
8
 
9
9
  const ctx: TrialChromeContext = {
10
10
  trialId: 't1',
11
+ undockedPanels: [],
12
+ undockPanel: () => {},
13
+ dockPanel: () => {},
11
14
  instrumentName: 'Stub',
12
15
  isLastTrial: false,
13
16
  zoom: 1,
@@ -16,6 +19,8 @@ const ctx: TrialChromeContext = {
16
19
  canRedo: false,
17
20
  undo: () => {},
18
21
  redo: () => {},
22
+ loupeOn: false,
23
+ toggleLoupe: () => {},
19
24
  configFields: [],
20
25
  configSchema: fromConfigFields([]),
21
26
  config: {},
@@ -47,7 +52,13 @@ function ids(instrument: Instrument, c: TrialChromeContext = ctx): string[] {
47
52
 
48
53
  describe('builtinContributions', () => {
49
54
  it('always contributes the trial actions', () => {
50
- expect(ids(bare)).toEqual(['snapshot', 'clone', 'reset', 'close']);
55
+ expect(ids(bare)).toEqual(['clone', 'reset', 'snapshot', 'close']);
56
+ });
57
+
58
+ it('puts snapshot in the title bar with clone and reset, not the toolbar', () => {
59
+ const snapshot = builtinContributions(bare, ctx).find((c) => c.id === 'snapshot');
60
+ expect(snapshot?.region).toBe('titlebar');
61
+ expect(snapshot?.end).toBe(true);
51
62
  });
52
63
 
53
64
  it('contributes undo and redo only when the instrument declares undo', () => {
@@ -56,6 +67,25 @@ describe('builtinContributions', () => {
56
67
  expect(ids({ ...bare, undo: {} })).toContain('redo');
57
68
  });
58
69
 
70
+ it('contributes the loupe toggle only when the instrument declares one', () => {
71
+ expect(ids(bare)).not.toContain('loupe');
72
+ expect(ids({ ...bare, loupe: true })).toContain('loupe');
73
+ expect(ids({ ...bare, loupe: { render: () => null } })).toContain('loupe');
74
+ });
75
+
76
+ it('offers the loupe to a DOM instrument, which declares no canvas', () => {
77
+ const contributions = builtinContributions({ ...bare, loupe: true }, ctx);
78
+ const loupe = contributions.find((c) => c.id === 'loupe');
79
+ expect(loupe?.region).toBe('toolbar');
80
+ });
81
+
82
+ it('reports the loupe switch position, so the button reads as held', () => {
83
+ const off = builtinContributions({ ...bare, loupe: true }, ctx);
84
+ const on = builtinContributions({ ...bare, loupe: true }, { ...ctx, loupeOn: true });
85
+ expect(off.find((c) => c.id === 'loupe')?.item).toMatchObject({ pressed: false });
86
+ expect(on.find((c) => c.id === 'loupe')?.item).toMatchObject({ pressed: true });
87
+ });
88
+
59
89
  it('puts zoom controls in the viewport region, not the toolbar', () => {
60
90
  const zoomIn = builtinContributions(withCanvas, ctx).find((c) => c.id === 'zoom-in');
61
91
  expect(zoomIn?.region).toBe('viewport');
@@ -2,6 +2,7 @@ import {
2
2
  CloneIcon,
3
3
  CloseIcon,
4
4
  FitIcon,
5
+ LoupeIcon,
5
6
  RedoIcon,
6
7
  ResetIcon,
7
8
  SnapshotIcon,
@@ -9,6 +10,9 @@ import {
9
10
  ZoomInIcon,
10
11
  ZoomOutIcon,
11
12
  } from '@weasel-js/ui';
13
+ import { ExportMenu } from '../annotations/ExportMenu';
14
+ import { MarkList } from '../annotations/MarkList';
15
+ import { ANNOTATION_TOOLS } from '../annotations/toolMap';
12
16
  import type { ControlRenderer } from '../config/types';
13
17
  import { ControlPanel } from '../controls/ControlPanel';
14
18
  import type { Instrument } from '../instrument/types';
@@ -33,7 +37,9 @@ export function builtinContributions(
33
37
  const out: TrialContribution[] = [];
34
38
  const zoom = ctx.zoom;
35
39
 
36
- if (instrument.undo != null) {
40
+ // Weasel history is the undo authority for marks, so declaring `annotations`
41
+ // gets the buttons whether or not the instrument also declares `undo`.
42
+ if (instrument.undo != null || instrument.annotations != null) {
37
43
  out.push({
38
44
  id: 'undo',
39
45
  region: 'toolbar',
@@ -60,10 +66,16 @@ export function builtinContributions(
60
66
  });
61
67
  }
62
68
 
69
+ // Declaring `annotations` provides the drawing palette, the way declaring
70
+ // `tools` provides the instrument's own; both write the same tool slot.
63
71
  // A tool id shares the contribution namespace, so a tool called `close`
64
- // collides with the built-in close button and throws. Both are contributions
65
- // to one trial's chrome.
66
- for (const t of instrument.tools?.tools ?? []) {
72
+ // collides with the built-in close button and throws — and so do two tools
73
+ // of the same name from these two sources.
74
+ const tools = [
75
+ ...(instrument.annotations ? ANNOTATION_TOOLS : []),
76
+ ...(instrument.tools?.tools ?? []),
77
+ ];
78
+ for (const t of tools) {
67
79
  out.push({
68
80
  id: t.id,
69
81
  region: 'palette',
@@ -72,6 +84,20 @@ export function builtinContributions(
72
84
  });
73
85
  }
74
86
 
87
+ if (instrument.loupe != null) {
88
+ out.push({
89
+ id: 'loupe',
90
+ region: 'toolbar',
91
+ group: 'view',
92
+ item: {
93
+ icon: LoupeIcon,
94
+ label: 'Loupe',
95
+ pressed: ctx.loupeOn,
96
+ onActivate: ctx.toggleLoupe,
97
+ },
98
+ });
99
+ }
100
+
75
101
  // Zoom acts on the view of the trial, so it is a viewport control. A trial
76
102
  // holding a non-2D view reports zoom as null and gets none of this.
77
103
  if (instrument.canvas != null && zoom !== null) {
@@ -122,6 +148,26 @@ export function builtinContributions(
122
148
  });
123
149
  }
124
150
 
151
+ if (instrument.annotations != null) {
152
+ // A popover, so it takes the `render` escape: the panel picks a target,
153
+ // and an icon button cannot ask which one.
154
+ out.push({
155
+ id: 'export',
156
+ region: 'toolbar',
157
+ group: 'trial',
158
+ end: true,
159
+ render: () => <ExportMenu />,
160
+ });
161
+ out.push({
162
+ id: 'marks',
163
+ region: 'sidebar',
164
+ item: {
165
+ title: 'Marks',
166
+ body: <MarkList meaning={instrument.annotations.meaning} config={ctx.config} />,
167
+ },
168
+ });
169
+ }
170
+
125
171
  if (Object.keys(ctx.configSchema.group.children).length > 0) {
126
172
  out.push({
127
173
  id: 'settings',
@@ -140,17 +186,6 @@ export function builtinContributions(
140
186
  });
141
187
  }
142
188
 
143
- out.push({
144
- id: 'snapshot',
145
- region: 'toolbar',
146
- group: 'history',
147
- item: {
148
- icon: SnapshotIcon,
149
- label: 'Save snapshot',
150
- shortcut: 'Mod+S',
151
- onActivate: () => ctx.saveSnapshot(),
152
- },
153
- });
154
189
  if (ctx.savedSnapshots.length > 0) {
155
190
  // A picker is not an icon button, so it takes the render escape. Held at
156
191
  // `selectedKey={null}` so it stays a "load one" action rather than
@@ -176,18 +211,27 @@ export function builtinContributions(
176
211
  }
177
212
  out.push({
178
213
  id: 'clone',
179
- region: 'toolbar',
180
- group: 'trial',
214
+ region: 'titlebar',
181
215
  end: true,
182
216
  item: { icon: CloneIcon, label: 'Clone trial', onActivate: ctx.clone },
183
217
  });
184
218
  out.push({
185
219
  id: 'reset',
186
- region: 'toolbar',
187
- group: 'trial',
220
+ region: 'titlebar',
188
221
  end: true,
189
222
  item: { icon: ResetIcon, label: 'Reset trial', onActivate: ctx.reset },
190
223
  });
224
+ out.push({
225
+ id: 'snapshot',
226
+ region: 'titlebar',
227
+ end: true,
228
+ item: {
229
+ icon: SnapshotIcon,
230
+ label: 'Save snapshot',
231
+ shortcut: 'Mod+S',
232
+ onActivate: () => ctx.saveSnapshot(),
233
+ },
234
+ });
191
235
  out.push({
192
236
  id: 'close',
193
237
  region: 'titlebar',
@@ -1,4 +1,4 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
  import type { TrialChromeContext, TrialContribution } from '../types';
4
4
  import { PaletteRegion } from './PaletteRegion';
@@ -33,4 +33,25 @@ describe('PaletteRegion', () => {
33
33
  screen.getByRole('button', { name: 'brush' }).click();
34
34
  expect(setActiveTool).toHaveBeenCalledWith('brush');
35
35
  });
36
+
37
+ it('puts exactly one tool in the tab order', () => {
38
+ render(<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith(null)} />);
39
+ expect(screen.getByRole('button', { name: 'brush' })).toHaveAttribute('tabindex', '0');
40
+ expect(screen.getByRole('button', { name: 'eraser' })).toHaveAttribute('tabindex', '-1');
41
+ });
42
+
43
+ it('walks the vertical strip with ArrowDown, and leaves ArrowRight to the page', () => {
44
+ render(<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith(null)} />);
45
+ const strip = screen.getByRole('toolbar', { name: 'Tools' });
46
+ const brush = screen.getByRole('button', { name: 'brush' });
47
+ const eraser = screen.getByRole('button', { name: 'eraser' });
48
+
49
+ brush.focus();
50
+ fireEvent.keyDown(strip, { key: 'ArrowDown' });
51
+ expect(document.activeElement).toBe(eraser);
52
+ expect(eraser).toHaveAttribute('tabindex', '0');
53
+
54
+ fireEvent.keyDown(strip, { key: 'ArrowRight' });
55
+ expect(document.activeElement).toBe(eraser);
56
+ });
36
57
  });