@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,3 +1,4 @@
1
+ import { useLatest } from '@weasel-js/core';
1
2
  import { type ReactNode, useEffect, useMemo, useRef } from 'react';
2
3
  import { asNodeId, createNode, Store, stripStrategy } from 'windease';
3
4
  import { type ChromeMap, Container, Provider, StrategyRegistryProvider } from 'windease/react';
@@ -42,6 +43,65 @@ export interface SplitProps {
42
43
  viewport?: { w: number; h: number };
43
44
  }
44
45
 
46
+ interface SplitInit {
47
+ side: 'start' | 'end';
48
+ label: string;
49
+ sidebarWidth: number;
50
+ minWidth: number;
51
+ maxWidth: number;
52
+ contentMinWidth: number;
53
+ }
54
+
55
+ /** A strip holding the sidebar and the content pane, sized as the first render asks. */
56
+ function createSplitStore({
57
+ side,
58
+ label,
59
+ sidebarWidth,
60
+ minWidth,
61
+ maxWidth,
62
+ contentMinWidth,
63
+ }: SplitInit): Store {
64
+ const store = new Store();
65
+ store.registerNode(
66
+ createNode({
67
+ kind: 'zone',
68
+ id: ZONE_ID,
69
+ // A strip's seam resizes the pane before it, which on the end side is the
70
+ // content; 'neighbor' has the seam size the sidebar after it too.
71
+ container: {
72
+ strategyId: 'strip',
73
+ config: {
74
+ axis: 'x',
75
+ resizable: true,
76
+ ...(side === 'end' ? { resizeMode: 'neighbor' } : {}),
77
+ },
78
+ },
79
+ }),
80
+ );
81
+ const sidebarNode = createNode({
82
+ kind: 'sidebar',
83
+ id: SIDEBAR_ID,
84
+ parentId: ZONE_ID,
85
+ meta: { title: label },
86
+ placement: { size: { w: sidebarWidth } },
87
+ hints: { minSize: { w: minWidth, h: 0 }, maxSize: { w: maxWidth, h: 0 } },
88
+ });
89
+ const contentNode = createNode({
90
+ kind: 'content',
91
+ id: CONTENT_ID,
92
+ parentId: ZONE_ID,
93
+ meta: { title: 'Content' },
94
+ hints: { minSize: { w: contentMinWidth, h: 0 } },
95
+ });
96
+ // A strip lays its children out in registration order.
97
+ for (const node of side === 'end' ? [contentNode, sidebarNode] : [sidebarNode, contentNode]) {
98
+ store.registerNode(node);
99
+ }
100
+ store.showNode(SIDEBAR_ID);
101
+ store.showNode(CONTENT_ID);
102
+ return store;
103
+ }
104
+
45
105
  /**
46
106
  * A sidebar and a content pane as a two-pane strip with a draggable seam.
47
107
  *
@@ -70,45 +130,14 @@ export function Split({
70
130
  // stretches its panes to the container's height and never reads `h`.
71
131
  const storeRef = useRef<Store | null>(null);
72
132
  if (storeRef.current === null) {
73
- const store = new Store();
74
- store.registerNode(
75
- createNode({
76
- kind: 'zone',
77
- id: ZONE_ID,
78
- // A strip's seam resizes the pane before it, which on the end side is the
79
- // content; 'neighbor' has the seam size the sidebar after it too.
80
- container: {
81
- strategyId: 'strip',
82
- config: {
83
- axis: 'x',
84
- resizable: true,
85
- ...(side === 'end' ? { resizeMode: 'neighbor' } : {}),
86
- },
87
- },
88
- }),
89
- );
90
- const sidebarNode = createNode({
91
- kind: 'sidebar',
92
- id: SIDEBAR_ID,
93
- parentId: ZONE_ID,
94
- meta: { title: label },
95
- placement: { size: { w: width ?? defaultWidth } },
96
- hints: { minSize: { w: minWidth, h: 0 }, maxSize: { w: maxWidth, h: 0 } },
97
- });
98
- const contentNode = createNode({
99
- kind: 'content',
100
- id: CONTENT_ID,
101
- parentId: ZONE_ID,
102
- meta: { title: 'Content' },
103
- hints: { minSize: { w: contentMinWidth, h: 0 } },
133
+ storeRef.current = createSplitStore({
134
+ side,
135
+ label,
136
+ sidebarWidth: width ?? defaultWidth,
137
+ minWidth,
138
+ maxWidth,
139
+ contentMinWidth,
104
140
  });
105
- // A strip lays its children out in registration order.
106
- for (const node of side === 'end' ? [contentNode, sidebarNode] : [sidebarNode, contentNode]) {
107
- store.registerNode(node);
108
- }
109
- store.showNode(SIDEBAR_ID);
110
- store.showNode(CONTENT_ID);
111
- storeRef.current = store;
112
141
  }
113
142
  const store = storeRef.current;
114
143
 
@@ -134,8 +163,7 @@ export function Split({
134
163
  store.patchPlacement(SIDEBAR_ID, { size: { w: width } });
135
164
  }, [store, width]);
136
165
 
137
- const onWidthChangeRef = useRef(onWidthChange);
138
- onWidthChangeRef.current = onWidthChange;
166
+ const onWidthChangeRef = useLatest(onWidthChange);
139
167
  useEffect(
140
168
  () =>
141
169
  store.events.on('node.placementChanged', () => {
@@ -148,7 +176,7 @@ export function Split({
148
176
  widthRef.current = next;
149
177
  onWidthChangeRef.current?.(next);
150
178
  }),
151
- [store],
179
+ [onWidthChangeRef, store],
152
180
  );
153
181
 
154
182
  const chrome = useMemo<ChromeMap>(
@@ -1,5 +1,5 @@
1
1
  import type { MouseEventHandler, ReactNode } from 'react';
2
- import { useRovingTabIndex } from './useRovingTabIndex';
2
+ import { useRovingTabIndex } from '../passthrough/weasel-ui';
3
3
 
4
4
  /** Props for `<Toolbar>`. */
5
5
  export interface ToolbarProps {
@@ -12,10 +12,10 @@ export interface ToolbarProps {
12
12
  /** A horizontal bar of controls. Fill it with `<Toolbar.Title>`,
13
13
  * `<Toolbar.Group>`, `<Toolbar.Button>` and `<Toolbar.Spacer>`. */
14
14
  export function Toolbar({ children, 'aria-label': ariaLabel }: ToolbarProps) {
15
- const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>();
15
+ const { rootRef, onKeyDown } = useRovingTabIndex<HTMLDivElement>({ orientation: 'horizontal' });
16
16
  return (
17
17
  <div
18
- ref={ref}
18
+ ref={rootRef}
19
19
  className="lk-toolbar"
20
20
  role="toolbar"
21
21
  aria-label={ariaLabel}
@@ -13,8 +13,7 @@
13
13
  }
14
14
  }
15
15
 
16
- // The slider is a weasel-ui component whose module class sets `width`.
17
- // See the `.lk-root` prefix convention in theme/base.less.
16
+ // Under `.lk-root` to outrank theme/base.less's element defaults; see its prefix convention.
18
17
  .lk-root {
19
18
  .lk-zoom {
20
19
  // Narrow enough to sit in a trial toolbar, wide enough for the octave
@@ -666,7 +666,6 @@ function ToolChrome() {
666
666
  label={id}
667
667
  shortcut={id.slice(0, 1).toUpperCase()}
668
668
  active={tool === id}
669
- tabbable={tool === id}
670
669
  onClick={() => setTool(id)}
671
670
  />
672
671
  ))}
@@ -8,10 +8,13 @@ export interface UndockedPanel {
8
8
  /** Undocked panels, keyed by {@link panelKey}. */
9
9
  export type UndockedPanels = Record<string, UndockedPanel>;
10
10
 
11
+ /** The `UndockedPanels` key for one trial's section. */
11
12
  export function panelKey(trialId: string, sectionId: string): string {
12
13
  return `${trialId}::${sectionId}`;
13
14
  }
14
15
 
16
+ /** `panels` with the section marked undocked `as` a tile or a floating panel,
17
+ * replacing any earlier entry for it. Returns a new record. */
15
18
  export function undockPanel(
16
19
  panels: UndockedPanels,
17
20
  trialId: string,
@@ -10,6 +10,13 @@ interface Entry<T> {
10
10
  mounts: number;
11
11
  }
12
12
 
13
+ function openEntry<T>(open: () => T | Promise<T>): Entry<T> {
14
+ const result = open();
15
+ return result instanceof Promise
16
+ ? { value: null, pending: result, mounts: 0 }
17
+ : { value: result, pending: null, mounts: 0 };
18
+ }
19
+
13
20
  /**
14
21
  * Open something once for a component's lifetime: at once when `open` returns
15
22
  * a value, after it settles when it returns a promise. Closed on unmount — but
@@ -18,13 +25,7 @@ interface Entry<T> {
18
25
  */
19
26
  export function useOpenOnce<T extends Closable>(open: () => T | Promise<T>): T | null {
20
27
  const entry = useRef<Entry<T> | null>(null);
21
- if (entry.current === null) {
22
- const result = open();
23
- entry.current =
24
- result instanceof Promise
25
- ? { value: null, pending: result, mounts: 0 }
26
- : { value: result, pending: null, mounts: 0 };
27
- }
28
+ if (entry.current === null) entry.current = openEntry(open);
28
29
  const [value, setValue] = useState<T | null>(entry.current.value);
29
30
 
30
31
  useEffect(() => {
@@ -56,13 +57,16 @@ export function useOpenOnce<T extends Closable>(open: () => T | Promise<T>): T |
56
57
  export function useWarnIgnoredChange(component: string, props: Record<string, unknown>): void {
57
58
  const first = useRef(props);
58
59
  const warned = useRef(false);
59
- if (process.env.NODE_ENV === 'production' || warned.current) return;
60
- for (const key of Object.keys(props)) {
61
- if (Object.is(first.current[key], props[key])) continue;
62
- warned.current = true;
63
- console.warn(
64
- `[labkit] ${component} reads \`${key}\` once, at mount; a later change is ignored`,
65
- );
66
- return;
67
- }
60
+ // In an effect, so only props that committed are compared.
61
+ useEffect(() => {
62
+ if (process.env.NODE_ENV === 'production' || warned.current) return;
63
+ for (const key of Object.keys(props)) {
64
+ if (Object.is(first.current[key], props[key])) continue;
65
+ warned.current = true;
66
+ console.warn(
67
+ `[labkit] ${component} reads \`${key}\` once, at mount; a later change is ignored`,
68
+ );
69
+ return;
70
+ }
71
+ });
68
72
  }
package/src/state/view.ts CHANGED
@@ -5,6 +5,7 @@ import type { ViewTransform } from '../instrument/types';
5
5
  * other. Re-exported under its own name so a consumer can say which it means. */
6
6
  export type ViewTransform2D = ViewTransform;
7
7
 
8
+ /** Zoom 1, no pan. */
8
9
  export const DEFAULT_VIEW: ViewTransform2D = { zoom: 1, pan: { x: 0, y: 0 } };
9
10
 
10
11
  /** A trial's view is opaque to labkit, so anything that needs the 2D shape — the
@@ -1,4 +1,4 @@
1
- export { composeRects, rectsEqual } from './composeRects';
1
+ export { composeRects } from './composeRects';
2
2
  export { toDeviceRect } from './deviceRect';
3
3
  export type { Box, Rect } from './rect';
4
4
  export type { SurfaceCanvases, SurfaceLayer } from './SurfaceContext';
@@ -1,4 +1,5 @@
1
1
  import { act, render, renderHook } from '@testing-library/react';
2
+ import { renderThenAbandon } from '@weasel-js/react/testing/abandonRender';
2
3
  import { useEffect } from 'react';
3
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import type { SurfaceFrame, SurfaceHandle } from './useTiledSurface';
@@ -505,6 +506,19 @@ describe('useTiledSurface', () => {
505
506
  expect(clear).toHaveBeenCalledTimes(1);
506
507
  });
507
508
  });
509
+ it("hands a frame to the committed owner, never an abandoned render's", () => {
510
+ const committed: SurfaceFrame[] = [];
511
+ const abandoned: SurfaceFrame[] = [];
512
+ const onHandle = (): void => {};
513
+ renderThenAbandon<HarnessProps>(
514
+ { frames: committed, onHandle },
515
+ { frames: abandoned, onHandle },
516
+ (p) => <Harness {...p} />,
517
+ );
518
+ flushFrames();
519
+ expect(abandoned).toHaveLength(0);
520
+ expect(committed.length).toBeGreaterThan(0);
521
+ });
508
522
  });
509
523
 
510
524
  describe('useTiledSurface handle identity', () => {
@@ -1,4 +1,4 @@
1
- import { useVisibleRaf } from '@weasel-js/core';
1
+ import { useLatest, useVisibleRaf } from '@weasel-js/core';
2
2
  import { useCallback, useEffect, useMemo, useRef } from 'react';
3
3
  import { composeRects, rectsEqual } from './composeRects';
4
4
  import type { Box, Rect } from './rect';
@@ -55,10 +55,19 @@ export type TilePainter = (rect: Rect, frame: SurfaceFrame) => void;
55
55
  * viewport takes. */
56
56
  export type SurfaceClear = (size: { width: number; height: number }, dpr: number) => void;
57
57
 
58
+ /** Inputs to `useTiledSurface`. `onFrame` runs once per frame that has dirty
59
+ * tiles, before any tile paints — the place to size the shared buffer. */
58
60
  export interface UseTiledSurfaceOptions {
59
61
  onFrame: (frame: SurfaceFrame) => void;
60
62
  }
61
63
 
64
+ /**
65
+ * Share one drawing buffer among tiles laid out by the DOM. Tiles register
66
+ * their elements; the hook measures them against the container, re-measures on
67
+ * resize, and batches invalidations into one visible-only frame that calls
68
+ * `onFrame`, then (on a retiled frame) the registered clears, then each dirty
69
+ * tile's painter.
70
+ */
62
71
  export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHandle {
63
72
  const container = useRef<HTMLElement | null>(null);
64
73
  const tiles = useRef(new Map<string, HTMLElement>());
@@ -72,8 +81,7 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
72
81
 
73
82
  // Held in a ref so a caller passing an inline closure does not re-create every
74
83
  // callback below on each render.
75
- const onFrameRef = useRef(onFrame);
76
- onFrameRef.current = onFrame;
84
+ const onFrameRef = useLatest(onFrame);
77
85
 
78
86
  const measure = useCallback((): boolean => {
79
87
  const el = container.current;
@@ -180,6 +180,79 @@ describe('the trial undo chrome over marks', () => {
180
180
  });
181
181
  });
182
182
 
183
+ function GrowingPane({ n, grow }: { n: number; grow: () => void }) {
184
+ const api = useAnnotations();
185
+ return (
186
+ <button type="button" data-testid="growing" data-targets={api.targets().length} onClick={grow}>
187
+ {n}
188
+ </button>
189
+ );
190
+ }
191
+
192
+ describe('targets that follow the trial state', () => {
193
+ it('reach a reader of the store once the change commits', () => {
194
+ // The store reads the committed state, so a reader rendering in the same
195
+ // pass as the change sees the one before; the trial tells it to read again.
196
+ const growing = defineInstrument<{ n: number }, Record<string, never>>({
197
+ name: 'Growing',
198
+ defaultConfig: () => ({}),
199
+ initialState: () => ({ n: 1 }),
200
+ render: (ctx) => (
201
+ <GrowingPane n={ctx.state.n} grow={() => ctx.setState((s) => ({ n: s.n + 1 }))} />
202
+ ),
203
+ annotations: {
204
+ targets: (state) =>
205
+ Array.from({ length: state.n }, (_, i) => ({
206
+ id: `t${i}`,
207
+ ref: { current: null },
208
+ content: { w: 10, h: 10 },
209
+ })),
210
+ },
211
+ });
212
+ render(<Lab instruments={[growing]} defaultInstrument="Growing" />);
213
+ expect(screen.getByTestId('growing').dataset.targets).toBe('1');
214
+ act(() => {
215
+ fireEvent.click(screen.getByTestId('growing'));
216
+ });
217
+ expect(screen.getByTestId('growing').textContent).toBe('2');
218
+ expect(screen.getByTestId('growing').dataset.targets).toBe('2');
219
+ });
220
+ });
221
+
222
+ describe("the capability's selection mode", () => {
223
+ const twoTargets = (selection?: 'per-target') =>
224
+ defineInstrument<Record<string, never>, Record<string, never>>({
225
+ ...annotating,
226
+ name: 'Two',
227
+ annotations: {
228
+ targets: () => [
229
+ { id: 'a', ref: { current: null }, content: { w: 200, h: 100 } },
230
+ { id: 'b', ref: { current: null }, content: { w: 200, h: 100 } },
231
+ ],
232
+ ...(selection ? { selection } : {}),
233
+ },
234
+ });
235
+ const selectBoth = (): readonly string[] => {
236
+ const onA = marks().add({ target: 'a', kind: 'rect', frac: { x: 0, y: 0, w: 0.1, h: 0.1 } });
237
+ const onB = marks().add({ target: 'b', kind: 'rect', frac: { x: 0, y: 0, w: 0.1, h: 0.1 } });
238
+ act(() => marks().setSelection([onA, onB]));
239
+ return [onA, onB];
240
+ };
241
+
242
+ it('keeps the selection to one target by default', () => {
243
+ render(<Lab instruments={[twoTargets()]} defaultInstrument="Two" />);
244
+ const [onA] = selectBoth();
245
+ expect(marks().selection()).toEqual([onA]);
246
+ });
247
+
248
+ it("reaches the trial's store when the instrument asks for per-target", () => {
249
+ render(<Lab instruments={[twoTargets('per-target')]} defaultInstrument="Two" />);
250
+ expect(marks().selection()).toEqual([]);
251
+ const both = selectBoth();
252
+ expect(marks().selection()).toEqual(both);
253
+ });
254
+ });
255
+
183
256
  describe('an instrument that declares none', () => {
184
257
  it('gets no store and no palette', () => {
185
258
  render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
@@ -76,22 +76,6 @@
76
76
  height: 100%;
77
77
  }
78
78
 
79
- // A DOM instrument's own box, so the lens has something to be positioned
80
- // against. Reproduces the content well's column so the instrument still
81
- // stretches to it rather than collapsing to its intrinsic height.
82
- &__loupe-host {
83
- position: relative;
84
- display: flex;
85
- flex-direction: column;
86
- min-height: 0;
87
- height: 100%;
88
-
89
- > * {
90
- flex: 1 1 auto;
91
- min-height: 0;
92
- }
93
- }
94
-
95
79
  &__toolbar {
96
80
  flex-shrink: 0;
97
81
  }
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
3
3
  import { beforeAll, describe, expect, it, vi } from 'vitest';
4
4
  import { defineInstrument } from '../instrument/defineInstrument';
5
5
  import { Lab } from '../lab/Lab';
6
+ import { TrialLoupe } from '../loupe';
6
7
 
7
8
  beforeAll(() => {
8
9
  HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
@@ -21,17 +22,19 @@ const canvasInstrument = defineInstrument<Record<string, never>, Record<string,
21
22
  name: 'Drawn',
22
23
  defaultConfig: () => ({}),
23
24
  initialState: () => ({}),
24
- render: () => null,
25
+ render: () => <TrialLoupe />,
25
26
  canvas: { layers: [{ id: 'main', draw: () => undefined }] },
26
- loupe: true,
27
27
  });
28
28
 
29
29
  const domInstrument = defineInstrument<Record<string, never>, Record<string, never>>({
30
30
  name: 'Written',
31
31
  defaultConfig: () => ({}),
32
32
  initialState: () => ({}),
33
- render: () => <p data-testid="body">the content</p>,
34
- loupe: { render: ({ view }) => <p data-testid="lens-body">zoom {view.zoom}</p> },
33
+ render: () => (
34
+ <TrialLoupe render={({ view }) => <p data-testid="lens-body">zoom {view.zoom}</p>}>
35
+ <p data-testid="body">the content</p>
36
+ </TrialLoupe>
37
+ ),
35
38
  });
36
39
 
37
40
  const noLoupe = defineInstrument<Record<string, never>, Record<string, never>>({
@@ -49,7 +52,7 @@ function pointAt(host: Element, x: number, y: number): void {
49
52
  fireEvent.pointerMove(host, { clientX: x, clientY: y });
50
53
  }
51
54
 
52
- describe('the loupe capability', () => {
55
+ describe('a lens the instrument mounts', () => {
53
56
  it('gives a trial a toolbar toggle, and no lens until it is on', () => {
54
57
  const { container } = render(
55
58
  <Lab instruments={[canvasInstrument]} defaultInstrument="Drawn" />,
@@ -58,7 +61,7 @@ describe('the loupe capability', () => {
58
61
  expect(lens(container)).toBeNull();
59
62
  });
60
63
 
61
- it('offers nothing to an instrument that declares none', () => {
64
+ it('offers nothing to an instrument that mounts none', () => {
62
65
  render(<Lab instruments={[noLoupe]} defaultInstrument="Plain" />);
63
66
  expect(screen.queryByRole('button', { name: 'Loupe' })).toBeNull();
64
67
  });
@@ -95,7 +98,7 @@ describe('the loupe capability', () => {
95
98
  const { container } = render(<Lab instruments={[domInstrument]} defaultInstrument="Written" />);
96
99
  await user.click(screen.getByRole('button', { name: 'Loupe' }));
97
100
 
98
- const host = container.querySelector('.lk-trial__loupe-host');
101
+ const host = container.querySelector('.lk-loupe-host');
99
102
  if (!host) throw new Error('no loupe host');
100
103
  pointAt(host, 60, 40);
101
104
 
@@ -105,9 +108,24 @@ describe('the loupe capability', () => {
105
108
  expect(screen.getByTestId('body')).toBeInTheDocument();
106
109
  });
107
110
 
108
- it('wraps a DOM instrument only when it declares a loupe', () => {
111
+ it('wraps nothing for an instrument that mounts no lens', () => {
109
112
  const { container } = render(<Lab instruments={[noLoupe]} defaultInstrument="Plain" />);
110
- expect(container.querySelector('.lk-trial__loupe-host')).toBeNull();
113
+ expect(container.querySelector('.lk-loupe-host')).toBeNull();
114
+ });
115
+
116
+ it('raises a lens told `enabled` without the trial toggle', () => {
117
+ const always = defineInstrument<Record<string, never>, Record<string, never>>({
118
+ name: 'Always',
119
+ defaultConfig: () => ({}),
120
+ initialState: () => ({}),
121
+ render: () => <TrialLoupe enabled />,
122
+ canvas: { layers: [{ id: 'main', draw: () => undefined }] },
123
+ });
124
+ const { container } = render(<Lab instruments={[always]} defaultInstrument="Always" />);
125
+ const stack = container.querySelector('.lk-canvas-stack');
126
+ if (!stack) throw new Error('no canvas stack');
127
+ pointAt(stack, 60, 40);
128
+ expect(lens(container)).not.toBeNull();
111
129
  });
112
130
 
113
131
  it('puts the lens away again when the toggle goes off', async () => {
@@ -136,7 +154,7 @@ describe('the loupe and the trial camera share one dispatcher', () => {
136
154
  name: 'Zoomed',
137
155
  defaultConfig: () => ({}),
138
156
  initialState: () => ({}),
139
- render: () => null,
157
+ render: () => <TrialLoupe />,
140
158
  canvas: {
141
159
  layers: [
142
160
  {
@@ -147,7 +165,6 @@ describe('the loupe and the trial camera share one dispatcher', () => {
147
165
  },
148
166
  ],
149
167
  },
150
- loupe: true,
151
168
  });
152
169
  const r = render(<Lab instruments={[drawn]} defaultInstrument="Zoomed" />);
153
170
  const stack = r.container.querySelector('.lk-canvas-stack');