@weasel-js/labkit 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-D2jnIwfk.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-CCWfQQWM.d.ts → Stage-D6t7o0pF.d.ts} +3 -2
  4. package/dist/_dts/{index-CsYqUHD3.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-CSm9jgol.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-CRPSGFAp.d.ts → types-BUBSIZuV.d.ts} +8 -7
  7. package/dist/_dts/{useTrialState-CTZPgeZ7.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
  8. package/dist/canvas/index.d.ts +8 -6
  9. package/dist/canvas/index.js +6 -6
  10. package/dist/chrome/index.d.ts +7 -5
  11. package/dist/chrome/index.js +10 -9
  12. package/dist/chunk-2ES4W47N.js +8 -0
  13. package/dist/chunk-2ES4W47N.js.map +1 -0
  14. package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
  15. package/dist/chunk-2N7UBSS2.js.map +1 -0
  16. package/dist/chunk-4KSC7NXL.js +15491 -0
  17. package/dist/chunk-4KSC7NXL.js.map +1 -0
  18. package/dist/{chunk-7BO7HNGF.js → chunk-CEGFXTBD.js} +8 -61
  19. package/dist/chunk-CEGFXTBD.js.map +1 -0
  20. package/dist/{chunk-275JRRVD.js → chunk-E23Q6A3Y.js} +232 -37
  21. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  22. package/dist/{chunk-GOZLNM4L.js → chunk-E6NARGU7.js} +5 -5
  23. package/dist/{chunk-GOZLNM4L.js.map → chunk-E6NARGU7.js.map} +1 -1
  24. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  25. package/dist/chunk-EESJUSYR.js.map +1 -0
  26. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  27. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  28. package/dist/chunk-F4ACIK7Z.js +210 -0
  29. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  30. package/dist/{chunk-FMK7UQ6V.js → chunk-FB4MIZOO.js} +61 -44
  31. package/dist/chunk-FB4MIZOO.js.map +1 -0
  32. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  33. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  34. package/dist/{chunk-I5DUQ5HM.js → chunk-GAHELLJT.js} +75 -72
  35. package/dist/chunk-GAHELLJT.js.map +1 -0
  36. package/dist/{chunk-YT75VYRD.js → chunk-IBVRMHW5.js} +5 -5
  37. package/dist/{chunk-YT75VYRD.js.map → chunk-IBVRMHW5.js.map} +1 -1
  38. package/dist/{chunk-T5IBSPAM.js → chunk-JFOJESJF.js} +33 -23
  39. package/dist/chunk-JFOJESJF.js.map +1 -0
  40. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  41. package/dist/chunk-PMLZP7RT.js.map +1 -0
  42. package/dist/{chunk-Y23G2VZL.js → chunk-READTDQX.js} +2 -2
  43. package/dist/chunk-READTDQX.js.map +1 -0
  44. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  45. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  46. package/dist/{chunk-ZIFPHIWZ.js → chunk-T6DLGEJB.js} +12 -14
  47. package/dist/chunk-T6DLGEJB.js.map +1 -0
  48. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  49. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  50. package/dist/{chunk-UNAZSLWE.js → chunk-VYVGVBGY.js} +17 -20
  51. package/dist/chunk-VYVGVBGY.js.map +1 -0
  52. package/dist/{chunk-KMJ7RWD3.js → chunk-WLMDTI2Q.js} +3 -3
  53. package/dist/{chunk-KMJ7RWD3.js.map → chunk-WLMDTI2Q.js.map} +1 -1
  54. package/dist/{chunk-J7OWWTCW.js → chunk-XE5HJOPL.js} +5 -6
  55. package/dist/chunk-XE5HJOPL.js.map +1 -0
  56. package/dist/chunk-XVUKDSYV.js +38 -0
  57. package/dist/chunk-XVUKDSYV.js.map +1 -0
  58. package/dist/{chunk-FZ7YFQN7.js → chunk-YCFKW5RX.js} +3 -3
  59. package/dist/chunk-YCFKW5RX.js.map +1 -0
  60. package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
  61. package/dist/chunk-ZQIHJXP4.js.map +1 -0
  62. package/dist/config/index.d.ts +18 -10
  63. package/dist/config/index.js +4 -4
  64. package/dist/controls/index.d.ts +4 -7
  65. package/dist/controls/index.js +6 -5
  66. package/dist/dragdrop/index.d.ts +23 -3
  67. package/dist/dragdrop/index.js +4 -3
  68. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  69. package/dist/index.d.ts +60 -714
  70. package/dist/index.js +357 -334
  71. package/dist/index.js.map +1 -1
  72. package/dist/job/index.d.ts +8 -0
  73. package/dist/job/index.js +1 -1
  74. package/dist/layers/index.d.ts +6 -5
  75. package/dist/layers/index.js +3 -2
  76. package/dist/loupe/index.d.ts +100 -30
  77. package/dist/loupe/index.js +585 -6
  78. package/dist/loupe/index.js.map +1 -1
  79. package/dist/overview/index.d.ts +4 -2
  80. package/dist/overview/index.js +15 -14
  81. package/dist/overview/index.js.map +1 -1
  82. package/dist/passthrough/weasel-canvas.d.ts +2 -0
  83. package/dist/passthrough/weasel-ui.d.ts +2 -2871
  84. package/dist/passthrough/weasel-ui.js +3 -2
  85. package/dist/primitives/index.d.ts +213 -3
  86. package/dist/primitives/index.js +8 -7
  87. package/dist/state/index.d.ts +7 -5
  88. package/dist/state/index.js +6 -6
  89. package/dist/styles.css +60 -44
  90. package/dist/surface/index.d.ts +10 -2
  91. package/dist/surface/index.js +1 -1
  92. package/dist/undo/index.d.ts +5 -3
  93. package/package.json +11 -11
  94. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  95. package/src/annotations/AnnotationOverlay.tsx +50 -31
  96. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  97. package/src/annotations/capture.browser.test.ts +41 -0
  98. package/src/annotations/capture.ts +14 -7
  99. package/src/annotations/drawOne.ts +2 -2
  100. package/src/annotations/frac.test.ts +44 -1
  101. package/src/annotations/frac.ts +46 -18
  102. package/src/annotations/index.ts +1 -0
  103. package/src/annotations/paint.test.ts +74 -33
  104. package/src/annotations/paint.ts +70 -32
  105. package/src/annotations/store.test.ts +212 -2
  106. package/src/annotations/store.ts +130 -27
  107. package/src/annotations/svgNodes.test.ts +22 -10
  108. package/src/annotations/svgNodes.ts +3 -3
  109. package/src/annotations/types.ts +29 -10
  110. package/src/canvas/CameraInput.test.tsx +30 -0
  111. package/src/canvas/CameraInput.tsx +16 -15
  112. package/src/canvas/CanvasStack.tsx +5 -5
  113. package/src/canvas/CanvasStackContext.ts +4 -1
  114. package/src/canvas/Stage.tsx +3 -3
  115. package/src/canvas/cameraRegistry.ts +1 -0
  116. package/src/canvas/worldSpec.ts +3 -1
  117. package/src/chrome/builtins.test.ts +7 -6
  118. package/src/chrome/builtins.tsx +5 -1
  119. package/src/chrome/index.ts +1 -1
  120. package/src/chrome/merge.test.ts +5 -0
  121. package/src/chrome/merge.ts +4 -2
  122. package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
  123. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  124. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  125. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  126. package/src/chrome/regions/ViewportRegion.less +6 -0
  127. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  128. package/src/chrome/types.ts +4 -2
  129. package/src/config/auto.ts +1 -0
  130. package/src/config/builder.ts +6 -0
  131. package/src/config/types.ts +2 -1
  132. package/src/controls/ControlPanel.test.tsx +37 -3
  133. package/src/controls/ControlPanel.tsx +12 -3
  134. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  135. package/src/dragdrop/DragGhost.tsx +3 -0
  136. package/src/dragdrop/Palette.tsx +2 -0
  137. package/src/index.test.ts +5 -0
  138. package/src/index.ts +1 -1
  139. package/src/instrument/types.ts +0 -6
  140. package/src/job/useJob.ts +14 -9
  141. package/src/lab/Lab.tsx +5 -1
  142. package/src/lab/LabShell.less +1 -0
  143. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  144. package/src/lab/LabSwitcher.less +2 -0
  145. package/src/lab/LabZoom.tsx +4 -4
  146. package/src/lab/Workspace.tsx +48 -52
  147. package/src/loupe/AGENTS.md +28 -15
  148. package/src/loupe/CanvasLoupe.tsx +2 -3
  149. package/src/loupe/DomLoupe.tsx +1 -7
  150. package/src/loupe/Loupe.less +16 -0
  151. package/src/loupe/TrialLoupe.tsx +120 -57
  152. package/src/loupe/index.ts +1 -6
  153. package/src/loupe/types.test.ts +3 -3
  154. package/src/loupe/types.ts +24 -27
  155. package/src/loupe/useLoupe.test.tsx +29 -4
  156. package/src/loupe/useLoupe.ts +20 -17
  157. package/src/overview/OverviewMarks.tsx +12 -4
  158. package/src/overview/TrialOverview.tsx +3 -3
  159. package/src/passthrough/weasel-ui.test.ts +1 -1
  160. package/src/passthrough/weasel-ui.ts +1 -1
  161. package/src/primitives/FloatingPanel.tsx +7 -3
  162. package/src/primitives/JobProgress.less +1 -0
  163. package/src/primitives/Readout.test.tsx +2 -2
  164. package/src/primitives/Split.tsx +69 -41
  165. package/src/primitives/Toolbar.tsx +3 -3
  166. package/src/primitives/ZoomControl.less +1 -2
  167. package/src/specimen/Specimen.tsx +0 -1
  168. package/src/state/undock.ts +3 -0
  169. package/src/state/useOpenOnce.ts +20 -16
  170. package/src/state/view.ts +1 -0
  171. package/src/surface/index.ts +1 -1
  172. package/src/surface/useTiledSurface.test.tsx +14 -0
  173. package/src/surface/useTiledSurface.ts +11 -3
  174. package/src/trial/Trial.annotations.test.tsx +73 -0
  175. package/src/trial/Trial.less +0 -16
  176. package/src/trial/Trial.loupe.test.tsx +28 -11
  177. package/src/trial/Trial.tsx +74 -62
  178. package/src/trial/TrialChrome.tsx +6 -3
  179. package/src/trial/index.ts +2 -0
  180. package/src/trial/loupeSwitch.ts +15 -0
  181. package/dist/_dts/LayerList.d-DiuEArKq.d.ts +0 -83
  182. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +0 -79
  183. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +0 -141
  184. package/dist/_dts/Select.d-Bensr_MT.d.ts +0 -579
  185. package/dist/_dts/index-B2Yj5aA0.d.ts +0 -274
  186. package/dist/chunk-275JRRVD.js.map +0 -1
  187. package/dist/chunk-4LJSC7MB.js +0 -143
  188. package/dist/chunk-4LJSC7MB.js.map +0 -1
  189. package/dist/chunk-7BO7HNGF.js.map +0 -1
  190. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  191. package/dist/chunk-FMK7UQ6V.js.map +0 -1
  192. package/dist/chunk-FZ7YFQN7.js.map +0 -1
  193. package/dist/chunk-HXXTULDN.js.map +0 -1
  194. package/dist/chunk-I5DUQ5HM.js.map +0 -1
  195. package/dist/chunk-IJJUK4FU.js.map +0 -1
  196. package/dist/chunk-J7OWWTCW.js.map +0 -1
  197. package/dist/chunk-M7HP3FTK.js +0 -549
  198. package/dist/chunk-M7HP3FTK.js.map +0 -1
  199. package/dist/chunk-MIM5R4ZT.js +0 -14972
  200. package/dist/chunk-MIM5R4ZT.js.map +0 -1
  201. package/dist/chunk-RWNDDKOH.js.map +0 -1
  202. package/dist/chunk-T5IBSPAM.js.map +0 -1
  203. package/dist/chunk-UNAZSLWE.js.map +0 -1
  204. package/dist/chunk-WPLAANH4.js.map +0 -1
  205. package/dist/chunk-Y23G2VZL.js.map +0 -1
  206. package/dist/chunk-ZIFPHIWZ.js.map +0 -1
  207. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  208. package/src/primitives/useRovingTabIndex.ts +0 -88
@@ -1,53 +1,97 @@
1
- import { type RefObject, useContext, useMemo } from 'react';
2
- import { CameraScope } from '../canvas/CameraInput';
1
+ import { type ReactNode, type RefObject, useContext, useEffect, useMemo, useRef } from 'react';
2
+ import { CameraContext, CameraScope } from '../canvas/CameraInput';
3
3
  import { CanvasStackContext } from '../canvas/CanvasStackContext';
4
- import type { WorldSpec } from '../canvas/worldSpec';
4
+ import { fromCameraView } from '../canvas/cameraView';
5
5
  import type { ViewTransform } from '../instrument/types';
6
+ import { LoupeSwitchContext } from '../trial/loupeSwitch';
6
7
  import { CanvasLoupe } from './CanvasLoupe';
7
8
  import { sampleStack } from './canvasLens';
8
9
  import { DomLoupe } from './DomLoupe';
9
10
  import { LoupeBubble } from './LoupeBubble';
10
11
  import { LoupeGestures } from './LoupeGestures';
11
- import type { ResolvedLoupe } from './types';
12
+ import { type LoupeOptions, resolveLoupe } from './types';
12
13
  import { useHostSize } from './useHostSize';
13
14
  import { useLoupe } from './useLoupe';
14
15
 
15
16
  /** Props for `<TrialLoupe>`. */
16
- export interface TrialLoupeProps {
17
- capability: ResolvedLoupe;
18
- /** Whether the trial's loupe is turned on. Hold-to-peek shows it anyway. */
19
- enabled: boolean;
20
- state: unknown;
21
- config: unknown;
22
- /** The trial's own camera. */
23
- view: ViewTransform;
24
- worldSpec?: WorldSpec;
25
- /** The element the lens tracks. A loupe mounted inside a `<CanvasStack>`
26
- * takes the stack's own element instead, and this goes unused. */
17
+ export interface TrialLoupeProps extends LoupeOptions {
18
+ /** Whether the lens is turned on. Omitted, it follows the trial's toolbar
19
+ * toggle, which the trial offers while a lens like this is mounted — and is
20
+ * off outside a trial. Hold-to-peek shows it either way. */
21
+ enabled?: boolean;
22
+ /** The camera a `render` lens composes its magnification onto. Defaults to
23
+ * the camera around it, and to the identity outside one. */
24
+ view?: ViewTransform;
25
+ /** The element the lens tracks. Defaults to the canvas stack or stage around
26
+ * it, and outside both to a box wrapped around `children`. */
27
27
  hostRef?: RefObject<HTMLElement | null>;
28
+ /** Content for the lens to sit over. Wrapped in a box of the lens's own only
29
+ * when nothing around it gives the lens an element to track. */
30
+ children?: ReactNode;
28
31
  }
29
32
 
30
- /** Stable stand-in, so a loupe with nothing to track does not re-bind its
31
- * listeners on every render. */
32
- const NO_HOST: RefObject<HTMLElement | null> = { current: null };
33
+ const IDENTITY: ViewTransform = { zoom: 1, pan: { x: 0, y: 0 } };
33
34
 
34
35
  /**
35
- * A trial's loupe, painted by whichever painter suits its content: the canvas
36
- * stack it is mounted inside, or the instrument's own `render` at a magnified
37
- * camera.
36
+ * A trial's magnifier, mounted by the instrument wherever its content is: in
37
+ * a canvas instrument's `render`, a stage's `overlay`, or around DOM content.
38
+ *
39
+ * With no `render` inside a `<CanvasStack>`, it re-draws the stack's own
40
+ * layers through a zoomed camera. With `render`, it asks for the content again
41
+ * at a magnified camera.
38
42
  */
39
43
  export function TrialLoupe({
40
- capability,
41
44
  enabled,
42
- state,
43
- config,
44
45
  view,
45
- worldSpec,
46
46
  hostRef,
47
+ children,
48
+ render,
49
+ factor,
50
+ minFactor,
51
+ maxFactor,
52
+ mode,
53
+ diameter,
54
+ peekKey,
55
+ onColorChange,
47
56
  }: TrialLoupeProps) {
57
+ const options = useMemo(
58
+ () =>
59
+ resolveLoupe({
60
+ render,
61
+ factor,
62
+ minFactor,
63
+ maxFactor,
64
+ mode,
65
+ diameter,
66
+ peekKey,
67
+ onColorChange,
68
+ }),
69
+ [render, factor, minFactor, maxFactor, mode, diameter, peekKey, onColorChange],
70
+ );
71
+
48
72
  const stack = useContext(CanvasStackContext);
49
- const surface = capability.render ? undefined : stack?.surface;
50
- const host = surface?.element ?? hostRef ?? NO_HOST;
73
+ const camera = useContext(CameraContext);
74
+ const loupeSwitch = useContext(LoupeSwitchContext);
75
+ const surface = options.render ? undefined : stack?.surface;
76
+
77
+ const ownHost = useRef<HTMLDivElement | null>(null);
78
+ const cameraHost = useMemo<RefObject<HTMLElement | null> | null>(
79
+ () =>
80
+ camera
81
+ ? {
82
+ get current() {
83
+ return camera.element();
84
+ },
85
+ }
86
+ : null,
87
+ [camera],
88
+ );
89
+ const host = surface?.element ?? hostRef ?? cameraHost ?? ownHost;
90
+ const wraps = host === ownHost;
91
+
92
+ // A lens told whether it is on has no use for the trial's toggle.
93
+ const mount = enabled === undefined ? loupeSwitch?.mount : undefined;
94
+ useEffect(() => mount?.(), [mount]);
51
95
 
52
96
  const sample = useMemo(() => {
53
97
  if (!surface) return undefined;
@@ -57,7 +101,12 @@ export function TrialLoupe({
57
101
  };
58
102
  }, [surface]);
59
103
 
60
- const loupe = useLoupe({ capability, hostRef: host, enabled, sample });
104
+ const loupe = useLoupe({
105
+ options,
106
+ hostRef: host,
107
+ enabled: enabled ?? loupeSwitch?.on ?? false,
108
+ sample,
109
+ });
61
110
  const measured = useHostSize(host);
62
111
  const size = surface?.size ?? measured;
63
112
 
@@ -67,42 +116,56 @@ export function TrialLoupe({
67
116
  // dispatcher; outside one, `<CameraScope>` gives them an isolated scope.
68
117
  const gestures = (
69
118
  <CameraScope>
70
- <LoupeGestures hostRef={host} input={loupe.input} peekKey={capability.peekKey ?? null} />
119
+ <LoupeGestures hostRef={host} input={loupe.input} peekKey={options.peekKey ?? null} />
71
120
  </CameraScope>
72
121
  );
73
122
 
74
- if (!loupe.visible) return gestures;
123
+ const lens = loupe.visible ? (
124
+ <LoupeBubble aim={loupe.aim} diameter={options.diameter}>
125
+ {options.render ? (
126
+ <DomLoupe
127
+ aim={loupe.aim}
128
+ factor={loupe.factor}
129
+ mode={loupe.mode}
130
+ diameter={options.diameter}
131
+ size={size}
132
+ view={
133
+ view ??
134
+ stack?.view ??
135
+ (camera ? fromCameraView(camera.view.get(), camera.frame) : IDENTITY)
136
+ }
137
+ frame={stack?.frame ?? camera?.frame}
138
+ render={options.render}
139
+ />
140
+ ) : surface && stack ? (
141
+ <CanvasLoupe
142
+ aim={loupe.aim}
143
+ factor={loupe.factor}
144
+ mode={loupe.mode}
145
+ diameter={options.diameter}
146
+ surface={surface}
147
+ view={stack.view}
148
+ frame={stack.frame}
149
+ worldSpec={surface.worldSpec}
150
+ />
151
+ ) : null}
152
+ </LoupeBubble>
153
+ ) : null;
75
154
 
155
+ if (wraps) {
156
+ return (
157
+ <div ref={ownHost} className="lk-loupe-host">
158
+ {children}
159
+ {gestures}
160
+ {lens}
161
+ </div>
162
+ );
163
+ }
76
164
  return (
77
165
  <>
166
+ {children}
78
167
  {gestures}
79
- <LoupeBubble aim={loupe.aim} diameter={capability.diameter}>
80
- {capability.render ? (
81
- <DomLoupe
82
- aim={loupe.aim}
83
- factor={loupe.factor}
84
- mode={loupe.mode}
85
- diameter={capability.diameter}
86
- size={size}
87
- view={view}
88
- frame={stack?.frame}
89
- state={state}
90
- config={config}
91
- render={capability.render}
92
- />
93
- ) : surface && stack ? (
94
- <CanvasLoupe
95
- aim={loupe.aim}
96
- factor={loupe.factor}
97
- mode={loupe.mode}
98
- diameter={capability.diameter}
99
- surface={surface}
100
- view={stack.view}
101
- frame={stack.frame}
102
- worldSpec={worldSpec}
103
- />
104
- ) : null}
105
- </LoupeBubble>
168
+ {lens}
106
169
  </>
107
170
  );
108
171
  }
@@ -13,12 +13,7 @@ export type { LoupeInputApi } from './loupeActions';
13
13
  export { createLoupeActions, LOUPE_MAGNIFY_ID, LOUPE_PEEK_ID } from './loupeActions';
14
14
  export type { TrialLoupeProps } from './TrialLoupe';
15
15
  export { TrialLoupe } from './TrialLoupe';
16
- export type {
17
- LoupeCapability,
18
- LoupeDeclaration,
19
- LoupeRenderArgs,
20
- ResolvedLoupe,
21
- } from './types';
16
+ export type { LoupeOptions, LoupeRenderArgs, ResolvedLoupe } from './types';
22
17
  export { LOUPE_DEFAULTS, resolveLoupe } from './types';
23
18
  export type { LoupeState, UseLoupeOptions } from './useLoupe';
24
19
  export { useLoupe } from './useLoupe';
@@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest';
2
2
  import { LOUPE_DEFAULTS, resolveLoupe } from './types';
3
3
 
4
4
  describe('resolveLoupe', () => {
5
- it('takes every default from a bare `true`', () => {
6
- expect(resolveLoupe(true)).toMatchObject(LOUPE_DEFAULTS);
5
+ it('takes every default when given nothing', () => {
6
+ expect(resolveLoupe()).toMatchObject(LOUPE_DEFAULTS);
7
7
  });
8
8
 
9
- it('keeps what the instrument declared', () => {
9
+ it('keeps what it was given', () => {
10
10
  const cap = resolveLoupe({ factor: 12, diameter: 320, minFactor: 1, maxFactor: 40 });
11
11
  expect(cap).toMatchObject({ factor: 12, diameter: 320, minFactor: 1, maxFactor: 40 });
12
12
  });
@@ -3,11 +3,9 @@ import type { ReactNode } from 'react';
3
3
  import type { ViewportSize } from '../canvas/worldSpec';
4
4
  import type { ViewTransform } from '../instrument/types';
5
5
 
6
- /** What a DOM instrument's loupe `render` is handed: the same data its own
7
- * `render` gets, and the camera to draw it through. */
8
- export interface LoupeRenderArgs<TS = unknown, TC = unknown> {
9
- state: TS;
10
- config: TC;
6
+ /** What a DOM loupe's `render` is handed: the camera to draw the content
7
+ * through again. */
8
+ export interface LoupeRenderArgs {
11
9
  /** The trial's own camera composed with the magnification, about the aimed
12
10
  * point — so drawing the same content through it magnifies in place. */
13
11
  view: ViewTransform;
@@ -21,14 +19,14 @@ export interface LoupeRenderArgs<TS = unknown, TC = unknown> {
21
19
  }
22
20
 
23
21
  /**
24
- * Declares that an instrument can be magnified.
22
+ * How a lens magnifies.
25
23
  *
26
- * With no `render`, the loupe re-draws the instrument's canvas layers through a
27
- * zoomed camera, so it stays sharp at any factor. An instrument whose content is
28
- * DOM supplies `render` instead: given a camera, draw me again.
24
+ * With no `render`, the lens re-draws the canvas stack's layers through a
25
+ * zoomed camera, so it stays sharp at any factor. Over DOM content, `render`
26
+ * draws it instead: given a camera, draw me again.
29
27
  */
30
- export interface LoupeCapability<TS = unknown, TC = unknown> {
31
- render?: (args: LoupeRenderArgs<TS, TC>) => ReactNode;
28
+ export interface LoupeOptions {
29
+ render?: (args: LoupeRenderArgs) => ReactNode;
32
30
  /** Opening magnification, clamped to the bounds below. Default 6. */
33
31
  factor?: number;
34
32
  /** What the wheel clamps to. Defaults 2 and 32. */
@@ -48,12 +46,9 @@ export interface LoupeCapability<TS = unknown, TC = unknown> {
48
46
  onColorChange?: (hex: string) => void;
49
47
  }
50
48
 
51
- /** An instrument's loupe declaration. `true` takes every default. */
52
- export type LoupeDeclaration<TS = unknown, TC = unknown> = true | LoupeCapability<TS, TC>;
53
-
54
- /** A {@link LoupeCapability} with every default filled in. */
55
- export interface ResolvedLoupe<TS = unknown, TC = unknown> {
56
- render?: (args: LoupeRenderArgs<TS, TC>) => ReactNode;
49
+ /** {@link LoupeOptions} with every default filled in. */
50
+ export interface ResolvedLoupe {
51
+ render?: (args: LoupeRenderArgs) => ReactNode;
57
52
  onColorChange?: (hex: string) => void;
58
53
  factor: number;
59
54
  minFactor: number;
@@ -63,6 +58,7 @@ export interface ResolvedLoupe<TS = unknown, TC = unknown> {
63
58
  peekKey: string | null;
64
59
  }
65
60
 
61
+ /** What `resolveLoupe` fills in for anything a declaration leaves unset. */
66
62
  export const LOUPE_DEFAULTS = {
67
63
  factor: 6,
68
64
  minFactor: 2,
@@ -72,19 +68,20 @@ export const LOUPE_DEFAULTS = {
72
68
  peekKey: 'Alt',
73
69
  } as const satisfies Omit<ResolvedLoupe, 'render' | 'onColorChange'>;
74
70
 
75
- export function resolveLoupe<TS, TC>(declared: LoupeDeclaration<TS, TC>): ResolvedLoupe<TS, TC> {
76
- const cap = declared === true ? {} : declared;
77
- const minFactor = cap.minFactor ?? LOUPE_DEFAULTS.minFactor;
78
- const maxFactor = cap.maxFactor ?? LOUPE_DEFAULTS.maxFactor;
71
+ /** `options` with every default filled in and `factor` clamped to
72
+ * `[minFactor, maxFactor]`. A loupe with its own `render` is always `'vector'`. */
73
+ export function resolveLoupe(options: LoupeOptions = {}): ResolvedLoupe {
74
+ const minFactor = options.minFactor ?? LOUPE_DEFAULTS.minFactor;
75
+ const maxFactor = options.maxFactor ?? LOUPE_DEFAULTS.maxFactor;
79
76
  return {
80
- render: cap.render,
81
- onColorChange: cap.onColorChange,
77
+ render: options.render,
78
+ onColorChange: options.onColorChange,
82
79
  minFactor,
83
80
  maxFactor,
84
- factor: Math.min(maxFactor, Math.max(minFactor, cap.factor ?? LOUPE_DEFAULTS.factor)),
85
- diameter: cap.diameter ?? LOUPE_DEFAULTS.diameter,
86
- peekKey: cap.peekKey === undefined ? LOUPE_DEFAULTS.peekKey : cap.peekKey,
81
+ factor: Math.min(maxFactor, Math.max(minFactor, options.factor ?? LOUPE_DEFAULTS.factor)),
82
+ diameter: options.diameter ?? LOUPE_DEFAULTS.diameter,
83
+ peekKey: options.peekKey === undefined ? LOUPE_DEFAULTS.peekKey : options.peekKey,
87
84
  // A DOM loupe has no framebuffer, so `pixel` would have nothing to enlarge.
88
- mode: cap.render ? 'vector' : (cap.mode ?? LOUPE_DEFAULTS.mode),
85
+ mode: options.render ? 'vector' : (options.mode ?? LOUPE_DEFAULTS.mode),
89
86
  };
90
87
  }
@@ -1,5 +1,6 @@
1
1
  import { act, fireEvent, render, screen } from '@testing-library/react';
2
2
  import { WeaselProvider } from '@weasel-js/core';
3
+ import { renderThenAbandon } from '@weasel-js/react/testing/abandonRender';
3
4
  import { type RefObject, StrictMode, useRef } from 'react';
4
5
  import { describe, expect, it, vi } from 'vitest';
5
6
  import { LoupeGestures } from './LoupeGestures';
@@ -23,12 +24,12 @@ interface HarnessProps {
23
24
  * cases below exercise the real dispatcher route rather than a stand-in. */
24
25
  function Harness({ enabled = true, seen, sample, ...rest }: HarnessProps) {
25
26
  const hostRef = useRef<HTMLDivElement | null>(null);
26
- const capability = resolveLoupe(rest);
27
+ const options = resolveLoupe(rest);
27
28
  const loupe = useLoupe({
28
29
  hostRef: hostRef as RefObject<HTMLElement | null>,
29
30
  enabled,
30
31
  sample,
31
- capability,
32
+ options,
32
33
  });
33
34
  seen?.(loupe);
34
35
  return (
@@ -37,7 +38,7 @@ function Harness({ enabled = true, seen, sample, ...rest }: HarnessProps) {
37
38
  <LoupeGestures
38
39
  hostRef={hostRef as RefObject<HTMLElement | null>}
39
40
  input={loupe.input}
40
- peekKey={capability.peekKey ?? null}
41
+ peekKey={options.peekKey ?? null}
41
42
  />
42
43
  </WeaselProvider>
43
44
  <span data-testid="visible">{String(loupe.visible)}</span>
@@ -92,7 +93,7 @@ describe('useLoupe', () => {
92
93
  expect(read('visible')).toBe('false');
93
94
  });
94
95
 
95
- it('does not peek when the capability turned the key off', () => {
96
+ it('does not peek when its options turned the key off', () => {
96
97
  render(<Harness enabled={false} peekKey={null} />);
97
98
  move(40, 25);
98
99
  act(() => {
@@ -251,4 +252,28 @@ describe('useLoupe', () => {
251
252
  });
252
253
  expect(read('mode')).toBe('pixel');
253
254
  });
255
+
256
+ it('aims as the committed `enabled` allows, not an abandoned render', () => {
257
+ const seen: LoupeState[] = [];
258
+ // Reads the aim back through the model: the DOM is not re-rendered while
259
+ // the abandoned transition is still pending.
260
+ const aimOf = (p: { x: number; y: number }) => `${p.x},${p.y}`;
261
+ renderThenAbandon({ enabled: false }, { enabled: true }, (p) => (
262
+ <Harness enabled={p.enabled} sample={aimOf} seen={(l) => seen.push(l)} />
263
+ ));
264
+ const before = seen[0]?.pick();
265
+ move(40, 25);
266
+ expect(seen[0]?.pick()).toBe(before);
267
+ });
268
+
269
+ it('samples through the committed `sample`, not an abandoned render', () => {
270
+ const seen: LoupeState[] = [];
271
+ const committed = vi.fn(() => '#111111');
272
+ const abandoned = vi.fn(() => '#222222');
273
+ renderThenAbandon({ sample: committed }, { sample: abandoned }, (p) => (
274
+ <Harness sample={p.sample} seen={(l) => seen.push(l)} />
275
+ ));
276
+ expect(seen[0]?.pick()).toBe('#111111');
277
+ expect(abandoned).not.toHaveBeenCalled();
278
+ });
254
279
  });
@@ -1,3 +1,4 @@
1
+ import { useLatest } from '@weasel-js/core';
1
2
  import {
2
3
  createLoupeModel,
3
4
  type LoupeMode,
@@ -11,7 +12,7 @@ import type { ResolvedLoupe } from './types';
11
12
 
12
13
  /** Options for {@link useLoupe}. */
13
14
  export interface UseLoupeOptions {
14
- capability: ResolvedLoupe;
15
+ options: ResolvedLoupe;
15
16
  /** The element the lens tracks the pointer across. */
16
17
  hostRef: RefObject<HTMLElement | null>;
17
18
  /** Whether the loupe is turned on. Hold-to-peek shows it regardless. */
@@ -39,6 +40,11 @@ export interface LoupeState {
39
40
  input: LoupeInputApi;
40
41
  }
41
42
 
43
+ /** Whether the lens is up: the pointer is over the host, and the loupe is on or peeked. */
44
+ function lensShown(over: boolean, enabled: boolean, peeking: boolean): boolean {
45
+ return over && (enabled || peeking);
46
+ }
47
+
42
48
  /**
43
49
  * Binds `@weasel-js/loupe`'s model to a host element and tracks the pointer
44
50
  * across it.
@@ -50,31 +56,28 @@ export interface LoupeState {
50
56
  * has no continuous-motion entry, and inventing one to carry the lens' aim
51
57
  * would be an input taxonomy change, not a loupe change.
52
58
  */
53
- export function useLoupe({ capability, hostRef, enabled, sample }: UseLoupeOptions): LoupeState {
59
+ export function useLoupe({ options, hostRef, enabled, sample }: UseLoupeOptions): LoupeState {
54
60
  const [, bump] = useReducer((n: number) => n + 1, 0);
55
61
 
56
62
  const overRef = useRef(false);
57
63
  const peekingRef = useRef(false);
58
- const enabledRef = useRef(enabled);
59
- enabledRef.current = enabled;
60
- const sampleRef = useRef(sample);
61
- sampleRef.current = sample;
62
- const onColorChangeRef = useRef(capability.onColorChange);
63
- onColorChangeRef.current = capability.onColorChange;
64
+ const enabledRef = useLatest(enabled);
65
+ const sampleRef = useLatest(sample);
66
+ const onColorChangeRef = useLatest(options.onColorChange);
64
67
 
65
68
  // Reads nothing but refs, so it is stable and an effect may depend on it.
66
69
  const shown = useCallback(
67
- (): boolean => overRef.current && (enabledRef.current || peekingRef.current),
68
- [],
70
+ (): boolean => lensShown(overRef.current, enabledRef.current, peekingRef.current),
71
+ [enabledRef],
69
72
  );
70
73
 
71
74
  const modelRef = useRef<LoupeModel | null>(null);
72
75
  if (modelRef.current === null) {
73
76
  modelRef.current = createLoupeModel({
74
- mode: capability.mode,
75
- factor: capability.factor,
76
- minFactor: capability.minFactor,
77
- maxFactor: capability.maxFactor,
77
+ mode: options.mode,
78
+ factor: options.factor,
79
+ minFactor: options.minFactor,
80
+ maxFactor: options.maxFactor,
78
81
  onColorChange: (hex) => onColorChangeRef.current?.(hex),
79
82
  surface: {
80
83
  lens: () => {
@@ -94,8 +97,7 @@ export function useLoupe({ capability, hostRef, enabled, sample }: UseLoupeOptio
94
97
  }
95
98
  const model = modelRef.current;
96
99
 
97
- const diameterRef = useRef(capability.diameter);
98
- diameterRef.current = capability.diameter;
100
+ const diameterRef = useLatest(options.diameter);
99
101
  const goneRef = useRef(false);
100
102
 
101
103
  // The model holds no resources, and `dispose` is one-way — so unmounting only
@@ -156,7 +158,8 @@ export function useLoupe({ capability, hostRef, enabled, sample }: UseLoupeOptio
156
158
  }
157
159
 
158
160
  return {
159
- visible: shown(),
161
+ // This render's `enabled`: `enabledRef` holds the last committed one.
162
+ visible: lensShown(overRef.current, enabled, peekingRef.current),
160
163
  aim: model.aim,
161
164
  factor: model.factor,
162
165
  mode: model.mode,
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Every annotation target's marks, drawn read-only on the overview through the
3
- * export's own vector path: `markSvgNodes`, then `serializeSvg`.
3
+ * export's own vector path: `markSvgNodes`, then `serializeSvg` — synchronous
4
+ * with no `warmSvg`, since marks export nothing that loads on demand.
4
5
  */
5
6
  import type { View } from '@weasel-js/core';
6
7
  import { serializeSvg } from '@weasel-js/svg';
@@ -40,10 +41,17 @@ function elementOf(target: object): HTMLElement | null {
40
41
  return (target as { ref?: RefObject<HTMLElement | null> }).ref?.current ?? null;
41
42
  }
42
43
 
43
- function markupOf(api: AnnotationsApi, target: string, content: { w: number; h: number }): string {
44
+ function markupOf(
45
+ api: AnnotationsApi,
46
+ target: string,
47
+ content: { w: number; h: number },
48
+ shown: { w: number; h: number },
49
+ ): string {
44
50
  const painted = api.paintedMarks(target);
45
51
  if (painted.length === 0) return '';
46
- const nodes = painted.flatMap(({ mark, style }) => markSvgNodes(mark, content, style));
52
+ // The document is shown at `shown` px, fitted like any SVG viewBox.
53
+ const k = Math.min(shown.w / content.w, shown.h / content.h) || 1;
54
+ const nodes = painted.flatMap(({ mark, style }) => markSvgNodes(mark, style, { x: k, y: k }));
47
55
  return serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: content.w, height: content.h } });
48
56
  }
49
57
 
@@ -69,7 +77,7 @@ export function OverviewMarks({ fit, camera }: OverviewMarksProps) {
69
77
  {api.targets().map((t) => {
70
78
  const el = elementOf(t);
71
79
  const box = el ? localRectOf(el, camera) : null;
72
- const markup = box ? markupOf(api, t.id, t.content) : '';
80
+ const markup = box ? markupOf(api, t.id, t.content, { w: box.w * s, h: box.h * s }) : '';
73
81
  if (!box || !markup) return null;
74
82
  const place = {
75
83
  ['--lk-overview-mx' as string]: `${(box.x - fit.x) * s}px`,
@@ -20,6 +20,7 @@ import {
20
20
  useActionsRegistry,
21
21
  useDepSource,
22
22
  useGestureDispatcher,
23
+ useLatest,
23
24
  usePointerPosition,
24
25
  type View,
25
26
  type ViewApi,
@@ -314,8 +315,7 @@ function OverviewDispatch({
314
315
  camera: CameraContextValue;
315
316
  }) {
316
317
  const registry = useActionsRegistry();
317
- const fitRef = useRef(fit);
318
- fitRef.current = fit;
318
+ const fitRef = useLatest(fit);
319
319
  const root = camera.view;
320
320
  useDepSource('rootView', () => root as ViewApi);
321
321
 
@@ -336,7 +336,7 @@ function OverviewDispatch({
336
336
  y: (cy - (r?.top ?? 0)) / f.scale.y + f.y,
337
337
  };
338
338
  },
339
- [boxRef],
339
+ [boxRef, fitRef],
340
340
  );
341
341
 
342
342
  useGestureDispatcher({
@@ -20,11 +20,11 @@ import {
20
20
  paintGradientTrack,
21
21
  RadioGroup,
22
22
  RangeSlider,
23
- sampleByInterpolation,
24
23
  Select,
25
24
  Sidebar,
26
25
  Slider,
27
26
  Switch,
27
+ sampleByInterpolation,
28
28
  Tabs,
29
29
  ToolPalette,
30
30
  useReorderDragList,
@@ -182,7 +182,7 @@ export {
182
182
  type ReorderDragHandlers,
183
183
  type ReorderDragState,
184
184
  type ReorderGhost,
185
- type RovingItem,
185
+ type RovingOrientation,
186
186
  type RovingTabIndex,
187
187
  type SegmentTooltipFields,
188
188
  Select,
@@ -1,4 +1,9 @@
1
- import { openPointerSession, type PointerSession, pastDragThreshold } from '@weasel-js/core';
1
+ import {
2
+ openPointerSession,
3
+ type PointerSession,
4
+ pastDragThreshold,
5
+ useLatest,
6
+ } from '@weasel-js/core';
2
7
  import type React from 'react';
3
8
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
4
9
  import {
@@ -119,8 +124,7 @@ export function FloatingPanel({
119
124
  // A drag outlives the render that started it, so the reducer's inputs are
120
125
  // read here and not from that render's closure — a container resized
121
126
  // mid-drag would otherwise place the panel against its old box.
122
- const layout = useRef({ container, options, item });
123
- layout.current = { container, options, item };
127
+ const layout = useLatest({ container, options, item });
124
128
 
125
129
  useEffect(() => () => session.current?.cancel(), []);
126
130
 
@@ -31,6 +31,7 @@
31
31
 
32
32
  &__count {
33
33
  font-variant-numeric: tabular-nums;
34
+ font-family: var(--wzl-font-numeric);
34
35
  white-space: nowrap;
35
36
  }
36
37
 
@@ -81,8 +81,8 @@ describe('Readout', () => {
81
81
  });
82
82
 
83
83
  it('merges a consumer className on its root', () => {
84
- const { container } = render(<Readout className="mine" rows={[]} />);
85
- expect(must(container.firstElementChild).className).toMatch(/\blk-readout\b.*\bmine\b/);
84
+ const { container } = render(<Readout className="is-mine" rows={[]} />);
85
+ expect(must(container.firstElementChild).className).toMatch(/\blk-readout\b.*\bis-mine\b/);
86
86
  });
87
87
 
88
88
  it('is exported from the labkit barrel, with the list it is built on', () => {