@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,195 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Lightbox } from './Lightbox';
|
|
5
|
+
import { useLightboxControl } from './LightboxContext';
|
|
6
|
+
|
|
7
|
+
function root(): HTMLElement {
|
|
8
|
+
return document.querySelector('.lk-lightbox') as HTMLElement;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const expanded = (): boolean => root().classList.contains('lk-lightbox--expanded');
|
|
12
|
+
|
|
13
|
+
describe('Lightbox', () => {
|
|
14
|
+
it('does not open on a double-click unless asked to', () => {
|
|
15
|
+
render(
|
|
16
|
+
<Lightbox>
|
|
17
|
+
<div data-testid="out">output</div>
|
|
18
|
+
</Lightbox>,
|
|
19
|
+
);
|
|
20
|
+
fireEvent.doubleClick(screen.getByTestId('out'));
|
|
21
|
+
expect(expanded()).toBe(false);
|
|
22
|
+
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('opens on a double-click on its content when asked to, and closes on another', () => {
|
|
26
|
+
render(
|
|
27
|
+
<Lightbox expandOnDoubleClick>
|
|
28
|
+
<div data-testid="out">output</div>
|
|
29
|
+
</Lightbox>,
|
|
30
|
+
);
|
|
31
|
+
fireEvent.doubleClick(screen.getByTestId('out'));
|
|
32
|
+
expect(expanded()).toBe(true);
|
|
33
|
+
expect(root().getAttribute('role')).toBe('dialog');
|
|
34
|
+
fireEvent.doubleClick(screen.getByTestId('out'));
|
|
35
|
+
expect(expanded()).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('ignores a double-click on a control, on an opted-out region, or one already handled', () => {
|
|
39
|
+
render(
|
|
40
|
+
<Lightbox expandOnDoubleClick>
|
|
41
|
+
<button type="button">Run</button>
|
|
42
|
+
<input aria-label="n" />
|
|
43
|
+
<div data-lk-lightbox-ignore="">
|
|
44
|
+
<span data-testid="ignored">x</span>
|
|
45
|
+
</div>
|
|
46
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: the subject is a handler that claims the gesture */}
|
|
47
|
+
<div data-testid="claimed" onDoubleClick={(e) => e.preventDefault()} />
|
|
48
|
+
</Lightbox>,
|
|
49
|
+
);
|
|
50
|
+
fireEvent.doubleClick(screen.getByRole('button', { name: 'Run' }));
|
|
51
|
+
fireEvent.doubleClick(screen.getByLabelText('n'));
|
|
52
|
+
fireEvent.doubleClick(screen.getByTestId('ignored'));
|
|
53
|
+
fireEvent.doubleClick(screen.getByTestId('claimed'));
|
|
54
|
+
expect(expanded()).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('opens from the expand button and moves focus to the close button, then back', () => {
|
|
58
|
+
render(
|
|
59
|
+
<Lightbox>
|
|
60
|
+
<div>output</div>
|
|
61
|
+
</Lightbox>,
|
|
62
|
+
);
|
|
63
|
+
const open = screen.getByRole('button', { name: 'Expand' });
|
|
64
|
+
open.focus();
|
|
65
|
+
fireEvent.click(open);
|
|
66
|
+
const close = screen.getByRole('button', { name: 'Close expanded view' });
|
|
67
|
+
expect(document.activeElement).toBe(close);
|
|
68
|
+
fireEvent.click(close);
|
|
69
|
+
expect(expanded()).toBe(false);
|
|
70
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Expand' }));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('closes on Escape unless something inside handled it', () => {
|
|
74
|
+
render(
|
|
75
|
+
<Lightbox>
|
|
76
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: stands in for an overlay that consumes its own Escape */}
|
|
77
|
+
<div data-testid="menu" onKeyDown={(e) => e.preventDefault()} />
|
|
78
|
+
</Lightbox>,
|
|
79
|
+
);
|
|
80
|
+
fireEvent.click(screen.getByRole('button', { name: 'Expand' }));
|
|
81
|
+
fireEvent.keyDown(screen.getByTestId('menu'), { key: 'Escape' });
|
|
82
|
+
expect(expanded()).toBe(true);
|
|
83
|
+
fireEvent.keyDown(document.body, { key: 'Escape' });
|
|
84
|
+
expect(expanded()).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('closes on a click in the margin, not on one in the content', () => {
|
|
88
|
+
render(
|
|
89
|
+
<Lightbox>
|
|
90
|
+
<div data-testid="out">output</div>
|
|
91
|
+
</Lightbox>,
|
|
92
|
+
);
|
|
93
|
+
fireEvent.click(screen.getByRole('button', { name: 'Expand' }));
|
|
94
|
+
fireEvent.click(screen.getByTestId('out'));
|
|
95
|
+
expect(expanded()).toBe(true);
|
|
96
|
+
fireEvent.click(root());
|
|
97
|
+
expect(expanded()).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('makes everything outside it inert while open, and only what it marked', () => {
|
|
101
|
+
render(
|
|
102
|
+
<div>
|
|
103
|
+
<aside data-testid="already" inert />
|
|
104
|
+
<nav data-testid="sibling" />
|
|
105
|
+
<Lightbox>
|
|
106
|
+
<div>output</div>
|
|
107
|
+
</Lightbox>
|
|
108
|
+
</div>,
|
|
109
|
+
);
|
|
110
|
+
fireEvent.click(screen.getByRole('button', { name: 'Expand' }));
|
|
111
|
+
expect(screen.getByTestId('sibling').hasAttribute('inert')).toBe(true);
|
|
112
|
+
fireEvent.keyDown(document.body, { key: 'Escape' });
|
|
113
|
+
expect(screen.getByTestId('sibling').hasAttribute('inert')).toBe(false);
|
|
114
|
+
expect(screen.getByTestId('already').hasAttribute('inert')).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('marks itself a portal host only while open, so overlays stack inside it', () => {
|
|
118
|
+
render(
|
|
119
|
+
<Lightbox>
|
|
120
|
+
<div>output</div>
|
|
121
|
+
</Lightbox>,
|
|
122
|
+
);
|
|
123
|
+
expect(root().hasAttribute('data-wzl-portal-host')).toBe(false);
|
|
124
|
+
fireEvent.click(screen.getByRole('button', { name: 'Expand' }));
|
|
125
|
+
expect(root().hasAttribute('data-wzl-portal-host')).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('drops its corner button while a descendant draws the control', () => {
|
|
129
|
+
function OwnControl() {
|
|
130
|
+
const lb = useLightboxControl();
|
|
131
|
+
return (
|
|
132
|
+
<button type="button" onClick={lb?.toggle}>
|
|
133
|
+
Mine
|
|
134
|
+
</button>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
render(
|
|
138
|
+
<Lightbox>
|
|
139
|
+
<OwnControl />
|
|
140
|
+
</Lightbox>,
|
|
141
|
+
);
|
|
142
|
+
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull();
|
|
143
|
+
fireEvent.click(screen.getByRole('button', { name: 'Mine' }));
|
|
144
|
+
expect(expanded()).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('can be controlled', () => {
|
|
148
|
+
const onChange = vi.fn();
|
|
149
|
+
function Host() {
|
|
150
|
+
const [open, setOpen] = useState(true);
|
|
151
|
+
return (
|
|
152
|
+
<Lightbox
|
|
153
|
+
expanded={open}
|
|
154
|
+
onExpandedChange={(next) => {
|
|
155
|
+
onChange(next);
|
|
156
|
+
setOpen(next);
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<div>output</div>
|
|
160
|
+
</Lightbox>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
render(<Host />);
|
|
164
|
+
expect(expanded()).toBe(true);
|
|
165
|
+
fireEvent.keyDown(document.body, { key: 'Escape' });
|
|
166
|
+
expect(onChange).toHaveBeenCalledWith(false);
|
|
167
|
+
expect(expanded()).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('keeps its content mounted when it is turned off', () => {
|
|
171
|
+
let mounts = 0;
|
|
172
|
+
function Content() {
|
|
173
|
+
useState(() => {
|
|
174
|
+
mounts += 1;
|
|
175
|
+
});
|
|
176
|
+
return <div data-testid="out" />;
|
|
177
|
+
}
|
|
178
|
+
const { rerender } = render(
|
|
179
|
+
<Lightbox expandOnDoubleClick>
|
|
180
|
+
<Content />
|
|
181
|
+
</Lightbox>,
|
|
182
|
+
);
|
|
183
|
+
act(() => {
|
|
184
|
+
rerender(
|
|
185
|
+
<Lightbox disabled expandOnDoubleClick>
|
|
186
|
+
<Content />
|
|
187
|
+
</Lightbox>,
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
fireEvent.doubleClick(screen.getByTestId('out'));
|
|
191
|
+
expect(expanded()).toBe(false);
|
|
192
|
+
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull();
|
|
193
|
+
expect(mounts).toBe(1);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useLatest } from '@weasel-js/core';
|
|
2
|
+
import { CloseIcon, FullscreenIcon } from '@weasel-js/ui';
|
|
3
|
+
import {
|
|
4
|
+
type MouseEvent,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { useSurfaceOptional } from '../surface/useSurfaceTile';
|
|
14
|
+
import { LightboxContext, type LightboxContextValue } from './LightboxContext';
|
|
15
|
+
import { useLightboxLayers } from './LightboxLayers';
|
|
16
|
+
import { inertOutside, liftStack, opensLightbox } from './modal';
|
|
17
|
+
|
|
18
|
+
/** Props for `<Lightbox>`. */
|
|
19
|
+
export interface LightboxProps {
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
/** Names the expanded view for assistive tech. */
|
|
22
|
+
label?: string;
|
|
23
|
+
/** Turns the lightbox off: no expand button, no double-click, and an open
|
|
24
|
+
* one closes. The content stays mounted either way. */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Let a double-click on the content open it, and another close it. Off by
|
|
27
|
+
* default — the expand button is always there; a double-click is for
|
|
28
|
+
* content that has no other use for one, such as an image. */
|
|
29
|
+
expandOnDoubleClick?: boolean;
|
|
30
|
+
/** Controlled open state. Omit to let the lightbox keep its own. */
|
|
31
|
+
expanded?: boolean;
|
|
32
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
33
|
+
/** Extra class on the outer element. */
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What the root becomes while open: a modal dialog, and the host overlays
|
|
38
|
+
* opened inside it portal into, so they stack above it. */
|
|
39
|
+
function dialogProps(label: string) {
|
|
40
|
+
return {
|
|
41
|
+
role: 'dialog',
|
|
42
|
+
'aria-modal': true,
|
|
43
|
+
'aria-label': label,
|
|
44
|
+
'data-wzl-portal-host': '',
|
|
45
|
+
} as const;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Shows its content large, over a dimmed page, from the expand button in its
|
|
50
|
+
* corner — or a double-click, where `expandOnDoubleClick` asks for one —
|
|
51
|
+
* without remounting it. The same element is lifted into the browser's top
|
|
52
|
+
* layer (`popover="manual"`), so a WebGL context or a scene inside keeps
|
|
53
|
+
* running and sees only a bigger box: anything sizing itself from a
|
|
54
|
+
* `ResizeObserver` follows on its own. The top layer also escapes an
|
|
55
|
+
* ancestor's transform, clipping, and stacking.
|
|
56
|
+
*
|
|
57
|
+
* Layers declared by a `<LightboxLayers>` above are lifted around it, and a
|
|
58
|
+
* labkit surface above is scoped to it while it is open, so pixels drawn for
|
|
59
|
+
* the content from outside it come along.
|
|
60
|
+
*
|
|
61
|
+
* Escape, a click on the dimmed margin, the close button, or another
|
|
62
|
+
* double-click where those open it closes it, and focus returns to wherever it
|
|
63
|
+
* was. A double-click on a control, inside `[data-lk-lightbox-ignore]`, or one
|
|
64
|
+
* a handler further in called `preventDefault()` on does not open it.
|
|
65
|
+
*/
|
|
66
|
+
export function Lightbox({
|
|
67
|
+
children,
|
|
68
|
+
label = 'Expanded view',
|
|
69
|
+
disabled = false,
|
|
70
|
+
expandOnDoubleClick = false,
|
|
71
|
+
expanded: expandedProp,
|
|
72
|
+
onExpandedChange,
|
|
73
|
+
className,
|
|
74
|
+
}: LightboxProps) {
|
|
75
|
+
const [own, setOwn] = useState(false);
|
|
76
|
+
const expanded = !disabled && (expandedProp ?? own);
|
|
77
|
+
const onChangeRef = useLatest(onExpandedChange);
|
|
78
|
+
const expandedRef = useLatest(expanded);
|
|
79
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
80
|
+
const closeRef = useRef<HTMLButtonElement | null>(null);
|
|
81
|
+
const [claims, setClaims] = useState(0);
|
|
82
|
+
const layers = useLightboxLayers();
|
|
83
|
+
const surface = useSurfaceOptional();
|
|
84
|
+
|
|
85
|
+
const set = useCallback(
|
|
86
|
+
(next: boolean) => {
|
|
87
|
+
if (next === expandedRef.current) return;
|
|
88
|
+
setOwn(next);
|
|
89
|
+
onChangeRef.current?.(next);
|
|
90
|
+
},
|
|
91
|
+
[expandedRef, onChangeRef],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const ctx = useMemo<LightboxContextValue | null>(
|
|
95
|
+
() =>
|
|
96
|
+
disabled
|
|
97
|
+
? null
|
|
98
|
+
: {
|
|
99
|
+
expanded,
|
|
100
|
+
open: () => set(true),
|
|
101
|
+
close: () => set(false),
|
|
102
|
+
toggle: () => set(!expandedRef.current),
|
|
103
|
+
claimControl: () => {
|
|
104
|
+
setClaims((n) => n + 1);
|
|
105
|
+
return () => setClaims((n) => n - 1);
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
[disabled, expanded, set, expandedRef],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Lift, trap and focus before paint, so the first frame is already the
|
|
112
|
+
// expanded one. The popover attribute is set here rather than rendered: a
|
|
113
|
+
// closed popover is `display: none`, which would hide the tile.
|
|
114
|
+
useLayoutEffect(() => {
|
|
115
|
+
const el = rootRef.current;
|
|
116
|
+
if (!expanded || !el) return;
|
|
117
|
+
const returnTo = document.activeElement as HTMLElement | null;
|
|
118
|
+
const stack = [...layers.below, el, ...layers.above].filter(
|
|
119
|
+
(layer): layer is HTMLElement => layer !== null,
|
|
120
|
+
);
|
|
121
|
+
const unlift = liftStack(stack, el);
|
|
122
|
+
const release = inertOutside(stack);
|
|
123
|
+
// A shared buffer lifted with this one must stop painting every other tile
|
|
124
|
+
// over it.
|
|
125
|
+
surface?.scope(el);
|
|
126
|
+
closeRef.current?.focus({ preventScroll: true });
|
|
127
|
+
return () => {
|
|
128
|
+
surface?.scope(null);
|
|
129
|
+
release();
|
|
130
|
+
unlift();
|
|
131
|
+
if (returnTo?.isConnected) returnTo.focus({ preventScroll: true });
|
|
132
|
+
};
|
|
133
|
+
}, [expanded, layers, surface]);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!expanded) return;
|
|
137
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
138
|
+
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
set(false);
|
|
141
|
+
};
|
|
142
|
+
document.addEventListener('keydown', onKey);
|
|
143
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
144
|
+
}, [expanded, set]);
|
|
145
|
+
|
|
146
|
+
const onDoubleClick = (e: MouseEvent<HTMLDivElement>): void => {
|
|
147
|
+
if (disabled || !expandOnDoubleClick || !opensLightbox(e, e.currentTarget)) return;
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
set(!expanded);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Only the margin around the frame is the root's own surface.
|
|
153
|
+
const onClick = (e: MouseEvent<HTMLDivElement>): void => {
|
|
154
|
+
if (expanded && e.target === e.currentTarget) set(false);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const classes = ['lk-lightbox'];
|
|
158
|
+
if (expanded) classes.push('lk-lightbox--expanded');
|
|
159
|
+
if (className) classes.push(className);
|
|
160
|
+
|
|
161
|
+
let control: ReactNode = null;
|
|
162
|
+
if (expanded) {
|
|
163
|
+
control = (
|
|
164
|
+
<button
|
|
165
|
+
ref={closeRef}
|
|
166
|
+
type="button"
|
|
167
|
+
className="lk-lightbox__button lk-lightbox__close"
|
|
168
|
+
aria-label="Close expanded view"
|
|
169
|
+
title="Close (Esc)"
|
|
170
|
+
onClick={() => set(false)}
|
|
171
|
+
>
|
|
172
|
+
<CloseIcon size={16} />
|
|
173
|
+
</button>
|
|
174
|
+
);
|
|
175
|
+
} else if (!disabled && claims === 0) {
|
|
176
|
+
control = (
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
className="lk-lightbox__button lk-lightbox__open"
|
|
180
|
+
aria-label="Expand"
|
|
181
|
+
title="Expand"
|
|
182
|
+
onClick={() => set(true)}
|
|
183
|
+
>
|
|
184
|
+
<FullscreenIcon size={16} />
|
|
185
|
+
</button>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<LightboxContext.Provider value={ctx}>
|
|
191
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: the double-click is an opt-in shortcut; the expand and close buttons are the keyboard path */}
|
|
192
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: the margin click mirrors Escape, which is handled on the document */}
|
|
193
|
+
<div
|
|
194
|
+
ref={rootRef}
|
|
195
|
+
className={classes.join(' ')}
|
|
196
|
+
{...(expanded ? dialogProps(label) : null)}
|
|
197
|
+
onDoubleClick={onDoubleClick}
|
|
198
|
+
onClick={onClick}
|
|
199
|
+
>
|
|
200
|
+
<div className="lk-lightbox__frame">{children}</div>
|
|
201
|
+
{control}
|
|
202
|
+
</div>
|
|
203
|
+
</LightboxContext.Provider>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createContext, useContext, useLayoutEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/** What a `<Lightbox>` offers the content inside it. */
|
|
4
|
+
export interface LightboxApi {
|
|
5
|
+
/** Whether the content is showing in the lightbox right now. */
|
|
6
|
+
expanded: boolean;
|
|
7
|
+
open: () => void;
|
|
8
|
+
close: () => void;
|
|
9
|
+
toggle: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The context value: the public API plus the registration the
|
|
13
|
+
* default affordance steps aside for. */
|
|
14
|
+
export interface LightboxContextValue extends LightboxApi {
|
|
15
|
+
claimControl: () => () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const LightboxContext = createContext<LightboxContextValue | null>(null);
|
|
19
|
+
|
|
20
|
+
/** The nearest `<Lightbox>`, or `null` outside one. Reading it changes
|
|
21
|
+
* nothing; see `useLightboxControl` to replace the default expand button. */
|
|
22
|
+
export function useLightbox(): LightboxApi | null {
|
|
23
|
+
return useContext(LightboxContext);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The nearest `<Lightbox>`, for a component that draws its own expand
|
|
28
|
+
* control: while a caller is mounted, the lightbox hides its default corner
|
|
29
|
+
* button so the tile does not offer two. `<Trial>` uses this to put the
|
|
30
|
+
* control in its title bar.
|
|
31
|
+
*/
|
|
32
|
+
export function useLightboxControl(): LightboxApi | null {
|
|
33
|
+
const ctx = useContext(LightboxContext);
|
|
34
|
+
const claim = ctx?.claimControl;
|
|
35
|
+
// Layout effect, so the corner button is gone before the first paint.
|
|
36
|
+
useLayoutEffect(() => claim?.(), [claim]);
|
|
37
|
+
return ctx;
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Elements a lightbox lifts with it, in top-layer order around itself. A
|
|
4
|
+
* null entry is skipped, so a ref that has not landed yet can be passed as
|
|
5
|
+
* it is. */
|
|
6
|
+
export interface LightboxLayerSet {
|
|
7
|
+
below: readonly (HTMLElement | null)[];
|
|
8
|
+
above: readonly (HTMLElement | null)[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const NONE: LightboxLayerSet = { below: [], above: [] };
|
|
12
|
+
|
|
13
|
+
const LightboxLayersContext = createContext<LightboxLayerSet>(NONE);
|
|
14
|
+
|
|
15
|
+
/** Props for `<LightboxLayers>`. Keep the arrays stable across renders. */
|
|
16
|
+
export interface LightboxLayersProps extends Partial<LightboxLayerSet> {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Declares layers that belong to every lightbox below it: something drawn
|
|
22
|
+
* for the content from outside it, such as a shared buffer the tiles paint
|
|
23
|
+
* into. An open lightbox lifts each `below` layer, then itself, then each
|
|
24
|
+
* `above` layer into the top layer, each covering the window, so they keep
|
|
25
|
+
* stacking as they did. A layer is measured against the window while lifted,
|
|
26
|
+
* so whatever positions things inside it must re-measure — a labkit surface
|
|
27
|
+
* whose container is one of the layers does.
|
|
28
|
+
*/
|
|
29
|
+
export function LightboxLayers({ below, above, children }: LightboxLayersProps) {
|
|
30
|
+
const value = useMemo(
|
|
31
|
+
() => ({ below: below ?? NONE.below, above: above ?? NONE.above }),
|
|
32
|
+
[below, above],
|
|
33
|
+
);
|
|
34
|
+
return <LightboxLayersContext.Provider value={value}>{children}</LightboxLayersContext.Provider>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The layers declared above, or none. */
|
|
38
|
+
export function useLightboxLayers(): LightboxLayerSet {
|
|
39
|
+
return useContext(LightboxLayersContext);
|
|
40
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Lightbox, type LightboxProps } from './Lightbox';
|
|
2
|
+
export { type LightboxApi, useLightbox, useLightboxControl } from './LightboxContext';
|
|
3
|
+
export {
|
|
4
|
+
type LightboxLayerSet,
|
|
5
|
+
LightboxLayers,
|
|
6
|
+
type LightboxLayersProps,
|
|
7
|
+
useLightboxLayers,
|
|
8
|
+
} from './LightboxLayers';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elements whose double-click already means something: a press there edits,
|
|
3
|
+
* selects text, or works a control. `data-lk-lightbox-ignore` is the opt-out
|
|
4
|
+
* for anything else.
|
|
5
|
+
*/
|
|
6
|
+
const OWN_DOUBLE_CLICK = [
|
|
7
|
+
'button',
|
|
8
|
+
'a[href]',
|
|
9
|
+
'input',
|
|
10
|
+
'select',
|
|
11
|
+
'textarea',
|
|
12
|
+
'label',
|
|
13
|
+
'summary',
|
|
14
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
15
|
+
'[role="button"]',
|
|
16
|
+
'[role="checkbox"]',
|
|
17
|
+
'[role="combobox"]',
|
|
18
|
+
'[role="link"]',
|
|
19
|
+
'[role="menuitem"]',
|
|
20
|
+
'[role="option"]',
|
|
21
|
+
'[role="radio"]',
|
|
22
|
+
'[role="separator"]',
|
|
23
|
+
'[role="slider"]',
|
|
24
|
+
'[role="spinbutton"]',
|
|
25
|
+
'[role="switch"]',
|
|
26
|
+
'[role="tab"]',
|
|
27
|
+
'[role="textbox"]',
|
|
28
|
+
'[data-lk-lightbox-ignore]',
|
|
29
|
+
].join(',');
|
|
30
|
+
|
|
31
|
+
/** Whether a double-click that reached `root` should open its lightbox:
|
|
32
|
+
* nothing handled it first, and it did not land on a control. */
|
|
33
|
+
export function opensLightbox(
|
|
34
|
+
event: { target: EventTarget | null; defaultPrevented: boolean },
|
|
35
|
+
root: Element,
|
|
36
|
+
): boolean {
|
|
37
|
+
if (event.defaultPrevented) return false;
|
|
38
|
+
const target = event.target;
|
|
39
|
+
if (!(target instanceof Element)) return true;
|
|
40
|
+
const owner = target.closest(OWN_DOUBLE_CLICK);
|
|
41
|
+
return owner === null || !root.contains(owner);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Marks everything outside `keep` inert — every sibling of each kept element
|
|
46
|
+
* and of each of their ancestors, up to the body — and returns the undo.
|
|
47
|
+
* Leaves alone what was already inert, so the undo cannot clear someone
|
|
48
|
+
* else's.
|
|
49
|
+
*/
|
|
50
|
+
export function inertOutside(keep: readonly Element[]): () => void {
|
|
51
|
+
const path = new Set<Element>();
|
|
52
|
+
for (const el of keep) {
|
|
53
|
+
for (let node: Element | null = el; node && node !== document.body; node = node.parentElement) {
|
|
54
|
+
path.add(node);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const marked: Element[] = [];
|
|
58
|
+
for (const node of path) {
|
|
59
|
+
const parent: Element | null = node.parentElement;
|
|
60
|
+
if (!parent) continue;
|
|
61
|
+
for (const sibling of Array.from(parent.children)) {
|
|
62
|
+
if (path.has(sibling) || sibling.hasAttribute('inert')) continue;
|
|
63
|
+
sibling.setAttribute('inert', '');
|
|
64
|
+
marked.push(sibling);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return () => {
|
|
68
|
+
for (const sibling of marked) sibling.removeAttribute('inert');
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lifts `stack` into the top layer in order, so the first ends up lowest, and
|
|
74
|
+
* returns the undo. The first carries the dimming. Every member but `self` is
|
|
75
|
+
* marked a layer, which the stylesheet stretches over the window. Does nothing
|
|
76
|
+
* where the browser has no popover support.
|
|
77
|
+
*/
|
|
78
|
+
export function liftStack(stack: readonly HTMLElement[], self: HTMLElement): () => void {
|
|
79
|
+
if (typeof self.showPopover !== 'function') return () => {};
|
|
80
|
+
stack.forEach((el, i) => {
|
|
81
|
+
el.setAttribute('popover', 'manual');
|
|
82
|
+
if (el !== self) el.setAttribute('data-lk-lightbox-layer', '');
|
|
83
|
+
if (i === 0) el.setAttribute('data-lk-lightbox-scrim', '');
|
|
84
|
+
el.showPopover();
|
|
85
|
+
});
|
|
86
|
+
return () => {
|
|
87
|
+
for (const el of [...stack].reverse()) {
|
|
88
|
+
if (el.matches(':popover-open')) el.hidePopover();
|
|
89
|
+
el.removeAttribute('popover');
|
|
90
|
+
el.removeAttribute('data-lk-lightbox-layer');
|
|
91
|
+
el.removeAttribute('data-lk-lightbox-scrim');
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/loupe/AGENTS.md
CHANGED
|
@@ -18,10 +18,12 @@ This directory binds that model to a labkit trial and draws it.
|
|
|
18
18
|
| `loupeActions.ts` | `loupe.peek` and `loupe.magnify`, as `Action` descriptors |
|
|
19
19
|
| `LoupeGestures.tsx` | Registers those, and mounts a dispatcher on the host when no camera has one there |
|
|
20
20
|
| `TrialLoupe.tsx` | Finds its host and the trial's switch, picks the painter, mounts the lens |
|
|
21
|
-
| `LoupeBubble.tsx` | The
|
|
21
|
+
| `LoupeBubble.tsx` | The circle or square clip, positioned on the lens's center |
|
|
22
22
|
| `CanvasLoupe.tsx` | Painter for a `<CanvasStack>` |
|
|
23
23
|
| `canvasLens.ts` | That painter's geometry and drawing, with no React in it |
|
|
24
24
|
| `DomLoupe.tsx` | Painter for DOM content |
|
|
25
|
+
| `SourceLoupe.tsx` | Painter for any canvas named by `source` |
|
|
26
|
+
| `sourceLens.ts` | That painter's source resolution, box measurement and drawing |
|
|
25
27
|
| `useHostSize.ts` | The host's measured box, for the DOM stage |
|
|
26
28
|
|
|
27
29
|
## How it reads the trial
|
|
@@ -48,11 +50,42 @@ content again; a DOM loupe is always `vector`, since DOM has no framebuffer to
|
|
|
48
50
|
enlarge. The canvas painter needs the stack's own pixels, which is why a
|
|
49
51
|
drawing instrument mounts the lens in its `render`, inside `<CanvasStack>`.
|
|
50
52
|
|
|
53
|
+
`source` names a canvas labkit need not own, and wins over the stack: the lens
|
|
54
|
+
enlarges that canvas's pixels (`SourceLoupe`), or, beside `render`, only
|
|
55
|
+
samples its color. The capture rule for WebGL lives with `createCanvasSource`
|
|
56
|
+
in `@weasel-js/loupe`.
|
|
57
|
+
|
|
58
|
+
## Where the lens goes
|
|
59
|
+
|
|
60
|
+
By default a lens is a `diameter` box centered on the aim. `place` lets the
|
|
61
|
+
host put it elsewhere per aim — a box of any size at `center`, showing what is
|
|
62
|
+
around `shows` at its own `factor` — and `placeBand` (from `@weasel-js/loupe`)
|
|
63
|
+
is the stock fit of a region shown whole. `useLoupe` resolves the result into
|
|
64
|
+
`loupe.lens`; the bubble is drawn at `lens.center` and the painters are aimed at
|
|
65
|
+
`lens.shows`, which are different points once a lens is moved to stay on the
|
|
66
|
+
host. `onColorChange` still reports the aim.
|
|
67
|
+
|
|
68
|
+
`hollow` runs no painter and leaves the bubble's inside clear, for a host that
|
|
69
|
+
draws the magnified view itself; `onLens` tells it the box, on each change in
|
|
70
|
+
value and with `null` once a lens that was up goes away.
|
|
71
|
+
|
|
51
72
|
## Traps
|
|
52
73
|
|
|
74
|
+
**A placed lens must reach the model, not only the painters.** `pick` maps a
|
|
75
|
+
point inside the lens back to the page through `LoupeSurface.lens()`; the rect
|
|
76
|
+
it returns carries `shows` and `factor`, and a lens drawn somewhere the model
|
|
77
|
+
does not know about picks colors from the wrong place with every test of the
|
|
78
|
+
drawing still green.
|
|
79
|
+
|
|
53
80
|
**jsdom cannot see magnification.** Everything assertable there is state — aim
|
|
54
81
|
moved, factor clamped, mode switched, the lens raised and put away. That the
|
|
55
|
-
lens shows the right region is a screenshot
|
|
82
|
+
lens shows the right region is a screenshot, or a pixel read in a browser test
|
|
83
|
+
(`SourceLoupe.browser.test.tsx`).
|
|
84
|
+
|
|
85
|
+
**Retain a source by its resolved identity, not the prop.** An inline
|
|
86
|
+
`source={() => ref.current}` is a new function every render, and every aim
|
|
87
|
+
re-renders. Keyed on the prop, the lens released and re-took the source on each
|
|
88
|
+
aim, and each first reader marks a captured frame stale.
|
|
56
89
|
|
|
57
90
|
**Do not dispose the model when React unmounts.** `dispose` is one-way, and
|
|
58
91
|
StrictMode mounts / unmounts / mounts every effect — so disposing in the
|
|
@@ -70,6 +103,11 @@ reason and the dispatcher falls through to the zoom. Outside a camera — the DO
|
|
|
70
103
|
`GESTURE_DESCRIPTORS` names no continuous-motion gesture, so `pointermove` /
|
|
71
104
|
`pointerleave` stay hand-attached in `useLoupe`. Everything else routes.
|
|
72
105
|
|
|
106
|
+
**The model ignores aims while the lens is down.** So a lens that comes up
|
|
107
|
+
under a still pointer — the peek key, or the lens turned on — has had no aim
|
|
108
|
+
since the last time it was up. `useLoupe` keeps the pointer's last spot over the
|
|
109
|
+
host and aims there as the lens rises; without it the lens opens at 0,0.
|
|
110
|
+
|
|
73
111
|
**`<LoupeGestures>` mounts outside the visibility gate.** Hold-to-peek is what
|
|
74
112
|
raises a lens that is down; gate its registration on `loupe.visible` and the
|
|
75
113
|
peek key stops working entirely.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useLatest, useVisibleRaf } from '@weasel-js/core';
|
|
2
|
-
import type
|
|
2
|
+
import { type LoupeMode, type LoupePoint, type LoupeSize, loupeExtent } from '@weasel-js/loupe';
|
|
3
3
|
import { useEffect, useRef } from 'react';
|
|
4
4
|
import type { CanvasStackSurface } from '../canvas/CanvasStackContext';
|
|
5
5
|
import type { WorldFrame, WorldSpec } from '../canvas/worldSpec';
|
|
@@ -11,7 +11,8 @@ export interface CanvasLoupeProps {
|
|
|
11
11
|
aim: LoupePoint;
|
|
12
12
|
factor: number;
|
|
13
13
|
mode: LoupeMode;
|
|
14
|
-
|
|
14
|
+
/** One number for a round or square lens, or a width and a height. */
|
|
15
|
+
diameter: LoupeSize;
|
|
15
16
|
/** The stack being magnified: its layers, its pixels, and its measured box. */
|
|
16
17
|
surface: CanvasStackSurface;
|
|
17
18
|
view: ViewTransform;
|
|
@@ -75,8 +76,8 @@ export function CanvasLoupe({
|
|
|
75
76
|
<canvas
|
|
76
77
|
ref={canvasRef}
|
|
77
78
|
className="lk-loupe__canvas"
|
|
78
|
-
width={Math.max(1, Math.round(diameter * dpr))}
|
|
79
|
-
height={Math.max(1, Math.round(diameter * dpr))}
|
|
79
|
+
width={Math.max(1, Math.round(loupeExtent(diameter).width * dpr))}
|
|
80
|
+
height={Math.max(1, Math.round(loupeExtent(diameter).height * dpr))}
|
|
80
81
|
/>
|
|
81
82
|
);
|
|
82
83
|
}
|