@weasel-js/labkit 1.7.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/_dts/{CanvasStackContext-D2jnIwfk.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
- package/dist/_dts/{Stage-CCWfQQWM.d.ts → Stage-D6t7o0pF.d.ts} +3 -2
- package/dist/_dts/{index-CsYqUHD3.d.ts → index-BTmaRy6a.d.ts} +10 -6
- package/dist/_dts/{types-CSm9jgol.d.ts → index-DCMA4Fy1.d.ts} +59 -113
- package/dist/_dts/{types-CRPSGFAp.d.ts → types-BUBSIZuV.d.ts} +8 -7
- package/dist/_dts/{useTrialState-CTZPgeZ7.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
- package/dist/canvas/index.d.ts +8 -6
- package/dist/canvas/index.js +6 -6
- package/dist/chrome/index.d.ts +7 -5
- package/dist/chrome/index.js +10 -9
- package/dist/chunk-2ES4W47N.js +8 -0
- package/dist/chunk-2ES4W47N.js.map +1 -0
- package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
- package/dist/chunk-2N7UBSS2.js.map +1 -0
- package/dist/chunk-4KSC7NXL.js +15491 -0
- package/dist/chunk-4KSC7NXL.js.map +1 -0
- package/dist/{chunk-7BO7HNGF.js → chunk-CEGFXTBD.js} +8 -61
- package/dist/chunk-CEGFXTBD.js.map +1 -0
- package/dist/{chunk-275JRRVD.js → chunk-E23Q6A3Y.js} +232 -37
- package/dist/chunk-E23Q6A3Y.js.map +1 -0
- package/dist/{chunk-GOZLNM4L.js → chunk-E6NARGU7.js} +5 -5
- package/dist/{chunk-GOZLNM4L.js.map → chunk-E6NARGU7.js.map} +1 -1
- package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
- package/dist/chunk-EESJUSYR.js.map +1 -0
- package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
- package/dist/chunk-EFXRM6XQ.js.map +1 -0
- package/dist/chunk-F4ACIK7Z.js +210 -0
- package/dist/chunk-F4ACIK7Z.js.map +1 -0
- package/dist/{chunk-FMK7UQ6V.js → chunk-FB4MIZOO.js} +61 -44
- package/dist/chunk-FB4MIZOO.js.map +1 -0
- package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
- package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
- package/dist/{chunk-I5DUQ5HM.js → chunk-GAHELLJT.js} +75 -72
- package/dist/chunk-GAHELLJT.js.map +1 -0
- package/dist/{chunk-YT75VYRD.js → chunk-IBVRMHW5.js} +5 -5
- package/dist/{chunk-YT75VYRD.js.map → chunk-IBVRMHW5.js.map} +1 -1
- package/dist/{chunk-T5IBSPAM.js → chunk-JFOJESJF.js} +33 -23
- package/dist/chunk-JFOJESJF.js.map +1 -0
- package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
- package/dist/chunk-PMLZP7RT.js.map +1 -0
- package/dist/{chunk-Y23G2VZL.js → chunk-READTDQX.js} +2 -2
- package/dist/chunk-READTDQX.js.map +1 -0
- package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
- package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
- package/dist/{chunk-ZIFPHIWZ.js → chunk-T6DLGEJB.js} +12 -14
- package/dist/chunk-T6DLGEJB.js.map +1 -0
- package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
- package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
- package/dist/{chunk-UNAZSLWE.js → chunk-VYVGVBGY.js} +17 -20
- package/dist/chunk-VYVGVBGY.js.map +1 -0
- package/dist/{chunk-KMJ7RWD3.js → chunk-WLMDTI2Q.js} +3 -3
- package/dist/{chunk-KMJ7RWD3.js.map → chunk-WLMDTI2Q.js.map} +1 -1
- package/dist/{chunk-J7OWWTCW.js → chunk-XE5HJOPL.js} +5 -6
- package/dist/chunk-XE5HJOPL.js.map +1 -0
- package/dist/chunk-XVUKDSYV.js +38 -0
- package/dist/chunk-XVUKDSYV.js.map +1 -0
- package/dist/{chunk-FZ7YFQN7.js → chunk-YCFKW5RX.js} +3 -3
- package/dist/chunk-YCFKW5RX.js.map +1 -0
- package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
- package/dist/chunk-ZQIHJXP4.js.map +1 -0
- package/dist/config/index.d.ts +18 -10
- package/dist/config/index.js +4 -4
- package/dist/controls/index.d.ts +4 -7
- package/dist/controls/index.js +6 -5
- package/dist/dragdrop/index.d.ts +23 -3
- package/dist/dragdrop/index.js +4 -3
- package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
- package/dist/index.d.ts +60 -714
- package/dist/index.js +357 -334
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +8 -0
- package/dist/job/index.js +1 -1
- package/dist/layers/index.d.ts +6 -5
- package/dist/layers/index.js +3 -2
- package/dist/loupe/index.d.ts +100 -30
- package/dist/loupe/index.js +585 -6
- package/dist/loupe/index.js.map +1 -1
- package/dist/overview/index.d.ts +4 -2
- package/dist/overview/index.js +15 -14
- package/dist/overview/index.js.map +1 -1
- package/dist/passthrough/weasel-canvas.d.ts +2 -0
- package/dist/passthrough/weasel-ui.d.ts +2 -2871
- package/dist/passthrough/weasel-ui.js +3 -2
- package/dist/primitives/index.d.ts +213 -3
- package/dist/primitives/index.js +8 -7
- package/dist/state/index.d.ts +7 -5
- package/dist/state/index.js +6 -6
- package/dist/styles.css +60 -44
- package/dist/surface/index.d.ts +10 -2
- package/dist/surface/index.js +1 -1
- package/dist/undo/index.d.ts +5 -3
- package/package.json +11 -11
- package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
- package/src/annotations/AnnotationOverlay.tsx +50 -31
- package/src/annotations/Annotations.overlay.test.tsx +31 -0
- package/src/annotations/capture.browser.test.ts +41 -0
- package/src/annotations/capture.ts +14 -7
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/frac.test.ts +44 -1
- package/src/annotations/frac.ts +46 -18
- package/src/annotations/index.ts +1 -0
- package/src/annotations/paint.test.ts +74 -33
- package/src/annotations/paint.ts +70 -32
- package/src/annotations/store.test.ts +212 -2
- package/src/annotations/store.ts +130 -27
- package/src/annotations/svgNodes.test.ts +22 -10
- package/src/annotations/svgNodes.ts +3 -3
- package/src/annotations/types.ts +29 -10
- package/src/canvas/CameraInput.test.tsx +30 -0
- package/src/canvas/CameraInput.tsx +16 -15
- package/src/canvas/CanvasStack.tsx +5 -5
- package/src/canvas/CanvasStackContext.ts +4 -1
- package/src/canvas/Stage.tsx +3 -3
- package/src/canvas/cameraRegistry.ts +1 -0
- package/src/canvas/worldSpec.ts +3 -1
- package/src/chrome/builtins.test.ts +7 -6
- package/src/chrome/builtins.tsx +5 -1
- package/src/chrome/index.ts +1 -1
- package/src/chrome/merge.test.ts +5 -0
- package/src/chrome/merge.ts +4 -2
- package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
- package/src/chrome/regions/PaletteRegion.tsx +3 -4
- package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
- package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
- package/src/chrome/regions/ViewportRegion.less +6 -0
- package/src/chrome/regions/ViewportRegion.tsx +10 -4
- package/src/chrome/types.ts +4 -2
- package/src/config/auto.ts +1 -0
- package/src/config/builder.ts +6 -0
- package/src/config/types.ts +2 -1
- package/src/controls/ControlPanel.test.tsx +37 -3
- package/src/controls/ControlPanel.tsx +12 -3
- package/src/dragdrop/DragDropRuntime.tsx +22 -13
- package/src/dragdrop/DragGhost.tsx +3 -0
- package/src/dragdrop/Palette.tsx +2 -0
- package/src/index.test.ts +5 -0
- package/src/index.ts +1 -1
- package/src/instrument/types.ts +0 -6
- package/src/job/useJob.ts +14 -9
- package/src/lab/Lab.tsx +5 -1
- package/src/lab/LabShell.less +1 -0
- package/src/lab/LabSwitcher.browser.test.tsx +27 -0
- package/src/lab/LabSwitcher.less +2 -0
- package/src/lab/LabZoom.tsx +4 -4
- package/src/lab/Workspace.tsx +48 -52
- package/src/loupe/AGENTS.md +28 -15
- package/src/loupe/CanvasLoupe.tsx +2 -3
- package/src/loupe/DomLoupe.tsx +1 -7
- package/src/loupe/Loupe.less +16 -0
- package/src/loupe/TrialLoupe.tsx +120 -57
- package/src/loupe/index.ts +1 -6
- package/src/loupe/types.test.ts +3 -3
- package/src/loupe/types.ts +24 -27
- package/src/loupe/useLoupe.test.tsx +29 -4
- package/src/loupe/useLoupe.ts +20 -17
- package/src/overview/OverviewMarks.tsx +12 -4
- package/src/overview/TrialOverview.tsx +3 -3
- package/src/passthrough/weasel-ui.test.ts +1 -1
- package/src/passthrough/weasel-ui.ts +1 -1
- package/src/primitives/FloatingPanel.tsx +7 -3
- package/src/primitives/JobProgress.less +1 -0
- package/src/primitives/Readout.test.tsx +2 -2
- package/src/primitives/Split.tsx +69 -41
- package/src/primitives/Toolbar.tsx +3 -3
- package/src/primitives/ZoomControl.less +1 -2
- package/src/specimen/Specimen.tsx +0 -1
- package/src/state/undock.ts +3 -0
- package/src/state/useOpenOnce.ts +20 -16
- package/src/state/view.ts +1 -0
- package/src/surface/index.ts +1 -1
- package/src/surface/useTiledSurface.test.tsx +14 -0
- package/src/surface/useTiledSurface.ts +11 -3
- package/src/trial/Trial.annotations.test.tsx +73 -0
- package/src/trial/Trial.less +0 -16
- package/src/trial/Trial.loupe.test.tsx +28 -11
- package/src/trial/Trial.tsx +74 -62
- package/src/trial/TrialChrome.tsx +6 -3
- package/src/trial/index.ts +2 -0
- package/src/trial/loupeSwitch.ts +15 -0
- package/dist/_dts/LayerList.d-DiuEArKq.d.ts +0 -83
- package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +0 -79
- package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +0 -141
- package/dist/_dts/Select.d-Bensr_MT.d.ts +0 -579
- package/dist/_dts/index-B2Yj5aA0.d.ts +0 -274
- package/dist/chunk-275JRRVD.js.map +0 -1
- package/dist/chunk-4LJSC7MB.js +0 -143
- package/dist/chunk-4LJSC7MB.js.map +0 -1
- package/dist/chunk-7BO7HNGF.js.map +0 -1
- package/dist/chunk-CQLQPQ4P.js.map +0 -1
- package/dist/chunk-FMK7UQ6V.js.map +0 -1
- package/dist/chunk-FZ7YFQN7.js.map +0 -1
- package/dist/chunk-HXXTULDN.js.map +0 -1
- package/dist/chunk-I5DUQ5HM.js.map +0 -1
- package/dist/chunk-IJJUK4FU.js.map +0 -1
- package/dist/chunk-J7OWWTCW.js.map +0 -1
- package/dist/chunk-M7HP3FTK.js +0 -549
- package/dist/chunk-M7HP3FTK.js.map +0 -1
- package/dist/chunk-MIM5R4ZT.js +0 -14972
- package/dist/chunk-MIM5R4ZT.js.map +0 -1
- package/dist/chunk-RWNDDKOH.js.map +0 -1
- package/dist/chunk-T5IBSPAM.js.map +0 -1
- package/dist/chunk-UNAZSLWE.js.map +0 -1
- package/dist/chunk-WPLAANH4.js.map +0 -1
- package/dist/chunk-Y23G2VZL.js.map +0 -1
- package/dist/chunk-ZIFPHIWZ.js.map +0 -1
- package/src/primitives/useRovingTabIndex.test.ts +0 -48
- package/src/primitives/useRovingTabIndex.ts +0 -88
package/src/annotations/store.ts
CHANGED
|
@@ -8,7 +8,17 @@ import {
|
|
|
8
8
|
import { captureTarget } from './capture';
|
|
9
9
|
import { type MarkDrawOptions, resolveMarkStyle } from './drawOne';
|
|
10
10
|
import type { WorldRect } from './frac';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
boundsOf,
|
|
13
|
+
fracContains,
|
|
14
|
+
fracEncloses,
|
|
15
|
+
fracToWorld,
|
|
16
|
+
fromShape,
|
|
17
|
+
roundFrac,
|
|
18
|
+
roundPoints,
|
|
19
|
+
toShape,
|
|
20
|
+
worldToFrac,
|
|
21
|
+
} from './frac';
|
|
12
22
|
import { MarkHistory } from './history';
|
|
13
23
|
import { isStale as isStaleAgainst, seenFrom } from './staleness';
|
|
14
24
|
import type {
|
|
@@ -18,6 +28,7 @@ import type {
|
|
|
18
28
|
AnnotationMeaning,
|
|
19
29
|
AnnotationPatch,
|
|
20
30
|
AnnotationQuery,
|
|
31
|
+
AnnotationSelectionMode,
|
|
21
32
|
AnnotationsApi,
|
|
22
33
|
AnnotationTargetInfo,
|
|
23
34
|
CaptureOptions,
|
|
@@ -38,6 +49,7 @@ export function createAnnotationScene(): MarkScene {
|
|
|
38
49
|
return createScene<AnnotationData, 'marks', MarkPose>({ systemLayers: [{ id: 'marks' }] });
|
|
39
50
|
}
|
|
40
51
|
|
|
52
|
+
/** Inputs to `createAnnotationStore`. */
|
|
41
53
|
export interface AnnotationStoreOptions {
|
|
42
54
|
/** Re-read on every call, so a target resizing or gaining a dependency takes
|
|
43
55
|
* effect without rebuilding the store. */
|
|
@@ -53,6 +65,10 @@ export interface AnnotationStoreOptions {
|
|
|
53
65
|
config?: () => unknown;
|
|
54
66
|
/** Notified after every finished export. */
|
|
55
67
|
onCapture?: (result: CaptureResult) => void;
|
|
68
|
+
/** Whether a selection can stand in several targets at once. `'exclusive'`
|
|
69
|
+
* (the default) clears every other target when a mark is selected in one.
|
|
70
|
+
* A thunk is read at each selection change. */
|
|
71
|
+
selection?: AnnotationSelectionMode | (() => AnnotationSelectionMode | undefined);
|
|
56
72
|
}
|
|
57
73
|
|
|
58
74
|
/** Everything a capture needs that is not the store's own state. Split out so
|
|
@@ -66,6 +82,34 @@ export interface CaptureDeps {
|
|
|
66
82
|
|
|
67
83
|
const NO_CONTENT = { w: 0, h: 0 };
|
|
68
84
|
|
|
85
|
+
/** Where a mark's vertices sit in its own bounds, from points in the content
|
|
86
|
+
* box. A point has none to store: its bounds are its position. */
|
|
87
|
+
function shapeFor(
|
|
88
|
+
kind: AnnotationData['kind'],
|
|
89
|
+
points: readonly FracPoint[] | undefined,
|
|
90
|
+
frac: FracRect,
|
|
91
|
+
): readonly FracPoint[] | undefined {
|
|
92
|
+
if (!points || kind === 'point') return undefined;
|
|
93
|
+
return roundPoints(toShape(points, frac));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type SerializedMarks = SerializedScene<AnnotationData, 'marks', MarkPose>;
|
|
97
|
+
|
|
98
|
+
/** Scenes saved before vertices followed the bounds hold them as `points` in
|
|
99
|
+
* the content box. Restated against each mark's bounds as saved. */
|
|
100
|
+
function migrateLegacyPoints(
|
|
101
|
+
raw: SerializedMarks,
|
|
102
|
+
content: { w: number; h: number },
|
|
103
|
+
): SerializedMarks {
|
|
104
|
+
const nodes = raw.nodes.map((n) => {
|
|
105
|
+
const { points, ...data } = n.data as AnnotationData & { points?: readonly FracPoint[] };
|
|
106
|
+
if (!points || data.shape) return n;
|
|
107
|
+
const shape = shapeFor(data.kind, points, worldToFrac(n.pose, content));
|
|
108
|
+
return { ...n, data: { ...data, ...(shape ? { shape } : {}) } };
|
|
109
|
+
});
|
|
110
|
+
return { ...raw, nodes };
|
|
111
|
+
}
|
|
112
|
+
|
|
69
113
|
/** A mark's id is its target and its node. One scene per target, so a node id
|
|
70
114
|
* is only unique within one — and an annotation is addressed by both. */
|
|
71
115
|
function splitId(id: string): { target: string; node: string } | undefined {
|
|
@@ -92,21 +136,73 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
92
136
|
for (const fn of subs) fn();
|
|
93
137
|
};
|
|
94
138
|
|
|
139
|
+
// Scene changes made while held are told to subscribers as one, once the
|
|
140
|
+
// outermost hold ends — so a click that also clears another target, or a
|
|
141
|
+
// `setSelection` spanning several, is one event with the final answer.
|
|
142
|
+
let holding = 0;
|
|
143
|
+
let heldDirty = false;
|
|
144
|
+
const hold = (fn: () => void): void => {
|
|
145
|
+
holding += 1;
|
|
146
|
+
try {
|
|
147
|
+
fn();
|
|
148
|
+
} finally {
|
|
149
|
+
holding -= 1;
|
|
150
|
+
}
|
|
151
|
+
if (holding === 0 && heldDirty) {
|
|
152
|
+
heldDirty = false;
|
|
153
|
+
notify();
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const exclusive = (): boolean =>
|
|
158
|
+
(typeof opts.selection === 'function' ? opts.selection() : opts.selection) !== 'per-target';
|
|
159
|
+
|
|
160
|
+
/** Each scene's selection as last seen, to tell a selection change from any
|
|
161
|
+
* other change arriving on the same channel. */
|
|
162
|
+
const lastSelection = new Map<string, string>();
|
|
163
|
+
const selectionKey = (scene: MarkScene): string => scene.getSelection().map(String).join(' ');
|
|
164
|
+
|
|
165
|
+
const clearOthers = (keep: string): void => {
|
|
166
|
+
for (const [target, scene] of scenes) {
|
|
167
|
+
if (target !== keep && scene.getSelection().length > 0) scene.setSelection([]);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const targetOf = (id: string): AnnotationTargetInfo | undefined =>
|
|
172
|
+
targets().find((t) => t.id === id);
|
|
173
|
+
|
|
174
|
+
const contentOf = (id: string) => targetOf(id)?.content ?? NO_CONTENT;
|
|
175
|
+
|
|
95
176
|
const sceneFor = (target: string): MarkScene => {
|
|
96
177
|
const known = scenes.get(target);
|
|
97
178
|
if (known) return known;
|
|
98
179
|
const raw = restore?.[target];
|
|
99
180
|
const scene = raw
|
|
100
181
|
? sceneFromJSON<AnnotationData, 'marks', MarkPose>(
|
|
101
|
-
raw as
|
|
182
|
+
migrateLegacyPoints(raw as SerializedMarks, contentOf(target)),
|
|
102
183
|
{},
|
|
103
184
|
)
|
|
104
185
|
: createAnnotationScene();
|
|
105
186
|
scenes.set(target, scene);
|
|
106
187
|
history.track(target, scene);
|
|
188
|
+
lastSelection.set(target, selectionKey(scene));
|
|
107
189
|
scene.subscribe(() => {
|
|
108
190
|
history.observe(target, scene);
|
|
109
|
-
|
|
191
|
+
const selected = selectionKey(scene);
|
|
192
|
+
const newlySelected = selected !== '' && selected !== lastSelection.get(target);
|
|
193
|
+
lastSelection.set(target, selected);
|
|
194
|
+
if (holding > 0) {
|
|
195
|
+
heldDirty = true;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (newlySelected && exclusive()) {
|
|
199
|
+
hold(() => {
|
|
200
|
+
heldDirty = true;
|
|
201
|
+
clearOthers(target);
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
notify();
|
|
205
|
+
}
|
|
110
206
|
});
|
|
111
207
|
return scene;
|
|
112
208
|
};
|
|
@@ -120,10 +216,6 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
120
216
|
// a mark a sidebar cannot list.
|
|
121
217
|
for (const target of Object.keys(restore ?? {})) sceneFor(target);
|
|
122
218
|
|
|
123
|
-
const targetOf = (id: string): AnnotationTargetInfo | undefined =>
|
|
124
|
-
targets().find((t) => t.id === id);
|
|
125
|
-
|
|
126
|
-
const contentOf = (id: string) => targetOf(id)?.content ?? NO_CONTENT;
|
|
127
219
|
/** How a target's marks are drawn right now — one answer for the pane, an
|
|
128
220
|
* export and an overview, so none can disagree on a color or a dash. */
|
|
129
221
|
const drawOptionsFor = (info: AnnotationTargetInfo): MarkDrawOptions => ({
|
|
@@ -136,12 +228,13 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
136
228
|
const project = (target: string, node: string): Annotation | undefined => {
|
|
137
229
|
const found = scenes.get(target)?.get(asNodeId(node));
|
|
138
230
|
if (!found) return undefined;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
231
|
+
const { shape, ...data } = found.data;
|
|
232
|
+
const frac = roundFrac(worldToFrac(found.pose as MarkPose, contentOf(target)));
|
|
233
|
+
const points =
|
|
234
|
+
data.kind === 'point'
|
|
235
|
+
? [{ x: frac.x, y: frac.y }]
|
|
236
|
+
: shape && roundPoints(fromShape(shape, frac));
|
|
237
|
+
return { ...data, target, id: `${target}/${node}`, frac, ...(points ? { points } : {}) };
|
|
145
238
|
};
|
|
146
239
|
|
|
147
240
|
/** Every target with a scene, in declaration order — then any whose target
|
|
@@ -245,20 +338,24 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
245
338
|
|
|
246
339
|
setSelection(ids) {
|
|
247
340
|
const wanted = new Map<string, string[]>();
|
|
341
|
+
const only = exclusive();
|
|
248
342
|
for (const id of ids) {
|
|
249
343
|
const found = nodeOf(id);
|
|
250
344
|
if (!found) continue;
|
|
345
|
+
if (only && wanted.size > 0 && !wanted.has(found.target)) continue;
|
|
251
346
|
const node = String(found.node.id);
|
|
252
347
|
const on = wanted.get(found.target);
|
|
253
348
|
if (!on) wanted.set(found.target, [node]);
|
|
254
349
|
else if (!on.includes(node)) on.push(node);
|
|
255
350
|
}
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
351
|
+
hold(() => {
|
|
352
|
+
for (const [target, scene] of scenes) {
|
|
353
|
+
const next = wanted.get(target) ?? [];
|
|
354
|
+
const now = scene.getSelection();
|
|
355
|
+
if (now.length === next.length && next.every((n, i) => String(now[i]) === n)) continue;
|
|
356
|
+
scene.setSelection(next.map(asNodeId));
|
|
357
|
+
}
|
|
358
|
+
});
|
|
262
359
|
},
|
|
263
360
|
|
|
264
361
|
subscribe(fn) {
|
|
@@ -270,10 +367,12 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
270
367
|
|
|
271
368
|
add(init: AnnotationInit, config?: unknown) {
|
|
272
369
|
const target = targetOf(init.target);
|
|
370
|
+
const frac = roundFrac(init.frac);
|
|
371
|
+
const shape = shapeFor(init.kind, init.points, frac);
|
|
273
372
|
const data: AnnotationData = {
|
|
274
373
|
target: init.target,
|
|
275
374
|
kind: init.kind,
|
|
276
|
-
...(
|
|
375
|
+
...(shape ? { shape } : {}),
|
|
277
376
|
...(init.title !== undefined ? { title: init.title } : {}),
|
|
278
377
|
...(init.status !== undefined ? { status: init.status } : {}),
|
|
279
378
|
...(init.tags !== undefined ? { tags: init.tags } : {}),
|
|
@@ -283,7 +382,7 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
283
382
|
const node = sceneFor(init.target).add({
|
|
284
383
|
kind: 'leaf',
|
|
285
384
|
layer: 'marks',
|
|
286
|
-
pose: fracToWorld(
|
|
385
|
+
pose: fracToWorld(frac, target?.content ?? NO_CONTENT),
|
|
287
386
|
data,
|
|
288
387
|
});
|
|
289
388
|
return `${init.target}/${String(node)}`;
|
|
@@ -292,14 +391,18 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
292
391
|
update(id, patch: AnnotationPatch) {
|
|
293
392
|
const found = nodeOf(id);
|
|
294
393
|
if (!found) return;
|
|
295
|
-
const { frac, ...meaning } = patch;
|
|
394
|
+
const { frac: fracPatch, points, ...meaning } = patch;
|
|
296
395
|
const nid = asNodeId(found.node.id);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
396
|
+
const content = contentOf(found.target);
|
|
397
|
+
const frac = fracPatch ?? (points ? boundsOf(points) : null);
|
|
398
|
+
const bounds = roundFrac(frac ?? worldToFrac(found.node.pose as MarkPose, content));
|
|
399
|
+
const shape = points ? shapeFor(found.node.data.kind, points, bounds) : undefined;
|
|
400
|
+
if (Object.keys(meaning).length > 0 || shape) {
|
|
401
|
+
found.scene.update(nid, {
|
|
402
|
+
data: { ...found.node.data, ...meaning, ...(shape ? { shape } : {}) },
|
|
403
|
+
});
|
|
302
404
|
}
|
|
405
|
+
if (frac) found.scene.setPose(nid, fracToWorld(bounds, content));
|
|
303
406
|
},
|
|
304
407
|
|
|
305
408
|
setMeta(id, meta) {
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import { serializeSvg } from '@weasel-js/svg';
|
|
1
|
+
import { serializeSvg, svgNeeds } from '@weasel-js/svg';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import { markCommands } from './paint';
|
|
4
4
|
import { markSvgNodes } from './svgNodes';
|
|
5
5
|
import type { AnnotationData, AnnotationKind } from './types';
|
|
6
6
|
|
|
7
|
-
const CONTENT = { w: 100, h: 60 };
|
|
8
|
-
|
|
9
7
|
const mark = (kind: AnnotationKind, extra: Partial<AnnotationData> = {}) => ({
|
|
10
8
|
pose: { x: 10, y: 12, width: 30, height: 20 },
|
|
11
9
|
data: {
|
|
12
10
|
target: 'flat',
|
|
13
11
|
kind,
|
|
14
|
-
|
|
12
|
+
shape: [
|
|
15
13
|
{ x: 0.1, y: 0.2 },
|
|
16
14
|
{ x: 0.5, y: 0.6 },
|
|
17
15
|
],
|
|
@@ -24,8 +22,8 @@ const KINDS: AnnotationKind[] = ['rect', 'ellipse', 'line', 'arrow', 'stroke'];
|
|
|
24
22
|
describe('a mark as vector', () => {
|
|
25
23
|
it.each(KINDS)('emits %s off the same geometry the screen draws', (kind) => {
|
|
26
24
|
const m = mark(kind);
|
|
27
|
-
const cmds = markCommands(m
|
|
28
|
-
const nodes = markSvgNodes(m
|
|
25
|
+
const cmds = markCommands(m);
|
|
26
|
+
const nodes = markSvgNodes(m);
|
|
29
27
|
expect(nodes).toHaveLength(cmds.length);
|
|
30
28
|
for (const [i, cmd] of cmds.entries()) {
|
|
31
29
|
if (cmd.kind !== 'path') throw new Error('expected path commands');
|
|
@@ -46,13 +44,13 @@ describe('a mark as vector', () => {
|
|
|
46
44
|
});
|
|
47
45
|
|
|
48
46
|
it('carries a stale mark′s dash through', () => {
|
|
49
|
-
const [node] = markSvgNodes(mark('rect'),
|
|
47
|
+
const [node] = markSvgNodes(mark('rect'), { stale: true });
|
|
50
48
|
if (node?.kind !== 'path') throw new Error('expected a path node');
|
|
51
49
|
expect(node.stroke?.dash).toEqual([6, 4]);
|
|
52
50
|
});
|
|
53
51
|
|
|
54
52
|
it("keeps the arrow's marker, so the export draws its head", () => {
|
|
55
|
-
const nodes = markSvgNodes(mark('arrow')
|
|
53
|
+
const nodes = markSvgNodes(mark('arrow'));
|
|
56
54
|
expect(nodes).toHaveLength(1);
|
|
57
55
|
const [node] = nodes;
|
|
58
56
|
if (node?.kind !== 'path') throw new Error('expected a path node');
|
|
@@ -64,11 +62,25 @@ describe('a mark as vector', () => {
|
|
|
64
62
|
|
|
65
63
|
it('emits text as text, at the pose, in the mark color', () => {
|
|
66
64
|
const m = mark('text', { title: 'missing edge' });
|
|
67
|
-
const [node] = markSvgNodes(m
|
|
65
|
+
const [node] = markSvgNodes(m);
|
|
68
66
|
expect(node).toMatchObject({ kind: 'text', text: 'missing edge', x: 10, y: 12 });
|
|
69
67
|
});
|
|
70
68
|
|
|
71
69
|
it('emits nothing for a text mark with no words', () => {
|
|
72
|
-
expect(markSvgNodes(mark('text')
|
|
70
|
+
expect(markSvgNodes(mark('text'))).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Why `composeCaptureSvg` and `<OverviewMarks>` serialize without awaiting
|
|
74
|
+
// `warmSvg`: a mark exports nothing that loads on demand. A mark gaining a
|
|
75
|
+
// gradient paint or a script run fails this, and those two then need it.
|
|
76
|
+
it('needs nothing loaded to serialize, stale or not', () => {
|
|
77
|
+
const all: AnnotationKind[] = [...KINDS, 'point', 'text'];
|
|
78
|
+
for (const stale of [false, true]) {
|
|
79
|
+
const nodes = all.flatMap((k) =>
|
|
80
|
+
markSvgNodes(mark(k, { title: 'words' }), { stale, color: '#123456' }),
|
|
81
|
+
);
|
|
82
|
+
expect(nodes.length).toBeGreaterThan(all.length - 1);
|
|
83
|
+
expect(svgNeeds(nodes)).toEqual({ fonts: [], paintKinds: [] });
|
|
84
|
+
}
|
|
73
85
|
});
|
|
74
86
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DrawCommand, FillStyle, Stroke } from '@weasel-js/core';
|
|
1
|
+
import type { DrawCommand, FillStyle, Stroke, View } from '@weasel-js/core';
|
|
2
2
|
import type { SvgNode, SvgPaint, SvgStroke } from '@weasel-js/svg';
|
|
3
3
|
import { type MarkStyle, markCommands, type PaintableMark } from './paint';
|
|
4
4
|
|
|
@@ -67,8 +67,8 @@ function toSvgNode(cmd: DrawCommand, m: PaintableMark): SvgNode {
|
|
|
67
67
|
*/
|
|
68
68
|
export function markSvgNodes(
|
|
69
69
|
m: PaintableMark,
|
|
70
|
-
content: { w: number; h: number },
|
|
71
70
|
style: MarkStyle = {},
|
|
71
|
+
scale?: View['scale'],
|
|
72
72
|
): SvgNode[] {
|
|
73
|
-
return markCommands(m,
|
|
73
|
+
return markCommands(m, style, scale).map((cmd) => toSvgNode(cmd, m));
|
|
74
74
|
}
|
package/src/annotations/types.ts
CHANGED
|
@@ -22,8 +22,8 @@ export interface FracRect {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** What shape a mark is. The kinds map onto weasel's own tools; an arrow is a
|
|
25
|
-
* line carrying an end marker, not a separate geometry. A point
|
|
26
|
-
*
|
|
25
|
+
* line carrying an end marker, not a separate geometry. A point is zero-size
|
|
26
|
+
* bounds at its position. */
|
|
27
27
|
export type AnnotationKind = 'stroke' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'text' | 'point';
|
|
28
28
|
|
|
29
29
|
/** One selectable status in an instrument's meaning tier. */
|
|
@@ -47,8 +47,11 @@ export interface AnnotationData {
|
|
|
47
47
|
target: string;
|
|
48
48
|
kind: AnnotationKind;
|
|
49
49
|
/** Vertices for the kinds a bounding box cannot describe — a line's two
|
|
50
|
-
* ends, a stroke's path
|
|
51
|
-
|
|
50
|
+
* ends, a stroke's path — in fractions of the mark's own bounds, so moving
|
|
51
|
+
* or resizing the bounds carries them along. A point stores none: it is its
|
|
52
|
+
* bounds' origin. `Annotation.points` is this restated in the target's
|
|
53
|
+
* content box. */
|
|
54
|
+
shape?: readonly FracPoint[];
|
|
52
55
|
title?: string;
|
|
53
56
|
status?: string;
|
|
54
57
|
tags?: readonly string[];
|
|
@@ -61,10 +64,13 @@ export interface AnnotationData {
|
|
|
61
64
|
|
|
62
65
|
/** A mark, as the store reports it. `id` is `<target>/<node>`: one scene per
|
|
63
66
|
* target means a node id is only unique within one. */
|
|
64
|
-
export interface Annotation extends AnnotationData {
|
|
67
|
+
export interface Annotation extends Omit<AnnotationData, 'shape'> {
|
|
65
68
|
id: string;
|
|
66
69
|
/** Bounds in fractions of the target's content box. */
|
|
67
70
|
frac: FracRect;
|
|
71
|
+
/** Vertices in fractions of the target's content box, wherever the bounds
|
|
72
|
+
* now are. A point's one entry is its bounds' origin. */
|
|
73
|
+
points?: readonly FracPoint[];
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/** A new mark, before the store gives it an id and dates it. */
|
|
@@ -72,6 +78,8 @@ export interface AnnotationInit {
|
|
|
72
78
|
target: string;
|
|
73
79
|
kind: AnnotationKind;
|
|
74
80
|
frac: FracRect;
|
|
81
|
+
/** In the target's content box. Stored relative to `frac`, so they should
|
|
82
|
+
* lie inside it; a point takes its position from `frac` alone. */
|
|
75
83
|
points?: readonly FracPoint[];
|
|
76
84
|
title?: string;
|
|
77
85
|
status?: string;
|
|
@@ -89,7 +97,8 @@ export interface AnnotationQuery {
|
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
/** What a mark's meaning can be patched to. Geometry moves through `frac` /
|
|
92
|
-
* `points`; everything else is the meaning tier.
|
|
100
|
+
* `points`; everything else is the meaning tier. `frac` alone carries the
|
|
101
|
+
* vertices with it; `points` alone moves the bounds to enclose them. */
|
|
93
102
|
export type AnnotationPatch = Partial<
|
|
94
103
|
Pick<Annotation, 'frac' | 'points' | 'title' | 'status' | 'tags' | 'meta'>
|
|
95
104
|
>;
|
|
@@ -159,8 +168,6 @@ export interface AnnotationStorage {
|
|
|
159
168
|
save: (doc: SerializedAnnotations) => void;
|
|
160
169
|
}
|
|
161
170
|
|
|
162
|
-
/** Declares that an instrument accepts marks: which regions take them,
|
|
163
|
-
* optionally what a mark is allowed to mean, and optionally where they live. */
|
|
164
171
|
/** The annotation tools a lab's rail can carry. */
|
|
165
172
|
export type AnnotationToolId =
|
|
166
173
|
| 'pointer'
|
|
@@ -172,6 +179,13 @@ export type AnnotationToolId =
|
|
|
172
179
|
| 'ellipse'
|
|
173
180
|
| 'text';
|
|
174
181
|
|
|
182
|
+
/** Whether a selection can stand in several of a trial's targets at once.
|
|
183
|
+
* `'exclusive'` clears every other target when a mark is selected in one;
|
|
184
|
+
* `'per-target'` lets each target keep its own. */
|
|
185
|
+
export type AnnotationSelectionMode = 'exclusive' | 'per-target';
|
|
186
|
+
|
|
187
|
+
/** Declares that an instrument accepts marks: which regions take them,
|
|
188
|
+
* optionally what a mark is allowed to mean, and optionally where they live. */
|
|
175
189
|
export interface AnnotationsCapability<TS = unknown, TC = unknown> {
|
|
176
190
|
/** `trial` is which trial is asking: a declaration made once per instrument
|
|
177
191
|
* is called once per trial, and its targets are that trial's own. */
|
|
@@ -188,6 +202,9 @@ export interface AnnotationsCapability<TS = unknown, TC = unknown> {
|
|
|
188
202
|
* notification, not an interception: a host wanting its own flow calls
|
|
189
203
|
* `capture()` from its own UI, which is the surface the chrome uses. */
|
|
190
204
|
onCapture?: (result: CaptureResult) => void;
|
|
205
|
+
/** Defaults to `'exclusive'`: selecting a mark in one target clears the
|
|
206
|
+
* selection in every other. */
|
|
207
|
+
selection?: AnnotationSelectionMode;
|
|
191
208
|
}
|
|
192
209
|
|
|
193
210
|
/** A persisted mark set. Versioned by this arc rather than by labkit's
|
|
@@ -229,11 +246,13 @@ export interface AnnotationsApi {
|
|
|
229
246
|
* annotation ids. Every one resolves through `get`. */
|
|
230
247
|
selection(): readonly string[];
|
|
231
248
|
/** Replace the selection. An id naming a target or a mark that is not
|
|
232
|
-
* there is dropped, the way `update` and `remove` ignore one.
|
|
249
|
+
* there is dropped, the way `update` and `remove` ignore one. Under an
|
|
250
|
+
* `'exclusive'` selection, so is every id outside the first id's target. */
|
|
233
251
|
setSelection(ids: readonly string[]): void;
|
|
234
252
|
/** Fires after every mutation *and* after a selection change — weasel keeps
|
|
235
253
|
* a canvas's selection on the scene, so both already arrive on this one
|
|
236
|
-
* channel.
|
|
254
|
+
* channel. A selection change touching several targets is one event. No
|
|
255
|
+
* delta: re-query, and re-read `selection()`. */
|
|
237
256
|
subscribe(fn: () => void): () => void;
|
|
238
257
|
|
|
239
258
|
/** Whether the last mark change on any target can be taken back. Weasel
|
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { act, render } from '@testing-library/react';
|
|
8
8
|
import { createPointerStore, PointerContextProvider } from '@weasel-js/core';
|
|
9
|
+
import { renderThenAbandon } from '@weasel-js/react/testing/abandonRender';
|
|
9
10
|
import { useState } from 'react';
|
|
10
11
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
11
12
|
import type { ViewTransform } from '../instrument/types';
|
|
13
|
+
import { type CameraView, useCameraView } from './CameraInput';
|
|
12
14
|
import { CanvasStack } from './CanvasStack';
|
|
13
15
|
import { Stage } from './Stage';
|
|
16
|
+
import { DEFAULT_FRAME } from './worldSpec';
|
|
14
17
|
|
|
15
18
|
beforeAll(() => {
|
|
16
19
|
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
@@ -133,4 +136,31 @@ describe('camera input', () => {
|
|
|
133
136
|
act(() => pointer(host, 'pointermove', 40, 30, 0));
|
|
134
137
|
expect(store.get()).toEqual({ worldX: 20, worldY: -15, viewId: 'stage' });
|
|
135
138
|
});
|
|
139
|
+
|
|
140
|
+
it('reads, writes and clamps through the committed props, never an abandoned render', () => {
|
|
141
|
+
let camera: CameraView | null = null;
|
|
142
|
+
const hostRef = { current: null };
|
|
143
|
+
const committed = vi.fn();
|
|
144
|
+
const abandoned = vi.fn();
|
|
145
|
+
interface ProbeProps {
|
|
146
|
+
view: ViewTransform;
|
|
147
|
+
onViewChange: (v: ViewTransform) => void;
|
|
148
|
+
maxZoom: number;
|
|
149
|
+
}
|
|
150
|
+
function Probe(props: ProbeProps) {
|
|
151
|
+
camera = useCameraView({ ...props, frame: DEFAULT_FRAME, hostRef });
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
renderThenAbandon<ProbeProps>(
|
|
155
|
+
{ view: { zoom: 1, pan: { x: 0, y: 0 } }, onViewChange: committed, maxZoom: 4 },
|
|
156
|
+
{ view: { zoom: 2, pan: { x: 50, y: 50 } }, onViewChange: abandoned, maxZoom: 8 },
|
|
157
|
+
(p) => <Probe {...p} />,
|
|
158
|
+
);
|
|
159
|
+
const cam = camera as unknown as CameraView;
|
|
160
|
+
expect(cam.get().scale.x).toBe(1);
|
|
161
|
+
expect(cam.zoomRange().max).toBe(4);
|
|
162
|
+
act(() => cam.set(cam.get()));
|
|
163
|
+
expect(abandoned).not.toHaveBeenCalled();
|
|
164
|
+
expect(committed).toHaveBeenCalledTimes(1);
|
|
165
|
+
});
|
|
136
166
|
});
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
useActionsRegistry,
|
|
15
15
|
useDepSource,
|
|
16
16
|
useGestureDispatcher,
|
|
17
|
+
useLatest,
|
|
17
18
|
usePointerContext,
|
|
18
19
|
type View,
|
|
19
20
|
type ViewApi,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
type RefObject,
|
|
27
28
|
useContext,
|
|
28
29
|
useEffect,
|
|
30
|
+
useInsertionEffect,
|
|
29
31
|
useMemo,
|
|
30
32
|
useRef,
|
|
31
33
|
} from 'react';
|
|
@@ -111,15 +113,18 @@ export function useCameraView({
|
|
|
111
113
|
minZoom = 0.1,
|
|
112
114
|
maxZoom = 32,
|
|
113
115
|
}: CameraViewOptions): CameraView {
|
|
116
|
+
// Written by each commit and by `set`, ahead of the render it asks for —
|
|
117
|
+
// two writers, so not `useLatest`.
|
|
114
118
|
const live = useRef({ view: normalize2DView(view), onViewChange, frame });
|
|
115
|
-
|
|
119
|
+
useInsertionEffect(() => {
|
|
120
|
+
live.current = { view: normalize2DView(view), onViewChange, frame };
|
|
121
|
+
});
|
|
116
122
|
const initialZoom = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);
|
|
117
|
-
const bounds = useRef({ min: minZoom, max: maxZoom });
|
|
118
123
|
const opening = initialZoom.current;
|
|
119
|
-
bounds
|
|
124
|
+
const bounds = useLatest({
|
|
120
125
|
min: opening == null ? minZoom : Math.min(minZoom, opening),
|
|
121
126
|
max: opening == null ? maxZoom : Math.max(maxZoom, opening),
|
|
122
|
-
};
|
|
127
|
+
});
|
|
123
128
|
|
|
124
129
|
return useMemo<CameraView>(() => {
|
|
125
130
|
const get = (): View => toCameraView(live.current.view, live.current.frame);
|
|
@@ -140,7 +145,7 @@ export function useCameraView({
|
|
|
140
145
|
},
|
|
141
146
|
zoomRange: () => ({ ...bounds.current }),
|
|
142
147
|
};
|
|
143
|
-
}, [hostRef]);
|
|
148
|
+
}, [bounds, hostRef]);
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
const TAP_ID = 'camera.tap';
|
|
@@ -177,10 +182,8 @@ function CameraDispatch({
|
|
|
177
182
|
onTap,
|
|
178
183
|
registry,
|
|
179
184
|
}: CameraInputProps & { registry: ActionsRegistry }) {
|
|
180
|
-
const frameRef =
|
|
181
|
-
|
|
182
|
-
const onTapRef = useRef(onTap);
|
|
183
|
-
onTapRef.current = onTap;
|
|
185
|
+
const frameRef = useLatest(frame);
|
|
186
|
+
const onTapRef = useLatest(onTap);
|
|
184
187
|
|
|
185
188
|
useDepSource('view', () => camera);
|
|
186
189
|
useDepSource('rootView', () => camera);
|
|
@@ -217,7 +220,7 @@ function CameraDispatch({
|
|
|
217
220
|
{ ...zoom, defaultBinding: wheelOnly as Action['defaultBinding'] },
|
|
218
221
|
tap,
|
|
219
222
|
];
|
|
220
|
-
}, []);
|
|
223
|
+
}, [frameRef, onTapRef]);
|
|
221
224
|
|
|
222
225
|
useEffect(() => {
|
|
223
226
|
const offs = actions.map((a) => registry.register(a));
|
|
@@ -284,10 +287,8 @@ export function usePublishPointer(
|
|
|
284
287
|
viewId: string,
|
|
285
288
|
): void {
|
|
286
289
|
const store = usePointerContext();
|
|
287
|
-
const frameRef =
|
|
288
|
-
|
|
289
|
-
const toLocal = useRef(clientToLocal);
|
|
290
|
-
toLocal.current = clientToLocal;
|
|
290
|
+
const frameRef = useLatest(frame);
|
|
291
|
+
const toLocal = useLatest(clientToLocal);
|
|
291
292
|
useEffect(() => {
|
|
292
293
|
const el = hostRef.current;
|
|
293
294
|
if (!el || !store) return;
|
|
@@ -305,7 +306,7 @@ export function usePublishPointer(
|
|
|
305
306
|
el.removeEventListener('pointermove', onMove);
|
|
306
307
|
el.removeEventListener('pointerleave', onLeave);
|
|
307
308
|
};
|
|
308
|
-
}, [hostRef, store, viewId]);
|
|
309
|
+
}, [frameRef, hostRef, store, toLocal, viewId]);
|
|
309
310
|
}
|
|
310
311
|
|
|
311
312
|
/** The pointer as a frame-local point, or `null` when there is none or it is
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Yoke } from '@weasel-js/core';
|
|
2
|
+
import { useLatest } from '@weasel-js/core';
|
|
2
3
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
4
|
import type { Point, ViewTransform } from '../instrument/types';
|
|
4
5
|
import { normalize2DView } from '../state/view';
|
|
@@ -80,12 +81,11 @@ export function CanvasStack({
|
|
|
80
81
|
return () => ro.disconnect();
|
|
81
82
|
}, []);
|
|
82
83
|
|
|
83
|
-
const onResizeRef =
|
|
84
|
-
onResizeRef.current = onResize;
|
|
84
|
+
const onResizeRef = useLatest(onResize);
|
|
85
85
|
useEffect(() => {
|
|
86
86
|
if (size.width === 0 && size.height === 0) return;
|
|
87
87
|
onResizeRef.current?.({ width: size.width, height: size.height });
|
|
88
|
-
}, [size.width, size.height]);
|
|
88
|
+
}, [size.width, size.height, onResizeRef]);
|
|
89
89
|
|
|
90
90
|
const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);
|
|
91
91
|
|
|
@@ -107,9 +107,9 @@ export function CanvasStack({
|
|
|
107
107
|
() => ({
|
|
108
108
|
view,
|
|
109
109
|
frame,
|
|
110
|
-
surface: { element: containerRef, size, canvases: canvasMap, layers },
|
|
110
|
+
surface: { element: containerRef, size, canvases: canvasMap, layers, worldSpec },
|
|
111
111
|
}),
|
|
112
|
-
[view, frame, size, layers],
|
|
112
|
+
[view, frame, size, layers, worldSpec],
|
|
113
113
|
);
|
|
114
114
|
|
|
115
115
|
const containerStyle: CSSProperties = { width, height };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createContext, type RefObject } from 'react';
|
|
2
2
|
import type { ViewTransform } from '../instrument/types';
|
|
3
3
|
import type { CanvasLayerDescriptor } from './useLayerScheduler';
|
|
4
|
-
import type { WorldFrame } from './worldSpec';
|
|
4
|
+
import type { WorldFrame, WorldSpec } from './worldSpec';
|
|
5
5
|
|
|
6
6
|
/** The stack's own drawing surface, for an overlay that has to re-draw it at
|
|
7
7
|
* another camera or read back what it presented — a loupe. */
|
|
@@ -14,6 +14,9 @@ export interface CanvasStackSurface {
|
|
|
14
14
|
canvases: RefObject<Map<string, HTMLCanvasElement>>;
|
|
15
15
|
/** The layers as the stack is drawing them, bottom first. */
|
|
16
16
|
layers: readonly CanvasLayerDescriptor[];
|
|
17
|
+
/** The coordinate system the stack was given, which `frame` resolves at
|
|
18
|
+
* its size — for a re-draw at another size. */
|
|
19
|
+
worldSpec?: WorldSpec;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
/** What a canvas stack publishes to its children: the view, and the resolved
|
package/src/canvas/Stage.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Yoke } from '@weasel-js/core';
|
|
2
|
+
import { useLatest } from '@weasel-js/core';
|
|
2
3
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
3
4
|
import type { ViewTransform } from '../instrument/types';
|
|
4
5
|
import { normalize2DView } from '../state/view';
|
|
@@ -85,8 +86,7 @@ export function Stage({
|
|
|
85
86
|
[camera, size.width, size.height],
|
|
86
87
|
);
|
|
87
88
|
|
|
88
|
-
const onResizeRef =
|
|
89
|
-
onResizeRef.current = onResize;
|
|
89
|
+
const onResizeRef = useLatest(onResize);
|
|
90
90
|
useEffect(() => {
|
|
91
91
|
const el = host.current;
|
|
92
92
|
if (!el) return;
|
|
@@ -101,7 +101,7 @@ export function Stage({
|
|
|
101
101
|
const ro = new ResizeObserver(measure);
|
|
102
102
|
ro.observe(el);
|
|
103
103
|
return () => ro.disconnect();
|
|
104
|
-
}, []);
|
|
104
|
+
}, [onResizeRef]);
|
|
105
105
|
|
|
106
106
|
// A transform moves every tile inside the content without resizing anything,
|
|
107
107
|
// and a ResizeObserver reports only resizes.
|