@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/index.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
export type {
|
|
1
|
+
export type {
|
|
2
|
+
BandPlacement,
|
|
3
|
+
CanvasSource,
|
|
4
|
+
CanvasSourceOptions,
|
|
5
|
+
LoupeMode,
|
|
6
|
+
LoupePoint,
|
|
7
|
+
LoupeSize,
|
|
8
|
+
PlaceBandArgs,
|
|
9
|
+
SourceBox,
|
|
10
|
+
SourceCanvas,
|
|
11
|
+
SourceContext,
|
|
12
|
+
} from '@weasel-js/loupe';
|
|
13
|
+
export { createCanvasSource, loupeExtent, placeBand } from '@weasel-js/loupe';
|
|
2
14
|
export type { CanvasLoupeProps } from './CanvasLoupe';
|
|
3
15
|
export { CanvasLoupe } from './CanvasLoupe';
|
|
4
16
|
export type { LensCamera, SourceRect } from './canvasLens';
|
|
@@ -11,9 +23,21 @@ export type { LoupeGesturesProps } from './LoupeGestures';
|
|
|
11
23
|
export { LoupeGestures } from './LoupeGestures';
|
|
12
24
|
export type { LoupeInputApi } from './loupeActions';
|
|
13
25
|
export { createLoupeActions, LOUPE_MAGNIFY_ID, LOUPE_PEEK_ID } from './loupeActions';
|
|
26
|
+
export type { SourceLoupeProps } from './SourceLoupe';
|
|
27
|
+
export { SourceLoupe } from './SourceLoupe';
|
|
28
|
+
export type { LoupeSource, LoupeSourceTarget } from './sourceLens';
|
|
29
|
+
export { drawSourceLens, resolveLoupeSource, sampleSource, sourceBoxIn } from './sourceLens';
|
|
14
30
|
export type { TrialLoupeProps } from './TrialLoupe';
|
|
15
31
|
export { TrialLoupe } from './TrialLoupe';
|
|
16
|
-
export type {
|
|
32
|
+
export type {
|
|
33
|
+
LoupeLens,
|
|
34
|
+
LoupeOptions,
|
|
35
|
+
LoupePlaceArgs,
|
|
36
|
+
LoupePlacement,
|
|
37
|
+
LoupeRenderArgs,
|
|
38
|
+
LoupeShape,
|
|
39
|
+
ResolvedLoupe,
|
|
40
|
+
} from './types';
|
|
17
41
|
export { LOUPE_DEFAULTS, resolveLoupe } from './types';
|
|
18
42
|
export type { LoupeState, UseLoupeOptions } from './useLoupe';
|
|
19
43
|
export { useLoupe } from './useLoupe';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createCanvasSource } from '@weasel-js/loupe';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { resolveLoupeSource, sourceBoxIn } from './sourceLens';
|
|
4
|
+
|
|
5
|
+
function canvas(width = 200, height = 100): HTMLCanvasElement {
|
|
6
|
+
const c = document.createElement('canvas');
|
|
7
|
+
c.width = width;
|
|
8
|
+
c.height = height;
|
|
9
|
+
return c;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('resolveLoupeSource', () => {
|
|
13
|
+
it('passes a CanvasSource through', () => {
|
|
14
|
+
const s = createCanvasSource(canvas());
|
|
15
|
+
expect(resolveLoupeSource(s)).toBe(s);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('makes one source per canvas, however often a getter returns it', () => {
|
|
19
|
+
const c = canvas();
|
|
20
|
+
const get = () => c;
|
|
21
|
+
const a = resolveLoupeSource(get);
|
|
22
|
+
expect(a?.canvas).toBe(c);
|
|
23
|
+
expect(resolveLoupeSource(get)).toBe(a);
|
|
24
|
+
expect(resolveLoupeSource(c)).toBe(a);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('is null while a getter has nothing yet', () => {
|
|
28
|
+
expect(resolveLoupeSource(() => null)).toBeNull();
|
|
29
|
+
expect(resolveLoupeSource(undefined)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('sourceBoxIn', () => {
|
|
34
|
+
it('prefers the box the source declares', () => {
|
|
35
|
+
const box = { x: 4, y: 5, width: 6, height: 7 };
|
|
36
|
+
expect(sourceBoxIn(createCanvasSource(canvas(), { box: () => box }), null)).toBe(box);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back to the whole host for a canvas that is not on the page', () => {
|
|
40
|
+
const host = document.createElement('div');
|
|
41
|
+
host.getBoundingClientRect = () => ({ left: 0, top: 0, width: 300, height: 150 }) as DOMRect;
|
|
42
|
+
expect(sourceBoxIn(createCanvasSource(canvas()), host)).toEqual({
|
|
43
|
+
x: 0,
|
|
44
|
+
y: 0,
|
|
45
|
+
width: 300,
|
|
46
|
+
height: 150,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('measures a laid-out canvas against the host', () => {
|
|
51
|
+
const host = document.createElement('div');
|
|
52
|
+
const c = canvas();
|
|
53
|
+
host.append(c);
|
|
54
|
+
document.body.append(host);
|
|
55
|
+
host.getBoundingClientRect = () => ({ left: 100, top: 50, width: 300, height: 150 }) as DOMRect;
|
|
56
|
+
c.getBoundingClientRect = () => ({ left: 120, top: 60, width: 100, height: 50 }) as DOMRect;
|
|
57
|
+
expect(sourceBoxIn(createCanvasSource(c), host)).toEqual({
|
|
58
|
+
x: 20,
|
|
59
|
+
y: 10,
|
|
60
|
+
width: 100,
|
|
61
|
+
height: 50,
|
|
62
|
+
});
|
|
63
|
+
host.remove();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CanvasSource,
|
|
3
|
+
createCanvasSource,
|
|
4
|
+
type LoupePoint,
|
|
5
|
+
type LoupeSize,
|
|
6
|
+
loupeExtent,
|
|
7
|
+
type SourceBox,
|
|
8
|
+
type SourceCanvas,
|
|
9
|
+
type SourceContext,
|
|
10
|
+
sourcePixel,
|
|
11
|
+
sourceRegion,
|
|
12
|
+
} from '@weasel-js/loupe';
|
|
13
|
+
|
|
14
|
+
/** Anything a lens can be pointed at for its pixels: a `CanvasSource`, a
|
|
15
|
+
* canvas, a context on one, or a function returning any of those once it
|
|
16
|
+
* exists. */
|
|
17
|
+
export type LoupeSourceTarget = CanvasSource | SourceCanvas | SourceContext;
|
|
18
|
+
export type LoupeSource = LoupeSourceTarget | (() => LoupeSourceTarget | null | undefined);
|
|
19
|
+
|
|
20
|
+
function isCanvasSource(t: LoupeSourceTarget): t is CanvasSource {
|
|
21
|
+
return 'capture' in t && 'retain' in t;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// One source per canvas or context, so a getter returning the same canvas
|
|
25
|
+
// every frame keeps one snapshot rather than minting a new one per call.
|
|
26
|
+
const made = new WeakMap<object, CanvasSource>();
|
|
27
|
+
|
|
28
|
+
/** `source` as a `CanvasSource`, or `null` while a getter has nothing yet. */
|
|
29
|
+
export function resolveLoupeSource(source: LoupeSource | undefined): CanvasSource | null {
|
|
30
|
+
if (!source) return null;
|
|
31
|
+
const target = typeof source === 'function' ? source() : source;
|
|
32
|
+
if (!target) return null;
|
|
33
|
+
if (isCanvasSource(target)) return target;
|
|
34
|
+
let s = made.get(target);
|
|
35
|
+
if (!s) {
|
|
36
|
+
s = createCanvasSource(target);
|
|
37
|
+
made.set(target, s);
|
|
38
|
+
}
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where a source canvas sits over the lens's host, in the host's CSS px: its
|
|
44
|
+
* own `box` if it has one, else the canvas's laid-out box measured against the
|
|
45
|
+
* host, else — for an offscreen canvas, or one not on the page — the whole
|
|
46
|
+
* host.
|
|
47
|
+
*/
|
|
48
|
+
export function sourceBoxIn(source: CanvasSource, host: HTMLElement | null): SourceBox {
|
|
49
|
+
const own = source.box?.();
|
|
50
|
+
if (own) return own;
|
|
51
|
+
const hostRect = host?.getBoundingClientRect();
|
|
52
|
+
const c = source.canvas;
|
|
53
|
+
if (typeof HTMLCanvasElement !== 'undefined' && c instanceof HTMLCanvasElement && c.isConnected) {
|
|
54
|
+
const r = c.getBoundingClientRect();
|
|
55
|
+
if (r.width > 0 && r.height > 0) {
|
|
56
|
+
return {
|
|
57
|
+
x: r.left - (hostRect?.left ?? 0),
|
|
58
|
+
y: r.top - (hostRect?.top ?? 0),
|
|
59
|
+
width: r.width,
|
|
60
|
+
height: r.height,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { x: 0, y: 0, width: hostRect?.width ?? c.width, height: hostRect?.height ?? c.height };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The color a source shows at a host point, or `null` where it shows none. */
|
|
68
|
+
export function sampleSource(source: CanvasSource, p: LoupePoint, box: SourceBox): string | null {
|
|
69
|
+
return source.sample(sourcePixel(p, box, source.canvas));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Enlarge the pixels of `source` around `aim` into a lens' own canvas, with
|
|
73
|
+
* smoothing off. Leaves the lens clear while the source has no frame. */
|
|
74
|
+
export function drawSourceLens(
|
|
75
|
+
ctx: CanvasRenderingContext2D,
|
|
76
|
+
opts: {
|
|
77
|
+
aim: LoupePoint;
|
|
78
|
+
factor: number;
|
|
79
|
+
diameter: LoupeSize;
|
|
80
|
+
dpr: number;
|
|
81
|
+
source: CanvasSource;
|
|
82
|
+
box: SourceBox;
|
|
83
|
+
},
|
|
84
|
+
): void {
|
|
85
|
+
const { aim, factor, diameter, dpr, source, box } = opts;
|
|
86
|
+
const { width, height } = loupeExtent(diameter);
|
|
87
|
+
ctx.save();
|
|
88
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
89
|
+
ctx.clearRect(0, 0, width, height);
|
|
90
|
+
const frame = source.frame();
|
|
91
|
+
if (frame) {
|
|
92
|
+
const { sx, sy, sw, sh } = sourceRegion(aim, factor, diameter, box, source.canvas);
|
|
93
|
+
ctx.imageSmoothingEnabled = false;
|
|
94
|
+
ctx.drawImage(frame, sx, sy, sw, sh, 0, 0, width, height);
|
|
95
|
+
}
|
|
96
|
+
ctx.restore();
|
|
97
|
+
}
|
package/src/loupe/types.test.ts
CHANGED
|
@@ -21,6 +21,12 @@ describe('resolveLoupe', () => {
|
|
|
21
21
|
expect(resolveLoupe({ mode: 'pixel' }).mode).toBe('pixel');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it('forces pixel on a source loupe, unless render draws it', () => {
|
|
25
|
+
const source = () => null;
|
|
26
|
+
expect(resolveLoupe({ source, mode: 'vector' }).mode).toBe('pixel');
|
|
27
|
+
expect(resolveLoupe({ source, render: () => null }).mode).toBe('vector');
|
|
28
|
+
});
|
|
29
|
+
|
|
24
30
|
it('distinguishes an unset peek key from one turned off', () => {
|
|
25
31
|
expect(resolveLoupe({}).peekKey).toBe('Alt');
|
|
26
32
|
expect(resolveLoupe({ peekKey: null }).peekKey).toBeNull();
|
package/src/loupe/types.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { LoupeMode } from '@weasel-js/loupe';
|
|
1
|
+
import type { LoupeMode, LoupePoint } from '@weasel-js/loupe';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
import type { ViewportSize } from '../canvas/worldSpec';
|
|
4
4
|
import type { ViewTransform } from '../instrument/types';
|
|
5
|
+
import type { LoupeSource } from './sourceLens';
|
|
5
6
|
|
|
6
7
|
/** What a DOM loupe's `render` is handed: the camera to draw the content
|
|
7
8
|
* through again. */
|
|
@@ -18,15 +19,66 @@ export interface LoupeRenderArgs {
|
|
|
18
19
|
size: ViewportSize;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** The outline of a lens. On a box that is not square, `'circle'` is an ellipse. */
|
|
23
|
+
export type LoupeShape = 'circle' | 'square';
|
|
24
|
+
|
|
25
|
+
/** Where a host puts the lens for one aim, in the host's own CSS px. */
|
|
26
|
+
export interface LoupePlacement {
|
|
27
|
+
/** Where the lens is drawn: the middle of its box. */
|
|
28
|
+
center: LoupePoint;
|
|
29
|
+
/** The host point the lens shows at its middle. Omitted, `center`; it
|
|
30
|
+
* differs when a lens is moved to stay on the host but keeps showing what
|
|
31
|
+
* it was placed over. */
|
|
32
|
+
shows?: LoupePoint;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
/** The magnification for this placement. Omitted, the wheel's. */
|
|
36
|
+
factor?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where a lens is drawn and what it shows, once `place` has had its say. */
|
|
40
|
+
export interface LoupeLens {
|
|
41
|
+
/** Where the lens is drawn: the middle of its box. */
|
|
42
|
+
center: LoupePoint;
|
|
43
|
+
/** The host point the lens shows at its middle. */
|
|
44
|
+
shows: LoupePoint;
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
/** What it magnifies by: the wheel's, unless `place` chose another. */
|
|
48
|
+
factor: number;
|
|
49
|
+
shape: LoupeShape;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What `place` is asked with. */
|
|
53
|
+
export interface LoupePlaceArgs {
|
|
54
|
+
aim: LoupePoint;
|
|
55
|
+
factor: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
21
58
|
/**
|
|
22
59
|
* How a lens magnifies.
|
|
23
60
|
*
|
|
24
61
|
* With no `render`, the lens re-draws the canvas stack's layers through a
|
|
25
62
|
* zoomed camera, so it stays sharp at any factor. Over DOM content, `render`
|
|
26
|
-
* draws it instead: given a camera, draw me again.
|
|
63
|
+
* draws it instead: given a camera, draw me again. With a `source`, it
|
|
64
|
+
* enlarges the pixels of that canvas — any canvas, labkit's or not.
|
|
27
65
|
*/
|
|
28
66
|
export interface LoupeOptions {
|
|
29
67
|
render?: (args: LoupeRenderArgs) => ReactNode;
|
|
68
|
+
/**
|
|
69
|
+
* A canvas to read pixels from instead of the canvas stack: a
|
|
70
|
+
* `CanvasSource` from `createCanvasSource`, a canvas, a context on one, or
|
|
71
|
+
* a function returning one once it exists. 2D, WebGL and WebGL2 all work.
|
|
72
|
+
* Without `render` the lens enlarges its pixels, so the mode is `'pixel'`;
|
|
73
|
+
* with `render`, `render` draws the lens and the source answers
|
|
74
|
+
* `onColorChange`.
|
|
75
|
+
*
|
|
76
|
+
* A WebGL canvas made without `preserveDrawingBuffer` is blank to anyone
|
|
77
|
+
* reading it after the frame that drew it. Make its source with
|
|
78
|
+
* `createCanvasSource(gl)` and call `source.capture()` right after each
|
|
79
|
+
* draw; a canvas that draws on demand also passes `requestRedraw`.
|
|
80
|
+
*/
|
|
81
|
+
source?: LoupeSource;
|
|
30
82
|
/** Opening magnification, clamped to the bounds below. Default 6. */
|
|
31
83
|
factor?: number;
|
|
32
84
|
/** What the wheel clamps to. Defaults 2 and 32. */
|
|
@@ -34,27 +86,62 @@ export interface LoupeOptions {
|
|
|
34
86
|
maxFactor?: number;
|
|
35
87
|
/** `'vector'` (default) re-renders the content magnified; `'pixel'` blows up
|
|
36
88
|
* the pixels the instrument presented. A `render` loupe is always vector —
|
|
37
|
-
* DOM has no framebuffer to enlarge. */
|
|
89
|
+
* DOM has no framebuffer to enlarge — and a `source` loupe always pixel. */
|
|
38
90
|
mode?: LoupeMode;
|
|
39
91
|
/** Lens diameter in CSS px. Default 200. */
|
|
40
92
|
diameter?: number;
|
|
93
|
+
/** Default `'circle'`. */
|
|
94
|
+
shape?: LoupeShape;
|
|
95
|
+
/**
|
|
96
|
+
* Puts the lens somewhere other than a `diameter` box on the aim: the lens is
|
|
97
|
+
* drawn with the returned box centered on `center`, and shows what is around
|
|
98
|
+
* `shows` (or `center`) magnified by the returned `factor`, or by the wheel's when it
|
|
99
|
+
* returns none. `factor` in the argument is always the wheel's, so a host
|
|
100
|
+
* lowering it to fit something in keeps the wheel as the ceiling. Returning
|
|
101
|
+
* `null` keeps the default. It is
|
|
102
|
+
* called while the lens renders, so a host whose content moves under a still
|
|
103
|
+
* pointer re-renders the lens to re-place it. The color under the aim is
|
|
104
|
+
* still the one `onColorChange` reports.
|
|
105
|
+
*/
|
|
106
|
+
place?: (at: LoupePlaceArgs) => LoupePlacement | null;
|
|
107
|
+
/**
|
|
108
|
+
* Draw only the lens's outline — ring, shadow, shape — and leave its inside
|
|
109
|
+
* empty, so whatever is under it shows through: for a host that draws the
|
|
110
|
+
* magnified view itself, into the box `onLens` reports. No painter runs; a
|
|
111
|
+
* `source`, if given, still answers `onColorChange`. Under a tiled surface,
|
|
112
|
+
* each move invalidates the tiles under the box it left and the box it now
|
|
113
|
+
* covers, so the host redraws those and no others.
|
|
114
|
+
*/
|
|
115
|
+
hollow?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Called with where the lens is drawn and what it shows, in host CSS px,
|
|
118
|
+
* whenever that changes — an aim, the wheel, `place`, `shape` — and with
|
|
119
|
+
* `null` when a lens that was up goes away.
|
|
120
|
+
*/
|
|
121
|
+
onLens?: (lens: LoupeLens | null) => void;
|
|
41
122
|
/** Held for a momentary peek while the loupe is off. Default `'Alt'`; `null`
|
|
42
123
|
* turns hold-to-peek off. Matched against `KeyboardEvent.key`. */
|
|
43
124
|
peekKey?: string | null;
|
|
44
125
|
/** Called with the color under the aim, wherever the surface can say. The
|
|
45
|
-
* canvas painter
|
|
126
|
+
* canvas painter and a `source` read it back; a DOM loupe with no `source`
|
|
127
|
+
* has no pixels to sample. */
|
|
46
128
|
onColorChange?: (hex: string) => void;
|
|
47
129
|
}
|
|
48
130
|
|
|
49
131
|
/** {@link LoupeOptions} with every default filled in. */
|
|
50
132
|
export interface ResolvedLoupe {
|
|
51
133
|
render?: (args: LoupeRenderArgs) => ReactNode;
|
|
134
|
+
source?: LoupeSource;
|
|
52
135
|
onColorChange?: (hex: string) => void;
|
|
53
136
|
factor: number;
|
|
54
137
|
minFactor: number;
|
|
55
138
|
maxFactor: number;
|
|
56
139
|
mode: LoupeMode;
|
|
57
140
|
diameter: number;
|
|
141
|
+
shape: LoupeShape;
|
|
142
|
+
place?: (at: LoupePlaceArgs) => LoupePlacement | null;
|
|
143
|
+
hollow: boolean;
|
|
144
|
+
onLens?: (lens: LoupeLens | null) => void;
|
|
58
145
|
peekKey: string | null;
|
|
59
146
|
}
|
|
60
147
|
|
|
@@ -65,23 +152,38 @@ export const LOUPE_DEFAULTS = {
|
|
|
65
152
|
maxFactor: 32,
|
|
66
153
|
mode: 'vector',
|
|
67
154
|
diameter: 200,
|
|
155
|
+
shape: 'circle',
|
|
156
|
+
hollow: false,
|
|
68
157
|
peekKey: 'Alt',
|
|
69
|
-
} as const satisfies Omit<
|
|
158
|
+
} as const satisfies Omit<
|
|
159
|
+
ResolvedLoupe,
|
|
160
|
+
'render' | 'source' | 'onColorChange' | 'place' | 'onLens'
|
|
161
|
+
>;
|
|
70
162
|
|
|
71
163
|
/** `options` with every default filled in and `factor` clamped to
|
|
72
|
-
* `[minFactor, maxFactor]`. A loupe with its own `render` is always `'vector'
|
|
164
|
+
* `[minFactor, maxFactor]`. A loupe with its own `render` is always `'vector'`,
|
|
165
|
+
* and one with only a `source` always `'pixel'`. */
|
|
73
166
|
export function resolveLoupe(options: LoupeOptions = {}): ResolvedLoupe {
|
|
74
167
|
const minFactor = options.minFactor ?? LOUPE_DEFAULTS.minFactor;
|
|
75
168
|
const maxFactor = options.maxFactor ?? LOUPE_DEFAULTS.maxFactor;
|
|
76
169
|
return {
|
|
77
170
|
render: options.render,
|
|
171
|
+
source: options.source,
|
|
78
172
|
onColorChange: options.onColorChange,
|
|
79
173
|
minFactor,
|
|
80
174
|
maxFactor,
|
|
81
175
|
factor: Math.min(maxFactor, Math.max(minFactor, options.factor ?? LOUPE_DEFAULTS.factor)),
|
|
82
176
|
diameter: options.diameter ?? LOUPE_DEFAULTS.diameter,
|
|
177
|
+
shape: options.shape ?? LOUPE_DEFAULTS.shape,
|
|
178
|
+
place: options.place,
|
|
179
|
+
hollow: options.hollow ?? LOUPE_DEFAULTS.hollow,
|
|
180
|
+
onLens: options.onLens,
|
|
83
181
|
peekKey: options.peekKey === undefined ? LOUPE_DEFAULTS.peekKey : options.peekKey,
|
|
84
182
|
// A DOM loupe has no framebuffer, so `pixel` would have nothing to enlarge.
|
|
85
|
-
mode: options.render
|
|
183
|
+
mode: options.render
|
|
184
|
+
? 'vector'
|
|
185
|
+
: options.source
|
|
186
|
+
? 'pixel'
|
|
187
|
+
: (options.mode ?? LOUPE_DEFAULTS.mode),
|
|
86
188
|
};
|
|
87
189
|
}
|
|
@@ -4,7 +4,7 @@ import { renderThenAbandon } from '@weasel-js/react/testing/abandonRender';
|
|
|
4
4
|
import { type RefObject, StrictMode, useRef } from 'react';
|
|
5
5
|
import { describe, expect, it, vi } from 'vitest';
|
|
6
6
|
import { LoupeGestures } from './LoupeGestures';
|
|
7
|
-
import { resolveLoupe } from './types';
|
|
7
|
+
import { type LoupeOptions, type LoupeShape, resolveLoupe } from './types';
|
|
8
8
|
import { type LoupeState, useLoupe } from './useLoupe';
|
|
9
9
|
|
|
10
10
|
interface HarnessProps {
|
|
@@ -14,7 +14,13 @@ interface HarnessProps {
|
|
|
14
14
|
minFactor?: number;
|
|
15
15
|
maxFactor?: number;
|
|
16
16
|
onColorChange?: (hex: string) => void;
|
|
17
|
+
shape?: LoupeShape;
|
|
18
|
+
place?: LoupeOptions['place'];
|
|
19
|
+
onLens?: LoupeOptions['onLens'];
|
|
17
20
|
seen?: (loupe: LoupeState) => void;
|
|
21
|
+
/** A wheel listener of the host's own, attached before the loupe's, as a
|
|
22
|
+
* camera's controls on the same canvas would be. */
|
|
23
|
+
hostWheel?: (e: WheelEvent) => void;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
/** Reports the loupe's state as text, which is every assertion jsdom can make
|
|
@@ -22,7 +28,7 @@ interface HarnessProps {
|
|
|
22
28
|
*
|
|
23
29
|
* Mounts `<LoupeGestures>` the way `<TrialLoupe>` does, so the key and wheel
|
|
24
30
|
* cases below exercise the real dispatcher route rather than a stand-in. */
|
|
25
|
-
function Harness({ enabled = true, seen, sample, ...rest }: HarnessProps) {
|
|
31
|
+
function Harness({ enabled = true, seen, sample, hostWheel, ...rest }: HarnessProps) {
|
|
26
32
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
27
33
|
const options = resolveLoupe(rest);
|
|
28
34
|
const loupe = useLoupe({
|
|
@@ -33,7 +39,13 @@ function Harness({ enabled = true, seen, sample, ...rest }: HarnessProps) {
|
|
|
33
39
|
});
|
|
34
40
|
seen?.(loupe);
|
|
35
41
|
return (
|
|
36
|
-
<div
|
|
42
|
+
<div
|
|
43
|
+
ref={(el) => {
|
|
44
|
+
if (el && hostWheel && hostRef.current !== el) el.addEventListener('wheel', hostWheel);
|
|
45
|
+
hostRef.current = el;
|
|
46
|
+
}}
|
|
47
|
+
data-testid="host"
|
|
48
|
+
>
|
|
37
49
|
<WeaselProvider isolate>
|
|
38
50
|
<LoupeGestures
|
|
39
51
|
hostRef={hostRef as RefObject<HTMLElement | null>}
|
|
@@ -93,6 +105,25 @@ describe('useLoupe', () => {
|
|
|
93
105
|
expect(read('visible')).toBe('false');
|
|
94
106
|
});
|
|
95
107
|
|
|
108
|
+
it('opens a peek where the pointer already is', () => {
|
|
109
|
+
// The model ignores aims while the lens is down, so a peek over a pointer
|
|
110
|
+
// that has not moved since must aim from what the listener saw.
|
|
111
|
+
render(<Harness enabled={false} />);
|
|
112
|
+
move(40, 25);
|
|
113
|
+
act(() => {
|
|
114
|
+
fireEvent.keyDown(window, { key: 'Alt' });
|
|
115
|
+
});
|
|
116
|
+
expect(read('aim')).toBe('40,25');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('opens where the pointer already is when turned on', () => {
|
|
120
|
+
const { rerender } = render(<Harness enabled={false} />);
|
|
121
|
+
move(40, 25);
|
|
122
|
+
rerender(<Harness enabled />);
|
|
123
|
+
expect(read('visible')).toBe('true');
|
|
124
|
+
expect(read('aim')).toBe('40,25');
|
|
125
|
+
});
|
|
126
|
+
|
|
96
127
|
it('does not peek when its options turned the key off', () => {
|
|
97
128
|
render(<Harness enabled={false} peekKey={null} />);
|
|
98
129
|
move(40, 25);
|
|
@@ -125,6 +156,20 @@ describe('useLoupe', () => {
|
|
|
125
156
|
expect(Number(read('factor'))).toBeGreaterThan(6);
|
|
126
157
|
});
|
|
127
158
|
|
|
159
|
+
it('keeps the wheel from the host’s own listeners while the lens is up', () => {
|
|
160
|
+
const hostWheel = vi.fn();
|
|
161
|
+
render(<Harness hostWheel={hostWheel} />);
|
|
162
|
+
move(40, 25);
|
|
163
|
+
act(() => {
|
|
164
|
+
fireEvent.wheel(screen.getByTestId('host'), { deltaY: -100 });
|
|
165
|
+
});
|
|
166
|
+
expect(Number(read('factor'))).toBeGreaterThan(6);
|
|
167
|
+
expect(hostWheel).not.toHaveBeenCalled();
|
|
168
|
+
fireEvent.pointerLeave(screen.getByTestId('host'));
|
|
169
|
+
fireEvent.wheel(screen.getByTestId('host'), { deltaY: -100 });
|
|
170
|
+
expect(hostWheel).toHaveBeenCalledTimes(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
128
173
|
it('clamps the wheel to the declared bounds', () => {
|
|
129
174
|
render(<Harness minFactor={4} maxFactor={8} />);
|
|
130
175
|
move(40, 25);
|
|
@@ -276,4 +321,144 @@ describe('useLoupe', () => {
|
|
|
276
321
|
expect(seen[0]?.pick()).toBe('#111111');
|
|
277
322
|
expect(abandoned).not.toHaveBeenCalled();
|
|
278
323
|
});
|
|
324
|
+
|
|
325
|
+
describe('placed by the host', () => {
|
|
326
|
+
const last = (seen: LoupeState[]): LoupeState => seen[seen.length - 1] as LoupeState;
|
|
327
|
+
|
|
328
|
+
it('is a diameter box on the aim, at the wheel factor, when nothing places it', () => {
|
|
329
|
+
const seen: LoupeState[] = [];
|
|
330
|
+
render(<Harness seen={(l) => seen.push(l)} />);
|
|
331
|
+
move(40, 30);
|
|
332
|
+
expect(last(seen).lens).toEqual({
|
|
333
|
+
center: { x: 40, y: 30 },
|
|
334
|
+
shows: { x: 40, y: 30 },
|
|
335
|
+
width: 200,
|
|
336
|
+
height: 200,
|
|
337
|
+
factor: 6,
|
|
338
|
+
shape: 'circle',
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('asks place with the aim and the wheel factor, and draws the box it returns', () => {
|
|
343
|
+
const seen: LoupeState[] = [];
|
|
344
|
+
const place = vi.fn(({ aim }: { aim: { x: number; y: number } }) => ({
|
|
345
|
+
center: { x: 150, y: Math.floor(aim.y / 20) * 20 + 10 },
|
|
346
|
+
width: 300,
|
|
347
|
+
height: 60,
|
|
348
|
+
}));
|
|
349
|
+
render(<Harness shape="square" place={place} seen={(l) => seen.push(l)} />);
|
|
350
|
+
move(40, 33);
|
|
351
|
+
expect(place).toHaveBeenLastCalledWith({ aim: { x: 40, y: 33 }, factor: 6 });
|
|
352
|
+
expect(last(seen).lens).toEqual({
|
|
353
|
+
center: { x: 150, y: 30 },
|
|
354
|
+
shows: { x: 150, y: 30 },
|
|
355
|
+
width: 300,
|
|
356
|
+
height: 60,
|
|
357
|
+
factor: 6,
|
|
358
|
+
shape: 'square',
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('draws a placement at its center while showing the point it names', () => {
|
|
363
|
+
const seen: LoupeState[] = [];
|
|
364
|
+
render(
|
|
365
|
+
<Harness
|
|
366
|
+
place={() => ({
|
|
367
|
+
center: { x: 50, y: 20 },
|
|
368
|
+
shows: { x: 10, y: 20 },
|
|
369
|
+
width: 100,
|
|
370
|
+
height: 40,
|
|
371
|
+
})}
|
|
372
|
+
seen={(l) => seen.push(l)}
|
|
373
|
+
/>,
|
|
374
|
+
);
|
|
375
|
+
move(10, 20);
|
|
376
|
+
expect(last(seen).lens).toMatchObject({ center: { x: 50, y: 20 }, shows: { x: 10, y: 20 } });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('shows at the factor place returns, leaving the wheel factor as what place is asked with', () => {
|
|
380
|
+
const seen: LoupeState[] = [];
|
|
381
|
+
const place = vi.fn(({ factor }: { factor: number }) => ({
|
|
382
|
+
center: { x: 0, y: 0 },
|
|
383
|
+
width: 100,
|
|
384
|
+
height: 20,
|
|
385
|
+
factor: Math.min(factor, 2.5),
|
|
386
|
+
}));
|
|
387
|
+
render(<Harness place={place} seen={(l) => seen.push(l)} />);
|
|
388
|
+
move(10, 10);
|
|
389
|
+
expect(last(seen).lens.factor).toBe(2.5);
|
|
390
|
+
expect(last(seen).factor).toBe(6);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('falls back to the diameter box when place returns null', () => {
|
|
394
|
+
const seen: LoupeState[] = [];
|
|
395
|
+
render(<Harness place={() => null} seen={(l) => seen.push(l)} />);
|
|
396
|
+
move(40, 30);
|
|
397
|
+
expect(last(seen).lens).toMatchObject({ center: { x: 40, y: 30 }, width: 200, height: 200 });
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('does not ask place while the lens is down', () => {
|
|
401
|
+
const place = vi.fn(() => null);
|
|
402
|
+
render(<Harness enabled={false} place={place} />);
|
|
403
|
+
move(40, 30);
|
|
404
|
+
expect(place).not.toHaveBeenCalled();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('tells onLens where the lens is drawn as it moves, and null once it is put away', () => {
|
|
408
|
+
const onLens = vi.fn();
|
|
409
|
+
render(<Harness shape="square" onLens={onLens} />);
|
|
410
|
+
expect(onLens).not.toHaveBeenCalled();
|
|
411
|
+
move(40, 30);
|
|
412
|
+
expect(onLens).toHaveBeenLastCalledWith({
|
|
413
|
+
center: { x: 40, y: 30 },
|
|
414
|
+
shows: { x: 40, y: 30 },
|
|
415
|
+
width: 200,
|
|
416
|
+
height: 200,
|
|
417
|
+
factor: 6,
|
|
418
|
+
shape: 'square',
|
|
419
|
+
});
|
|
420
|
+
move(50, 30);
|
|
421
|
+
expect(onLens).toHaveBeenLastCalledWith(
|
|
422
|
+
expect.objectContaining({ center: { x: 50, y: 30 } }),
|
|
423
|
+
);
|
|
424
|
+
const calls = onLens.mock.calls.length;
|
|
425
|
+
fireEvent.pointerLeave(screen.getByTestId('host'));
|
|
426
|
+
expect(onLens).toHaveBeenLastCalledWith(null);
|
|
427
|
+
expect(onLens).toHaveBeenCalledTimes(calls + 1);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('does not tell onLens again when a render changes nothing about the lens', () => {
|
|
431
|
+
const onLens = vi.fn();
|
|
432
|
+
const { rerender } = render(<Harness onLens={onLens} />);
|
|
433
|
+
move(40, 30);
|
|
434
|
+
const calls = onLens.mock.calls.length;
|
|
435
|
+
rerender(<Harness onLens={onLens} />);
|
|
436
|
+
expect(onLens).toHaveBeenCalledTimes(calls);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('tells onLens null when a lens that is up unmounts', () => {
|
|
440
|
+
const onLens = vi.fn();
|
|
441
|
+
const { unmount } = render(<Harness onLens={onLens} />);
|
|
442
|
+
move(40, 30);
|
|
443
|
+
unmount();
|
|
444
|
+
expect(onLens).toHaveBeenLastCalledWith(null);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('picks what a placed lens shows, about its own center and factor', () => {
|
|
448
|
+
const seen: LoupeState[] = [];
|
|
449
|
+
render(
|
|
450
|
+
<Harness
|
|
451
|
+
sample={(p) => `#${Math.round(p.x)}`}
|
|
452
|
+
place={() => ({ center: { x: 300, y: 50 }, width: 400, height: 40, factor: 2 })}
|
|
453
|
+
seen={(l) => seen.push(l)}
|
|
454
|
+
/>,
|
|
455
|
+
);
|
|
456
|
+
move(100, 50);
|
|
457
|
+
// 100px right of the lens's middle, at 2x, is 50 page px right of the
|
|
458
|
+
// center it shows — not of the aim.
|
|
459
|
+
expect(last(seen).pick({ x: 300 + 100, y: 50 })).toBe('#350');
|
|
460
|
+
// The color under the aim is still the aim's.
|
|
461
|
+
expect(read('color')).toBe('#100');
|
|
462
|
+
});
|
|
463
|
+
});
|
|
279
464
|
});
|