@weasel-js/labkit 1.7.3 → 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-Cjy6I-Iv.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
- package/dist/_dts/{Stage-D6t7o0pF.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
- package/dist/_dts/{index-DCMA4Fy1.d.ts → index-8geQNQet.d.ts} +84 -12
- package/dist/_dts/{index-BTmaRy6a.d.ts → index-DRi3V5sN.d.ts} +6 -4
- package/dist/_dts/{types-BUBSIZuV.d.ts → types-DUfQ0isU.d.ts} +5 -1
- package/dist/_dts/{useTrialState-Bh8bzFgE.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
- package/dist/canvas/index.d.ts +5 -5
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +3 -3
- package/dist/chrome/index.js +7 -7
- package/dist/{chunk-HBGH344L.js → chunk-4RCVWX4M.js} +55 -18
- 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-LPGBC63T.js → chunk-KHB3KRRQ.js} +7 -7
- package/dist/chunk-KHB3KRRQ.js.map +1 -0
- package/dist/{chunk-IBVRMHW5.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-4X44DMRS.js → chunk-RN7T7OBO.js} +46 -24
- package/dist/chunk-RN7T7OBO.js.map +1 -0
- package/dist/{chunk-4TJ34FXA.js → chunk-RXO5UEGT.js} +5 -12
- package/dist/chunk-RXO5UEGT.js.map +1 -0
- package/dist/{chunk-E6NARGU7.js → chunk-SQX35X5V.js} +3 -3
- package/dist/{chunk-E6NARGU7.js.map → chunk-SQX35X5V.js.map} +1 -1
- package/dist/{chunk-YCFKW5RX.js → chunk-T5OEUNCB.js} +16 -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-IRMPZIYU.js → chunk-V4SMGEYY.js} +6732 -6507
- 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-XSJJTXNA.js → chunk-YZA73U4A.js} +19 -19
- package/dist/chunk-YZA73U4A.js.map +1 -0
- package/dist/config/index.d.ts +12 -5
- package/dist/config/index.js +2 -2
- package/dist/controls/index.d.ts +2 -2
- package/dist/controls/index.js +5 -5
- package/dist/dragdrop/index.d.ts +2 -2
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +158 -16
- package/dist/index.js +1036 -415
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +176 -28
- package/dist/loupe/index.js +547 -141
- package/dist/loupe/index.js.map +1 -1
- package/dist/overview/index.d.ts +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 +6 -8
- package/dist/state/index.js +5 -5
- package/dist/styles.css +181 -44
- package/dist/surface/index.d.ts +20 -1
- package/dist/surface/index.js +2 -2
- package/dist/undo/index.d.ts +19 -8
- 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/builder.ts +11 -1
- package/src/config/resolve.test.ts +12 -0
- package/src/config/resolve.ts +5 -0
- package/src/config/types.ts +5 -1
- package/src/controls/ControlMatrix.test.tsx +10 -0
- package/src/controls/ControlMatrix.tsx +110 -58
- package/src/controls/ControlPanel.test.tsx +30 -0
- 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/theme/interstellar.theme.json +0 -14
- 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-4TJ34FXA.js.map +0 -1
- package/dist/chunk-4X44DMRS.js.map +0 -1
- package/dist/chunk-EESJUSYR.js.map +0 -1
- package/dist/chunk-HBGH344L.js.map +0 -1
- package/dist/chunk-IBVRMHW5.js.map +0 -1
- package/dist/chunk-IRMPZIYU.js.map +0 -1
- package/dist/chunk-LPGBC63T.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-XSJJTXNA.js.map +0 -1
- package/dist/chunk-YCFKW5RX.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
|
@@ -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
|
+
});
|
package/src/loupe/TrialLoupe.tsx
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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 =
|
|
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={
|
|
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={
|
|
128
|
-
factor={
|
|
217
|
+
aim={shows}
|
|
218
|
+
factor={shownFactor}
|
|
129
219
|
mode={loupe.mode}
|
|
130
|
-
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={
|
|
143
|
-
factor={
|
|
240
|
+
aim={shows}
|
|
241
|
+
factor={shownFactor}
|
|
144
242
|
mode={loupe.mode}
|
|
145
|
-
diameter={
|
|
243
|
+
diameter={box}
|
|
146
244
|
surface={surface}
|
|
147
245
|
view={stack.view}
|
|
148
246
|
frame={stack.frame}
|
package/src/loupe/canvasLens.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type
|
|
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
|
|
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
|
-
|
|
27
|
+
size: LoupeSize,
|
|
28
28
|
worldSpec?: WorldSpec,
|
|
29
29
|
): LensCamera {
|
|
30
|
-
const
|
|
31
|
-
const frame = resolveFrame(worldSpec,
|
|
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,
|
|
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:
|
|
46
|
-
*
|
|
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
|
-
|
|
51
|
+
size: LoupeSize,
|
|
52
52
|
dpr: number,
|
|
53
53
|
): SourceRect {
|
|
54
|
-
const
|
|
54
|
+
const { width, height } = loupeExtent(size);
|
|
55
|
+
const spanX = width / factor;
|
|
56
|
+
const spanY = height / factor;
|
|
55
57
|
return {
|
|
56
|
-
sx: (aim.x -
|
|
57
|
-
sy: (aim.y -
|
|
58
|
-
sw:
|
|
59
|
-
sh:
|
|
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:
|
|
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,
|
|
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,
|
|
123
|
+
ctx.drawImage(canvas, sx, sy, sw, sh, 0, 0, width, height);
|
|
121
124
|
}
|
|
122
125
|
ctx.restore();
|
|
123
126
|
return;
|