@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.
Files changed (120) hide show
  1. package/dist/_dts/{CanvasStackContext-LnCfqNBA.d.ts → CanvasStackContext-BIRUVVGH.d.ts} +1 -1
  2. package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
  3. package/dist/_dts/{frac-C-2c72Ij.d.ts → frac-X7mWgd7y.d.ts} +10 -4
  4. package/dist/_dts/{index-BDVzvRzQ.d.ts → index-FEa1MudK.d.ts} +8 -2
  5. package/dist/_dts/{types-DYMaEvM5.d.ts → types-ChVJJHvk.d.ts} +3 -0
  6. package/dist/_dts/{types-D6s4b7if.d.ts → types-lg4TSCb2.d.ts} +3 -1
  7. package/dist/_dts/{useTrialState-gmMvZqPc.d.ts → useTrialState-Cdm13fvl.d.ts} +2 -1
  8. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
  9. package/dist/canvas/index.d.ts +16 -16
  10. package/dist/canvas/index.js +2 -2
  11. package/dist/chrome/index.d.ts +6 -6
  12. package/dist/chrome/index.js +5 -5
  13. package/dist/chunk-2YILQ7Y5.js +33 -0
  14. package/dist/chunk-2YILQ7Y5.js.map +1 -0
  15. package/dist/{chunk-TO2FUOKF.js → chunk-53AZ2O2N.js} +16 -13
  16. package/dist/chunk-53AZ2O2N.js.map +1 -0
  17. package/dist/{chunk-QB5SM4BM.js → chunk-7TRFMIWX.js} +55 -41
  18. package/dist/chunk-7TRFMIWX.js.map +1 -0
  19. package/dist/{chunk-6OEYTYML.js → chunk-BMQWLKOL.js} +15 -15
  20. package/dist/chunk-BMQWLKOL.js.map +1 -0
  21. package/dist/{chunk-M75ZU6ZZ.js → chunk-D7QIPGEB.js} +3 -3
  22. package/dist/{chunk-M75ZU6ZZ.js.map → chunk-D7QIPGEB.js.map} +1 -1
  23. package/dist/{chunk-L5QJOOLV.js → chunk-DG2IFMLA.js} +1309 -273
  24. package/dist/chunk-DG2IFMLA.js.map +1 -0
  25. package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
  26. package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
  27. package/dist/{chunk-RBGKL7NF.js → chunk-J6SBFZYT.js} +1807 -1814
  28. package/dist/chunk-J6SBFZYT.js.map +1 -0
  29. package/dist/{chunk-XJ6N32QP.js → chunk-LJSIUFYD.js} +14 -30
  30. package/dist/chunk-LJSIUFYD.js.map +1 -0
  31. package/dist/{chunk-AE5CNVRU.js → chunk-MIT3ISW4.js} +37 -31
  32. package/dist/chunk-MIT3ISW4.js.map +1 -0
  33. package/dist/{chunk-HHGVISVZ.js → chunk-MSR7EVS6.js} +7 -5
  34. package/dist/chunk-MSR7EVS6.js.map +1 -0
  35. package/dist/{chunk-ESIQNQ6K.js → chunk-OFCBEZMU.js} +12 -6
  36. package/dist/chunk-OFCBEZMU.js.map +1 -0
  37. package/dist/{chunk-HMFODCOX.js → chunk-PP7ZWAMV.js} +83 -75
  38. package/dist/chunk-PP7ZWAMV.js.map +1 -0
  39. package/dist/{chunk-E2UQVZ44.js → chunk-RLOXQZW3.js} +3 -3
  40. package/dist/{chunk-E2UQVZ44.js.map → chunk-RLOXQZW3.js.map} +1 -1
  41. package/dist/controls/index.d.ts +4 -3
  42. package/dist/controls/index.js +3 -3
  43. package/dist/dragdrop/index.d.ts +5 -5
  44. package/dist/dragdrop/index.js +2 -2
  45. package/dist/index.d.ts +57 -77
  46. package/dist/index.js +308 -161
  47. package/dist/index.js.map +1 -1
  48. package/dist/job/index.js +1 -1
  49. package/dist/layers/index.d.ts +6 -6
  50. package/dist/layers/index.js +3 -3
  51. package/dist/loupe/index.d.ts +7 -13
  52. package/dist/loupe/index.js +2 -2
  53. package/dist/passthrough/weasel-canvas.d.ts +3 -343
  54. package/dist/passthrough/weasel-canvas.js +1 -1
  55. package/dist/passthrough/weasel-ui.d.ts +141 -2866
  56. package/dist/passthrough/weasel-ui.js +2 -2
  57. package/dist/primitives/index.js +4 -4
  58. package/dist/state/index.d.ts +3 -3
  59. package/dist/state/index.js +4 -2
  60. package/dist/state/index.js.map +1 -1
  61. package/dist/styles.css +74 -5
  62. package/dist/surface/index.d.ts +17 -2
  63. package/dist/surface/index.js +3 -2
  64. package/dist/ui/layers/index.js +2 -2
  65. package/dist/undo/index.d.ts +6 -5
  66. package/package.json +8 -8
  67. package/src/annotations/AnnotationOverlay.tsx +10 -6
  68. package/src/annotations/Annotations.less +6 -0
  69. package/src/annotations/Annotations.overlay.test.tsx +113 -0
  70. package/src/annotations/MarkList.tsx +3 -2
  71. package/src/canvas/CanvasStack.tsx +8 -13
  72. package/src/canvas/useOrbit.test.ts +93 -1
  73. package/src/canvas/useOrbit.ts +41 -34
  74. package/src/canvas/usePanZoom.test.ts +102 -1
  75. package/src/canvas/usePanZoom.ts +54 -36
  76. package/src/config/builder.test.ts +5 -0
  77. package/src/config/builder.ts +6 -0
  78. package/src/config/types.ts +1 -0
  79. package/src/controls/ControlPanel.test.tsx +23 -0
  80. package/src/controls/ControlPanel.tsx +3 -0
  81. package/src/job/useJob.ts +1 -1
  82. package/src/lab/Lab.surface.test.tsx +8 -3
  83. package/src/lab/LabHeader.test.tsx +12 -0
  84. package/src/lab/LabHeader.tsx +16 -6
  85. package/src/lab/Workspace.tsx +1 -1
  86. package/src/layers/LayerList.test.tsx +72 -1
  87. package/src/layers/LayerList.tsx +40 -32
  88. package/src/passthrough/weasel-ui.test.ts +9 -0
  89. package/src/passthrough/weasel-ui.ts +4 -0
  90. package/src/primitives/FloatingPanel.test.tsx +82 -0
  91. package/src/primitives/FloatingPanel.tsx +59 -41
  92. package/src/primitives/ZoomControl.tsx +2 -0
  93. package/src/state/store.ts +10 -0
  94. package/src/state/types.ts +3 -0
  95. package/src/surface/AGENTS.md +11 -1
  96. package/src/surface/index.ts +7 -1
  97. package/src/surface/useSurfaceTile.ts +20 -2
  98. package/src/surface/useTiledSurface.ts +4 -1
  99. package/src/theme/Interstellar.stories.tsx +4 -3
  100. package/src/theme/base.less +39 -1
  101. package/src/trial/Trial.less +27 -5
  102. package/src/trial/TrialBody.test.tsx +67 -0
  103. package/src/trial/TrialBody.tsx +155 -0
  104. package/src/trial/TrialChrome.tsx +18 -10
  105. package/src/trial/index.ts +2 -0
  106. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
  107. package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
  108. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
  109. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
  110. package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
  111. package/dist/chunk-6OEYTYML.js.map +0 -1
  112. package/dist/chunk-AE5CNVRU.js.map +0 -1
  113. package/dist/chunk-ESIQNQ6K.js.map +0 -1
  114. package/dist/chunk-HHGVISVZ.js.map +0 -1
  115. package/dist/chunk-HMFODCOX.js.map +0 -1
  116. package/dist/chunk-L5QJOOLV.js.map +0 -1
  117. package/dist/chunk-QB5SM4BM.js.map +0 -1
  118. package/dist/chunk-RBGKL7NF.js.map +0 -1
  119. package/dist/chunk-TO2FUOKF.js.map +0 -1
  120. package/dist/chunk-XJ6N32QP.js.map +0 -1
@@ -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 };
@@ -1,4 +1,5 @@
1
- export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTiledSurface } from '../chunk-ESIQNQ6K.js';
2
- import '../chunk-L5QJOOLV.js';
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
@@ -1,4 +1,4 @@
1
- export { c3 as LayerStack } from '../../chunk-RBGKL7NF.js';
2
- import '../../chunk-L5QJOOLV.js';
1
+ export { c3 as LayerStack } from '../../chunk-J6SBFZYT.js';
2
+ import '../../chunk-DG2IFMLA.js';
3
3
  //# sourceMappingURL=index.js.map
4
4
  //# sourceMappingURL=index.js.map
@@ -1,10 +1,11 @@
1
- export { a0 as UndoCapability } from '../_dts/frac-C-2c72Ij.js';
1
+ export { a0 as UndoCapability } from '../_dts/frac-X7mWgd7y.js';
2
2
  import 'react';
3
- import '../_dts/types-D6s4b7if.js';
4
- import '../_dts/PrefsForm-BkUJZx0A.js';
5
- import '../_dts/types-DYMaEvM5.js';
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.0-pre.1",
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.0-pre.1",
118
- "@weasel-js/loupe": "1.4.0-pre.1",
119
- "@weasel-js/svg": "1.4.0-pre.1",
120
- "@weasel-js/theme": "1.4.0-pre.1",
121
- "@weasel-js/ui": "1.4.0-pre.1",
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.0-pre.1",
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(id, el);
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(id, null);
121
+ surface?.registerTile(tileId, null);
118
122
  },
119
- [surface, id],
123
+ [surface, tileId],
120
124
  );
121
125
 
122
126
  useEffect(
123
127
  () =>
124
- surface?.registerPainter(id, (next) => {
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, id],
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
- <input
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={(e) => marks.update(a.id, { title: e.target.value })}
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 handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame });
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 { clampPitch, orbitAfterDrag, orbitAfterWheel, PITCH_LIMIT, wrapYaw } from './useOrbit';
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
+ });
@@ -1,4 +1,11 @@
1
- import { type PointerEvent, useCallback, useRef, type WheelEvent } from 'react';
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: PointerEvent<HTMLElement>) => void;
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 onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
112
- if (e.button !== 0) return;
113
- e.currentTarget.setPointerCapture(e.pointerId);
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 onPointerMove = useCallback(
124
- (e: PointerEvent<HTMLElement>) => {
125
- const drag = dragRef.current;
126
- if (!drag || drag.pointerId !== e.pointerId) return;
127
- const dx = e.clientX - drag.startX;
128
- const dy = e.clientY - drag.startY;
129
- if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
130
- drag.moved = true;
131
- onViewChange(orbitAfterDrag(drag.startView, dx, dy));
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
- [onViewChange],
147
+ [clearDrag],
134
148
  );
135
149
 
136
- const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {
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, onPointerMove, onPointerUp, onDoubleClick, isDragging };
155
+ return { onWheel, onPointerDown, onDoubleClick, isDragging };
149
156
  }