@weasel-js/routing 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.js CHANGED
@@ -1,37 +1,80 @@
1
- import { createDispatcher, buildDepsFromRequires, validateActionId, validateActionDefaultBinding, scopeBindings, liveScope, offhandKeyFor, makeToolOffhandAction, buildToolOffhandBindings, isDev, reportRouteConflicts, dlog, isEditableTarget, clientToCanvasRect, openPointerSession, pointerGestureId, itemsFromDataTransfer, itemsFromClipboardData } from './chunk-D2TZLDP5.js';
2
- import { createContext, useState, useCallback, useMemo, useContext, useRef, useEffect } from 'react';
1
+ import { createDepRegistry, createDispatcher, scopeBindings, liveScope, isDev, reportRouteConflicts, offhandKeyFor, makeToolOffhandAction, buildToolOffhandBindings, dlog, isTool, isEditableTarget, clientToCanvasRect, openPointerSession, publishLiveDispatch, pointerGestureId, itemsFromDataTransfer, itemsFromClipboardData, ScopeNode, pushOwner, validateActionId, validateActionDefaultBinding, depNodeOf, DepNode, buildDepsFromRequires } from './chunk-Q3Y3DPKH.js';
2
+ import { createContext, useState, useContext, useMemo, useRef, useEffect, useCallback, useSyncExternalStore } from 'react';
3
3
  import { jsx, Fragment } from 'react/jsx-runtime';
4
+ import { pastDragThreshold } from '@weasel-js/gestures';
4
5
  import { resolveCursorTier } from '@weasel-js/cursor';
6
+ import { activeModeOf } from '@weasel-js/modes';
5
7
 
8
+ var ToolNode = class extends ScopeNode {
9
+ state;
10
+ value = null;
11
+ constructor(parent, descends, owns, initialActive) {
12
+ super(parent, descends);
13
+ this.state = owns ? { active: initialActive ?? parent?.owner().state?.active ?? null, hotkeyStack: [] } : null;
14
+ }
15
+ /** The node whose tool a read here resolves to. */
16
+ owner() {
17
+ for (const n of this.start().chain()) if (n.state) return n;
18
+ return this;
19
+ }
20
+ update(next) {
21
+ const owner = this.owner();
22
+ if (!owner.state) return;
23
+ const updated = next(owner.state);
24
+ if (updated === owner.state) return;
25
+ owner.state = updated;
26
+ owner.changed();
27
+ }
28
+ /** A stable value per owner and state, for `useSyncExternalStore`. */
29
+ snapshot() {
30
+ const from = this.owner();
31
+ const state = from.state ?? { active: null, hotkeyStack: [] };
32
+ const cached = this.value;
33
+ if (cached && cached.from === from && cached.state === state) return cached.out;
34
+ const out = {
35
+ active: state.active,
36
+ hotkeyStack: state.hotkeyStack,
37
+ setActive: (id) => this.update((s) => s.active === id ? s : { ...s, active: id }),
38
+ pushHotkey: (id) => this.update((s) => ({ ...s, hotkeyStack: [...s.hotkeyStack, id] })),
39
+ popHotkey: (id) => this.update((s) => {
40
+ const stack = s.hotkeyStack;
41
+ if (stack.length === 0) return s;
42
+ if (id === void 0) return { ...s, hotkeyStack: stack.slice(0, -1) };
43
+ const i = stack.lastIndexOf(id);
44
+ return i === -1 ? s : { ...s, hotkeyStack: [...stack.slice(0, i), ...stack.slice(i + 1)] };
45
+ })
46
+ };
47
+ this.value = { from, state, out };
48
+ return out;
49
+ }
50
+ subscribe = (listener) => {
51
+ this.tree.listeners.add(listener);
52
+ return () => {
53
+ this.tree.listeners.delete(listener);
54
+ };
55
+ };
56
+ };
6
57
  var ActiveToolContext = createContext(null);
58
+ var ActiveToolNodeContext = ActiveToolContext;
7
59
  function ActiveToolContextProvider({
8
60
  children,
9
- initialActive = "select"
61
+ initialActive = null
10
62
  }) {
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]
63
+ const [node] = useState(() => new ToolNode(null, true, true, initialActive));
64
+ return /* @__PURE__ */ jsx(ActiveToolContext.Provider, { value: node, children });
65
+ }
66
+ function useNodeValue(node) {
67
+ const subscribe = useMemo(() => node?.subscribe ?? NO_SUBSCRIBE, [node]);
68
+ return useSyncExternalStore(
69
+ subscribe,
70
+ () => node?.snapshot() ?? null,
71
+ () => node?.snapshot() ?? null
30
72
  );
31
- return /* @__PURE__ */ jsx(ActiveToolContext.Provider, { value, children });
32
73
  }
74
+ var NO_SUBSCRIBE = () => () => {
75
+ };
33
76
  function useActiveToolContext() {
34
- const value = useContext(ActiveToolContext);
77
+ const value = useNodeValue(useContext(ActiveToolContext));
35
78
  if (value === null) {
36
79
  throw new Error(
37
80
  "useActiveToolContext: no ActiveToolContextProvider in scope. Wrap your tree with <ActiveToolContextProvider> (typically inside <SceneCanvas>)."
@@ -40,7 +83,7 @@ function useActiveToolContext() {
40
83
  return value;
41
84
  }
42
85
  function useOptionalActiveToolContext() {
43
- return useContext(ActiveToolContext);
86
+ return useNodeValue(useContext(ActiveToolContext));
44
87
  }
45
88
  function ActiveToolContextProviderIfRoot({
46
89
  children
@@ -51,28 +94,7 @@ function ActiveToolContextProviderIfRoot({
51
94
  }
52
95
  var DepRegistryContext = createContext(null);
53
96
  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
- }), []);
97
+ const registry = useMemo(createDepRegistry, []);
76
98
  return /* @__PURE__ */ jsx(DepRegistryContext.Provider, { value: registry, children });
77
99
  }
78
100
  function useDepRegistry() {
@@ -134,7 +156,7 @@ 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 } = opts;
138
160
  const pointerChannel = channels?.pointer !== false;
139
161
  const wheelChannel = channels?.wheel !== false;
140
162
  const pinchChannel = channels?.pinch !== false;
@@ -148,12 +170,14 @@ function useGestureDispatcher(opts) {
148
170
  if (!dispatcherRef.current) {
149
171
  dispatcherRef.current = dispatcherOpt ?? createDispatcher();
150
172
  }
173
+ const registryRef = useRef(actions);
174
+ registryRef.current = actions;
151
175
  const ctxRef = useRef({
152
176
  actions,
153
177
  depRegistry,
154
178
  activeToolId: activeTool.active,
155
179
  hotkeyStack: activeTool.hotkeyStack,
156
- toolsById,
180
+ entriesById,
157
181
  isMac: IS_MAC
158
182
  });
159
183
  ctxRef.current = {
@@ -161,7 +185,7 @@ function useGestureDispatcher(opts) {
161
185
  depRegistry,
162
186
  activeToolId: activeTool.active,
163
187
  hotkeyStack: activeTool.hotkeyStack,
164
- toolsById,
188
+ entriesById,
165
189
  isMac: IS_MAC
166
190
  };
167
191
  const rootTargetRef = useRef({
@@ -191,25 +215,39 @@ function useGestureDispatcher(opts) {
191
215
  const paintedCursorRef = useRef(paintedCursor);
192
216
  paintedCursorRef.current = paintedCursor;
193
217
  const lastClickRef = useRef(null);
218
+ const liveToolsRef = useRef(/* @__PURE__ */ new Set());
194
219
  const prevActiveRef = useRef(activeTool.active);
220
+ const entriesByIdRef = useRef(entriesById);
221
+ entriesByIdRef.current = entriesById;
222
+ const fireLifecycle = (id, hook) => {
223
+ const tool = entriesByIdRef.current.get(id);
224
+ if (!tool || !isTool(tool)) return;
225
+ const fn = tool[hook];
226
+ if (!fn) return;
227
+ try {
228
+ fn({ scratch: tool.initScratch?.() });
229
+ } catch (err) {
230
+ console.error(`weasel: tool "${id}".${hook} threw`, err);
231
+ }
232
+ };
195
233
  useEffect(() => {
234
+ const prev = liveToolsRef.current;
235
+ const next = new Set(
236
+ activeTool.active === null ? activeTool.hotkeyStack : [activeTool.active, ...activeTool.hotkeyStack]
237
+ );
238
+ liveToolsRef.current = next;
239
+ for (const id of prev) if (!next.has(id)) fireLifecycle(id, "onDeactivate");
196
240
  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
241
  for (const d of allDispatchers()) d.cancelAll("cancel");
210
242
  }
211
243
  prevActiveRef.current = activeTool.active;
244
+ for (const id of next) if (!prev.has(id)) fireLifecycle(id, "onActivate");
212
245
  });
246
+ useEffect(() => () => {
247
+ const live = liveToolsRef.current;
248
+ liveToolsRef.current = /* @__PURE__ */ new Set();
249
+ for (const id of live) fireLifecycle(id, "onDeactivate");
250
+ }, []);
213
251
  useEffect(() => {
214
252
  if (!enabled) return;
215
253
  let disposed = false;
@@ -233,7 +271,8 @@ function useGestureDispatcher(opts) {
233
271
  const ctxNow = () => ({
234
272
  ...ctxRef.current,
235
273
  depRegistry: routedDeps,
236
- getRuleCtx: routedRuleCtx
274
+ getRuleCtx: routedRuleCtx,
275
+ viewId: target().id
237
276
  });
238
277
  const canvas = canvasRef.current;
239
278
  const toWorld = (cx, cy) => {
@@ -248,7 +287,8 @@ function useGestureDispatcher(opts) {
248
287
  if (before > 0 || after > 0) requestRedrawRef.current?.();
249
288
  return result;
250
289
  };
251
- const heldKeys = /* @__PURE__ */ new Set();
290
+ const heldKeys = /* @__PURE__ */ new Map();
291
+ const physicalKey = (e) => e.code || e.key.toLowerCase();
252
292
  const held = /* @__PURE__ */ new Map();
253
293
  const toCanvasLocal = (p) => {
254
294
  const rect = canvas?.getBoundingClientRect();
@@ -272,7 +312,6 @@ function useGestureDispatcher(opts) {
272
312
  };
273
313
  let multiTouchStart = null;
274
314
  const TAP_THRESHOLD_PX = 8;
275
- const DRAG_THRESHOLD_PX = 4;
276
315
  const LONG_PRESS_MS = 500;
277
316
  const longPressTimers = /* @__PURE__ */ new Map();
278
317
  const cancelLongPress = (pointerId) => {
@@ -321,8 +360,9 @@ function useGestureDispatcher(opts) {
321
360
  const DOUBLE_CLICK_MAX_MS = 600;
322
361
  const DOUBLE_CLICK_MAX_PX = 8;
323
362
  const onKeyDown = (e) => {
324
- if (isEditableTarget(e.target)) return;
325
- if (e.repeat && heldKeys.has(e.key)) {
363
+ if (e.defaultPrevented || isEditableTarget(e.target)) return;
364
+ if (!registryRef.current.isActive()) return;
365
+ if (e.repeat && heldKeys.has(physicalKey(e))) {
326
366
  e.preventDefault();
327
367
  return;
328
368
  }
@@ -347,17 +387,19 @@ function useGestureDispatcher(opts) {
347
387
  };
348
388
  const heldResult = dispatch(heldEv);
349
389
  if (heldResult === "handled") {
350
- heldKeys.add(e.key);
390
+ heldKeys.set(physicalKey(e), e.key);
351
391
  }
352
392
  if (keyResult === "handled" || heldResult === "handled") {
353
393
  e.preventDefault();
354
394
  }
355
395
  };
356
396
  const onKeyUp = (e) => {
357
- if (!heldKeys.has(e.key)) return;
397
+ const physical = physicalKey(e);
398
+ const downKey = heldKeys.get(physical);
399
+ if (downKey === void 0) return;
358
400
  const ev = {
359
401
  kind: "key-held",
360
- key: e.key,
402
+ key: downKey,
361
403
  phase: "up",
362
404
  altKey: e.altKey,
363
405
  ctrlKey: e.ctrlKey,
@@ -365,12 +407,12 @@ function useGestureDispatcher(opts) {
365
407
  shiftKey: e.shiftKey
366
408
  };
367
409
  dispatch(ev);
368
- heldKeys.delete(e.key);
410
+ heldKeys.delete(physical);
369
411
  };
370
412
  const onWindowBlur = () => {
371
413
  endPinch();
372
414
  for (const p of [...held.values()]) p.session.cancel();
373
- for (const key of heldKeys) {
415
+ for (const key of heldKeys.values()) {
374
416
  const ev = {
375
417
  kind: "key-held",
376
418
  key,
@@ -385,6 +427,7 @@ function useGestureDispatcher(opts) {
385
427
  heldKeys.clear();
386
428
  };
387
429
  const onWheel = (e) => {
430
+ registryRef.current.activate();
388
431
  if (pinchLive && e.ctrlKey) {
389
432
  e.preventDefault();
390
433
  e.stopPropagation();
@@ -468,6 +511,7 @@ function useGestureDispatcher(opts) {
468
511
  endPinch();
469
512
  };
470
513
  const onPointerDown = (e) => {
514
+ registryRef.current.activate();
471
515
  if ((e.button ?? 0) !== 0) return;
472
516
  if (!canvas) return;
473
517
  routeDown(e.pointerId, e.clientX, e.clientY);
@@ -560,9 +604,7 @@ function useGestureDispatcher(opts) {
560
604
  if (rec) rec.pos = { x: e.clientX, y: e.clientY };
561
605
  const buffered = rec?.buffered;
562
606
  if (rec && buffered) {
563
- const dx = e.clientX - buffered.clientX;
564
- const dy = e.clientY - buffered.clientY;
565
- if (Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) {
607
+ if (pastDragThreshold(buffered, e)) {
566
608
  rec.buffered = null;
567
609
  cancelLongPress(e.pointerId);
568
610
  dispatch(buffered.ev);
@@ -670,10 +712,17 @@ function useGestureDispatcher(opts) {
670
712
  ...bodyKind !== void 0 ? { bodyKind } : {}
671
713
  };
672
714
  const ctx = ctxNow();
673
- const dragCursor = dispatcherNow().resolveOnly(
674
- { kind: "pointerdown", x: w.x, y: w.y, clientX: h.clientX, clientY: h.clientY, ...where },
675
- ctx
676
- )?.action.cursor;
715
+ const press = {
716
+ kind: "pointerdown",
717
+ x: w.x,
718
+ y: w.y,
719
+ clientX: h.clientX,
720
+ clientY: h.clientY,
721
+ ...where
722
+ };
723
+ const d = dispatcherNow();
724
+ publishLiveDispatch(() => d.explain(press, ctx));
725
+ const dragCursor = d.resolveOnly(press, ctx)?.action.cursor;
677
726
  const cursor = dragCursor ?? dispatcherNow().resolveOnly(
678
727
  { kind: "click", x: w.x, y: w.y, pressX: w.x, pressY: w.y, ...where },
679
728
  ctx
@@ -699,6 +748,7 @@ function useGestureDispatcher(opts) {
699
748
  };
700
749
  const onHoverPointerLeave = () => {
701
750
  lastHover = null;
751
+ publishLiveDispatch(null);
702
752
  paintedCursorRef.current?.()?.clearPointer();
703
753
  clearHoverCursor();
704
754
  };
@@ -911,9 +961,10 @@ function useGestureDispatcher(opts) {
911
961
  });
912
962
  if (result === "handled") e.preventDefault();
913
963
  };
964
+ const keyCapture = keyboard === "first";
914
965
  if (keyboard) {
915
- window.addEventListener("keydown", onKeyDown);
916
- window.addEventListener("keyup", onKeyUp);
966
+ window.addEventListener("keydown", onKeyDown, keyCapture);
967
+ window.addEventListener("keyup", onKeyUp, keyCapture);
917
968
  }
918
969
  window.addEventListener("blur", onWindowBlur);
919
970
  window.addEventListener("keydown", scheduleHoverCursorRefresh);
@@ -939,8 +990,8 @@ function useGestureDispatcher(opts) {
939
990
  }
940
991
  return () => {
941
992
  if (keyboard) {
942
- window.removeEventListener("keydown", onKeyDown);
943
- window.removeEventListener("keyup", onKeyUp);
993
+ window.removeEventListener("keydown", onKeyDown, keyCapture);
994
+ window.removeEventListener("keyup", onKeyUp, keyCapture);
944
995
  }
945
996
  window.removeEventListener("blur", onWindowBlur);
946
997
  window.removeEventListener("keydown", scheduleHoverCursorRefresh);
@@ -969,203 +1020,152 @@ function useGestureDispatcher(opts) {
969
1020
  }, [enabled, keyboard, canvasRef, pointerChannel, wheelChannel, pinchChannel, contextMenuChannel, ingestChannel]);
970
1021
  }
971
1022
  var ActionsContext = createContext(null);
972
- function useMuted(base) {
973
- const mutedRef = useRef(/* @__PURE__ */ new Map());
974
- const listenersRef = useRef(/* @__PURE__ */ new Set());
975
- const cacheRef = useRef(null);
976
- return useMemo(() => {
977
- if (!base) return null;
978
- const isMuted = (id) => mutedRef.current.has(id);
979
- const notify = () => {
980
- for (const l of listenersRef.current) {
981
- try {
982
- l();
983
- } catch (err) {
984
- console.error("weasel ActionsRegistry: subscriber threw", err);
1023
+ function depsFor(a, r) {
1024
+ if (!r) return {};
1025
+ if (a.requires) return buildDepsFromRequires(a, r);
1026
+ return {
1027
+ selection: r.get("selection"),
1028
+ scene: r.get("scene"),
1029
+ history: r.get("history"),
1030
+ view: r.get("view"),
1031
+ activeTool: r.get("activeTool"),
1032
+ booleansAdapter: r.get("booleansAdapter")
1033
+ };
1034
+ }
1035
+ var ActionsNode = class extends ScopeNode {
1036
+ constructor(parent, descends, contextDeps) {
1037
+ super(parent, descends);
1038
+ this.contextDeps = contextDeps;
1039
+ NODES.set(this.registry, this);
1040
+ }
1041
+ contextDeps;
1042
+ actions = /* @__PURE__ */ new Map();
1043
+ muted = /* @__PURE__ */ new Map();
1044
+ dispatchers = [];
1045
+ cache = null;
1046
+ /** Called on `activate`, so a scope's dep and tool nodes move with it. */
1047
+ onActivate = [];
1048
+ resolve(id) {
1049
+ for (const n of this.chain()) {
1050
+ if (n.muted.has(id)) return void 0;
1051
+ const a = n.actions.get(id)?.at(-1);
1052
+ if (a) return a;
1053
+ }
1054
+ return void 0;
1055
+ }
1056
+ snapshot() {
1057
+ const leaf = this.start();
1058
+ const cached = this.cache;
1059
+ if (cached && cached.version === this.tree.version && cached.leaf === leaf) return cached.list;
1060
+ const ids = /* @__PURE__ */ new Set();
1061
+ for (const n of leaf.chain()) for (const id of n.actions.keys()) ids.add(id);
1062
+ const out = [];
1063
+ for (const id of ids) {
1064
+ const a = leaf.resolve(id);
1065
+ if (a) out.push(a);
1066
+ }
1067
+ const list = Object.freeze(out);
1068
+ this.cache = { version: this.tree.version, leaf, list };
1069
+ return list;
1070
+ }
1071
+ deps() {
1072
+ for (const n of this.chain()) {
1073
+ const r = n.contextDeps();
1074
+ if (r) return r;
1075
+ }
1076
+ return null;
1077
+ }
1078
+ dispatcher() {
1079
+ for (const n of this.chain()) {
1080
+ const d = n.dispatchers.at(-1);
1081
+ if (d) return d;
1082
+ }
1083
+ return null;
1084
+ }
1085
+ activate() {
1086
+ super.activate();
1087
+ for (const f of this.onActivate) f();
1088
+ }
1089
+ registry = {
1090
+ register: (action) => {
1091
+ validateActionId(action.id);
1092
+ validateActionDefaultBinding(action);
1093
+ const stack = this.actions.get(action.id);
1094
+ if (stack) stack.push(action);
1095
+ else this.actions.set(action.id, [action]);
1096
+ this.changed();
1097
+ let released = false;
1098
+ return () => {
1099
+ if (released) return;
1100
+ released = true;
1101
+ const cur = this.actions.get(action.id);
1102
+ if (!cur) return;
1103
+ const i = cur.lastIndexOf(action);
1104
+ if (i === -1) return;
1105
+ cur.splice(i, 1);
1106
+ if (cur.length === 0) this.actions.delete(action.id);
1107
+ this.changed();
1108
+ };
1109
+ },
1110
+ unregister: (id) => {
1111
+ if (this.actions.delete(id)) this.changed();
1112
+ },
1113
+ mute: (id) => {
1114
+ this.muted.set(id, (this.muted.get(id) ?? 0) + 1);
1115
+ this.changed();
1116
+ let released = false;
1117
+ return () => {
1118
+ if (released) return;
1119
+ released = true;
1120
+ const held = this.muted.get(id);
1121
+ if (held === void 0) return;
1122
+ if (held > 1) this.muted.set(id, held - 1);
1123
+ else this.muted.delete(id);
1124
+ this.changed();
1125
+ };
1126
+ },
1127
+ list: () => this.snapshot(),
1128
+ trigger: (id, params) => {
1129
+ const leaf = this.start();
1130
+ const a = leaf.resolve(id);
1131
+ if (!a) return false;
1132
+ try {
1133
+ if (a.invoker && a.invoker.timing === "immediate") {
1134
+ a.invoker.run(depsFor(a, leaf.deps()), params);
985
1135
  }
1136
+ } catch (err) {
1137
+ console.error(`weasel ActionsRegistry: action "${id}" threw`, err);
986
1138
  }
987
- };
988
- return {
989
- ...base,
990
- list: () => {
991
- const src = base.list();
992
- if (mutedRef.current.size === 0) return src;
993
- const cached = cacheRef.current;
994
- if (cached && cached.src === src) return cached.out;
995
- const out = Object.freeze(src.filter((a) => !mutedRef.current.has(a.id)));
996
- cacheRef.current = { src, out };
997
- return out;
998
- },
999
- trigger: (id, params) => isMuted(id) ? false : base.trigger(id, params),
1000
- begin: (id, params) => isMuted(id) ? null : base.begin(id, params),
1001
- subscribe: (listener) => {
1002
- const offBase = base.subscribe(listener);
1003
- listenersRef.current.add(listener);
1004
- return () => {
1005
- offBase();
1006
- listenersRef.current.delete(listener);
1007
- };
1008
- },
1009
- mute: (id) => {
1010
- mutedRef.current.set(id, (mutedRef.current.get(id) ?? 0) + 1);
1011
- cacheRef.current = null;
1012
- notify();
1013
- let released = false;
1014
- return () => {
1015
- if (released) return;
1016
- released = true;
1017
- const held = mutedRef.current.get(id);
1018
- if (held === void 0) return;
1019
- if (held > 1) mutedRef.current.set(id, held - 1);
1020
- else mutedRef.current.delete(id);
1021
- cacheRef.current = null;
1022
- notify();
1023
- };
1024
- }
1025
- };
1026
- }, [base]);
1027
- }
1028
- function ActionsScope({ children }) {
1029
- const parent = useActionsRegistry();
1030
- const scoped = useMuted(parent);
1031
- if (!scoped) return /* @__PURE__ */ jsx(Fragment, { children });
1032
- return /* @__PURE__ */ jsx(ActionsContext.Provider, { value: scoped, children });
1033
- }
1034
- function pushOwner(ref, value) {
1035
- if (value === null) return () => {
1036
- };
1037
- ref.current.push(value);
1038
- let released = false;
1039
- return () => {
1040
- if (released) return;
1041
- released = true;
1042
- const i = ref.current.lastIndexOf(value);
1043
- if (i !== -1) ref.current.splice(i, 1);
1139
+ return true;
1140
+ },
1141
+ subscribe: (listener) => {
1142
+ this.tree.listeners.add(listener);
1143
+ return () => {
1144
+ this.tree.listeners.delete(listener);
1145
+ };
1146
+ },
1147
+ setDispatcher: (d) => pushOwner(this.dispatchers, d),
1148
+ begin: (id, params) => {
1149
+ const leaf = this.start();
1150
+ const a = leaf.resolve(id);
1151
+ const disp = leaf.dispatcher();
1152
+ if (!a || !disp) return null;
1153
+ return disp.beginUiOngoing(id, depsFor(a, leaf.deps()), params);
1154
+ },
1155
+ activate: () => this.activate(),
1156
+ isActive: () => this.isActive()
1044
1157
  };
1158
+ };
1159
+ var NODES = /* @__PURE__ */ new WeakMap();
1160
+ function actionsNodeOf(registry) {
1161
+ return registry ? NODES.get(registry) ?? null : null;
1045
1162
  }
1046
1163
  function ActionsProvider({ children }) {
1047
- const actionsRef = useRef(/* @__PURE__ */ new Map());
1048
- const liveAction = (id) => actionsRef.current.get(id)?.at(-1);
1049
- const versionRef = useRef(0);
1050
- const cachedRef = useRef([]);
1051
- const cachedVerRef = useRef(-1);
1052
- const listenersRef = useRef(/* @__PURE__ */ new Set());
1053
1164
  const depReg = useOptionalDepRegistry();
1054
1165
  const depRegRef = useRef(depReg);
1055
1166
  depRegRef.current = depReg;
1056
- const warnedRef = useRef(false);
1057
- const wiredDepRegRef = useRef([]);
1058
- const dispatcherRef = useRef([]);
1059
- const store = useMemo(() => {
1060
- const snapshot = () => {
1061
- const v = versionRef.current;
1062
- if (cachedVerRef.current === v) return cachedRef.current;
1063
- const out = Object.freeze(
1064
- Array.from(actionsRef.current.values(), (stack) => stack[stack.length - 1])
1065
- );
1066
- cachedRef.current = out;
1067
- cachedVerRef.current = v;
1068
- return out;
1069
- };
1070
- const notify = () => {
1071
- for (const l of listenersRef.current) {
1072
- try {
1073
- l();
1074
- } catch (err) {
1075
- console.error("weasel ActionsRegistry: subscriber threw", err);
1076
- }
1077
- }
1078
- };
1079
- return {
1080
- register: (action) => {
1081
- validateActionId(action.id);
1082
- validateActionDefaultBinding(action);
1083
- const stack = actionsRef.current.get(action.id);
1084
- if (stack) stack.push(action);
1085
- else actionsRef.current.set(action.id, [action]);
1086
- versionRef.current++;
1087
- notify();
1088
- let released = false;
1089
- return () => {
1090
- if (released) return;
1091
- released = true;
1092
- const cur = actionsRef.current.get(action.id);
1093
- if (!cur) return;
1094
- const i = cur.lastIndexOf(action);
1095
- if (i === -1) return;
1096
- cur.splice(i, 1);
1097
- if (cur.length === 0) actionsRef.current.delete(action.id);
1098
- versionRef.current++;
1099
- notify();
1100
- };
1101
- },
1102
- unregister: (id) => {
1103
- if (actionsRef.current.delete(id)) {
1104
- versionRef.current++;
1105
- notify();
1106
- }
1107
- },
1108
- list: () => snapshot(),
1109
- trigger: (id, params) => {
1110
- const a = liveAction(id);
1111
- if (!a) return false;
1112
- try {
1113
- if (a.invoker && a.invoker.timing === "immediate") {
1114
- const r = wiredDepRegRef.current.at(-1) ?? depRegRef.current;
1115
- const deps = !r ? {} : a.requires ? buildDepsFromRequires(a, r) : {
1116
- selection: r.get("selection"),
1117
- scene: r.get("scene"),
1118
- history: r.get("history"),
1119
- view: r.get("view"),
1120
- activeTool: r.get("activeTool"),
1121
- booleansAdapter: r.get("booleansAdapter")
1122
- };
1123
- a.invoker.run(deps, params);
1124
- }
1125
- } catch (err) {
1126
- console.error(`weasel ActionsRegistry: action "${id}" threw`, err);
1127
- }
1128
- return true;
1129
- },
1130
- subscribe: (listener) => {
1131
- listenersRef.current.add(listener);
1132
- return () => {
1133
- listenersRef.current.delete(listener);
1134
- };
1135
- },
1136
- setDispatcher: (d) => {
1137
- if (d && dispatcherRef.current.length > 0 && dispatcherRef.current.at(-1) !== d && !warnedRef.current) {
1138
- warnedRef.current = true;
1139
- warnSharedScope();
1140
- }
1141
- return pushOwner(dispatcherRef, d);
1142
- },
1143
- setDepRegistry: (r) => pushOwner(wiredDepRegRef, r),
1144
- begin: (id, params) => {
1145
- const disp = dispatcherRef.current.at(-1) ?? null;
1146
- if (!disp) return null;
1147
- const r = wiredDepRegRef.current.at(-1) ?? depRegRef.current;
1148
- const a = liveAction(id);
1149
- const deps = !r ? {} : a?.requires ? buildDepsFromRequires(a, r) : {
1150
- selection: r.get("selection"),
1151
- scene: r.get("scene"),
1152
- history: r.get("history"),
1153
- view: r.get("view"),
1154
- activeTool: r.get("activeTool"),
1155
- booleansAdapter: r.get("booleansAdapter")
1156
- };
1157
- return disp.beginUiOngoing(id, deps, params);
1158
- }
1159
- };
1160
- }, []);
1161
- const registry = useMuted(store);
1162
- return /* @__PURE__ */ jsx(ActionsContext.Provider, { value: registry, children });
1163
- }
1164
- function warnSharedScope() {
1165
- if (!IS_DEV) return;
1166
- console.warn(
1167
- "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>."
1168
- );
1167
+ const node = useMemo(() => new ActionsNode(null, true, () => depRegRef.current), []);
1168
+ return /* @__PURE__ */ jsx(ActionsContext.Provider, { value: node.registry, children });
1169
1169
  }
1170
1170
  var IS_DEV = (() => {
1171
1171
  try {
@@ -1191,6 +1191,41 @@ function useAction(action) {
1191
1191
  return reg.register(action);
1192
1192
  }, [reg, action]);
1193
1193
  }
1194
+ function useNodes(descends, ownsTool, parent) {
1195
+ const parentActions = useActionsRegistry();
1196
+ const parentDeps = useOptionalDepRegistry();
1197
+ const parentTools = useContext(ActiveToolNodeContext);
1198
+ const pa = parent ? parent.actions : actionsNodeOf(parentActions);
1199
+ const pd = parent ? parent.deps : depNodeOf(parentDeps);
1200
+ const pt = parent ? parent.tools : parentTools;
1201
+ const nodes = useMemo(() => {
1202
+ const deps = new DepNode(pd, descends);
1203
+ const tools = new ToolNode(pt, descends, ownsTool, null);
1204
+ const actions = new ActionsNode(pa, descends, () => deps.registry);
1205
+ actions.onActivate.push(() => deps.activate(), () => tools.activate());
1206
+ return { actions, deps, tools };
1207
+ }, [pa, pd, pt, descends, ownsTool]);
1208
+ useEffect(() => {
1209
+ const leave = [nodes.deps.mount(), nodes.tools.mount(), nodes.actions.mount()];
1210
+ return () => {
1211
+ for (const f of leave) f();
1212
+ };
1213
+ }, [nodes]);
1214
+ return nodes;
1215
+ }
1216
+ function Provide({ nodes, children }) {
1217
+ 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 }) }) });
1218
+ }
1219
+ function InputScope({ yoke, children }) {
1220
+ const nodes = useNodes(false, yoke === void 0, yoke);
1221
+ return /* @__PURE__ */ jsx(Provide, { nodes, children });
1222
+ }
1223
+ function useYoke() {
1224
+ return useNodes(true, true, void 0);
1225
+ }
1226
+ function Yoke({ value, children }) {
1227
+ return /* @__PURE__ */ jsx(Provide, { nodes: value, children });
1228
+ }
1194
1229
  function useOngoingAction(actionId) {
1195
1230
  const reg = useActionsRegistry();
1196
1231
  const regRef = useRef(reg);
@@ -1232,14 +1267,14 @@ function useContributions(opts) {
1232
1267
  const needsFirstMountSyncRef = useRef(false);
1233
1268
  if (isFirstRender) {
1234
1269
  hasInitializedRef.current = true;
1235
- needsFirstMountSyncRef.current = ctx.active === "select" && opts.focused !== "select";
1270
+ needsFirstMountSyncRef.current = ctx.active === null && opts.focused !== null;
1236
1271
  }
1237
1272
  useEffect(() => {
1238
1273
  if (!needsFirstMountSyncRef.current) return;
1239
1274
  needsFirstMountSyncRef.current = false;
1240
1275
  ctx.setActive(opts.focused);
1241
1276
  }, []);
1242
- const focused = isFirstRender && ctx.active === "select" && opts.focused !== "select" ? opts.focused : ctx.active;
1277
+ const focused = isFirstRender && ctx.active === null && opts.focused !== null ? opts.focused : ctx.active;
1243
1278
  const entriesRef = useRef(opts.entries);
1244
1279
  entriesRef.current = opts.entries;
1245
1280
  const focusedRef = useRef(focused);
@@ -1285,21 +1320,15 @@ function useContributions(opts) {
1285
1320
  }
1286
1321
  return [...active, ...hotkey, ...ambient];
1287
1322
  }, [snapshot]);
1288
- const offhandSig = opts.entries.map((e) => e.eligibility?.offhand ? `${e.id}:${e.eligibility.offhand}` : "").filter(Boolean).join(",");
1289
- useEffect(() => {
1290
- if (!actionsRegistry || offhandSig === "") return;
1291
- const specs = offhandSig.split(",").map((entry) => {
1292
- const [toolId, trigger] = entry.split(":");
1293
- return { toolId, key: offhandKeyFor(trigger) };
1294
- });
1295
- return actionsRegistry.register(makeToolOffhandAction(buildToolOffhandBindings(specs)));
1296
- }, [actionsRegistry, offhandSig]);
1323
+ useOffhandAction(opts.entries);
1297
1324
  const lastConflictSigRef = useRef(null);
1298
1325
  const entrySig = opts.entries.map((e) => e.id).join(",");
1326
+ const modesRegistry = opts.modes;
1299
1327
  useEffect(() => {
1300
1328
  if (!isDev()) return;
1301
- if (lastConflictSigRef.current === entrySig) return;
1302
- lastConflictSigRef.current = entrySig;
1329
+ const last = lastConflictSigRef.current;
1330
+ if (last?.sig === entrySig && last.modes === modesRegistry) return;
1331
+ lastConflictSigRef.current = { sig: entrySig, modes: modesRegistry };
1303
1332
  const registry = [];
1304
1333
  const ambient = [];
1305
1334
  for (const entry of entriesRef.current) {
@@ -1309,9 +1338,10 @@ function useContributions(opts) {
1309
1338
  reportRouteConflicts({
1310
1339
  registry,
1311
1340
  ambient,
1312
- actions: actionsRegistry?.list() ?? []
1341
+ actions: actionsRegistry?.list() ?? [],
1342
+ ...modesRegistry ? { modes: modesRegistry.list().map(activeModeOf) } : {}
1313
1343
  });
1314
- }, [entrySig, actionsRegistry]);
1344
+ }, [entrySig, actionsRegistry, modesRegistry]);
1315
1345
  return useMemo(
1316
1346
  () => ({
1317
1347
  get entries() {
@@ -1325,6 +1355,16 @@ function useContributions(opts) {
1325
1355
  [focused, setFocused, scopedBindings, overlays]
1326
1356
  );
1327
1357
  }
1358
+ function useOffhandAction(entries) {
1359
+ const actionsRegistry = useActionsRegistry();
1360
+ const offhandSig = JSON.stringify(entries.filter((e) => e.eligibility?.offhand).map((e) => [e.id, e.eligibility.offhand]));
1361
+ useEffect(() => {
1362
+ const pairs = JSON.parse(offhandSig);
1363
+ if (!actionsRegistry || pairs.length === 0) return;
1364
+ const specs = pairs.map(([toolId, trigger]) => ({ toolId, key: offhandKeyFor(trigger) }));
1365
+ return actionsRegistry.register(makeToolOffhandAction(buildToolOffhandBindings(specs)));
1366
+ }, [actionsRegistry, offhandSig]);
1367
+ }
1328
1368
 
1329
1369
  // src/tools/useTools.ts
1330
1370
  function declareSlot(tool, slot) {
@@ -1336,6 +1376,24 @@ function declareSlot(tool, slot) {
1336
1376
  };
1337
1377
  return sameEligibility(tool.eligibility, eligibility) ? tool : { ...tool, eligibility };
1338
1378
  }
1379
+ var NO_AMBIENT = [];
1380
+ function useStableSources(registry, ambient) {
1381
+ const next = { registry, ambient: ambient ?? NO_AMBIENT };
1382
+ const ref = useRef(next);
1383
+ const prev = ref.current;
1384
+ if (prev !== next && !(sameRecord(prev.registry, next.registry) && sameList(prev.ambient, next.ambient))) {
1385
+ ref.current = next;
1386
+ }
1387
+ return ref.current;
1388
+ }
1389
+ function sameRecord(a, b) {
1390
+ const ak = Object.keys(a);
1391
+ if (ak.length !== Object.keys(b).length) return false;
1392
+ return ak.every((k) => k in b && a[k] === b[k]);
1393
+ }
1394
+ function sameList(a, b) {
1395
+ return a.length === b.length && a.every((t, i) => t === b[i]);
1396
+ }
1339
1397
  function sameEligibility(a, b) {
1340
1398
  if (!a) return false;
1341
1399
  const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
@@ -1343,16 +1401,18 @@ function sameEligibility(a, b) {
1343
1401
  return true;
1344
1402
  }
1345
1403
  function useTools(opts) {
1346
- if (!(opts.active in opts.registry)) {
1347
- throw new Error(`useTools: active "${opts.active}" not in registry`);
1404
+ const initialActive = opts.active ?? null;
1405
+ if (initialActive !== null && !(initialActive in opts.registry)) {
1406
+ throw new Error(`useTools: active "${initialActive}" not in registry`);
1348
1407
  }
1349
1408
  const ctx = useActiveToolContext();
1409
+ const sources = useStableSources(opts.registry, opts.ambient);
1350
1410
  const slotted = useMemo(() => {
1351
1411
  const registry = {};
1352
- for (const [id, tool] of Object.entries(opts.registry)) {
1412
+ for (const [id, tool] of Object.entries(sources.registry)) {
1353
1413
  registry[id] = declareSlot(tool, "focus");
1354
1414
  }
1355
- const ambient = (opts.ambient ?? []).map((t) => declareSlot(t, "always"));
1415
+ const ambient = sources.ambient.map((t) => declareSlot(t, "always"));
1356
1416
  const byId = /* @__PURE__ */ new Map();
1357
1417
  for (const tool of [...Object.values(registry), ...ambient]) {
1358
1418
  const prior = byId.get(tool.id);
@@ -1360,8 +1420,12 @@ function useTools(opts) {
1360
1420
  else byId.set(tool.id, tool);
1361
1421
  }
1362
1422
  return { registry, ambient, entries: [...byId.values()] };
1363
- }, [opts.registry, opts.ambient]);
1364
- const contributions = useContributions({ entries: slotted.entries, focused: opts.active });
1423
+ }, [sources]);
1424
+ const contributions = useContributions({
1425
+ entries: slotted.entries,
1426
+ focused: initialActive,
1427
+ ...opts.modes ? { modes: opts.modes } : {}
1428
+ });
1365
1429
  const hotkeyEngaged = ctx.hotkeyStack.at(-1) ?? null;
1366
1430
  const slottedRef = useRef(slotted);
1367
1431
  slottedRef.current = slotted;
@@ -1372,7 +1436,7 @@ function useTools(opts) {
1372
1436
  const setFocused = contributions.setFocused;
1373
1437
  const setActive = useCallback(
1374
1438
  (id) => {
1375
- if (!(id in slottedRef.current.registry)) {
1439
+ if (id !== null && !(id in slottedRef.current.registry)) {
1376
1440
  throw new Error(`setActive: "${id}" not in registry`);
1377
1441
  }
1378
1442
  dlog("tools", "active:", activeRef.current, "\u2192", id);
@@ -1400,7 +1464,6 @@ function useTools(opts) {
1400
1464
  );
1401
1465
  const active = contributions.focused;
1402
1466
  const getActiveOverlays = contributions.overlays;
1403
- const registryKey = Object.keys(slotted.registry).sort().join("|") + "#" + slotted.ambient.map((t) => t.id).join("|");
1404
1467
  return useMemo(
1405
1468
  () => ({
1406
1469
  active,
@@ -1408,18 +1471,15 @@ function useTools(opts) {
1408
1471
  hotkeyEngaged,
1409
1472
  engageHotkey,
1410
1473
  disengageHotkey,
1411
- ambient: slottedRef.current.ambient,
1412
- registry: slottedRef.current.registry,
1474
+ ambient: slotted.ambient,
1475
+ registry: slotted.registry,
1413
1476
  has,
1414
1477
  getActiveOverlays
1415
1478
  }),
1416
- // `registryKey` isn't read in the body on purpose: the body reads
1417
- // `slottedRef.current`, and the key is what tells us that ref's contents moved.
1418
- // eslint-disable-next-line react-hooks/exhaustive-deps
1419
- [active, setActive, hotkeyEngaged, engageHotkey, disengageHotkey, has, getActiveOverlays, registryKey]
1479
+ [active, setActive, hotkeyEngaged, engageHotkey, disengageHotkey, has, getActiveOverlays, slotted]
1420
1480
  );
1421
1481
  }
1422
1482
 
1423
- export { ActionsProvider, ActionsScope, ActiveToolContextProvider, ActiveToolContextProviderIfRoot, DepRegistryProvider, useAction, useActionsRegistry, useActiveToolContext, useContributions, useDepRegistry, useDepSource, useGestureDispatcher, useOngoingAction, useOptionalActiveToolContext, useOptionalDepRegistry, useTools };
1483
+ export { ActionsProvider, ActiveToolContextProvider, ActiveToolContextProviderIfRoot, DepRegistryProvider, InputScope, Yoke, useAction, useActionsRegistry, useActiveToolContext, useContributions, useDepRegistry, useDepSource, useGestureDispatcher, useOffhandAction, useOngoingAction, useOptionalActiveToolContext, useOptionalDepRegistry, useTools, useYoke };
1424
1484
  //# sourceMappingURL=react.js.map
1425
1485
  //# sourceMappingURL=react.js.map