@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
package/dist/surface/index.d.ts
CHANGED
|
@@ -30,7 +30,10 @@ declare function toDeviceRect(rect: Rect, surfaceHeight: number, dpr: number): R
|
|
|
30
30
|
|
|
31
31
|
/** What a surface owner is handed once per animation frame. `rects` carries every
|
|
32
32
|
* tile, not only the dirty ones: a scissored draw has to know where it is drawing
|
|
33
|
-
* relative to a surface that may have resized under it.
|
|
33
|
+
* relative to a surface that may have resized under it.
|
|
34
|
+
*
|
|
35
|
+
* Its keys are what `useTileId` returns — `<trial>/<id>` for a tile registered
|
|
36
|
+
* inside a trial — not the bare id the tile was named with. */
|
|
34
37
|
interface SurfaceFrame {
|
|
35
38
|
dirty: ReadonlySet<string>;
|
|
36
39
|
rects: ReadonlyMap<string, Rect>;
|
|
@@ -80,14 +83,26 @@ declare function useSurfaceCanvas(): HTMLCanvasElement | null;
|
|
|
80
83
|
declare function useSurfaceOptional(): SurfaceHandle | null;
|
|
81
84
|
/** The surface above. Throws where a caller cannot work without one. */
|
|
82
85
|
declare function useSurface(): SurfaceHandle;
|
|
86
|
+
/**
|
|
87
|
+
* The key `id` is registered under, and so the key it comes back on in a
|
|
88
|
+
* frame's `rects`: `<trial>/<id>` inside a trial, and `id` alone outside one.
|
|
89
|
+
*
|
|
90
|
+
* A surface's tile namespace is flat and shared by the whole lab, but an
|
|
91
|
+
* instrument names its regions once and every trial of it declares those same
|
|
92
|
+
* names. Unscoped, the second trial to mount takes the first one's rect and its
|
|
93
|
+
* painter, and the first is never told it moved again.
|
|
94
|
+
*/
|
|
95
|
+
declare function useTileId(id: string): string;
|
|
83
96
|
/**
|
|
84
97
|
* A ref callback that publishes this element's rect to the surface under `id`.
|
|
85
98
|
*
|
|
86
99
|
* Attach it to whichever element the surface should draw into — that is not
|
|
87
100
|
* necessarily the trial's own element, since a trial may hold a drawn pane beside
|
|
88
101
|
* an undrawn one, or none at all.
|
|
102
|
+
*
|
|
103
|
+
* The frame reports it under `useTileId(id)`, not under `id`.
|
|
89
104
|
*/
|
|
90
105
|
declare function useSurfaceTile(id: string): (el: HTMLElement | null) => void;
|
|
91
106
|
|
|
92
|
-
export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTiledSurface };
|
|
107
|
+
export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId, useTiledSurface };
|
|
93
108
|
export type { Box, Rect, SurfaceFrame, SurfaceHandle, TilePainter, UseTiledSurfaceOptions };
|
package/dist/surface/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTiledSurface } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId, useTiledSurface } from '../chunk-OFCBEZMU.js';
|
|
2
|
+
import '../chunk-2YILQ7Y5.js';
|
|
3
|
+
import '../chunk-DG2IFMLA.js';
|
|
3
4
|
//# sourceMappingURL=index.js.map
|
|
4
5
|
//# sourceMappingURL=index.js.map
|
package/dist/ui/layers/index.js
CHANGED
package/dist/undo/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
export { a0 as UndoCapability } from '../_dts/frac-
|
|
1
|
+
export { a0 as UndoCapability } from '../_dts/frac-X7mWgd7y.js';
|
|
2
2
|
import 'react';
|
|
3
|
-
import '../_dts/types-
|
|
4
|
-
import '../_dts/
|
|
5
|
-
import '
|
|
3
|
+
import '../_dts/types-lg4TSCb2.js';
|
|
4
|
+
import '../_dts/weasel-canvas-FJTFi3ZZ.js';
|
|
5
|
+
import 'react/jsx-runtime';
|
|
6
|
+
import '../_dts/PrefsForm.d-CgxUequc.js';
|
|
7
|
+
import '../_dts/types-ChVJJHvk.js';
|
|
6
8
|
import '../_dts/types-DJ79Tg5J.js';
|
|
7
|
-
import '../_dts/types-C-gh9Ap-.js';
|
|
8
9
|
|
|
9
10
|
/** A subscriber to a named event. */
|
|
10
11
|
type EventListener = () => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/labkit",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
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=3072 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.
|
|
118
|
-
"@weasel-js/loupe": "1.4.
|
|
119
|
-
"@weasel-js/svg": "1.4.
|
|
120
|
-
"@weasel-js/theme": "1.4.
|
|
121
|
-
"@weasel-js/ui": "1.4.
|
|
117
|
+
"@weasel-js/core": "1.4.1",
|
|
118
|
+
"@weasel-js/loupe": "1.4.1",
|
|
119
|
+
"@weasel-js/svg": "1.4.1",
|
|
120
|
+
"@weasel-js/theme": "1.4.1",
|
|
121
|
+
"@weasel-js/ui": "1.4.1",
|
|
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.
|
|
132
|
+
"@weasel-js/modes": "1.4.1",
|
|
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
|
|
@@ -81,7 +81,14 @@ export function CanvasStack({
|
|
|
81
81
|
|
|
82
82
|
const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
|
|
83
83
|
|
|
84
|
-
const
|
|
84
|
+
const onTap = (e: PointerEvent) => {
|
|
85
|
+
const el = containerRef.current;
|
|
86
|
+
if (!onHitTest || !el) return;
|
|
87
|
+
const rect = el.getBoundingClientRect();
|
|
88
|
+
onHitTest(screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, view, frame));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame, onTap });
|
|
85
92
|
useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });
|
|
86
93
|
|
|
87
94
|
const ctxValue = useMemo(
|
|
@@ -103,16 +110,6 @@ export function CanvasStack({
|
|
|
103
110
|
height: size.height,
|
|
104
111
|
};
|
|
105
112
|
|
|
106
|
-
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
107
|
-
const wasDragging = handlers.isDragging();
|
|
108
|
-
handlers.onPointerUp(e);
|
|
109
|
-
if (!wasDragging && onHitTest && containerRef.current) {
|
|
110
|
-
const rect = containerRef.current.getBoundingClientRect();
|
|
111
|
-
const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
112
|
-
onHitTest(screenToWorld(screen, view, frame));
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
|
|
116
113
|
return (
|
|
117
114
|
<CanvasStackContext.Provider value={ctxValue}>
|
|
118
115
|
<div
|
|
@@ -121,8 +118,6 @@ export function CanvasStack({
|
|
|
121
118
|
style={containerStyle}
|
|
122
119
|
onWheel={handlers.onWheel}
|
|
123
120
|
onPointerDown={handlers.onPointerDown}
|
|
124
|
-
onPointerMove={handlers.onPointerMove}
|
|
125
|
-
onPointerUp={handlePointerUp}
|
|
126
121
|
>
|
|
127
122
|
{layers.map((layer) => (
|
|
128
123
|
<canvas
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import type { PointerEvent as ReactPointerEvent } from 'react';
|
|
1
3
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
clampPitch,
|
|
6
|
+
type OrbitView,
|
|
7
|
+
orbitAfterDrag,
|
|
8
|
+
orbitAfterWheel,
|
|
9
|
+
PITCH_LIMIT,
|
|
10
|
+
useOrbit,
|
|
11
|
+
wrapYaw,
|
|
12
|
+
} from './useOrbit';
|
|
3
13
|
|
|
4
14
|
const view = { yaw: 0, pitch: 0, distance: 5, target: { x: 0, y: 0, z: 0 } };
|
|
5
15
|
|
|
@@ -69,3 +79,85 @@ describe('orbitAfterWheel', () => {
|
|
|
69
79
|
expect(orbitAfterWheel(view, -100_000, 0.5, 50).distance).toBe(0.5);
|
|
70
80
|
});
|
|
71
81
|
});
|
|
82
|
+
|
|
83
|
+
/** The fields `useOrbit` reads off a React pointerdown, plus the element the
|
|
84
|
+
* session hangs its document listeners off. */
|
|
85
|
+
function press(
|
|
86
|
+
el: HTMLElement,
|
|
87
|
+
x: number,
|
|
88
|
+
y: number,
|
|
89
|
+
init: { pointerId?: number; button?: number; buttons?: number } = {},
|
|
90
|
+
): ReactPointerEvent<HTMLElement> {
|
|
91
|
+
return {
|
|
92
|
+
pointerId: init.pointerId ?? 1,
|
|
93
|
+
button: init.button ?? 0,
|
|
94
|
+
buttons: init.buttons ?? 1,
|
|
95
|
+
clientX: x,
|
|
96
|
+
clientY: y,
|
|
97
|
+
currentTarget: el,
|
|
98
|
+
} as unknown as ReactPointerEvent<HTMLElement>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function dispatchOn(target: EventTarget, type: string, init: PointerEventInit) {
|
|
102
|
+
act(() => {
|
|
103
|
+
target.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dragSetup() {
|
|
108
|
+
let current: OrbitView = view;
|
|
109
|
+
const onViewChange = (v: OrbitView) => {
|
|
110
|
+
current = v;
|
|
111
|
+
};
|
|
112
|
+
const { result, unmount } = renderHook(() => useOrbit({ view, onViewChange }));
|
|
113
|
+
const el = document.createElement('div');
|
|
114
|
+
document.body.appendChild(el);
|
|
115
|
+
return { result, unmount, el, getView: () => current };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('useOrbit dragging', () => {
|
|
119
|
+
it('turns from a move the element never sees', () => {
|
|
120
|
+
const { result, el, getView } = dragSetup();
|
|
121
|
+
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
122
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 200, clientY: 150 });
|
|
123
|
+
expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('ends the drag on a release dispatched off the element', () => {
|
|
127
|
+
const { result, el, getView } = dragSetup();
|
|
128
|
+
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
129
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 200, clientY: 150 });
|
|
130
|
+
dispatchOn(document, 'pointerup', { clientX: 200, clientY: 150 });
|
|
131
|
+
expect(result.current.isDragging()).toBe(false);
|
|
132
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
|
|
133
|
+
expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('ends the drag when capture is lost mid-gesture', () => {
|
|
137
|
+
const { result, el, getView } = dragSetup();
|
|
138
|
+
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
139
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 200, clientY: 150 });
|
|
140
|
+
dispatchOn(el, 'lostpointercapture', {});
|
|
141
|
+
expect(result.current.isDragging()).toBe(false);
|
|
142
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
|
|
143
|
+
expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('reads a move with nothing held as the release that never arrived', () => {
|
|
147
|
+
const { result, el, getView } = dragSetup();
|
|
148
|
+
act(() => result.current.onPointerDown(press(el, 100, 100, { buttons: 1 })));
|
|
149
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 200, clientY: 150 });
|
|
150
|
+
expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
|
|
151
|
+
dispatchOn(document, 'pointermove', { buttons: 0, clientX: 400, clientY: 400 });
|
|
152
|
+
expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
|
|
153
|
+
expect(result.current.isDragging()).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('drops its listeners when the hook unmounts mid-drag', () => {
|
|
157
|
+
const { result, el, unmount, getView } = dragSetup();
|
|
158
|
+
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
159
|
+
unmount();
|
|
160
|
+
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
|
|
161
|
+
expect(getView()).toEqual(view);
|
|
162
|
+
});
|
|
163
|
+
});
|
package/src/canvas/useOrbit.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { openPointerSession, type PointerSession } from '@weasel-js/core';
|
|
2
|
+
import {
|
|
3
|
+
type PointerEvent as ReactPointerEvent,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
type WheelEvent,
|
|
8
|
+
} from 'react';
|
|
2
9
|
|
|
3
10
|
/** A point in the space the instrument works in. */
|
|
4
11
|
export interface Vec3 {
|
|
@@ -70,15 +77,12 @@ export interface UseOrbitOptions {
|
|
|
70
77
|
|
|
71
78
|
export interface OrbitHandlers {
|
|
72
79
|
onWheel: (e: WheelEvent<HTMLElement>) => void;
|
|
73
|
-
onPointerDown: (e:
|
|
74
|
-
onPointerMove: (e: PointerEvent<HTMLElement>) => void;
|
|
75
|
-
onPointerUp: (e: PointerEvent<HTMLElement>) => void;
|
|
80
|
+
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
|
|
76
81
|
onDoubleClick: () => void;
|
|
77
82
|
isDragging: () => boolean;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
interface DragState {
|
|
81
|
-
pointerId: number;
|
|
82
86
|
startX: number;
|
|
83
87
|
startY: number;
|
|
84
88
|
startView: OrbitView;
|
|
@@ -95,8 +99,11 @@ export function useOrbit({
|
|
|
95
99
|
maxDistance = 1000,
|
|
96
100
|
}: UseOrbitOptions): OrbitHandlers {
|
|
97
101
|
const dragRef = useRef<DragState | null>(null);
|
|
102
|
+
const sessionRef = useRef<PointerSession | null>(null);
|
|
98
103
|
const viewRef = useRef(view);
|
|
99
104
|
viewRef.current = view;
|
|
105
|
+
const onViewChangeRef = useRef(onViewChange);
|
|
106
|
+
onViewChangeRef.current = onViewChange;
|
|
100
107
|
const homeRef = useRef(home ?? view);
|
|
101
108
|
if (home) homeRef.current = home;
|
|
102
109
|
|
|
@@ -108,42 +115,42 @@ export function useOrbit({
|
|
|
108
115
|
[onViewChange, minDistance, maxDistance],
|
|
109
116
|
);
|
|
110
117
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
dragRef.current = {
|
|
115
|
-
pointerId: e.pointerId,
|
|
116
|
-
startX: e.clientX,
|
|
117
|
-
startY: e.clientY,
|
|
118
|
-
startView: viewRef.current,
|
|
119
|
-
moved: false,
|
|
120
|
-
};
|
|
118
|
+
const clearDrag = useCallback(() => {
|
|
119
|
+
dragRef.current = null;
|
|
120
|
+
sessionRef.current = null;
|
|
121
121
|
}, []);
|
|
122
122
|
|
|
123
|
-
const
|
|
124
|
-
(e:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
const onPointerDown = useCallback(
|
|
124
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
125
|
+
if (e.button !== 0) return;
|
|
126
|
+
const drag: DragState = {
|
|
127
|
+
startX: e.clientX,
|
|
128
|
+
startY: e.clientY,
|
|
129
|
+
startView: viewRef.current,
|
|
130
|
+
moved: false,
|
|
131
|
+
};
|
|
132
|
+
dragRef.current = drag;
|
|
133
|
+
sessionRef.current = openPointerSession(e.currentTarget, e, {
|
|
134
|
+
onMove: (ev) => {
|
|
135
|
+
const dx = ev.clientX - drag.startX;
|
|
136
|
+
const dy = ev.clientY - drag.startY;
|
|
137
|
+
if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
138
|
+
drag.moved = true;
|
|
139
|
+
onViewChangeRef.current(orbitAfterDrag(drag.startView, dx, dy));
|
|
140
|
+
},
|
|
141
|
+
onEnd: clearDrag,
|
|
142
|
+
// A cancelled turn keeps where it got to: every move was committed as
|
|
143
|
+
// it arrived, so there is nothing left to undo.
|
|
144
|
+
onCancel: clearDrag,
|
|
145
|
+
});
|
|
132
146
|
},
|
|
133
|
-
[
|
|
147
|
+
[clearDrag],
|
|
134
148
|
);
|
|
135
149
|
|
|
136
|
-
|
|
137
|
-
const drag = dragRef.current;
|
|
138
|
-
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
139
|
-
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
140
|
-
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
141
|
-
}
|
|
142
|
-
dragRef.current = null;
|
|
143
|
-
}, []);
|
|
150
|
+
useEffect(() => () => sessionRef.current?.cancel(), []);
|
|
144
151
|
|
|
145
152
|
const onDoubleClick = useCallback(() => onViewChange(homeRef.current), [onViewChange]);
|
|
146
153
|
const isDragging = useCallback(() => dragRef.current?.moved === true, []);
|
|
147
154
|
|
|
148
|
-
return { onWheel, onPointerDown,
|
|
155
|
+
return { onWheel, onPointerDown, onDoubleClick, isDragging };
|
|
149
156
|
}
|