@weasel-js/labkit 1.3.0 → 1.4.0-pre.0
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/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
- package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
- package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
- package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
- package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
- package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
- package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
- package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
- package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
- package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
- package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
- package/dist/canvas/index.d.ts +43 -25
- package/dist/canvas/index.js +5 -4
- package/dist/chrome/index.d.ts +9 -6
- package/dist/chrome/index.js +6 -6
- package/dist/chunk-2AYGEN57.js +32 -0
- package/dist/chunk-2AYGEN57.js.map +1 -0
- package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
- package/dist/chunk-3P5TTTA7.js.map +1 -0
- package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
- package/dist/chunk-3ZELRRBV.js.map +1 -0
- package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
- package/dist/chunk-4EY67BKJ.js.map +1 -0
- package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
- package/dist/chunk-5TCEVMPI.js.map +1 -0
- package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
- package/dist/chunk-6JMU56CU.js.map +1 -0
- package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
- package/dist/chunk-CQLQPQ4P.js.map +1 -0
- package/dist/chunk-CTRKTLYZ.js +33 -0
- package/dist/chunk-CTRKTLYZ.js.map +1 -0
- package/dist/chunk-EQIB2PGC.js +458 -0
- package/dist/chunk-EQIB2PGC.js.map +1 -0
- package/dist/chunk-HEORBLVT.js +7128 -0
- package/dist/chunk-HEORBLVT.js.map +1 -0
- package/dist/{chunk-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
- package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
- package/dist/chunk-LITE5YFB.js +669 -0
- package/dist/chunk-LITE5YFB.js.map +1 -0
- package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
- package/dist/chunk-ROQDRXKW.js.map +1 -0
- package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
- package/dist/chunk-XJ6N32QP.js.map +1 -0
- package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
- package/dist/chunk-YQJNT4XQ.js.map +1 -0
- package/dist/controls/index.d.ts +4 -28
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +9 -5
- package/dist/dragdrop/index.js +3 -3
- package/dist/index.d.ts +518 -190
- package/dist/index.js +1753 -75
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +6 -5
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +187 -0
- package/dist/loupe/index.js +7 -0
- package/dist/loupe/index.js.map +1 -0
- package/dist/passthrough/weasel-canvas.d.ts +3 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +138 -91
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +3 -1
- package/dist/primitives/index.js +5 -5
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/styles.css +229 -1
- package/dist/surface/index.d.ts +18 -2
- package/dist/surface/index.js +2 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +5 -4
- package/package.json +14 -7
- package/src/annotations/AnnotationOverlay.tsx +205 -0
- package/src/annotations/AnnotationTargets.tsx +37 -0
- package/src/annotations/Annotations.less +129 -0
- package/src/annotations/Annotations.meaning.test.tsx +111 -0
- package/src/annotations/Annotations.overlay.test.tsx +142 -0
- package/src/annotations/AnnotationsContext.ts +49 -0
- package/src/annotations/ExportMenu.test.tsx +86 -0
- package/src/annotations/ExportMenu.tsx +182 -0
- package/src/annotations/MarkList.tsx +75 -0
- package/src/annotations/capture.test.ts +140 -0
- package/src/annotations/capture.ts +237 -0
- package/src/annotations/drawOne.test.ts +56 -0
- package/src/annotations/drawOne.ts +47 -0
- package/src/annotations/frac.test.ts +59 -0
- package/src/annotations/frac.ts +66 -0
- package/src/annotations/history.test.ts +82 -0
- package/src/annotations/history.ts +118 -0
- package/src/annotations/index.ts +37 -0
- package/src/annotations/paint.test.ts +125 -0
- package/src/annotations/paint.ts +130 -0
- package/src/annotations/staleness.test.ts +56 -0
- package/src/annotations/staleness.ts +41 -0
- package/src/annotations/store.test.ts +175 -0
- package/src/annotations/store.ts +317 -0
- package/src/annotations/svgNodes.test.ts +77 -0
- package/src/annotations/svgNodes.ts +74 -0
- package/src/annotations/toolMap.test.ts +36 -0
- package/src/annotations/toolMap.ts +54 -0
- package/src/annotations/types.ts +222 -0
- package/src/annotations/view.test.ts +37 -0
- package/src/annotations/view.ts +40 -0
- package/src/canvas/AGENTS.md +55 -5
- package/src/canvas/CanvasStack.test.tsx +27 -1
- package/src/canvas/CanvasStack.tsx +29 -4
- package/src/canvas/CanvasStackContext.ts +23 -4
- package/src/canvas/camera.test.ts +78 -0
- package/src/canvas/camera.ts +62 -0
- package/src/canvas/canvasCoords.test.ts +26 -0
- package/src/canvas/canvasCoords.ts +20 -7
- package/src/canvas/index.ts +5 -1
- package/src/canvas/useLayerScheduler.ts +7 -3
- package/src/canvas/usePanZoom.test.ts +36 -5
- package/src/canvas/usePanZoom.ts +20 -15
- package/src/canvas/worldSpec.test.ts +63 -0
- package/src/canvas/worldSpec.ts +48 -0
- package/src/chrome/builtins.test.ts +31 -1
- package/src/chrome/builtins.tsx +63 -19
- package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
- package/src/chrome/regions/PaletteRegion.tsx +4 -0
- package/src/chrome/regions/SidebarRegion.tsx +37 -13
- package/src/chrome/regions/ToolbarRegion.tsx +2 -1
- package/src/chrome/regions/ViewportRegion.tsx +9 -1
- package/src/chrome/regions/regions.test.tsx +6 -1
- package/src/chrome/types.ts +20 -0
- package/src/controls/ControlPanel.test.tsx +38 -0
- package/src/controls/ControlPanel.tsx +50 -3
- package/src/dragdrop/DragDropRuntime.tsx +8 -2
- package/src/dragdrop/dragDrop.test.tsx +29 -2
- package/src/index.test.ts +22 -0
- package/src/index.ts +87 -2
- package/src/instrument/SineWave.smoke.test.tsx +1 -0
- package/src/instrument/types.ts +24 -1
- package/src/lab/Lab.surface.test.tsx +162 -0
- package/src/lab/Lab.tsx +102 -17
- package/src/lab/Lab.undock.test.tsx +85 -0
- package/src/lab/LabFullChrome.stories.tsx +20 -0
- package/src/lab/LabShell.less +11 -0
- package/src/lab/Workspace.less +73 -0
- package/src/lab/Workspace.surface.test.tsx +2 -0
- package/src/lab/Workspace.tsx +97 -8
- package/src/lab/index.ts +1 -1
- package/src/lab/panelHost.ts +37 -0
- package/src/loupe/AGENTS.md +49 -0
- package/src/loupe/CanvasLoupe.tsx +83 -0
- package/src/loupe/DomLoupe.tsx +62 -0
- package/src/loupe/Loupe.less +38 -0
- package/src/loupe/LoupeBubble.tsx +32 -0
- package/src/loupe/TrialLoupe.tsx +93 -0
- package/src/loupe/canvasLens.test.ts +205 -0
- package/src/loupe/canvasLens.ts +141 -0
- package/src/loupe/index.ts +19 -0
- package/src/loupe/types.test.ts +29 -0
- package/src/loupe/types.ts +90 -0
- package/src/loupe/useHostSize.ts +30 -0
- package/src/loupe/useLoupe.test.tsx +197 -0
- package/src/loupe/useLoupe.ts +189 -0
- package/src/passthrough/weasel-ui.ts +3 -0
- package/src/primitives/FloatingPanel.test.tsx +76 -1
- package/src/primitives/FloatingPanel.tsx +19 -3
- package/src/primitives/ScaleIndicator.test.tsx +7 -2
- package/src/primitives/Sidebar.less +18 -0
- package/src/primitives/Toolbar.less +8 -0
- package/src/primitives/Toolbar.tsx +5 -0
- package/src/primitives/useRovingTabIndex.test.ts +21 -0
- package/src/primitives/useRovingTabIndex.ts +26 -17
- package/src/state/document.test.ts +23 -0
- package/src/state/document.ts +14 -2
- package/src/state/index.ts +2 -0
- package/src/state/store.test.ts +24 -0
- package/src/state/store.ts +35 -1
- package/src/state/types.ts +11 -0
- package/src/state/undock.test.ts +40 -0
- package/src/state/undock.ts +39 -0
- package/src/styles.less +2 -0
- package/src/surface/SurfaceContext.ts +4 -0
- package/src/surface/index.ts +8 -3
- package/src/surface/useSurfaceTile.test.tsx +2 -0
- package/src/surface/useSurfaceTile.ts +7 -1
- package/src/surface/useTiledSurface.test.tsx +58 -0
- package/src/surface/useTiledSurface.ts +38 -3
- package/src/trial/Trial.annotations.persist.test.tsx +166 -0
- package/src/trial/Trial.annotations.test.tsx +121 -0
- package/src/trial/Trial.canvas.test.tsx +76 -1
- package/src/trial/Trial.less +19 -0
- package/src/trial/Trial.loupe.test.tsx +128 -0
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +12 -0
- package/src/trial/Trial.trialId.test.tsx +36 -0
- package/src/trial/Trial.tsx +215 -40
- package/src/trial/TrialChrome.tsx +35 -0
- package/src/trial/UndockedSections.tsx +65 -0
- package/src/trial/trialOps.test.ts +21 -0
- package/src/trial/trialOps.ts +5 -3
- package/dist/_dts/types-BttTIent.d.ts +0 -314
- package/dist/chunk-3XXPU73K.js.map +0 -1
- package/dist/chunk-4TMMVIDM.js.map +0 -1
- package/dist/chunk-6X5RBC6G.js +0 -366
- package/dist/chunk-6X5RBC6G.js.map +0 -1
- package/dist/chunk-BDJXIBRZ.js.map +0 -1
- package/dist/chunk-CPUJ3QXL.js.map +0 -1
- package/dist/chunk-JDROYQM3.js.map +0 -1
- package/dist/chunk-LGKRIUSW.js.map +0 -1
- package/dist/chunk-QCICIHEO.js.map +0 -1
- package/dist/chunk-QRYPXSGP.js.map +0 -1
- package/dist/chunk-RGT4EJIB.js +0 -6961
- package/dist/chunk-RGT4EJIB.js.map +0 -1
- package/dist/chunk-T66HU3HX.js +0 -17
- package/dist/chunk-T66HU3HX.js.map +0 -1
- package/dist/chunk-WTI26YTM.js.map +0 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { worldToScreen } from '../canvas/canvasCoords';
|
|
3
|
+
import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
|
|
4
|
+
import { DEFAULT_FRAME, resolveFrame } from '../canvas/worldSpec';
|
|
5
|
+
import { drawCanvasLens, lensCamera, lensSourceRect, sampleStack } from './canvasLens';
|
|
6
|
+
|
|
7
|
+
const outer = { zoom: 2, pan: { x: 30, y: -10 } };
|
|
8
|
+
|
|
9
|
+
describe('lensCamera', () => {
|
|
10
|
+
it('multiplies the stack zoom by the factor', () => {
|
|
11
|
+
expect(lensCamera({ x: 50, y: 50 }, outer, DEFAULT_FRAME, 6, 200).view.zoom).toBe(12);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('puts the aimed world point at the middle of the lens', () => {
|
|
15
|
+
const aim = { x: 137, y: 61 };
|
|
16
|
+
const lens = lensCamera(aim, outer, DEFAULT_FRAME, 6, 200);
|
|
17
|
+
// The world point the stack shows at `aim`, projected through the lens.
|
|
18
|
+
const world = {
|
|
19
|
+
x: (aim.x - outer.pan.x) / outer.zoom,
|
|
20
|
+
y: (aim.y - outer.pan.y) / outer.zoom,
|
|
21
|
+
};
|
|
22
|
+
const p = worldToScreen(world, lens.view, lens.frame);
|
|
23
|
+
expect(p.x).toBeCloseTo(100, 8);
|
|
24
|
+
expect(p.y).toBeCloseTo(100, 8);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('resolves the world spec against the lens, not the stack', () => {
|
|
28
|
+
const spec = { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' as const };
|
|
29
|
+
const stackFrame = resolveFrame(spec, { width: 800, height: 600 });
|
|
30
|
+
const lens = lensCamera({ x: 137, y: 61 }, outer, stackFrame, 6, 200, spec);
|
|
31
|
+
expect(lens.frame.originPx).toEqual({ x: 100, y: 100 });
|
|
32
|
+
expect(lens.frame.yDir).toBe(-1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('centres the same world point on a flipped, re-origined frame', () => {
|
|
36
|
+
const spec = { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' as const };
|
|
37
|
+
const stackFrame = resolveFrame(spec, { width: 800, height: 600 });
|
|
38
|
+
const aim = { x: 137, y: 61 };
|
|
39
|
+
const lens = lensCamera(aim, outer, stackFrame, 6, 200, spec);
|
|
40
|
+
const world = {
|
|
41
|
+
x: (aim.x - stackFrame.originPx.x - outer.pan.x) / outer.zoom,
|
|
42
|
+
y: (aim.y - stackFrame.originPx.y - outer.pan.y) / (outer.zoom * stackFrame.yDir),
|
|
43
|
+
};
|
|
44
|
+
const p = worldToScreen(world, lens.view, lens.frame);
|
|
45
|
+
expect(p.x).toBeCloseTo(100, 8);
|
|
46
|
+
expect(p.y).toBeCloseTo(100, 8);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('lensSourceRect', () => {
|
|
51
|
+
it('copies the diameter-over-factor CSS px around the aim', () => {
|
|
52
|
+
expect(lensSourceRect({ x: 100, y: 50 }, 4, 200, 1)).toEqual({
|
|
53
|
+
sx: 75,
|
|
54
|
+
sy: 25,
|
|
55
|
+
sw: 50,
|
|
56
|
+
sh: 50,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('is measured in backing-store pixels', () => {
|
|
61
|
+
expect(lensSourceRect({ x: 100, y: 50 }, 4, 200, 2)).toEqual({
|
|
62
|
+
sx: 150,
|
|
63
|
+
sy: 50,
|
|
64
|
+
sw: 100,
|
|
65
|
+
sh: 100,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function fakeCanvas(pixel: [number, number, number, number] | null): HTMLCanvasElement {
|
|
71
|
+
return {
|
|
72
|
+
width: 100,
|
|
73
|
+
height: 100,
|
|
74
|
+
getContext: () =>
|
|
75
|
+
pixel === null ? null : { getImageData: () => ({ data: Uint8ClampedArray.from(pixel) }) },
|
|
76
|
+
} as unknown as HTMLCanvasElement;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('sampleStack', () => {
|
|
80
|
+
const layers = (ids: string[]): CanvasLayerDescriptor[] =>
|
|
81
|
+
ids.map((id) => ({ id, visible: true, render: vi.fn() }));
|
|
82
|
+
|
|
83
|
+
it('reports the topmost layer with something opaque there', () => {
|
|
84
|
+
const canvases = new Map([
|
|
85
|
+
['under', fakeCanvas([0, 0, 255, 255])],
|
|
86
|
+
['over', fakeCanvas([255, 0, 0, 255])],
|
|
87
|
+
]);
|
|
88
|
+
expect(sampleStack(layers(['under', 'over']), canvases, { x: 5, y: 5 }, 1)).toBe('#ff0000');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('falls through a transparent pixel to the layer below', () => {
|
|
92
|
+
const canvases = new Map([
|
|
93
|
+
['under', fakeCanvas([0, 0, 255, 255])],
|
|
94
|
+
['over', fakeCanvas([255, 0, 0, 0])],
|
|
95
|
+
]);
|
|
96
|
+
expect(sampleStack(layers(['under', 'over']), canvases, { x: 5, y: 5 }, 1)).toBe('#0000ff');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('skips a hidden layer even when it has pixels', () => {
|
|
100
|
+
const stack = layers(['under', 'over']);
|
|
101
|
+
stack[1].visible = false;
|
|
102
|
+
const canvases = new Map([
|
|
103
|
+
['under', fakeCanvas([0, 0, 255, 255])],
|
|
104
|
+
['over', fakeCanvas([255, 0, 0, 255])],
|
|
105
|
+
]);
|
|
106
|
+
expect(sampleStack(stack, canvases, { x: 5, y: 5 }, 1)).toBe('#0000ff');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('says nothing rather than black when every layer is transparent', () => {
|
|
110
|
+
const canvases = new Map([['only', fakeCanvas([0, 0, 0, 0])]]);
|
|
111
|
+
expect(sampleStack(layers(['only']), canvases, { x: 5, y: 5 }, 1)).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('says nothing for a point off the backing store', () => {
|
|
115
|
+
const canvases = new Map([['only', fakeCanvas([255, 0, 0, 255])]]);
|
|
116
|
+
expect(sampleStack(layers(['only']), canvases, { x: 400, y: 5 }, 1)).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('pads a single-digit channel, so #0a0a0a is not #aaa', () => {
|
|
120
|
+
const canvases = new Map([['only', fakeCanvas([10, 10, 10, 255])]]);
|
|
121
|
+
expect(sampleStack(layers(['only']), canvases, { x: 5, y: 5 }, 1)).toBe('#0a0a0a');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function recordingContext(): CanvasRenderingContext2D & { calls: unknown[][] } {
|
|
126
|
+
const calls: unknown[][] = [];
|
|
127
|
+
const ctx = {
|
|
128
|
+
calls,
|
|
129
|
+
imageSmoothingEnabled: true,
|
|
130
|
+
save: () => calls.push(['save']),
|
|
131
|
+
restore: () => calls.push(['restore']),
|
|
132
|
+
setTransform: (...a: unknown[]) => calls.push(['setTransform', ...a]),
|
|
133
|
+
clearRect: (...a: unknown[]) => calls.push(['clearRect', ...a]),
|
|
134
|
+
drawImage: (...a: unknown[]) => calls.push(['drawImage', ...a]),
|
|
135
|
+
};
|
|
136
|
+
return ctx as unknown as CanvasRenderingContext2D & { calls: unknown[][] };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe('drawCanvasLens', () => {
|
|
140
|
+
const base = {
|
|
141
|
+
aim: { x: 100, y: 50 },
|
|
142
|
+
factor: 4,
|
|
143
|
+
diameter: 200,
|
|
144
|
+
dpr: 1,
|
|
145
|
+
outer,
|
|
146
|
+
layers: [] as CanvasLayerDescriptor[],
|
|
147
|
+
canvases: new Map<string, HTMLCanvasElement>(),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
it('re-runs each visible layer at the lens camera in vector mode', () => {
|
|
151
|
+
const under = vi.fn();
|
|
152
|
+
const over = vi.fn();
|
|
153
|
+
const hidden = vi.fn();
|
|
154
|
+
const layers: CanvasLayerDescriptor[] = [
|
|
155
|
+
{ id: 'under', visible: true, render: under },
|
|
156
|
+
{ id: 'hidden', visible: false, render: hidden },
|
|
157
|
+
{ id: 'over', visible: true, render: over },
|
|
158
|
+
];
|
|
159
|
+
const ctx = recordingContext();
|
|
160
|
+
drawCanvasLens(ctx, { ...base, mode: 'vector', layers });
|
|
161
|
+
|
|
162
|
+
expect(hidden).not.toHaveBeenCalled();
|
|
163
|
+
const expected = lensCamera(base.aim, outer, DEFAULT_FRAME, 4, 200);
|
|
164
|
+
for (const fn of [under, over]) {
|
|
165
|
+
expect(fn).toHaveBeenCalledWith(ctx, expected.view, expected.frame);
|
|
166
|
+
}
|
|
167
|
+
// Bottom layer first, so the stack composites the way the trial shows it.
|
|
168
|
+
expect(under.mock.invocationCallOrder[0]).toBeLessThan(over.mock.invocationCallOrder[0]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('enlarges the presented pixels with smoothing off in pixel mode', () => {
|
|
172
|
+
const canvases = new Map([['only', fakeCanvas([0, 0, 0, 255])]]);
|
|
173
|
+
const layers: CanvasLayerDescriptor[] = [{ id: 'only', visible: true, render: vi.fn() }];
|
|
174
|
+
const ctx = recordingContext();
|
|
175
|
+
drawCanvasLens(ctx, { ...base, mode: 'pixel', layers, canvases });
|
|
176
|
+
|
|
177
|
+
expect(ctx.imageSmoothingEnabled).toBe(false);
|
|
178
|
+
const draw = ctx.calls.find((c) => c[0] === 'drawImage');
|
|
179
|
+
expect(draw?.slice(2)).toEqual([75, 25, 50, 50, 0, 0, 200, 200]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('does not re-run a layer draw in pixel mode', () => {
|
|
183
|
+
const render = vi.fn();
|
|
184
|
+
const canvases = new Map([['only', fakeCanvas([0, 0, 0, 255])]]);
|
|
185
|
+
drawCanvasLens(recordingContext(), {
|
|
186
|
+
...base,
|
|
187
|
+
mode: 'pixel',
|
|
188
|
+
layers: [{ id: 'only', visible: true, render }],
|
|
189
|
+
canvases,
|
|
190
|
+
});
|
|
191
|
+
expect(render).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('skips a backing store with no pixels rather than throwing', () => {
|
|
195
|
+
const empty = { width: 0, height: 0, getContext: () => null } as unknown as HTMLCanvasElement;
|
|
196
|
+
const ctx = recordingContext();
|
|
197
|
+
drawCanvasLens(ctx, {
|
|
198
|
+
...base,
|
|
199
|
+
mode: 'pixel',
|
|
200
|
+
layers: [{ id: 'only', visible: true, render: vi.fn() }],
|
|
201
|
+
canvases: new Map([['only', empty]]),
|
|
202
|
+
});
|
|
203
|
+
expect(ctx.calls.some((c) => c[0] === 'drawImage')).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { LoupePoint } from '@weasel-js/loupe';
|
|
2
|
+
import { centerOn } from '../canvas/camera';
|
|
3
|
+
import { screenToWorld } from '../canvas/canvasCoords';
|
|
4
|
+
import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
|
|
5
|
+
import { DEFAULT_FRAME, resolveFrame, type WorldFrame, type WorldSpec } from '../canvas/worldSpec';
|
|
6
|
+
import type { ViewTransform } from '../instrument/types';
|
|
7
|
+
|
|
8
|
+
/** The camera a round lens of `diameter` shows a magnified region through, and
|
|
9
|
+
* the coordinate system it is read in — the instrument's own `WorldSpec`,
|
|
10
|
+
* resolved against the lens rather than the stack. */
|
|
11
|
+
export interface LensCamera {
|
|
12
|
+
view: ViewTransform;
|
|
13
|
+
frame: WorldFrame;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a lens aimed at `aim` renders the stack's layers through.
|
|
18
|
+
*
|
|
19
|
+
* The lens is its own viewport, so the world spec resolves against its box: an
|
|
20
|
+
* instrument centred on its viewport is centred in the lens too.
|
|
21
|
+
*/
|
|
22
|
+
export function lensCamera(
|
|
23
|
+
aim: LoupePoint,
|
|
24
|
+
outer: ViewTransform,
|
|
25
|
+
outerFrame: WorldFrame,
|
|
26
|
+
factor: number,
|
|
27
|
+
diameter: number,
|
|
28
|
+
worldSpec?: WorldSpec,
|
|
29
|
+
): LensCamera {
|
|
30
|
+
const size = { width: diameter, height: diameter };
|
|
31
|
+
const frame = resolveFrame(worldSpec, size);
|
|
32
|
+
const world = screenToWorld(aim, outer, outerFrame);
|
|
33
|
+
return { view: centerOn(world, outer.zoom * factor, size, frame), frame };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A rectangle in a backing store's own device pixels. */
|
|
37
|
+
export interface SourceRect {
|
|
38
|
+
sx: number;
|
|
39
|
+
sy: number;
|
|
40
|
+
sw: number;
|
|
41
|
+
sh: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The region a pixel-mode lens copies out of a presented canvas: the `diameter
|
|
46
|
+
* / factor` CSS px around `aim`, in that canvas' backing-store pixels.
|
|
47
|
+
*/
|
|
48
|
+
export function lensSourceRect(
|
|
49
|
+
aim: LoupePoint,
|
|
50
|
+
factor: number,
|
|
51
|
+
diameter: number,
|
|
52
|
+
dpr: number,
|
|
53
|
+
): SourceRect {
|
|
54
|
+
const span = diameter / factor;
|
|
55
|
+
return {
|
|
56
|
+
sx: (aim.x - span / 2) * dpr,
|
|
57
|
+
sy: (aim.y - span / 2) * dpr,
|
|
58
|
+
sw: span * dpr,
|
|
59
|
+
sh: span * dpr,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The colour a stack presents at a point: the topmost visible layer with
|
|
65
|
+
* anything opaque there, or `null` when every layer is transparent — which is
|
|
66
|
+
* the honest answer for a point showing nothing but the workspace behind.
|
|
67
|
+
*/
|
|
68
|
+
export function sampleStack(
|
|
69
|
+
layers: readonly CanvasLayerDescriptor[],
|
|
70
|
+
canvases: Map<string, HTMLCanvasElement>,
|
|
71
|
+
p: LoupePoint,
|
|
72
|
+
dpr: number,
|
|
73
|
+
): string | null {
|
|
74
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
75
|
+
const layer = layers[i];
|
|
76
|
+
if (!layer.visible) continue;
|
|
77
|
+
const canvas = canvases.get(layer.id);
|
|
78
|
+
if (!canvas) continue;
|
|
79
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
80
|
+
if (!ctx) continue;
|
|
81
|
+
const x = Math.floor(p.x * dpr);
|
|
82
|
+
const y = Math.floor(p.y * dpr);
|
|
83
|
+
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) continue;
|
|
84
|
+
const [r, g, b, a] = ctx.getImageData(x, y, 1, 1).data;
|
|
85
|
+
if (a === 0) continue;
|
|
86
|
+
return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Redraw the stack into a lens' own canvas. `mode` decides whether the layers
|
|
92
|
+
* are re-run at the lens camera or their presented pixels are enlarged. */
|
|
93
|
+
export function drawCanvasLens(
|
|
94
|
+
ctx: CanvasRenderingContext2D,
|
|
95
|
+
opts: {
|
|
96
|
+
aim: LoupePoint;
|
|
97
|
+
factor: number;
|
|
98
|
+
diameter: number;
|
|
99
|
+
dpr: number;
|
|
100
|
+
mode: 'vector' | 'pixel';
|
|
101
|
+
outer: ViewTransform;
|
|
102
|
+
outerFrame?: WorldFrame;
|
|
103
|
+
worldSpec?: WorldSpec;
|
|
104
|
+
layers: readonly CanvasLayerDescriptor[];
|
|
105
|
+
canvases: Map<string, HTMLCanvasElement>;
|
|
106
|
+
},
|
|
107
|
+
): void {
|
|
108
|
+
const { aim, factor, diameter, dpr, mode, outer, layers, canvases } = opts;
|
|
109
|
+
ctx.save();
|
|
110
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
111
|
+
ctx.clearRect(0, 0, diameter, diameter);
|
|
112
|
+
|
|
113
|
+
if (mode === 'pixel') {
|
|
114
|
+
const { sx, sy, sw, sh } = lensSourceRect(aim, factor, diameter, dpr);
|
|
115
|
+
ctx.imageSmoothingEnabled = false;
|
|
116
|
+
for (const layer of layers) {
|
|
117
|
+
if (!layer.visible) continue;
|
|
118
|
+
const canvas = canvases.get(layer.id);
|
|
119
|
+
if (!canvas || canvas.width === 0 || canvas.height === 0) continue;
|
|
120
|
+
ctx.drawImage(canvas, sx, sy, sw, sh, 0, 0, diameter, diameter);
|
|
121
|
+
}
|
|
122
|
+
ctx.restore();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const lens = lensCamera(
|
|
127
|
+
aim,
|
|
128
|
+
outer,
|
|
129
|
+
opts.outerFrame ?? DEFAULT_FRAME,
|
|
130
|
+
factor,
|
|
131
|
+
diameter,
|
|
132
|
+
opts.worldSpec,
|
|
133
|
+
);
|
|
134
|
+
for (const layer of layers) {
|
|
135
|
+
if (!layer.visible) continue;
|
|
136
|
+
ctx.save();
|
|
137
|
+
layer.render(ctx, lens.view, lens.frame);
|
|
138
|
+
ctx.restore();
|
|
139
|
+
}
|
|
140
|
+
ctx.restore();
|
|
141
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type { CanvasLoupeProps } from './CanvasLoupe';
|
|
2
|
+
export { CanvasLoupe } from './CanvasLoupe';
|
|
3
|
+
export type { LensCamera, SourceRect } from './canvasLens';
|
|
4
|
+
export { drawCanvasLens, lensCamera, lensSourceRect, sampleStack } from './canvasLens';
|
|
5
|
+
export type { DomLoupeProps } from './DomLoupe';
|
|
6
|
+
export { DomLoupe } from './DomLoupe';
|
|
7
|
+
export type { LoupeBubbleProps } from './LoupeBubble';
|
|
8
|
+
export { LoupeBubble } from './LoupeBubble';
|
|
9
|
+
export type { TrialLoupeProps } from './TrialLoupe';
|
|
10
|
+
export { TrialLoupe } from './TrialLoupe';
|
|
11
|
+
export type {
|
|
12
|
+
LoupeCapability,
|
|
13
|
+
LoupeDeclaration,
|
|
14
|
+
LoupeRenderArgs,
|
|
15
|
+
ResolvedLoupe,
|
|
16
|
+
} from './types';
|
|
17
|
+
export { LOUPE_DEFAULTS, resolveLoupe } from './types';
|
|
18
|
+
export type { LoupeState, UseLoupeOptions } from './useLoupe';
|
|
19
|
+
export { useLoupe } from './useLoupe';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { LOUPE_DEFAULTS, resolveLoupe } from './types';
|
|
3
|
+
|
|
4
|
+
describe('resolveLoupe', () => {
|
|
5
|
+
it('takes every default from a bare `true`', () => {
|
|
6
|
+
expect(resolveLoupe(true)).toMatchObject(LOUPE_DEFAULTS);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('keeps what the instrument declared', () => {
|
|
10
|
+
const cap = resolveLoupe({ factor: 12, diameter: 320, minFactor: 1, maxFactor: 40 });
|
|
11
|
+
expect(cap).toMatchObject({ factor: 12, diameter: 320, minFactor: 1, maxFactor: 40 });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('clamps an opening factor outside the bounds it also declared', () => {
|
|
15
|
+
expect(resolveLoupe({ factor: 100, maxFactor: 16 }).factor).toBe(16);
|
|
16
|
+
expect(resolveLoupe({ factor: 1, minFactor: 4 }).factor).toBe(4);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('forces vector on a DOM loupe, which has no framebuffer to enlarge', () => {
|
|
20
|
+
expect(resolveLoupe({ render: () => null, mode: 'pixel' }).mode).toBe('vector');
|
|
21
|
+
expect(resolveLoupe({ mode: 'pixel' }).mode).toBe('pixel');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('distinguishes an unset peek key from one turned off', () => {
|
|
25
|
+
expect(resolveLoupe({}).peekKey).toBe('Alt');
|
|
26
|
+
expect(resolveLoupe({ peekKey: null }).peekKey).toBeNull();
|
|
27
|
+
expect(resolveLoupe({ peekKey: 'Shift' }).peekKey).toBe('Shift');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { LoupeMode } from '@weasel-js/loupe';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import type { ViewportSize } from '../canvas/worldSpec';
|
|
4
|
+
import type { ViewTransform } from '../instrument/types';
|
|
5
|
+
|
|
6
|
+
/** What a DOM instrument's loupe `render` is handed: the same data its own
|
|
7
|
+
* `render` gets, and the camera to draw it through. */
|
|
8
|
+
export interface LoupeRenderArgs<TS = unknown, TC = unknown> {
|
|
9
|
+
state: TS;
|
|
10
|
+
config: TC;
|
|
11
|
+
/** The trial's own camera composed with the magnification, about the aimed
|
|
12
|
+
* point — so drawing the same content through it magnifies in place. */
|
|
13
|
+
view: ViewTransform;
|
|
14
|
+
/** The magnification on its own, for whatever must not scale with the
|
|
15
|
+
* camera. */
|
|
16
|
+
factor: number;
|
|
17
|
+
mode: LoupeMode;
|
|
18
|
+
/** The size of the viewport `view` is written for: the trial's content well,
|
|
19
|
+
* not the lens. The lens shows a circle cut out of it. */
|
|
20
|
+
size: ViewportSize;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Declares that an instrument can be magnified.
|
|
25
|
+
*
|
|
26
|
+
* With no `render`, the loupe re-draws the instrument's canvas layers through a
|
|
27
|
+
* zoomed camera, so it stays sharp at any factor. An instrument whose content is
|
|
28
|
+
* DOM supplies `render` instead: given a camera, draw me again.
|
|
29
|
+
*/
|
|
30
|
+
export interface LoupeCapability<TS = unknown, TC = unknown> {
|
|
31
|
+
render?: (args: LoupeRenderArgs<TS, TC>) => ReactNode;
|
|
32
|
+
/** Opening magnification, clamped to the bounds below. Default 6. */
|
|
33
|
+
factor?: number;
|
|
34
|
+
/** What the wheel clamps to. Defaults 2 and 32. */
|
|
35
|
+
minFactor?: number;
|
|
36
|
+
maxFactor?: number;
|
|
37
|
+
/** `'vector'` (default) re-renders the content magnified; `'pixel'` blows up
|
|
38
|
+
* the pixels the instrument presented. A `render` loupe is always vector —
|
|
39
|
+
* DOM has no framebuffer to enlarge. */
|
|
40
|
+
mode?: LoupeMode;
|
|
41
|
+
/** Lens diameter in CSS px. Default 200. */
|
|
42
|
+
diameter?: number;
|
|
43
|
+
/** Held for a momentary peek while the loupe is off. Default `'Alt'`; `null`
|
|
44
|
+
* turns hold-to-peek off. Matched against `KeyboardEvent.key`. */
|
|
45
|
+
peekKey?: string | null;
|
|
46
|
+
/** Called with the colour under the aim, wherever the surface can say. The
|
|
47
|
+
* canvas painter reads it back; a DOM loupe has no pixels to sample. */
|
|
48
|
+
onColorChange?: (hex: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An instrument's loupe declaration. `true` takes every default. */
|
|
52
|
+
export type LoupeDeclaration<TS = unknown, TC = unknown> = true | LoupeCapability<TS, TC>;
|
|
53
|
+
|
|
54
|
+
/** A {@link LoupeCapability} with every default filled in. */
|
|
55
|
+
export interface ResolvedLoupe<TS = unknown, TC = unknown> {
|
|
56
|
+
render?: (args: LoupeRenderArgs<TS, TC>) => ReactNode;
|
|
57
|
+
onColorChange?: (hex: string) => void;
|
|
58
|
+
factor: number;
|
|
59
|
+
minFactor: number;
|
|
60
|
+
maxFactor: number;
|
|
61
|
+
mode: LoupeMode;
|
|
62
|
+
diameter: number;
|
|
63
|
+
peekKey: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const LOUPE_DEFAULTS = {
|
|
67
|
+
factor: 6,
|
|
68
|
+
minFactor: 2,
|
|
69
|
+
maxFactor: 32,
|
|
70
|
+
mode: 'vector',
|
|
71
|
+
diameter: 200,
|
|
72
|
+
peekKey: 'Alt',
|
|
73
|
+
} as const satisfies Omit<ResolvedLoupe, 'render' | 'onColorChange'>;
|
|
74
|
+
|
|
75
|
+
export function resolveLoupe<TS, TC>(declared: LoupeDeclaration<TS, TC>): ResolvedLoupe<TS, TC> {
|
|
76
|
+
const cap = declared === true ? {} : declared;
|
|
77
|
+
const minFactor = cap.minFactor ?? LOUPE_DEFAULTS.minFactor;
|
|
78
|
+
const maxFactor = cap.maxFactor ?? LOUPE_DEFAULTS.maxFactor;
|
|
79
|
+
return {
|
|
80
|
+
render: cap.render,
|
|
81
|
+
onColorChange: cap.onColorChange,
|
|
82
|
+
minFactor,
|
|
83
|
+
maxFactor,
|
|
84
|
+
factor: Math.min(maxFactor, Math.max(minFactor, cap.factor ?? LOUPE_DEFAULTS.factor)),
|
|
85
|
+
diameter: cap.diameter ?? LOUPE_DEFAULTS.diameter,
|
|
86
|
+
peekKey: cap.peekKey === undefined ? LOUPE_DEFAULTS.peekKey : cap.peekKey,
|
|
87
|
+
// A DOM loupe has no framebuffer, so `pixel` would have nothing to enlarge.
|
|
88
|
+
mode: cap.render ? 'vector' : (cap.mode ?? LOUPE_DEFAULTS.mode),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type RefObject, useEffect, useState } from 'react';
|
|
2
|
+
import type { ViewportSize } from '../canvas/worldSpec';
|
|
3
|
+
|
|
4
|
+
const ZERO: ViewportSize = { width: 0, height: 0 };
|
|
5
|
+
|
|
6
|
+
/** An element's CSS-pixel box, kept current. `{0, 0}` until it is measured, and
|
|
7
|
+
* wherever `ResizeObserver` is absent. */
|
|
8
|
+
export function useHostSize(ref: RefObject<HTMLElement | null>): ViewportSize {
|
|
9
|
+
const [size, setSize] = useState<ViewportSize>(ZERO);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const el = ref.current;
|
|
13
|
+
if (!el) return;
|
|
14
|
+
const update = (): void => {
|
|
15
|
+
const rect = el.getBoundingClientRect();
|
|
16
|
+
setSize((prev) =>
|
|
17
|
+
prev.width === rect.width && prev.height === rect.height
|
|
18
|
+
? prev
|
|
19
|
+
: { width: rect.width, height: rect.height },
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
update();
|
|
23
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
24
|
+
const ro = new ResizeObserver(update);
|
|
25
|
+
ro.observe(el);
|
|
26
|
+
return () => ro.disconnect();
|
|
27
|
+
}, [ref]);
|
|
28
|
+
|
|
29
|
+
return size;
|
|
30
|
+
}
|