@weasel-js/routing 1.9.2 → 1.9.4

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,8 +1,8 @@
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-RTCD26NN.js';
1
+ import { createDepRegistry, createDispatcher, isTool, assembleEntries, liveScope, isDev, reportRouteConflicts, offhandKeyFor, makeToolOffhandAction, buildToolOffhandBindings, dlog, openPointerSession, LONG_PRESS_MS, publishLiveDispatch, pointerGestureId, ScopeNode, pushOwner, validateActionId, validateActionDefaultBinding, depNodeOf, DepNode, clientToCanvasRect, buildDepsFromRequires, isEditableTarget, activatesFocusedControl, itemsFromClipboardData, itemsFromDataTransfer } from './chunk-Y5RURZBH.js';
2
2
  import { createContext, useState, useContext, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } from 'react';
3
3
  import { useLatest, useStableByContent, sameList } from '@weasel-js/react';
4
4
  import { jsx, Fragment } from 'react/jsx-runtime';
5
- import { isInControlWithin, pastDragThreshold } from '@weasel-js/gestures';
5
+ import { isApplePlatform, isInControlWithin, pastDragThreshold } from '@weasel-js/gestures';
6
6
  import { resolveCursorTier } from '@weasel-js/cursor';
7
7
  import { activeModeOf } from '@weasel-js/modes';
8
8
 
@@ -115,19 +115,8 @@ function useDepSource(name, source) {
115
115
  return registry.register(name, () => sourceRef.current());
116
116
  }, [name, registry, sourceRef]);
117
117
  }
118
- var DROPOVER_CLASS = "weasel-dropover";
119
- function stylusOf(e) {
120
- return {
121
- ...typeof e.pressure === "number" ? { pressure: e.pressure } : {},
122
- ...typeof e.tiltX === "number" ? { tiltX: e.tiltX } : {},
123
- ...typeof e.tiltY === "number" ? { tiltY: e.tiltY } : {}
124
- };
125
- }
126
- var IS_MAC = typeof navigator !== "undefined" && /mac/i.test(
127
- // `||`, not `??`: jsdom (and some embedders) report an empty-string
128
- // platform, which is not nullish and would swallow the UA fallback.
129
- navigator.platform || navigator.userAgent
130
- );
118
+
119
+ // src/interactions/dispatcher/dispatchCore.ts
131
120
  function withViewDeps(base, overlay) {
132
121
  return {
133
122
  register: (name, source) => base().register(name, source),
@@ -138,6 +127,297 @@ function withViewDeps(base, overlay) {
138
127
  }
139
128
  };
140
129
  }
130
+
131
+ // src/interactions/dispatcher/channels/contextMenu.ts
132
+ function contextMenuInput(core) {
133
+ const { routeAt, toWorld, target, dispatch } = core;
134
+ const onContextMenu = (e) => {
135
+ e.preventDefault();
136
+ routeAt(null, e.clientX, e.clientY);
137
+ const w = toWorld(e.clientX, e.clientY);
138
+ const affordance = target().affordanceAt?.(w) ?? void 0;
139
+ const menuBody = target().classifyTarget?.(w);
140
+ const ev = {
141
+ kind: "contextmenu",
142
+ target: e.target,
143
+ altKey: e.altKey,
144
+ ctrlKey: e.ctrlKey,
145
+ metaKey: e.metaKey,
146
+ shiftKey: e.shiftKey,
147
+ x: w.x,
148
+ y: w.y,
149
+ clientX: e.clientX,
150
+ clientY: e.clientY,
151
+ ...affordance !== void 0 ? { affordance } : {},
152
+ ...menuBody?.body !== void 0 ? { bodyTarget: menuBody.body } : {},
153
+ ...menuBody?.kind !== void 0 ? { bodyKind: menuBody.kind } : {}
154
+ };
155
+ dispatch(ev);
156
+ };
157
+ return onContextMenu;
158
+ }
159
+
160
+ // src/interactions/dispatcher/channels/ingest.ts
161
+ var DROPOVER_CLASS = "weasel-dropover";
162
+ function ingestInput(core) {
163
+ const { canvas, routeAt, toWorld, dispatch } = core;
164
+ const onDragOver = (e) => {
165
+ if (!e.dataTransfer) return;
166
+ e.preventDefault();
167
+ e.dataTransfer.dropEffect = "copy";
168
+ canvas?.classList.add(DROPOVER_CLASS);
169
+ };
170
+ const onDragLeave = () => {
171
+ canvas?.classList.remove(DROPOVER_CLASS);
172
+ };
173
+ const onDrop = (e) => {
174
+ canvas?.classList.remove(DROPOVER_CLASS);
175
+ const dt = e.dataTransfer;
176
+ if (!dt) return;
177
+ e.preventDefault();
178
+ routeAt(null, e.clientX, e.clientY);
179
+ const dropView = core.routedView();
180
+ const w = toWorld(e.clientX, e.clientY);
181
+ const base = {
182
+ altKey: e.altKey,
183
+ ctrlKey: e.ctrlKey,
184
+ metaKey: e.metaKey,
185
+ shiftKey: e.shiftKey
186
+ };
187
+ void itemsFromDataTransfer(dt).then((items) => {
188
+ if (core.disposed()) return;
189
+ if (items.length === 0) return;
190
+ const routedNow = core.routedView();
191
+ core.setRoutedView(dropView);
192
+ try {
193
+ dispatch({
194
+ kind: "drop",
195
+ items,
196
+ x: w.x,
197
+ y: w.y,
198
+ clientX: e.clientX,
199
+ clientY: e.clientY,
200
+ ...base
201
+ });
202
+ } finally {
203
+ core.setRoutedView(routedNow);
204
+ }
205
+ });
206
+ };
207
+ const onPaste = (e) => {
208
+ if (e.defaultPrevented || isEditableTarget(e.target)) return;
209
+ if (!core.holdsKeys()) return;
210
+ const cd = e.clipboardData;
211
+ if (!cd) return;
212
+ const items = itemsFromClipboardData(cd);
213
+ if (items.length === 0) return;
214
+ const result = dispatch({
215
+ kind: "paste",
216
+ items,
217
+ // ClipboardEvent carries no modifier state.
218
+ altKey: false,
219
+ ctrlKey: false,
220
+ metaKey: false,
221
+ shiftKey: false
222
+ });
223
+ if (result === "handled") e.preventDefault();
224
+ };
225
+ return { onDragOver, onDragLeave, onDrop, onPaste };
226
+ }
227
+
228
+ // src/interactions/dispatcher/channels/keyboard.ts
229
+ function keyboardInput(core) {
230
+ const { dispatch, dispatcherNow } = core;
231
+ const heldKeys = /* @__PURE__ */ new Map();
232
+ const codeOf = (e) => e.code ? { code: e.code } : {};
233
+ const physicalKey = (e) => e.code || e.key.toLowerCase();
234
+ const onKeyDown = (e) => {
235
+ if (e.defaultPrevented || isEditableTarget(e.target) || activatesFocusedControl(e)) return;
236
+ if (!core.holdsKeys()) return;
237
+ if (e.repeat && heldKeys.has(physicalKey(e))) {
238
+ e.preventDefault();
239
+ return;
240
+ }
241
+ const keyEv = {
242
+ kind: "key",
243
+ key: e.key,
244
+ ...codeOf(e),
245
+ altKey: e.altKey,
246
+ ctrlKey: e.ctrlKey,
247
+ metaKey: e.metaKey,
248
+ shiftKey: e.shiftKey,
249
+ repeat: e.repeat
250
+ };
251
+ const keyResult = dispatch(keyEv);
252
+ const heldEv = {
253
+ kind: "key-held",
254
+ key: e.key,
255
+ ...codeOf(e),
256
+ phase: "down",
257
+ altKey: e.altKey,
258
+ ctrlKey: e.ctrlKey,
259
+ metaKey: e.metaKey,
260
+ shiftKey: e.shiftKey
261
+ };
262
+ const heldOn = dispatcherNow();
263
+ const heldResult = dispatch(heldEv, heldOn);
264
+ if (heldResult === "handled") {
265
+ heldKeys.set(physicalKey(e), { key: e.key, ...codeOf(e), on: heldOn });
266
+ }
267
+ if (keyResult === "handled" || heldResult === "handled") {
268
+ e.preventDefault();
269
+ }
270
+ };
271
+ const onKeyUp = (e) => {
272
+ const physical = physicalKey(e);
273
+ const down = heldKeys.get(physical);
274
+ if (down === void 0) return;
275
+ const ev = {
276
+ kind: "key-held",
277
+ key: down.key,
278
+ ...down.code !== void 0 ? { code: down.code } : {},
279
+ phase: "up",
280
+ altKey: e.altKey,
281
+ ctrlKey: e.ctrlKey,
282
+ metaKey: e.metaKey,
283
+ shiftKey: e.shiftKey
284
+ };
285
+ heldKeys.delete(physical);
286
+ dispatch(ev, down.on);
287
+ };
288
+ const releaseAll = () => {
289
+ const releasing = [...heldKeys.values()];
290
+ heldKeys.clear();
291
+ for (const { key, code, on } of releasing) {
292
+ const ev = {
293
+ kind: "key-held",
294
+ key,
295
+ ...code !== void 0 ? { code } : {},
296
+ phase: "up",
297
+ altKey: false,
298
+ ctrlKey: false,
299
+ metaKey: false,
300
+ shiftKey: false
301
+ };
302
+ dispatch(ev, on);
303
+ }
304
+ };
305
+ return { onKeyDown, onKeyUp, releaseAll };
306
+ }
307
+
308
+ // src/interactions/dispatcher/channels/webkitPinch.ts
309
+ function webkitPinchInput(core) {
310
+ const { canvas, routeAt, toWorld, target, dispatch, dispatcherNow, ctxNow } = core;
311
+ let pinchLive = false;
312
+ let pinchScale = 1;
313
+ let pinchRotation = 0;
314
+ const endPinch = () => {
315
+ pinchLive = false;
316
+ pinchScale = 1;
317
+ pinchRotation = 0;
318
+ };
319
+ const pinchEventOf = (e, scale, rotation) => {
320
+ const g = e;
321
+ const rect = canvas?.getBoundingClientRect();
322
+ const cx = typeof g.clientX === "number" ? g.clientX : rect ? rect.left + rect.width / 2 : 0;
323
+ const cy = typeof g.clientY === "number" ? g.clientY : rect ? rect.top + rect.height / 2 : 0;
324
+ routeAt(null, cx, cy);
325
+ const [localX, localY] = rect ? clientToCanvasRect(rect, cx, cy) : [cx, cy];
326
+ const hitPoint = toWorld(cx, cy);
327
+ const affordance = target().affordanceAt?.(hitPoint) ?? void 0;
328
+ const body = target().classifyTarget?.(hitPoint);
329
+ return {
330
+ kind: "pinch",
331
+ altKey: g.altKey === true,
332
+ ctrlKey: g.ctrlKey === true,
333
+ metaKey: g.metaKey === true,
334
+ shiftKey: g.shiftKey === true,
335
+ scale,
336
+ rotation,
337
+ clientX: localX,
338
+ clientY: localY,
339
+ ...affordance !== void 0 ? { affordance } : {},
340
+ ...body?.body !== void 0 ? { bodyTarget: body.body } : {},
341
+ ...body?.kind !== void 0 ? { bodyKind: body.kind } : {}
342
+ };
343
+ };
344
+ const onGestureStart = (e) => {
345
+ endPinch();
346
+ const probe = pinchEventOf(e, 1, 0);
347
+ if (dispatcherNow().resolveOnly(probe, ctxNow()) === null) return;
348
+ pinchLive = true;
349
+ e.preventDefault();
350
+ };
351
+ const onGestureChange = (e) => {
352
+ const g = e;
353
+ const scale = typeof g.scale === "number" && g.scale > 0 ? g.scale : pinchScale;
354
+ const rotation = typeof g.rotation === "number" ? g.rotation : pinchRotation;
355
+ const step = scale / pinchScale;
356
+ const turn = rotation - pinchRotation;
357
+ pinchScale = scale;
358
+ pinchRotation = rotation;
359
+ if (dispatch(pinchEventOf(e, step, turn)) === "handled") {
360
+ pinchLive = true;
361
+ e.preventDefault();
362
+ } else if (pinchLive) {
363
+ e.preventDefault();
364
+ }
365
+ };
366
+ const onGestureEnd = (e) => {
367
+ if (pinchLive) e.preventDefault();
368
+ endPinch();
369
+ };
370
+ return { onGestureStart, onGestureChange, onGestureEnd, live: () => pinchLive, end: endPinch };
371
+ }
372
+
373
+ // src/interactions/dispatcher/channels/wheel.ts
374
+ function wheelInput(core, pinchLive) {
375
+ const { canvas, routeAt, toWorld, target, dispatch } = core;
376
+ const onWheel = (e) => {
377
+ core.activate();
378
+ if (pinchLive() && e.ctrlKey) {
379
+ e.preventDefault();
380
+ e.stopPropagation();
381
+ return;
382
+ }
383
+ routeAt(null, e.clientX, e.clientY);
384
+ const rect = canvas?.getBoundingClientRect();
385
+ const [localX, localY] = rect ? clientToCanvasRect(rect, e.clientX, e.clientY) : [e.clientX, e.clientY];
386
+ const hitPoint = toWorld(e.clientX, e.clientY);
387
+ const affordance = target().affordanceAt?.(hitPoint) ?? void 0;
388
+ const body = target().classifyTarget?.(hitPoint);
389
+ const ev = {
390
+ kind: "wheel",
391
+ altKey: e.altKey,
392
+ ctrlKey: e.ctrlKey,
393
+ metaKey: e.metaKey,
394
+ shiftKey: e.shiftKey,
395
+ deltaX: e.deltaX,
396
+ deltaY: e.deltaY,
397
+ clientX: localX,
398
+ clientY: localY,
399
+ ...affordance !== void 0 ? { affordance } : {},
400
+ ...body?.body !== void 0 ? { bodyTarget: body.body } : {},
401
+ ...body?.kind !== void 0 ? { bodyKind: body.kind } : {}
402
+ };
403
+ const result = dispatch(ev);
404
+ if (result === "handled") {
405
+ e.preventDefault();
406
+ e.stopImmediatePropagation();
407
+ }
408
+ };
409
+ return onWheel;
410
+ }
411
+
412
+ // src/interactions/dispatcher/useGestureDispatcher.tsx
413
+ function stylusOf(e) {
414
+ return {
415
+ ...typeof e.pressure === "number" ? { pressure: e.pressure } : {},
416
+ ...typeof e.tiltX === "number" ? { tiltX: e.tiltX } : {},
417
+ ...typeof e.tiltY === "number" ? { tiltY: e.tiltY } : {}
418
+ };
419
+ }
420
+ var IS_MAC = isApplePlatform();
141
421
  function computeMultiTouchGeometry(positions) {
142
422
  const pts = [...positions];
143
423
  if (pts.length < 2) {
@@ -156,7 +436,7 @@ function computeMultiTouchGeometry(positions) {
156
436
  return { centroid, spread };
157
437
  }
158
438
  function useGestureDispatcher(opts) {
159
- const { canvasRef, actions, entriesById, enabled = true, keyboard = true, affordanceAt, classifyTarget, dispatcher: dispatcherOpt, clientToWorld, requestRedraw, paintedCursor, getRuleCtx, onClick, onDoubleClick, views, channels, longPress } = opts;
439
+ const { canvasRef, actions, entriesById, enabled = true, keyboard = true, affordanceAt, classifyTarget, dispatcher: dispatcherOpt, clientToWorld, requestRedraw, paintedCursor, getRuleCtx, onClick, onDoubleClick, views, channels, longPress, claimKeys = false } = opts;
160
440
  const pointerChannel = channels?.pointer !== false;
161
441
  const wheelChannel = channels?.wheel !== false;
162
442
  const pinchChannel = channels?.pinch !== false;
@@ -199,13 +479,21 @@ function useGestureDispatcher(opts) {
199
479
  const liveToolsRef = useRef(/* @__PURE__ */ new Set());
200
480
  const prevActiveRef = useRef(activeTool.active);
201
481
  const entriesByIdRef = useLatest(entriesById);
482
+ const lifecycleScratchRef = useRef(/* @__PURE__ */ new Map());
202
483
  const fireLifecycle = useCallback((id, hook) => {
484
+ const scratches = lifecycleScratchRef.current;
203
485
  const tool = entriesByIdRef.current.get(id);
204
- if (!tool || !isTool(tool)) return;
486
+ if (!tool || !isTool(tool)) {
487
+ scratches.delete(id);
488
+ return;
489
+ }
490
+ if (hook === "onActivate" || !scratches.has(id)) scratches.set(id, tool.initScratch?.());
491
+ const scratch = scratches.get(id);
492
+ if (hook === "onDeactivate") scratches.delete(id);
205
493
  const fn = tool[hook];
206
494
  if (!fn) return;
207
495
  try {
208
- fn({ scratch: tool.initScratch?.() });
496
+ fn({ scratch });
209
497
  } catch (err) {
210
498
  console.error(`weasel: tool "${id}".${hook} threw`, err);
211
499
  }
@@ -228,6 +516,9 @@ function useGestureDispatcher(opts) {
228
516
  liveToolsRef.current = /* @__PURE__ */ new Set();
229
517
  for (const id of live) fireLifecycle(id, "onDeactivate");
230
518
  }, [fireLifecycle]);
519
+ useEffect(() => {
520
+ if (enabled && claimKeys) registryRef.current.activate();
521
+ }, [enabled, claimKeys, registryRef]);
231
522
  useEffect(() => {
232
523
  if (!enabled) return;
233
524
  let disposed = false;
@@ -267,8 +558,29 @@ function useGestureDispatcher(opts) {
267
558
  if (before > 0 || after > 0) requestRedrawRef.current?.();
268
559
  return result;
269
560
  };
270
- const heldKeys = /* @__PURE__ */ new Map();
271
- const physicalKey = (e) => e.code || e.key.toLowerCase();
561
+ const core = {
562
+ canvas,
563
+ target,
564
+ routeAt,
565
+ routedView: () => routedId,
566
+ setRoutedView: (id) => {
567
+ routedId = id;
568
+ },
569
+ dispatcherNow,
570
+ ctxNow,
571
+ toWorld,
572
+ dispatch,
573
+ holdsKeys: () => canvas ? registryRef.current.holdsKeys() : registryRef.current.isActive(),
574
+ activate: () => registryRef.current.activate(),
575
+ disposed: () => disposed
576
+ };
577
+ const keys = keyboardInput(core);
578
+ const { onKeyDown, onKeyUp } = keys;
579
+ const pinch = webkitPinchInput(core);
580
+ const { onGestureStart, onGestureChange, onGestureEnd } = pinch;
581
+ const onWheel = wheelInput(core, pinch.live);
582
+ const onContextMenu = contextMenuInput(core);
583
+ const { onDragOver, onDragLeave, onDrop, onPaste } = ingestInput(core);
272
584
  const held = /* @__PURE__ */ new Map();
273
585
  const toCanvasLocal = (p) => {
274
586
  const rect = canvas?.getBoundingClientRect();
@@ -282,14 +594,6 @@ function useGestureDispatcher(opts) {
282
594
  );
283
595
  return { centroid: toCanvasLocal(centroid), spread };
284
596
  };
285
- let pinchLive = false;
286
- let pinchScale = 1;
287
- let pinchRotation = 0;
288
- const endPinch = () => {
289
- pinchLive = false;
290
- pinchScale = 1;
291
- pinchRotation = 0;
292
- };
293
597
  let multiTouchStart = null;
294
598
  const TAP_THRESHOLD_PX = 8;
295
599
  const longPressTimers = /* @__PURE__ */ new Map();
@@ -335,168 +639,23 @@ function useGestureDispatcher(opts) {
335
639
  return d.resolveOnly(evs.longpress, ctxNow()) !== null || d.resolveOnly(evs.contextmenu, ctxNow()) !== null;
336
640
  };
337
641
  const fireLongPress = (pointerId, pointerType) => {
338
- const down = held.get(pointerId)?.down;
642
+ const rec = held.get(pointerId);
339
643
  clearPending(pointerId);
340
- if (!down) return;
341
- const evs = longPressEvents(down);
644
+ if (!rec) return;
645
+ const evs = longPressEvents(rec.down);
342
646
  let result = dispatch(evs.longpress);
343
647
  if (result === "unhandled") result = dispatch(evs.contextmenu);
648
+ if (result === "handled") rec.buffered = null;
344
649
  if (result === "handled" && longPressRef.current?.haptics !== false && (pointerType === "touch" || pointerType === "pen") && typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
345
650
  navigator.vibrate(HAPTIC_MS);
346
651
  }
347
652
  };
348
653
  const DOUBLE_CLICK_MAX_MS = 600;
349
654
  const DOUBLE_CLICK_MAX_PX = 8;
350
- const onKeyDown = (e) => {
351
- if (e.defaultPrevented || isEditableTarget(e.target)) return;
352
- if (!registryRef.current.isActive()) return;
353
- if (e.repeat && heldKeys.has(physicalKey(e))) {
354
- e.preventDefault();
355
- return;
356
- }
357
- const keyEv = {
358
- kind: "key",
359
- key: e.key,
360
- altKey: e.altKey,
361
- ctrlKey: e.ctrlKey,
362
- metaKey: e.metaKey,
363
- shiftKey: e.shiftKey,
364
- repeat: e.repeat
365
- };
366
- const keyResult = dispatch(keyEv);
367
- const heldEv = {
368
- kind: "key-held",
369
- key: e.key,
370
- phase: "down",
371
- altKey: e.altKey,
372
- ctrlKey: e.ctrlKey,
373
- metaKey: e.metaKey,
374
- shiftKey: e.shiftKey
375
- };
376
- const heldResult = dispatch(heldEv);
377
- if (heldResult === "handled") {
378
- heldKeys.set(physicalKey(e), e.key);
379
- }
380
- if (keyResult === "handled" || heldResult === "handled") {
381
- e.preventDefault();
382
- }
383
- };
384
- const onKeyUp = (e) => {
385
- const physical = physicalKey(e);
386
- const downKey = heldKeys.get(physical);
387
- if (downKey === void 0) return;
388
- const ev = {
389
- kind: "key-held",
390
- key: downKey,
391
- phase: "up",
392
- altKey: e.altKey,
393
- ctrlKey: e.ctrlKey,
394
- metaKey: e.metaKey,
395
- shiftKey: e.shiftKey
396
- };
397
- dispatch(ev);
398
- heldKeys.delete(physical);
399
- };
400
655
  const onWindowBlur = () => {
401
- endPinch();
656
+ pinch.end();
402
657
  for (const p of [...held.values()]) p.session.cancel();
403
- for (const key of heldKeys.values()) {
404
- const ev = {
405
- kind: "key-held",
406
- key,
407
- phase: "up",
408
- altKey: false,
409
- ctrlKey: false,
410
- metaKey: false,
411
- shiftKey: false
412
- };
413
- dispatch(ev);
414
- }
415
- heldKeys.clear();
416
- };
417
- const onWheel = (e) => {
418
- registryRef.current.activate();
419
- if (pinchLive && e.ctrlKey) {
420
- e.preventDefault();
421
- e.stopPropagation();
422
- return;
423
- }
424
- routeAt(null, e.clientX, e.clientY);
425
- const rect = canvas?.getBoundingClientRect();
426
- const [localX, localY] = rect ? clientToCanvasRect(rect, e.clientX, e.clientY) : [e.clientX, e.clientY];
427
- const hitPoint = toWorld(e.clientX, e.clientY);
428
- const affordance = target().affordanceAt?.(hitPoint) ?? void 0;
429
- const body = target().classifyTarget?.(hitPoint);
430
- const ev = {
431
- kind: "wheel",
432
- altKey: e.altKey,
433
- ctrlKey: e.ctrlKey,
434
- metaKey: e.metaKey,
435
- shiftKey: e.shiftKey,
436
- deltaX: e.deltaX,
437
- deltaY: e.deltaY,
438
- clientX: localX,
439
- clientY: localY,
440
- ...affordance !== void 0 ? { affordance } : {},
441
- ...body?.body !== void 0 ? { bodyTarget: body.body } : {},
442
- ...body?.kind !== void 0 ? { bodyKind: body.kind } : {}
443
- };
444
- const result = dispatch(ev);
445
- if (result === "handled") {
446
- e.preventDefault();
447
- e.stopImmediatePropagation();
448
- }
449
- };
450
- const pinchEventOf = (e, scale, rotation) => {
451
- const g = e;
452
- const rect = canvas?.getBoundingClientRect();
453
- const cx = typeof g.clientX === "number" ? g.clientX : rect ? rect.left + rect.width / 2 : 0;
454
- const cy = typeof g.clientY === "number" ? g.clientY : rect ? rect.top + rect.height / 2 : 0;
455
- routeAt(null, cx, cy);
456
- const [localX, localY] = rect ? clientToCanvasRect(rect, cx, cy) : [cx, cy];
457
- const hitPoint = toWorld(cx, cy);
458
- const affordance = target().affordanceAt?.(hitPoint) ?? void 0;
459
- const body = target().classifyTarget?.(hitPoint);
460
- return {
461
- kind: "pinch",
462
- altKey: g.altKey === true,
463
- ctrlKey: g.ctrlKey === true,
464
- metaKey: g.metaKey === true,
465
- shiftKey: g.shiftKey === true,
466
- scale,
467
- rotation,
468
- clientX: localX,
469
- clientY: localY,
470
- ...affordance !== void 0 ? { affordance } : {},
471
- ...body?.body !== void 0 ? { bodyTarget: body.body } : {},
472
- ...body?.kind !== void 0 ? { bodyKind: body.kind } : {}
473
- };
474
- };
475
- const onGestureStart = (e) => {
476
- endPinch();
477
- const probe = pinchEventOf(e, 1, 0);
478
- if (dispatcherNow().resolveOnly(probe, ctxNow()) === null) return;
479
- pinchLive = true;
480
- e.preventDefault();
481
- };
482
- const onGestureChange = (e) => {
483
- const g = e;
484
- const scale = typeof g.scale === "number" && g.scale > 0 ? g.scale : pinchScale;
485
- const rotation = typeof g.rotation === "number" ? g.rotation : pinchRotation;
486
- const step = scale / pinchScale;
487
- const turn = rotation - pinchRotation;
488
- pinchScale = scale;
489
- pinchRotation = rotation;
490
- if (dispatch(pinchEventOf(e, step, turn)) === "handled") {
491
- pinchLive = true;
492
- e.preventDefault();
493
- } else if (pinchLive) {
494
- e.preventDefault();
495
- }
496
- };
497
- const onGestureEnd = (e) => {
498
- if (pinchLive) e.preventDefault();
499
- endPinch();
658
+ keys.releaseAll();
500
659
  };
501
660
  let claimedDoubleClick = false;
502
661
  const onNativeDoubleClick = (e) => {
@@ -506,7 +665,6 @@ function useGestureDispatcher(opts) {
506
665
  };
507
666
  const onPointerDown = (e) => {
508
667
  claimedDoubleClick = false;
509
- registryRef.current.activate();
510
668
  if ((e.button ?? 0) !== 0) return;
511
669
  if (!canvas) return;
512
670
  if (isInControlWithin(e.target, canvas)) return;
@@ -593,10 +751,19 @@ function useGestureDispatcher(opts) {
593
751
  for (const p of held.values()) p.buffered = null;
594
752
  const { centroid, spread } = heldGeometry();
595
753
  if (!multiTouchStart) {
596
- multiTouchStart = { fingers: held.size, centroid: { ...centroid } };
754
+ multiTouchStart = {
755
+ fingers: held.size,
756
+ centroid: { ...centroid },
757
+ at: /* @__PURE__ */ new Map(),
758
+ moved: false,
759
+ startedAt: performance.now()
760
+ };
597
761
  } else {
598
762
  multiTouchStart.fingers = held.size;
599
763
  }
764
+ for (const [id, p] of held) {
765
+ if (!multiTouchStart.at.has(id)) multiTouchStart.at.set(id, { ...p.pos });
766
+ }
600
767
  const mt = {
601
768
  kind: "multitouch",
602
769
  fingers: held.size,
@@ -610,10 +777,25 @@ function useGestureDispatcher(opts) {
610
777
  dispatch(mt);
611
778
  }
612
779
  };
780
+ const endMultiTouch = (end, fingers, mods) => {
781
+ dispatch({
782
+ kind: "multitouch",
783
+ fingers,
784
+ end,
785
+ altKey: mods.altKey,
786
+ ctrlKey: mods.ctrlKey,
787
+ metaKey: mods.metaKey,
788
+ shiftKey: mods.shiftKey
789
+ });
790
+ };
613
791
  const onPointerMove = (e) => {
614
792
  routeAt(e.pointerId, e.clientX, e.clientY);
615
793
  const rec = held.get(e.pointerId);
616
794
  if (rec) rec.pos = { x: e.clientX, y: e.clientY };
795
+ const joinedAt = multiTouchStart?.at.get(e.pointerId);
796
+ if (multiTouchStart && joinedAt && Math.hypot(e.clientX - joinedAt.x, e.clientY - joinedAt.y) > TAP_THRESHOLD_PX) {
797
+ multiTouchStart.moved = true;
798
+ }
617
799
  const buffered = rec?.buffered;
618
800
  if (rec && buffered) {
619
801
  if (pastDragThreshold(buffered, e)) {
@@ -677,13 +859,12 @@ function useGestureDispatcher(opts) {
677
859
  clearHoverCursor();
678
860
  return;
679
861
  }
680
- const painted = tier.kind === "painted" ? tier : null;
681
- paintedCursorRef.current?.()?.setOverride(painted);
682
862
  const cursor = tier.kind === "css" ? tier.css : "none";
683
863
  if (!cursor) {
684
864
  clearHoverCursor();
685
865
  return;
686
866
  }
867
+ paintedCursorRef.current?.()?.setOverride(tier.kind === "painted" ? tier : "none");
687
868
  canvas.style.cursor = cursor;
688
869
  cursorOverridden = true;
689
870
  };
@@ -771,11 +952,8 @@ function useGestureDispatcher(opts) {
771
952
  const rec = held.get(e.pointerId);
772
953
  held.delete(e.pointerId);
773
954
  const staysUnderThreshold = rec?.buffered != null;
955
+ if (prevSize >= 2) endMultiTouch("commit", prevSize, e);
774
956
  if (prevSize >= 2 && held.size < 2) {
775
- const mtGestureId = `multitouch-${prevSize}`;
776
- if (dispatcherNow().inFlight().has(mtGestureId)) {
777
- dispatcherNow().cancelAll("commit");
778
- }
779
957
  const start = multiTouchStart;
780
958
  if (start) {
781
959
  const centroid = toCanvasLocal(
@@ -786,7 +964,8 @@ function useGestureDispatcher(opts) {
786
964
  );
787
965
  const dx = centroid.x - start.centroid.x;
788
966
  const dy = centroid.y - start.centroid.y;
789
- if (Math.hypot(dx, dy) <= TAP_THRESHOLD_PX) {
967
+ const brief = performance.now() - start.startedAt < (longPressRef.current?.duration ?? LONG_PRESS_MS);
968
+ if (brief && !start.moved && Math.hypot(dx, dy) <= TAP_THRESHOLD_PX) {
790
969
  const tap = {
791
970
  kind: "multitouchtap",
792
971
  fingers: start.fingers,
@@ -883,9 +1062,11 @@ function useGestureDispatcher(opts) {
883
1062
  if (disposed) return;
884
1063
  const rec = held.get(pointerId);
885
1064
  if (!rec) return;
1065
+ const prevSize = held.size;
886
1066
  held.delete(pointerId);
887
1067
  routeAt(pointerId, rec.pos.x, rec.pos.y);
888
1068
  cancelLongPress(pointerId);
1069
+ if (prevSize >= 2) endMultiTouch("cancel", prevSize, rec.down);
889
1070
  multiTouchStart = null;
890
1071
  const ev = {
891
1072
  kind: "pointercancel",
@@ -898,82 +1079,8 @@ function useGestureDispatcher(opts) {
898
1079
  dispatch(ev);
899
1080
  routeRelease(pointerId);
900
1081
  };
901
- const onContextMenu = (e) => {
902
- e.preventDefault();
903
- routeAt(null, e.clientX, e.clientY);
904
- const w = toWorld(e.clientX, e.clientY);
905
- const affordance = target().affordanceAt?.(w) ?? void 0;
906
- const menuBody = target().classifyTarget?.(w);
907
- const ev = {
908
- kind: "contextmenu",
909
- target: e.target,
910
- altKey: e.altKey,
911
- ctrlKey: e.ctrlKey,
912
- metaKey: e.metaKey,
913
- shiftKey: e.shiftKey,
914
- x: w.x,
915
- y: w.y,
916
- clientX: e.clientX,
917
- clientY: e.clientY,
918
- ...affordance !== void 0 ? { affordance } : {},
919
- ...menuBody?.body !== void 0 ? { bodyTarget: menuBody.body } : {},
920
- ...menuBody?.kind !== void 0 ? { bodyKind: menuBody.kind } : {}
921
- };
922
- dispatch(ev);
923
- };
924
- const onDragOver = (e) => {
925
- if (!e.dataTransfer) return;
926
- e.preventDefault();
927
- e.dataTransfer.dropEffect = "copy";
928
- canvas?.classList.add(DROPOVER_CLASS);
929
- };
930
- const onDragLeave = () => {
931
- canvas?.classList.remove(DROPOVER_CLASS);
932
- };
933
- const onDrop = (e) => {
934
- canvas?.classList.remove(DROPOVER_CLASS);
935
- const dt = e.dataTransfer;
936
- if (!dt) return;
937
- e.preventDefault();
938
- routeAt(null, e.clientX, e.clientY);
939
- const dropOn = target().dispatcher;
940
- const w = toWorld(e.clientX, e.clientY);
941
- const base = {
942
- altKey: e.altKey,
943
- ctrlKey: e.ctrlKey,
944
- metaKey: e.metaKey,
945
- shiftKey: e.shiftKey
946
- };
947
- void itemsFromDataTransfer(dt).then((items) => {
948
- if (disposed) return;
949
- if (items.length === 0) return;
950
- dispatch({
951
- kind: "drop",
952
- items,
953
- x: w.x,
954
- y: w.y,
955
- clientX: e.clientX,
956
- clientY: e.clientY,
957
- ...base
958
- }, dropOn);
959
- });
960
- };
961
- const onPaste = (e) => {
962
- if (isEditableTarget(e.target)) return;
963
- const cd = e.clipboardData;
964
- if (!cd) return;
965
- const items = itemsFromClipboardData(cd);
966
- if (items.length === 0) return;
967
- const result = dispatch({
968
- kind: "paste",
969
- items,
970
- // ClipboardEvent carries no modifier state.
971
- altKey: false,
972
- ctrlKey: false,
973
- metaKey: false,
974
- shiftKey: false
975
- });
976
- if (result === "handled") e.preventDefault();
1082
+ const onPressActivate = () => {
1083
+ registryRef.current.activate();
977
1084
  };
978
1085
  const keyCapture = keyboard === "first";
979
1086
  if (keyboard) {
@@ -981,6 +1088,7 @@ function useGestureDispatcher(opts) {
981
1088
  window.addEventListener("keyup", onKeyUp, keyCapture);
982
1089
  }
983
1090
  window.addEventListener("blur", onWindowBlur);
1091
+ canvas?.addEventListener("pointerdown", onPressActivate);
984
1092
  window.addEventListener("keydown", scheduleHoverCursorRefresh);
985
1093
  window.addEventListener("keyup", scheduleHoverCursorRefresh);
986
1094
  if (pointerChannel) {
@@ -1009,6 +1117,7 @@ function useGestureDispatcher(opts) {
1009
1117
  window.removeEventListener("keyup", onKeyUp, keyCapture);
1010
1118
  }
1011
1119
  window.removeEventListener("blur", onWindowBlur);
1120
+ canvas?.removeEventListener("pointerdown", onPressActivate);
1012
1121
  window.removeEventListener("keydown", scheduleHoverCursorRefresh);
1013
1122
  window.removeEventListener("keyup", scheduleHoverCursorRefresh);
1014
1123
  canvas?.removeEventListener("pointerleave", onHoverPointerLeave);
@@ -1026,6 +1135,7 @@ function useGestureDispatcher(opts) {
1026
1135
  canvas?.removeEventListener("drop", onDrop);
1027
1136
  window.removeEventListener("paste", onPaste);
1028
1137
  canvas?.classList.remove(DROPOVER_CLASS);
1138
+ registryRef.current.dropKeys();
1029
1139
  clearHoverCursor();
1030
1140
  cancelAllLongPress();
1031
1141
  disposed = true;
@@ -1118,6 +1228,7 @@ var ActionsNode = class extends ScopeNode {
1118
1228
  return null;
1119
1229
  }
1120
1230
  activate() {
1231
+ keyHolder = this;
1121
1232
  super.activate();
1122
1233
  for (const f of this.onActivate) f();
1123
1234
  }
@@ -1138,7 +1249,11 @@ var ActionsNode = class extends ScopeNode {
1138
1249
  const i = cur.lastIndexOf(action);
1139
1250
  if (i === -1) return;
1140
1251
  cur.splice(i, 1);
1141
- if (cur.length === 0) this.actions.delete(action.id);
1252
+ if (cur.length === 0) {
1253
+ queueMicrotask(() => {
1254
+ if (cur.length === 0 && this.actions.get(action.id) === cur) this.actions.delete(action.id);
1255
+ });
1256
+ }
1142
1257
  this.changed();
1143
1258
  };
1144
1259
  },
@@ -1163,11 +1278,9 @@ var ActionsNode = class extends ScopeNode {
1163
1278
  trigger: (id, params) => {
1164
1279
  const leaf = this.start();
1165
1280
  const a = leaf.resolve(id);
1166
- if (!a) return false;
1281
+ if (!a?.invoker || a.invoker.timing !== "immediate") return false;
1167
1282
  try {
1168
- if (a.invoker && a.invoker.timing === "immediate") {
1169
- a.invoker.run(depsFor(a, leaf.deps()), params);
1170
- }
1283
+ a.invoker.run(depsFor(a, leaf.deps()), params);
1171
1284
  } catch (err) {
1172
1285
  console.error(`weasel ActionsRegistry: action "${id}" threw`, err);
1173
1286
  }
@@ -1188,9 +1301,14 @@ var ActionsNode = class extends ScopeNode {
1188
1301
  return disp.beginUiOngoing(id, depsFor(a, leaf.deps()), params);
1189
1302
  },
1190
1303
  activate: () => this.activate(),
1191
- isActive: () => this.isActive()
1304
+ isActive: () => this.isActive(),
1305
+ holdsKeys: () => keyHolder === this,
1306
+ dropKeys: () => {
1307
+ if (keyHolder === this) keyHolder = null;
1308
+ }
1192
1309
  };
1193
1310
  };
1311
+ var keyHolder = null;
1194
1312
  var NODES = /* @__PURE__ */ new WeakMap();
1195
1313
  function actionsNodeOf(registry) {
1196
1314
  return registry ? NODES.get(registry) ?? null : null;
@@ -1271,16 +1389,14 @@ function useOngoingAction(actionId) {
1271
1389
  ctrlRef.current = null;
1272
1390
  ctrl?.end(reason);
1273
1391
  };
1392
+ const drive = (params) => {
1393
+ if (ctrlRef.current?.active) ctrlRef.current.update(params);
1394
+ else ctrlRef.current = regRef.current?.begin(idRef.current, params) ?? null;
1395
+ };
1274
1396
  return {
1275
- input(params) {
1276
- if (ctrlRef.current) ctrlRef.current.update(params);
1277
- else ctrlRef.current = regRef.current?.begin(idRef.current, params) ?? null;
1278
- },
1397
+ input: drive,
1279
1398
  commit(params) {
1280
- if (params !== void 0) {
1281
- if (ctrlRef.current) ctrlRef.current.update(params);
1282
- else ctrlRef.current = regRef.current?.begin(idRef.current, params) ?? null;
1283
- }
1399
+ if (params !== void 0) drive(params);
1284
1400
  end("commit");
1285
1401
  },
1286
1402
  cancel() {
@@ -1291,6 +1407,8 @@ function useOngoingAction(actionId) {
1291
1407
  useEffect(() => () => edit.commit(), [edit, actionId]);
1292
1408
  return edit;
1293
1409
  }
1410
+ var NO_MODES = () => () => {
1411
+ };
1294
1412
  function useContributions(opts) {
1295
1413
  const ctx = useActiveToolContext();
1296
1414
  const actionsRegistry = useActionsRegistry();
@@ -1309,25 +1427,18 @@ function useContributions(opts) {
1309
1427
  const setFocused = useCallback((id) => {
1310
1428
  ctx.setActive(id);
1311
1429
  }, [ctx]);
1312
- const snapshot = useCallback(() => {
1313
- const engagedIds = new Set(engaged);
1314
- const ordered = [];
1315
- for (const id of engaged) {
1316
- const entry = entries.find((e) => e.id === id);
1317
- if (entry && !ordered.includes(entry)) ordered.push(entry);
1318
- }
1319
- for (const entry of entries) {
1320
- if (!ordered.includes(entry)) ordered.push(entry);
1321
- }
1322
- return {
1323
- ordered,
1324
- state: { focusedId: focused, engagedIds }
1325
- };
1326
- }, [entries, focused, engaged]);
1327
- const scopedBindings = useCallback(() => {
1328
- const { ordered, state } = snapshot();
1329
- return scopeBindings(ordered, state);
1330
- }, [snapshot]);
1430
+ const modes = opts.modes;
1431
+ const modeVersion = useSyncExternalStore(
1432
+ modes ? modes.subscribe : NO_MODES,
1433
+ () => modes?.getVersion() ?? 0
1434
+ );
1435
+ const snapshot = useCallback(() => assembleEntries(entries, {
1436
+ focusedId: focused,
1437
+ hotkeyStack: engaged,
1438
+ ...modes ? { allowedCapabilities: activeModeOf(modes.current()).allowedCapabilities } : {}
1439
+ // The version is what changes; `modes` is one object for the registry's life.
1440
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1441
+ }), [entries, focused, engaged, modes, modeVersion]);
1331
1442
  const overlays = useCallback((position = "top") => {
1332
1443
  const { ordered, state } = snapshot();
1333
1444
  const active = [];
@@ -1367,8 +1478,8 @@ function useContributions(opts) {
1367
1478
  });
1368
1479
  }, [entrySig, entries, actionsRegistry, modesRegistry]);
1369
1480
  return useMemo(
1370
- () => ({ entries, focused, setFocused, scopedBindings, overlays }),
1371
- [entries, focused, setFocused, scopedBindings, overlays]
1481
+ () => ({ entries, focused, setFocused, overlays }),
1482
+ [entries, focused, setFocused, overlays]
1372
1483
  );
1373
1484
  }
1374
1485
  function useOffhandAction(entries) {