@weasel-js/labkit 1.5.2 → 1.6.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 (217) hide show
  1. package/README.md +3 -7
  2. package/dist/_dts/{CanvasStackContext-CMMo180l.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
  3. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  4. package/dist/_dts/LayerList.d-BoELAMsM.d.ts +83 -0
  5. package/dist/_dts/{PrefsForm.d-DPy9I8It.d.ts → PrefsRow.d-DFpPIeIC.d.ts} +16 -1
  6. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +404 -0
  7. package/dist/_dts/{Select.d-ZTiwzYyT.d.ts → Select.d-Cr8-zoup.d.ts} +105 -15
  8. package/dist/_dts/{usePanZoom-SSTZe410.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
  9. package/dist/_dts/{index-DlKolEIZ.d.ts → index-CKyX0ME_.d.ts} +12 -13
  10. package/dist/_dts/{frac-B4hFJMiN.d.ts → types-B7yBTTF9.d.ts} +709 -651
  11. package/dist/_dts/{types-Db-3NNk1.d.ts → types-DTuViBvc.d.ts} +21 -2
  12. package/dist/_dts/{useTrialState-pnAE-UBD.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +41 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +6 -6
  16. package/dist/chrome/index.js +9 -7
  17. package/dist/chunk-24W5VEJW.js +18 -0
  18. package/dist/chunk-24W5VEJW.js.map +1 -0
  19. package/dist/chunk-2RVCB2PT.js +3 -0
  20. package/dist/chunk-2RVCB2PT.js.map +1 -0
  21. package/dist/chunk-3RY6WL5T.js +38 -0
  22. package/dist/chunk-3RY6WL5T.js.map +1 -0
  23. package/dist/{chunk-CKUKF72R.js → chunk-4TT3PNPR.js} +68 -55
  24. package/dist/chunk-4TT3PNPR.js.map +1 -0
  25. package/dist/{chunk-KJCUERQF.js → chunk-6FLXL3I7.js} +9 -164
  26. package/dist/chunk-6FLXL3I7.js.map +1 -0
  27. package/dist/chunk-ASOK76ZH.js +431 -0
  28. package/dist/chunk-ASOK76ZH.js.map +1 -0
  29. package/dist/chunk-AWQG7FCC.js +19 -0
  30. package/dist/chunk-AWQG7FCC.js.map +1 -0
  31. package/dist/chunk-CGHF6RZV.js +300 -0
  32. package/dist/chunk-CGHF6RZV.js.map +1 -0
  33. package/dist/chunk-DWMSB4CA.js +228 -0
  34. package/dist/chunk-DWMSB4CA.js.map +1 -0
  35. package/dist/chunk-GHGO52SO.js +92 -0
  36. package/dist/chunk-GHGO52SO.js.map +1 -0
  37. package/dist/{chunk-LRI3MBGR.js → chunk-HKV7LRHY.js} +4 -4
  38. package/dist/{chunk-LRI3MBGR.js.map → chunk-HKV7LRHY.js.map} +1 -1
  39. package/dist/{chunk-EMXAIRUJ.js → chunk-IRTGUQ5O.js} +5 -4
  40. package/dist/chunk-IRTGUQ5O.js.map +1 -0
  41. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  42. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  43. package/dist/chunk-KHK5FGER.js +145 -0
  44. package/dist/chunk-KHK5FGER.js.map +1 -0
  45. package/dist/chunk-NEXR37YJ.js +144 -0
  46. package/dist/chunk-NEXR37YJ.js.map +1 -0
  47. package/dist/{chunk-A2ACAGW6.js → chunk-O6FFFOHS.js} +32 -7
  48. package/dist/chunk-O6FFFOHS.js.map +1 -0
  49. package/dist/chunk-QGMOA2YE.js +731 -0
  50. package/dist/chunk-QGMOA2YE.js.map +1 -0
  51. package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
  52. package/dist/chunk-S4NCI32V.js.map +1 -0
  53. package/dist/{chunk-753Y6HE6.js → chunk-SLAQPUI4.js} +4 -4
  54. package/dist/chunk-SLAQPUI4.js.map +1 -0
  55. package/dist/{chunk-REOBGYMC.js → chunk-TJ77A4A3.js} +3 -5
  56. package/dist/chunk-TJ77A4A3.js.map +1 -0
  57. package/dist/chunk-TYN6MLXX.js +13864 -0
  58. package/dist/chunk-TYN6MLXX.js.map +1 -0
  59. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  60. package/dist/chunk-WPLAANH4.js.map +1 -0
  61. package/dist/config/index.d.ts +51 -7
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +92 -5
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -6
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +108 -154
  68. package/dist/index.js +456 -600
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +5 -35
  71. package/dist/layers/index.js +3 -3
  72. package/dist/loupe/index.d.ts +21 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +288 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +434 -231
  78. package/dist/passthrough/weasel-ui.js +2 -1
  79. package/dist/primitives/index.d.ts +19 -54
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -7
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +360 -174
  85. package/dist/undo/index.d.ts +5 -4
  86. package/package.json +13 -13
  87. package/src/annotations/frac.test.ts +7 -0
  88. package/src/annotations/index.ts +1 -0
  89. package/src/annotations/paint.test.ts +21 -0
  90. package/src/annotations/paint.ts +12 -0
  91. package/src/annotations/store.test.ts +57 -0
  92. package/src/annotations/store.ts +25 -6
  93. package/src/annotations/toolMap.test.ts +37 -2
  94. package/src/annotations/toolMap.ts +13 -0
  95. package/src/annotations/types.ts +30 -2
  96. package/src/canvas/AGENTS.md +8 -10
  97. package/src/canvas/CameraInput.test.tsx +136 -0
  98. package/src/canvas/CameraInput.tsx +311 -0
  99. package/src/canvas/CanvasStack.tsx +42 -58
  100. package/src/canvas/LinkedCursor.less +16 -0
  101. package/src/canvas/LinkedCursor.tsx +54 -0
  102. package/src/canvas/Stage.test.tsx +18 -0
  103. package/src/canvas/Stage.tsx +42 -37
  104. package/src/canvas/cameraView.test.ts +60 -0
  105. package/src/canvas/cameraView.ts +61 -0
  106. package/src/canvas/index.ts +11 -2
  107. package/src/chrome/regions/SidebarRegion.less +197 -0
  108. package/src/chrome/regions/SidebarRegion.test.tsx +27 -0
  109. package/src/chrome/regions/SidebarRegion.tsx +7 -2
  110. package/src/chrome/types.ts +5 -2
  111. package/src/config/autoConfig.ts +2 -2
  112. package/src/config/builder.ts +21 -2
  113. package/src/config/entry.test.ts +10 -0
  114. package/src/config/fromConfigField.ts +1 -0
  115. package/src/config/index.ts +5 -0
  116. package/src/config/resolve.ts +11 -1
  117. package/src/config/rules.ts +2 -0
  118. package/src/config/sectionTree.test.ts +152 -0
  119. package/src/config/sectionTree.ts +134 -0
  120. package/src/config/types.ts +21 -0
  121. package/src/controls/ControlPanel.less +4 -0
  122. package/src/controls/ControlPanel.stories.tsx +55 -2
  123. package/src/controls/ControlPanel.test.tsx +40 -3
  124. package/src/controls/ControlPanel.tsx +134 -336
  125. package/src/controls/inDialog.test.tsx +100 -0
  126. package/src/controls/inDialog.tsx +38 -0
  127. package/src/controls/index.ts +1 -0
  128. package/src/index.test.ts +1 -1
  129. package/src/index.ts +18 -29
  130. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  131. package/src/instrument/types.ts +15 -3
  132. package/src/lab/Lab.nebula.test.tsx +40 -0
  133. package/src/lab/Lab.test.tsx +3 -1
  134. package/src/lab/Lab.tsx +42 -19
  135. package/src/lab/LabBoundary.test.tsx +66 -0
  136. package/src/lab/LabBoundary.tsx +56 -0
  137. package/src/lab/LabHeader.tsx +4 -23
  138. package/src/lab/LabRoot.tsx +2 -3
  139. package/src/lab/LabShell.less +0 -1
  140. package/src/lab/LabShell.stories.tsx +15 -0
  141. package/src/lab/LabShell.test.tsx +18 -0
  142. package/src/lab/LabShell.tsx +14 -1
  143. package/src/lab/LabSwitcher.less +1 -1
  144. package/src/lab/LabSwitcher.test.tsx +41 -0
  145. package/src/lab/LabSwitcher.tsx +30 -8
  146. package/src/lab/index.ts +1 -0
  147. package/src/layers/AGENTS.md +18 -74
  148. package/src/layers/index.ts +4 -2
  149. package/src/loupe/AGENTS.md +7 -6
  150. package/src/loupe/LoupeGestures.tsx +14 -9
  151. package/src/loupe/TrialLoupe.tsx +5 -5
  152. package/src/loupe/index.ts +1 -0
  153. package/src/loupe/loupeActions.ts +6 -2
  154. package/src/overview/AGENTS.md +38 -0
  155. package/src/overview/OverviewMarks.test.tsx +100 -0
  156. package/src/overview/OverviewMarks.tsx +93 -0
  157. package/src/overview/TrialOverview.less +53 -0
  158. package/src/overview/TrialOverview.stories.tsx +50 -0
  159. package/src/overview/TrialOverview.test.tsx +116 -0
  160. package/src/overview/TrialOverview.tsx +374 -0
  161. package/src/overview/index.ts +2 -0
  162. package/src/passthrough/weasel-ui.ts +32 -10
  163. package/src/primitives/index.ts +0 -2
  164. package/src/specimen/Specimen.tsx +59 -56
  165. package/src/state/labRecords.test.ts +4 -1
  166. package/src/state/store.test.ts +4 -4
  167. package/src/state/types.ts +2 -1
  168. package/src/state/useTrialState.test.tsx +1 -1
  169. package/src/styles.less +6 -2
  170. package/src/theme/Interstellar.stories.tsx +8 -13
  171. package/src/theme/base.less +3 -20
  172. package/src/theme/interstellar.test.ts +6 -3
  173. package/src/theme/interstellar.theme.json +0 -56
  174. package/src/theme/interstellar.ts +2 -2
  175. package/src/tools/labTool.ts +6 -2
  176. package/src/trial/Trial.annotations.test.tsx +16 -0
  177. package/src/trial/Trial.canvas.test.tsx +1 -1
  178. package/src/trial/Trial.loupe.test.tsx +73 -1
  179. package/src/trial/Trial.stories.tsx +2 -0
  180. package/src/trial/Trial.tsx +47 -9
  181. package/src/trial/trialOps.test.ts +4 -4
  182. package/dist/_dts/index-8LiKe3A-.d.ts +0 -410
  183. package/dist/chunk-2AYGEN57.js +0 -32
  184. package/dist/chunk-2AYGEN57.js.map +0 -1
  185. package/dist/chunk-6BUQ77ZV.js +0 -18
  186. package/dist/chunk-6BUQ77ZV.js.map +0 -1
  187. package/dist/chunk-753Y6HE6.js.map +0 -1
  188. package/dist/chunk-A2ACAGW6.js.map +0 -1
  189. package/dist/chunk-CKUKF72R.js.map +0 -1
  190. package/dist/chunk-D35PFTS7.js +0 -170
  191. package/dist/chunk-D35PFTS7.js.map +0 -1
  192. package/dist/chunk-EMXAIRUJ.js.map +0 -1
  193. package/dist/chunk-ERFMC6WU.js.map +0 -1
  194. package/dist/chunk-FKLT3ITC.js +0 -590
  195. package/dist/chunk-FKLT3ITC.js.map +0 -1
  196. package/dist/chunk-KJCUERQF.js.map +0 -1
  197. package/dist/chunk-LASCBWW7.js +0 -13456
  198. package/dist/chunk-LASCBWW7.js.map +0 -1
  199. package/dist/chunk-REOBGYMC.js.map +0 -1
  200. package/dist/chunk-RS3HRQWU.js.map +0 -1
  201. package/dist/chunk-TT5C5S6R.js +0 -368
  202. package/dist/chunk-TT5C5S6R.js.map +0 -1
  203. package/dist/ui/layers/index.d.ts +0 -66
  204. package/dist/ui/layers/index.js +0 -3
  205. package/dist/ui/layers/index.js.map +0 -1
  206. package/src/canvas/usePanZoom.test.ts +0 -244
  207. package/src/canvas/usePanZoom.ts +0 -147
  208. package/src/lab/useSystemMode.ts +0 -30
  209. package/src/layers/LayerList.less +0 -93
  210. package/src/layers/LayerList.stories.tsx +0 -66
  211. package/src/layers/LayerList.test.tsx +0 -232
  212. package/src/layers/LayerList.tsx +0 -268
  213. package/src/primitives/Sidebar.less +0 -112
  214. package/src/primitives/Sidebar.stories.tsx +0 -38
  215. package/src/primitives/Sidebar.test.tsx +0 -74
  216. package/src/primitives/Sidebar.tsx +0 -35
  217. package/src/ui/layers/index.ts +0 -5
@@ -1,244 +0,0 @@
1
- import { act, renderHook } from '@testing-library/react';
2
- import type { PointerEvent as ReactPointerEvent, WheelEvent } from 'react';
3
- import { describe, expect, it } from 'vitest';
4
- import type { ViewTransform } from '../instrument/types';
5
- import { screenToWorld } from './canvasCoords';
6
- import { type UsePanZoomOptions, usePanZoom } from './usePanZoom';
7
- import { resolveFrame } from './worldSpec';
8
-
9
- function wheelEvent(deltaY: number, cursor = { x: 0, y: 0 }): WheelEvent<HTMLElement> {
10
- return {
11
- deltaY,
12
- clientX: cursor.x,
13
- clientY: cursor.y,
14
- preventDefault: () => {},
15
- currentTarget: { getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }) },
16
- } as unknown as WheelEvent<HTMLElement>;
17
- }
18
-
19
- /** Renders the hook the way `CanvasStack` drives it: `view` lives in the
20
- * test, `onViewChange` writes it, and each wheel call re-renders the hook
21
- * with the updated view before returning it. */
22
- function setup(initialView: ViewTransform, options?: Partial<UsePanZoomOptions>) {
23
- let view = initialView;
24
- const onViewChange = (v: ViewTransform) => {
25
- view = v;
26
- };
27
- const { result, rerender } = renderHook(
28
- (props: { view: ViewTransform }) => usePanZoom({ ...options, view: props.view, onViewChange }),
29
- { initialProps: { view: initialView } },
30
- );
31
- const wheel = (deltaY: number, cursor?: { x: number; y: number }): ViewTransform => {
32
- act(() => result.current.onWheel(wheelEvent(deltaY, cursor)));
33
- rerender({ view });
34
- return view;
35
- };
36
- return { wheel, getView: () => view };
37
- }
38
-
39
- describe('usePanZoom', () => {
40
- it('stacks two wheel events that land before a re-render', () => {
41
- const { wheel } = setup({ zoom: 1, pan: { x: 0, y: 0 } });
42
- const once = wheel(-100).zoom;
43
-
44
- const views: ViewTransform[] = [];
45
- const { result } = renderHook(() =>
46
- usePanZoom({ view: { zoom: 1, pan: { x: 0, y: 0 } }, onViewChange: (v) => views.push(v) }),
47
- );
48
- act(() => {
49
- result.current.onWheel(wheelEvent(-100));
50
- result.current.onWheel(wheelEvent(-100));
51
- });
52
- expect(views[1].zoom).toBeCloseTo(once * once, 10);
53
- });
54
-
55
- it('lets the view prop win at the next render', () => {
56
- const views: ViewTransform[] = [];
57
- const { result, rerender } = renderHook(
58
- (props: { view: ViewTransform }) =>
59
- usePanZoom({ view: props.view, onViewChange: (v) => views.push(v) }),
60
- { initialProps: { view: { zoom: 1, pan: { x: 0, y: 0 } } } },
61
- );
62
- act(() => result.current.onWheel(wheelEvent(-100)));
63
- rerender({ view: { zoom: 2, pan: { x: 0, y: 0 } } });
64
- act(() => result.current.onWheel(wheelEvent(0)));
65
- expect(views[1].zoom).toBeCloseTo(2, 10);
66
- });
67
-
68
- it('does not collapse a canvas opening far outside the default range on the first wheel event', () => {
69
- const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } });
70
- const next = wheel(-1); // zoom in slightly
71
- expect(next.zoom).toBeCloseTo(1600, 0);
72
- });
73
-
74
- it('keeps the opening zoom reachable after zooming away from it', () => {
75
- const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } });
76
- // Zoom far out.
77
- for (let i = 0; i < 20; i++) wheel(1000);
78
- const zoomedOut = wheel(1000);
79
- expect(zoomedOut.zoom).toBeLessThan(1600);
80
- // Zoom back in — the opening view must still be reachable.
81
- for (let i = 0; i < 40; i++) wheel(-1000);
82
- const zoomedIn = wheel(-1000);
83
- expect(zoomedIn.zoom).toBeCloseTo(1600, 0);
84
- });
85
-
86
- it('admits the opening zoom even when an explicit maxZoom is lower than it', () => {
87
- const { wheel } = setup({ zoom: 1600, pan: { x: 0, y: 0 } }, { maxZoom: 32 });
88
- const next = wheel(-1);
89
- expect(next.zoom).toBeCloseTo(1600, 0);
90
- });
91
-
92
- it('falls back to the plain defaults when the opening zoom is non-finite or zero', () => {
93
- const nanCase = setup({ zoom: Number.NaN, pan: { x: 0, y: 0 } });
94
- const nanResult = nanCase.wheel(-100000);
95
- expect(nanResult.zoom).toBeCloseTo(32, 5);
96
- expect(Number.isNaN(nanResult.zoom)).toBe(false);
97
-
98
- const zeroCase = setup({ zoom: 0, pan: { x: 0, y: 0 } });
99
- const zeroResult = zeroCase.wheel(100000);
100
- expect(zeroResult.zoom).toBeCloseTo(0.1, 5);
101
- });
102
-
103
- it('still clamps ordinary views to the default range', () => {
104
- const { wheel } = setup({ zoom: 1, pan: { x: 0, y: 0 } });
105
- const zoomedIn = wheel(-1_000_000);
106
- expect(zoomedIn.zoom).toBeCloseTo(32, 5);
107
- const zoomedOut = wheel(1_000_000);
108
- expect(zoomedOut.zoom).toBeCloseTo(0.1, 5);
109
- });
110
-
111
- describe('under a declared frame', () => {
112
- const size = { width: 1430, height: 870 };
113
-
114
- it('keeps the world point under the cursor fixed in a centred, y-up frame', () => {
115
- const frame = resolveFrame({ origin: { x: 0.5, y: 0.5 }, yAxis: 'up' }, size);
116
- const start: ViewTransform = { zoom: 1600, pan: { x: 0, y: 0 } };
117
- const { wheel } = setup(start, { frame });
118
- const cursor = { x: 900, y: 200 };
119
-
120
- const before = screenToWorld(cursor, start, frame);
121
- const after = screenToWorld(cursor, wheel(120, cursor), frame);
122
-
123
- expect(after.x).toBeCloseTo(before.x, 6);
124
- expect(after.y).toBeCloseTo(before.y, 6);
125
- });
126
-
127
- it('anchors on the element top-left when no frame is declared', () => {
128
- const start: ViewTransform = { zoom: 4, pan: { x: 0, y: 0 } };
129
- const { wheel } = setup(start);
130
- const cursor = { x: 300, y: 120 };
131
-
132
- const before = screenToWorld(cursor, start);
133
- const after = screenToWorld(cursor, wheel(-120, cursor));
134
-
135
- expect(after.x).toBeCloseTo(before.x, 6);
136
- expect(after.y).toBeCloseTo(before.y, 6);
137
- });
138
- });
139
- });
140
-
141
- /** The fields `usePanZoom` reads off a React pointerdown, plus the element the
142
- * session hangs its document listeners off. */
143
- function press(
144
- el: HTMLElement,
145
- x: number,
146
- y: number,
147
- init: { pointerId?: number; button?: number; buttons?: number } = {},
148
- ): ReactPointerEvent<HTMLElement> {
149
- return {
150
- pointerId: init.pointerId ?? 1,
151
- button: init.button ?? 0,
152
- buttons: init.buttons ?? 1,
153
- clientX: x,
154
- clientY: y,
155
- currentTarget: el,
156
- } as unknown as ReactPointerEvent<HTMLElement>;
157
- }
158
-
159
- function dispatchOn(target: EventTarget, type: string, init: PointerEventInit) {
160
- act(() => {
161
- target.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
162
- });
163
- }
164
-
165
- function dragSetup(options?: Partial<UsePanZoomOptions>) {
166
- let view: ViewTransform = { zoom: 1, pan: { x: 0, y: 0 } };
167
- const onViewChange = (v: ViewTransform) => {
168
- view = v;
169
- };
170
- const { result, unmount } = renderHook(() =>
171
- usePanZoom({ ...options, view, onViewChange } as UsePanZoomOptions),
172
- );
173
- const el = document.createElement('div');
174
- document.body.appendChild(el);
175
- return { result, unmount, el, getView: () => view };
176
- }
177
-
178
- describe('usePanZoom dragging', () => {
179
- it('pans from a move the element never sees', () => {
180
- const { result, el, getView } = dragSetup();
181
- act(() => result.current.onPointerDown(press(el, 100, 100)));
182
- dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
183
- expect(getView().pan).toEqual({ x: 50, y: 20 });
184
- });
185
-
186
- it('ends the drag on a release dispatched off the element', () => {
187
- const { result, el, getView } = dragSetup();
188
- act(() => result.current.onPointerDown(press(el, 100, 100)));
189
- dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
190
- dispatchOn(document, 'pointerup', { clientX: 150, clientY: 120 });
191
- expect(result.current.isDragging()).toBe(false);
192
- dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
193
- expect(getView().pan).toEqual({ x: 50, y: 20 });
194
- });
195
-
196
- // The other half of the rule — a detached origin does cancel — is owned by
197
- // pointerSession.test.ts, which exercises it without React in the way.
198
- it('keeps panning when capture is lost and the element is still in the document', () => {
199
- const { result, el, getView } = dragSetup();
200
- act(() => result.current.onPointerDown(press(el, 100, 100)));
201
- dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
202
- // Chrome releases capture a beat before it delivers pointerup, so ending
203
- // here throws away a release that has already been dispatched.
204
- dispatchOn(el, 'lostpointercapture', {});
205
- expect(result.current.isDragging()).toBe(true);
206
- dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
207
- expect(getView().pan).toEqual({ x: 300, y: 300 });
208
- });
209
-
210
- it('reads a move with nothing held as the release that never arrived', () => {
211
- const { result, el, getView } = dragSetup();
212
- act(() => result.current.onPointerDown(press(el, 100, 100, { buttons: 1 })));
213
- dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
214
- expect(getView().pan).toEqual({ x: 50, y: 20 });
215
- dispatchOn(document, 'pointermove', { buttons: 0, clientX: 300, clientY: 300 });
216
- expect(getView().pan).toEqual({ x: 50, y: 20 });
217
- expect(result.current.isDragging()).toBe(false);
218
- });
219
-
220
- it('reports a release that never crossed the threshold as a tap', () => {
221
- const taps: number[] = [];
222
- const { result, el } = dragSetup({ onTap: (e) => taps.push(e.clientX) });
223
- act(() => result.current.onPointerDown(press(el, 100, 100)));
224
- dispatchOn(document, 'pointerup', { clientX: 101, clientY: 100 });
225
- expect(taps).toEqual([101]);
226
- });
227
-
228
- it('does not report a tap when the pointer panned', () => {
229
- const taps: number[] = [];
230
- const { result, el } = dragSetup({ onTap: (e) => taps.push(e.clientX) });
231
- act(() => result.current.onPointerDown(press(el, 100, 100)));
232
- dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
233
- dispatchOn(document, 'pointerup', { clientX: 150, clientY: 120 });
234
- expect(taps).toEqual([]);
235
- });
236
-
237
- it('drops its listeners when the hook unmounts mid-drag', () => {
238
- const { result, el, unmount, getView } = dragSetup();
239
- act(() => result.current.onPointerDown(press(el, 100, 100)));
240
- unmount();
241
- dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
242
- expect(getView().pan).toEqual({ x: 0, y: 0 });
243
- });
244
- });
@@ -1,147 +0,0 @@
1
- import { openPointerSession, type PointerSession } from '@weasel-js/core';
2
- import {
3
- type PointerEvent as ReactPointerEvent,
4
- useCallback,
5
- useEffect,
6
- useRef,
7
- type WheelEvent,
8
- } from 'react';
9
- import type { ViewTransform } from '../instrument/types';
10
- import { normalize2DView } from '../state/view';
11
- import { zoomAt } from './camera';
12
- import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
13
-
14
- export interface UsePanZoomOptions {
15
- view: ViewTransform;
16
- onViewChange: (v: ViewTransform) => void;
17
- /** The opening `view.zoom` always stays reachable, widening these past
18
- * whatever is passed here if it would otherwise exclude it. */
19
- minZoom?: number;
20
- maxZoom?: number;
21
- /** The instrument's coordinate system, resolved against the viewport. The
22
- * wheel anchors in this frame; omitting it anchors at the element's
23
- * top-left, which is what labkit did before a frame could be declared. */
24
- frame?: WorldFrame;
25
- /** A primary-button press released without ever crossing the drag
26
- * threshold. The pointer session owns the release, so the gesture's end is
27
- * reported here rather than left for a consumer's own `onPointerUp`. */
28
- onTap?: (e: PointerEvent) => void;
29
- }
30
-
31
- export interface PanZoomHandlers {
32
- /** Zoom about the pointer. `anchor` is the element the pointer position is
33
- * measured against, for an event whose `currentTarget` is some other box. */
34
- onWheel: (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => void;
35
- onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
36
- isDragging: () => boolean;
37
- }
38
-
39
- interface DragState {
40
- startScreenX: number;
41
- startScreenY: number;
42
- startPan: { x: number; y: number };
43
- moved: boolean;
44
- }
45
-
46
- const DRAG_THRESHOLD = 3;
47
-
48
- function isPositiveFinite(n: number): boolean {
49
- return Number.isFinite(n) && n > 0;
50
- }
51
-
52
- export function usePanZoom({
53
- view,
54
- onViewChange,
55
- minZoom = 0.1,
56
- maxZoom = 32,
57
- frame = DEFAULT_FRAME,
58
- onTap,
59
- }: UsePanZoomOptions): PanZoomHandlers {
60
- const dragRef = useRef<DragState | null>(null);
61
- const sessionRef = useRef<PointerSession | null>(null);
62
- const viewRef = useRef(normalize2DView(view));
63
- viewRef.current = normalize2DView(view);
64
- const onViewChangeRef = useRef(onViewChange);
65
- onViewChangeRef.current = onViewChange;
66
- const onTapRef = useRef(onTap);
67
- onTapRef.current = onTap;
68
- // Captured once, from the view the canvas opened at — not the current view,
69
- // which would let a prior zoom-out shrink the range and trap the opening
70
- // view unreachable behind it.
71
- const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
72
-
73
- // Written ahead of the render so a second event inside one commit steps
74
- // from this one; the render-time assignment above still lets the prop win.
75
- const commit = useCallback((next: ViewTransform) => {
76
- viewRef.current = normalize2DView(next);
77
- onViewChangeRef.current(next);
78
- }, []);
79
-
80
- const onWheel = useCallback(
81
- (e: WheelEvent<HTMLElement> | globalThis.WheelEvent, anchor?: Element) => {
82
- const el = anchor ?? (e.currentTarget as Element | null);
83
- if (!el) return;
84
- e.preventDefault();
85
- const rect = el.getBoundingClientRect();
86
- const initialZoom = initialZoomRef.current;
87
- commit(
88
- zoomAt(
89
- viewRef.current,
90
- Math.exp(-e.deltaY * 0.001),
91
- { x: e.clientX - rect.left, y: e.clientY - rect.top },
92
- {
93
- frame,
94
- min: initialZoom == null ? minZoom : Math.min(minZoom, initialZoom),
95
- max: initialZoom == null ? maxZoom : Math.max(maxZoom, initialZoom),
96
- },
97
- ),
98
- );
99
- },
100
- [commit, minZoom, maxZoom, frame],
101
- );
102
-
103
- const clearDrag = useCallback(() => {
104
- dragRef.current = null;
105
- sessionRef.current = null;
106
- }, []);
107
-
108
- const onPointerDown = useCallback(
109
- (e: ReactPointerEvent<HTMLElement>) => {
110
- if (e.button !== 0) return;
111
- const drag: DragState = {
112
- startScreenX: e.clientX,
113
- startScreenY: e.clientY,
114
- startPan: { ...viewRef.current.pan },
115
- moved: false,
116
- };
117
- dragRef.current = drag;
118
- sessionRef.current = openPointerSession(e.currentTarget, e, {
119
- onMove: (ev) => {
120
- const dx = ev.clientX - drag.startScreenX;
121
- const dy = ev.clientY - drag.startScreenY;
122
- if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
123
- drag.moved = true;
124
- commit({
125
- zoom: viewRef.current.zoom,
126
- pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },
127
- });
128
- },
129
- onEnd: (ev) => {
130
- const tapped = !drag.moved;
131
- clearDrag();
132
- if (tapped) onTapRef.current?.(ev);
133
- },
134
- // A cancelled pan keeps whatever it had already applied: every move
135
- // was committed as it arrived, so there is nothing left to undo.
136
- onCancel: clearDrag,
137
- });
138
- },
139
- [clearDrag, commit],
140
- );
141
-
142
- useEffect(() => () => sessionRef.current?.cancel(), []);
143
-
144
- const isDragging = useCallback(() => dragRef.current?.moved === true, []);
145
-
146
- return { onWheel, onPointerDown, isDragging };
147
- }
@@ -1,30 +0,0 @@
1
- import { useEffect, useState } from 'react';
2
- import type { LabMode } from '../state/types';
3
-
4
- function query(): MediaQueryList | null {
5
- if (typeof window === 'undefined' || !window.matchMedia) return null;
6
- return window.matchMedia('(prefers-color-scheme: light)');
7
- }
8
-
9
- /**
10
- * Resolve a `LabMode` to the mode the theme resolver understands.
11
- *
12
- * `auto` is not a mode — it means "read the OS", live. An explicit choice
13
- * opts out of the subscription entirely.
14
- */
15
- export function useResolvedMode(mode: LabMode): 'light' | 'dark' {
16
- const [system, setSystem] = useState<'light' | 'dark'>(() =>
17
- query()?.matches ? 'light' : 'dark',
18
- );
19
-
20
- useEffect(() => {
21
- if (mode !== 'auto') return;
22
- const mq = query();
23
- if (!mq) return;
24
- const onChange = (e: MediaQueryListEvent) => setSystem(e.matches ? 'light' : 'dark');
25
- mq.addEventListener('change', onChange);
26
- return () => mq.removeEventListener('change', onChange);
27
- }, [mode]);
28
-
29
- return mode === 'auto' ? system : mode;
30
- }
@@ -1,93 +0,0 @@
1
- // No padding of its own: whatever mounts the list insets it. The sidebar
2
- // section body already does, and a second inset here put the grip 22px off a
3
- // 161px-wide sidebar's edge.
4
- .lk-layer-list {
5
- display: flex;
6
- flex-direction: column;
7
- gap: 1px;
8
- }
9
-
10
- .lk-layer-list__empty {
11
- color: var(--wzl-fg-muted);
12
- font-size: var(--wzl-font-size-sm);
13
- font-style: italic;
14
- }
15
-
16
- .lk-layer-list__row {
17
- display: flex;
18
- align-items: center;
19
- gap: var(--wzl-space-xs);
20
- padding: 1px 2px;
21
- border-radius: var(--wzl-radius-sm);
22
- user-select: none;
23
- }
24
-
25
- .lk-layer-list__row:hover {
26
- background: var(--wzl-surface-hover);
27
- }
28
-
29
- .lk-layer-list__row--dragging {
30
- background: var(--wzl-surface-pressed);
31
- opacity: 0.7;
32
- }
33
-
34
- .lk-layer-list__row--pinned {
35
- opacity: 0.7;
36
- }
37
-
38
- // Prefixed with `.lk-root` to outrank base.less's `:where(button)` default,
39
- // which would otherwise wrap this grip in a 24px chrome box with a border, an
40
- // elevated fill and 12px of side padding — around a glyph a third that size.
41
- .lk-root .lk-layer-list__handle {
42
- display: inline-flex;
43
- align-items: center;
44
- height: auto;
45
- // Transparent, so the grab target is comfortable while nothing but the dots
46
- // is drawn. The negative margin keeps it from widening the row.
47
- padding: 4px;
48
- margin: -4px;
49
- border: 0;
50
- border-radius: 0;
51
- background: transparent;
52
- backdrop-filter: none;
53
- -webkit-backdrop-filter: none;
54
- cursor: grab;
55
- color: var(--wzl-fg-muted);
56
- }
57
-
58
- .lk-root .lk-layer-list__handle:hover {
59
- color: var(--wzl-fg);
60
- }
61
-
62
- .lk-layer-list__handle:active {
63
- cursor: grabbing;
64
- }
65
-
66
- .lk-layer-list__lock {
67
- color: var(--wzl-fg-muted);
68
- }
69
-
70
- .lk-layer-list__label {
71
- flex: 1;
72
- font-size: var(--wzl-font-size-sm);
73
- }
74
-
75
- .lk-layer-list input[type='checkbox'] {
76
- accent-color: var(--wzl-accent);
77
- }
78
-
79
- .lk-layer-list__children {
80
- display: flex;
81
- flex-direction: column;
82
- gap: 1px;
83
- // Indents by the twisty column, so a child's own twisty starts where its
84
- // parent's label does.
85
- padding-left: var(--wzl-space-md);
86
- }
87
-
88
- // Holds the twisty column open on rows that have nothing to expand, so labels
89
- // line up down the level.
90
- .lk-layer-list__twisty-gap {
91
- flex: 0 0 auto;
92
- width: var(--wzl-disclosure-target, var(--wzl-control-h-sm));
93
- }
@@ -1,66 +0,0 @@
1
- import type { Meta, StoryObj } from '@weasel-js/forge';
2
- import { useState } from 'react';
3
- import { LayerList, type LayerTreeNode } from './LayerList';
4
-
5
- const meta: Meta<typeof LayerList> = {
6
- title: 'labkit/Layers/LayerList',
7
- component: LayerList,
8
- };
9
- export default meta;
10
- type Story = StoryObj<typeof LayerList>;
11
-
12
- const FLAT: LayerTreeNode[] = [
13
- { id: 'plants', label: 'Plants' },
14
- { id: 'grid', label: 'Grid' },
15
- { id: 'legend', label: 'Legend', alwaysOn: true },
16
- ];
17
-
18
- const NESTED: LayerTreeNode[] = [
19
- {
20
- id: 'ingest',
21
- label: 'Ingest',
22
- children: [
23
- { id: 'ingest.fetch', label: 'Fetch' },
24
- { id: 'ingest.parse', label: 'Parse' },
25
- ],
26
- },
27
- {
28
- id: 'solve',
29
- label: 'Solve',
30
- defaultCollapsed: true,
31
- children: [
32
- { id: 'solve.seed', label: 'Seed' },
33
- {
34
- id: 'solve.relax',
35
- label: 'Relax',
36
- children: [
37
- { id: 'solve.relax.sweep', label: 'Sweep' },
38
- { id: 'solve.relax.check', label: 'Check' },
39
- ],
40
- },
41
- ],
42
- },
43
- { id: 'report', label: 'Report', alwaysOn: true },
44
- ];
45
-
46
- function Demo({ seed }: { seed: LayerTreeNode[] }) {
47
- const [layers, setLayers] = useState(seed);
48
- const [visibility, setVisibility] = useState<Record<string, boolean>>({});
49
- return (
50
- <div style={{ width: 220 }}>
51
- <LayerList
52
- layers={layers}
53
- visibility={visibility}
54
- onReorder={setLayers}
55
- onToggle={(id, visible) => setVisibility((v) => ({ ...v, [id]: visible }))}
56
- />
57
- </div>
58
- );
59
- }
60
-
61
- /** No node has children, so no row spends width on a twisty column. */
62
- export const Flat: Story = { render: () => <Demo seed={FLAT} /> };
63
-
64
- /** Dragging a row reorders it among its own siblings — a drag never moves a
65
- * layer to a different parent. */
66
- export const Tree: Story = { render: () => <Demo seed={NESTED} /> };