@weasel-js/labkit 1.3.0 → 1.4.0-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
  2. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  3. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  4. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  5. package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
  6. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  7. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  8. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  9. package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
  10. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  11. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  12. package/dist/canvas/index.d.ts +43 -25
  13. package/dist/canvas/index.js +5 -4
  14. package/dist/chrome/index.d.ts +9 -6
  15. package/dist/chrome/index.js +6 -6
  16. package/dist/chunk-2AYGEN57.js +32 -0
  17. package/dist/chunk-2AYGEN57.js.map +1 -0
  18. package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
  19. package/dist/chunk-3P5TTTA7.js.map +1 -0
  20. package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
  21. package/dist/chunk-3ZELRRBV.js.map +1 -0
  22. package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
  23. package/dist/chunk-4EY67BKJ.js.map +1 -0
  24. package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
  25. package/dist/chunk-5TCEVMPI.js.map +1 -0
  26. package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
  27. package/dist/chunk-6JMU56CU.js.map +1 -0
  28. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  29. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  30. package/dist/chunk-CTRKTLYZ.js +33 -0
  31. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  32. package/dist/chunk-EQIB2PGC.js +458 -0
  33. package/dist/chunk-EQIB2PGC.js.map +1 -0
  34. package/dist/chunk-HEORBLVT.js +7128 -0
  35. package/dist/chunk-HEORBLVT.js.map +1 -0
  36. package/dist/{chunk-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
  37. package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
  38. package/dist/chunk-LITE5YFB.js +669 -0
  39. package/dist/chunk-LITE5YFB.js.map +1 -0
  40. package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
  41. package/dist/chunk-ROQDRXKW.js.map +1 -0
  42. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  43. package/dist/chunk-XJ6N32QP.js.map +1 -0
  44. package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
  45. package/dist/chunk-YQJNT4XQ.js.map +1 -0
  46. package/dist/controls/index.d.ts +4 -28
  47. package/dist/controls/index.js +3 -3
  48. package/dist/dragdrop/index.d.ts +9 -5
  49. package/dist/dragdrop/index.js +3 -3
  50. package/dist/index.d.ts +518 -190
  51. package/dist/index.js +1753 -75
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +6 -5
  54. package/dist/layers/index.js +3 -3
  55. package/dist/loupe/index.d.ts +187 -0
  56. package/dist/loupe/index.js +7 -0
  57. package/dist/loupe/index.js.map +1 -0
  58. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  59. package/dist/passthrough/weasel-canvas.js +1 -1
  60. package/dist/passthrough/weasel-ui.d.ts +138 -91
  61. package/dist/passthrough/weasel-ui.js +2 -2
  62. package/dist/primitives/index.d.ts +3 -1
  63. package/dist/primitives/index.js +5 -5
  64. package/dist/state/index.d.ts +3 -3
  65. package/dist/state/index.js +2 -2
  66. package/dist/styles.css +229 -1
  67. package/dist/surface/index.d.ts +18 -2
  68. package/dist/surface/index.js +2 -2
  69. package/dist/ui/layers/index.js +2 -2
  70. package/dist/undo/index.d.ts +5 -4
  71. package/package.json +14 -7
  72. package/src/annotations/AnnotationOverlay.tsx +205 -0
  73. package/src/annotations/AnnotationTargets.tsx +37 -0
  74. package/src/annotations/Annotations.less +129 -0
  75. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  76. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  77. package/src/annotations/AnnotationsContext.ts +49 -0
  78. package/src/annotations/ExportMenu.test.tsx +86 -0
  79. package/src/annotations/ExportMenu.tsx +182 -0
  80. package/src/annotations/MarkList.tsx +75 -0
  81. package/src/annotations/capture.test.ts +140 -0
  82. package/src/annotations/capture.ts +237 -0
  83. package/src/annotations/drawOne.test.ts +56 -0
  84. package/src/annotations/drawOne.ts +47 -0
  85. package/src/annotations/frac.test.ts +59 -0
  86. package/src/annotations/frac.ts +66 -0
  87. package/src/annotations/history.test.ts +82 -0
  88. package/src/annotations/history.ts +118 -0
  89. package/src/annotations/index.ts +37 -0
  90. package/src/annotations/paint.test.ts +125 -0
  91. package/src/annotations/paint.ts +130 -0
  92. package/src/annotations/staleness.test.ts +56 -0
  93. package/src/annotations/staleness.ts +41 -0
  94. package/src/annotations/store.test.ts +175 -0
  95. package/src/annotations/store.ts +317 -0
  96. package/src/annotations/svgNodes.test.ts +77 -0
  97. package/src/annotations/svgNodes.ts +74 -0
  98. package/src/annotations/toolMap.test.ts +36 -0
  99. package/src/annotations/toolMap.ts +54 -0
  100. package/src/annotations/types.ts +222 -0
  101. package/src/annotations/view.test.ts +37 -0
  102. package/src/annotations/view.ts +40 -0
  103. package/src/canvas/AGENTS.md +55 -5
  104. package/src/canvas/CanvasStack.test.tsx +27 -1
  105. package/src/canvas/CanvasStack.tsx +29 -4
  106. package/src/canvas/CanvasStackContext.ts +23 -4
  107. package/src/canvas/camera.test.ts +78 -0
  108. package/src/canvas/camera.ts +62 -0
  109. package/src/canvas/canvasCoords.test.ts +26 -0
  110. package/src/canvas/canvasCoords.ts +20 -7
  111. package/src/canvas/index.ts +5 -1
  112. package/src/canvas/useLayerScheduler.ts +7 -3
  113. package/src/canvas/usePanZoom.test.ts +36 -5
  114. package/src/canvas/usePanZoom.ts +20 -15
  115. package/src/canvas/worldSpec.test.ts +63 -0
  116. package/src/canvas/worldSpec.ts +48 -0
  117. package/src/chrome/builtins.test.ts +31 -1
  118. package/src/chrome/builtins.tsx +63 -19
  119. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  120. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  121. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  122. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  123. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  124. package/src/chrome/regions/regions.test.tsx +6 -1
  125. package/src/chrome/types.ts +20 -0
  126. package/src/controls/ControlPanel.test.tsx +38 -0
  127. package/src/controls/ControlPanel.tsx +50 -3
  128. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  129. package/src/dragdrop/dragDrop.test.tsx +29 -2
  130. package/src/index.test.ts +22 -0
  131. package/src/index.ts +87 -2
  132. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  133. package/src/instrument/types.ts +24 -1
  134. package/src/lab/Lab.surface.test.tsx +162 -0
  135. package/src/lab/Lab.tsx +102 -17
  136. package/src/lab/Lab.undock.test.tsx +85 -0
  137. package/src/lab/LabFullChrome.stories.tsx +20 -0
  138. package/src/lab/LabShell.less +11 -0
  139. package/src/lab/Workspace.less +73 -0
  140. package/src/lab/Workspace.surface.test.tsx +2 -0
  141. package/src/lab/Workspace.tsx +97 -8
  142. package/src/lab/index.ts +1 -1
  143. package/src/lab/panelHost.ts +37 -0
  144. package/src/loupe/AGENTS.md +49 -0
  145. package/src/loupe/CanvasLoupe.tsx +83 -0
  146. package/src/loupe/DomLoupe.tsx +62 -0
  147. package/src/loupe/Loupe.less +38 -0
  148. package/src/loupe/LoupeBubble.tsx +32 -0
  149. package/src/loupe/TrialLoupe.tsx +93 -0
  150. package/src/loupe/canvasLens.test.ts +205 -0
  151. package/src/loupe/canvasLens.ts +141 -0
  152. package/src/loupe/index.ts +19 -0
  153. package/src/loupe/types.test.ts +29 -0
  154. package/src/loupe/types.ts +90 -0
  155. package/src/loupe/useHostSize.ts +30 -0
  156. package/src/loupe/useLoupe.test.tsx +197 -0
  157. package/src/loupe/useLoupe.ts +189 -0
  158. package/src/passthrough/weasel-ui.ts +3 -0
  159. package/src/primitives/FloatingPanel.test.tsx +76 -1
  160. package/src/primitives/FloatingPanel.tsx +19 -3
  161. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  162. package/src/primitives/Sidebar.less +18 -0
  163. package/src/primitives/Toolbar.less +8 -0
  164. package/src/primitives/Toolbar.tsx +5 -0
  165. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  166. package/src/primitives/useRovingTabIndex.ts +26 -17
  167. package/src/state/document.test.ts +23 -0
  168. package/src/state/document.ts +14 -2
  169. package/src/state/index.ts +2 -0
  170. package/src/state/store.test.ts +24 -0
  171. package/src/state/store.ts +35 -1
  172. package/src/state/types.ts +11 -0
  173. package/src/state/undock.test.ts +40 -0
  174. package/src/state/undock.ts +39 -0
  175. package/src/styles.less +2 -0
  176. package/src/surface/SurfaceContext.ts +4 -0
  177. package/src/surface/index.ts +8 -3
  178. package/src/surface/useSurfaceTile.test.tsx +2 -0
  179. package/src/surface/useSurfaceTile.ts +7 -1
  180. package/src/surface/useTiledSurface.test.tsx +58 -0
  181. package/src/surface/useTiledSurface.ts +38 -3
  182. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  183. package/src/trial/Trial.annotations.test.tsx +121 -0
  184. package/src/trial/Trial.canvas.test.tsx +76 -1
  185. package/src/trial/Trial.less +19 -0
  186. package/src/trial/Trial.loupe.test.tsx +128 -0
  187. package/src/trial/Trial.stories.tsx +1 -0
  188. package/src/trial/Trial.test.tsx +12 -0
  189. package/src/trial/Trial.trialId.test.tsx +36 -0
  190. package/src/trial/Trial.tsx +215 -40
  191. package/src/trial/TrialChrome.tsx +35 -0
  192. package/src/trial/UndockedSections.tsx +65 -0
  193. package/src/trial/trialOps.test.ts +21 -0
  194. package/src/trial/trialOps.ts +5 -3
  195. package/dist/_dts/types-BttTIent.d.ts +0 -314
  196. package/dist/chunk-3XXPU73K.js.map +0 -1
  197. package/dist/chunk-4TMMVIDM.js.map +0 -1
  198. package/dist/chunk-6X5RBC6G.js +0 -366
  199. package/dist/chunk-6X5RBC6G.js.map +0 -1
  200. package/dist/chunk-BDJXIBRZ.js.map +0 -1
  201. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  202. package/dist/chunk-JDROYQM3.js.map +0 -1
  203. package/dist/chunk-LGKRIUSW.js.map +0 -1
  204. package/dist/chunk-QCICIHEO.js.map +0 -1
  205. package/dist/chunk-QRYPXSGP.js.map +0 -1
  206. package/dist/chunk-RGT4EJIB.js +0 -6961
  207. package/dist/chunk-RGT4EJIB.js.map +0 -1
  208. package/dist/chunk-T66HU3HX.js +0 -17
  209. package/dist/chunk-T66HU3HX.js.map +0 -1
  210. package/dist/chunk-WTI26YTM.js.map +0 -1
@@ -0,0 +1,197 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { type RefObject, StrictMode, useRef } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { resolveLoupe } from './types';
5
+ import { type LoupeState, useLoupe } from './useLoupe';
6
+
7
+ interface HarnessProps {
8
+ enabled?: boolean;
9
+ peekKey?: string | null;
10
+ sample?: (p: { x: number; y: number }) => string | null;
11
+ minFactor?: number;
12
+ maxFactor?: number;
13
+ onColorChange?: (hex: string) => void;
14
+ seen?: (loupe: LoupeState) => void;
15
+ }
16
+
17
+ /** Reports the loupe's state as text, which is every assertion jsdom can make
18
+ * about a magnifier — that the lens shows the right region is a screenshot. */
19
+ function Harness({ enabled = true, seen, sample, ...rest }: HarnessProps) {
20
+ const hostRef = useRef<HTMLDivElement | null>(null);
21
+ const loupe = useLoupe({
22
+ hostRef: hostRef as RefObject<HTMLElement | null>,
23
+ enabled,
24
+ sample,
25
+ capability: resolveLoupe(rest),
26
+ });
27
+ seen?.(loupe);
28
+ return (
29
+ <div ref={hostRef} data-testid="host">
30
+ <span data-testid="visible">{String(loupe.visible)}</span>
31
+ <span data-testid="aim">{`${loupe.aim.x},${loupe.aim.y}`}</span>
32
+ <span data-testid="factor">{loupe.factor}</span>
33
+ <span data-testid="mode">{loupe.mode}</span>
34
+ <span data-testid="color">{String(loupe.color)}</span>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ const read = (id: string): string => screen.getByTestId(id).textContent ?? '';
40
+
41
+ /** jsdom lays nothing out, so every rect is 0×0 at the origin and a client
42
+ * coordinate is already host-relative. */
43
+ function move(x: number, y: number): void {
44
+ fireEvent.pointerMove(screen.getByTestId('host'), { clientX: x, clientY: y });
45
+ }
46
+
47
+ describe('useLoupe', () => {
48
+ it('stays hidden until the pointer is over the host', () => {
49
+ render(<Harness />);
50
+ expect(read('visible')).toBe('false');
51
+ move(40, 25);
52
+ expect(read('visible')).toBe('true');
53
+ expect(read('aim')).toBe('40,25');
54
+ });
55
+
56
+ it('hides again when the pointer leaves', () => {
57
+ render(<Harness />);
58
+ move(40, 25);
59
+ fireEvent.pointerLeave(screen.getByTestId('host'));
60
+ expect(read('visible')).toBe('false');
61
+ });
62
+
63
+ it('ignores the pointer while it is turned off', () => {
64
+ render(<Harness enabled={false} />);
65
+ move(40, 25);
66
+ expect(read('visible')).toBe('false');
67
+ });
68
+
69
+ it('peeks while the peek key is held, and stops on release', () => {
70
+ render(<Harness enabled={false} />);
71
+ move(40, 25);
72
+ act(() => {
73
+ fireEvent.keyDown(window, { key: 'Alt' });
74
+ });
75
+ expect(read('visible')).toBe('true');
76
+ act(() => {
77
+ fireEvent.keyUp(window, { key: 'Alt' });
78
+ });
79
+ expect(read('visible')).toBe('false');
80
+ });
81
+
82
+ it('does not peek when the capability turned the key off', () => {
83
+ render(<Harness enabled={false} peekKey={null} />);
84
+ move(40, 25);
85
+ act(() => {
86
+ fireEvent.keyDown(window, { key: 'Alt' });
87
+ });
88
+ expect(read('visible')).toBe('false');
89
+ });
90
+
91
+ it('drops the peek when the window loses focus, so a held key cannot stick', () => {
92
+ render(<Harness enabled={false} />);
93
+ move(40, 25);
94
+ act(() => {
95
+ fireEvent.keyDown(window, { key: 'Alt' });
96
+ });
97
+ act(() => {
98
+ fireEvent.blur(window);
99
+ });
100
+ expect(read('visible')).toBe('false');
101
+ });
102
+
103
+ it('opens at the declared factor and zooms on the wheel', () => {
104
+ render(<Harness />);
105
+ move(40, 25);
106
+ expect(read('factor')).toBe('6');
107
+ act(() => {
108
+ fireEvent.wheel(screen.getByTestId('host'), { deltaY: -100 });
109
+ });
110
+ expect(Number(read('factor'))).toBeGreaterThan(6);
111
+ });
112
+
113
+ it('clamps the wheel to the declared bounds', () => {
114
+ render(<Harness minFactor={4} maxFactor={8} />);
115
+ move(40, 25);
116
+ for (let i = 0; i < 40; i++) {
117
+ act(() => {
118
+ fireEvent.wheel(screen.getByTestId('host'), { deltaY: -100 });
119
+ });
120
+ }
121
+ expect(Number(read('factor'))).toBe(8);
122
+ for (let i = 0; i < 80; i++) {
123
+ act(() => {
124
+ fireEvent.wheel(screen.getByTestId('host'), { deltaY: 100 });
125
+ });
126
+ }
127
+ expect(Number(read('factor'))).toBe(4);
128
+ });
129
+
130
+ it('leaves the wheel to pan-zoom while the lens is not shown', () => {
131
+ render(<Harness enabled={false} />);
132
+ const host = screen.getByTestId('host');
133
+ const wheel = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true });
134
+ act(() => {
135
+ host.dispatchEvent(wheel);
136
+ });
137
+ expect(wheel.defaultPrevented).toBe(false);
138
+ });
139
+
140
+ it('takes the wheel from pan-zoom while the lens is shown', () => {
141
+ render(<Harness />);
142
+ move(40, 25);
143
+ const host = screen.getByTestId('host');
144
+ const wheel = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true });
145
+ act(() => {
146
+ host.dispatchEvent(wheel);
147
+ });
148
+ expect(wheel.defaultPrevented).toBe(true);
149
+ });
150
+
151
+ it('reports the colour under the aim', () => {
152
+ const onColorChange = vi.fn();
153
+ render(
154
+ <Harness sample={(p) => (p.x > 10 ? '#ff0000' : '#00ff00')} onColorChange={onColorChange} />,
155
+ );
156
+ move(40, 25);
157
+ expect(read('color')).toBe('#ff0000');
158
+ expect(onColorChange).toHaveBeenCalledWith('#ff0000');
159
+ move(4, 25);
160
+ expect(read('color')).toBe('#00ff00');
161
+ });
162
+
163
+ it('picks what the lens shows at a point inside it', () => {
164
+ const seen: LoupeState[] = [];
165
+ render(<Harness sample={(p) => `#${Math.round(p.x)}`} seen={(l) => seen.push(l)} />);
166
+ move(100, 100);
167
+ const loupe = seen[seen.length - 1];
168
+ // Half the default 200px lens right of centre, at 6x, is 100/6 world px
169
+ // right of the aim.
170
+ const hex = loupe.pick({ x: 100 + 100, y: 100 });
171
+ expect(hex).toBe(`#${Math.round(100 + 100 / 6)}`);
172
+ });
173
+
174
+ it('still aims after a mount / unmount / remount', () => {
175
+ // What StrictMode does to every effect. The model is created once and
176
+ // `dispose` is one-way, so tearing it down on the first unmount left a
177
+ // magnifier that drew but never moved.
178
+ render(
179
+ <StrictMode>
180
+ <Harness />
181
+ </StrictMode>,
182
+ );
183
+ move(40, 25);
184
+ expect(read('visible')).toBe('true');
185
+ expect(read('aim')).toBe('40,25');
186
+ });
187
+
188
+ it('switches mode', () => {
189
+ const seen: LoupeState[] = [];
190
+ render(<Harness seen={(l) => seen.push(l)} />);
191
+ move(40, 25);
192
+ act(() => {
193
+ seen[seen.length - 1].setMode('pixel');
194
+ });
195
+ expect(read('mode')).toBe('pixel');
196
+ });
197
+ });
@@ -0,0 +1,189 @@
1
+ import {
2
+ createLoupeModel,
3
+ type LoupeMode,
4
+ type LoupeModel,
5
+ type LoupePoint,
6
+ } from '@weasel-js/loupe';
7
+ import { type RefObject, useCallback, useEffect, useReducer, useRef } from 'react';
8
+ import type { ResolvedLoupe } from './types';
9
+
10
+ /** Options for {@link useLoupe}. */
11
+ export interface UseLoupeOptions {
12
+ capability: ResolvedLoupe;
13
+ /** The element the lens tracks the pointer across. */
14
+ hostRef: RefObject<HTMLElement | null>;
15
+ /** Whether the loupe is turned on. Hold-to-peek shows it regardless. */
16
+ enabled: boolean;
17
+ /** Hex colour at a host point. Omitted, the loupe reports no colour — which
18
+ * is the honest answer for a surface with no pixels to read. */
19
+ sample?: (p: LoupePoint) => string | null;
20
+ }
21
+
22
+ /** A loupe as a React view reads it. */
23
+ export interface LoupeState {
24
+ /** Whether the lens should be drawn. */
25
+ visible: boolean;
26
+ /** Where it is aimed, in the host's own pixels. */
27
+ aim: LoupePoint;
28
+ factor: number;
29
+ mode: LoupeMode;
30
+ color: string | null;
31
+ setMode: (mode: LoupeMode) => void;
32
+ setFactor: (factor: number) => void;
33
+ /** Sample what the lens shows at a point inside it. */
34
+ pick: (p?: LoupePoint) => string | null;
35
+ }
36
+
37
+ /** How fast the wheel walks the magnification. Gentler than the pan-zoom
38
+ * wheel: the lens' whole range is one order of magnitude. */
39
+ const WHEEL_RATE = 0.002;
40
+
41
+ /**
42
+ * Binds `@weasel-js/loupe`'s model to a host element and drives it from the
43
+ * pointer, the peek key and the wheel.
44
+ *
45
+ * These are plain listeners rather than gesture bindings because labkit trials
46
+ * do not route input through the dispatcher — see the loupe entry under
47
+ * "Selection, actions & UI panels" in `docs/TODO.md`. They are all in this one
48
+ * hook so that when trial input does go through it, this is the only file to
49
+ * rewrite.
50
+ */
51
+ export function useLoupe({ capability, hostRef, enabled, sample }: UseLoupeOptions): LoupeState {
52
+ const [, bump] = useReducer((n: number) => n + 1, 0);
53
+
54
+ const overRef = useRef(false);
55
+ const peekingRef = useRef(false);
56
+ const enabledRef = useRef(enabled);
57
+ enabledRef.current = enabled;
58
+ const sampleRef = useRef(sample);
59
+ sampleRef.current = sample;
60
+ const onColorChangeRef = useRef(capability.onColorChange);
61
+ onColorChangeRef.current = capability.onColorChange;
62
+
63
+ // Reads nothing but refs, so it is stable and an effect may depend on it.
64
+ const shown = useCallback(
65
+ (): boolean => overRef.current && (enabledRef.current || peekingRef.current),
66
+ [],
67
+ );
68
+
69
+ const modelRef = useRef<LoupeModel | null>(null);
70
+ if (modelRef.current === null) {
71
+ modelRef.current = createLoupeModel({
72
+ mode: capability.mode,
73
+ factor: capability.factor,
74
+ minFactor: capability.minFactor,
75
+ maxFactor: capability.maxFactor,
76
+ onColorChange: (hex) => onColorChangeRef.current?.(hex),
77
+ surface: {
78
+ lens: () => {
79
+ const d = diameterRef.current;
80
+ const { x, y } = modelRef.current?.aim ?? { x: 0, y: 0 };
81
+ return { x: x - d / 2, y: y - d / 2, w: d, h: d };
82
+ },
83
+ // The lens follows the pointer and is painted over the host rather than
84
+ // into it, so it is never part of the picture being magnified.
85
+ covers: () => false,
86
+ sample: (p) => sampleRef.current?.(p) ?? null,
87
+ hidden: () => !shown(),
88
+ gone: () => goneRef.current,
89
+ changed: bump,
90
+ },
91
+ });
92
+ }
93
+ const model = modelRef.current;
94
+
95
+ const diameterRef = useRef(capability.diameter);
96
+ diameterRef.current = capability.diameter;
97
+ const goneRef = useRef(false);
98
+
99
+ // The model holds no resources, and `dispose` is one-way — so unmounting only
100
+ // reports the lens gone. Disposing here instead leaves React's mount / unmount
101
+ // / remount in StrictMode with a permanently dead model that silently ignores
102
+ // every aim.
103
+ useEffect(() => {
104
+ goneRef.current = false;
105
+ return () => {
106
+ goneRef.current = true;
107
+ };
108
+ }, []);
109
+
110
+ // Turning the loupe off with the pointer still inside must put the lens away,
111
+ // and the model only reconsiders on an aim.
112
+ useEffect(() => {
113
+ if (!enabled && !peekingRef.current) bump();
114
+ }, [enabled]);
115
+
116
+ useEffect(() => {
117
+ const host = hostRef.current;
118
+ if (!host) return;
119
+
120
+ const onPointerMove = (e: PointerEvent): void => {
121
+ overRef.current = true;
122
+ const rect = host.getBoundingClientRect();
123
+ model.aimAt({ x: e.clientX - rect.left, y: e.clientY - rect.top });
124
+ if (!shown()) return;
125
+ bump();
126
+ };
127
+ const onPointerLeave = (): void => {
128
+ overRef.current = false;
129
+ bump();
130
+ };
131
+ const onWheel = (e: WheelEvent): void => {
132
+ if (!shown()) return;
133
+ // Ahead of the stack's own pan-zoom, which is a React handler on this same
134
+ // element: capture runs first, and stopping propagation keeps the event
135
+ // from reaching React's root listener at all.
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+ model.setFactor(model.factor * Math.exp(-e.deltaY * WHEEL_RATE));
139
+ };
140
+
141
+ host.addEventListener('pointermove', onPointerMove);
142
+ host.addEventListener('pointerleave', onPointerLeave);
143
+ host.addEventListener('wheel', onWheel, { capture: true, passive: false });
144
+ return () => {
145
+ host.removeEventListener('pointermove', onPointerMove);
146
+ host.removeEventListener('pointerleave', onPointerLeave);
147
+ host.removeEventListener('wheel', onWheel, { capture: true });
148
+ };
149
+ }, [hostRef, model, shown]);
150
+
151
+ const peekKey = capability.peekKey;
152
+ useEffect(() => {
153
+ if (peekKey == null) return;
154
+ const setPeeking = (next: boolean): void => {
155
+ if (peekingRef.current === next) return;
156
+ peekingRef.current = next;
157
+ bump();
158
+ };
159
+ const onKeyDown = (e: KeyboardEvent): void => {
160
+ if (e.key === peekKey) setPeeking(true);
161
+ };
162
+ const onKeyUp = (e: KeyboardEvent): void => {
163
+ if (e.key === peekKey) setPeeking(false);
164
+ };
165
+ // A key held while the window loses focus never sends its keyup, so the
166
+ // peek would stay on until the key was pressed and released again.
167
+ const onBlur = (): void => setPeeking(false);
168
+
169
+ window.addEventListener('keydown', onKeyDown);
170
+ window.addEventListener('keyup', onKeyUp);
171
+ window.addEventListener('blur', onBlur);
172
+ return () => {
173
+ window.removeEventListener('keydown', onKeyDown);
174
+ window.removeEventListener('keyup', onKeyUp);
175
+ window.removeEventListener('blur', onBlur);
176
+ };
177
+ }, [peekKey]);
178
+
179
+ return {
180
+ visible: shown(),
181
+ aim: model.aim,
182
+ factor: model.factor,
183
+ mode: model.mode,
184
+ color: model.color,
185
+ setMode: model.setMode,
186
+ setFactor: model.setFactor,
187
+ pick: model.pick,
188
+ };
189
+ }
@@ -29,6 +29,9 @@ export {
29
29
  type ButtonProps,
30
30
  type ButtonSize,
31
31
  type ButtonVariant,
32
+ Callout,
33
+ type CalloutProps,
34
+ CalloutTrigger,
32
35
  Checkbox,
33
36
  type CheckboxProps,
34
37
  type ChromaCurve,
@@ -117,14 +117,25 @@ describe('FloatingPanel', () => {
117
117
  });
118
118
 
119
119
  describe('FloatingPanel dragging', () => {
120
- it('marks itself dragging between pointerdown and pointerup', () => {
120
+ it('marks itself dragging once the pointer moves, not on the press', () => {
121
121
  const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
122
122
  fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
123
+ expect(panel.dataset.dragging).toBeUndefined();
124
+ fireEvent.pointerMove(panel, { clientX: 140, clientY: 100 });
123
125
  expect(panel.dataset.dragging).toBe('true');
124
126
  fireEvent.pointerUp(panel);
125
127
  expect(panel.dataset.dragging).toBeUndefined();
126
128
  });
127
129
 
130
+ it('a press that never moves is not a drag', () => {
131
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
132
+ const before = panel.style.left;
133
+ fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
134
+ fireEvent.pointerUp(panel, { clientX: 100, clientY: 100 });
135
+ expect(panel.dataset.dragging).toBeUndefined();
136
+ expect(panel.style.left).toBe(before);
137
+ });
138
+
128
139
  it('moves with the pointer, accumulating across moves', () => {
129
140
  const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
130
141
  expect(panel.style.left).toBe('12px');
@@ -238,3 +249,67 @@ describe('FloatingPanel persistence', () => {
238
249
  expect(() => renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>)).not.toThrow();
239
250
  });
240
251
  });
252
+
253
+ describe('a child that is not a native control', () => {
254
+ // The real symptom — capture retargets mouseup, so the browser synthesizes no
255
+ // `click` on the child — cannot be reproduced in jsdom, which fires the click
256
+ // either way. Capture-on-press is the observable proxy for it.
257
+ it('does not capture the pointer until the pointer has actually moved', () => {
258
+ const captured: number[] = [];
259
+ Element.prototype.setPointerCapture = (id: number) => {
260
+ captured.push(id);
261
+ };
262
+ render(
263
+ <FloatingPanel>
264
+ {/* biome-ignore lint/a11y/useSemanticElements: a non-native control is the subject */}
265
+ <span data-testid="row" role="button" tabIndex={0}>
266
+ row
267
+ </span>
268
+ </FloatingPanel>,
269
+ );
270
+ const row = screen.getByTestId('row');
271
+ fireEvent.pointerDown(row, { pointerId: 1, button: 0, clientX: 40, clientY: 40 });
272
+ expect(captured).toEqual([]);
273
+ });
274
+ });
275
+
276
+ describe('a clickable row nested in a list, the shape a floating panel usually holds', () => {
277
+ it('does not capture the pointer on a press two ancestors below the panel', () => {
278
+ const captured: number[] = [];
279
+ Element.prototype.setPointerCapture = (id: number) => {
280
+ captured.push(id);
281
+ };
282
+ render(
283
+ <FloatingPanel>
284
+ <ul>
285
+ <li>
286
+ {/* biome-ignore lint/a11y/useSemanticElements: a non-native control is the subject */}
287
+ <span data-testid="row" role="button" tabIndex={0}>
288
+ row
289
+ </span>
290
+ </li>
291
+ </ul>
292
+ </FloatingPanel>,
293
+ );
294
+ fireEvent.pointerDown(screen.getByTestId('row'), {
295
+ pointerId: 1,
296
+ button: 0,
297
+ clientX: 40,
298
+ clientY: 40,
299
+ });
300
+ expect(captured).toEqual([]);
301
+ });
302
+
303
+ it('still drags when the press that started in the list actually moves', () => {
304
+ const { panel } = renderPanel(
305
+ <FloatingPanel anchor="top-left">
306
+ <ul>
307
+ <li>row</li>
308
+ </ul>
309
+ </FloatingPanel>,
310
+ );
311
+ fireEvent.pointerDown(screen.getByText('row'), { pointerId: 1, clientX: 100, clientY: 100 });
312
+ fireEvent.pointerMove(panel, { pointerId: 1, clientX: 200, clientY: 100 });
313
+ expect(panel.dataset.dragging).toBe('true');
314
+ });
315
+ });
@@ -37,6 +37,9 @@ function borderBoxOf(entry: ResizeObserverEntry): { w: number; h: number } {
37
37
  return { w: entry.contentRect.width, h: entry.contentRect.height };
38
38
  }
39
39
 
40
+ /** How far the pointer must travel before a press becomes a drag. */
41
+ const DRAG_THRESHOLD = 3;
42
+
40
43
  /** A pointerdown on one of these is the child's, not a drag. */
41
44
  function isInteractive(target: EventTarget | null): boolean {
42
45
  if (!(target instanceof Element)) return false;
@@ -127,18 +130,30 @@ export function FloatingPanel({
127
130
  .placements.get(ITEM_ID);
128
131
 
129
132
  const dragging = useRef<{ x: number; y: number } | null>(null);
133
+ /** Pressed but not yet moved far enough to be a drag. */
134
+ const pending = useRef<{ x: number; y: number; pointerId: number } | null>(null);
130
135
  const [isDragging, setDragging] = useState(false);
131
136
 
132
137
  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
133
138
  if (isInteractive(e.target)) return;
134
139
  // The canvas stack underneath owns pan/zoom on the same pointer events.
135
140
  e.stopPropagation();
136
- dragging.current = { x: e.clientX, y: e.clientY };
137
- setDragging(true);
138
- e.currentTarget.setPointerCapture?.(e.pointerId);
141
+ // Armed, not capturing. Capture retargets mouseup to this element, and the
142
+ // browser then synthesizes no `click` on the child under the cursor — so
143
+ // capturing on press makes every non-native control in the panel dead to a
144
+ // real mouse, silently. `isInteractive` only exempts the names it lists; a
145
+ // `role="button"` span, a react-aria control or a canvas is not among them.
146
+ pending.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
139
147
  };
140
148
 
141
149
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
150
+ const armed = pending.current;
151
+ if (armed && armed.pointerId === e.pointerId && !dragging.current) {
152
+ if (Math.hypot(e.clientX - armed.x, e.clientY - armed.y) < DRAG_THRESHOLD) return;
153
+ dragging.current = { x: armed.x, y: armed.y };
154
+ setDragging(true);
155
+ e.currentTarget.setPointerCapture?.(e.pointerId);
156
+ }
142
157
  const from = dragging.current;
143
158
  if (!from) return;
144
159
  const dx = e.clientX - from.x;
@@ -160,6 +175,7 @@ export function FloatingPanel({
160
175
  };
161
176
 
162
177
  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
178
+ pending.current = null;
163
179
  if (!dragging.current) return;
164
180
  dragging.current = null;
165
181
  setDragging(false);
@@ -1,6 +1,7 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import { describe, expect, test } from 'vitest';
3
3
  import { CanvasStackContext } from '../canvas/CanvasStackContext';
4
+ import { DEFAULT_FRAME } from '../canvas/worldSpec';
4
5
  import { ScaleIndicator } from './ScaleIndicator';
5
6
 
6
7
  describe('ScaleIndicator', () => {
@@ -27,7 +28,9 @@ describe('ScaleIndicator', () => {
27
28
 
28
29
  test('reads zoom from CanvasStackContext when prop omitted', () => {
29
30
  render(
30
- <CanvasStackContext.Provider value={{ view: { zoom: 2.5, pan: { x: 0, y: 0 } } }}>
31
+ <CanvasStackContext.Provider
32
+ value={{ view: { zoom: 2.5, pan: { x: 0, y: 0 } }, frame: DEFAULT_FRAME }}
33
+ >
31
34
  <ScaleIndicator pixelsPerUnit={50} unit="ft" />
32
35
  </CanvasStackContext.Provider>,
33
36
  );
@@ -37,7 +40,9 @@ describe('ScaleIndicator', () => {
37
40
 
38
41
  test('explicit zoom prop overrides context', () => {
39
42
  render(
40
- <CanvasStackContext.Provider value={{ view: { zoom: 100, pan: { x: 0, y: 0 } } }}>
43
+ <CanvasStackContext.Provider
44
+ value={{ view: { zoom: 100, pan: { x: 0, y: 0 } }, frame: DEFAULT_FRAME }}
45
+ >
41
46
  <ScaleIndicator zoom={1} pixelsPerUnit={50} unit="ft" />
42
47
  </CanvasStackContext.Provider>,
43
48
  );
@@ -62,7 +62,25 @@
62
62
  border-bottom: var(--wzl-border-w) solid var(--wzl-line-subtle);
63
63
  }
64
64
 
65
+ .lk-sidebar-section__bar {
66
+ display: flex;
67
+ align-items: center;
68
+ }
69
+
70
+ .lk-sidebar-section__undock {
71
+ padding: 0 var(--wzl-space-xs);
72
+ border: 0;
73
+ background: transparent;
74
+ color: var(--wzl-fg-subtle);
75
+ cursor: pointer;
76
+
77
+ &:hover {
78
+ color: var(--wzl-fg);
79
+ }
80
+ }
81
+
65
82
  .lk-sidebar-section__head {
83
+ flex: 1;
66
84
  width: 100%;
67
85
  padding: var(--wzl-space-xs);
68
86
  border: 0;
@@ -73,6 +73,14 @@
73
73
  border-color: var(--wzl-border);
74
74
  }
75
75
 
76
+ // A toggle that is on stays on when the pointer leaves, so it reads as held
77
+ // rather than hovered — the hover rule alone would be indistinguishable.
78
+ &--pressed {
79
+ background: var(--wzl-accent-soft);
80
+ border-color: var(--wzl-accent);
81
+ color: var(--wzl-fg);
82
+ }
83
+
76
84
  &--danger:hover:not(:disabled) {
77
85
  color: var(--wzl-danger);
78
86
  border-color: var(--wzl-danger);
@@ -70,6 +70,8 @@ export interface ToolbarButtonProps {
70
70
  variant?: 'default' | 'danger';
71
71
  /** Square, for a button whose whole content is one glyph. */
72
72
  iconOnly?: boolean;
73
+ /** Makes this a toggle: `aria-pressed`, and held down while true. */
74
+ pressed?: boolean;
73
75
  }
74
76
 
75
77
  function Button({
@@ -80,11 +82,13 @@ function Button({
80
82
  'aria-label': ariaLabel,
81
83
  variant = 'default',
82
84
  iconOnly,
85
+ pressed,
83
86
  }: ToolbarButtonProps) {
84
87
  const cls = [
85
88
  'lk-toolbar-button',
86
89
  variant === 'danger' && 'lk-toolbar-button--danger',
87
90
  iconOnly && 'lk-toolbar-button--icon',
91
+ pressed && 'lk-toolbar-button--pressed',
88
92
  ]
89
93
  .filter(Boolean)
90
94
  .join(' ');
@@ -96,6 +100,7 @@ function Button({
96
100
  disabled={disabled}
97
101
  title={title}
98
102
  aria-label={ariaLabel ?? title}
103
+ aria-pressed={pressed}
99
104
  >
100
105
  {children}
101
106
  </button>
@@ -25,3 +25,24 @@ describe('nextIndex', () => {
25
25
  expect(nextIndex(0, 'ArrowRight', 0)).toBeNull();
26
26
  });
27
27
  });
28
+
29
+ describe('nextIndex — vertical', () => {
30
+ it('walks the down/up arrows', () => {
31
+ expect(nextIndex(0, 'ArrowDown', 3, 'vertical')).toBe(1);
32
+ expect(nextIndex(2, 'ArrowDown', 3, 'vertical')).toBe(0);
33
+ expect(nextIndex(1, 'ArrowUp', 3, 'vertical')).toBe(0);
34
+ expect(nextIndex(0, 'ArrowUp', 3, 'vertical')).toBe(2);
35
+ });
36
+
37
+ it('leaves the cross-axis arrows to the page', () => {
38
+ expect(nextIndex(0, 'ArrowRight', 3, 'vertical')).toBeNull();
39
+ expect(nextIndex(0, 'ArrowLeft', 3, 'vertical')).toBeNull();
40
+ expect(nextIndex(0, 'ArrowDown', 3)).toBeNull();
41
+ expect(nextIndex(0, 'ArrowUp', 3)).toBeNull();
42
+ });
43
+
44
+ it('still jumps to the ends', () => {
45
+ expect(nextIndex(1, 'Home', 3, 'vertical')).toBe(0);
46
+ expect(nextIndex(1, 'End', 3, 'vertical')).toBe(2);
47
+ });
48
+ });