@weasel-js/labkit 1.4.0-pre.0 → 1.4.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/README.md +226 -55
- package/dist/_dts/{CanvasStackContext-DtAn_3O-.d.ts → CanvasStackContext-D6M0o8cQ.d.ts} +1 -1
- package/dist/_dts/{frac-Cp3NivlC.d.ts → frac-QS7XzvpG.d.ts} +11 -4
- package/dist/_dts/{index-BDVzvRzQ.d.ts → index-B2X21G1c.d.ts} +8 -2
- package/dist/_dts/{types-D6s4b7if.d.ts → types-C1Zw7s5P.d.ts} +1 -0
- package/dist/_dts/{types-DYMaEvM5.d.ts → types-ChVJJHvk.d.ts} +3 -0
- package/dist/_dts/{useTrialState-gmMvZqPc.d.ts → useTrialState-Cdm13fvl.d.ts} +2 -1
- package/dist/canvas/index.d.ts +75 -9
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +4 -4
- 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-3P5TTTA7.js → chunk-4AMN4MMB.js} +5 -5
- package/dist/{chunk-3P5TTTA7.js.map → chunk-4AMN4MMB.js.map} +1 -1
- package/dist/{chunk-ROQDRXKW.js → chunk-7VW3S44V.js} +621 -51
- package/dist/chunk-7VW3S44V.js.map +1 -0
- package/dist/{chunk-4EY67BKJ.js → chunk-B5VWJBWM.js} +12 -6
- package/dist/chunk-B5VWJBWM.js.map +1 -0
- package/dist/{chunk-HEORBLVT.js → chunk-L5WBGUVR.js} +1664 -1628
- package/dist/chunk-L5WBGUVR.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-6JMU56CU.js → chunk-QR5V3AFG.js} +6 -4
- package/dist/chunk-QR5V3AFG.js.map +1 -0
- package/dist/{chunk-LITE5YFB.js → chunk-UD7TEHZT.js} +15 -15
- package/dist/chunk-UD7TEHZT.js.map +1 -0
- package/dist/{chunk-3ZELRRBV.js → chunk-VHFQKAUL.js} +86 -6
- package/dist/chunk-VHFQKAUL.js.map +1 -0
- package/dist/{chunk-YQJNT4XQ.js → chunk-W3KSUXXR.js} +16 -13
- package/dist/chunk-W3KSUXXR.js.map +1 -0
- package/dist/{chunk-JDDIZSLL.js → chunk-XCBCZEBO.js} +3 -3
- package/dist/{chunk-JDDIZSLL.js.map → chunk-XCBCZEBO.js.map} +1 -1
- package/dist/{chunk-EQIB2PGC.js → chunk-Y2HK44TG.js} +3 -3
- package/dist/{chunk-EQIB2PGC.js.map → chunk-Y2HK44TG.js.map} +1 -1
- package/dist/{chunk-5TCEVMPI.js → chunk-YQGQUBKA.js} +3 -3
- package/dist/{chunk-5TCEVMPI.js.map → chunk-YQGQUBKA.js.map} +1 -1
- package/dist/controls/index.d.ts +2 -2
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +3 -3
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +57 -59
- package/dist/index.js +335 -240
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -4
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +5 -5
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +336 -15
- 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 +64 -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 +3 -3
- 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/annotations/store.test.ts +72 -0
- package/src/annotations/store.ts +30 -0
- package/src/annotations/types.ts +9 -2
- package/src/canvas/index.ts +11 -0
- 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/index.test.ts +1 -0
- package/src/index.ts +13 -9
- 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/passthrough/weasel-ui.test.ts +9 -0
- package/src/passthrough/weasel-ui.ts +4 -0
- 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/chunk-3ZELRRBV.js.map +0 -1
- package/dist/chunk-4EY67BKJ.js.map +0 -1
- package/dist/chunk-6JMU56CU.js.map +0 -1
- package/dist/chunk-HEORBLVT.js.map +0 -1
- package/dist/chunk-LITE5YFB.js.map +0 -1
- package/dist/chunk-ROQDRXKW.js.map +0 -1
- package/dist/chunk-XJ6N32QP.js.map +0 -1
- package/dist/chunk-YQJNT4XQ.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/labkit",
|
|
3
|
-
"version": "1.4.0
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "React widgets for building self-contained interactive lab pages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "orochi235",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"dev:drag": "LABKIT_EXAMPLE=drag-lab vite",
|
|
94
94
|
"dev:weasel": "LABKIT_EXAMPLE=weasel-lab vite",
|
|
95
95
|
"build": "tsup && npm run build:dts && npm run build:css && npm run build:fonts",
|
|
96
|
-
"build:dts": "tsx scripts/build-dts.mts",
|
|
96
|
+
"build:dts": "NODE_OPTIONS=--max-old-space-size=8192 tsx scripts/build-dts.mts",
|
|
97
97
|
"prepublishOnly": "npm run build",
|
|
98
98
|
"build:css": "node scripts/build-css.mjs",
|
|
99
99
|
"build:fonts": "mkdir -p dist/fonts && cp ../theme/fonts/*.woff2 dist/fonts/",
|
|
@@ -114,11 +114,11 @@
|
|
|
114
114
|
"react-dom": "^19.0.0"
|
|
115
115
|
},
|
|
116
116
|
"dependencies": {
|
|
117
|
-
"@weasel-js/core": "1.4.0
|
|
118
|
-
"@weasel-js/loupe": "1.4.0
|
|
119
|
-
"@weasel-js/svg": "1.4.0
|
|
120
|
-
"@weasel-js/theme": "1.4.0
|
|
121
|
-
"@weasel-js/ui": "1.4.0
|
|
117
|
+
"@weasel-js/core": "1.4.0",
|
|
118
|
+
"@weasel-js/loupe": "1.4.0",
|
|
119
|
+
"@weasel-js/svg": "1.4.0",
|
|
120
|
+
"@weasel-js/theme": "1.4.0",
|
|
121
|
+
"@weasel-js/ui": "1.4.0",
|
|
122
122
|
"earcut": "2.2.4",
|
|
123
123
|
"polygon-clipping": "^0.15.7",
|
|
124
124
|
"react-aria-components": "^1.5.0",
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
"@biomejs/biome": "^2.4.12",
|
|
130
130
|
"@rollup/plugin-alias": "^6.0.0",
|
|
131
131
|
"@testing-library/jest-dom": "^6.6.0",
|
|
132
|
-
"@weasel-js/modes": "1.4.0
|
|
132
|
+
"@weasel-js/modes": "1.4.0",
|
|
133
133
|
"less": "^4.2.0",
|
|
134
134
|
"rollup-plugin-dts": "^6.4.1",
|
|
135
135
|
"tsx": "^4.19.0",
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { type CSSProperties, useEffect, useRef, useState } from 'react';
|
|
10
10
|
import { createPortal } from 'react-dom';
|
|
11
11
|
import type { Rect } from '../surface/rect';
|
|
12
|
-
import { useSurfaceCanvas, useSurfaceOptional } from '../surface/useSurfaceTile';
|
|
12
|
+
import { useSurfaceCanvas, useSurfaceOptional, useTileId } from '../surface/useSurfaceTile';
|
|
13
13
|
import { createMarkDrawOne } from './drawOne';
|
|
14
14
|
import type { WorldRect } from './frac';
|
|
15
15
|
import { seenFrom } from './staleness';
|
|
@@ -101,12 +101,16 @@ export function AnnotationOverlay({
|
|
|
101
101
|
configRef.current = config;
|
|
102
102
|
|
|
103
103
|
const id = target.id;
|
|
104
|
+
// The surface's tile namespace is the whole lab's, and every trial of an
|
|
105
|
+
// instrument declares the same target ids — so the tile is registered under
|
|
106
|
+
// the trial-scoped key, not under `id`.
|
|
107
|
+
const tileId = useTileId(id);
|
|
104
108
|
const lastTile = useRef<HTMLElement | null>(null);
|
|
105
109
|
useEffect(() => {
|
|
106
110
|
const el = target.ref.current ?? null;
|
|
107
111
|
if (el === lastTile.current) return;
|
|
108
112
|
lastTile.current = el;
|
|
109
|
-
surface?.registerTile(
|
|
113
|
+
surface?.registerTile(tileId, el);
|
|
110
114
|
});
|
|
111
115
|
useEffect(
|
|
112
116
|
() => () => {
|
|
@@ -114,20 +118,20 @@ export function AnnotationOverlay({
|
|
|
114
118
|
// commit. StrictMode's mount / unmount / mount would otherwise take the
|
|
115
119
|
// tile out and never put it back, and the surface would measure nothing.
|
|
116
120
|
lastTile.current = null;
|
|
117
|
-
surface?.registerTile(
|
|
121
|
+
surface?.registerTile(tileId, null);
|
|
118
122
|
},
|
|
119
|
-
[surface,
|
|
123
|
+
[surface, tileId],
|
|
120
124
|
);
|
|
121
125
|
|
|
122
126
|
useEffect(
|
|
123
127
|
() =>
|
|
124
|
-
surface?.registerPainter(
|
|
128
|
+
surface?.registerPainter(tileId, (next) => {
|
|
125
129
|
setRect((prev) => (sameRect(prev, next) ? prev : next));
|
|
126
130
|
// The shared buffer was cleared if it resized this frame; this pane's
|
|
127
131
|
// own loop has no way to know that.
|
|
128
132
|
sceneCanvas.current?.requestRedraw();
|
|
129
133
|
}),
|
|
130
|
-
[surface,
|
|
134
|
+
[surface, tileId],
|
|
131
135
|
);
|
|
132
136
|
|
|
133
137
|
const container = surface?.getContainer() ?? null;
|
|
@@ -61,6 +61,12 @@
|
|
|
61
61
|
cursor: pointer;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// Box and typography come from <Input>. This only says how the field shares
|
|
65
|
+
// the row.
|
|
66
|
+
.lk-mark-list__title {
|
|
67
|
+
width: 100%;
|
|
68
|
+
}
|
|
69
|
+
|
|
64
70
|
// The export panel: a target to export, how, and at what resolution. Held in
|
|
65
71
|
// a popover because a mark belongs to a target — with more than one pane,
|
|
66
72
|
// "export" on its own does not say what.
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
import { act, render } from '@testing-library/react';
|
|
11
11
|
import { StrictMode, useRef } from 'react';
|
|
12
12
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { TrialIdProvider } from '../state/context';
|
|
13
14
|
import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
|
|
15
|
+
import type { SurfaceHandle } from '../surface/useTiledSurface';
|
|
14
16
|
import { useTiledSurface } from '../surface/useTiledSurface';
|
|
15
17
|
import { AnnotationTargets } from './AnnotationTargets';
|
|
16
18
|
import { createAnnotationStore } from './store';
|
|
@@ -140,3 +142,114 @@ describe('<AnnotationTargets>', () => {
|
|
|
140
142
|
expect(document.querySelectorAll('.lk-annotate__input')).toHaveLength(0);
|
|
141
143
|
});
|
|
142
144
|
});
|
|
145
|
+
|
|
146
|
+
/** One trial's worth of the harness above: its own pane element, its own
|
|
147
|
+
* overlay, and the target id every trial of an instrument shares. */
|
|
148
|
+
function OneTrial({
|
|
149
|
+
trialId,
|
|
150
|
+
box,
|
|
151
|
+
}: {
|
|
152
|
+
trialId: string;
|
|
153
|
+
box: { left: number; top: number; width: number; height: number };
|
|
154
|
+
}) {
|
|
155
|
+
const pane = useRef<HTMLDivElement | null>(null);
|
|
156
|
+
const annotations = useRef(createAnnotationStore({ targets: () => [] })).current;
|
|
157
|
+
const capability: AnnotationsCapability = {
|
|
158
|
+
targets: (): AnnotationTarget[] => [{ id: 'pane:a', ref: pane, content: { w: 200, h: 100 } }],
|
|
159
|
+
};
|
|
160
|
+
return (
|
|
161
|
+
<TrialIdProvider trialId={trialId}>
|
|
162
|
+
<div
|
|
163
|
+
data-testid={trialId}
|
|
164
|
+
ref={(el) => {
|
|
165
|
+
pane.current = el;
|
|
166
|
+
if (el) stubBox(el, box.left, box.top, box.width, box.height);
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
<AnnotationTargets
|
|
170
|
+
capability={capability}
|
|
171
|
+
state={{}}
|
|
172
|
+
config={{}}
|
|
173
|
+
annotations={annotations}
|
|
174
|
+
activeToolId="rect"
|
|
175
|
+
/>
|
|
176
|
+
</TrialIdProvider>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
describe('two trials of one instrument', () => {
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
vi.useFakeTimers();
|
|
183
|
+
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) =>
|
|
184
|
+
setTimeout(() => cb(0), 16),
|
|
185
|
+
);
|
|
186
|
+
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
|
|
187
|
+
});
|
|
188
|
+
afterEach(() => {
|
|
189
|
+
vi.useRealTimers();
|
|
190
|
+
vi.unstubAllGlobals();
|
|
191
|
+
vi.restoreAllMocks();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// An instrument names its regions once, so every trial of it declares the
|
|
195
|
+
// same target id. Sharing the surface's tile namespace, the trial that opens
|
|
196
|
+
// second takes the first one's rect, its observer registration and its
|
|
197
|
+
// painter — and the first is never told it moved again, so it keeps the box
|
|
198
|
+
// it was measured at while it was the only trial open.
|
|
199
|
+
it('re-measures a trial that was alone when a second one opens beside it', () => {
|
|
200
|
+
let handle: SurfaceHandle | null = null;
|
|
201
|
+
|
|
202
|
+
function Trials({ second }: { second: boolean }) {
|
|
203
|
+
const surface = useTiledSurface({ onFrame: () => {} });
|
|
204
|
+
handle = surface;
|
|
205
|
+
return (
|
|
206
|
+
<SurfaceContext.Provider value={surface}>
|
|
207
|
+
<SurfaceCanvasContext.Provider value={null}>
|
|
208
|
+
<div
|
|
209
|
+
ref={(el) => {
|
|
210
|
+
if (!el) return;
|
|
211
|
+
stubBox(el, 0, 0, 1200, 700);
|
|
212
|
+
surface.containerRef(el);
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<OneTrial
|
|
216
|
+
trialId="t1"
|
|
217
|
+
box={{ left: 0, top: 0, width: second ? 476 : 666, height: 639 }}
|
|
218
|
+
/>
|
|
219
|
+
{second ? (
|
|
220
|
+
<OneTrial trialId="t2" box={{ left: 500, top: 0, width: 476, height: 639 }} />
|
|
221
|
+
) : null}
|
|
222
|
+
</div>
|
|
223
|
+
</SurfaceCanvasContext.Provider>
|
|
224
|
+
</SurfaceContext.Provider>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const boxes = (root: HTMLElement) => [
|
|
229
|
+
...root.querySelectorAll<HTMLElement>('.lk-annotate__input'),
|
|
230
|
+
];
|
|
231
|
+
const widths = (root: HTMLElement) =>
|
|
232
|
+
boxes(root).map((el) => el.style.getPropertyValue('--lk-anno-w'));
|
|
233
|
+
|
|
234
|
+
const { container, rerender } = render(<Trials second={false} />, { wrapper: StrictMode });
|
|
235
|
+
act(() => {
|
|
236
|
+
vi.advanceTimersByTime(64);
|
|
237
|
+
});
|
|
238
|
+
expect(widths(container)).toEqual(['666px']);
|
|
239
|
+
|
|
240
|
+
// The grid halves the open trial to make room. jsdom's ResizeObserver never
|
|
241
|
+
// fires, so this takes `Workspace`'s path — the one it takes anyway for a
|
|
242
|
+
// tile that moves without resizing, which no observer would have seen.
|
|
243
|
+
rerender(<Trials second={true} />);
|
|
244
|
+
act(() => {
|
|
245
|
+
handle?.invalidateRects();
|
|
246
|
+
vi.advanceTimersByTime(64);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(widths(container)).toEqual(['476px', '476px']);
|
|
250
|
+
expect(boxes(container).map((el) => el.style.getPropertyValue('--lk-anno-x'))).toEqual([
|
|
251
|
+
'0px',
|
|
252
|
+
'500px',
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Input } from '../passthrough/weasel-ui';
|
|
1
2
|
import { useAnnotations } from './AnnotationsContext';
|
|
2
3
|
import type { AnnotationMeaning } from './types';
|
|
3
4
|
|
|
@@ -43,12 +44,12 @@ export function MarkList({ meaning, config }: MarkListProps) {
|
|
|
43
44
|
×
|
|
44
45
|
</button>
|
|
45
46
|
</div>
|
|
46
|
-
<
|
|
47
|
+
<Input
|
|
47
48
|
className="lk-mark-list__title"
|
|
48
49
|
aria-label={`Title of ${a.kind} on ${a.target}`}
|
|
49
50
|
value={a.title ?? ''}
|
|
50
51
|
placeholder="Untitled"
|
|
51
|
-
onChange={(
|
|
52
|
+
onChange={(next) => marks.update(a.id, { title: next })}
|
|
52
53
|
/>
|
|
53
54
|
{statuses.length > 0 ? (
|
|
54
55
|
// A native select: it carries role, name and keyboard operability
|
|
@@ -155,6 +155,78 @@ describe('the annotation store', () => {
|
|
|
155
155
|
expect(fn.mock.calls.length).toBe(before);
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
// What the overlay's canvas does to a selection is `scene.setSelection` —
|
|
159
|
+
// weasel binds `useSelection` to the scene it is handed, so the selection is
|
|
160
|
+
// scene state, not React state, and these exercise the real write. What they
|
|
161
|
+
// do NOT reach is the pointer half: no click, marquee or handle runs here,
|
|
162
|
+
// because jsdom has no WebGL2 and the overlay's canvas never paints or
|
|
163
|
+
// hit-tests. That the canvas is bound to this scene is core's claim.
|
|
164
|
+
it('merges the selection across targets, and maps it back to annotation ids', () => {
|
|
165
|
+
const store = makeStore();
|
|
166
|
+
const onNaive = store.add(RING);
|
|
167
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
168
|
+
|
|
169
|
+
store.setSelection([onOcct, onNaive]);
|
|
170
|
+
// Declaration order, not call order: one merged answer over several scenes.
|
|
171
|
+
expect(store.selection()).toEqual([onNaive, onOcct]);
|
|
172
|
+
expect(store.selection().map((id) => store.get(id)?.target)).toEqual(['naive', 'occt']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('reads back what the canvas would have written into the scene', () => {
|
|
176
|
+
const store = makeStore();
|
|
177
|
+
const id = store.add(RING);
|
|
178
|
+
const [node] = [...store.sceneFor('naive').renderOrder()];
|
|
179
|
+
if (node === undefined) throw new Error('unreachable');
|
|
180
|
+
|
|
181
|
+
store.sceneFor('naive').setSelection([node]);
|
|
182
|
+
expect(store.selection()).toEqual([id]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('replaces rather than adds, clearing a target the new selection omits', () => {
|
|
186
|
+
const store = makeStore();
|
|
187
|
+
const onNaive = store.add(RING);
|
|
188
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
189
|
+
|
|
190
|
+
store.setSelection([onNaive]);
|
|
191
|
+
store.setSelection([onOcct]);
|
|
192
|
+
expect(store.selection()).toEqual([onOcct]);
|
|
193
|
+
expect(store.sceneFor('naive').getSelection()).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('drops an id it cannot resolve instead of throwing', () => {
|
|
197
|
+
const store = makeStore();
|
|
198
|
+
const id = store.add(RING);
|
|
199
|
+
|
|
200
|
+
expect(() => store.setSelection(['naive/nope', 'ghost/1', 'noslash', ''])).not.toThrow();
|
|
201
|
+
expect(store.selection()).toEqual([]);
|
|
202
|
+
|
|
203
|
+
store.setSelection([id, id]);
|
|
204
|
+
expect(store.selection()).toEqual([id]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('stops reporting a selected mark once it is removed', () => {
|
|
208
|
+
const store = makeStore();
|
|
209
|
+
const id = store.add(RING);
|
|
210
|
+
store.setSelection([id]);
|
|
211
|
+
store.remove(id);
|
|
212
|
+
expect(store.selection()).toEqual([]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('notifies subscribers when the selection changes', () => {
|
|
216
|
+
const store = makeStore();
|
|
217
|
+
const id = store.add(RING);
|
|
218
|
+
const fn = vi.fn();
|
|
219
|
+
store.subscribe(fn);
|
|
220
|
+
|
|
221
|
+
store.setSelection([id]);
|
|
222
|
+
expect(fn).toHaveBeenCalled();
|
|
223
|
+
|
|
224
|
+
// A write that changes nothing is not an event.
|
|
225
|
+
const before = fn.mock.calls.length;
|
|
226
|
+
store.setSelection([id]);
|
|
227
|
+
expect(fn.mock.calls.length).toBe(before);
|
|
228
|
+
});
|
|
229
|
+
|
|
158
230
|
it('round-trips through JSON, and the snapshot is JSON-clean', () => {
|
|
159
231
|
const store = makeStore();
|
|
160
232
|
const id = store.add(RING, { angle: 'iso', shading: 'outline' });
|
package/src/annotations/store.ts
CHANGED
|
@@ -221,6 +221,36 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
221
221
|
return history.redo(historySceneAt);
|
|
222
222
|
},
|
|
223
223
|
|
|
224
|
+
selection() {
|
|
225
|
+
const out: string[] = [];
|
|
226
|
+
for (const target of liveTargets()) {
|
|
227
|
+
const scene = scenes.get(target);
|
|
228
|
+
if (!scene) continue;
|
|
229
|
+
for (const node of scene.getSelection()) {
|
|
230
|
+
if (scene.get(node)) out.push(`${target}/${String(node)}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
setSelection(ids) {
|
|
237
|
+
const wanted = new Map<string, string[]>();
|
|
238
|
+
for (const id of ids) {
|
|
239
|
+
const found = nodeOf(id);
|
|
240
|
+
if (!found) continue;
|
|
241
|
+
const node = String(found.node.id);
|
|
242
|
+
const on = wanted.get(found.target);
|
|
243
|
+
if (!on) wanted.set(found.target, [node]);
|
|
244
|
+
else if (!on.includes(node)) on.push(node);
|
|
245
|
+
}
|
|
246
|
+
for (const [target, scene] of scenes) {
|
|
247
|
+
const next = wanted.get(target) ?? [];
|
|
248
|
+
const now = scene.getSelection();
|
|
249
|
+
if (now.length === next.length && next.every((n, i) => String(now[i]) === n)) continue;
|
|
250
|
+
scene.setSelection(next.map(asNodeId));
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
|
|
224
254
|
subscribe(fn) {
|
|
225
255
|
subs.add(fn);
|
|
226
256
|
return () => {
|
package/src/annotations/types.ts
CHANGED
|
@@ -196,8 +196,15 @@ export interface AnnotationsApi {
|
|
|
196
196
|
within(target: string, box: FracRect): Annotation[];
|
|
197
197
|
/** Whether `a`'s position still describes the picture `config` produces. */
|
|
198
198
|
isStale(a: Annotation, config: unknown): boolean;
|
|
199
|
-
/**
|
|
200
|
-
*
|
|
199
|
+
/** The marks the user currently has selected, across every target, as
|
|
200
|
+
* annotation ids. Every one resolves through `get`. */
|
|
201
|
+
selection(): readonly string[];
|
|
202
|
+
/** Replace the selection. An id naming a target or a mark that is not
|
|
203
|
+
* there is dropped, the way `update` and `remove` ignore one. */
|
|
204
|
+
setSelection(ids: readonly string[]): void;
|
|
205
|
+
/** Fires after every mutation *and* after a selection change — weasel keeps
|
|
206
|
+
* a canvas's selection on the scene, so both already arrive on this one
|
|
207
|
+
* channel. No delta: re-query, and re-read `selection()`. */
|
|
201
208
|
subscribe(fn: () => void): () => void;
|
|
202
209
|
|
|
203
210
|
/** Whether the last mark change on any target can be taken back. Weasel
|
package/src/canvas/index.ts
CHANGED
|
@@ -6,5 +6,16 @@ export type { ZoomAtOptions } from './camera';
|
|
|
6
6
|
export { centerOn, zoomAt } from './camera';
|
|
7
7
|
export { screenToWorld, worldToScreen } from './canvasCoords';
|
|
8
8
|
export type { CanvasLayerDescriptor } from './useLayerScheduler';
|
|
9
|
+
export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './useOrbit';
|
|
10
|
+
export {
|
|
11
|
+
clampPitch,
|
|
12
|
+
orbitAfterDrag,
|
|
13
|
+
orbitAfterWheel,
|
|
14
|
+
PITCH_LIMIT,
|
|
15
|
+
useOrbit,
|
|
16
|
+
wrapYaw,
|
|
17
|
+
} from './useOrbit';
|
|
18
|
+
export type { PanZoomHandlers, UsePanZoomOptions } from './usePanZoom';
|
|
19
|
+
export { usePanZoom } from './usePanZoom';
|
|
9
20
|
export type { ViewportSize, WorldFrame, WorldSpec } from './worldSpec';
|
|
10
21
|
export { applyCamera, DEFAULT_FRAME, resolveFrame } from './worldSpec';
|
|
@@ -29,6 +29,11 @@ describe('builder', () => {
|
|
|
29
29
|
});
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
it('annotates a number with a display suffix', () => {
|
|
33
|
+
const n = f.number(20).suffix('px');
|
|
34
|
+
expect(n.annotations.suffix).toBe('px');
|
|
35
|
+
});
|
|
36
|
+
|
|
32
37
|
it('keeps a custom node kind across a chain', () => {
|
|
33
38
|
const n = f.custom('vector2', { x: 0 }).label('Offset');
|
|
34
39
|
expect(n.kind).toBe('vector2');
|
package/src/config/builder.ts
CHANGED
|
@@ -90,6 +90,12 @@ class NumberNode extends BaseNode<number> {
|
|
|
90
90
|
return this.ann({ step });
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** A display suffix for the value — `'px'`, `'ms'`, `'°'`. Presentation only:
|
|
94
|
+
* it is never parsed, and the stored value stays a plain number. */
|
|
95
|
+
suffix(suffix: string): this {
|
|
96
|
+
return this.ann({ suffix });
|
|
97
|
+
}
|
|
98
|
+
|
|
93
99
|
/** Force a slider even without both bounds. */
|
|
94
100
|
slider(): this {
|
|
95
101
|
return this.ann({ control: 'slider' });
|
package/src/config/types.ts
CHANGED
|
@@ -420,3 +420,26 @@ describe('<ControlPanel> packing', () => {
|
|
|
420
420
|
expect(rowOf('Width')?.className).toMatch(/rowInline/);
|
|
421
421
|
});
|
|
422
422
|
});
|
|
423
|
+
|
|
424
|
+
describe('<ControlPanel> suffix', () => {
|
|
425
|
+
const panel = (node: ReturnType<typeof f.number>) =>
|
|
426
|
+
render(
|
|
427
|
+
<ControlPanel
|
|
428
|
+
schema={resolveConfigSchema(f.schema({ width: node.label('Width') }), [])}
|
|
429
|
+
config={{ width: 20 }}
|
|
430
|
+
setConfig={vi.fn()}
|
|
431
|
+
/>,
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
it('suffixes a slider readout with the suffix the leaf declares', () => {
|
|
435
|
+
const { container } = panel(f.number(20).range(0, 100).slider().suffix('px'));
|
|
436
|
+
expect(container.querySelector('input[type="range"]')).not.toBeNull();
|
|
437
|
+
expect(container.textContent).toContain('px');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('suffixes a typed number with the suffix the leaf declares', () => {
|
|
441
|
+
const { container } = panel(f.number(20).input().suffix('px'));
|
|
442
|
+
expect(container.querySelector('input[type="number"]')).not.toBeNull();
|
|
443
|
+
expect(container.textContent).toContain('px');
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -159,6 +159,7 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
159
159
|
const min = extra<number>(leaf, 'min');
|
|
160
160
|
const max = extra<number>(leaf, 'max');
|
|
161
161
|
const step = extra<number>(leaf, 'step');
|
|
162
|
+
const suffix = extra<string>(leaf, 'suffix');
|
|
162
163
|
if (extra<string>(leaf, 'control') === 'slider' && min !== undefined && max !== undefined) {
|
|
163
164
|
return (
|
|
164
165
|
<SliderRow
|
|
@@ -167,6 +168,7 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
167
168
|
min={min}
|
|
168
169
|
max={max}
|
|
169
170
|
step={step}
|
|
171
|
+
unit={suffix}
|
|
170
172
|
onChange={write}
|
|
171
173
|
layout={layout}
|
|
172
174
|
span={wide}
|
|
@@ -186,6 +188,7 @@ function ControlRow<TC extends Record<string, unknown>>({
|
|
|
186
188
|
min={min}
|
|
187
189
|
max={max}
|
|
188
190
|
step={step}
|
|
191
|
+
unit={suffix}
|
|
189
192
|
onChange={(n) => write(Math.min(hi, Math.max(lo, n)))}
|
|
190
193
|
layout={layout}
|
|
191
194
|
description={description}
|
package/src/index.test.ts
CHANGED
|
@@ -67,6 +67,7 @@ describe('surface, job and orbit entry points', () => {
|
|
|
67
67
|
expect(typeof kit.fracToWorld).toBe('function');
|
|
68
68
|
expect(typeof kit.useOrbit).toBe('function');
|
|
69
69
|
expect(typeof kit.orbitAfterDrag).toBe('function');
|
|
70
|
+
expect(typeof kit.usePanZoom).toBe('function');
|
|
70
71
|
expect(typeof kit.useJob).toBe('function');
|
|
71
72
|
expect(typeof kit.as2DView).toBe('function');
|
|
72
73
|
expect(kit.SurfaceContext).toBeDefined();
|
package/src/index.ts
CHANGED
|
@@ -115,6 +115,12 @@ export type {
|
|
|
115
115
|
CanvasStackContextValue,
|
|
116
116
|
CanvasStackProps,
|
|
117
117
|
CanvasStackSurface,
|
|
118
|
+
OrbitHandlers,
|
|
119
|
+
OrbitView,
|
|
120
|
+
PanZoomHandlers,
|
|
121
|
+
UseOrbitOptions,
|
|
122
|
+
UsePanZoomOptions,
|
|
123
|
+
Vec3,
|
|
118
124
|
ViewportSize,
|
|
119
125
|
WorldFrame,
|
|
120
126
|
WorldSpec,
|
|
@@ -125,21 +131,19 @@ export {
|
|
|
125
131
|
CanvasStack,
|
|
126
132
|
CanvasStackContext,
|
|
127
133
|
centerOn,
|
|
128
|
-
DEFAULT_FRAME,
|
|
129
|
-
resolveFrame,
|
|
130
|
-
screenToWorld,
|
|
131
|
-
worldToScreen,
|
|
132
|
-
zoomAt,
|
|
133
|
-
} from './canvas';
|
|
134
|
-
export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './canvas/useOrbit';
|
|
135
|
-
export {
|
|
136
134
|
clampPitch,
|
|
135
|
+
DEFAULT_FRAME,
|
|
137
136
|
orbitAfterDrag,
|
|
138
137
|
orbitAfterWheel,
|
|
139
138
|
PITCH_LIMIT,
|
|
139
|
+
resolveFrame,
|
|
140
|
+
screenToWorld,
|
|
140
141
|
useOrbit,
|
|
142
|
+
usePanZoom,
|
|
143
|
+
worldToScreen,
|
|
141
144
|
wrapYaw,
|
|
142
|
-
|
|
145
|
+
zoomAt,
|
|
146
|
+
} from './canvas';
|
|
143
147
|
export * from './chrome';
|
|
144
148
|
export { f } from './config/builder';
|
|
145
149
|
export { fromConfigFields } from './config/fromConfigField';
|
|
@@ -12,7 +12,7 @@ import type { ReactNode } from 'react';
|
|
|
12
12
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { defineInstrument } from '../instrument/defineInstrument';
|
|
14
14
|
import { SurfaceContext } from '../surface/SurfaceContext';
|
|
15
|
-
import { useSurfaceOptional, useSurfaceTile } from '../surface/useSurfaceTile';
|
|
15
|
+
import { useSurfaceOptional, useSurfaceTile, useTileId } from '../surface/useSurfaceTile';
|
|
16
16
|
import type { SurfaceFrame, SurfaceHandle } from '../surface/useTiledSurface';
|
|
17
17
|
import { useTiledSurface } from '../surface/useTiledSurface';
|
|
18
18
|
import { Lab } from './Lab';
|
|
@@ -39,9 +39,13 @@ beforeAll(() => {
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
let seen: SurfaceHandle | null | 'not-rendered' = 'not-rendered';
|
|
42
|
+
/** The key the probe's tile is actually registered under: a tile inside a
|
|
43
|
+
* trial is scoped by it, and the trial's id is minted by the store. */
|
|
44
|
+
let paneTileId = 'pane';
|
|
42
45
|
|
|
43
46
|
function Probe() {
|
|
44
47
|
seen = useSurfaceOptional();
|
|
48
|
+
paneTileId = useTileId('pane');
|
|
45
49
|
const tile = useSurfaceTile('pane');
|
|
46
50
|
return <div data-testid="pane" ref={tile} />;
|
|
47
51
|
}
|
|
@@ -146,7 +150,7 @@ describe('<Lab> tile round trip', () => {
|
|
|
146
150
|
|
|
147
151
|
act(() => {
|
|
148
152
|
handle?.invalidateRects();
|
|
149
|
-
handle?.invalidate(
|
|
153
|
+
handle?.invalidate(paneTileId);
|
|
150
154
|
});
|
|
151
155
|
act(() => {
|
|
152
156
|
vi.advanceTimersByTime(64);
|
|
@@ -156,7 +160,8 @@ describe('<Lab> tile round trip', () => {
|
|
|
156
160
|
// that passes because nothing ran is the failure mode this arc keeps
|
|
157
161
|
// hitting.
|
|
158
162
|
expect(frames.length).toBeGreaterThan(0);
|
|
159
|
-
|
|
163
|
+
expect(paneTileId).not.toBe('pane');
|
|
164
|
+
const rect = frames[frames.length - 1]?.rects.get(paneTileId);
|
|
160
165
|
expect(rect).toEqual({ x: 240, y: 100, w: 320, h: 200 });
|
|
161
166
|
});
|
|
162
167
|
});
|
|
@@ -39,4 +39,16 @@ describe('<LabHeader>', () => {
|
|
|
39
39
|
expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
it('draws each color mode as a glyph, with the word only as its name', () => {
|
|
44
|
+
// The assertion above passes whether the segment holds text or an icon,
|
|
45
|
+
// because both routes produce the same accessible name. This is the one
|
|
46
|
+
// that can tell them apart.
|
|
47
|
+
render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
|
|
48
|
+
for (const label of ['Auto', 'Light', 'Dark']) {
|
|
49
|
+
const segment = screen.getByRole('radio', { name: label });
|
|
50
|
+
expect(segment.querySelector('svg')).not.toBeNull();
|
|
51
|
+
expect(segment).toHaveTextContent('');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
42
54
|
});
|
package/src/lab/LabHeader.tsx
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { AddIcon } from '@weasel-js/ui';
|
|
1
|
+
import { AddIcon, ModeAutoIcon, ModeDarkIcon, ModeLightIcon } from '@weasel-js/ui';
|
|
2
2
|
import { Select, ToggleBar } from '../passthrough/weasel-ui';
|
|
3
3
|
import type { LabMode } from '../state/types';
|
|
4
4
|
import { useLabContext } from './LabContext';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
{ value: '
|
|
6
|
+
// The glyph is the segment's content and the word is its accessible name, so
|
|
7
|
+
// the bar stays a three-way radiogroup announcing Auto / Light / Dark.
|
|
8
|
+
const MODES = [
|
|
9
|
+
{ value: 'auto' as LabMode, label: 'Auto', glyph: <ModeAutoIcon size={14} /> },
|
|
10
|
+
{ value: 'light' as LabMode, label: 'Light', glyph: <ModeLightIcon size={14} /> },
|
|
11
|
+
{ value: 'dark' as LabMode, label: 'Dark', glyph: <ModeDarkIcon size={14} /> },
|
|
10
12
|
];
|
|
11
13
|
|
|
14
|
+
const MODE_ITEMS = MODES.map(({ value, label, glyph }) => ({
|
|
15
|
+
value,
|
|
16
|
+
label: glyph,
|
|
17
|
+
ariaLabel: label,
|
|
18
|
+
}));
|
|
19
|
+
|
|
12
20
|
/** The controls `<Lab>` puts in its header: add a trial, and choose the color
|
|
13
21
|
* mode. Both drive `LabContext`, which carried them with no UI at all — so
|
|
14
22
|
* every consumer rebuilt these two. Rendered before a consumer's own header
|
|
@@ -46,7 +54,9 @@ export function LabHeader() {
|
|
|
46
54
|
<ToggleBar
|
|
47
55
|
className="lk-lab-header__mode"
|
|
48
56
|
ariaLabel="Color mode"
|
|
49
|
-
|
|
57
|
+
size="sm"
|
|
58
|
+
variant="flat"
|
|
59
|
+
items={MODE_ITEMS}
|
|
50
60
|
value={lab.mode}
|
|
51
61
|
onChange={(next) => {
|
|
52
62
|
if (next) lab.setMode(next);
|
|
@@ -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();
|