@weasel-js/labkit 1.4.0-pre.1 → 1.4.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/dist/_dts/{CanvasStackContext-LnCfqNBA.d.ts → CanvasStackContext-BIRUVVGH.d.ts} +1 -1
- package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
- package/dist/_dts/{frac-C-2c72Ij.d.ts → frac-X7mWgd7y.d.ts} +10 -4
- package/dist/_dts/{index-BDVzvRzQ.d.ts → index-FEa1MudK.d.ts} +8 -2
- package/dist/_dts/{types-DYMaEvM5.d.ts → types-ChVJJHvk.d.ts} +3 -0
- package/dist/_dts/{types-D6s4b7if.d.ts → types-lg4TSCb2.d.ts} +3 -1
- package/dist/_dts/{useTrialState-gmMvZqPc.d.ts → useTrialState-Cdm13fvl.d.ts} +2 -1
- package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
- package/dist/canvas/index.d.ts +16 -16
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +6 -6
- package/dist/chrome/index.js +5 -5
- package/dist/chunk-2YILQ7Y5.js +33 -0
- package/dist/chunk-2YILQ7Y5.js.map +1 -0
- package/dist/{chunk-TO2FUOKF.js → chunk-53AZ2O2N.js} +16 -13
- package/dist/chunk-53AZ2O2N.js.map +1 -0
- package/dist/{chunk-QB5SM4BM.js → chunk-7TRFMIWX.js} +55 -41
- package/dist/chunk-7TRFMIWX.js.map +1 -0
- package/dist/{chunk-6OEYTYML.js → chunk-BMQWLKOL.js} +15 -15
- package/dist/chunk-BMQWLKOL.js.map +1 -0
- package/dist/{chunk-M75ZU6ZZ.js → chunk-D7QIPGEB.js} +3 -3
- package/dist/{chunk-M75ZU6ZZ.js.map → chunk-D7QIPGEB.js.map} +1 -1
- package/dist/{chunk-L5QJOOLV.js → chunk-DG2IFMLA.js} +1309 -273
- package/dist/chunk-DG2IFMLA.js.map +1 -0
- package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
- package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
- package/dist/{chunk-RBGKL7NF.js → chunk-J6SBFZYT.js} +1807 -1814
- package/dist/chunk-J6SBFZYT.js.map +1 -0
- package/dist/{chunk-XJ6N32QP.js → chunk-LJSIUFYD.js} +14 -30
- package/dist/chunk-LJSIUFYD.js.map +1 -0
- package/dist/{chunk-AE5CNVRU.js → chunk-MIT3ISW4.js} +37 -31
- package/dist/chunk-MIT3ISW4.js.map +1 -0
- package/dist/{chunk-HHGVISVZ.js → chunk-MSR7EVS6.js} +7 -5
- package/dist/chunk-MSR7EVS6.js.map +1 -0
- package/dist/{chunk-ESIQNQ6K.js → chunk-OFCBEZMU.js} +12 -6
- package/dist/chunk-OFCBEZMU.js.map +1 -0
- package/dist/{chunk-HMFODCOX.js → chunk-PP7ZWAMV.js} +83 -75
- package/dist/chunk-PP7ZWAMV.js.map +1 -0
- package/dist/{chunk-E2UQVZ44.js → chunk-RLOXQZW3.js} +3 -3
- package/dist/{chunk-E2UQVZ44.js.map → chunk-RLOXQZW3.js.map} +1 -1
- package/dist/controls/index.d.ts +4 -3
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +5 -5
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +57 -77
- package/dist/index.js +308 -161
- package/dist/index.js.map +1 -1
- package/dist/job/index.js +1 -1
- package/dist/layers/index.d.ts +6 -6
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +7 -13
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-canvas.d.ts +3 -343
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +141 -2866
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.js +4 -4
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +4 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +74 -5
- package/dist/surface/index.d.ts +17 -2
- package/dist/surface/index.js +3 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +6 -5
- package/package.json +8 -8
- package/src/annotations/AnnotationOverlay.tsx +10 -6
- package/src/annotations/Annotations.less +6 -0
- package/src/annotations/Annotations.overlay.test.tsx +113 -0
- package/src/annotations/MarkList.tsx +3 -2
- package/src/canvas/CanvasStack.tsx +8 -13
- package/src/canvas/useOrbit.test.ts +93 -1
- package/src/canvas/useOrbit.ts +41 -34
- package/src/canvas/usePanZoom.test.ts +102 -1
- package/src/canvas/usePanZoom.ts +54 -36
- package/src/config/builder.test.ts +5 -0
- package/src/config/builder.ts +6 -0
- package/src/config/types.ts +1 -0
- package/src/controls/ControlPanel.test.tsx +23 -0
- package/src/controls/ControlPanel.tsx +3 -0
- package/src/job/useJob.ts +1 -1
- package/src/lab/Lab.surface.test.tsx +8 -3
- package/src/lab/LabHeader.test.tsx +12 -0
- package/src/lab/LabHeader.tsx +16 -6
- package/src/lab/Workspace.tsx +1 -1
- package/src/layers/LayerList.test.tsx +72 -1
- package/src/layers/LayerList.tsx +40 -32
- package/src/passthrough/weasel-ui.test.ts +9 -0
- package/src/passthrough/weasel-ui.ts +4 -0
- package/src/primitives/FloatingPanel.test.tsx +82 -0
- package/src/primitives/FloatingPanel.tsx +59 -41
- package/src/primitives/ZoomControl.tsx +2 -0
- package/src/state/store.ts +10 -0
- package/src/state/types.ts +3 -0
- package/src/surface/AGENTS.md +11 -1
- package/src/surface/index.ts +7 -1
- package/src/surface/useSurfaceTile.ts +20 -2
- package/src/surface/useTiledSurface.ts +4 -1
- package/src/theme/Interstellar.stories.tsx +4 -3
- package/src/theme/base.less +39 -1
- package/src/trial/Trial.less +27 -5
- package/src/trial/TrialBody.test.tsx +67 -0
- package/src/trial/TrialBody.tsx +155 -0
- package/src/trial/TrialChrome.tsx +18 -10
- package/src/trial/index.ts +2 -0
- package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
- package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
- package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
- package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
- package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
- package/dist/chunk-6OEYTYML.js.map +0 -1
- package/dist/chunk-AE5CNVRU.js.map +0 -1
- package/dist/chunk-ESIQNQ6K.js.map +0 -1
- package/dist/chunk-HHGVISVZ.js.map +0 -1
- package/dist/chunk-HMFODCOX.js.map +0 -1
- package/dist/chunk-L5QJOOLV.js.map +0 -1
- package/dist/chunk-QB5SM4BM.js.map +0 -1
- package/dist/chunk-RBGKL7NF.js.map +0 -1
- package/dist/chunk-TO2FUOKF.js.map +0 -1
- package/dist/chunk-XJ6N32QP.js.map +0 -1
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
Dialog,
|
|
9
9
|
Field,
|
|
10
10
|
formatNumber,
|
|
11
|
+
ICON_PATHS,
|
|
12
|
+
Icon,
|
|
11
13
|
Input,
|
|
12
14
|
KeyCap,
|
|
13
15
|
MINUS_SIGN,
|
|
@@ -69,6 +71,13 @@ describe('weasel-ui passthrough', () => {
|
|
|
69
71
|
}
|
|
70
72
|
});
|
|
71
73
|
|
|
74
|
+
it('re-exports the icon set, so a lab need not depend on weasel-ui itself', () => {
|
|
75
|
+
expect(['function', 'object']).toContain(typeof Icon);
|
|
76
|
+
expect(ICON_PATHS.layoutRows).toContain('<rect');
|
|
77
|
+
expect(ICON_PATHS.layoutColumns).toContain('<rect');
|
|
78
|
+
expect(ICON_PATHS.layoutGrid).toContain('<rect');
|
|
79
|
+
});
|
|
80
|
+
|
|
72
81
|
it('re-exports app-chrome primitives as React components', () => {
|
|
73
82
|
for (const C of [Sidebar, ToolPalette, KeyCap]) {
|
|
74
83
|
expect(C).toBeDefined();
|
|
@@ -313,3 +313,85 @@ describe('a clickable row nested in a list, the shape a floating panel usually h
|
|
|
313
313
|
expect(panel.dataset.dragging).toBe('true');
|
|
314
314
|
});
|
|
315
315
|
});
|
|
316
|
+
|
|
317
|
+
describe('FloatingPanel drag lifecycle', () => {
|
|
318
|
+
const realCapture = Element.prototype.setPointerCapture;
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
Element.prototype.setPointerCapture = realCapture;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('keeps dragging from moves the panel never sees, and ends on a release off it', () => {
|
|
324
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
325
|
+
fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
|
|
326
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
|
|
327
|
+
expect(panel.dataset.dragging).toBe('true');
|
|
328
|
+
expect(panel.style.left).toBe('100px');
|
|
329
|
+
fireEvent.pointerUp(document.body, { pointerId: 1, clientX: 200, clientY: 180 });
|
|
330
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
331
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 300, clientY: 180 });
|
|
332
|
+
expect(panel.style.left).toBe('100px');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('ends the drag when capture is lost mid-gesture', () => {
|
|
336
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
337
|
+
fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
|
|
338
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
|
|
339
|
+
fireEvent(panel, new PointerEvent('lostpointercapture', { pointerId: 1, bubbles: true }));
|
|
340
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
341
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 300, clientY: 180 });
|
|
342
|
+
expect(panel.style.left).toBe('100px');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('reads a move with nothing held as the release that never arrived', () => {
|
|
346
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
347
|
+
fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
|
|
348
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
|
|
349
|
+
expect(panel.dataset.dragging).toBe('true');
|
|
350
|
+
fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 0, clientX: 300, clientY: 180 });
|
|
351
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
352
|
+
expect(panel.style.left).toBe('100px');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('captures the pointer only once the press crosses the drag threshold', () => {
|
|
356
|
+
// A proxy for the real symptom: jsdom's setPointerCapture never retargets,
|
|
357
|
+
// so the child click a capture-on-press would swallow cannot be observed.
|
|
358
|
+
const captured: number[] = [];
|
|
359
|
+
Element.prototype.setPointerCapture = (id: number) => {
|
|
360
|
+
captured.push(id);
|
|
361
|
+
};
|
|
362
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
363
|
+
fireEvent.pointerDown(panel, { pointerId: 7, buttons: 1, clientX: 100, clientY: 100 });
|
|
364
|
+
expect(captured).toEqual([]);
|
|
365
|
+
fireEvent.pointerMove(document.body, { pointerId: 7, buttons: 1, clientX: 140, clientY: 100 });
|
|
366
|
+
expect(captured).toEqual([7]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('drops its document listeners when the panel unmounts mid-drag', () => {
|
|
370
|
+
const added: string[] = [];
|
|
371
|
+
const removed: string[] = [];
|
|
372
|
+
const realAdd = document.addEventListener.bind(document);
|
|
373
|
+
const realRemove = document.removeEventListener.bind(document);
|
|
374
|
+
const addSpy = vi
|
|
375
|
+
.spyOn(document, 'addEventListener')
|
|
376
|
+
.mockImplementation((type, listener, opts) => {
|
|
377
|
+
added.push(String(type));
|
|
378
|
+
realAdd(type as never, listener as never, opts as never);
|
|
379
|
+
});
|
|
380
|
+
const removeSpy = vi
|
|
381
|
+
.spyOn(document, 'removeEventListener')
|
|
382
|
+
.mockImplementation((type, listener, opts) => {
|
|
383
|
+
removed.push(String(type));
|
|
384
|
+
realRemove(type as never, listener as never, opts as never);
|
|
385
|
+
});
|
|
386
|
+
try {
|
|
387
|
+
const { panel, unmount } = renderPanel(<FloatingPanel>x</FloatingPanel>);
|
|
388
|
+
fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
|
|
389
|
+
expect(added).toContain('pointermove');
|
|
390
|
+
unmount();
|
|
391
|
+
expect(removed).toContain('pointermove');
|
|
392
|
+
} finally {
|
|
393
|
+
addSpy.mockRestore();
|
|
394
|
+
removeSpy.mockRestore();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { openPointerSession, type PointerSession } from '@weasel-js/core';
|
|
1
2
|
import type React from 'react';
|
|
2
3
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
4
|
import {
|
|
@@ -130,67 +131,84 @@ export function FloatingPanel({
|
|
|
130
131
|
.placements.get(ITEM_ID);
|
|
131
132
|
|
|
132
133
|
const dragging = useRef<{ x: number; y: number } | null>(null);
|
|
133
|
-
|
|
134
|
-
const pending = useRef<{ x: number; y: number; pointerId: number } | null>(null);
|
|
134
|
+
const session = useRef<PointerSession | null>(null);
|
|
135
135
|
const [isDragging, setDragging] = useState(false);
|
|
136
|
+
// A drag outlives the render that started it, so the reducer's inputs are
|
|
137
|
+
// read here and not from that render's closure — a container resized
|
|
138
|
+
// mid-drag would otherwise place the panel against its old box.
|
|
139
|
+
const layout = useRef({ container, options, item });
|
|
140
|
+
layout.current = { container, options, item };
|
|
141
|
+
|
|
142
|
+
useEffect(() => () => session.current?.cancel(), []);
|
|
136
143
|
|
|
137
144
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
138
145
|
if (isInteractive(e.target)) return;
|
|
139
146
|
// The canvas stack underneath owns pan/zoom on the same pointer events.
|
|
140
147
|
e.stopPropagation();
|
|
148
|
+
const origin = e.currentTarget;
|
|
149
|
+
const pointerId = e.pointerId;
|
|
150
|
+
const start = { x: e.clientX, y: e.clientY };
|
|
151
|
+
|
|
152
|
+
const end = () => {
|
|
153
|
+
session.current = null;
|
|
154
|
+
if (!dragging.current) return;
|
|
155
|
+
dragging.current = null;
|
|
156
|
+
setDragging(false);
|
|
157
|
+
try {
|
|
158
|
+
origin.releasePointerCapture?.(pointerId);
|
|
159
|
+
} catch {
|
|
160
|
+
// Capture is already gone whenever the session ended by losing it.
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
141
164
|
// Armed, not capturing. Capture retargets mouseup to this element, and the
|
|
142
165
|
// browser then synthesizes no `click` on the child under the cursor — so
|
|
143
166
|
// capturing on press makes every non-native control in the panel dead to a
|
|
144
167
|
// real mouse, silently. `isInteractive` only exempts the names it lists; a
|
|
145
168
|
// `role="button"` span, a react-aria control or a canvas is not among them.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
session.current = openPointerSession(
|
|
170
|
+
origin,
|
|
171
|
+
e,
|
|
172
|
+
{
|
|
173
|
+
onMove: (ev) => {
|
|
174
|
+
if (!dragging.current) {
|
|
175
|
+
if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return;
|
|
176
|
+
dragging.current = { ...start };
|
|
177
|
+
setDragging(true);
|
|
178
|
+
origin.setPointerCapture?.(pointerId);
|
|
179
|
+
}
|
|
180
|
+
const from = dragging.current;
|
|
181
|
+
const dx = ev.clientX - from.x;
|
|
182
|
+
const dy = ev.clientY - from.y;
|
|
183
|
+
if (dx === 0 && dy === 0) return;
|
|
184
|
+
dragging.current = { x: ev.clientX, y: ev.clientY };
|
|
185
|
+
const { container: box, options: opts, item: only } = layout.current;
|
|
186
|
+
setPlace(
|
|
187
|
+
(prev) =>
|
|
188
|
+
strategy.reduce?.(
|
|
189
|
+
{ at: { [ITEM_ID]: prev }, inner: undefined },
|
|
190
|
+
{
|
|
191
|
+
affordanceId: `${FLOATING_DRAG_PREFIX}${ITEM_ID}`,
|
|
192
|
+
kind: 'drag',
|
|
193
|
+
payload: { dx, dy },
|
|
194
|
+
},
|
|
195
|
+
{ container: box, options: opts, items: [only] },
|
|
196
|
+
).at[ITEM_ID] ?? prev,
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
onEnd: end,
|
|
200
|
+
onCancel: end,
|
|
201
|
+
},
|
|
202
|
+
{ capture: false },
|
|
174
203
|
);
|
|
175
204
|
};
|
|
176
205
|
|
|
177
|
-
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
178
|
-
pending.current = null;
|
|
179
|
-
if (!dragging.current) return;
|
|
180
|
-
dragging.current = null;
|
|
181
|
-
setDragging(false);
|
|
182
|
-
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
183
|
-
};
|
|
184
|
-
|
|
185
206
|
return (
|
|
186
207
|
<div
|
|
187
208
|
className={className ? `lk-floating-panel ${className}` : 'lk-floating-panel'}
|
|
188
209
|
data-dragging={isDragging ? 'true' : undefined}
|
|
189
210
|
data-placed={rect ? 'true' : undefined}
|
|
190
|
-
onPointerCancel={endDrag}
|
|
191
211
|
onPointerDown={onPointerDown}
|
|
192
|
-
onPointerMove={onPointerMove}
|
|
193
|
-
onPointerUp={endDrag}
|
|
194
212
|
ref={ref}
|
|
195
213
|
style={
|
|
196
214
|
{
|
|
@@ -45,6 +45,7 @@ export function ZoomControl({
|
|
|
45
45
|
<Slider
|
|
46
46
|
className="lk-zoom__slider"
|
|
47
47
|
ariaLabel="Zoom"
|
|
48
|
+
density="slim"
|
|
48
49
|
min={toTrack(min)}
|
|
49
50
|
max={toTrack(max)}
|
|
50
51
|
// Detents at the octaves plus actual size, so 25/50/100/200/400% are
|
|
@@ -65,6 +66,7 @@ export function ZoomControl({
|
|
|
65
66
|
maxValue={max}
|
|
66
67
|
step={0.01}
|
|
67
68
|
hideSteppers
|
|
69
|
+
ghost
|
|
68
70
|
formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}
|
|
69
71
|
onChange={(next) => {
|
|
70
72
|
if (!Number.isFinite(next)) return;
|
package/src/state/store.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface LabStoreActions {
|
|
|
34
34
|
updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
|
|
35
35
|
updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
|
|
36
36
|
updateTrialView: (id: string, view: unknown) => void;
|
|
37
|
+
updateTrialSidebarWidth: (id: string, width: number) => void;
|
|
37
38
|
updateTrialAnnotations: (id: string, doc: unknown) => void;
|
|
38
39
|
updateTrialUndoStack: (
|
|
39
40
|
id: string,
|
|
@@ -136,6 +137,15 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
136
137
|
scheduleFlush();
|
|
137
138
|
},
|
|
138
139
|
|
|
140
|
+
updateTrialSidebarWidth: (id, width) => {
|
|
141
|
+
set((s) => ({
|
|
142
|
+
trials: s.trials.map((w) =>
|
|
143
|
+
w.id === id && w.sidebarWidth !== width ? { ...w, sidebarWidth: width } : w,
|
|
144
|
+
),
|
|
145
|
+
}));
|
|
146
|
+
scheduleFlush();
|
|
147
|
+
},
|
|
148
|
+
|
|
139
149
|
updateTrialAnnotations: (id, doc) => {
|
|
140
150
|
set((s) => ({
|
|
141
151
|
trials: s.trials.map((w) =>
|
package/src/state/types.ts
CHANGED
|
@@ -18,6 +18,9 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
|
|
|
18
18
|
view: TV;
|
|
19
19
|
/** This trial's own tool slot. Undefined means it reads the lab's. */
|
|
20
20
|
activeToolId?: string | null;
|
|
21
|
+
/** The extent the trial's sidebar was last dragged to, in pixels. Undefined
|
|
22
|
+
* until someone moves the seam. */
|
|
23
|
+
sidebarWidth?: number;
|
|
21
24
|
/** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
|
|
22
25
|
* wrote them. Opaque here, and absent for a trial whose instrument declares
|
|
23
26
|
* no `annotations` — or declares its own `storage`. Not in `state`, which
|
package/src/surface/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ than a few 3D tiles have to share one surface.
|
|
|
17
17
|
| `deviceRect.ts` | `toDeviceRect` — y-flip and device-grid snapping for a GL viewport |
|
|
18
18
|
| `useTiledSurface.ts` | ResizeObserver, dirty set, rAF coalescing, DPR |
|
|
19
19
|
| `SurfaceContext.ts` | Carries the handle down |
|
|
20
|
-
| `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useSurface()`, `useSurfaceOptional()` |
|
|
20
|
+
| `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useTileId(id)`, `useSurface()`, `useSurfaceOptional()` |
|
|
21
21
|
|
|
22
22
|
## Shape of a consumer
|
|
23
23
|
|
|
@@ -40,6 +40,16 @@ const surface = useTiledSurface({
|
|
|
40
40
|
draw has to know where it is drawing relative to a surface that may have resized
|
|
41
41
|
under it.
|
|
42
42
|
|
|
43
|
+
## A tile id is scoped to its trial
|
|
44
|
+
|
|
45
|
+
The tile namespace is one lab-wide map, but an instrument names its regions once
|
|
46
|
+
and every trial of it declares those same names. `useSurfaceTile` registers under
|
|
47
|
+
`useTileId(id)` — `<trial>/<id>` inside a trial — and a frame's `rects` are keyed
|
|
48
|
+
the same way, so a host looking a tile up calls `useTileId` for the key. Unscoped,
|
|
49
|
+
the second trial to mount takes the first one's rect *and* its painter, and the
|
|
50
|
+
first is never told it moved again: it keeps the box it was measured at while it
|
|
51
|
+
was the only trial open.
|
|
52
|
+
|
|
43
53
|
## The unit is a rect, not a trial
|
|
44
54
|
|
|
45
55
|
`useSurfaceTile(id)` attaches to whatever element the surface should draw into. A
|
package/src/surface/index.ts
CHANGED
|
@@ -2,7 +2,13 @@ export { composeRects, rectsEqual } from './composeRects';
|
|
|
2
2
|
export { toDeviceRect } from './deviceRect';
|
|
3
3
|
export type { Box, Rect } from './rect';
|
|
4
4
|
export { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
useSurface,
|
|
7
|
+
useSurfaceCanvas,
|
|
8
|
+
useSurfaceOptional,
|
|
9
|
+
useSurfaceTile,
|
|
10
|
+
useTileId,
|
|
11
|
+
} from './useSurfaceTile';
|
|
6
12
|
export type {
|
|
7
13
|
SurfaceFrame,
|
|
8
14
|
SurfaceHandle,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useContext } from 'react';
|
|
2
|
+
import { TrialIdContext } from '../state/context';
|
|
2
3
|
import { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
|
|
3
4
|
import type { SurfaceHandle } from './useTiledSurface';
|
|
4
5
|
|
|
@@ -20,19 +21,36 @@ export function useSurface(): SurfaceHandle {
|
|
|
20
21
|
return surface;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The key `id` is registered under, and so the key it comes back on in a
|
|
26
|
+
* frame's `rects`: `<trial>/<id>` inside a trial, and `id` alone outside one.
|
|
27
|
+
*
|
|
28
|
+
* A surface's tile namespace is flat and shared by the whole lab, but an
|
|
29
|
+
* instrument names its regions once and every trial of it declares those same
|
|
30
|
+
* names. Unscoped, the second trial to mount takes the first one's rect and its
|
|
31
|
+
* painter, and the first is never told it moved again.
|
|
32
|
+
*/
|
|
33
|
+
export function useTileId(id: string): string {
|
|
34
|
+
const trial = useContext(TrialIdContext);
|
|
35
|
+
return trial ? `${trial}/${id}` : id;
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
/**
|
|
24
39
|
* A ref callback that publishes this element's rect to the surface under `id`.
|
|
25
40
|
*
|
|
26
41
|
* Attach it to whichever element the surface should draw into — that is not
|
|
27
42
|
* necessarily the trial's own element, since a trial may hold a drawn pane beside
|
|
28
43
|
* an undrawn one, or none at all.
|
|
44
|
+
*
|
|
45
|
+
* The frame reports it under `useTileId(id)`, not under `id`.
|
|
29
46
|
*/
|
|
30
47
|
export function useSurfaceTile(id: string): (el: HTMLElement | null) => void {
|
|
31
48
|
const surface = useSurfaceOptional();
|
|
49
|
+
const tileId = useTileId(id);
|
|
32
50
|
return useCallback(
|
|
33
51
|
(el: HTMLElement | null) => {
|
|
34
|
-
surface?.registerTile(
|
|
52
|
+
surface?.registerTile(tileId, el);
|
|
35
53
|
},
|
|
36
|
-
[surface,
|
|
54
|
+
[surface, tileId],
|
|
37
55
|
);
|
|
38
56
|
}
|
|
@@ -5,7 +5,10 @@ import type { Box, Rect } from './rect';
|
|
|
5
5
|
|
|
6
6
|
/** What a surface owner is handed once per animation frame. `rects` carries every
|
|
7
7
|
* tile, not only the dirty ones: a scissored draw has to know where it is drawing
|
|
8
|
-
* relative to a surface that may have resized under it.
|
|
8
|
+
* relative to a surface that may have resized under it.
|
|
9
|
+
*
|
|
10
|
+
* Its keys are what `useTileId` returns — `<trial>/<id>` for a tile registered
|
|
11
|
+
* inside a trial — not the bare id the tile was named with. */
|
|
9
12
|
export interface SurfaceFrame {
|
|
10
13
|
dirty: ReadonlySet<string>;
|
|
11
14
|
rects: ReadonlyMap<string, Rect>;
|
|
@@ -91,11 +91,12 @@ function SwatchGrid({ tokens, title }: { tokens: readonly Swatch[]; title: strin
|
|
|
91
91
|
function NebulaPreview() {
|
|
92
92
|
return (
|
|
93
93
|
<div>
|
|
94
|
-
<h3 className="lk-theme-doc__heading">Cosmic backdrop
|
|
94
|
+
<h3 className="lk-theme-doc__heading">Cosmic backdrop</h3>
|
|
95
95
|
<div className="lk-theme-doc__nebula" style={{ background: 'var(--wzl-backdrop)' }} />
|
|
96
96
|
<p className="lk-theme-doc__note">
|
|
97
|
-
|
|
98
|
-
by passing <code>nebula={'{[colors]}'}</code> to the
|
|
97
|
+
<code>--wzl-backdrop</code> — four radial gradients (purple, pink, blue) over a dark void
|
|
98
|
+
base. Override per-Lab by passing <code>nebula={'{[colors]}'}</code> to the{' '}
|
|
99
|
+
<code><Lab></code> component.
|
|
99
100
|
</p>
|
|
100
101
|
</div>
|
|
101
102
|
);
|
package/src/theme/base.less
CHANGED
|
@@ -66,8 +66,46 @@
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Mirrors packages/ui/src/components/range.module.css so a consumer's bare
|
|
70
|
+
// range matches the kit's. A weasel-ui component's own module class outranks
|
|
71
|
+
// this, which is what keeps InlineRange's gradient track intact.
|
|
69
72
|
:where(input[type="range"]) {
|
|
70
|
-
|
|
73
|
+
appearance: none;
|
|
74
|
+
-webkit-appearance: none;
|
|
75
|
+
height: 12px;
|
|
76
|
+
background: transparent;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
|
|
79
|
+
&::-webkit-slider-runnable-track {
|
|
80
|
+
height: var(--wzl-slider-track-h);
|
|
81
|
+
border-radius: var(--wzl-radius-sm);
|
|
82
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
&::-moz-range-track {
|
|
86
|
+
height: var(--wzl-slider-track-h);
|
|
87
|
+
border-radius: var(--wzl-radius-sm);
|
|
88
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
&::-webkit-slider-thumb {
|
|
92
|
+
appearance: none;
|
|
93
|
+
-webkit-appearance: none;
|
|
94
|
+
width: var(--wzl-slider-thumb-size);
|
|
95
|
+
height: var(--wzl-slider-thumb-size);
|
|
96
|
+
border: none;
|
|
97
|
+
border-radius: 50%;
|
|
98
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
|
|
99
|
+
margin-top: calc((var(--wzl-slider-track-h) - var(--wzl-slider-thumb-size)) / 2);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
&::-moz-range-thumb {
|
|
103
|
+
width: var(--wzl-slider-thumb-size);
|
|
104
|
+
height: var(--wzl-slider-thumb-size);
|
|
105
|
+
border: none;
|
|
106
|
+
border-radius: 50%;
|
|
107
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
|
|
108
|
+
}
|
|
71
109
|
}
|
|
72
110
|
}
|
|
73
111
|
|
package/src/trial/Trial.less
CHANGED
|
@@ -20,13 +20,19 @@
|
|
|
20
20
|
overflow: hidden;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// The sidebar and the content well are a windease strip, so their extents
|
|
24
|
+
// come from the strategy and this pane only has to fill the rect it was
|
|
25
|
+
// given. `.windease-zone` supplies the containing block.
|
|
26
|
+
&__panes {
|
|
27
|
+
flex: 1;
|
|
28
|
+
min-width: 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
&__sidebar {
|
|
24
|
-
flex-shrink: 0;
|
|
25
|
-
// Stated, not content-derived: an auto-width sidebar is as wide as its
|
|
26
|
-
// widest label, so one verbose config key sets the width of the lab.
|
|
27
|
-
width: var(--lk-trial-sidebar-w, 20rem);
|
|
28
32
|
display: flex;
|
|
29
33
|
flex-direction: column;
|
|
34
|
+
width: 100%;
|
|
35
|
+
height: 100%;
|
|
30
36
|
min-height: 0;
|
|
31
37
|
// Sections floor at their content height, so without this the last ones
|
|
32
38
|
// are cut rather than reachable.
|
|
@@ -40,7 +46,8 @@
|
|
|
40
46
|
&__content {
|
|
41
47
|
// The viewport region anchors against this well.
|
|
42
48
|
position: relative;
|
|
43
|
-
|
|
49
|
+
width: 100%;
|
|
50
|
+
height: 100%;
|
|
44
51
|
min-width: 0;
|
|
45
52
|
min-height: 0;
|
|
46
53
|
display: flex;
|
|
@@ -89,6 +96,21 @@
|
|
|
89
96
|
flex-shrink: 0;
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
// The strip's own seam. windease positions and sizes it; this is only what
|
|
100
|
+
// it looks like.
|
|
101
|
+
&__panes [data-affordance-kind='resize-x'] {
|
|
102
|
+
background: var(--wzl-border);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
&__panes .windease-affordance-hit:hover [data-affordance-kind='resize-x'],
|
|
106
|
+
&__panes .windease-affordance-hit:focus-visible [data-affordance-kind='resize-x'] {
|
|
107
|
+
background: var(--wzl-accent);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&__panes .windease-affordance-hit:focus-visible {
|
|
111
|
+
outline: none;
|
|
112
|
+
}
|
|
113
|
+
|
|
92
114
|
&--unknown {
|
|
93
115
|
display: flex;
|
|
94
116
|
align-items: center;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { TrialBody } from './TrialBody';
|
|
4
|
+
|
|
5
|
+
const VIEWPORT = { w: 800, h: 600 };
|
|
6
|
+
|
|
7
|
+
function renderBody(props: Partial<Parameters<typeof TrialBody>[0]> = {}) {
|
|
8
|
+
return render(
|
|
9
|
+
<TrialBody viewport={VIEWPORT} sidebar={<p>sidebar body</p>} {...props}>
|
|
10
|
+
<p>instrument output</p>
|
|
11
|
+
</TrialBody>,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('TrialBody', () => {
|
|
16
|
+
it('renders both panes', () => {
|
|
17
|
+
renderBody();
|
|
18
|
+
expect(screen.getByText('sidebar body')).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByText('instrument output')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('puts a labeled separator between the sidebar and the content', () => {
|
|
23
|
+
renderBody({ width: 320 });
|
|
24
|
+
const seam = screen.getByRole('separator');
|
|
25
|
+
expect(seam).toHaveAttribute('aria-orientation', 'horizontal');
|
|
26
|
+
expect(seam).toHaveAttribute('aria-valuenow', '320');
|
|
27
|
+
expect(seam).toHaveAccessibleName(/sidebar/i);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('reports the width a drag lands on', () => {
|
|
31
|
+
const onWidthChange = vi.fn();
|
|
32
|
+
renderBody({ width: 320, onWidthChange });
|
|
33
|
+
const seam = screen.getByRole('separator');
|
|
34
|
+
fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
|
|
35
|
+
fireEvent.pointerMove(seam, { clientX: 380, clientY: 100 });
|
|
36
|
+
fireEvent.pointerUp(seam, { clientX: 380, clientY: 100 });
|
|
37
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(380);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('resizes from the keyboard', () => {
|
|
41
|
+
const onWidthChange = vi.fn();
|
|
42
|
+
renderBody({ width: 320, onWidthChange });
|
|
43
|
+
const seam = screen.getByRole('separator');
|
|
44
|
+
fireEvent.keyDown(seam, { key: 'ArrowRight' });
|
|
45
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(328);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('refuses to drag the sidebar past its stated bounds', () => {
|
|
49
|
+
const onWidthChange = vi.fn();
|
|
50
|
+
renderBody({ width: 320, minWidth: 200, maxWidth: 400, onWidthChange });
|
|
51
|
+
const seam = screen.getByRole('separator');
|
|
52
|
+
expect(seam).toHaveAttribute('aria-valuemin', '200');
|
|
53
|
+
expect(seam).toHaveAttribute('aria-valuemax', '400');
|
|
54
|
+
fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
|
|
55
|
+
fireEvent.pointerMove(seam, { clientX: 900, clientY: 100 });
|
|
56
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(400);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('leaves the content pane a floor the seam cannot cross', () => {
|
|
60
|
+
const onWidthChange = vi.fn();
|
|
61
|
+
renderBody({ width: 320, maxWidth: 10_000, contentMinWidth: 300, onWidthChange });
|
|
62
|
+
const seam = screen.getByRole('separator');
|
|
63
|
+
fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
|
|
64
|
+
fireEvent.pointerMove(seam, { clientX: 5_000, clientY: 100 });
|
|
65
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(VIEWPORT.w - 300);
|
|
66
|
+
});
|
|
67
|
+
});
|