drawnui-react 0.1.0-preview.16 → 0.1.0-preview.18

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.
Files changed (55) hide show
  1. package/README.md +1 -1
  2. package/SKIPPED.md +7 -7
  3. package/dist/controls/AnimatedFramesRenderer.d.ts +7 -0
  4. package/dist/controls/AnimatedFramesRenderer.js +16 -1
  5. package/dist/controls/DrawnGame.d.ts +4 -1
  6. package/dist/controls/DrawnGame.js +7 -3
  7. package/dist/controls/GridStructure.d.ts +13 -1
  8. package/dist/controls/GridStructure.js +38 -2
  9. package/dist/controls/SkiaButton.d.ts +12 -2
  10. package/dist/controls/SkiaButton.js +19 -6
  11. package/dist/controls/SkiaCarousel.d.ts +1 -1
  12. package/dist/controls/SkiaCarousel.js +3 -2
  13. package/dist/controls/SkiaEditor.d.ts +5 -1
  14. package/dist/controls/SkiaEditor.js +19 -17
  15. package/dist/controls/SkiaGif.d.ts +3 -0
  16. package/dist/controls/SkiaGif.js +11 -1
  17. package/dist/controls/SkiaImage.js +15 -10
  18. package/dist/controls/SkiaLabel.d.ts +52 -3
  19. package/dist/controls/SkiaLabel.js +222 -50
  20. package/dist/controls/SkiaLayout.d.ts +5 -0
  21. package/dist/controls/SkiaLayout.js +7 -4
  22. package/dist/controls/SkiaLottie.js +3 -1
  23. package/dist/controls/SkiaProgress.d.ts +2 -1
  24. package/dist/controls/SkiaProgress.js +4 -1
  25. package/dist/controls/SkiaRadioButton.js +3 -1
  26. package/dist/controls/SkiaScroll.d.ts +17 -1
  27. package/dist/controls/SkiaScroll.js +84 -9
  28. package/dist/controls/SkiaShape.d.ts +13 -2
  29. package/dist/controls/SkiaShape.js +50 -10
  30. package/dist/controls/SkiaSlider.d.ts +10 -2
  31. package/dist/controls/SkiaSlider.js +15 -2
  32. package/dist/controls/SkiaSprite.d.ts +1 -0
  33. package/dist/controls/SkiaSprite.js +4 -0
  34. package/dist/controls/SkiaToggle.d.ts +5 -1
  35. package/dist/controls/SkiaToggle.js +11 -2
  36. package/dist/controls/SnappingLayout.d.ts +10 -1
  37. package/dist/controls/SnappingLayout.js +16 -1
  38. package/dist/core/Accessibility.d.ts +13 -0
  39. package/dist/core/Accessibility.js +10 -4
  40. package/dist/core/Animators.d.ts +28 -1
  41. package/dist/core/Animators.js +76 -8
  42. package/dist/core/Canvas.d.ts +21 -4
  43. package/dist/core/Canvas.js +122 -23
  44. package/dist/core/Easing.d.ts +12 -1
  45. package/dist/core/Easing.js +25 -1
  46. package/dist/core/Gestures.d.ts +24 -1
  47. package/dist/core/Gestures.js +75 -2
  48. package/dist/core/Hover.d.ts +14 -4
  49. package/dist/core/Hover.js +38 -22
  50. package/dist/core/ScrollAnimators.js +9 -0
  51. package/dist/core/SkiaControl.d.ts +27 -6
  52. package/dist/core/SkiaControl.js +81 -38
  53. package/dist/react/SkiaShell.js +33 -14
  54. package/dist/react/index.js +29 -2
  55. package/package.json +16 -1
@@ -16,7 +16,20 @@ export class AnimatorBase {
16
16
  StartFrameTimeNanos = 0;
17
17
  OnStart;
18
18
  OnStop;
19
+ /** Paused: the canvas does not tick it and it wakes no frames; Resume goes on from where it was (C# Pause / Resume). */
20
+ IsPaused = false;
21
+ /** Paused by the canvas because its control is hidden; resumed when it shows again. */
22
+ PausedWhileHidden = false;
23
+ /**
24
+ * Frame time (ns) before which nothing it shows changes: the canvas does not tick it and it keeps no frames coming
25
+ * until then, a timer wakes the frame loop at that time (a GIF or a sprite between two frames; DrawnUi.Rust frame
26
+ * animators sleep between changes). 0 = tick every frame.
27
+ */
28
+ SleepUntilNanos = 0;
19
29
  delayHandle = 0;
30
+ delayEnd = 0;
31
+ delayLeft = 0;
32
+ pausedAt = 0;
20
33
  constructor(parent) { this.Parent = parent; }
21
34
  Register() {
22
35
  const parent = this.Parent;
@@ -60,6 +73,8 @@ export class AnimatorBase {
60
73
  this.delayHandle = 0;
61
74
  }
62
75
  if (delayMs > 0) {
76
+ // the delay waits without frames
77
+ this.delayEnd = performance.now() + delayMs;
63
78
  this.delayHandle = window.setTimeout(() => { this.delayHandle = 0; this.Start(); }, delayMs);
64
79
  return;
65
80
  }
@@ -72,7 +87,43 @@ export class AnimatorBase {
72
87
  }
73
88
  }
74
89
  }
90
+ /** Freezes the run (and what is left of a start delay); Resume goes on from there. */
91
+ Pause() {
92
+ if (this.IsPaused)
93
+ return;
94
+ this.IsPaused = true;
95
+ this.pausedAt = performance.now();
96
+ if (this.delayHandle) {
97
+ clearTimeout(this.delayHandle);
98
+ this.delayHandle = 0;
99
+ this.delayLeft = Math.max(0, this.delayEnd - this.pausedAt);
100
+ }
101
+ }
102
+ /** Goes on from where Pause froze it: the paused time is not counted. */
103
+ Resume() {
104
+ if (!this.IsPaused)
105
+ return;
106
+ this.IsPaused = false;
107
+ const paused = (performance.now() - this.pausedAt) * 1_000_000;
108
+ if (this.StartFrameTimeNanos !== 0) {
109
+ this.StartFrameTimeNanos += paused;
110
+ this.LastFrameTimeNanos += paused;
111
+ }
112
+ if (this.SleepUntilNanos !== 0)
113
+ this.SleepUntilNanos += paused;
114
+ if (this.delayLeft > 0) {
115
+ const left = this.delayLeft;
116
+ this.delayLeft = 0;
117
+ this.Start(left);
118
+ return;
119
+ }
120
+ this.Parent?.Repaint(); // frames again
121
+ }
75
122
  Stop() {
123
+ this.IsPaused = false;
124
+ this.PausedWhileHidden = false;
125
+ this.SleepUntilNanos = 0;
126
+ this.delayLeft = 0;
76
127
  if (this.delayHandle) {
77
128
  clearTimeout(this.delayHandle);
78
129
  this.delayHandle = 0;
@@ -155,23 +206,21 @@ export class SkiaValueAnimator extends AnimatorBase {
155
206
  }
156
207
  /** Passed over mValue; subclasses derive their own reported value here. */
157
208
  TransformReportedValue(_deltaT) { return this.mValue; }
158
- /** Updates mValue from elapsed time; true when the target was reached. */
209
+ /**
210
+ * Updates mValue from elapsed time; true when the run is over. The eased value is not clamped to the range: a spring
211
+ * easing goes past its ends, and a run whose mMinValue is above mMaxValue (a ping-pong's way back) goes down.
212
+ */
159
213
  UpdateValue(_deltaT, deltaFromStart) {
160
214
  const elapsedMs = deltaFromStart / 1_000_000;
161
215
  const progress = this.Speed > 0 ? elapsedMs / this.Speed : 1;
162
216
  const deltaValue = this.mMaxValue - this.mMinValue;
163
217
  const eased = this.Easing.Ease(Math.min(progress, 1));
164
218
  this.Progress = eased;
165
- const value = deltaValue * eased + this.mMinValue;
166
- if (value < this.mMinValue) {
167
- this.mValue = this.mMinValue;
168
- return false;
169
- }
170
- if (value >= this.mMaxValue || progress >= 1) {
219
+ if (progress >= 1) {
171
220
  this.mValue = this.mMaxValue;
172
221
  return true;
173
222
  }
174
- this.mValue = value;
223
+ this.mValue = deltaValue * eased + this.mMinValue;
175
224
  return false;
176
225
  }
177
226
  FinishedRunning() {
@@ -188,6 +237,25 @@ export class SkiaValueAnimator extends AnimatorBase {
188
237
  return true;
189
238
  }
190
239
  }
240
+ /** DrawnUi PingPongAnimator: every repeat runs the other way (Repeat -1 = forever by default). */
241
+ export class PingPongAnimator extends SkiaValueAnimator {
242
+ /** One way done, the next starts (C# CycleFInished). */
243
+ CycleFinished;
244
+ constructor(parent) { super(parent); this.Repeat = -1; }
245
+ FinishedRunning() {
246
+ if (this.Repeat !== 0) {
247
+ this.CycleFinished?.();
248
+ if (this.Repeat > 0)
249
+ this.Repeat--;
250
+ [this.mMinValue, this.mMaxValue] = [this.mMaxValue, this.mMinValue];
251
+ this.mValue = this.mMinValue;
252
+ this.LastFrameTimeNanos = 0;
253
+ this.StartFrameTimeNanos = 0;
254
+ return false;
255
+ }
256
+ return super.FinishedRunning();
257
+ }
258
+ }
191
259
  /** DrawnUi RenderingAnimator: a value animator that paints itself above its Parent (IOverlayEffect). */
192
260
  export class RenderingAnimator extends SkiaValueAnimator {
193
261
  constructor(parent) { super(parent); }
@@ -15,6 +15,11 @@ export declare class Canvas {
15
15
  readonly Element: HTMLCanvasElement;
16
16
  /** Points a pointer may travel between Down and Up and still count as a tap (AppoMobi TouchEffect default). */
17
17
  static TappedCancelMoveThresholdPoints: number;
18
+ /**
19
+ * A press held this long without moving past TappedCancelMoveThresholdPoints is a LongPressing, at the press point
20
+ * (AppoMobi TouchEffect, DrawnUi.Rust LONG_PRESS_MS); its release sends no Tapped. A timer, no frames while waiting.
21
+ */
22
+ static LongPressTimeMs: number;
18
23
  BackgroundColor: Color;
19
24
  /** Accelerated = WebGL surface, Default = software. Read once at first frame. */
20
25
  RenderingMode: RenderingModeType;
@@ -96,6 +101,10 @@ export declare class Canvas {
96
101
  UnregisterAnimator(uid: number): void;
97
102
  /** Ticks every registered animator once; returns how many ran. */
98
103
  protected ExecuteAnimators(frameTimeNanos: number): number;
104
+ private wakeTimer;
105
+ private wakeAtNanos;
106
+ /** One timer for the earliest sleeping animator: the frame loop wakes then, no frames run in between. */
107
+ private WakeAt;
99
108
  /** Frames drawn so far (SkiaBackdrop uses it to refresh once after a cached record). */
100
109
  FrameIndex: number;
101
110
  /**
@@ -143,10 +152,11 @@ export declare class Canvas {
143
152
  private cursorPointer;
144
153
  /**
145
154
  * DrawnUi.Blazor shows `cursor: pointer` over interactive controls through its overlay elements; here the overlay
146
- * is pointer-events:none, so the mouse position is tested against the accessibility snapshot (the accessible
147
- * controls' rects in points, already sorted and rate-limited), each hit control answering WantsPointerCursor
148
- * (itself tappable, or a tappable span of a label), and the canvas element's cursor is switched only when the
149
- * answer changes. Mouse moves only, no work without a mouse and none in the frame loop.
155
+ * is pointer-events:none, so the canvas decides from the hover hit path (Hover.Path: the controls under the mouse by
156
+ * the routing rules of ProcessGestures, so nothing below a popup that blocks gestures below): a control in the
157
+ * accessibility tree (it has a role) that answers WantsPointerCursor (itself tappable, or a tappable span of a label)
158
+ * shows the hand. A popup's close-on-background-tap wrapper has no role, so its backdrop shows none. The canvas
159
+ * element's cursor is switched only when the answer changes. Mouse moves only, none in the frame loop.
150
160
  */
151
161
  private UpdateCursor;
152
162
  /**
@@ -179,9 +189,16 @@ export declare class Canvas {
179
189
  private readonly UpdateTouchAction;
180
190
  private pageObserver?;
181
191
  private AttachInput;
192
+ /** The page's own overscroll-behavior before Lock set it to none (html, body), put back on detach. */
193
+ private pageOverscroll?;
182
194
  private DetachInput;
183
195
  /** Port of DrawnUi.Blazor Canvas.OnTouchAction: per-pointer state machine producing Down / Panning / Tapped / Up. */
184
196
  OnTouchAction(args: TouchActionEventArgs): void;
197
+ private readonly longPressTimers;
198
+ private readonly longPressed;
199
+ /** The long press of a new press: due LongPressTimeMs later unless the pointer moves away or lets go. */
200
+ private ArmLongPress;
201
+ private DisarmLongPress;
185
202
  private OnGestureEvent;
186
203
  private ProcessPendingGestures;
187
204
  /**
@@ -13,6 +13,11 @@ export class Canvas {
13
13
  Element;
14
14
  /** Points a pointer may travel between Down and Up and still count as a tap (AppoMobi TouchEffect default). */
15
15
  static TappedCancelMoveThresholdPoints = 16;
16
+ /**
17
+ * A press held this long without moving past TappedCancelMoveThresholdPoints is a LongPressing, at the press point
18
+ * (AppoMobi TouchEffect, DrawnUi.Rust LONG_PRESS_MS); its release sends no Tapped. A timer, no frames while waiting.
19
+ */
20
+ static LongPressTimeMs = 1500;
16
21
  BackgroundColor = Colors.Transparent;
17
22
  /** Accelerated = WebGL surface, Default = software. Read once at first frame. */
18
23
  RenderingMode = "Accelerated";
@@ -242,17 +247,51 @@ export class Canvas {
242
247
  UnregisterAnimator(uid) { this.AnimatingControls.delete(uid); }
243
248
  /** Ticks every registered animator once; returns how many ran. */
244
249
  ExecuteAnimators(frameTimeNanos) {
245
- let executed = 0;
250
+ let executed = 0, wake = Infinity;
246
251
  for (const a of [...this.AnimatingControls.values()]) {
247
252
  if (!a.Parent) {
248
253
  this.AnimatingControls.delete(a.Uid);
249
254
  continue;
250
255
  }
256
+ // the control or a parent is hidden: its animator pauses, no frames, and goes on from where it was when shown
257
+ if (a.Parent.HiddenInTree()) {
258
+ if (!a.IsPaused) {
259
+ a.Pause();
260
+ a.PausedWhileHidden = true;
261
+ }
262
+ continue;
263
+ }
264
+ if (a.PausedWhileHidden) {
265
+ a.PausedWhileHidden = false;
266
+ a.Resume();
267
+ }
268
+ if (a.IsPaused)
269
+ continue; // a paused animator neither ticks nor keeps frames coming (C# DrawnView)
270
+ if (a.SleepUntilNanos > frameTimeNanos) {
271
+ wake = Math.min(wake, a.SleepUntilNanos);
272
+ continue;
273
+ } // nothing changes yet
251
274
  a.TickFrame(frameTimeNanos);
252
275
  executed++;
276
+ if (a.SleepUntilNanos > frameTimeNanos)
277
+ wake = Math.min(wake, a.SleepUntilNanos);
253
278
  }
279
+ this.WakeAt(wake, frameTimeNanos);
254
280
  return executed;
255
281
  }
282
+ wakeTimer = 0;
283
+ wakeAtNanos = Infinity;
284
+ /** One timer for the earliest sleeping animator: the frame loop wakes then, no frames run in between. */
285
+ WakeAt(nanos, nowNanos) {
286
+ if (!isFinite(nanos))
287
+ return;
288
+ if (this.wakeTimer && this.wakeAtNanos <= nanos)
289
+ return;
290
+ if (this.wakeTimer)
291
+ clearTimeout(this.wakeTimer);
292
+ this.wakeAtNanos = nanos;
293
+ this.wakeTimer = window.setTimeout(() => { this.wakeTimer = 0; this.wakeAtNanos = Infinity; this.Update(); }, Math.max(0, (nanos - nowNanos) / 1_000_000));
294
+ }
256
295
  /** Frames drawn so far (SkiaBackdrop uses it to refresh once after a cached record). */
257
296
  FrameIndex = 0;
258
297
  /**
@@ -311,6 +350,10 @@ export class Canvas {
311
350
  }
312
351
  Dispose() {
313
352
  this.disposed = true;
353
+ if (this.wakeTimer) {
354
+ clearTimeout(this.wakeTimer);
355
+ this.wakeTimer = 0;
356
+ }
314
357
  this.Element.removeEventListener("webglcontextlost", this.onContextLost);
315
358
  this.Element.removeEventListener("webglcontextrestored", this.onContextRestored);
316
359
  this.Gestures = "Disabled";
@@ -351,10 +394,10 @@ export class Canvas {
351
394
  if (type === "Moved" && !this.activeTouchIds.has(e.pointerId)) {
352
395
  if (e.pointerType === "touch")
353
396
  return; // touch never hovers
354
- if (e.pointerType === "mouse")
355
- this.UpdateCursor(e.offsetX, e.offsetY);
356
397
  if (this.activeTouchIds.size === 0)
357
398
  this.Hover.Move(this.content, e.offsetX * this.RenderingScale, e.offsetY * this.RenderingScale);
399
+ if (e.pointerType === "mouse")
400
+ this.UpdateCursor();
358
401
  return;
359
402
  }
360
403
  if ((type === "Released" || type === "Cancelled") && !this.activeTouchIds.has(e.pointerId))
@@ -414,23 +457,19 @@ export class Canvas {
414
457
  cursorPointer = false;
415
458
  /**
416
459
  * DrawnUi.Blazor shows `cursor: pointer` over interactive controls through its overlay elements; here the overlay
417
- * is pointer-events:none, so the mouse position is tested against the accessibility snapshot (the accessible
418
- * controls' rects in points, already sorted and rate-limited), each hit control answering WantsPointerCursor
419
- * (itself tappable, or a tappable span of a label), and the canvas element's cursor is switched only when the
420
- * answer changes. Mouse moves only, no work without a mouse and none in the frame loop.
460
+ * is pointer-events:none, so the canvas decides from the hover hit path (Hover.Path: the controls under the mouse by
461
+ * the routing rules of ProcessGestures, so nothing below a popup that blocks gestures below): a control in the
462
+ * accessibility tree (it has a role) that answers WantsPointerCursor (itself tappable, or a tappable span of a label)
463
+ * shows the hand. A popup's close-on-background-tap wrapper has no role, so its backdrop shows none. The canvas
464
+ * element's cursor is switched only when the answer changes. Mouse moves only, none in the frame loop.
421
465
  */
422
- UpdateCursor(x, y) {
466
+ UpdateCursor() {
423
467
  let hit = false;
424
- const scale = this.RenderingScale;
425
- for (const n of this.AccessibilityManager.Snapshot) {
426
- if (x < n.Rect.Left || x >= n.Rect.Right || y < n.Rect.Top || y >= n.Rect.Bottom)
427
- continue;
428
- // the control decides (whole control, or a tappable span of a label); point in pixels relative to the control's
429
- // origin, taken from the snapshot rect (already carries the scroll / cache offset; a rotated control gets its bbox)
430
- if (n.Source.WantsPointerCursor((x - n.Rect.Left) * scale, (y - n.Rect.Top) * scale)) {
468
+ const path = this.Hover.Path, xs = this.Hover.PathX, ys = this.Hover.PathY;
469
+ for (let i = path.length - 1; i >= 0 && !hit; i--) {
470
+ const c = path[i];
471
+ if (c.IsAccessibilityElement && c.WantsPointerCursor(xs[i] - c.DrawingRect.Left, ys[i] - c.DrawingRect.Top))
431
472
  hit = true;
432
- break;
433
- }
434
473
  }
435
474
  if (hit !== this.cursorPointer) {
436
475
  this.cursorPointer = hit;
@@ -534,11 +573,22 @@ export class Canvas {
534
573
  el.addEventListener("pointerleave", this.onPointerLeave);
535
574
  el.addEventListener("contextmenu", this.onContextMenu);
536
575
  el.addEventListener("wheel", this.onWheel, { passive: false });
537
- if (this.gestures === "Lock")
576
+ if (this.gestures === "Lock") {
538
577
  el.addEventListener("touchmove", this.preventTouch, { passive: false });
578
+ // the canvas owns every touch (DrawnUi.Web applyGestureStyle, DrawnUi.Rust host): no iOS callout or text
579
+ // selection on a long press, and no rubber band / pull-down of the page starting on it
580
+ const st = el.style;
581
+ st.webkitUserSelect = "none";
582
+ st.webkitTouchCallout = "none";
583
+ this.pageOverscroll = [document.documentElement.style.overscrollBehavior, document.body.style.overscrollBehavior];
584
+ document.documentElement.style.overscrollBehavior = "none";
585
+ document.body.style.overscrollBehavior = "none";
586
+ }
539
587
  else if (this.gestures === "Enabled")
540
588
  el.addEventListener("touchmove", this.preventClaimedTouch, { passive: false });
541
589
  }
590
+ /** The page's own overscroll-behavior before Lock set it to none (html, body), put back on detach. */
591
+ pageOverscroll;
542
592
  DetachInput() {
543
593
  const el = this.Element;
544
594
  el.style.touchAction = "";
@@ -553,7 +603,17 @@ export class Canvas {
553
603
  el.removeEventListener("wheel", this.onWheel);
554
604
  el.removeEventListener("touchmove", this.preventTouch);
555
605
  el.removeEventListener("touchmove", this.preventClaimedTouch);
606
+ if (this.pageOverscroll) {
607
+ const st = el.style;
608
+ st.webkitUserSelect = "";
609
+ st.webkitTouchCallout = "";
610
+ [document.documentElement.style.overscrollBehavior, document.body.style.overscrollBehavior] = this.pageOverscroll;
611
+ this.pageOverscroll = undefined;
612
+ }
556
613
  this.claimedTouches.clear();
614
+ for (const id of [...this.longPressTimers.keys()])
615
+ this.DisarmLongPress(id);
616
+ this.longPressed.clear();
557
617
  this.activeTouchIds.clear();
558
618
  this.pointerDownArgs.clear();
559
619
  this.previousTouchArgs.clear();
@@ -564,6 +624,7 @@ export class Canvas {
564
624
  return;
565
625
  const id = args.Id;
566
626
  if (args.Type === "Pressed") {
627
+ this.ArmLongPress(id, args);
567
628
  this.activeTouchIds.add(id);
568
629
  args.NumberOfTouches = this.activeTouchIds.size;
569
630
  args.StartingLocation = args.Location;
@@ -588,6 +649,9 @@ export class Canvas {
588
649
  const downArgs = this.pointerDownArgs.get(id);
589
650
  args.StartingLocation = downArgs ? downArgs.StartingLocation : args.Location;
590
651
  if (args.Type === "Moved") {
652
+ const threshold = Canvas.TappedCancelMoveThresholdPoints * Math.max(0.1, this.RenderingScale);
653
+ if (Math.abs(args.Distance.Total.X) >= threshold || Math.abs(args.Distance.Total.Y) >= threshold)
654
+ this.DisarmLongPress(id);
591
655
  if (args.Distance.Delta.X !== 0 || args.Distance.Delta.Y !== 0)
592
656
  this.OnGestureEvent(args, "Panning");
593
657
  this.previousTouchArgs.set(id, args);
@@ -595,7 +659,9 @@ export class Canvas {
595
659
  }
596
660
  if (args.Type === "Released" || args.Type === "Cancelled") {
597
661
  args.IsInContact = args.NumberOfTouches > 1;
598
- if (!args.IsInContact && downArgs && args.Type === "Released") {
662
+ const longPressed = this.longPressed.delete(id);
663
+ this.DisarmLongPress(id);
664
+ if (!args.IsInContact && downArgs && args.Type === "Released" && !longPressed) {
599
665
  const threshold = Canvas.TappedCancelMoveThresholdPoints * Math.max(0.1, this.RenderingScale);
600
666
  if (Math.abs(args.Distance.Total.X) < threshold && Math.abs(args.Distance.Total.Y) < threshold)
601
667
  this.OnGestureEvent(args, "Tapped");
@@ -606,6 +672,30 @@ export class Canvas {
606
672
  this.activeTouchIds.delete(id);
607
673
  }
608
674
  }
675
+ longPressTimers = new Map();
676
+ longPressed = new Set();
677
+ /** The long press of a new press: due LongPressTimeMs later unless the pointer moves away or lets go. */
678
+ ArmLongPress(id, down) {
679
+ this.DisarmLongPress(id);
680
+ this.longPressed.delete(id);
681
+ this.longPressTimers.set(id, window.setTimeout(() => {
682
+ this.longPressTimers.delete(id);
683
+ if (!this.activeTouchIds.has(id))
684
+ return;
685
+ this.longPressed.add(id);
686
+ const args = new TouchActionEventArgs();
687
+ Object.assign(args, down);
688
+ args.Location = down.StartingLocation ?? down.Location;
689
+ this.OnGestureEvent(args, "LongPressing");
690
+ }, Canvas.LongPressTimeMs));
691
+ }
692
+ DisarmLongPress(id) {
693
+ const t = this.longPressTimers.get(id);
694
+ if (t) {
695
+ clearTimeout(t);
696
+ this.longPressTimers.delete(id);
697
+ }
698
+ }
609
699
  OnGestureEvent(args, result) {
610
700
  this.pendingGestures.push(SkiaGesturesParameters.Create(result, args));
611
701
  this.Update();
@@ -634,21 +724,30 @@ export class Canvas {
634
724
  /** Entry into the control tree, same shape as DrawnUi Canvas.ProcessGestures. */
635
725
  ProcessGestures(root, args) {
636
726
  const info = () => new GestureEventProcessingInfo(args.Event.Location, SKPoint.Empty, SKPoint.Empty, null);
727
+ // the wheel goes to the press owner only while a press is held; between presses it goes by position, and it
728
+ // never makes an owner: a wheel over a second scroll moves that one, not the last one the wheel moved
729
+ // (DrawnUi.Rust input_router "the wheel goes to the owner of the press first"; C# keeps the last wheel consumer)
730
+ const wheel = args.Type === "Wheel";
637
731
  if (args.Type === "Down")
638
732
  this.gestureOwner = null;
639
- else if (this.gestureOwner && Canvas.IsSavedGesture(args.Type)) {
733
+ else if (this.gestureOwner && Canvas.IsSavedGesture(args.Type) && (!wheel || this.activeTouchIds.size > 0)) {
640
734
  const owner = this.gestureOwner;
641
735
  const alive = !!owner.Superview && owner.IsVisible && !owner.InputTransparent;
642
736
  const consumed = alive ? owner.OnSkiaGestureEvent(args, info()) : null;
643
737
  if (consumed) {
644
738
  // it still wants the gesture: nobody else sees this one (C# skips the tree pass for a saved gesture)
645
- this.gestureOwner = args.Type === "Up" ? null : consumed;
739
+ if (!wheel)
740
+ this.gestureOwner = args.Type === "Up" ? null : consumed;
646
741
  return consumed;
647
742
  }
648
- this.gestureOwner = null; // it let go (a button whose press turned into a pan): route normally again
743
+ // it let go (a button whose press turned into a pan): route normally again; a wheel it does not use goes by
744
+ // position and leaves it the press
745
+ if (!wheel)
746
+ this.gestureOwner = null;
649
747
  }
650
748
  const consumed = root.ProcessGestures(args, info());
651
- this.gestureOwner = consumed && args.Type !== "Up" ? consumed : null;
749
+ if (!wheel)
750
+ this.gestureOwner = consumed && args.Type !== "Up" ? consumed : null;
652
751
  return consumed;
653
752
  }
654
753
  }
@@ -1,4 +1,4 @@
1
- /** Mirrors MAUI Easing (subset): Ease(t) with t in 0..1. */
1
+ /** Mirrors MAUI Easing: Ease(t) with t in 0..1 (Spring* leave 0..1 on the way). */
2
2
  export declare class Easing {
3
3
  private readonly fn;
4
4
  constructor(fn: (x: number) => number);
@@ -9,5 +9,16 @@ export declare class Easing {
9
9
  static readonly CubicInOut: Easing;
10
10
  static readonly SinOut: Easing;
11
11
  static readonly SinIn: Easing;
12
+ /** Half a cosine: slow at both ends. */
13
+ static readonly SinInOut: Easing;
14
+ /** Leaps to the end, bounces three times and settles. */
15
+ static readonly BounceOut: Easing;
16
+ /** Bounces three times, then leaps to the end. */
17
+ static readonly BounceIn: Easing;
18
+ /** Moves away first, then leaps to the end. */
19
+ static readonly SpringIn: Easing;
20
+ /** Goes a little past the end and comes back. */
21
+ static readonly SpringOut: Easing;
22
+ private static Bounce;
12
23
  static readonly Default: Easing;
13
24
  }
@@ -1,4 +1,4 @@
1
- /** Mirrors MAUI Easing (subset): Ease(t) with t in 0..1. */
1
+ /** Mirrors MAUI Easing: Ease(t) with t in 0..1 (Spring* leave 0..1 on the way). */
2
2
  export class Easing {
3
3
  fn;
4
4
  constructor(fn) {
@@ -11,5 +11,29 @@ export class Easing {
11
11
  static CubicInOut = new Easing((x) => (x < 0.5 ? 4 * x ** 3 : (x - 1) * (2 * x - 2) ** 2 + 1));
12
12
  static SinOut = new Easing((x) => Math.sin((x * Math.PI) / 2));
13
13
  static SinIn = new Easing((x) => 1 - Math.cos((x * Math.PI) / 2));
14
+ /** Half a cosine: slow at both ends. */
15
+ static SinInOut = new Easing((x) => -Math.cos(Math.PI * x) / 2 + 0.5);
16
+ /** Leaps to the end, bounces three times and settles. */
17
+ static BounceOut = new Easing(Easing.Bounce);
18
+ /** Bounces three times, then leaps to the end. */
19
+ static BounceIn = new Easing((x) => 1 - Easing.Bounce(1 - x));
20
+ /** Moves away first, then leaps to the end. */
21
+ static SpringIn = new Easing((x) => x * x * ((1.70158 + 1) * x - 1.70158));
22
+ /** Goes a little past the end and comes back. */
23
+ static SpringOut = new Easing((x) => (x - 1) * (x - 1) * ((1.70158 + 1) * (x - 1) + 1.70158) + 1);
24
+ static Bounce(p) {
25
+ if (p < 1 / 2.75)
26
+ return 7.5625 * p * p;
27
+ if (p < 2 / 2.75) {
28
+ p -= 1.5 / 2.75;
29
+ return 7.5625 * p * p + 0.75;
30
+ }
31
+ if (p < 2.5 / 2.75) {
32
+ p -= 2.25 / 2.75;
33
+ return 7.5625 * p * p + 0.9375;
34
+ }
35
+ p -= 2.625 / 2.75;
36
+ return 7.5625 * p * p + 0.984375;
37
+ }
14
38
  static Default = Easing.CubicInOut;
15
39
  }
@@ -9,7 +9,7 @@ export declare class SKPoint {
9
9
  }
10
10
  /** Raw platform action (subset of TouchActionType). */
11
11
  export type TouchActionType = "Pressed" | "Moved" | "Released" | "Cancelled" | "Wheel" | "Pointer";
12
- /** Recognized gesture (TouchActionResult). LongPressing/Pointer/Touch declared for parity, not produced yet. */
12
+ /** Recognized gesture (TouchActionResult). LongPressing after Canvas.LongPressTimeMs held still; Pointer / Touch declared for parity, not produced. */
13
13
  export type TouchActionResult = "Touch" | "Down" | "Up" | "Tapped" | "LongPressing" | "Panning" | "Wheel" | "Pointer";
14
14
  export type GesturesMode = "Disabled" | "Enabled" | "Lock";
15
15
  export type LockTouch = "Disabled" | "Enabled" | "PassNone" | "PassTap" | "PassTapAndLongPress";
@@ -21,6 +21,27 @@ export declare class DistanceInfo {
21
21
  Total: SKPoint;
22
22
  Velocity: SKPoint;
23
23
  }
24
+ /**
25
+ * The last positions of a press with their times (DrawnUi.Rust gestures `Trail`, 6d525f7). Measured only against the
26
+ * previous move, irregular delivery made the velocity worthless: moves handled in one burst (queued pointer events
27
+ * handed over back to back by a busy main thread, WSLg pairs 0.01 ms apart) gave millions of px/s and every fling at
28
+ * the limit. Moves under BurstMs apart are one position; a move's velocity is its displacement over the last WindowMs,
29
+ * the earlier position interpolated; when the previous move is already that old, its own velocity exactly (evenly
30
+ * spaced input at 60 Hz measures as before).
31
+ */
32
+ export declare class VelocityTrail {
33
+ /** A move's velocity is its displacement over this much time. */
34
+ static readonly WindowMs = 16;
35
+ /** Below this since the press no velocity is measured yet: the first moves of a burst. */
36
+ static readonly MinMs = 4;
37
+ /** Moves closer than this came in one burst: one position, at the burst's first time. */
38
+ static readonly BurstMs = 1;
39
+ private static readonly Capacity;
40
+ private readonly points;
41
+ Push(x: number, y: number, t: number): void;
42
+ /** The velocity at (x, y) at time t (ms), pixels per second; `fallback` right after the press. */
43
+ Velocity(x: number, y: number, t: number, fallback: SKPoint): SKPoint;
44
+ }
24
45
  /** AppoMobi.Gestures MouseButton: which button pressed / released (DOM button 0..4). */
25
46
  export type MouseButton = "Left" | "Middle" | "Right" | "XButton1" | "XButton2" | "Extended";
26
47
  export type PointerDeviceType = "Mouse" | "Touch" | "Pen";
@@ -49,6 +70,8 @@ export declare class TouchActionEventArgs {
49
70
  Timestamp: number;
50
71
  /** ms since the previous event of the same pointer. */
51
72
  DeltaTimeMs: number;
73
+ /** The press's recent positions the velocity is measured over, carried from event to event of one pointer. */
74
+ Trail?: VelocityTrail;
52
75
  /**
53
76
  * Mouse wheel: Delta > 0 = wheel down / right (browser sign). IsHorizontal: the event is along the X axis (a tilting
54
77
  * wheel, the sideways part of a two-finger touchpad swipe), as C# WheelEventArgs.IsHorizontal.
@@ -18,6 +18,67 @@ export class DistanceInfo {
18
18
  Total = SKPoint.Empty;
19
19
  Velocity = SKPoint.Empty;
20
20
  }
21
+ /**
22
+ * The last positions of a press with their times (DrawnUi.Rust gestures `Trail`, 6d525f7). Measured only against the
23
+ * previous move, irregular delivery made the velocity worthless: moves handled in one burst (queued pointer events
24
+ * handed over back to back by a busy main thread, WSLg pairs 0.01 ms apart) gave millions of px/s and every fling at
25
+ * the limit. Moves under BurstMs apart are one position; a move's velocity is its displacement over the last WindowMs,
26
+ * the earlier position interpolated; when the previous move is already that old, its own velocity exactly (evenly
27
+ * spaced input at 60 Hz measures as before).
28
+ */
29
+ export class VelocityTrail {
30
+ /** A move's velocity is its displacement over this much time. */
31
+ static WindowMs = 16;
32
+ /** Below this since the press no velocity is measured yet: the first moves of a burst. */
33
+ static MinMs = 4;
34
+ /** Moves closer than this came in one burst: one position, at the burst's first time. */
35
+ static BurstMs = 1;
36
+ static Capacity = 16;
37
+ points = [];
38
+ Push(x, y, t) {
39
+ const last = this.points[this.points.length - 1];
40
+ if (last && t - last.T < VelocityTrail.BurstMs) {
41
+ last.X = x;
42
+ last.Y = y;
43
+ return;
44
+ }
45
+ if (this.points.length === VelocityTrail.Capacity)
46
+ this.points.shift();
47
+ this.points.push({ X: x, Y: y, T: t });
48
+ }
49
+ /** The velocity at (x, y) at time t (ms), pixels per second; `fallback` right after the press. */
50
+ Velocity(x, y, t, fallback) {
51
+ let n = this.points.length;
52
+ // a move in the burst of the last position measures from the positions before it
53
+ if (n > 0 && t - this.points[n - 1].T < VelocityTrail.BurstMs)
54
+ n--;
55
+ if (n === 0)
56
+ return fallback;
57
+ const since = t - this.points[0].T;
58
+ if (since < VelocityTrail.MinMs)
59
+ return fallback;
60
+ const window = Math.min(VelocityTrail.WindowMs, since), at = t - window;
61
+ const last = this.points[n - 1];
62
+ if (last.T <= at) {
63
+ const secs = (t - last.T) / 1000;
64
+ return new SKPoint((x - last.X) / secs, (y - last.Y) / secs);
65
+ }
66
+ // the position at `at`: between the two positions around it (the current one included)
67
+ let fromX = this.points[0].X, fromY = this.points[0].Y, prev = this.points[0];
68
+ for (let i = 1; i <= n; i++) {
69
+ const p = i < n ? this.points[i] : { X: x, Y: y, T: t };
70
+ if (p.T >= at) {
71
+ const share = p.T - prev.T < 1e-6 ? 1 : (at - prev.T) / (p.T - prev.T);
72
+ fromX = prev.X + (p.X - prev.X) * share;
73
+ fromY = prev.Y + (p.Y - prev.Y) * share;
74
+ break;
75
+ }
76
+ prev = p;
77
+ }
78
+ const secs = window / 1000;
79
+ return new SKPoint((x - fromX) / secs, (y - fromY) / secs);
80
+ }
81
+ }
21
82
  /** AppoMobi.Gestures PointerData: the device and button behind a Down / Up / Tapped (every button is delivered). */
22
83
  export class PointerData {
23
84
  Button = "Left";
@@ -43,6 +104,8 @@ export class TouchActionEventArgs {
43
104
  Timestamp = performance.now();
44
105
  /** ms since the previous event of the same pointer. */
45
106
  DeltaTimeMs = 0;
107
+ /** The press's recent positions the velocity is measured over, carried from event to event of one pointer. */
108
+ Trail;
46
109
  /**
47
110
  * Mouse wheel: Delta > 0 = wheel down / right (browser sign). IsHorizontal: the event is along the X axis (a tilting
48
111
  * wheel, the sideways part of a two-finger touchpad swipe), as C# WheelEventArgs.IsHorizontal.
@@ -68,8 +131,18 @@ export class TouchActionEventArgs {
68
131
  d.End = released ? previous.Location : current.Location;
69
132
  d.Delta = released ? SKPoint.Empty : current.Location.Subtract(previous.Location);
70
133
  d.Total = previous.Distance.Total.Add(d.Delta);
71
- const secs = current.DeltaTimeMs / 1000;
72
- d.Velocity = secs > 0 && !released ? new SKPoint(d.Delta.X / secs, d.Delta.Y / secs) : previous.Distance.Velocity;
134
+ if (released)
135
+ d.Velocity = previous.Distance.Velocity;
136
+ else {
137
+ let trail = previous.Trail;
138
+ if (!trail) {
139
+ trail = new VelocityTrail();
140
+ trail.Push(previous.Location.X, previous.Location.Y, previous.Timestamp);
141
+ }
142
+ d.Velocity = trail.Velocity(current.Location.X, current.Location.Y, current.Timestamp, previous.Distance.Velocity);
143
+ trail.Push(current.Location.X, current.Location.Y, current.Timestamp);
144
+ current.Trail = trail;
145
+ }
73
146
  current.Distance = d;
74
147
  }
75
148
  }