@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.
- package/README.md +24 -0
- package/dist/_dts/{CanvasStackContext-C0fzFsQg.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
- package/dist/_dts/{Stage-Cvwxdnfp.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
- package/dist/_dts/{index-DRaswz91.d.ts → index-8geQNQet.d.ts} +83 -11
- package/dist/_dts/{index-DEHRoTXK.d.ts → index-DRi3V5sN.d.ts} +5 -3
- package/dist/_dts/{useTrialState-CS4TdWPW.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
- package/dist/canvas/index.d.ts +4 -4
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +2 -2
- package/dist/chrome/index.js +7 -7
- package/dist/{chunk-YX75W6EK.js → chunk-4RCVWX4M.js} +8 -8
- package/dist/chunk-4RCVWX4M.js.map +1 -0
- package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
- package/dist/chunk-6IIKLXBZ.js.map +1 -0
- package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
- package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
- package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
- package/dist/chunk-7ALJOVGP.js.map +1 -0
- package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
- package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
- package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
- package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
- package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
- package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
- package/dist/{chunk-NVKBL7ZE.js → chunk-KHB3KRRQ.js} +7 -7
- package/dist/chunk-KHB3KRRQ.js.map +1 -0
- package/dist/{chunk-XXACAPVU.js → chunk-NHIO2O3O.js} +31 -30
- package/dist/chunk-NHIO2O3O.js.map +1 -0
- package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
- package/dist/chunk-QMAY74TP.js.map +1 -0
- package/dist/{chunk-YDE6XATS.js → chunk-RN7T7OBO.js} +44 -22
- package/dist/chunk-RN7T7OBO.js.map +1 -0
- package/dist/{chunk-3L4MLLQB.js → chunk-RXO5UEGT.js} +4 -11
- package/dist/chunk-RXO5UEGT.js.map +1 -0
- package/dist/{chunk-MOTDMMMN.js → chunk-SQX35X5V.js} +3 -3
- package/dist/{chunk-MOTDMMMN.js.map → chunk-SQX35X5V.js.map} +1 -1
- package/dist/{chunk-J5HJJDLF.js → chunk-T5OEUNCB.js} +7 -2
- package/dist/chunk-T5OEUNCB.js.map +1 -0
- package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
- package/dist/chunk-UX66USTN.js.map +1 -0
- package/dist/{chunk-WURZTJGQ.js → chunk-V4SMGEYY.js} +4858 -4806
- package/dist/chunk-V4SMGEYY.js.map +1 -0
- package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
- package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
- package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
- package/dist/chunk-XWR2P6MR.js.map +1 -0
- package/dist/{chunk-7KAETQSS.js → chunk-YZA73U4A.js} +7 -7
- package/dist/chunk-YZA73U4A.js.map +1 -0
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +2 -2
- package/dist/controls/index.js +5 -5
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +157 -15
- package/dist/index.js +893 -258
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +1 -1
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +175 -27
- package/dist/loupe/index.js +547 -141
- package/dist/loupe/index.js.map +1 -1
- package/dist/overview/index.js +5 -5
- package/dist/passthrough/weasel-ui.js +3 -3
- package/dist/primitives/index.d.ts +4 -19
- package/dist/primitives/index.js +7 -7
- package/dist/state/index.d.ts +5 -7
- package/dist/state/index.js +5 -5
- package/dist/styles.css +173 -38
- package/dist/surface/index.d.ts +20 -1
- package/dist/surface/index.js +2 -2
- package/dist/undo/index.d.ts +18 -7
- package/dist/undo/index.js +1 -1
- package/package.json +11 -11
- package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
- package/src/annotations/AnnotationOverlay.tsx +10 -1
- package/src/annotations/Annotations.less +2 -0
- package/src/annotations/Annotations.overlay.test.tsx +29 -0
- package/src/canvas/CanvasStack.test.tsx +35 -0
- package/src/canvas/CanvasStack.tsx +12 -1
- package/src/canvas/useLayerScheduler.ts +14 -0
- package/src/chrome/builtins.test.ts +14 -0
- package/src/chrome/builtins.tsx +17 -2
- package/src/chrome/regions/StatusRegion.tsx +20 -14
- package/src/chrome/regions/regions.test.tsx +28 -0
- package/src/chrome/types.ts +8 -0
- package/src/clock/clock.browser.test.less +5 -0
- package/src/clock/clock.browser.test.tsx +77 -0
- package/src/clock/clock.integration.test.tsx +95 -0
- package/src/clock/clockRegistry.test.ts +33 -0
- package/src/clock/clockRegistry.ts +46 -0
- package/src/clock/hooks.ts +44 -0
- package/src/clock/index.ts +2 -0
- package/src/clock/trialClock.test.ts +265 -0
- package/src/clock/trialClock.ts +245 -0
- package/src/clock/useClockLoop.ts +45 -0
- package/src/config/resolve.test.ts +12 -0
- package/src/config/resolve.ts +5 -0
- package/src/controls/ControlMatrix.tsx +56 -54
- package/src/index.ts +3 -2
- package/src/instrument/types.ts +21 -1
- package/src/lab/Lab.chrome.test.tsx +8 -1
- package/src/lab/Lab.presentation.test.tsx +113 -0
- package/src/lab/Lab.surface.test.tsx +20 -9
- package/src/lab/Lab.tsx +132 -257
- package/src/lab/LabLightbox.browser.test.less +17 -0
- package/src/lab/LabLightbox.browser.test.tsx +216 -0
- package/src/lab/LabShell.less +10 -3
- package/src/lab/LabSurface.tsx +129 -0
- package/src/lab/Workspace.stories.tsx +18 -0
- package/src/lab/Workspace.surface.test.tsx +4 -0
- package/src/lab/Workspace.test.tsx +37 -1
- package/src/lab/Workspace.tsx +34 -6
- package/src/lab/index.ts +2 -0
- package/src/lab/nebula.ts +30 -0
- package/src/lab/openLab.test.ts +81 -0
- package/src/lab/openLab.ts +119 -0
- package/src/lab/presentation.browser.test.less +5 -0
- package/src/lab/presentation.browser.test.tsx +119 -0
- package/src/lab/presentation.less +64 -0
- package/src/lab/presentation.tsx +47 -0
- package/src/lab/useFocusPick.ts +43 -0
- package/src/lightbox/Lightbox.browser.test.less +14 -0
- package/src/lightbox/Lightbox.browser.test.tsx +113 -0
- package/src/lightbox/Lightbox.less +128 -0
- package/src/lightbox/Lightbox.test.tsx +195 -0
- package/src/lightbox/Lightbox.tsx +205 -0
- package/src/lightbox/LightboxContext.ts +38 -0
- package/src/lightbox/LightboxLayers.tsx +40 -0
- package/src/lightbox/index.ts +8 -0
- package/src/lightbox/modal.ts +94 -0
- package/src/loupe/AGENTS.md +40 -2
- package/src/loupe/CanvasLoupe.tsx +5 -4
- package/src/loupe/DomLoupe.tsx +7 -5
- package/src/loupe/Loupe.less +15 -0
- package/src/loupe/LoupeBubble.tsx +35 -13
- package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
- package/src/loupe/SourceLoupe.tsx +60 -0
- package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
- package/src/loupe/TrialLoupe.tsx +126 -28
- package/src/loupe/canvasLens.ts +20 -17
- package/src/loupe/index.ts +26 -2
- package/src/loupe/sourceLens.test.ts +65 -0
- package/src/loupe/sourceLens.ts +97 -0
- package/src/loupe/types.test.ts +6 -0
- package/src/loupe/types.ts +109 -7
- package/src/loupe/useLoupe.test.tsx +188 -3
- package/src/loupe/useLoupe.ts +106 -11
- package/src/primitives/index.ts +2 -2
- package/src/specimen/Specimen.tsx +0 -7
- package/src/state/helpers.test.ts +4 -11
- package/src/state/helpers.ts +2 -8
- package/src/state/index.ts +0 -2
- package/src/state/openLabStore.test.ts +10 -8
- package/src/state/openLabStore.ts +1 -1
- package/src/state/store.test.ts +2 -2
- package/src/state/store.ts +15 -16
- package/src/state/types.ts +9 -9
- package/src/styles.less +2 -1
- package/src/surface/AGENTS.md +13 -0
- package/src/surface/index.ts +1 -0
- package/src/surface/useSurfaceTile.test.tsx +4 -0
- package/src/surface/useSurfaceTile.ts +17 -1
- package/src/surface/useTiledSurface.test.tsx +65 -0
- package/src/surface/useTiledSurface.ts +68 -1
- package/src/trial/Trial.stories.tsx +0 -1
- package/src/trial/Trial.test.tsx +40 -3
- package/src/trial/Trial.tsx +87 -43
- package/src/trial/TrialChrome.tsx +20 -2
- package/src/trial/trialOps.test.ts +1 -2
- package/src/trial/trialOps.ts +2 -3
- package/src/undo/stateOp.ts +21 -0
- package/src/undo/undoStack.ts +18 -6
- package/dist/chunk-3L4MLLQB.js.map +0 -1
- package/dist/chunk-7KAETQSS.js.map +0 -1
- package/dist/chunk-EESJUSYR.js.map +0 -1
- package/dist/chunk-J5HJJDLF.js.map +0 -1
- package/dist/chunk-NVKBL7ZE.js.map +0 -1
- package/dist/chunk-PMLZP7RT.js.map +0 -1
- package/dist/chunk-RL2LOLNI.js.map +0 -1
- package/dist/chunk-T6DLGEJB.js.map +0 -1
- package/dist/chunk-WIG6XHJ7.js.map +0 -1
- package/dist/chunk-WURZTJGQ.js.map +0 -1
- package/dist/chunk-XXACAPVU.js.map +0 -1
- package/dist/chunk-YDE6XATS.js.map +0 -1
- package/dist/chunk-YX75W6EK.js.map +0 -1
- package/src/primitives/StatusBar.less +0 -34
- package/src/primitives/StatusBar.stories.tsx +0 -24
- package/src/primitives/StatusBar.test.tsx +0 -25
- package/src/primitives/StatusBar.tsx +0 -28
package/src/loupe/DomLoupe.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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
|
|
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(${
|
|
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}>
|
package/src/loupe/Loupe.less
CHANGED
|
@@ -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
|
|
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
|
|
7
|
+
/** Where the lens is centered, in its container's own pixels. */
|
|
7
8
|
aim: LoupePoint;
|
|
8
|
-
|
|
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
|
|
16
|
+
children?: ReactNode;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
|
-
* The lens itself: a circle
|
|
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({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
+
}
|