@weasel-js/labkit 1.8.0 → 1.8.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 (189) hide show
  1. package/README.md +24 -0
  2. package/dist/_dts/{CanvasStackContext-C0fzFsQg.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
  3. package/dist/_dts/{Stage-Cvwxdnfp.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
  4. package/dist/_dts/{index-DRaswz91.d.ts → index-8geQNQet.d.ts} +83 -11
  5. package/dist/_dts/{index-DEHRoTXK.d.ts → index-DRi3V5sN.d.ts} +5 -3
  6. package/dist/_dts/{useTrialState-CS4TdWPW.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
  7. package/dist/canvas/index.d.ts +4 -4
  8. package/dist/canvas/index.js +2 -2
  9. package/dist/chrome/index.d.ts +2 -2
  10. package/dist/chrome/index.js +7 -7
  11. package/dist/{chunk-YX75W6EK.js → chunk-4RCVWX4M.js} +8 -8
  12. package/dist/chunk-4RCVWX4M.js.map +1 -0
  13. package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
  14. package/dist/chunk-6IIKLXBZ.js.map +1 -0
  15. package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
  16. package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
  17. package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
  18. package/dist/chunk-7ALJOVGP.js.map +1 -0
  19. package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
  20. package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
  21. package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
  22. package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
  23. package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
  24. package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
  25. package/dist/{chunk-NVKBL7ZE.js → chunk-KHB3KRRQ.js} +7 -7
  26. package/dist/chunk-KHB3KRRQ.js.map +1 -0
  27. package/dist/{chunk-XXACAPVU.js → chunk-NHIO2O3O.js} +31 -30
  28. package/dist/chunk-NHIO2O3O.js.map +1 -0
  29. package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
  30. package/dist/chunk-QMAY74TP.js.map +1 -0
  31. package/dist/{chunk-YDE6XATS.js → chunk-RN7T7OBO.js} +44 -22
  32. package/dist/chunk-RN7T7OBO.js.map +1 -0
  33. package/dist/{chunk-3L4MLLQB.js → chunk-RXO5UEGT.js} +4 -11
  34. package/dist/chunk-RXO5UEGT.js.map +1 -0
  35. package/dist/{chunk-MOTDMMMN.js → chunk-SQX35X5V.js} +3 -3
  36. package/dist/{chunk-MOTDMMMN.js.map → chunk-SQX35X5V.js.map} +1 -1
  37. package/dist/{chunk-J5HJJDLF.js → chunk-T5OEUNCB.js} +7 -2
  38. package/dist/chunk-T5OEUNCB.js.map +1 -0
  39. package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
  40. package/dist/chunk-UX66USTN.js.map +1 -0
  41. package/dist/{chunk-WURZTJGQ.js → chunk-V4SMGEYY.js} +4858 -4806
  42. package/dist/chunk-V4SMGEYY.js.map +1 -0
  43. package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
  44. package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
  45. package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
  46. package/dist/chunk-XWR2P6MR.js.map +1 -0
  47. package/dist/{chunk-7KAETQSS.js → chunk-YZA73U4A.js} +7 -7
  48. package/dist/chunk-YZA73U4A.js.map +1 -0
  49. package/dist/config/index.d.ts +2 -2
  50. package/dist/config/index.js +2 -2
  51. package/dist/controls/index.js +5 -5
  52. package/dist/dragdrop/index.d.ts +1 -1
  53. package/dist/dragdrop/index.js +2 -2
  54. package/dist/index.d.ts +157 -15
  55. package/dist/index.js +893 -258
  56. package/dist/index.js.map +1 -1
  57. package/dist/layers/index.d.ts +1 -1
  58. package/dist/layers/index.js +3 -3
  59. package/dist/loupe/index.d.ts +175 -27
  60. package/dist/loupe/index.js +547 -141
  61. package/dist/loupe/index.js.map +1 -1
  62. package/dist/overview/index.js +5 -5
  63. package/dist/passthrough/weasel-ui.js +3 -3
  64. package/dist/primitives/index.d.ts +4 -19
  65. package/dist/primitives/index.js +7 -7
  66. package/dist/state/index.d.ts +5 -7
  67. package/dist/state/index.js +5 -5
  68. package/dist/styles.css +173 -38
  69. package/dist/surface/index.d.ts +20 -1
  70. package/dist/surface/index.js +2 -2
  71. package/dist/undo/index.d.ts +18 -7
  72. package/dist/undo/index.js +1 -1
  73. package/package.json +11 -11
  74. package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
  75. package/src/annotations/AnnotationOverlay.tsx +10 -1
  76. package/src/annotations/Annotations.less +2 -0
  77. package/src/annotations/Annotations.overlay.test.tsx +29 -0
  78. package/src/canvas/CanvasStack.test.tsx +35 -0
  79. package/src/canvas/CanvasStack.tsx +12 -1
  80. package/src/canvas/useLayerScheduler.ts +14 -0
  81. package/src/chrome/builtins.test.ts +14 -0
  82. package/src/chrome/builtins.tsx +17 -2
  83. package/src/chrome/regions/StatusRegion.tsx +20 -14
  84. package/src/chrome/regions/regions.test.tsx +28 -0
  85. package/src/chrome/types.ts +8 -0
  86. package/src/clock/clock.browser.test.less +5 -0
  87. package/src/clock/clock.browser.test.tsx +77 -0
  88. package/src/clock/clock.integration.test.tsx +95 -0
  89. package/src/clock/clockRegistry.test.ts +33 -0
  90. package/src/clock/clockRegistry.ts +46 -0
  91. package/src/clock/hooks.ts +44 -0
  92. package/src/clock/index.ts +2 -0
  93. package/src/clock/trialClock.test.ts +265 -0
  94. package/src/clock/trialClock.ts +245 -0
  95. package/src/clock/useClockLoop.ts +45 -0
  96. package/src/config/resolve.test.ts +12 -0
  97. package/src/config/resolve.ts +5 -0
  98. package/src/controls/ControlMatrix.tsx +56 -54
  99. package/src/index.ts +3 -2
  100. package/src/instrument/types.ts +21 -1
  101. package/src/lab/Lab.chrome.test.tsx +8 -1
  102. package/src/lab/Lab.presentation.test.tsx +113 -0
  103. package/src/lab/Lab.surface.test.tsx +20 -9
  104. package/src/lab/Lab.tsx +132 -257
  105. package/src/lab/LabLightbox.browser.test.less +17 -0
  106. package/src/lab/LabLightbox.browser.test.tsx +216 -0
  107. package/src/lab/LabShell.less +10 -3
  108. package/src/lab/LabSurface.tsx +129 -0
  109. package/src/lab/Workspace.stories.tsx +18 -0
  110. package/src/lab/Workspace.surface.test.tsx +4 -0
  111. package/src/lab/Workspace.test.tsx +37 -1
  112. package/src/lab/Workspace.tsx +34 -6
  113. package/src/lab/index.ts +2 -0
  114. package/src/lab/nebula.ts +30 -0
  115. package/src/lab/openLab.test.ts +81 -0
  116. package/src/lab/openLab.ts +119 -0
  117. package/src/lab/presentation.browser.test.less +5 -0
  118. package/src/lab/presentation.browser.test.tsx +119 -0
  119. package/src/lab/presentation.less +64 -0
  120. package/src/lab/presentation.tsx +47 -0
  121. package/src/lab/useFocusPick.ts +43 -0
  122. package/src/lightbox/Lightbox.browser.test.less +14 -0
  123. package/src/lightbox/Lightbox.browser.test.tsx +113 -0
  124. package/src/lightbox/Lightbox.less +128 -0
  125. package/src/lightbox/Lightbox.test.tsx +195 -0
  126. package/src/lightbox/Lightbox.tsx +205 -0
  127. package/src/lightbox/LightboxContext.ts +38 -0
  128. package/src/lightbox/LightboxLayers.tsx +40 -0
  129. package/src/lightbox/index.ts +8 -0
  130. package/src/lightbox/modal.ts +94 -0
  131. package/src/loupe/AGENTS.md +40 -2
  132. package/src/loupe/CanvasLoupe.tsx +5 -4
  133. package/src/loupe/DomLoupe.tsx +7 -5
  134. package/src/loupe/Loupe.less +15 -0
  135. package/src/loupe/LoupeBubble.tsx +35 -13
  136. package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
  137. package/src/loupe/SourceLoupe.tsx +60 -0
  138. package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
  139. package/src/loupe/TrialLoupe.tsx +126 -28
  140. package/src/loupe/canvasLens.ts +20 -17
  141. package/src/loupe/index.ts +26 -2
  142. package/src/loupe/sourceLens.test.ts +65 -0
  143. package/src/loupe/sourceLens.ts +97 -0
  144. package/src/loupe/types.test.ts +6 -0
  145. package/src/loupe/types.ts +109 -7
  146. package/src/loupe/useLoupe.test.tsx +188 -3
  147. package/src/loupe/useLoupe.ts +106 -11
  148. package/src/primitives/index.ts +2 -2
  149. package/src/specimen/Specimen.tsx +0 -7
  150. package/src/state/helpers.test.ts +4 -11
  151. package/src/state/helpers.ts +2 -8
  152. package/src/state/index.ts +0 -2
  153. package/src/state/openLabStore.test.ts +10 -8
  154. package/src/state/openLabStore.ts +1 -1
  155. package/src/state/store.test.ts +2 -2
  156. package/src/state/store.ts +15 -16
  157. package/src/state/types.ts +9 -9
  158. package/src/styles.less +2 -1
  159. package/src/surface/AGENTS.md +13 -0
  160. package/src/surface/index.ts +1 -0
  161. package/src/surface/useSurfaceTile.test.tsx +4 -0
  162. package/src/surface/useSurfaceTile.ts +17 -1
  163. package/src/surface/useTiledSurface.test.tsx +65 -0
  164. package/src/surface/useTiledSurface.ts +68 -1
  165. package/src/trial/Trial.stories.tsx +0 -1
  166. package/src/trial/Trial.test.tsx +40 -3
  167. package/src/trial/Trial.tsx +87 -43
  168. package/src/trial/TrialChrome.tsx +20 -2
  169. package/src/trial/trialOps.test.ts +1 -2
  170. package/src/trial/trialOps.ts +2 -3
  171. package/src/undo/stateOp.ts +21 -0
  172. package/src/undo/undoStack.ts +18 -6
  173. package/dist/chunk-3L4MLLQB.js.map +0 -1
  174. package/dist/chunk-7KAETQSS.js.map +0 -1
  175. package/dist/chunk-EESJUSYR.js.map +0 -1
  176. package/dist/chunk-J5HJJDLF.js.map +0 -1
  177. package/dist/chunk-NVKBL7ZE.js.map +0 -1
  178. package/dist/chunk-PMLZP7RT.js.map +0 -1
  179. package/dist/chunk-RL2LOLNI.js.map +0 -1
  180. package/dist/chunk-T6DLGEJB.js.map +0 -1
  181. package/dist/chunk-WIG6XHJ7.js.map +0 -1
  182. package/dist/chunk-WURZTJGQ.js.map +0 -1
  183. package/dist/chunk-XXACAPVU.js.map +0 -1
  184. package/dist/chunk-YDE6XATS.js.map +0 -1
  185. package/dist/chunk-YX75W6EK.js.map +0 -1
  186. package/src/primitives/StatusBar.less +0 -34
  187. package/src/primitives/StatusBar.stories.tsx +0 -24
  188. package/src/primitives/StatusBar.test.tsx +0 -25
  189. package/src/primitives/StatusBar.tsx +0 -28
@@ -0,0 +1,342 @@
1
+ import '@weasel-js/theme/tokens.css';
2
+ import '../styles.less';
3
+ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import { afterEach, expect, test } from 'vitest';
6
+ import { SurfaceContext } from '../surface/SurfaceContext';
7
+ import { useTiledSurface } from '../surface/useTiledSurface';
8
+ import { TrialLoupe } from './TrialLoupe';
9
+ import type { LoupeOptions } from './types';
10
+
11
+ // The lens has to open where the pointer already is, however it comes up:
12
+ // the peek key held over a still pointer, or the lens turned on under one.
13
+ // Its position and what it draws are layout and pixels, which is why this is
14
+ // a browser test.
15
+
16
+ afterEach(cleanup);
17
+
18
+ const CSS_W = 100;
19
+ const CSS_H = 50;
20
+ const DIAMETER = 40;
21
+ /** Four vertical bands, each a quarter of the canvas wide. */
22
+ const BANDS: Array<[number, number, number]> = [
23
+ [255, 0, 0],
24
+ [0, 255, 0],
25
+ [0, 0, 255],
26
+ [255, 255, 255],
27
+ ];
28
+
29
+ /** A WebGL2 scene whose buffer is preserved, so the lens reads it with no
30
+ * capture — the shape of astv's 3D stage. */
31
+ function SourceScene({
32
+ enabled,
33
+ lab,
34
+ onColorChange,
35
+ shape,
36
+ place,
37
+ }: {
38
+ enabled?: boolean;
39
+ /** Mount inside `.lk-root`, under the lab's own resets. */
40
+ lab?: boolean;
41
+ onColorChange?: (hex: string) => void;
42
+ shape?: LoupeOptions['shape'];
43
+ place?: LoupeOptions['place'];
44
+ }) {
45
+ const ref = useRef<HTMLCanvasElement | null>(null);
46
+ const [gl, setGl] = useState<WebGL2RenderingContext | null>(null);
47
+ useEffect(() => {
48
+ const ctx = ref.current?.getContext('webgl2', {
49
+ preserveDrawingBuffer: true,
50
+ antialias: false,
51
+ });
52
+ if (!ctx) throw new Error('no WebGL2');
53
+ const w = ctx.drawingBufferWidth / BANDS.length;
54
+ ctx.enable(ctx.SCISSOR_TEST);
55
+ BANDS.forEach(([r, g, b], i) => {
56
+ ctx.scissor(i * w, 0, w, ctx.drawingBufferHeight);
57
+ ctx.clearColor(r / 255, g / 255, b / 255, 1);
58
+ ctx.clear(ctx.COLOR_BUFFER_BIT);
59
+ });
60
+ setGl(ctx);
61
+ }, []);
62
+ return (
63
+ <div className={lab ? 'lk-root' : undefined} style={{ width: CSS_W, height: CSS_H }}>
64
+ <TrialLoupe
65
+ enabled={enabled}
66
+ source={gl ?? undefined}
67
+ factor={4}
68
+ diameter={DIAMETER}
69
+ shape={shape}
70
+ place={place}
71
+ onColorChange={onColorChange}
72
+ >
73
+ <canvas
74
+ ref={ref}
75
+ width={CSS_W * 2}
76
+ height={CSS_H * 2}
77
+ style={{ display: 'block', width: CSS_W, height: CSS_H }}
78
+ />
79
+ </TrialLoupe>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ /** DOM content with a `render` lens, which draws it again magnified. */
85
+ function RenderScene({ enabled, lab }: { enabled?: boolean; lab?: boolean }) {
86
+ return (
87
+ <div className={lab ? 'lk-root' : undefined} style={{ width: CSS_W, height: CSS_H }}>
88
+ <TrialLoupe enabled={enabled} factor={4} diameter={DIAMETER} render={() => <div />}>
89
+ <div style={{ width: CSS_W, height: CSS_H }} />
90
+ </TrialLoupe>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ const frames = (n: number): Promise<void> =>
96
+ new Promise((resolve) => {
97
+ const step = (k: number): void => {
98
+ if (k === 0) resolve();
99
+ else requestAnimationFrame(() => step(k - 1));
100
+ };
101
+ step(n);
102
+ });
103
+
104
+ function host(container: HTMLElement): HTMLElement {
105
+ const h = container.querySelector<HTMLElement>('.lk-loupe-host');
106
+ if (!h) throw new Error('no host');
107
+ return h;
108
+ }
109
+
110
+ function move(container: HTMLElement, x: number, y: number): void {
111
+ const h = host(container);
112
+ const r = h.getBoundingClientRect();
113
+ fireEvent.pointerMove(h, { clientX: r.left + x, clientY: r.top + y });
114
+ }
115
+
116
+ function peek(down: boolean): void {
117
+ act(() => {
118
+ if (down) fireEvent.keyDown(window, { key: 'Alt' });
119
+ else fireEvent.keyUp(window, { key: 'Alt' });
120
+ });
121
+ }
122
+
123
+ /** The middle of the lens's inner box — inside its ring, where the painters
124
+ * put the aimed point — in the host's CSS px, read from layout and rounded to
125
+ * the nearest half pixel. */
126
+ function lensCenter(container: HTMLElement): { x: number; y: number } | null {
127
+ const lens = container.querySelector<HTMLElement>('.lk-loupe');
128
+ if (!lens) return null;
129
+ const l = lens.getBoundingClientRect();
130
+ const h = host(container).getBoundingClientRect();
131
+ const half = (n: number): number => Math.round(n * 2) / 2;
132
+ return {
133
+ x: half(l.left + lens.clientLeft + lens.clientWidth / 2 - h.left),
134
+ y: half(l.top + lens.clientTop + lens.clientHeight / 2 - h.top),
135
+ };
136
+ }
137
+
138
+ /** The pixel at the lens's own center. */
139
+ function lensMiddle(container: HTMLElement): number[] {
140
+ const lens = container.querySelector<HTMLCanvasElement>('.lk-loupe__canvas');
141
+ if (!lens) throw new Error('no lens canvas');
142
+ const ctx = lens.getContext('2d');
143
+ if (!ctx) throw new Error('no 2d');
144
+ return [...ctx.getImageData(lens.width / 2, lens.height / 2, 1, 1).data];
145
+ }
146
+
147
+ test('a source lens peeked over a still pointer opens under it', async () => {
148
+ const colors: string[] = [];
149
+ const { container } = render(
150
+ <SourceScene enabled={false} onColorChange={(c) => colors.push(c)} />,
151
+ );
152
+ await frames(2);
153
+ // Inside the blue band, then the peek key with no move after it.
154
+ move(container, 62, 25);
155
+ expect(lensCenter(container)).toBeNull();
156
+ peek(true);
157
+ await waitFor(() => expect(lensCenter(container)).toEqual({ x: 62, y: 25 }));
158
+ await waitFor(() => expect(lensMiddle(container)).toEqual([0, 0, 255, 255]));
159
+ expect(colors.at(-1)).toBe('#0000ff');
160
+ peek(false);
161
+ expect(lensCenter(container)).toBeNull();
162
+ });
163
+
164
+ test('a source lens turned on under a still pointer opens under it', async () => {
165
+ const { container, rerender } = render(<SourceScene enabled={false} />);
166
+ await frames(2);
167
+ move(container, 30, 25);
168
+ rerender(<SourceScene enabled />);
169
+ await waitFor(() => expect(lensCenter(container)).toEqual({ x: 30, y: 25 }));
170
+ await waitFor(() => expect(lensMiddle(container)).toEqual([0, 255, 0, 255]));
171
+ });
172
+
173
+ test('a peek before the pointer ever reaches the host shows nothing until it does', async () => {
174
+ const { container } = render(<SourceScene enabled={false} />);
175
+ await frames(2);
176
+ peek(true);
177
+ await frames(2);
178
+ expect(lensCenter(container)).toBeNull();
179
+ move(container, 88, 25);
180
+ await waitFor(() => expect(lensCenter(container)).toEqual({ x: 88, y: 25 }));
181
+ await waitFor(() => expect(lensMiddle(container)).toEqual([255, 255, 255, 255]));
182
+ });
183
+
184
+ test('a render lens peeked over a still pointer opens under it', async () => {
185
+ const { container } = render(<RenderScene enabled={false} />);
186
+ await frames(2);
187
+ move(container, 70, 20);
188
+ peek(true);
189
+ await waitFor(() => expect(lensCenter(container)).toEqual({ x: 70, y: 20 }));
190
+ });
191
+
192
+ // Where the magnified point lands on the page, against the pointer. The ring
193
+ // is drawn outside the box the painters fill, so a lens positioned by its
194
+ // outer edge puts the aim a border-width off; and under `.lk-root`, whose
195
+ // resets make every box `border-box`, the ring also eats into the painters'
196
+ // box. Both contexts, since they fail differently.
197
+ for (const lab of [false, true]) {
198
+ const where = lab ? 'inside a lab' : 'outside a lab';
199
+
200
+ test(`a source lens's magnified point sits under the pointer, ${where}`, async () => {
201
+ const { container } = render(<SourceScene enabled lab={lab} />);
202
+ await frames(2);
203
+ move(container, 62, 25);
204
+ await waitFor(() => expect(container.querySelector('.lk-loupe__canvas')).not.toBeNull());
205
+ const canvas = container.querySelector<HTMLElement>('.lk-loupe__canvas');
206
+ const c = canvas?.getBoundingClientRect() as DOMRect;
207
+ const h = host(container).getBoundingClientRect();
208
+ // The canvas is drawn for exactly the diameter, aim in its middle.
209
+ expect(c.width).toBeCloseTo(DIAMETER, 1);
210
+ expect(c.height).toBeCloseTo(DIAMETER, 1);
211
+ expect(Math.abs(c.left + c.width / 2 - (h.left + 62))).toBeLessThanOrEqual(0.5);
212
+ expect(Math.abs(c.top + c.height / 2 - (h.top + 25))).toBeLessThanOrEqual(0.5);
213
+ });
214
+
215
+ test(`a render lens's magnified point sits under the pointer, ${where}`, async () => {
216
+ const { container } = render(<RenderScene enabled lab={lab} />);
217
+ await frames(2);
218
+ move(container, 70, 20);
219
+ await waitFor(() => expect(container.querySelector('.lk-loupe__stage')).not.toBeNull());
220
+ const lens = container.querySelector<HTMLElement>('.lk-loupe') as HTMLElement;
221
+ const stage = container.querySelector<HTMLElement>('.lk-loupe__stage') as HTMLElement;
222
+ const s = stage.getBoundingClientRect();
223
+ const h = host(container).getBoundingClientRect();
224
+ // The stage reproduces the host and zooms about the aim, so the aim sits
225
+ // at its own offset into the stage: under the pointer when the stage
226
+ // lines up with the host.
227
+ expect(lens.clientWidth).toBe(DIAMETER);
228
+ expect(lens.clientHeight).toBe(DIAMETER);
229
+ expect(Math.abs(s.left - h.left)).toBeLessThanOrEqual(0.5);
230
+ expect(Math.abs(s.top - h.top)).toBeLessThanOrEqual(0.5);
231
+ });
232
+ }
233
+
234
+ test('a placed square lens is drawn at its center, shows the point it names, and reports the aim', async () => {
235
+ const colors: string[] = [];
236
+ // Drawn in the host's middle, showing the middle of the blue band (50–75)
237
+ // at 2x: 30 page px across, so its edges reach the green and white bands.
238
+ const place = () => ({
239
+ center: { x: 50, y: 25 },
240
+ shows: { x: 62, y: 25 },
241
+ width: 60,
242
+ height: 20,
243
+ factor: 2,
244
+ });
245
+ const { container } = render(
246
+ <SourceScene enabled shape="square" place={place} onColorChange={(c) => colors.push(c)} />,
247
+ );
248
+ await frames(2);
249
+ move(container, 30, 25);
250
+ await waitFor(() => expect(container.querySelector('.lk-loupe__canvas')).not.toBeNull());
251
+ const canvas = container.querySelector<HTMLCanvasElement>(
252
+ '.lk-loupe__canvas',
253
+ ) as HTMLCanvasElement;
254
+ const c = canvas.getBoundingClientRect();
255
+ const h = host(container).getBoundingClientRect();
256
+ expect(c.width).toBeCloseTo(60, 1);
257
+ expect(c.height).toBeCloseTo(20, 1);
258
+ expect(Math.abs(c.left + c.width / 2 - (h.left + 50))).toBeLessThanOrEqual(0.5);
259
+ expect(Math.abs(c.top + c.height / 2 - (h.top + 25))).toBeLessThanOrEqual(0.5);
260
+
261
+ const lens = container.querySelector<HTMLElement>('.lk-loupe') as HTMLElement;
262
+ expect(getComputedStyle(lens).borderTopLeftRadius).not.toBe('50%');
263
+
264
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
265
+ const at = (x: number): number[] => [...ctx.getImageData(x, canvas.height / 2, 1, 1).data];
266
+ await waitFor(() => expect(lensMiddle(container)).toEqual([0, 0, 255, 255]));
267
+ expect(at(2)).toEqual([0, 255, 0, 255]);
268
+ expect(at(canvas.width - 3)).toEqual([255, 255, 255, 255]);
269
+ // The pointer is over the green band, and that is the color reported.
270
+ await waitFor(() => expect(colors.at(-1)).toBe('#00ff00'));
271
+ });
272
+
273
+ test('a hollow lens draws only its outline, and tells the host where it is', async () => {
274
+ const lenses: unknown[] = [];
275
+ const { container } = render(
276
+ <div style={{ width: CSS_W, height: CSS_H }}>
277
+ <TrialLoupe
278
+ enabled
279
+ hollow
280
+ shape="square"
281
+ factor={4}
282
+ diameter={DIAMETER}
283
+ onLens={(l) => lenses.push(l)}
284
+ >
285
+ <div style={{ width: CSS_W, height: CSS_H }} />
286
+ </TrialLoupe>
287
+ </div>,
288
+ );
289
+ await frames(2);
290
+ move(container, 30, 25);
291
+ await waitFor(() => expect(lensCenter(container)).toEqual({ x: 30, y: 25 }));
292
+ const lens = container.querySelector<HTMLElement>('.lk-loupe') as HTMLElement;
293
+ expect(lens.children).toHaveLength(0);
294
+ expect(getComputedStyle(lens).backgroundColor).toBe('rgba(0, 0, 0, 0)');
295
+ expect(getComputedStyle(lens).borderTopStyle).not.toBe('none');
296
+ expect(lenses.at(-1)).toEqual({
297
+ center: { x: 30, y: 25 },
298
+ shows: { x: 30, y: 25 },
299
+ width: DIAMETER,
300
+ height: DIAMETER,
301
+ factor: 4,
302
+ shape: 'square',
303
+ });
304
+ });
305
+
306
+ /** Four tiles side by side, each a quarter of the host wide, under a hollow lens. */
307
+ function TiledHollow({ dirtied }: { dirtied: string[][] }) {
308
+ const surface = useTiledSurface({ onFrame: (f) => dirtied.push([...f.dirty].sort()) });
309
+ return (
310
+ <SurfaceContext.Provider value={surface}>
311
+ <TrialLoupe enabled hollow diameter={20}>
312
+ <div ref={surface.containerRef} style={{ display: 'flex', width: CSS_W, height: CSS_H }}>
313
+ {['t0', 't1', 't2', 't3'].map((id) => (
314
+ <div key={id} ref={(el) => surface.registerTile(id, el)} style={{ width: CSS_W / 4 }} />
315
+ ))}
316
+ </div>
317
+ </TrialLoupe>
318
+ </SurfaceContext.Provider>
319
+ );
320
+ }
321
+
322
+ test('a hollow lens on a surface dirties the tiles it leaves and lands on, and no others', async () => {
323
+ const dirtied: string[][] = [];
324
+ const { container } = render(<TiledHollow dirtied={dirtied} />);
325
+ await frames(3);
326
+ dirtied.length = 0;
327
+
328
+ // A 20px lens at x 15 spans 5..25: inside t0 alone.
329
+ move(container, 15, 25);
330
+ await waitFor(() => expect(dirtied).toEqual([['t0']]));
331
+ dirtied.length = 0;
332
+
333
+ // At x 40 it spans 30..50: t1, and t0 for the box it left.
334
+ move(container, 40, 25);
335
+ await waitFor(() => expect(dirtied).toEqual([['t0', 't1']]));
336
+ dirtied.length = 0;
337
+
338
+ act(() => {
339
+ fireEvent.pointerLeave(host(container));
340
+ });
341
+ await waitFor(() => expect(dirtied).toEqual([['t1']]));
342
+ });
@@ -1,15 +1,28 @@
1
- import { type ReactNode, type RefObject, useContext, useEffect, useMemo, useRef } from 'react';
1
+ import { useLatest } from '@weasel-js/core';
2
+ import {
3
+ type ReactNode,
4
+ type RefObject,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ } from 'react';
2
11
  import { CameraContext, CameraScope } from '../canvas/CameraInput';
3
12
  import { CanvasStackContext } from '../canvas/CanvasStackContext';
4
13
  import { fromCameraView } from '../canvas/cameraView';
5
14
  import type { ViewTransform } from '../instrument/types';
15
+ import type { Box } from '../surface/rect';
16
+ import { useSurfaceOptional } from '../surface/useSurfaceTile';
6
17
  import { LoupeSwitchContext } from '../trial/loupeSwitch';
7
18
  import { CanvasLoupe } from './CanvasLoupe';
8
19
  import { sampleStack } from './canvasLens';
9
20
  import { DomLoupe } from './DomLoupe';
10
21
  import { LoupeBubble } from './LoupeBubble';
11
22
  import { LoupeGestures } from './LoupeGestures';
12
- import { type LoupeOptions, resolveLoupe } from './types';
23
+ import { SourceLoupe } from './SourceLoupe';
24
+ import { resolveLoupeSource, sampleSource, sourceBoxIn } from './sourceLens';
25
+ import { type LoupeLens, type LoupeOptions, resolveLoupe } from './types';
13
26
  import { useHostSize } from './useHostSize';
14
27
  import { useLoupe } from './useLoupe';
15
28
 
@@ -38,7 +51,7 @@ const IDENTITY: ViewTransform = { zoom: 1, pan: { x: 0, y: 0 } };
38
51
  *
39
52
  * With no `render` inside a `<CanvasStack>`, it re-draws the stack's own
40
53
  * layers through a zoomed camera. With `render`, it asks for the content again
41
- * at a magnified camera.
54
+ * at a magnified camera. With `source`, it enlarges the pixels of any canvas.
42
55
  */
43
56
  export function TrialLoupe({
44
57
  enabled,
@@ -46,33 +59,23 @@ export function TrialLoupe({
46
59
  hostRef,
47
60
  children,
48
61
  render,
62
+ source,
49
63
  factor,
50
64
  minFactor,
51
65
  maxFactor,
52
66
  mode,
53
67
  diameter,
68
+ shape,
69
+ place,
70
+ hollow,
71
+ onLens,
54
72
  peekKey,
55
73
  onColorChange,
56
74
  }: TrialLoupeProps) {
57
- const options = useMemo(
58
- () =>
59
- resolveLoupe({
60
- render,
61
- factor,
62
- minFactor,
63
- maxFactor,
64
- mode,
65
- diameter,
66
- peekKey,
67
- onColorChange,
68
- }),
69
- [render, factor, minFactor, maxFactor, mode, diameter, peekKey, onColorChange],
70
- );
71
-
72
75
  const stack = useContext(CanvasStackContext);
73
76
  const camera = useContext(CameraContext);
74
77
  const loupeSwitch = useContext(LoupeSwitchContext);
75
- const surface = options.render ? undefined : stack?.surface;
78
+ const surface = render || source || hollow ? undefined : stack?.surface;
76
79
 
77
80
  const ownHost = useRef<HTMLDivElement | null>(null);
78
81
  const cameraHost = useMemo<RefObject<HTMLElement | null> | null>(
@@ -88,25 +91,110 @@ export function TrialLoupe({
88
91
  );
89
92
  const host = surface?.element ?? hostRef ?? cameraHost ?? ownHost;
90
93
  const wraps = host === ownHost;
94
+ const hostLatest = useLatest(host);
95
+
96
+ // A hollow lens is drawn by the host into the tiles under it, so on a shared surface each
97
+ // move dirties the tiles it left and the ones it landed on.
98
+ const tiles = useSurfaceOptional();
99
+ const onLensLatest = useLatest(onLens);
100
+ const was = useRef<Box | null>(null);
101
+ const damage = hollow ? tiles : null;
102
+ const reportLens = useCallback(
103
+ (lens: LoupeLens | null) => {
104
+ onLensLatest.current?.(lens);
105
+ if (!damage) return;
106
+ const at = hostLatest.current.current?.getBoundingClientRect();
107
+ const now =
108
+ lens && at
109
+ ? {
110
+ left: at.left + lens.center.x - lens.width / 2,
111
+ top: at.top + lens.center.y - lens.height / 2,
112
+ width: lens.width,
113
+ height: lens.height,
114
+ }
115
+ : null;
116
+ if (was.current) damage.invalidateBox(was.current);
117
+ if (now) damage.invalidateBox(now);
118
+ was.current = now;
119
+ },
120
+ [damage, onLensLatest, hostLatest],
121
+ );
122
+ const options = useMemo(
123
+ () =>
124
+ resolveLoupe({
125
+ render,
126
+ source,
127
+ factor,
128
+ minFactor,
129
+ maxFactor,
130
+ mode,
131
+ diameter,
132
+ shape,
133
+ place,
134
+ hollow,
135
+ onLens: onLens || damage ? reportLens : undefined,
136
+ peekKey,
137
+ onColorChange,
138
+ }),
139
+ [
140
+ render,
141
+ source,
142
+ factor,
143
+ minFactor,
144
+ maxFactor,
145
+ mode,
146
+ diameter,
147
+ shape,
148
+ place,
149
+ hollow,
150
+ onLens,
151
+ damage,
152
+ reportLens,
153
+ peekKey,
154
+ onColorChange,
155
+ ],
156
+ );
91
157
 
92
158
  // A lens told whether it is on has no use for the trial's toggle.
93
159
  const mount = enabled === undefined ? loupeSwitch?.mount : undefined;
94
160
  useEffect(() => mount?.(), [mount]);
95
161
 
96
162
  const sample = useMemo(() => {
163
+ if (options.source) {
164
+ const input = options.source;
165
+ return (p: { x: number; y: number }): string | null => {
166
+ const src = resolveLoupeSource(input);
167
+ return src ? sampleSource(src, p, sourceBoxIn(src, host.current)) : null;
168
+ };
169
+ }
97
170
  if (!surface) return undefined;
98
171
  return (p: { x: number; y: number }): string | null => {
99
172
  const canvases = surface.canvases.current;
100
173
  return canvases ? sampleStack(surface.layers, canvases, p, surface.size.dpr) : null;
101
174
  };
102
- }, [surface]);
175
+ }, [surface, options.source, host]);
176
+
177
+ // Keyed on the resolved source, not the prop: an inline getter is re-made
178
+ // every render, and every aim renders.
179
+ const resolved = resolveLoupeSource(options.source);
180
+ const subscribeResample = useMemo(
181
+ () => (resolved ? (fn: () => void) => resolved.subscribeFrame(fn) : undefined),
182
+ [resolved],
183
+ );
103
184
 
104
185
  const loupe = useLoupe({
105
186
  options,
106
187
  hostRef: host,
107
188
  enabled: enabled ?? loupeSwitch?.on ?? false,
108
189
  sample,
190
+ subscribeResample,
109
191
  });
192
+ // A captured source copies frames only while someone is reading them. Taken
193
+ // after `useLoupe` has subscribed, so the frame a first reader's redraw
194
+ // captures reaches the color too.
195
+ const reading = loupe.visible ? resolved : null;
196
+ useEffect(() => reading?.retain(), [reading]);
197
+
110
198
  const measured = useHostSize(host);
111
199
  const size = surface?.size ?? measured;
112
200
 
@@ -120,14 +208,16 @@ export function TrialLoupe({
120
208
  </CameraScope>
121
209
  );
122
210
 
211
+ const { center, shows, width, height, factor: shownFactor, shape: lensShape } = loupe.lens;
212
+ const box = { width, height };
123
213
  const lens = loupe.visible ? (
124
- <LoupeBubble aim={loupe.aim} diameter={options.diameter}>
125
- {options.render ? (
214
+ <LoupeBubble aim={center} diameter={box} shape={lensShape} hollow={options.hollow}>
215
+ {options.hollow ? null : options.render ? (
126
216
  <DomLoupe
127
- aim={loupe.aim}
128
- factor={loupe.factor}
217
+ aim={shows}
218
+ factor={shownFactor}
129
219
  mode={loupe.mode}
130
- diameter={options.diameter}
220
+ diameter={box}
131
221
  size={size}
132
222
  view={
133
223
  view ??
@@ -137,12 +227,20 @@ export function TrialLoupe({
137
227
  frame={stack?.frame ?? camera?.frame}
138
228
  render={options.render}
139
229
  />
230
+ ) : options.source ? (
231
+ <SourceLoupe
232
+ aim={shows}
233
+ factor={shownFactor}
234
+ diameter={box}
235
+ source={options.source}
236
+ hostRef={host}
237
+ />
140
238
  ) : surface && stack ? (
141
239
  <CanvasLoupe
142
- aim={loupe.aim}
143
- factor={loupe.factor}
240
+ aim={shows}
241
+ factor={shownFactor}
144
242
  mode={loupe.mode}
145
- diameter={options.diameter}
243
+ diameter={box}
146
244
  surface={surface}
147
245
  view={stack.view}
148
246
  frame={stack.frame}
@@ -1,11 +1,11 @@
1
- import type { LoupePoint } from '@weasel-js/loupe';
1
+ import { type LoupePoint, type LoupeSize, loupeExtent } from '@weasel-js/loupe';
2
2
  import { centerOn } from '../canvas/camera';
3
3
  import { screenToWorld } from '../canvas/canvasCoords';
4
4
  import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
5
5
  import { DEFAULT_FRAME, resolveFrame, type WorldFrame, type WorldSpec } from '../canvas/worldSpec';
6
6
  import type { ViewTransform } from '../instrument/types';
7
7
 
8
- /** The camera a round lens of `diameter` shows a magnified region through, and
8
+ /** The camera a lens of `size` shows a magnified region through, and
9
9
  * the coordinate system it is read in — the instrument's own `WorldSpec`,
10
10
  * resolved against the lens rather than the stack. */
11
11
  export interface LensCamera {
@@ -24,13 +24,13 @@ export function lensCamera(
24
24
  outer: ViewTransform,
25
25
  outerFrame: WorldFrame,
26
26
  factor: number,
27
- diameter: number,
27
+ size: LoupeSize,
28
28
  worldSpec?: WorldSpec,
29
29
  ): LensCamera {
30
- const size = { width: diameter, height: diameter };
31
- const frame = resolveFrame(worldSpec, size);
30
+ const extent = loupeExtent(size);
31
+ const frame = resolveFrame(worldSpec, extent);
32
32
  const world = screenToWorld(aim, outer, outerFrame);
33
- return { view: centerOn(world, outer.zoom * factor, size, frame), frame };
33
+ return { view: centerOn(world, outer.zoom * factor, extent, frame), frame };
34
34
  }
35
35
 
36
36
  /** A rectangle in a backing store's own device pixels. */
@@ -42,21 +42,23 @@ export interface SourceRect {
42
42
  }
43
43
 
44
44
  /**
45
- * The region a pixel-mode lens copies out of a presented canvas: the `diameter
46
- * / factor` CSS px around `aim`, in that canvas' backing-store pixels.
45
+ * The region a pixel-mode lens copies out of a presented canvas: `size /
46
+ * factor` CSS px around `aim`, in that canvas' backing-store pixels.
47
47
  */
48
48
  export function lensSourceRect(
49
49
  aim: LoupePoint,
50
50
  factor: number,
51
- diameter: number,
51
+ size: LoupeSize,
52
52
  dpr: number,
53
53
  ): SourceRect {
54
- const span = diameter / factor;
54
+ const { width, height } = loupeExtent(size);
55
+ const spanX = width / factor;
56
+ const spanY = height / factor;
55
57
  return {
56
- sx: (aim.x - span / 2) * dpr,
57
- sy: (aim.y - span / 2) * dpr,
58
- sw: span * dpr,
59
- sh: span * dpr,
58
+ sx: (aim.x - spanX / 2) * dpr,
59
+ sy: (aim.y - spanY / 2) * dpr,
60
+ sw: spanX * dpr,
61
+ sh: spanY * dpr,
60
62
  };
61
63
  }
62
64
 
@@ -95,7 +97,7 @@ export function drawCanvasLens(
95
97
  opts: {
96
98
  aim: LoupePoint;
97
99
  factor: number;
98
- diameter: number;
100
+ diameter: LoupeSize;
99
101
  dpr: number;
100
102
  mode: 'vector' | 'pixel';
101
103
  outer: ViewTransform;
@@ -106,9 +108,10 @@ export function drawCanvasLens(
106
108
  },
107
109
  ): void {
108
110
  const { aim, factor, diameter, dpr, mode, outer, layers, canvases } = opts;
111
+ const { width, height } = loupeExtent(diameter);
109
112
  ctx.save();
110
113
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
111
- ctx.clearRect(0, 0, diameter, diameter);
114
+ ctx.clearRect(0, 0, width, height);
112
115
 
113
116
  if (mode === 'pixel') {
114
117
  const { sx, sy, sw, sh } = lensSourceRect(aim, factor, diameter, dpr);
@@ -117,7 +120,7 @@ export function drawCanvasLens(
117
120
  if (!layer.visible) continue;
118
121
  const canvas = canvases.get(layer.id);
119
122
  if (!canvas || canvas.width === 0 || canvas.height === 0) continue;
120
- ctx.drawImage(canvas, sx, sy, sw, sh, 0, 0, diameter, diameter);
123
+ ctx.drawImage(canvas, sx, sy, sw, sh, 0, 0, width, height);
121
124
  }
122
125
  ctx.restore();
123
126
  return;