@weasel-js/routing 1.6.1 → 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/dist/react.js CHANGED
@@ -1,37 +1,81 @@
1
- import { createDispatcher, buildDepsFromRequires, validateActionId, validateActionDefaultBinding, scopeBindings, liveScope, offhandKeyFor, makeToolOffhandAction, buildToolOffhandBindings, isDev, reportRouteConflicts, dlog, isEditableTarget, clientToCanvasRect, openPointerSession, pointerGestureId, itemsFromDataTransfer, itemsFromClipboardData } from './chunk-VEC3ACV5.js';
2
- import { createContext, useState, useCallback, useMemo, useContext, useRef, useEffect } from 'react';
1
+ import { createDepRegistry, createDispatcher, isTool, scopeBindings, liveScope, isDev, reportRouteConflicts, offhandKeyFor, makeToolOffhandAction, buildToolOffhandBindings, dlog, isEditableTarget, clientToCanvasRect, openPointerSession, LONG_PRESS_MS, publishLiveDispatch, pointerGestureId, itemsFromDataTransfer, itemsFromClipboardData, ScopeNode, pushOwner, validateActionId, validateActionDefaultBinding, depNodeOf, DepNode, buildDepsFromRequires } from './chunk-ZGKJWZ4S.js';
2
+ import { createContext, useState, useContext, useMemo, useEffect, useRef, useCallback, useSyncExternalStore } from 'react';
3
+ import { useLatest, useStableByContent, sameList } from '@weasel-js/react';
3
4
  import { jsx, Fragment } from 'react/jsx-runtime';
5
+ import { pastDragThreshold } from '@weasel-js/gestures';
4
6
  import { resolveCursorTier } from '@weasel-js/cursor';
7
+ import { activeModeOf } from '@weasel-js/modes';
5
8
 
9
+ var ToolNode = class extends ScopeNode {
10
+ state;
11
+ value = null;
12
+ constructor(parent, descends, owns, initialActive) {
13
+ super(parent, descends);
14
+ this.state = owns ? { active: initialActive ?? parent?.owner().state?.active ?? null, hotkeyStack: [] } : null;
15
+ }
16
+ /** The node whose tool a read here resolves to. */
17
+ owner() {
18
+ for (const n of this.start().chain()) if (n.state) return n;
19
+ return this;
20
+ }
21
+ update(next) {
22
+ const owner = this.owner();
23
+ if (!owner.state) return;
24
+ const updated = next(owner.state);
25
+ if (updated === owner.state) return;
26
+ owner.state = updated;
27
+ owner.changed();
28
+ }
29
+ /** A stable value per owner and state, for `useSyncExternalStore`. */
30
+ snapshot() {
31
+ const from = this.owner();
32
+ const state = from.state ?? { active: null, hotkeyStack: [] };
33
+ const cached = this.value;
34
+ if (cached && cached.from === from && cached.state === state) return cached.out;
35
+ const out = {
36
+ active: state.active,
37
+ hotkeyStack: state.hotkeyStack,
38
+ setActive: (id) => this.update((s) => s.active === id ? s : { ...s, active: id }),
39
+ pushHotkey: (id) => this.update((s) => ({ ...s, hotkeyStack: [...s.hotkeyStack, id] })),
40
+ popHotkey: (id) => this.update((s) => {
41
+ const stack = s.hotkeyStack;
42
+ if (stack.length === 0) return s;
43
+ if (id === void 0) return { ...s, hotkeyStack: stack.slice(0, -1) };
44
+ const i = stack.lastIndexOf(id);
45
+ return i === -1 ? s : { ...s, hotkeyStack: [...stack.slice(0, i), ...stack.slice(i + 1)] };
46
+ })
47
+ };
48
+ this.value = { from, state, out };
49
+ return out;
50
+ }
51
+ subscribe = (listener) => {
52
+ this.tree.listeners.add(listener);
53
+ return () => {
54
+ this.tree.listeners.delete(listener);
55
+ };
56
+ };
57
+ };
6
58
  var ActiveToolContext = createContext(null);
59
+ var ActiveToolNodeContext = ActiveToolContext;
7
60
  function ActiveToolContextProvider({
8
61
  children,
9
- initialActive = "select"
62
+ initialActive = null
10
63
  }) {
11
- const [active, setActiveState] = useState(initialActive);
12
- const [hotkeyStack, setHotkeyStack] = useState([]);
13
- const setActive = useCallback((id) => {
14
- setActiveState(id);
15
- }, []);
16
- const pushHotkey = useCallback((id) => {
17
- setHotkeyStack((s) => [...s, id]);
18
- }, []);
19
- const popHotkey = useCallback((id) => {
20
- setHotkeyStack((s) => {
21
- if (s.length === 0) return s;
22
- if (id === void 0) return s.slice(0, -1);
23
- const i = s.lastIndexOf(id);
24
- return i === -1 ? s : [...s.slice(0, i), ...s.slice(i + 1)];
25
- });
26
- }, []);
27
- const value = useMemo(
28
- () => ({ active, hotkeyStack, setActive, pushHotkey, popHotkey }),
29
- [active, hotkeyStack, setActive, pushHotkey, popHotkey]
64
+ const [node] = useState(() => new ToolNode(null, true, true, initialActive));
65
+ return /* @__PURE__ */ jsx(ActiveToolContext.Provider, { value: node, children });
66
+ }
67
+ function useNodeValue(node) {
68
+ const subscribe = useMemo(() => node?.subscribe ?? NO_SUBSCRIBE, [node]);
69
+ return useSyncExternalStore(
70
+ subscribe,
71
+ () => node?.snapshot() ?? null,
72
+ () => node?.snapshot() ?? null
30
73
  );
31
- return /* @__PURE__ */ jsx(ActiveToolContext.Provider, { value, children });
32
74
  }
75
+ var NO_SUBSCRIBE = () => () => {
76
+ };
33
77
  function useActiveToolContext() {
34
- const value = useContext(ActiveToolContext);
78
+ const value = useNodeValue(useContext(ActiveToolContext));
35
79
  if (value === null) {
36
80
  throw new Error(
37
81
  "useActiveToolContext: no ActiveToolContextProvider in scope. Wrap your tree with <ActiveToolContextProvider> (typically inside <SceneCanvas>)."
@@ -40,7 +84,7 @@ function useActiveToolContext() {
40
84
  return value;
41
85
  }
42
86
  function useOptionalActiveToolContext() {
43
- return useContext(ActiveToolContext);
87
+ return useNodeValue(useContext(ActiveToolContext));
44
88
  }
45
89
  function ActiveToolContextProviderIfRoot({
46
90
  children
@@ -51,28 +95,7 @@ function ActiveToolContextProviderIfRoot({
51
95
  }
52
96
  var DepRegistryContext = createContext(null);
53
97
  function DepRegistryProvider({ children }) {
54
- const sourcesRef = useRef(/* @__PURE__ */ new Map());
55
- const registry = useMemo(() => ({
56
- register: (name, source) => {
57
- const key = name;
58
- const entry = source;
59
- const stack = sourcesRef.current.get(key);
60
- if (stack) stack.push(entry);
61
- else sourcesRef.current.set(key, [entry]);
62
- let released = false;
63
- return () => {
64
- if (released) return;
65
- released = true;
66
- const cur = sourcesRef.current.get(key);
67
- if (!cur) return;
68
- const i = cur.lastIndexOf(entry);
69
- if (i === -1) return;
70
- cur.splice(i, 1);
71
- if (cur.length === 0) sourcesRef.current.delete(key);
72
- };
73
- },
74
- get: (name) => sourcesRef.current.get(name)?.at(-1)?.()
75
- }), []);
98
+ const registry = useMemo(createDepRegistry, []);
76
99
  return /* @__PURE__ */ jsx(DepRegistryContext.Provider, { value: registry, children });
77
100
  }
78
101
  function useDepRegistry() {
@@ -87,11 +110,10 @@ function useOptionalDepRegistry() {
87
110
  }
88
111
  function useDepSource(name, source) {
89
112
  const registry = useDepRegistry();
90
- const sourceRef = useRef(source);
91
- sourceRef.current = source;
113
+ const sourceRef = useLatest(source);
92
114
  useEffect(() => {
93
115
  return registry.register(name, () => sourceRef.current());
94
- }, [name, registry]);
116
+ }, [name, registry, sourceRef]);
95
117
  }
96
118
  var DROPOVER_CLASS = "weasel-dropover";
97
119
  function stylusOf(e) {
@@ -134,37 +156,29 @@ function computeMultiTouchGeometry(positions) {
134
156
  return { centroid, spread };
135
157
  }
136
158
  function useGestureDispatcher(opts) {
137
- const { canvasRef, actions, toolsById, enabled = true, keyboard = true, affordanceAt, classifyTarget, dispatcher: dispatcherOpt, clientToWorld, requestRedraw, paintedCursor, getRuleCtx, onDoubleClick, views, channels } = opts;
159
+ const { canvasRef, actions, entriesById, enabled = true, keyboard = true, affordanceAt, classifyTarget, dispatcher: dispatcherOpt, clientToWorld, requestRedraw, paintedCursor, getRuleCtx, onDoubleClick, views, channels, longPress } = opts;
138
160
  const pointerChannel = channels?.pointer !== false;
139
161
  const wheelChannel = channels?.wheel !== false;
140
162
  const pinchChannel = channels?.pinch !== false;
141
163
  const contextMenuChannel = channels?.contextMenu !== false;
142
164
  const ingestChannel = channels?.ingest !== false;
143
- const onDoubleClickRef = useRef(onDoubleClick);
144
- onDoubleClickRef.current = onDoubleClick;
165
+ const onDoubleClickRef = useLatest(onDoubleClick);
145
166
  const activeTool = useActiveToolContext();
146
167
  const depRegistry = useDepRegistry();
147
168
  const dispatcherRef = useRef(null);
148
169
  if (!dispatcherRef.current) {
149
170
  dispatcherRef.current = dispatcherOpt ?? createDispatcher();
150
171
  }
151
- const ctxRef = useRef({
172
+ const registryRef = useLatest(actions);
173
+ const ctxRef = useLatest({
152
174
  actions,
153
175
  depRegistry,
154
176
  activeToolId: activeTool.active,
155
177
  hotkeyStack: activeTool.hotkeyStack,
156
- toolsById,
178
+ entriesById,
157
179
  isMac: IS_MAC
158
180
  });
159
- ctxRef.current = {
160
- actions,
161
- depRegistry,
162
- activeToolId: activeTool.active,
163
- hotkeyStack: activeTool.hotkeyStack,
164
- toolsById,
165
- isMac: IS_MAC
166
- };
167
- const rootTargetRef = useRef({
181
+ const rootTargetRef = useLatest({
168
182
  id: null,
169
183
  dispatcher: dispatcherRef.current,
170
184
  affordanceAt,
@@ -172,44 +186,47 @@ function useGestureDispatcher(opts) {
172
186
  clientToWorld,
173
187
  getRuleCtx
174
188
  });
175
- rootTargetRef.current = {
176
- id: null,
177
- dispatcher: dispatcherRef.current,
178
- affordanceAt,
179
- classifyTarget,
180
- clientToWorld,
181
- getRuleCtx
182
- };
183
- const viewsRef = useRef(views);
184
- viewsRef.current = views;
185
- const allDispatchers = () => [
189
+ const viewsRef = useLatest(views);
190
+ const allDispatchers = useCallback(() => [
186
191
  rootTargetRef.current.dispatcher,
187
192
  ...(viewsRef.current?.targets() ?? []).map((t) => t.dispatcher)
188
- ];
189
- const requestRedrawRef = useRef(requestRedraw);
190
- requestRedrawRef.current = requestRedraw;
191
- const paintedCursorRef = useRef(paintedCursor);
192
- paintedCursorRef.current = paintedCursor;
193
+ ], [rootTargetRef, viewsRef]);
194
+ const requestRedrawRef = useLatest(requestRedraw);
195
+ const paintedCursorRef = useLatest(paintedCursor);
196
+ const longPressRef = useLatest(longPress);
193
197
  const lastClickRef = useRef(null);
198
+ const liveToolsRef = useRef(/* @__PURE__ */ new Set());
194
199
  const prevActiveRef = useRef(activeTool.active);
200
+ const entriesByIdRef = useLatest(entriesById);
201
+ const fireLifecycle = useCallback((id, hook) => {
202
+ const tool = entriesByIdRef.current.get(id);
203
+ if (!tool || !isTool(tool)) return;
204
+ const fn = tool[hook];
205
+ if (!fn) return;
206
+ try {
207
+ fn({ scratch: tool.initScratch?.() });
208
+ } catch (err) {
209
+ console.error(`weasel: tool "${id}".${hook} threw`, err);
210
+ }
211
+ }, [entriesByIdRef]);
195
212
  useEffect(() => {
213
+ const prev = liveToolsRef.current;
214
+ const next = new Set(
215
+ activeTool.active === null ? activeTool.hotkeyStack : [activeTool.active, ...activeTool.hotkeyStack]
216
+ );
217
+ liveToolsRef.current = next;
218
+ for (const id of prev) if (!next.has(id)) fireLifecycle(id, "onDeactivate");
196
219
  if (prevActiveRef.current !== activeTool.active) {
197
- const prevTool = toolsById.get(prevActiveRef.current);
198
- if (prevTool?.onDeactivate) {
199
- const scratch = prevTool.initScratch?.();
200
- try {
201
- prevTool.onDeactivate({ scratch });
202
- } catch (err) {
203
- console.error(
204
- `weasel: tool "${prevActiveRef.current}".onDeactivate threw`,
205
- err
206
- );
207
- }
208
- }
209
220
  for (const d of allDispatchers()) d.cancelAll("cancel");
210
221
  }
211
222
  prevActiveRef.current = activeTool.active;
223
+ for (const id of next) if (!prev.has(id)) fireLifecycle(id, "onActivate");
212
224
  });
225
+ useEffect(() => () => {
226
+ const live = liveToolsRef.current;
227
+ liveToolsRef.current = /* @__PURE__ */ new Set();
228
+ for (const id of live) fireLifecycle(id, "onDeactivate");
229
+ }, [fireLifecycle]);
213
230
  useEffect(() => {
214
231
  if (!enabled) return;
215
232
  let disposed = false;
@@ -249,7 +266,8 @@ function useGestureDispatcher(opts) {
249
266
  if (before > 0 || after > 0) requestRedrawRef.current?.();
250
267
  return result;
251
268
  };
252
- const heldKeys = /* @__PURE__ */ new Set();
269
+ const heldKeys = /* @__PURE__ */ new Map();
270
+ const physicalKey = (e) => e.code || e.key.toLowerCase();
253
271
  const held = /* @__PURE__ */ new Map();
254
272
  const toCanvasLocal = (p) => {
255
273
  const rect = canvas?.getBoundingClientRect();
@@ -273,23 +291,27 @@ function useGestureDispatcher(opts) {
273
291
  };
274
292
  let multiTouchStart = null;
275
293
  const TAP_THRESHOLD_PX = 8;
276
- const DRAG_THRESHOLD_PX = 4;
277
- const LONG_PRESS_MS = 500;
278
294
  const longPressTimers = /* @__PURE__ */ new Map();
295
+ const clearPending = (pointerId) => {
296
+ const store = longPressRef.current?.state;
297
+ const p = store?.get();
298
+ if (p && (pointerId === void 0 || p.pointerId === pointerId)) store.set(null);
299
+ };
279
300
  const cancelLongPress = (pointerId) => {
280
301
  const t = longPressTimers.get(pointerId);
281
302
  if (t !== void 0) {
282
303
  clearTimeout(t);
283
304
  longPressTimers.delete(pointerId);
284
305
  }
306
+ clearPending(pointerId);
285
307
  };
286
308
  const cancelAllLongPress = () => {
287
309
  for (const t of longPressTimers.values()) clearTimeout(t);
288
310
  longPressTimers.clear();
311
+ clearPending();
289
312
  };
290
- const fireLongPress = (pointerId) => {
291
- const down = held.get(pointerId)?.down;
292
- if (!down) return;
313
+ const HAPTIC_MS = 15;
314
+ const longPressEvents = (down) => {
293
315
  const shared = {
294
316
  target: down.target,
295
317
  altKey: down.altKey,
@@ -300,30 +322,34 @@ function useGestureDispatcher(opts) {
300
322
  ...down.bodyTarget !== void 0 ? { bodyTarget: down.bodyTarget } : {},
301
323
  ...down.bodyKind !== void 0 ? { bodyKind: down.bodyKind } : {}
302
324
  };
303
- const result = dispatch({
304
- kind: "longpress",
305
- x: down.worldX,
306
- y: down.worldY,
307
- clientX: down.clientX,
308
- clientY: down.clientY,
309
- ...shared
310
- });
311
- if (result === "unhandled") {
312
- dispatch({
313
- kind: "contextmenu",
314
- x: down.worldX,
315
- y: down.worldY,
316
- clientX: down.clientX,
317
- clientY: down.clientY,
318
- ...shared
319
- });
325
+ const at = { x: down.worldX, y: down.worldY, clientX: down.clientX, clientY: down.clientY };
326
+ return {
327
+ longpress: { kind: "longpress", ...at, ...shared },
328
+ contextmenu: { kind: "contextmenu", ...at, ...shared }
329
+ };
330
+ };
331
+ const longPressWouldFire = (down) => {
332
+ const evs = longPressEvents(down);
333
+ const d = dispatcherNow();
334
+ return d.resolveOnly(evs.longpress, ctxNow()) !== null || d.resolveOnly(evs.contextmenu, ctxNow()) !== null;
335
+ };
336
+ const fireLongPress = (pointerId, pointerType) => {
337
+ const down = held.get(pointerId)?.down;
338
+ clearPending(pointerId);
339
+ if (!down) return;
340
+ const evs = longPressEvents(down);
341
+ let result = dispatch(evs.longpress);
342
+ if (result === "unhandled") result = dispatch(evs.contextmenu);
343
+ if (result === "handled" && longPressRef.current?.haptics !== false && (pointerType === "touch" || pointerType === "pen") && typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
344
+ navigator.vibrate(HAPTIC_MS);
320
345
  }
321
346
  };
322
347
  const DOUBLE_CLICK_MAX_MS = 600;
323
348
  const DOUBLE_CLICK_MAX_PX = 8;
324
349
  const onKeyDown = (e) => {
325
- if (isEditableTarget(e.target)) return;
326
- if (e.repeat && heldKeys.has(e.key)) {
350
+ if (e.defaultPrevented || isEditableTarget(e.target)) return;
351
+ if (!registryRef.current.isActive()) return;
352
+ if (e.repeat && heldKeys.has(physicalKey(e))) {
327
353
  e.preventDefault();
328
354
  return;
329
355
  }
@@ -348,17 +374,19 @@ function useGestureDispatcher(opts) {
348
374
  };
349
375
  const heldResult = dispatch(heldEv);
350
376
  if (heldResult === "handled") {
351
- heldKeys.add(e.key);
377
+ heldKeys.set(physicalKey(e), e.key);
352
378
  }
353
379
  if (keyResult === "handled" || heldResult === "handled") {
354
380
  e.preventDefault();
355
381
  }
356
382
  };
357
383
  const onKeyUp = (e) => {
358
- if (!heldKeys.has(e.key)) return;
384
+ const physical = physicalKey(e);
385
+ const downKey = heldKeys.get(physical);
386
+ if (downKey === void 0) return;
359
387
  const ev = {
360
388
  kind: "key-held",
361
- key: e.key,
389
+ key: downKey,
362
390
  phase: "up",
363
391
  altKey: e.altKey,
364
392
  ctrlKey: e.ctrlKey,
@@ -366,12 +394,12 @@ function useGestureDispatcher(opts) {
366
394
  shiftKey: e.shiftKey
367
395
  };
368
396
  dispatch(ev);
369
- heldKeys.delete(e.key);
397
+ heldKeys.delete(physical);
370
398
  };
371
399
  const onWindowBlur = () => {
372
400
  endPinch();
373
401
  for (const p of [...held.values()]) p.session.cancel();
374
- for (const key of heldKeys) {
402
+ for (const key of heldKeys.values()) {
375
403
  const ev = {
376
404
  kind: "key-held",
377
405
  key,
@@ -386,6 +414,7 @@ function useGestureDispatcher(opts) {
386
414
  heldKeys.clear();
387
415
  };
388
416
  const onWheel = (e) => {
417
+ registryRef.current.activate();
389
418
  if (pinchLive && e.ctrlKey) {
390
419
  e.preventDefault();
391
420
  e.stopPropagation();
@@ -469,6 +498,7 @@ function useGestureDispatcher(opts) {
469
498
  endPinch();
470
499
  };
471
500
  const onPointerDown = (e) => {
501
+ registryRef.current.activate();
472
502
  if ((e.button ?? 0) !== 0) return;
473
503
  if (!canvas) return;
474
504
  routeDown(e.pointerId, e.clientX, e.clientY);
@@ -524,14 +554,30 @@ function useGestureDispatcher(opts) {
524
554
  if (held.size < 2) dispatch({ ...ev, stage: "press" });
525
555
  if ((e.pointerType === "touch" || e.pointerType === "pen") && held.size === 1) {
526
556
  cancelLongPress(e.pointerId);
557
+ const duration = longPressRef.current?.duration ?? LONG_PRESS_MS;
527
558
  longPressTimers.set(
528
559
  e.pointerId,
529
560
  setTimeout(() => {
530
561
  longPressTimers.delete(e.pointerId);
531
562
  if (disposed) return;
532
- fireLongPress(e.pointerId);
533
- }, LONG_PRESS_MS)
563
+ fireLongPress(e.pointerId, e.pointerType);
564
+ }, duration)
534
565
  );
566
+ const store = longPressRef.current?.state;
567
+ const rec = held.get(e.pointerId);
568
+ if (store && rec) {
569
+ store.set({
570
+ pointerId: e.pointerId,
571
+ pointerType: e.pointerType,
572
+ client: { x: e.clientX, y: e.clientY },
573
+ local: toCanvasLocal({ x: e.clientX, y: e.clientY }),
574
+ world: { x: w.x, y: w.y },
575
+ viewId: target().id,
576
+ startedAt: performance.now(),
577
+ duration,
578
+ armed: longPressWouldFire(rec.down)
579
+ });
580
+ }
535
581
  }
536
582
  if (held.size >= 2) {
537
583
  cancelAllLongPress();
@@ -561,9 +607,7 @@ function useGestureDispatcher(opts) {
561
607
  if (rec) rec.pos = { x: e.clientX, y: e.clientY };
562
608
  const buffered = rec?.buffered;
563
609
  if (rec && buffered) {
564
- const dx = e.clientX - buffered.clientX;
565
- const dy = e.clientY - buffered.clientY;
566
- if (Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) {
610
+ if (pastDragThreshold(buffered, e)) {
567
611
  rec.buffered = null;
568
612
  cancelLongPress(e.pointerId);
569
613
  dispatch(buffered.ev);
@@ -671,10 +715,17 @@ function useGestureDispatcher(opts) {
671
715
  ...bodyKind !== void 0 ? { bodyKind } : {}
672
716
  };
673
717
  const ctx = ctxNow();
674
- const dragCursor = dispatcherNow().resolveOnly(
675
- { kind: "pointerdown", x: w.x, y: w.y, clientX: h.clientX, clientY: h.clientY, ...where },
676
- ctx
677
- )?.action.cursor;
718
+ const press = {
719
+ kind: "pointerdown",
720
+ x: w.x,
721
+ y: w.y,
722
+ clientX: h.clientX,
723
+ clientY: h.clientY,
724
+ ...where
725
+ };
726
+ const d = dispatcherNow();
727
+ publishLiveDispatch(() => d.explain(press, ctx));
728
+ const dragCursor = d.resolveOnly(press, ctx)?.action.cursor;
678
729
  const cursor = dragCursor ?? dispatcherNow().resolveOnly(
679
730
  { kind: "click", x: w.x, y: w.y, pressX: w.x, pressY: w.y, ...where },
680
731
  ctx
@@ -700,6 +751,7 @@ function useGestureDispatcher(opts) {
700
751
  };
701
752
  const onHoverPointerLeave = () => {
702
753
  lastHover = null;
754
+ publishLiveDispatch(null);
703
755
  paintedCursorRef.current?.()?.clearPointer();
704
756
  clearHoverCursor();
705
757
  };
@@ -912,9 +964,10 @@ function useGestureDispatcher(opts) {
912
964
  });
913
965
  if (result === "handled") e.preventDefault();
914
966
  };
967
+ const keyCapture = keyboard === "first";
915
968
  if (keyboard) {
916
- window.addEventListener("keydown", onKeyDown);
917
- window.addEventListener("keyup", onKeyUp);
969
+ window.addEventListener("keydown", onKeyDown, keyCapture);
970
+ window.addEventListener("keyup", onKeyUp, keyCapture);
918
971
  }
919
972
  window.addEventListener("blur", onWindowBlur);
920
973
  window.addEventListener("keydown", scheduleHoverCursorRefresh);
@@ -940,8 +993,8 @@ function useGestureDispatcher(opts) {
940
993
  }
941
994
  return () => {
942
995
  if (keyboard) {
943
- window.removeEventListener("keydown", onKeyDown);
944
- window.removeEventListener("keyup", onKeyUp);
996
+ window.removeEventListener("keydown", onKeyDown, keyCapture);
997
+ window.removeEventListener("keyup", onKeyUp, keyCapture);
945
998
  }
946
999
  window.removeEventListener("blur", onWindowBlur);
947
1000
  window.removeEventListener("keydown", scheduleHoverCursorRefresh);
@@ -967,206 +1020,172 @@ function useGestureDispatcher(opts) {
967
1020
  held.clear();
968
1021
  for (const d of allDispatchers()) d.cancelAll("cancel");
969
1022
  };
970
- }, [enabled, keyboard, canvasRef, pointerChannel, wheelChannel, pinchChannel, contextMenuChannel, ingestChannel]);
1023
+ }, [
1024
+ enabled,
1025
+ keyboard,
1026
+ canvasRef,
1027
+ pointerChannel,
1028
+ wheelChannel,
1029
+ pinchChannel,
1030
+ contextMenuChannel,
1031
+ ingestChannel,
1032
+ allDispatchers,
1033
+ ctxRef,
1034
+ longPressRef,
1035
+ onDoubleClickRef,
1036
+ paintedCursorRef,
1037
+ registryRef,
1038
+ requestRedrawRef,
1039
+ rootTargetRef,
1040
+ viewsRef
1041
+ ]);
971
1042
  }
972
1043
  var ActionsContext = createContext(null);
973
- function useMuted(base) {
974
- const mutedRef = useRef(/* @__PURE__ */ new Map());
975
- const listenersRef = useRef(/* @__PURE__ */ new Set());
976
- const cacheRef = useRef(null);
977
- return useMemo(() => {
978
- if (!base) return null;
979
- const isMuted = (id) => mutedRef.current.has(id);
980
- const notify = () => {
981
- for (const l of listenersRef.current) {
982
- try {
983
- l();
984
- } catch (err) {
985
- console.error("weasel ActionsRegistry: subscriber threw", err);
1044
+ function depsFor(a, r) {
1045
+ if (!r) return {};
1046
+ if (a.requires) return buildDepsFromRequires(a, r);
1047
+ return {
1048
+ selection: r.get("selection"),
1049
+ scene: r.get("scene"),
1050
+ history: r.get("history"),
1051
+ view: r.get("view"),
1052
+ activeTool: r.get("activeTool"),
1053
+ booleansAdapter: r.get("booleansAdapter")
1054
+ };
1055
+ }
1056
+ var ActionsNode = class extends ScopeNode {
1057
+ constructor(parent, descends, contextDeps) {
1058
+ super(parent, descends);
1059
+ this.contextDeps = contextDeps;
1060
+ NODES.set(this.registry, this);
1061
+ }
1062
+ contextDeps;
1063
+ actions = /* @__PURE__ */ new Map();
1064
+ muted = /* @__PURE__ */ new Map();
1065
+ dispatchers = [];
1066
+ cache = null;
1067
+ /** Called on `activate`, so a scope's dep and tool nodes move with it. */
1068
+ onActivate = [];
1069
+ resolve(id) {
1070
+ for (const n of this.chain()) {
1071
+ if (n.muted.has(id)) return void 0;
1072
+ const a = n.actions.get(id)?.at(-1);
1073
+ if (a) return a;
1074
+ }
1075
+ return void 0;
1076
+ }
1077
+ snapshot() {
1078
+ const leaf = this.start();
1079
+ const cached = this.cache;
1080
+ if (cached && cached.version === this.tree.version && cached.leaf === leaf) return cached.list;
1081
+ const ids = /* @__PURE__ */ new Set();
1082
+ for (const n of leaf.chain()) for (const id of n.actions.keys()) ids.add(id);
1083
+ const out = [];
1084
+ for (const id of ids) {
1085
+ const a = leaf.resolve(id);
1086
+ if (a) out.push(a);
1087
+ }
1088
+ const list = Object.freeze(out);
1089
+ this.cache = { version: this.tree.version, leaf, list };
1090
+ return list;
1091
+ }
1092
+ deps() {
1093
+ for (const n of this.chain()) {
1094
+ const r = n.contextDeps();
1095
+ if (r) return r;
1096
+ }
1097
+ return null;
1098
+ }
1099
+ dispatcher() {
1100
+ for (const n of this.chain()) {
1101
+ const d = n.dispatchers.at(-1);
1102
+ if (d) return d;
1103
+ }
1104
+ return null;
1105
+ }
1106
+ activate() {
1107
+ super.activate();
1108
+ for (const f of this.onActivate) f();
1109
+ }
1110
+ registry = {
1111
+ register: (action) => {
1112
+ validateActionId(action.id);
1113
+ validateActionDefaultBinding(action);
1114
+ const stack = this.actions.get(action.id);
1115
+ if (stack) stack.push(action);
1116
+ else this.actions.set(action.id, [action]);
1117
+ this.changed();
1118
+ let released = false;
1119
+ return () => {
1120
+ if (released) return;
1121
+ released = true;
1122
+ const cur = this.actions.get(action.id);
1123
+ if (!cur) return;
1124
+ const i = cur.lastIndexOf(action);
1125
+ if (i === -1) return;
1126
+ cur.splice(i, 1);
1127
+ if (cur.length === 0) this.actions.delete(action.id);
1128
+ this.changed();
1129
+ };
1130
+ },
1131
+ unregister: (id) => {
1132
+ if (this.actions.delete(id)) this.changed();
1133
+ },
1134
+ mute: (id) => {
1135
+ this.muted.set(id, (this.muted.get(id) ?? 0) + 1);
1136
+ this.changed();
1137
+ let released = false;
1138
+ return () => {
1139
+ if (released) return;
1140
+ released = true;
1141
+ const held = this.muted.get(id);
1142
+ if (held === void 0) return;
1143
+ if (held > 1) this.muted.set(id, held - 1);
1144
+ else this.muted.delete(id);
1145
+ this.changed();
1146
+ };
1147
+ },
1148
+ list: () => this.snapshot(),
1149
+ trigger: (id, params) => {
1150
+ const leaf = this.start();
1151
+ const a = leaf.resolve(id);
1152
+ if (!a) return false;
1153
+ try {
1154
+ if (a.invoker && a.invoker.timing === "immediate") {
1155
+ a.invoker.run(depsFor(a, leaf.deps()), params);
986
1156
  }
1157
+ } catch (err) {
1158
+ console.error(`weasel ActionsRegistry: action "${id}" threw`, err);
987
1159
  }
988
- };
989
- return {
990
- ...base,
991
- list: () => {
992
- const src = base.list();
993
- if (mutedRef.current.size === 0) return src;
994
- const cached = cacheRef.current;
995
- if (cached && cached.src === src) return cached.out;
996
- const out = Object.freeze(src.filter((a) => !mutedRef.current.has(a.id)));
997
- cacheRef.current = { src, out };
998
- return out;
999
- },
1000
- trigger: (id, params) => isMuted(id) ? false : base.trigger(id, params),
1001
- begin: (id, params) => isMuted(id) ? null : base.begin(id, params),
1002
- subscribe: (listener) => {
1003
- const offBase = base.subscribe(listener);
1004
- listenersRef.current.add(listener);
1005
- return () => {
1006
- offBase();
1007
- listenersRef.current.delete(listener);
1008
- };
1009
- },
1010
- mute: (id) => {
1011
- mutedRef.current.set(id, (mutedRef.current.get(id) ?? 0) + 1);
1012
- cacheRef.current = null;
1013
- notify();
1014
- let released = false;
1015
- return () => {
1016
- if (released) return;
1017
- released = true;
1018
- const held = mutedRef.current.get(id);
1019
- if (held === void 0) return;
1020
- if (held > 1) mutedRef.current.set(id, held - 1);
1021
- else mutedRef.current.delete(id);
1022
- cacheRef.current = null;
1023
- notify();
1024
- };
1025
- }
1026
- };
1027
- }, [base]);
1028
- }
1029
- function ActionsScope({ children }) {
1030
- const parent = useActionsRegistry();
1031
- const scoped = useMuted(parent);
1032
- if (!scoped) return /* @__PURE__ */ jsx(Fragment, { children });
1033
- return /* @__PURE__ */ jsx(ActionsContext.Provider, { value: scoped, children });
1034
- }
1035
- function pushOwner(ref, value) {
1036
- if (value === null) return () => {
1037
- };
1038
- ref.current.push(value);
1039
- let released = false;
1040
- return () => {
1041
- if (released) return;
1042
- released = true;
1043
- const i = ref.current.lastIndexOf(value);
1044
- if (i !== -1) ref.current.splice(i, 1);
1160
+ return true;
1161
+ },
1162
+ subscribe: (listener) => {
1163
+ this.tree.listeners.add(listener);
1164
+ return () => {
1165
+ this.tree.listeners.delete(listener);
1166
+ };
1167
+ },
1168
+ setDispatcher: (d) => pushOwner(this.dispatchers, d),
1169
+ begin: (id, params) => {
1170
+ const leaf = this.start();
1171
+ const a = leaf.resolve(id);
1172
+ const disp = leaf.dispatcher();
1173
+ if (!a || !disp) return null;
1174
+ return disp.beginUiOngoing(id, depsFor(a, leaf.deps()), params);
1175
+ },
1176
+ activate: () => this.activate(),
1177
+ isActive: () => this.isActive()
1045
1178
  };
1179
+ };
1180
+ var NODES = /* @__PURE__ */ new WeakMap();
1181
+ function actionsNodeOf(registry) {
1182
+ return registry ? NODES.get(registry) ?? null : null;
1046
1183
  }
1047
1184
  function ActionsProvider({ children }) {
1048
- const actionsRef = useRef(/* @__PURE__ */ new Map());
1049
- const liveAction = (id) => actionsRef.current.get(id)?.at(-1);
1050
- const versionRef = useRef(0);
1051
- const cachedRef = useRef([]);
1052
- const cachedVerRef = useRef(-1);
1053
- const listenersRef = useRef(/* @__PURE__ */ new Set());
1054
1185
  const depReg = useOptionalDepRegistry();
1055
- const depRegRef = useRef(depReg);
1056
- depRegRef.current = depReg;
1057
- const warnedRef = useRef(false);
1058
- const wiredDepRegRef = useRef([]);
1059
- const dispatcherRef = useRef([]);
1060
- const store = useMemo(() => {
1061
- const snapshot = () => {
1062
- const v = versionRef.current;
1063
- if (cachedVerRef.current === v) return cachedRef.current;
1064
- const out = Object.freeze(
1065
- Array.from(actionsRef.current.values(), (stack) => stack[stack.length - 1])
1066
- );
1067
- cachedRef.current = out;
1068
- cachedVerRef.current = v;
1069
- return out;
1070
- };
1071
- const notify = () => {
1072
- for (const l of listenersRef.current) {
1073
- try {
1074
- l();
1075
- } catch (err) {
1076
- console.error("weasel ActionsRegistry: subscriber threw", err);
1077
- }
1078
- }
1079
- };
1080
- return {
1081
- register: (action) => {
1082
- validateActionId(action.id);
1083
- validateActionDefaultBinding(action);
1084
- const stack = actionsRef.current.get(action.id);
1085
- if (stack) stack.push(action);
1086
- else actionsRef.current.set(action.id, [action]);
1087
- versionRef.current++;
1088
- notify();
1089
- let released = false;
1090
- return () => {
1091
- if (released) return;
1092
- released = true;
1093
- const cur = actionsRef.current.get(action.id);
1094
- if (!cur) return;
1095
- const i = cur.lastIndexOf(action);
1096
- if (i === -1) return;
1097
- cur.splice(i, 1);
1098
- if (cur.length === 0) actionsRef.current.delete(action.id);
1099
- versionRef.current++;
1100
- notify();
1101
- };
1102
- },
1103
- unregister: (id) => {
1104
- if (actionsRef.current.delete(id)) {
1105
- versionRef.current++;
1106
- notify();
1107
- }
1108
- },
1109
- list: () => snapshot(),
1110
- trigger: (id, params) => {
1111
- const a = liveAction(id);
1112
- if (!a) return false;
1113
- try {
1114
- if (a.invoker && a.invoker.timing === "immediate") {
1115
- const r = wiredDepRegRef.current.at(-1) ?? depRegRef.current;
1116
- const deps = !r ? {} : a.requires ? buildDepsFromRequires(a, r) : {
1117
- selection: r.get("selection"),
1118
- scene: r.get("scene"),
1119
- history: r.get("history"),
1120
- view: r.get("view"),
1121
- activeTool: r.get("activeTool"),
1122
- booleansAdapter: r.get("booleansAdapter")
1123
- };
1124
- a.invoker.run(deps, params);
1125
- }
1126
- } catch (err) {
1127
- console.error(`weasel ActionsRegistry: action "${id}" threw`, err);
1128
- }
1129
- return true;
1130
- },
1131
- subscribe: (listener) => {
1132
- listenersRef.current.add(listener);
1133
- return () => {
1134
- listenersRef.current.delete(listener);
1135
- };
1136
- },
1137
- setDispatcher: (d) => {
1138
- if (d && dispatcherRef.current.length > 0 && dispatcherRef.current.at(-1) !== d && !warnedRef.current) {
1139
- warnedRef.current = true;
1140
- warnSharedScope();
1141
- }
1142
- return pushOwner(dispatcherRef, d);
1143
- },
1144
- setDepRegistry: (r) => pushOwner(wiredDepRegRef, r),
1145
- begin: (id, params) => {
1146
- const disp = dispatcherRef.current.at(-1) ?? null;
1147
- if (!disp) return null;
1148
- const r = wiredDepRegRef.current.at(-1) ?? depRegRef.current;
1149
- const a = liveAction(id);
1150
- const deps = !r ? {} : a?.requires ? buildDepsFromRequires(a, r) : {
1151
- selection: r.get("selection"),
1152
- scene: r.get("scene"),
1153
- history: r.get("history"),
1154
- view: r.get("view"),
1155
- activeTool: r.get("activeTool"),
1156
- booleansAdapter: r.get("booleansAdapter")
1157
- };
1158
- return disp.beginUiOngoing(id, deps, params);
1159
- }
1160
- };
1161
- }, []);
1162
- const registry = useMuted(store);
1163
- return /* @__PURE__ */ jsx(ActionsContext.Provider, { value: registry, children });
1164
- }
1165
- function warnSharedScope() {
1166
- if (!IS_DEV) return;
1167
- console.warn(
1168
- "weasel: a second dispatcher claimed this <ActionsProvider>, so only the newest <SceneCanvas> under it will respond to input. Give each canvas its own scope with <WeaselProvider isolate>."
1169
- );
1186
+ const depRegRef = useLatest(depReg);
1187
+ const node = useMemo(() => new ActionsNode(null, true, () => depRegRef.current), [depRegRef]);
1188
+ return /* @__PURE__ */ jsx(ActionsContext.Provider, { value: node.registry, children });
1170
1189
  }
1171
1190
  var IS_DEV = (() => {
1172
1191
  try {
@@ -1192,12 +1211,45 @@ function useAction(action) {
1192
1211
  return reg.register(action);
1193
1212
  }, [reg, action]);
1194
1213
  }
1214
+ function useNodes(descends, ownsTool, parent) {
1215
+ const parentActions = useActionsRegistry();
1216
+ const parentDeps = useOptionalDepRegistry();
1217
+ const parentTools = useContext(ActiveToolNodeContext);
1218
+ const pa = parent ? parent.actions : actionsNodeOf(parentActions);
1219
+ const pd = parent ? parent.deps : depNodeOf(parentDeps);
1220
+ const pt = parent ? parent.tools : parentTools;
1221
+ const nodes = useMemo(() => {
1222
+ const deps = new DepNode(pd, descends);
1223
+ const tools = new ToolNode(pt, descends, ownsTool, null);
1224
+ const actions = new ActionsNode(pa, descends, () => deps.registry);
1225
+ actions.onActivate.push(() => deps.activate(), () => tools.activate());
1226
+ return { actions, deps, tools };
1227
+ }, [pa, pd, pt, descends, ownsTool]);
1228
+ useEffect(() => {
1229
+ const leave = [nodes.deps.mount(), nodes.tools.mount(), nodes.actions.mount()];
1230
+ return () => {
1231
+ for (const f of leave) f();
1232
+ };
1233
+ }, [nodes]);
1234
+ return nodes;
1235
+ }
1236
+ function Provide({ nodes, children }) {
1237
+ return /* @__PURE__ */ jsx(DepRegistryContext.Provider, { value: nodes.deps.registry, children: /* @__PURE__ */ jsx(ActionsContext.Provider, { value: nodes.actions.registry, children: /* @__PURE__ */ jsx(ActiveToolNodeContext.Provider, { value: nodes.tools, children }) }) });
1238
+ }
1239
+ function InputScope({ yoke, children }) {
1240
+ const nodes = useNodes(false, yoke === void 0, yoke);
1241
+ return /* @__PURE__ */ jsx(Provide, { nodes, children });
1242
+ }
1243
+ function useYoke() {
1244
+ return useNodes(true, true, void 0);
1245
+ }
1246
+ function Yoke({ value, children }) {
1247
+ return /* @__PURE__ */ jsx(Provide, { nodes: value, children });
1248
+ }
1195
1249
  function useOngoingAction(actionId) {
1196
1250
  const reg = useActionsRegistry();
1197
- const regRef = useRef(reg);
1198
- regRef.current = reg;
1199
- const idRef = useRef(actionId);
1200
- idRef.current = actionId;
1251
+ const regRef = useLatest(reg);
1252
+ const idRef = useLatest(actionId);
1201
1253
  const ctrlRef = useRef(null);
1202
1254
  const edit = useMemo(() => {
1203
1255
  const end = (reason) => {
@@ -1221,51 +1273,43 @@ function useOngoingAction(actionId) {
1221
1273
  end("cancel");
1222
1274
  }
1223
1275
  };
1224
- }, []);
1276
+ }, [regRef, idRef]);
1225
1277
  useEffect(() => () => edit.commit(), [edit, actionId]);
1226
1278
  return edit;
1227
1279
  }
1228
1280
  function useContributions(opts) {
1229
1281
  const ctx = useActiveToolContext();
1230
1282
  const actionsRegistry = useActionsRegistry();
1231
- const hasInitializedRef = useRef(false);
1232
- const isFirstRender = !hasInitializedRef.current;
1233
- const needsFirstMountSyncRef = useRef(false);
1234
- if (isFirstRender) {
1235
- hasInitializedRef.current = true;
1236
- needsFirstMountSyncRef.current = ctx.active === "select" && opts.focused !== "select";
1237
- }
1283
+ const firstMountRef = useRef(null);
1284
+ const isFirstRender = firstMountRef.current == null;
1285
+ if (firstMountRef.current == null) firstMountRef.current = { sync: ctx.active === null && opts.focused !== null };
1238
1286
  useEffect(() => {
1239
- if (!needsFirstMountSyncRef.current) return;
1240
- needsFirstMountSyncRef.current = false;
1287
+ const first = firstMountRef.current;
1288
+ if (!first?.sync) return;
1289
+ first.sync = false;
1241
1290
  ctx.setActive(opts.focused);
1242
1291
  }, []);
1243
- const focused = isFirstRender && ctx.active === "select" && opts.focused !== "select" ? opts.focused : ctx.active;
1244
- const entriesRef = useRef(opts.entries);
1245
- entriesRef.current = opts.entries;
1246
- const focusedRef = useRef(focused);
1247
- focusedRef.current = focused;
1248
- const engagedRef = useRef(ctx.hotkeyStack);
1249
- engagedRef.current = ctx.hotkeyStack;
1292
+ const focused = isFirstRender && ctx.active === null && opts.focused !== null ? opts.focused : ctx.active;
1293
+ const entries = useStableByContent(opts.entries, sameList);
1294
+ const engaged = ctx.hotkeyStack;
1250
1295
  const setFocused = useCallback((id) => {
1251
1296
  ctx.setActive(id);
1252
1297
  }, [ctx]);
1253
1298
  const snapshot = useCallback(() => {
1254
- const engaged = engagedRef.current;
1255
1299
  const engagedIds = new Set(engaged);
1256
1300
  const ordered = [];
1257
1301
  for (const id of engaged) {
1258
- const entry = entriesRef.current.find((e) => e.id === id);
1302
+ const entry = entries.find((e) => e.id === id);
1259
1303
  if (entry && !ordered.includes(entry)) ordered.push(entry);
1260
1304
  }
1261
- for (const entry of entriesRef.current) {
1305
+ for (const entry of entries) {
1262
1306
  if (!ordered.includes(entry)) ordered.push(entry);
1263
1307
  }
1264
1308
  return {
1265
1309
  ordered,
1266
- state: { focusedId: focusedRef.current, engagedIds }
1310
+ state: { focusedId: focused, engagedIds }
1267
1311
  };
1268
- }, []);
1312
+ }, [entries, focused, engaged]);
1269
1313
  const scopedBindings = useCallback(() => {
1270
1314
  const { ordered, state } = snapshot();
1271
1315
  return scopeBindings(ordered, state);
@@ -1286,48 +1330,43 @@ function useContributions(opts) {
1286
1330
  }
1287
1331
  return [...active, ...hotkey, ...ambient];
1288
1332
  }, [snapshot]);
1289
- const offhandSig = opts.entries.map((e) => e.eligibility?.offhand ? `${e.id}:${e.eligibility.offhand}` : "").filter(Boolean).join(",");
1290
- useEffect(() => {
1291
- if (!actionsRegistry || offhandSig === "") return;
1292
- const specs = offhandSig.split(",").map((entry) => {
1293
- const [toolId, trigger] = entry.split(":");
1294
- return { toolId, key: offhandKeyFor(trigger) };
1295
- });
1296
- return actionsRegistry.register(makeToolOffhandAction(buildToolOffhandBindings(specs)));
1297
- }, [actionsRegistry, offhandSig]);
1333
+ useOffhandAction(entries);
1298
1334
  const lastConflictSigRef = useRef(null);
1299
- const entrySig = opts.entries.map((e) => e.id).join(",");
1335
+ const entrySig = entries.map((e) => e.id).join(",");
1336
+ const modesRegistry = opts.modes;
1300
1337
  useEffect(() => {
1301
1338
  if (!isDev()) return;
1302
- if (lastConflictSigRef.current === entrySig) return;
1303
- lastConflictSigRef.current = entrySig;
1339
+ const last = lastConflictSigRef.current;
1340
+ if (last?.sig === entrySig && last.modes === modesRegistry) return;
1341
+ lastConflictSigRef.current = { sig: entrySig, modes: modesRegistry };
1304
1342
  const registry = [];
1305
1343
  const ambient = [];
1306
- for (const entry of entriesRef.current) {
1344
+ for (const entry of entries) {
1307
1345
  if (entry.eligibility?.focus) registry.push(entry);
1308
1346
  if (entry.eligibility?.always || entry.eligibility?.claimed) ambient.push(entry);
1309
1347
  }
1310
1348
  reportRouteConflicts({
1311
1349
  registry,
1312
1350
  ambient,
1313
- actions: actionsRegistry?.list() ?? []
1351
+ actions: actionsRegistry?.list() ?? [],
1352
+ ...modesRegistry ? { modes: modesRegistry.list().map(activeModeOf) } : {}
1314
1353
  });
1315
- }, [entrySig, actionsRegistry]);
1354
+ }, [entrySig, entries, actionsRegistry, modesRegistry]);
1316
1355
  return useMemo(
1317
- () => ({
1318
- get entries() {
1319
- return entriesRef.current;
1320
- },
1321
- focused,
1322
- setFocused,
1323
- scopedBindings,
1324
- overlays
1325
- }),
1326
- [focused, setFocused, scopedBindings, overlays]
1356
+ () => ({ entries, focused, setFocused, scopedBindings, overlays }),
1357
+ [entries, focused, setFocused, scopedBindings, overlays]
1327
1358
  );
1328
1359
  }
1329
-
1330
- // src/tools/useTools.ts
1360
+ function useOffhandAction(entries) {
1361
+ const actionsRegistry = useActionsRegistry();
1362
+ const offhandSig = JSON.stringify(entries.filter((e) => e.eligibility?.offhand).map((e) => [e.id, e.eligibility.offhand]));
1363
+ useEffect(() => {
1364
+ const pairs = JSON.parse(offhandSig);
1365
+ if (!actionsRegistry || pairs.length === 0) return;
1366
+ const specs = pairs.map(([toolId, trigger]) => ({ toolId, key: offhandKeyFor(trigger) }));
1367
+ return actionsRegistry.register(makeToolOffhandAction(buildToolOffhandBindings(specs)));
1368
+ }, [actionsRegistry, offhandSig]);
1369
+ }
1331
1370
  function declareSlot(tool, slot) {
1332
1371
  const hotkey = tool.def?.hotkey;
1333
1372
  const eligibility = {
@@ -1337,6 +1376,18 @@ function declareSlot(tool, slot) {
1337
1376
  };
1338
1377
  return sameEligibility(tool.eligibility, eligibility) ? tool : { ...tool, eligibility };
1339
1378
  }
1379
+ var NO_AMBIENT = [];
1380
+ function useStableSources(registry, ambient) {
1381
+ return useStableByContent(
1382
+ { registry, ambient: ambient ?? NO_AMBIENT },
1383
+ (a, b) => sameRecord(a.registry, b.registry) && sameList(a.ambient, b.ambient)
1384
+ );
1385
+ }
1386
+ function sameRecord(a, b) {
1387
+ const ak = Object.keys(a);
1388
+ if (ak.length !== Object.keys(b).length) return false;
1389
+ return ak.every((k) => k in b && a[k] === b[k]);
1390
+ }
1340
1391
  function sameEligibility(a, b) {
1341
1392
  if (!a) return false;
1342
1393
  const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
@@ -1344,16 +1395,18 @@ function sameEligibility(a, b) {
1344
1395
  return true;
1345
1396
  }
1346
1397
  function useTools(opts) {
1347
- if (!(opts.active in opts.registry)) {
1348
- throw new Error(`useTools: active "${opts.active}" not in registry`);
1398
+ const initialActive = opts.active ?? null;
1399
+ if (initialActive !== null && !(initialActive in opts.registry)) {
1400
+ throw new Error(`useTools: active "${initialActive}" not in registry`);
1349
1401
  }
1350
1402
  const ctx = useActiveToolContext();
1403
+ const sources = useStableSources(opts.registry, opts.ambient);
1351
1404
  const slotted = useMemo(() => {
1352
1405
  const registry = {};
1353
- for (const [id, tool] of Object.entries(opts.registry)) {
1406
+ for (const [id, tool] of Object.entries(sources.registry)) {
1354
1407
  registry[id] = declareSlot(tool, "focus");
1355
1408
  }
1356
- const ambient = (opts.ambient ?? []).map((t) => declareSlot(t, "always"));
1409
+ const ambient = sources.ambient.map((t) => declareSlot(t, "always"));
1357
1410
  const byId = /* @__PURE__ */ new Map();
1358
1411
  for (const tool of [...Object.values(registry), ...ambient]) {
1359
1412
  const prior = byId.get(tool.id);
@@ -1361,25 +1414,26 @@ function useTools(opts) {
1361
1414
  else byId.set(tool.id, tool);
1362
1415
  }
1363
1416
  return { registry, ambient, entries: [...byId.values()] };
1364
- }, [opts.registry, opts.ambient]);
1365
- const contributions = useContributions({ entries: slotted.entries, focused: opts.active });
1417
+ }, [sources]);
1418
+ const contributions = useContributions({
1419
+ entries: slotted.entries,
1420
+ focused: initialActive,
1421
+ ...opts.modes ? { modes: opts.modes } : {}
1422
+ });
1366
1423
  const hotkeyEngaged = ctx.hotkeyStack.at(-1) ?? null;
1367
- const slottedRef = useRef(slotted);
1368
- slottedRef.current = slotted;
1369
- const activeRef = useRef(contributions.focused);
1370
- activeRef.current = contributions.focused;
1371
- const hotkeyRef = useRef(hotkeyEngaged);
1372
- hotkeyRef.current = hotkeyEngaged;
1424
+ const slottedRef = useLatest(slotted);
1425
+ const activeRef = useLatest(contributions.focused);
1426
+ const hotkeyRef = useLatest(hotkeyEngaged);
1373
1427
  const setFocused = contributions.setFocused;
1374
1428
  const setActive = useCallback(
1375
1429
  (id) => {
1376
- if (!(id in slottedRef.current.registry)) {
1430
+ if (id !== null && !(id in slottedRef.current.registry)) {
1377
1431
  throw new Error(`setActive: "${id}" not in registry`);
1378
1432
  }
1379
1433
  dlog("tools", "active:", activeRef.current, "\u2192", id);
1380
1434
  setFocused(id);
1381
1435
  },
1382
- [setFocused]
1436
+ [setFocused, slottedRef, activeRef]
1383
1437
  );
1384
1438
  const engageHotkey = useCallback(
1385
1439
  (id) => {
@@ -1389,19 +1443,18 @@ function useTools(opts) {
1389
1443
  dlog("tools", "hotkey engaged:", id);
1390
1444
  ctx.pushHotkey(id);
1391
1445
  },
1392
- [ctx]
1446
+ [ctx, slottedRef]
1393
1447
  );
1394
1448
  const disengageHotkey = useCallback(() => {
1395
1449
  if (hotkeyRef.current) dlog("tools", "hotkey disengaged:", hotkeyRef.current);
1396
1450
  ctx.popHotkey();
1397
- }, [ctx]);
1451
+ }, [ctx, hotkeyRef]);
1398
1452
  const has = useCallback(
1399
- (id) => id in slottedRef.current.registry || slottedRef.current.ambient.some((t) => t.id === id),
1400
- []
1453
+ (id) => id in slotted.registry || slotted.ambient.some((t) => t.id === id),
1454
+ [slotted]
1401
1455
  );
1402
1456
  const active = contributions.focused;
1403
1457
  const getActiveOverlays = contributions.overlays;
1404
- const registryKey = Object.keys(slotted.registry).sort().join("|") + "#" + slotted.ambient.map((t) => t.id).join("|");
1405
1458
  return useMemo(
1406
1459
  () => ({
1407
1460
  active,
@@ -1409,18 +1462,15 @@ function useTools(opts) {
1409
1462
  hotkeyEngaged,
1410
1463
  engageHotkey,
1411
1464
  disengageHotkey,
1412
- ambient: slottedRef.current.ambient,
1413
- registry: slottedRef.current.registry,
1465
+ ambient: slotted.ambient,
1466
+ registry: slotted.registry,
1414
1467
  has,
1415
1468
  getActiveOverlays
1416
1469
  }),
1417
- // `registryKey` isn't read in the body on purpose: the body reads
1418
- // `slottedRef.current`, and the key is what tells us that ref's contents moved.
1419
- // eslint-disable-next-line react-hooks/exhaustive-deps
1420
- [active, setActive, hotkeyEngaged, engageHotkey, disengageHotkey, has, getActiveOverlays, registryKey]
1470
+ [active, setActive, hotkeyEngaged, engageHotkey, disengageHotkey, has, getActiveOverlays, slotted]
1421
1471
  );
1422
1472
  }
1423
1473
 
1424
- export { ActionsProvider, ActionsScope, ActiveToolContextProvider, ActiveToolContextProviderIfRoot, DepRegistryProvider, useAction, useActionsRegistry, useActiveToolContext, useContributions, useDepRegistry, useDepSource, useGestureDispatcher, useOngoingAction, useOptionalActiveToolContext, useOptionalDepRegistry, useTools };
1474
+ export { ActionsProvider, ActiveToolContextProvider, ActiveToolContextProviderIfRoot, DepRegistryProvider, InputScope, Yoke, useAction, useActionsRegistry, useActiveToolContext, useContributions, useDepRegistry, useDepSource, useGestureDispatcher, useOffhandAction, useOngoingAction, useOptionalActiveToolContext, useOptionalDepRegistry, useTools, useYoke };
1425
1475
  //# sourceMappingURL=react.js.map
1426
1476
  //# sourceMappingURL=react.js.map