@weasel-js/routing 1.6.1 → 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-VEC3ACV5.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;
@@ -249,7 +287,8 @@ function useGestureDispatcher(opts) {
249
287
  if (before > 0 || after > 0) requestRedrawRef.current?.();
250
288
  return result;
251
289
  };
252
- const heldKeys = /* @__PURE__ */ new Set();
290
+ const heldKeys = /* @__PURE__ */ new Map();
291
+ const physicalKey = (e) => e.code || e.key.toLowerCase();
253
292
  const held = /* @__PURE__ */ new Map();
254
293
  const toCanvasLocal = (p) => {
255
294
  const rect = canvas?.getBoundingClientRect();
@@ -273,7 +312,6 @@ function useGestureDispatcher(opts) {
273
312
  };
274
313
  let multiTouchStart = null;
275
314
  const TAP_THRESHOLD_PX = 8;
276
- const DRAG_THRESHOLD_PX = 4;
277
315
  const LONG_PRESS_MS = 500;
278
316
  const longPressTimers = /* @__PURE__ */ new Map();
279
317
  const cancelLongPress = (pointerId) => {
@@ -322,8 +360,9 @@ function useGestureDispatcher(opts) {
322
360
  const DOUBLE_CLICK_MAX_MS = 600;
323
361
  const DOUBLE_CLICK_MAX_PX = 8;
324
362
  const onKeyDown = (e) => {
325
- if (isEditableTarget(e.target)) return;
326
- 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))) {
327
366
  e.preventDefault();
328
367
  return;
329
368
  }
@@ -348,17 +387,19 @@ function useGestureDispatcher(opts) {
348
387
  };
349
388
  const heldResult = dispatch(heldEv);
350
389
  if (heldResult === "handled") {
351
- heldKeys.add(e.key);
390
+ heldKeys.set(physicalKey(e), e.key);
352
391
  }
353
392
  if (keyResult === "handled" || heldResult === "handled") {
354
393
  e.preventDefault();
355
394
  }
356
395
  };
357
396
  const onKeyUp = (e) => {
358
- if (!heldKeys.has(e.key)) return;
397
+ const physical = physicalKey(e);
398
+ const downKey = heldKeys.get(physical);
399
+ if (downKey === void 0) return;
359
400
  const ev = {
360
401
  kind: "key-held",
361
- key: e.key,
402
+ key: downKey,
362
403
  phase: "up",
363
404
  altKey: e.altKey,
364
405
  ctrlKey: e.ctrlKey,
@@ -366,12 +407,12 @@ function useGestureDispatcher(opts) {
366
407
  shiftKey: e.shiftKey
367
408
  };
368
409
  dispatch(ev);
369
- heldKeys.delete(e.key);
410
+ heldKeys.delete(physical);
370
411
  };
371
412
  const onWindowBlur = () => {
372
413
  endPinch();
373
414
  for (const p of [...held.values()]) p.session.cancel();
374
- for (const key of heldKeys) {
415
+ for (const key of heldKeys.values()) {
375
416
  const ev = {
376
417
  kind: "key-held",
377
418
  key,
@@ -386,6 +427,7 @@ function useGestureDispatcher(opts) {
386
427
  heldKeys.clear();
387
428
  };
388
429
  const onWheel = (e) => {
430
+ registryRef.current.activate();
389
431
  if (pinchLive && e.ctrlKey) {
390
432
  e.preventDefault();
391
433
  e.stopPropagation();
@@ -469,6 +511,7 @@ function useGestureDispatcher(opts) {
469
511
  endPinch();
470
512
  };
471
513
  const onPointerDown = (e) => {
514
+ registryRef.current.activate();
472
515
  if ((e.button ?? 0) !== 0) return;
473
516
  if (!canvas) return;
474
517
  routeDown(e.pointerId, e.clientX, e.clientY);
@@ -561,9 +604,7 @@ function useGestureDispatcher(opts) {
561
604
  if (rec) rec.pos = { x: e.clientX, y: e.clientY };
562
605
  const buffered = rec?.buffered;
563
606
  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) {
607
+ if (pastDragThreshold(buffered, e)) {
567
608
  rec.buffered = null;
568
609
  cancelLongPress(e.pointerId);
569
610
  dispatch(buffered.ev);
@@ -671,10 +712,17 @@ function useGestureDispatcher(opts) {
671
712
  ...bodyKind !== void 0 ? { bodyKind } : {}
672
713
  };
673
714
  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;
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;
678
726
  const cursor = dragCursor ?? dispatcherNow().resolveOnly(
679
727
  { kind: "click", x: w.x, y: w.y, pressX: w.x, pressY: w.y, ...where },
680
728
  ctx
@@ -700,6 +748,7 @@ function useGestureDispatcher(opts) {
700
748
  };
701
749
  const onHoverPointerLeave = () => {
702
750
  lastHover = null;
751
+ publishLiveDispatch(null);
703
752
  paintedCursorRef.current?.()?.clearPointer();
704
753
  clearHoverCursor();
705
754
  };
@@ -912,9 +961,10 @@ function useGestureDispatcher(opts) {
912
961
  });
913
962
  if (result === "handled") e.preventDefault();
914
963
  };
964
+ const keyCapture = keyboard === "first";
915
965
  if (keyboard) {
916
- window.addEventListener("keydown", onKeyDown);
917
- window.addEventListener("keyup", onKeyUp);
966
+ window.addEventListener("keydown", onKeyDown, keyCapture);
967
+ window.addEventListener("keyup", onKeyUp, keyCapture);
918
968
  }
919
969
  window.addEventListener("blur", onWindowBlur);
920
970
  window.addEventListener("keydown", scheduleHoverCursorRefresh);
@@ -940,8 +990,8 @@ function useGestureDispatcher(opts) {
940
990
  }
941
991
  return () => {
942
992
  if (keyboard) {
943
- window.removeEventListener("keydown", onKeyDown);
944
- window.removeEventListener("keyup", onKeyUp);
993
+ window.removeEventListener("keydown", onKeyDown, keyCapture);
994
+ window.removeEventListener("keyup", onKeyUp, keyCapture);
945
995
  }
946
996
  window.removeEventListener("blur", onWindowBlur);
947
997
  window.removeEventListener("keydown", scheduleHoverCursorRefresh);
@@ -970,203 +1020,152 @@ function useGestureDispatcher(opts) {
970
1020
  }, [enabled, keyboard, canvasRef, pointerChannel, wheelChannel, pinchChannel, contextMenuChannel, ingestChannel]);
971
1021
  }
972
1022
  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);
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);
986
1135
  }
1136
+ } catch (err) {
1137
+ console.error(`weasel ActionsRegistry: action "${id}" threw`, err);
987
1138
  }
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);
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()
1045
1157
  };
1158
+ };
1159
+ var NODES = /* @__PURE__ */ new WeakMap();
1160
+ function actionsNodeOf(registry) {
1161
+ return registry ? NODES.get(registry) ?? null : null;
1046
1162
  }
1047
1163
  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
1164
  const depReg = useOptionalDepRegistry();
1055
1165
  const depRegRef = useRef(depReg);
1056
1166
  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
- );
1167
+ const node = useMemo(() => new ActionsNode(null, true, () => depRegRef.current), []);
1168
+ return /* @__PURE__ */ jsx(ActionsContext.Provider, { value: node.registry, children });
1170
1169
  }
1171
1170
  var IS_DEV = (() => {
1172
1171
  try {
@@ -1192,6 +1191,41 @@ function useAction(action) {
1192
1191
  return reg.register(action);
1193
1192
  }, [reg, action]);
1194
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
+ }
1195
1229
  function useOngoingAction(actionId) {
1196
1230
  const reg = useActionsRegistry();
1197
1231
  const regRef = useRef(reg);
@@ -1233,14 +1267,14 @@ function useContributions(opts) {
1233
1267
  const needsFirstMountSyncRef = useRef(false);
1234
1268
  if (isFirstRender) {
1235
1269
  hasInitializedRef.current = true;
1236
- needsFirstMountSyncRef.current = ctx.active === "select" && opts.focused !== "select";
1270
+ needsFirstMountSyncRef.current = ctx.active === null && opts.focused !== null;
1237
1271
  }
1238
1272
  useEffect(() => {
1239
1273
  if (!needsFirstMountSyncRef.current) return;
1240
1274
  needsFirstMountSyncRef.current = false;
1241
1275
  ctx.setActive(opts.focused);
1242
1276
  }, []);
1243
- 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;
1244
1278
  const entriesRef = useRef(opts.entries);
1245
1279
  entriesRef.current = opts.entries;
1246
1280
  const focusedRef = useRef(focused);
@@ -1286,21 +1320,15 @@ function useContributions(opts) {
1286
1320
  }
1287
1321
  return [...active, ...hotkey, ...ambient];
1288
1322
  }, [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]);
1323
+ useOffhandAction(opts.entries);
1298
1324
  const lastConflictSigRef = useRef(null);
1299
1325
  const entrySig = opts.entries.map((e) => e.id).join(",");
1326
+ const modesRegistry = opts.modes;
1300
1327
  useEffect(() => {
1301
1328
  if (!isDev()) return;
1302
- if (lastConflictSigRef.current === entrySig) return;
1303
- lastConflictSigRef.current = entrySig;
1329
+ const last = lastConflictSigRef.current;
1330
+ if (last?.sig === entrySig && last.modes === modesRegistry) return;
1331
+ lastConflictSigRef.current = { sig: entrySig, modes: modesRegistry };
1304
1332
  const registry = [];
1305
1333
  const ambient = [];
1306
1334
  for (const entry of entriesRef.current) {
@@ -1310,9 +1338,10 @@ function useContributions(opts) {
1310
1338
  reportRouteConflicts({
1311
1339
  registry,
1312
1340
  ambient,
1313
- actions: actionsRegistry?.list() ?? []
1341
+ actions: actionsRegistry?.list() ?? [],
1342
+ ...modesRegistry ? { modes: modesRegistry.list().map(activeModeOf) } : {}
1314
1343
  });
1315
- }, [entrySig, actionsRegistry]);
1344
+ }, [entrySig, actionsRegistry, modesRegistry]);
1316
1345
  return useMemo(
1317
1346
  () => ({
1318
1347
  get entries() {
@@ -1326,6 +1355,16 @@ function useContributions(opts) {
1326
1355
  [focused, setFocused, scopedBindings, overlays]
1327
1356
  );
1328
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
+ }
1329
1368
 
1330
1369
  // src/tools/useTools.ts
1331
1370
  function declareSlot(tool, slot) {
@@ -1337,6 +1376,24 @@ 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
+ 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
+ }
1340
1397
  function sameEligibility(a, b) {
1341
1398
  if (!a) return false;
1342
1399
  const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
@@ -1344,16 +1401,18 @@ function sameEligibility(a, b) {
1344
1401
  return true;
1345
1402
  }
1346
1403
  function useTools(opts) {
1347
- if (!(opts.active in opts.registry)) {
1348
- 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`);
1349
1407
  }
1350
1408
  const ctx = useActiveToolContext();
1409
+ const sources = useStableSources(opts.registry, opts.ambient);
1351
1410
  const slotted = useMemo(() => {
1352
1411
  const registry = {};
1353
- for (const [id, tool] of Object.entries(opts.registry)) {
1412
+ for (const [id, tool] of Object.entries(sources.registry)) {
1354
1413
  registry[id] = declareSlot(tool, "focus");
1355
1414
  }
1356
- const ambient = (opts.ambient ?? []).map((t) => declareSlot(t, "always"));
1415
+ const ambient = sources.ambient.map((t) => declareSlot(t, "always"));
1357
1416
  const byId = /* @__PURE__ */ new Map();
1358
1417
  for (const tool of [...Object.values(registry), ...ambient]) {
1359
1418
  const prior = byId.get(tool.id);
@@ -1361,8 +1420,12 @@ function useTools(opts) {
1361
1420
  else byId.set(tool.id, tool);
1362
1421
  }
1363
1422
  return { registry, ambient, entries: [...byId.values()] };
1364
- }, [opts.registry, opts.ambient]);
1365
- 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
+ });
1366
1429
  const hotkeyEngaged = ctx.hotkeyStack.at(-1) ?? null;
1367
1430
  const slottedRef = useRef(slotted);
1368
1431
  slottedRef.current = slotted;
@@ -1373,7 +1436,7 @@ function useTools(opts) {
1373
1436
  const setFocused = contributions.setFocused;
1374
1437
  const setActive = useCallback(
1375
1438
  (id) => {
1376
- if (!(id in slottedRef.current.registry)) {
1439
+ if (id !== null && !(id in slottedRef.current.registry)) {
1377
1440
  throw new Error(`setActive: "${id}" not in registry`);
1378
1441
  }
1379
1442
  dlog("tools", "active:", activeRef.current, "\u2192", id);
@@ -1401,7 +1464,6 @@ function useTools(opts) {
1401
1464
  );
1402
1465
  const active = contributions.focused;
1403
1466
  const getActiveOverlays = contributions.overlays;
1404
- const registryKey = Object.keys(slotted.registry).sort().join("|") + "#" + slotted.ambient.map((t) => t.id).join("|");
1405
1467
  return useMemo(
1406
1468
  () => ({
1407
1469
  active,
@@ -1409,18 +1471,15 @@ function useTools(opts) {
1409
1471
  hotkeyEngaged,
1410
1472
  engageHotkey,
1411
1473
  disengageHotkey,
1412
- ambient: slottedRef.current.ambient,
1413
- registry: slottedRef.current.registry,
1474
+ ambient: slotted.ambient,
1475
+ registry: slotted.registry,
1414
1476
  has,
1415
1477
  getActiveOverlays
1416
1478
  }),
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]
1479
+ [active, setActive, hotkeyEngaged, engageHotkey, disengageHotkey, has, getActiveOverlays, slotted]
1421
1480
  );
1422
1481
  }
1423
1482
 
1424
- 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 };
1425
1484
  //# sourceMappingURL=react.js.map
1426
1485
  //# sourceMappingURL=react.js.map