@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
@@ -1,4 +1,4 @@
1
- import type { LoupeMode, LoupePoint } from '@weasel-js/loupe';
1
+ import { type LoupeMode, type LoupePoint, type LoupeSize, loupeExtent } from '@weasel-js/loupe';
2
2
  import type { CSSProperties } from 'react';
3
3
  import { zoomAt } from '../canvas/camera';
4
4
  import type { ViewportSize, WorldFrame } from '../canvas/worldSpec';
@@ -10,7 +10,8 @@ export interface DomLoupeProps {
10
10
  aim: LoupePoint;
11
11
  factor: number;
12
12
  mode: LoupeMode;
13
- diameter: number;
13
+ /** One number for a round or square lens, or a width and a height. */
14
+ diameter: LoupeSize;
14
15
  /** The host the instrument draws into, which the stage reproduces. */
15
16
  size: ViewportSize;
16
17
  /** The trial's own camera. */
@@ -25,8 +26,8 @@ export interface DomLoupeProps {
25
26
  *
26
27
  * The stage is a full copy of the host, so the instrument's own layout still
27
28
  * holds; the camera is composed about the aimed point, which keeps that point
28
- * where it already was, and the stage is then slid by `diameter / 2 - aim` to
29
- * bring it to the middle of the lens.
29
+ * where it already was, and the stage is then slid by half the lens less `aim`
30
+ * to bring it to the middle of the lens.
30
31
  */
31
32
  export function DomLoupe({
32
33
  aim,
@@ -38,10 +39,11 @@ export function DomLoupe({
38
39
  frame,
39
40
  render,
40
41
  }: DomLoupeProps) {
42
+ const { width, height } = loupeExtent(diameter);
41
43
  const style: CSSProperties = {
42
44
  width: `${size.width}px`,
43
45
  height: `${size.height}px`,
44
- transform: `translate(${diameter / 2 - aim.x}px, ${diameter / 2 - aim.y}px)`,
46
+ transform: `translate(${width / 2 - aim.x}px, ${height / 2 - aim.y}px)`,
45
47
  };
46
48
  return (
47
49
  <div className="lk-loupe__stage" style={style}>
@@ -2,6 +2,12 @@
2
2
  position: absolute;
3
3
  top: 0;
4
4
  left: 0;
5
+ // The ring sits outside the box the painters fill, centered on the aim;
6
+ // border-box here too, so `.lk-root`'s resets cannot squeeze that box.
7
+ box-sizing: border-box;
8
+ width: calc(var(--lk-loupe-width) + 2 * var(--wzl-border-w));
9
+ height: calc(var(--lk-loupe-height) + 2 * var(--wzl-border-w));
10
+ margin: calc(-1 * var(--wzl-border-w));
5
11
  border-radius: 50%;
6
12
  overflow: hidden;
7
13
  // Nothing under the lens stops working while it is up: the pan, the wheel and
@@ -15,6 +21,15 @@
15
21
  background: var(--wzl-surface-raised);
16
22
  }
17
23
 
24
+ .lk-loupe--square {
25
+ border-radius: var(--wzl-radius-sm);
26
+ }
27
+
28
+ // The host draws the magnified view into this box itself.
29
+ .lk-loupe--hollow {
30
+ background: transparent;
31
+ }
32
+
18
33
  // `.lk-canvas-stack__overlay > *` turns pointer events back on for an
19
34
  // instrument's own overlay; the lens is the one child that must stay inert.
20
35
  .lk-canvas-stack__overlay > .lk-loupe {
@@ -1,31 +1,53 @@
1
- import type { LoupePoint } from '@weasel-js/loupe';
1
+ import { type LoupePoint, type LoupeSize, loupeExtent } from '@weasel-js/loupe';
2
2
  import type { CSSProperties, ReactNode, RefObject } from 'react';
3
+ import type { LoupeShape } from './types';
3
4
 
4
5
  /** Props for `<LoupeBubble>`. */
5
6
  export interface LoupeBubbleProps {
6
- /** Where the lens is aimed, in its container's own pixels. */
7
+ /** Where the lens is centered, in its container's own pixels. */
7
8
  aim: LoupePoint;
8
- diameter: number;
9
+ /** One number for a round or square lens, or a width and a height. */
10
+ diameter: LoupeSize;
11
+ /** Default `'circle'`; on a box that is not square, `'circle'` is an ellipse. */
12
+ shape?: LoupeShape;
13
+ /** Only the ring and shadow: the inside is left clear for what is under it. */
14
+ hollow?: boolean;
9
15
  hostRef?: RefObject<HTMLDivElement | null>;
10
- children: ReactNode;
16
+ children?: ReactNode;
11
17
  }
12
18
 
13
19
  /**
14
- * The lens itself: a circle centred on the aimed point, clipping whatever a
15
- * painter draws into it.
20
+ * The lens itself: a circle or a rectangle centered on `aim`, clipping whatever
21
+ * a painter draws into it.
16
22
  *
17
23
  * It takes no pointer events, so the pan, the wheel and anything underneath
18
24
  * keep working while it is up — and it is `aria-hidden`, since it magnifies
19
25
  * content already on the page rather than adding any.
20
26
  */
21
- export function LoupeBubble({ aim, diameter, hostRef, children }: LoupeBubbleProps) {
22
- const style: CSSProperties = {
23
- width: `${diameter}px`,
24
- height: `${diameter}px`,
25
- transform: `translate(${aim.x - diameter / 2}px, ${aim.y - diameter / 2}px)`,
26
- };
27
+ export function LoupeBubble({
28
+ aim,
29
+ diameter,
30
+ shape = 'circle',
31
+ hollow,
32
+ hostRef,
33
+ children,
34
+ }: LoupeBubbleProps) {
35
+ const { width, height } = loupeExtent(diameter);
36
+ const square = shape === 'square';
37
+ const style = {
38
+ '--lk-loupe-width': `${width}px`,
39
+ '--lk-loupe-height': `${height}px`,
40
+ transform: `translate(${aim.x - width / 2}px, ${aim.y - height / 2}px)`,
41
+ } as CSSProperties;
27
42
  return (
28
- <div ref={hostRef} className="lk-loupe" style={style} aria-hidden="true">
43
+ <div
44
+ ref={hostRef}
45
+ className={['lk-loupe', square && 'lk-loupe--square', hollow && 'lk-loupe--hollow']
46
+ .filter(Boolean)
47
+ .join(' ')}
48
+ style={style}
49
+ aria-hidden="true"
50
+ >
29
51
  {children}
30
52
  </div>
31
53
  );
@@ -0,0 +1,204 @@
1
+ import '@weasel-js/theme/tokens.css';
2
+ import '../styles.less';
3
+ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
4
+ import { type CanvasSource, createCanvasSource } from '@weasel-js/loupe';
5
+ import { useEffect, useRef, useState } from 'react';
6
+ import { afterEach, expect, test } from 'vitest';
7
+ import { TrialLoupe } from './TrialLoupe';
8
+
9
+ // A foreign WebGL canvas — one labkit neither created nor draws — read by the
10
+ // lens. Its pixels only exist in the task that drew them, which is what a
11
+ // real browser shows and jsdom cannot.
12
+
13
+ afterEach(cleanup);
14
+
15
+ const CSS_W = 100;
16
+ const CSS_H = 50;
17
+ /** The backing store is twice the CSS box, as on a 2x display. */
18
+ const SCALE = 2;
19
+ /** Four vertical bands, each a quarter of the canvas wide. */
20
+ const BANDS: Array<[number, number, number]> = [
21
+ [255, 0, 0],
22
+ [0, 255, 0],
23
+ [0, 0, 255],
24
+ [255, 255, 255],
25
+ ];
26
+
27
+ function drawBands(gl: WebGLRenderingContext): void {
28
+ const w = gl.drawingBufferWidth / BANDS.length;
29
+ gl.enable(gl.SCISSOR_TEST);
30
+ BANDS.forEach(([r, g, b], i) => {
31
+ gl.scissor(i * w, 0, w, gl.drawingBufferHeight);
32
+ gl.clearColor(r / 255, g / 255, b / 255, 1);
33
+ gl.clear(gl.COLOR_BUFFER_BIT);
34
+ });
35
+ gl.disable(gl.SCISSOR_TEST);
36
+ }
37
+
38
+ interface SceneProps {
39
+ /** Whether the drawing code calls `capture()` after it draws. */
40
+ captures: boolean;
41
+ onColorChange?: (hex: string) => void;
42
+ /** Handed the context, and a way to redraw the whole canvas one color — a
43
+ * frame that changes what is under a lens that has not moved. */
44
+ onReady?: (gl: WebGLRenderingContext, fill: (rgb: [number, number, number]) => void) => void;
45
+ }
46
+
47
+ /** Stands in for an app's own WebGL view: it owns the context, draws on
48
+ * demand, and hands the lens a source. */
49
+ function Scene({ captures, onColorChange, onReady }: SceneProps) {
50
+ const ref = useRef<HTMLCanvasElement | null>(null);
51
+ const [source, setSource] = useState<CanvasSource | null>(null);
52
+ const onReadyRef = useRef(onReady);
53
+
54
+ useEffect(() => {
55
+ const canvas = ref.current;
56
+ const gl = canvas?.getContext('webgl', { preserveDrawingBuffer: false, antialias: false });
57
+ if (!gl) throw new Error('no WebGL');
58
+ let solid: [number, number, number] | null = null;
59
+ const draw = (): void => {
60
+ if (solid) {
61
+ gl.clearColor(solid[0] / 255, solid[1] / 255, solid[2] / 255, 1);
62
+ gl.clear(gl.COLOR_BUFFER_BIT);
63
+ } else drawBands(gl);
64
+ if (captures) s.capture();
65
+ };
66
+ const s = createCanvasSource(gl, { requestRedraw: draw });
67
+ draw();
68
+ setSource(s);
69
+ onReadyRef.current?.(gl, (rgb) => {
70
+ solid = rgb;
71
+ draw();
72
+ });
73
+ }, [captures]);
74
+
75
+ return (
76
+ <div style={{ width: CSS_W, height: CSS_H }}>
77
+ <TrialLoupe
78
+ enabled
79
+ source={source ?? undefined}
80
+ factor={4}
81
+ diameter={40}
82
+ peekKey={null}
83
+ onColorChange={onColorChange}
84
+ >
85
+ <canvas
86
+ ref={ref}
87
+ width={CSS_W * SCALE}
88
+ height={CSS_H * SCALE}
89
+ style={{ display: 'block', width: CSS_W, height: CSS_H }}
90
+ />
91
+ </TrialLoupe>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ const frames = (n: number): Promise<void> =>
97
+ new Promise((resolve) => {
98
+ const step = (k: number): void => {
99
+ if (k === 0) resolve();
100
+ else requestAnimationFrame(() => step(k - 1));
101
+ };
102
+ step(n);
103
+ });
104
+
105
+ function aim(container: HTMLElement, x: number, y: number): void {
106
+ const host = container.querySelector('.lk-loupe-host');
107
+ if (!host) throw new Error('no host');
108
+ const r = host.getBoundingClientRect();
109
+ fireEvent.pointerMove(host, { clientX: r.left + x, clientY: r.top + y });
110
+ }
111
+
112
+ /** The lens pixel at a CSS point inside the lens. */
113
+ function lensPixel(container: HTMLElement, x: number, y: number): number[] {
114
+ const lens = container.querySelector<HTMLCanvasElement>('.lk-loupe__canvas');
115
+ if (!lens) throw new Error('no lens');
116
+ const k = lens.width / 40;
117
+ const ctx = lens.getContext('2d');
118
+ if (!ctx) throw new Error('no 2d');
119
+ return [...ctx.getImageData(Math.floor(x * k), Math.floor(y * k), 1, 1).data];
120
+ }
121
+
122
+ test('a WebGL canvas without preserveDrawingBuffer reads blank after its frame', async () => {
123
+ // The premise the capture exists for. If this ever reads the bands, the
124
+ // browser has started keeping the buffer and the lens would pass for free.
125
+ let gl: WebGLRenderingContext | null = null;
126
+ render(<Scene captures={false} onReady={(g) => (gl = g)} />);
127
+ await frames(3);
128
+ const canvas = (gl as WebGLRenderingContext | null)?.canvas as HTMLCanvasElement;
129
+ const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
130
+ probe?.drawImage(canvas, 10, 10, 1, 1, 0, 0, 1, 1);
131
+ expect([...(probe?.getImageData(0, 0, 1, 1).data ?? [])]).toEqual([0, 0, 0, 0]);
132
+ });
133
+
134
+ test('the lens magnifies the frame a foreign WebGL canvas captured', async () => {
135
+ const { container } = render(<Scene captures />);
136
+ await frames(3);
137
+ // On the boundary between the green and blue bands, at 4x: the lens spans
138
+ // 10 CSS px of the canvas, half of each.
139
+ aim(container, 50, 25);
140
+ await waitFor(() => expect(lensPixel(container, 10, 20)).toEqual([0, 255, 0, 255]));
141
+ expect(lensPixel(container, 30, 20)).toEqual([0, 0, 255, 255]);
142
+
143
+ aim(container, 12, 25);
144
+ await waitFor(() => expect(lensPixel(container, 20, 20)).toEqual([255, 0, 0, 255]));
145
+ });
146
+
147
+ test('without capture the lens has nothing to show', async () => {
148
+ const { container } = render(<Scene captures={false} />);
149
+ await frames(3);
150
+ aim(container, 50, 25);
151
+ await frames(3);
152
+ expect(lensPixel(container, 10, 20)).toEqual([0, 0, 0, 0]);
153
+ });
154
+
155
+ test('reports the captured color on the first aim', async () => {
156
+ const colors: string[] = [];
157
+ const { container } = render(<Scene captures onColorChange={(c) => colors.push(c)} />);
158
+ await frames(3);
159
+ aim(container, 80, 25);
160
+ await waitFor(() => expect(colors.at(-1)).toBe('#ffffff'));
161
+ aim(container, 30, 25);
162
+ await waitFor(() => expect(colors.at(-1)).toBe('#00ff00'));
163
+ });
164
+
165
+ test('a still aim follows the canvas as it redraws', async () => {
166
+ const colors: string[] = [];
167
+ let fill: ((rgb: [number, number, number]) => void) | undefined;
168
+ const { container } = render(
169
+ <Scene captures onColorChange={(c) => colors.push(c)} onReady={(_, f) => (fill = f)} />,
170
+ );
171
+ await frames(3);
172
+ aim(container, 80, 25);
173
+ await waitFor(() => expect(colors.at(-1)).toBe('#ffffff'));
174
+ fill?.([255, 0, 255]);
175
+ await waitFor(() => expect(colors.at(-1)).toBe('#ff00ff'));
176
+ });
177
+
178
+ test('reads a plain 2D canvas directly, with no capture', async () => {
179
+ function Flat() {
180
+ const ref = useRef<HTMLCanvasElement | null>(null);
181
+ useEffect(() => {
182
+ const ctx = ref.current?.getContext('2d');
183
+ if (!ctx) return;
184
+ ctx.fillStyle = '#ff00ff';
185
+ ctx.fillRect(0, 0, CSS_W * SCALE, CSS_H * SCALE);
186
+ }, []);
187
+ return (
188
+ <div style={{ width: CSS_W, height: CSS_H }}>
189
+ <TrialLoupe enabled source={() => ref.current} factor={4} diameter={40} peekKey={null}>
190
+ <canvas
191
+ ref={ref}
192
+ width={CSS_W * SCALE}
193
+ height={CSS_H * SCALE}
194
+ style={{ display: 'block', width: CSS_W, height: CSS_H }}
195
+ />
196
+ </TrialLoupe>
197
+ </div>
198
+ );
199
+ }
200
+ const { container } = render(<Flat />);
201
+ await frames(2);
202
+ aim(container, 50, 25);
203
+ await waitFor(() => expect(lensPixel(container, 20, 20)).toEqual([255, 0, 255, 255]));
204
+ });
@@ -0,0 +1,60 @@
1
+ import { useLatest, useVisibleRaf } from '@weasel-js/core';
2
+ import { type LoupePoint, type LoupeSize, loupeExtent } from '@weasel-js/loupe';
3
+ import { type RefObject, useEffect, useRef } from 'react';
4
+ import { drawSourceLens, type LoupeSource, resolveLoupeSource, sourceBoxIn } from './sourceLens';
5
+
6
+ /** Props for `<SourceLoupe>`. */
7
+ export interface SourceLoupeProps {
8
+ aim: LoupePoint;
9
+ factor: number;
10
+ /** One number for a round or square lens, or a width and a height. */
11
+ diameter: LoupeSize;
12
+ /** The canvas whose pixels the lens enlarges. */
13
+ source: LoupeSource;
14
+ /** The element `aim` is measured in, which the source's box is found against. */
15
+ hostRef: RefObject<HTMLElement | null>;
16
+ }
17
+
18
+ /**
19
+ * Paints a pixel lens from any canvas — one labkit drew or one it has never
20
+ * seen, 2D or WebGL. It redraws every frame, since the canvas it reads moves
21
+ * on its own schedule.
22
+ */
23
+ export function SourceLoupe({ aim, factor, diameter, source, hostRef }: SourceLoupeProps) {
24
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
25
+ const dpr = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1;
26
+ const argsRef = useLatest({ aim, factor, diameter, dpr, source });
27
+
28
+ const loop = useVisibleRaf(
29
+ () => {
30
+ loop.request();
31
+ const ctx = canvasRef.current?.getContext('2d');
32
+ const a = argsRef.current;
33
+ const src = resolveLoupeSource(a.source);
34
+ if (!ctx || !src) return;
35
+ drawSourceLens(ctx, {
36
+ aim: a.aim,
37
+ factor: a.factor,
38
+ diameter: a.diameter,
39
+ dpr: a.dpr,
40
+ source: src,
41
+ box: sourceBoxIn(src, hostRef.current),
42
+ });
43
+ },
44
+ { target: canvasRef },
45
+ );
46
+
47
+ useEffect(() => {
48
+ loop.request();
49
+ return () => loop.cancel();
50
+ }, [loop]);
51
+
52
+ return (
53
+ <canvas
54
+ ref={canvasRef}
55
+ className="lk-loupe__canvas"
56
+ width={Math.max(1, Math.round(loupeExtent(diameter).width * dpr))}
57
+ height={Math.max(1, Math.round(loupeExtent(diameter).height * dpr))}
58
+ />
59
+ );
60
+ }