@weasel-js/gestures 1.10.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # @weasel-js/gestures
2
2
 
3
- Pure gesture taxonomy, route grammars, and matcher primitives. No React, no DOM, no scene.
3
+ The vocabulary weasel uses to say which user input fires what, and the pure
4
+ functions that decide whether an input matches. It has no React, no scene, and
5
+ no dependencies, so it can run in a test, a worker, or a host that is not
6
+ weasel at all.
4
7
 
5
8
  Part of [weasel](https://github.com/orochi235/weasel), a domain-agnostic 2D
6
9
  scene-graph canvas kit for React. See the
7
- [API reference](https://orochi235.github.io/weasel/api/).
10
+ [API reference](https://orochi235.github.io/weasel/#pkg/gestures).
8
11
 
9
12
  ## Install
10
13
 
@@ -12,12 +15,111 @@ scene-graph canvas kit for React. See the
12
15
  npm install @weasel-js/gestures
13
16
  ```
14
17
 
15
- ## Usage
18
+ ## Terms
19
+
20
+ These words have fixed meanings across weasel:
21
+
22
+ - A **gesture** is a form of input: a click, a drag, a wheel turn, a keystroke,
23
+ a pinch, a file dropped on the canvas.
24
+ - An **action** is a user-intent operation: move, delete, zoom in.
25
+ - An **interaction** is a gesture bound to an action: "shift-click on empty
26
+ canvas adds to the selection".
27
+
28
+ This package covers the gesture side only. It describes gestures, recognizes
29
+ them from raw pointer input, and matches them against descriptions. Actions,
30
+ and the dispatcher that runs the action a matched gesture is bound to, live in
31
+ other weasel packages.
32
+
33
+ ## What is in it
34
+
35
+ ### Describing a gesture
36
+
37
+ A `GestureSpec` is an object describing one gesture a binding listens for:
38
+ its `kind` (`'click'`, `'drag'`, `'key'`, `'wheel'`, and so on), and optionally
39
+ the modifier keys that must be held, what the pointer must be over, and the
40
+ phase the owning tool must be in.
41
+
42
+ A **route** is the same description as a string, short enough to write inline
43
+ and to show in a shortcut list: `[initial] click => empty +shift`.
44
+
45
+ `parseRoute` turns a route into a `ParsedRoute`, `formatRoute` writes the
46
+ canonical string back, `routeToSpec` converts a parsed route into the
47
+ `GestureSpec` the matcher takes, and `describeRoute` renders it as an English
48
+ sentence. `GESTURE_DESCRIPTORS` is the table of gesture names the grammar
49
+ accepts, with whether each takes a target and what argument it carries.
50
+
51
+ ### Matching input against a description
52
+
53
+ An `InputEvent` is one normalized input: a union keyed by `kind`, each arm
54
+ carrying the modifier state and whatever that kind of input reports.
55
+
56
+ `matchSpec(event, spec, isMac)` returns whether an event satisfies a spec.
57
+ Its parts are exported too: `matchModifiers`, `matchKey`, `matchTarget`, and
58
+ `matchPhase`. When several specs match one event, `specificity(spec)` gives a
59
+ tuple to rank them by, so the narrower description wins.
60
+
61
+ ### Recognizing gestures from pointer input
62
+
63
+ `createPointerRecognizer` turns a stream of pointer presses, moves, and
64
+ releases into `InputEvent`s. It decides when a press has moved far enough to be
65
+ a drag, when a release in place is a click, when two clicks make a double
66
+ click, when a held touch is a long-press, and when several pointers form a
67
+ multitouch gesture. It touches no DOM: the host feeds it samples and supplies
68
+ the clock, and it hands each recognized event to a sink the host provides.
69
+
70
+ The thresholds it uses are exported as constants: `DRAG_THRESHOLD_PX`,
71
+ `LONG_PRESS_MS`, `DOUBLE_CLICK_MAX_MS`, `DOUBLE_CLICK_MAX_PX`, and
72
+ `TAP_THRESHOLD_PX`.
73
+
74
+ ### How they fit together
75
+
76
+ | From | Through | To |
77
+ |---|---|---|
78
+ | Pointer samples | `createPointerRecognizer` | `InputEvent` |
79
+ | Route string | `parseRoute`, then `routeToSpec` | `GestureSpec` |
80
+ | `InputEvent` and `GestureSpec` | `matchSpec` | `true` or `false` |
81
+
82
+ A host listens to the DOM, feeds pointer samples to a recognizer, and builds
83
+ key, wheel, and drop events itself. Each binding carries a `GestureSpec`,
84
+ written directly or parsed from a route. For every event, the host asks
85
+ `matchSpec` which bindings apply, ranks them with `specificity`, and runs the
86
+ winner's action.
87
+
88
+ The package also exports a few helpers a host needs while doing this:
89
+ `isApplePlatform` to decide what the `mod` modifier means, and
90
+ `isEditableTarget`, `focusedControlOwnsKey`, and `isInControlWithin` to leave
91
+ keys and presses alone when they belong to a text field or a button.
92
+
93
+ ## Example
94
+
95
+ Parse a route, turn it into a spec, and test an event against it:
16
96
 
17
97
  ```ts
18
- import { /* … */ } from '@weasel-js/gestures';
98
+ import {
99
+ describeRoute, matchSpec, parseRoute, routeToSpec, type InputEvent,
100
+ } from '@weasel-js/gestures';
101
+
102
+ const route = parseRoute('[*:*] click => empty +shift');
103
+ const spec = routeToSpec(route);
104
+ // { kind: 'click', target: 'empty', mods: { shift: true } }
105
+
106
+ const shiftClick: InputEvent = {
107
+ kind: 'click',
108
+ bodyTarget: 'empty',
109
+ altKey: false, ctrlKey: false, metaKey: false, shiftKey: true,
110
+ };
111
+
112
+ matchSpec(shiftClick, spec, false); // true
113
+ matchSpec({ ...shiftClick, shiftKey: false }, spec, false); // false: shift is required
114
+
115
+ describeRoute(parseRoute('[initial] drag +mod ?shift'));
116
+ // 'Fires when the user Mod-drags anywhere, while the tool is idle (Shift optional).'
19
117
  ```
20
118
 
119
+ Modifier matching is strict. A modifier the spec does not mention must not be
120
+ held, so a spec for plain `click` does not match a shift-click. Mark a modifier
121
+ `?shift` in a route, or `shift: 'optional'` in a spec, to accept it either way.
122
+
21
123
  ## License
22
124
 
23
125
  MIT
package/dist/index.d.ts CHANGED
@@ -338,6 +338,11 @@ interface PointerDownSpec {
338
338
  * The pointer moving over the surface with no button pressed. Fires on every
339
339
  * such move; a held pointer's moves belong to its drag and fire nothing here.
340
340
  *
341
+ * With `motion: 'leave'` it fires instead when the pointer passes out of what
342
+ * the binding targets: onto something its `target` does not match, or off the
343
+ * surface. With no `target` that is the whole surface. Its params are the
344
+ * move's that took the pointer out, or the last move's before it left the surface.
345
+ *
341
346
  * A hover binding claims nothing. Every live binding that matches the move
342
347
  * runs, not only the best one, and the move still reaches the hover-cursor
343
348
  * pump. There is no press to own, so bind an immediate action, not an ongoing
@@ -346,6 +351,7 @@ interface PointerDownSpec {
346
351
  */
347
352
  interface HoverSpec {
348
353
  kind: 'hover';
354
+ motion?: 'move' | 'leave';
349
355
  target?: TargetSpec;
350
356
  mods?: ModSpec;
351
357
  phase?: PhaseSpec;
@@ -741,9 +747,19 @@ interface ContextMenuEvent extends EventModifiers {
741
747
  bodyTarget?: BodyTarget;
742
748
  bodyKind?: BodyKind;
743
749
  }
744
- /** A move of a pointer with no button pressed. Matched by `hover` specs. */
750
+ /** What a hover found under the pointer. */
751
+ interface HoverWhere {
752
+ affordance?: unknown;
753
+ bodyTarget?: BodyTarget;
754
+ bodyKind?: BodyKind;
755
+ }
756
+ /** A move of a pointer with no button pressed, or its leaving the surface. Matched by `hover` specs. */
745
757
  interface HoverEvent extends EventModifiers {
746
758
  kind: 'hover';
759
+ /** What the pointer was over at its last move, when it has made one since coming onto the surface. */
760
+ from?: HoverWhere;
761
+ /** The pointer has left the surface. Where it is and what it is over are its last move's. */
762
+ off?: boolean;
747
763
  /** World-space position of the pointer. */
748
764
  x: number;
749
765
  y: number;
@@ -856,29 +872,8 @@ type InputEvent = KeyEvent | KeyHeldEvent | WheelEvent | PinchEvent | PointerDow
856
872
  */
857
873
  declare function stylusOf(src: PointerSampleStylus): PointerSampleStylus;
858
874
 
859
- /** Pure gesture matcher — no React, no state, no DOM. */
875
+ /** Target matching: a `TargetSpec` against what an event classified under its point. */
860
876
 
861
- /** The modifier-state fields `matchModifiers` reads. Structural, so a DOM
862
- * `KeyboardEvent` or `PointerEvent` satisfies it directly. */
863
- type ModifiersEvent = {
864
- altKey: boolean;
865
- ctrlKey: boolean;
866
- metaKey: boolean;
867
- shiftKey: boolean;
868
- };
869
- /**
870
- * Strict modifier match.
871
- *
872
- * - Omitted or `false`: MUST NOT be held.
873
- * - `true`: MUST be held.
874
- * - `'optional'`: accepts either held or unheld.
875
- * - `mod` is platform-aware: matches `metaKey` on mac, `ctrlKey` elsewhere,
876
- * and the *other* platform key is forbidden. Callers should not combine
877
- * `mod` with `meta`/`ctrl`.
878
- */
879
- declare function matchModifiers(e: ModifiersEvent, mods: ModSpec | undefined, isMac: boolean): boolean;
880
- /** Case-insensitive key match; supports string or string[] (any-of). */
881
- declare function matchKey(eventKey: string, specKey: string | string[]): boolean;
882
877
  /**
883
878
  * A {@link TargetSpec} resolved to its form, with the parts each form encodes
884
879
  * in its string pulled out. This is the one enumeration of the target forms:
@@ -932,6 +927,30 @@ declare function parseTargetSpec(spec: TargetSpec): TargetSpecForm | null;
932
927
  * silent wildcard.
933
928
  */
934
929
  declare function matchTarget(target: unknown, specTarget: TargetSpec | undefined, bodyTarget?: string, bodyKind?: string): boolean;
930
+
931
+ /** Pure gesture matcher — no React, no state, no DOM. */
932
+
933
+ /** The modifier-state fields `matchModifiers` reads. Structural, so a DOM
934
+ * `KeyboardEvent` or `PointerEvent` satisfies it directly. */
935
+ type ModifiersEvent = {
936
+ altKey: boolean;
937
+ ctrlKey: boolean;
938
+ metaKey: boolean;
939
+ shiftKey: boolean;
940
+ };
941
+ /**
942
+ * Strict modifier match.
943
+ *
944
+ * - Omitted or `false`: MUST NOT be held.
945
+ * - `true`: MUST be held.
946
+ * - `'optional'`: accepts either held or unheld.
947
+ * - `mod` is platform-aware: matches `metaKey` on mac, `ctrlKey` elsewhere,
948
+ * and the *other* platform key is forbidden. Callers should not combine
949
+ * `mod` with `meta`/`ctrl`.
950
+ */
951
+ declare function matchModifiers(e: ModifiersEvent, mods: ModSpec | undefined, isMac: boolean): boolean;
952
+ /** Case-insensitive key match; supports string or string[] (any-of). */
953
+ declare function matchKey(eventKey: string, specKey: string | string[]): boolean;
935
954
  /** Per-tool gesture-lifecycle state at match time. `selfChannel` is the tool
936
955
  * id `'&'` resolves to — the tool that owns the binding being evaluated — or
937
956
  * `null` for a binding without an owning tool, in which case no `'&'` atom
@@ -1250,6 +1269,22 @@ declare const CONTROL_SELECTOR: string;
1250
1269
  */
1251
1270
  declare function isInControlWithin(target: EventTarget | null, host: Element): boolean;
1252
1271
 
1272
+ /**
1273
+ * Which pointers are down on an element: pressed on it or inside it, with any
1274
+ * button, and not yet released. The one answer to "is this move a hover" for
1275
+ * everything listening on that element.
1276
+ */
1277
+ interface HeldPointers {
1278
+ count(): number;
1279
+ /** Stop reading. The element's listeners go with its last reader. */
1280
+ release(): void;
1281
+ }
1282
+ /**
1283
+ * Read the pointers held on `element`. Every reader of one element shares one
1284
+ * set of listeners, so they cannot disagree.
1285
+ */
1286
+ declare function heldPointers(element: Element): HeldPointers;
1287
+
1253
1288
  /** Returns true when the target takes typed text: an input, a textarea, or a contenteditable element. */
1254
1289
  declare function isEditableTarget(target: EventTarget | null): boolean;
1255
1290
  /**
@@ -1268,4 +1303,4 @@ declare function activatesFocusedControl(e: KeyboardEvent): boolean;
1268
1303
  */
1269
1304
  declare function focusedControlOwnsKey(e: KeyboardEvent): boolean;
1270
1305
 
1271
- export { ACTIVE_SIGILS, type BodyClassification, type BodyKind, type BodyTarget, CONTROL_SELECTOR, type ChannelRef, type ClaimableGesture, type ClickEvent, type ClickMemory, type ClickObserver, type ClickSpec, type ContextMenuEvent, type ContextMenuSpec, DOUBLE_CLICK_MAX_MS, DOUBLE_CLICK_MAX_PX, DRAG_THRESHOLD_PX, type DescribeRouteOptions, type DoubleClickEvent, type DoubleClickSpec, type DragSpec, type DropEvent, type DropSpec, type EventModifiers, type FixedSeat, GESTURE_DESCRIPTORS, type GestureArgSpec, type GestureDescriptor, type GestureName, type GestureSpec, type HeldPress, type HoverEvent, type HoverSpec, type IngestItem, type InputEvent, type KeyEvent, type KeyHeldEvent, type KeyHeldSpec, type KeySpec, LONG_PRESS_MS, type LongPressEvent, type LongPressSpec, MOD_NAME_SET, type ModRequirement, type ModSpec, type ModifierCombo, type ModifierKey, type ModifiersEvent, type MultiTouchEvent, type MultiTouchGeometry, type MultiTouchSpec, type MultiTouchTapEvent, type MultiTouchTapSpec, type OptionalMod, type ParsedKeyRoute, type ParsedModifiers, type ParsedRoute, type PasteEvent, type PasteSpec, type PhaseAtom, type PhaseContext, type PhaseSpec, type PinchEvent, type PinchSpec, type Point, type PointerCancelEvent, type PointerDownEvent, type PointerDownSpec, type PointerIdentity, type PointerMoveEvent, type PointerRecognizer, type PointerRecognizerOptions, type PointerSample, type PointerSampleStylus, type PointerUpEvent, type PressFacts, type PressSample, RESERVED_ID_NAMES, RESERVED_ID_PREFIXES, RESERVED_SIGILS, ROUTE_FIELD_DEFINITIONS, ROUTE_TERMS, type RecognizerClock, type RecognizerObserver, type RecognizerSink, type ReleaseFacts, type ReleaseOutcome, type RouteDescriptionPart, type RouteFieldName, type RoutePhase, type RouteTermLabel, TAP_THRESHOLD_PX, type TargetPredicate, type TargetSpec, type TargetSpecForm, UNKNOWN_MIME, VALID_MOD_NAMES, type WheelEvent, type WheelSpec, activatesFocusedControl, bodyPredicate, canonicalModifiers, clickEvents, collapseShiftPairs, createPointerRecognizer, describeRoute, describeRouteParts, fixedSeat, focusedControlOwnsKey, formatKeyRoute, formatPhaseAtom, formatRoute, getGestureDescriptor, hitPredicate, isApplePlatform, isEditableTarget, isInControlWithin, isKnownGestureName, keyRouteToSpec, longPressEvents, matchIngestTypes, matchKey, matchModifiers, matchPhase, matchSpec, matchTarget, mimeMatchesGlob, modifierComboToParsed, mods, multiTouchGeometry, nearestPortalHost, parseKeyRoute, parseRoute, parseTargetSpec, pastDragThreshold, routeGestureForSpecKind, routeToSpec, specKindForRouteGesture, specificity, stylusOf };
1306
+ export { ACTIVE_SIGILS, type BodyClassification, type BodyKind, type BodyTarget, CONTROL_SELECTOR, type ChannelRef, type ClaimableGesture, type ClickEvent, type ClickMemory, type ClickObserver, type ClickSpec, type ContextMenuEvent, type ContextMenuSpec, DOUBLE_CLICK_MAX_MS, DOUBLE_CLICK_MAX_PX, DRAG_THRESHOLD_PX, type DescribeRouteOptions, type DoubleClickEvent, type DoubleClickSpec, type DragSpec, type DropEvent, type DropSpec, type EventModifiers, type FixedSeat, GESTURE_DESCRIPTORS, type GestureArgSpec, type GestureDescriptor, type GestureName, type GestureSpec, type HeldPointers, type HeldPress, type HoverEvent, type HoverSpec, type HoverWhere, type IngestItem, type InputEvent, type KeyEvent, type KeyHeldEvent, type KeyHeldSpec, type KeySpec, LONG_PRESS_MS, type LongPressEvent, type LongPressSpec, MOD_NAME_SET, type ModRequirement, type ModSpec, type ModifierCombo, type ModifierKey, type ModifiersEvent, type MultiTouchEvent, type MultiTouchGeometry, type MultiTouchSpec, type MultiTouchTapEvent, type MultiTouchTapSpec, type OptionalMod, type ParsedKeyRoute, type ParsedModifiers, type ParsedRoute, type PasteEvent, type PasteSpec, type PhaseAtom, type PhaseContext, type PhaseSpec, type PinchEvent, type PinchSpec, type Point, type PointerCancelEvent, type PointerDownEvent, type PointerDownSpec, type PointerIdentity, type PointerMoveEvent, type PointerRecognizer, type PointerRecognizerOptions, type PointerSample, type PointerSampleStylus, type PointerUpEvent, type PressFacts, type PressSample, RESERVED_ID_NAMES, RESERVED_ID_PREFIXES, RESERVED_SIGILS, ROUTE_FIELD_DEFINITIONS, ROUTE_TERMS, type RecognizerClock, type RecognizerObserver, type RecognizerSink, type ReleaseFacts, type ReleaseOutcome, type RouteDescriptionPart, type RouteFieldName, type RoutePhase, type RouteTermLabel, TAP_THRESHOLD_PX, type TargetPredicate, type TargetSpec, type TargetSpecForm, UNKNOWN_MIME, VALID_MOD_NAMES, type WheelEvent, type WheelSpec, activatesFocusedControl, bodyPredicate, canonicalModifiers, clickEvents, collapseShiftPairs, createPointerRecognizer, describeRoute, describeRouteParts, fixedSeat, focusedControlOwnsKey, formatKeyRoute, formatPhaseAtom, formatRoute, getGestureDescriptor, heldPointers, hitPredicate, isApplePlatform, isEditableTarget, isInControlWithin, isKnownGestureName, keyRouteToSpec, longPressEvents, matchIngestTypes, matchKey, matchModifiers, matchPhase, matchSpec, matchTarget, mimeMatchesGlob, modifierComboToParsed, mods, multiTouchGeometry, nearestPortalHost, parseKeyRoute, parseRoute, parseTargetSpec, pastDragThreshold, routeGestureForSpecKind, routeToSpec, specKindForRouteGesture, specificity, stylusOf };
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  var GESTURE_DESCRIPTORS = [
3
3
  { name: "click", hasTarget: true },
4
4
  { name: "pointerDown", hasTarget: true },
5
- { name: "hover", hasTarget: true },
5
+ { name: "hover", hasTarget: true, arg: { name: "motion", values: ["move", "leave"], default: "move" } },
6
6
  { name: "dblTap", hasTarget: true },
7
7
  { name: "drag", hasTarget: true },
8
8
  { name: "wheel", hasTarget: true, arg: { name: "direction", values: ["up", "down", "*"], default: "*" } },
@@ -286,47 +286,7 @@ function matchIngestTypes(items, types) {
286
286
  return items.some((it) => it.mime === UNKNOWN_MIME || types.some((g) => mimeMatchesGlob(it.mime, g)));
287
287
  }
288
288
 
289
- // src/ui/match.ts
290
- function resolveSpecValue(value) {
291
- if (value === true) return "required";
292
- if (value === "optional") return "optional";
293
- return "forbidden";
294
- }
295
- function matchModifiers(e, mods2, isMac) {
296
- let alt = "forbidden";
297
- let ctrl = "forbidden";
298
- let meta = "forbidden";
299
- let shift = "forbidden";
300
- if (mods2) {
301
- alt = resolveSpecValue(mods2.alt);
302
- shift = resolveSpecValue(mods2.shift);
303
- if (mods2.mod !== void 0) {
304
- const modReq = resolveSpecValue(mods2.mod);
305
- if (isMac) meta = modReq;
306
- else ctrl = modReq;
307
- } else {
308
- meta = resolveSpecValue(mods2.meta);
309
- ctrl = resolveSpecValue(mods2.ctrl);
310
- }
311
- }
312
- return checkKey(alt, e.altKey) && checkKey(ctrl, e.ctrlKey) && checkKey(meta, e.metaKey) && checkKey(shift, e.shiftKey);
313
- }
314
- function checkKey(req, held) {
315
- if (req === "required") return held;
316
- if (req === "forbidden") return !held;
317
- return true;
318
- }
319
- function matchKey(eventKey, specKey) {
320
- const lower = eventKey.toLowerCase();
321
- if (Array.isArray(specKey)) {
322
- return specKey.some((k) => k.toLowerCase() === lower);
323
- }
324
- return specKey.toLowerCase() === lower;
325
- }
326
- function matchKeyOrCode(e, spec) {
327
- if (spec.code !== void 0 && e.code !== void 0 && [spec.code].flat().includes(e.code)) return true;
328
- return matchKey(e.key, spec.key);
329
- }
289
+ // src/ui/matchTarget.ts
330
290
  function affordanceKindOf(target) {
331
291
  if (typeof target !== "object" || target === null) return void 0;
332
292
  const k = target.kind;
@@ -379,6 +339,48 @@ function matchTarget(target, specTarget, bodyTarget, bodyKind) {
379
339
  }
380
340
  }
381
341
  }
342
+
343
+ // src/ui/match.ts
344
+ function resolveSpecValue(value) {
345
+ if (value === true) return "required";
346
+ if (value === "optional") return "optional";
347
+ return "forbidden";
348
+ }
349
+ function matchModifiers(e, mods2, isMac) {
350
+ let alt = "forbidden";
351
+ let ctrl = "forbidden";
352
+ let meta = "forbidden";
353
+ let shift = "forbidden";
354
+ if (mods2) {
355
+ alt = resolveSpecValue(mods2.alt);
356
+ shift = resolveSpecValue(mods2.shift);
357
+ if (mods2.mod !== void 0) {
358
+ const modReq = resolveSpecValue(mods2.mod);
359
+ if (isMac) meta = modReq;
360
+ else ctrl = modReq;
361
+ } else {
362
+ meta = resolveSpecValue(mods2.meta);
363
+ ctrl = resolveSpecValue(mods2.ctrl);
364
+ }
365
+ }
366
+ return checkKey(alt, e.altKey) && checkKey(ctrl, e.ctrlKey) && checkKey(meta, e.metaKey) && checkKey(shift, e.shiftKey);
367
+ }
368
+ function checkKey(req, held) {
369
+ if (req === "required") return held;
370
+ if (req === "forbidden") return !held;
371
+ return true;
372
+ }
373
+ function matchKey(eventKey, specKey) {
374
+ const lower = eventKey.toLowerCase();
375
+ if (Array.isArray(specKey)) {
376
+ return specKey.some((k) => k.toLowerCase() === lower);
377
+ }
378
+ return specKey.toLowerCase() === lower;
379
+ }
380
+ function matchKeyOrCode(e, spec) {
381
+ if (spec.code !== void 0 && e.code !== void 0 && [spec.code].flat().includes(e.code)) return true;
382
+ return matchKey(e.key, spec.key);
383
+ }
382
384
  function normalizePhase(spec) {
383
385
  if (typeof spec === "string") {
384
386
  return [{ channel: "&", phase: spec }];
@@ -406,6 +408,16 @@ function matchPhaseAtom(a, ctx) {
406
408
  const isEngaged = ctx.engagedChannels.has(id);
407
409
  return a.phase === "engaged" ? isEngaged : !isEngaged;
408
410
  }
411
+ function matchPress(e, spec, isMac) {
412
+ if (e.kind !== "pointerdown") return false;
413
+ if (spec.kind === "drag") {
414
+ if (e.stage === "press") return false;
415
+ } else if (e.stage !== "press") {
416
+ return false;
417
+ }
418
+ if (!matchModifiers(e, spec.mods, isMac)) return false;
419
+ return matchTarget(e.affordance, spec.target, e.bodyTarget, e.bodyKind);
420
+ }
409
421
  function matchSpec(e, spec, isMac, phaseCtx) {
410
422
  if (spec.phase !== void 0) {
411
423
  const ctx = phaseCtx ?? { selfChannel: null, engagedChannels: EMPTY_ENGAGED };
@@ -452,24 +464,22 @@ function matchSpec(e, spec, isMac, phaseCtx) {
452
464
  // Each happens at a point, so its target is what the event classified
453
465
  // there: for a long-press, the affordance its press landed on.
454
466
  case "contextMenu":
455
- case "longPress":
456
- case "hover": {
457
- if (e.kind !== "contextmenu" && e.kind !== "longpress" && e.kind !== "hover") return false;
467
+ case "longPress": {
468
+ if (e.kind !== "contextmenu" && e.kind !== "longpress") return false;
458
469
  if (e.kind !== spec.kind.toLowerCase()) return false;
459
470
  if (!matchModifiers(e, spec.mods, isMac)) return false;
460
471
  return matchTarget(e.affordance, spec.target, e.bodyTarget, e.bodyKind);
461
472
  }
462
- case "drag": {
463
- if (e.kind !== "pointerdown") return false;
464
- if (e.stage === "press") return false;
465
- if (!matchModifiers(e, spec.mods, isMac)) return false;
466
- return matchTarget(e.affordance, spec.target, e.bodyTarget, e.bodyKind);
467
- }
468
- case "pointerDown": {
469
- if (e.kind !== "pointerdown" || e.stage !== "press") return false;
473
+ case "hover": {
474
+ if (e.kind !== "hover") return false;
470
475
  if (!matchModifiers(e, spec.mods, isMac)) return false;
471
- return matchTarget(e.affordance, spec.target, e.bodyTarget, e.bodyKind);
476
+ const on = !e.off && matchTarget(e.affordance, spec.target, e.bodyTarget, e.bodyKind);
477
+ if (spec.motion !== "leave") return on;
478
+ return !on && e.from !== void 0 && matchTarget(e.from.affordance, spec.target, e.from.bodyTarget, e.from.bodyKind);
472
479
  }
480
+ case "drag":
481
+ case "pointerDown":
482
+ return matchPress(e, spec, isMac);
473
483
  case "multiTouch": {
474
484
  if (e.kind !== "multitouch") return false;
475
485
  if (e.fingers !== spec.fingers) return false;
@@ -557,9 +567,10 @@ function routeToSpec(parsed) {
557
567
  switch (kind) {
558
568
  case void 0:
559
569
  return fail(`${parsed.gesture} has no GestureSpec kind`);
570
+ case "hover":
571
+ return withMods({ kind, ...arg === "leave" ? { motion: "leave" } : {}, ...t });
560
572
  case "click":
561
573
  case "pointerDown":
562
- case "hover":
563
574
  case "doubleClick":
564
575
  case "drag":
565
576
  case "contextMenu":
@@ -626,6 +637,10 @@ function partitionModifiers(mods2) {
626
637
  }
627
638
  return { required, optional };
628
639
  }
640
+ function leftClause(target) {
641
+ if (target === void 0 || target === "*") return "off the surface";
642
+ return target === "empty" ? "off empty canvas" : `off a ${target}`;
643
+ }
629
644
  function phaseAtomParts({ channel, phase }) {
630
645
  const subject = channel === "&" ? "the tool" : channel === "*" ? "any tool" : `the ${channel} tool`;
631
646
  if (phase === "initial") return [`${subject} is `, term("idle")];
@@ -688,7 +703,7 @@ function actionClause(parsed, required) {
688
703
  case "pointerDown":
689
704
  return userDoes(required, `presses${target}`);
690
705
  case "hover":
691
- return userDoes(required, `moves the pointer${target}`);
706
+ return userDoes(required, arg === "leave" ? `moves the pointer ${leftClause(parsed.target)}` : `moves the pointer${target}`);
692
707
  case "dblTap":
693
708
  return userDoes(required, `double-taps${target}`);
694
709
  case "drag":
@@ -1172,6 +1187,65 @@ function isInControlWithin(target, host) {
1172
1187
  return control !== null && control !== host && host.contains(control);
1173
1188
  }
1174
1189
 
1190
+ // src/ui/heldPointers.ts
1191
+ var trackers = /* @__PURE__ */ new WeakMap();
1192
+ function attach(element) {
1193
+ const doc = element.ownerDocument;
1194
+ const win = doc.defaultView;
1195
+ const held = /* @__PURE__ */ new Map();
1196
+ const down = (e) => {
1197
+ const p = e;
1198
+ held.set(p.pointerId, !!p.buttons);
1199
+ };
1200
+ const up = (e) => {
1201
+ held.delete(e.pointerId);
1202
+ };
1203
+ const move = (e) => {
1204
+ const p = e;
1205
+ if (held.get(p.pointerId) && p.buttons === 0) held.delete(p.pointerId);
1206
+ };
1207
+ const blur = () => {
1208
+ held.clear();
1209
+ };
1210
+ element.addEventListener("pointerdown", down, true);
1211
+ doc.addEventListener("pointerup", up, true);
1212
+ doc.addEventListener("pointercancel", up, true);
1213
+ doc.addEventListener("pointermove", move, true);
1214
+ win?.addEventListener("blur", blur);
1215
+ return {
1216
+ readers: 0,
1217
+ held,
1218
+ detach() {
1219
+ element.removeEventListener("pointerdown", down, true);
1220
+ doc.removeEventListener("pointerup", up, true);
1221
+ doc.removeEventListener("pointercancel", up, true);
1222
+ doc.removeEventListener("pointermove", move, true);
1223
+ win?.removeEventListener("blur", blur);
1224
+ }
1225
+ };
1226
+ }
1227
+ function heldPointers(element) {
1228
+ let tracker = trackers.get(element);
1229
+ if (!tracker) {
1230
+ tracker = attach(element);
1231
+ trackers.set(element, tracker);
1232
+ }
1233
+ const shared = tracker;
1234
+ shared.readers += 1;
1235
+ let released = false;
1236
+ return {
1237
+ count: () => shared.held.size,
1238
+ release() {
1239
+ if (released) return;
1240
+ released = true;
1241
+ shared.readers -= 1;
1242
+ if (shared.readers > 0) return;
1243
+ shared.detach();
1244
+ if (trackers.get(element) === shared) trackers.delete(element);
1245
+ }
1246
+ };
1247
+ }
1248
+
1175
1249
  // src/ui/keyOwner.ts
1176
1250
  function isEditableTarget(target) {
1177
1251
  if (!target || !(target instanceof Element)) return false;
@@ -1213,6 +1287,6 @@ function focusedControlOwnsKey(e) {
1213
1287
  return activatesFocusedControl(e) || selectOwnsKey(e);
1214
1288
  }
1215
1289
 
1216
- export { ACTIVE_SIGILS, CONTROL_SELECTOR, DOUBLE_CLICK_MAX_MS, DOUBLE_CLICK_MAX_PX, DRAG_THRESHOLD_PX, GESTURE_DESCRIPTORS, LONG_PRESS_MS, MOD_NAME_SET, RESERVED_ID_NAMES, RESERVED_ID_PREFIXES, RESERVED_SIGILS, ROUTE_FIELD_DEFINITIONS, ROUTE_TERMS, TAP_THRESHOLD_PX, UNKNOWN_MIME, VALID_MOD_NAMES, activatesFocusedControl, bodyPredicate, canonicalModifiers, clickEvents, collapseShiftPairs, createPointerRecognizer, describeRoute, describeRouteParts, fixedSeat, focusedControlOwnsKey, formatKeyRoute, formatPhaseAtom, formatRoute, getGestureDescriptor, hitPredicate, isApplePlatform, isEditableTarget, isInControlWithin, isKnownGestureName, keyRouteToSpec, longPressEvents, matchIngestTypes, matchKey, matchModifiers, matchPhase, matchSpec, matchTarget, mimeMatchesGlob, modifierComboToParsed, mods, multiTouchGeometry, nearestPortalHost, parseKeyRoute, parseRoute, parseTargetSpec, pastDragThreshold, routeGestureForSpecKind, routeToSpec, specKindForRouteGesture, specificity, stylusOf };
1290
+ export { ACTIVE_SIGILS, CONTROL_SELECTOR, DOUBLE_CLICK_MAX_MS, DOUBLE_CLICK_MAX_PX, DRAG_THRESHOLD_PX, GESTURE_DESCRIPTORS, LONG_PRESS_MS, MOD_NAME_SET, RESERVED_ID_NAMES, RESERVED_ID_PREFIXES, RESERVED_SIGILS, ROUTE_FIELD_DEFINITIONS, ROUTE_TERMS, TAP_THRESHOLD_PX, UNKNOWN_MIME, VALID_MOD_NAMES, activatesFocusedControl, bodyPredicate, canonicalModifiers, clickEvents, collapseShiftPairs, createPointerRecognizer, describeRoute, describeRouteParts, fixedSeat, focusedControlOwnsKey, formatKeyRoute, formatPhaseAtom, formatRoute, getGestureDescriptor, heldPointers, hitPredicate, isApplePlatform, isEditableTarget, isInControlWithin, isKnownGestureName, keyRouteToSpec, longPressEvents, matchIngestTypes, matchKey, matchModifiers, matchPhase, matchSpec, matchTarget, mimeMatchesGlob, modifierComboToParsed, mods, multiTouchGeometry, nearestPortalHost, parseKeyRoute, parseRoute, parseTargetSpec, pastDragThreshold, routeGestureForSpecKind, routeToSpec, specKindForRouteGesture, specificity, stylusOf };
1217
1291
  //# sourceMappingURL=index.js.map
1218
1292
  //# sourceMappingURL=index.js.map