drawnui-react 0.1.0-preview.2 → 0.1.0-preview.21

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 (87) hide show
  1. package/README.md +17 -4
  2. package/SKIPPED.md +33 -27
  3. package/dist/controls/AnimatedFramesRenderer.d.ts +7 -0
  4. package/dist/controls/AnimatedFramesRenderer.js +16 -1
  5. package/dist/controls/DrawnGame.d.ts +40 -0
  6. package/dist/controls/DrawnGame.js +64 -0
  7. package/dist/controls/GridStructure.d.ts +16 -2
  8. package/dist/controls/GridStructure.js +40 -4
  9. package/dist/controls/SkiaBackdrop.d.ts +8 -2
  10. package/dist/controls/SkiaBackdrop.js +48 -8
  11. package/dist/controls/SkiaButton.d.ts +19 -2
  12. package/dist/controls/SkiaButton.js +41 -11
  13. package/dist/controls/SkiaCarousel.d.ts +9 -1
  14. package/dist/controls/SkiaCarousel.js +16 -3
  15. package/dist/controls/SkiaCheckbox.d.ts +0 -1
  16. package/dist/controls/SkiaCheckbox.js +0 -6
  17. package/dist/controls/SkiaDrawer.d.ts +2 -0
  18. package/dist/controls/SkiaDrawer.js +2 -0
  19. package/dist/controls/SkiaEditor.d.ts +12 -1
  20. package/dist/controls/SkiaEditor.js +46 -19
  21. package/dist/controls/SkiaGif.d.ts +3 -0
  22. package/dist/controls/SkiaGif.js +12 -2
  23. package/dist/controls/SkiaImage.js +15 -10
  24. package/dist/controls/SkiaLabel.d.ts +54 -3
  25. package/dist/controls/SkiaLabel.js +235 -52
  26. package/dist/controls/SkiaLabelFps.d.ts +16 -0
  27. package/dist/controls/SkiaLabelFps.js +41 -0
  28. package/dist/controls/SkiaLayout.d.ts +64 -7
  29. package/dist/controls/SkiaLayout.js +316 -19
  30. package/dist/controls/SkiaLottie.js +4 -2
  31. package/dist/controls/SkiaProgress.d.ts +2 -1
  32. package/dist/controls/SkiaProgress.js +6 -3
  33. package/dist/controls/SkiaRadioButton.js +4 -3
  34. package/dist/controls/SkiaScroll.d.ts +35 -5
  35. package/dist/controls/SkiaScroll.js +233 -23
  36. package/dist/controls/SkiaScrollBar.d.ts +27 -0
  37. package/dist/controls/SkiaScrollBar.js +51 -2
  38. package/dist/controls/SkiaShaderCarousel.d.ts +5 -0
  39. package/dist/controls/SkiaShaderCarousel.js +5 -0
  40. package/dist/controls/SkiaShape.d.ts +13 -2
  41. package/dist/controls/SkiaShape.js +50 -10
  42. package/dist/controls/SkiaSlider.d.ts +20 -2
  43. package/dist/controls/SkiaSlider.js +59 -5
  44. package/dist/controls/SkiaSprite.d.ts +1 -0
  45. package/dist/controls/SkiaSprite.js +4 -0
  46. package/dist/controls/SkiaSvg.js +1 -1
  47. package/dist/controls/SkiaSwitch.d.ts +0 -1
  48. package/dist/controls/SkiaSwitch.js +0 -6
  49. package/dist/controls/SkiaToggle.d.ts +25 -5
  50. package/dist/controls/SkiaToggle.js +58 -5
  51. package/dist/controls/SnappingLayout.d.ts +12 -1
  52. package/dist/controls/SnappingLayout.js +19 -1
  53. package/dist/core/Accessibility.d.ts +80 -0
  54. package/dist/core/Accessibility.js +365 -8
  55. package/dist/core/Animators.d.ts +37 -1
  56. package/dist/core/Animators.js +102 -9
  57. package/dist/core/Canvas.d.ts +108 -7
  58. package/dist/core/Canvas.js +363 -35
  59. package/dist/core/Easing.d.ts +12 -1
  60. package/dist/core/Easing.js +25 -1
  61. package/dist/core/Gestures.d.ts +48 -2
  62. package/dist/core/Gestures.js +95 -3
  63. package/dist/core/Hover.d.ts +47 -0
  64. package/dist/core/Hover.js +134 -0
  65. package/dist/core/KeyboardManager.d.ts +13 -0
  66. package/dist/core/KeyboardManager.js +18 -0
  67. package/dist/core/ScrollAnimators.js +9 -0
  68. package/dist/core/SkiaControl.d.ts +216 -7
  69. package/dist/core/SkiaControl.js +516 -59
  70. package/dist/core/SkiaEffect.d.ts +17 -0
  71. package/dist/core/SkiaEffect.js +43 -8
  72. package/dist/core/SkiaImageManager.js +1 -1
  73. package/dist/core/Styles.d.ts +51 -0
  74. package/dist/core/Styles.js +92 -0
  75. package/dist/core/Super.d.ts +17 -1
  76. package/dist/core/Super.js +35 -4
  77. package/dist/core/ViewsAdapter.d.ts +7 -2
  78. package/dist/core/ViewsAdapter.js +10 -3
  79. package/dist/index.d.ts +4 -0
  80. package/dist/index.js +4 -0
  81. package/dist/react/SkiaShell.js +33 -14
  82. package/dist/react/index.d.ts +15 -4
  83. package/dist/react/index.js +102 -15
  84. package/dist/react/reconciler.js +34 -12
  85. package/dist/vite/index.d.ts +9 -0
  86. package/dist/vite/index.js +36 -3
  87. package/package.json +33 -3
@@ -1,7 +1,8 @@
1
1
  import { Super } from "./Super";
2
2
  import { SkiaAccessibilityManager } from "./Accessibility";
3
+ import { HoverManager } from "./Hover";
3
4
  import { Colors, SKRect } from "./Types";
4
- import { ContextMenuEventArgs, GestureEventProcessingInfo, SKPoint, SkiaGesturesParameters, TouchActionEventArgs, } from "./Gestures";
5
+ import { ContextMenuEventArgs, GestureEventProcessingInfo, PointerData, SKPoint, SkiaGesturesParameters, TouchActionEventArgs, } from "./Gestures";
5
6
  /**
6
7
  * Mirrors DrawnUi Canvas (DrawnView): hosts one Content control on an HTML canvas element,
7
8
  * owns RenderingScale (devicePixelRatio), the surface, the on-demand frame loop and raw input.
@@ -12,6 +13,11 @@ export class Canvas {
12
13
  Element;
13
14
  /** Points a pointer may travel between Down and Up and still count as a tap (AppoMobi TouchEffect default). */
14
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;
15
21
  BackgroundColor = Colors.Transparent;
16
22
  /** Accelerated = WebGL surface, Default = software. Read once at first frame. */
17
23
  RenderingMode = "Accelerated";
@@ -21,8 +27,22 @@ export class Canvas {
21
27
  /** Frames per second over the last second of drawn frames. */
22
28
  FPS = 0;
23
29
  frameTimes = [];
30
+ /** DrawnView.WasRendered: a frame has been drawn on a ready surface (set after the first one completes). */
31
+ WasRendered = false;
32
+ /** DrawnView.CanRender: the surface (and its GPU context when accelerated) is ready to draw. */
33
+ get CanRender() { return !!this.surface && !this.disposed; }
34
+ /**
35
+ * DrawnView.WillFirstTimeDraw: raised once, right before the first frame drawn on a ready surface, with that
36
+ * frame's drawing context. The engine draws its first frame while it is being constructed (so the page never shows
37
+ * an unpainted canvas): pass it through the constructor's `init` to receive that frame.
38
+ */
39
+ WillFirstTimeDraw;
40
+ /** DrawnView.WasDrawn: raised after every frame has been drawn (the C# event passes no context). */
41
+ WasDrawn;
24
42
  /** Registry + rate-limited snapshot of accessible controls, rendered by the DOM overlay (DrawnUi AccessibilityManager). */
25
43
  AccessibilityManager = new SkiaAccessibilityManager();
44
+ /** Mouse hover of the controls (drawnui-cross 6m): ReceivesHover / IsHovered / HoverChanged. */
45
+ Hover = new HoverManager();
26
46
  content;
27
47
  get Content() { return this.content; }
28
48
  set Content(value) {
@@ -33,6 +53,7 @@ export class Canvas {
33
53
  value.Parent = undefined;
34
54
  value._superview = this;
35
55
  }
56
+ this.Hover.RequestCheck();
36
57
  this.Update();
37
58
  }
38
59
  surface;
@@ -42,17 +63,28 @@ export class Canvas {
42
63
  frameId = 0;
43
64
  disposed = false;
44
65
  observer;
45
- constructor(Element) {
66
+ /** The WebGL context is lost: no frames until the browser restores it. */
67
+ contextLost = false;
68
+ /**
69
+ * Bumped when the WebGL context was restored: every GPU object made before (Image caches, picture caches that may
70
+ * replay them, kept composite surfaces, effect textures) is remade at its next draw (DrawnUi.Rust Gpu::epoch).
71
+ */
72
+ GpuEpoch = 0;
73
+ /** `init` runs before the first frame is drawn: the place to attach `WillFirstTimeDraw` / `WasDrawn` for that frame. */
74
+ constructor(Element, init) {
46
75
  this.Element = Element;
47
76
  if (!Super.CK)
48
77
  throw new Error("DrawnUi: call Super.UseDrawnUi()...BuildAsync() before creating a Canvas");
78
+ init?.(this);
79
+ Element.addEventListener("webglcontextlost", this.onContextLost);
80
+ Element.addEventListener("webglcontextrestored", this.onContextRestored);
49
81
  this.observer = new ResizeObserver(() => this.OnResized());
50
82
  this.observer.observe(Element);
51
83
  this.OnResized();
52
84
  }
53
85
  /** Request a redraw (full measure + arrange + render) on the next animation frame. */
54
86
  Update() {
55
- if (this.frameId || !this.surface || this.disposed)
87
+ if (this.frameId || !this.surface || this.disposed || this.contextLost)
56
88
  return;
57
89
  const surface = this.surface;
58
90
  this.frameId = surface.requestAnimationFrame((c) => {
@@ -103,16 +135,53 @@ export class Canvas {
103
135
  this.RenderingScale = dpr;
104
136
  this.Element.width = w;
105
137
  this.Element.height = h;
138
+ if (this.contextLost)
139
+ return; // the restore makes the surface at this size
106
140
  this.ReleaseSurface();
107
141
  this.surface = this.CreateSurface(w, h);
108
142
  if (!this.surface)
109
143
  throw new Error("DrawnUi: cannot create surface");
110
144
  this.DrawNow();
111
145
  }
146
+ /** WebGL context lost: keep the browser restoring it (preventDefault) and stop the frames. */
147
+ onContextLost = (e) => {
148
+ e.preventDefault();
149
+ this.contextLost = true;
150
+ if (this.frameId) {
151
+ cancelAnimationFrame(this.frameId);
152
+ this.frameId = 0;
153
+ }
154
+ };
155
+ /**
156
+ * WebGL context restored (as DrawnUi.Rust and the C# Wasm / Blazor heads): every GL object of the lost generation is
157
+ * gone. The old Skia context is abandoned first, while its own GL handle is current and before the new context makes
158
+ * any object, so its GL calls reach only lost-generation objects. CanvasKit exposes no `abandonContext`;
159
+ * `releaseResourcesAndAbandonContext` is its only way to abandon, and after it freeing an old image or surface makes no
160
+ * GL call. Then the same WebGL object is registered again (the old handle deleted first, which clears the canvas'
161
+ * context object) with a new GrContext and surface; GpuEpoch makes the old generation's GPU objects remake themselves.
162
+ */
163
+ onContextRestored = () => {
164
+ if (this.disposed)
165
+ return;
166
+ if (this.grContext)
167
+ this.grContext.releaseResourcesAndAbandonContext();
168
+ this.surface?.delete();
169
+ this.surface = undefined;
170
+ this.grContext?.delete();
171
+ this.grContext = undefined;
172
+ if (this.glHandle) {
173
+ Super.CK.deleteContext(this.glHandle);
174
+ this.glHandle = undefined;
175
+ }
176
+ this.contextLost = false;
177
+ this.GpuEpoch++;
178
+ this.surface = this.CreateSurface(this.Element.width, this.Element.height);
179
+ this.DrawNow();
180
+ };
112
181
  /** Draws one frame immediately (outside the rAF loop) and presents it. */
113
182
  DrawNow() {
114
183
  const surface = this.surface;
115
- if (!surface || this.disposed)
184
+ if (!surface || this.disposed || this.contextLost)
116
185
  return;
117
186
  this.Draw(surface.getCanvas());
118
187
  surface.flush();
@@ -121,7 +190,16 @@ export class Canvas {
121
190
  Draw(canvas) {
122
191
  const started = performance.now();
123
192
  this.ProcessPendingGestures();
124
- const executed = this.ExecuteAnimators(Math.round(started * 1_000_000));
193
+ // Animators run on the frame's vsync time (inside a rAF callback document.timeline.currentTime is the frame
194
+ // timestamp), not on when this callback happened to start: callback starts drift by several ms around vsync
195
+ // (measured 12..23 ms between frames shown 16.7 ms apart), and a game loop moving by speed * delta then
196
+ // jumps unevenly on screen.
197
+ const frameTime = Number(document.timeline?.currentTime ?? started);
198
+ this.FrameTimeNanos = Math.round(frameTime * 1_000_000);
199
+ const executed = this.ExecuteAnimators(Math.round(frameTime * 1_000_000));
200
+ const canRender = this.CanRender;
201
+ if (canRender && !this.WasRendered)
202
+ this.WillFirstTimeDraw?.(this, { Canvas: canvas, Surface: this.surface });
125
203
  canvas.clear(Super.ParseColor(this.BackgroundColor));
126
204
  const root = this.content;
127
205
  if (root) {
@@ -132,6 +210,7 @@ export class Canvas {
132
210
  root.Render({ Context: { Canvas: canvas, Surface: this.surface }, Destination: new SKRect(0, 0, w, h), Scale: scale });
133
211
  }
134
212
  this.AccessibilityManager.OnFrameEnd(this.RenderingScale, this.Element.width, this.Element.height, () => this.Update());
213
+ this.Hover.AfterFrame(this.content, this.activeTouchIds.size > 0);
135
214
  const now = performance.now();
136
215
  this.FrameTime = now - started;
137
216
  this.FrameIndex++;
@@ -141,6 +220,10 @@ export class Canvas {
141
220
  this.FPS = this.frameTimes.length;
142
221
  if (executed > 0)
143
222
  this.Update(); // animators running: keep frames coming
223
+ // C# order: WasDrawn at the end of the frame, then the frame counts as rendered
224
+ this.WasDrawn?.(this);
225
+ if (!this.WasRendered && canRender)
226
+ this.WasRendered = true;
144
227
  }
145
228
  // ---- deferred disposal (DrawnUi DisposeObject: never delete Skia objects mid-frame) ----
146
229
  disposeQueue = [];
@@ -164,19 +247,61 @@ export class Canvas {
164
247
  UnregisterAnimator(uid) { this.AnimatingControls.delete(uid); }
165
248
  /** Ticks every registered animator once; returns how many ran. */
166
249
  ExecuteAnimators(frameTimeNanos) {
167
- let executed = 0;
250
+ let executed = 0, wake = Infinity;
168
251
  for (const a of [...this.AnimatingControls.values()]) {
169
252
  if (!a.Parent) {
170
253
  this.AnimatingControls.delete(a.Uid);
171
254
  continue;
172
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
173
274
  a.TickFrame(frameTimeNanos);
174
275
  executed++;
276
+ if (a.SleepUntilNanos > frameTimeNanos)
277
+ wake = Math.min(wake, a.SleepUntilNanos);
175
278
  }
279
+ this.WakeAt(wake, frameTimeNanos);
176
280
  return executed;
177
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
+ }
178
295
  /** Frames drawn so far (SkiaBackdrop uses it to refresh once after a cached record). */
179
296
  FrameIndex = 0;
297
+ /**
298
+ * DrawnView.WheelDeltaPerNotch: the Wheel.Delta one mouse-wheel notch produces, 100 CSS pixels in Chrome and Edge. A
299
+ * scroll moves by the event's share of a notch, so a touchpad, which sends many small events, scrolls as far as the
300
+ * fingers moved. 0 = units unknown: every event scrolls one line.
301
+ */
302
+ WheelDeltaPerNotch = 100;
303
+ /** Vsync time of the last frame the animators ran on (nanoseconds, the performance.now() timeline). */
304
+ FrameTimeNanos = 0;
180
305
  /** On-screen surface (SkiaBackdrop snapshots it). */
181
306
  get Surface() { return this.surface; }
182
307
  /**
@@ -225,6 +350,12 @@ export class Canvas {
225
350
  }
226
351
  Dispose() {
227
352
  this.disposed = true;
353
+ if (this.wakeTimer) {
354
+ clearTimeout(this.wakeTimer);
355
+ this.wakeTimer = 0;
356
+ }
357
+ this.Element.removeEventListener("webglcontextlost", this.onContextLost);
358
+ this.Element.removeEventListener("webglcontextrestored", this.onContextRestored);
228
359
  this.Gestures = "Disabled";
229
360
  this.observer.disconnect();
230
361
  this.content?.Dispose();
@@ -260,17 +391,17 @@ export class Canvas {
260
391
  e.type === "pointercancel" ? "Cancelled" : undefined;
261
392
  if (!type)
262
393
  return;
263
- // only the primary mouse button is a touch (DrawnUi.Blazor): right / middle buttons never start a gesture, and
264
- // their Up / Cancel (pointer never pressed here) is ignored too, so a right click cannot end as a Tapped
265
- if (type === "Pressed" && e.pointerType === "mouse" && e.button !== 0)
266
- return;
267
394
  if (type === "Moved" && !this.activeTouchIds.has(e.pointerId)) {
395
+ if (e.pointerType === "touch")
396
+ return; // touch never hovers
397
+ if (this.activeTouchIds.size === 0)
398
+ this.Hover.Move(this.content, e.offsetX * this.RenderingScale, e.offsetY * this.RenderingScale);
268
399
  if (e.pointerType === "mouse")
269
- this.UpdateCursor(e.offsetX, e.offsetY);
400
+ this.UpdateCursor();
270
401
  return;
271
- } // hover not ported (TouchActionResult.Pointer)
402
+ }
272
403
  if ((type === "Released" || type === "Cancelled") && !this.activeTouchIds.has(e.pointerId))
273
- return;
404
+ return; // Up of a pointer that never pressed here
274
405
  // Capture so Up outside the element still arrives; throws for synthetic events (tests) — harmless.
275
406
  if (type === "Pressed") {
276
407
  try {
@@ -284,9 +415,29 @@ export class Canvas {
284
415
  args.Type = type;
285
416
  args.Scale = this.RenderingScale;
286
417
  args.Location = new SKPoint((e.clientX - rect.left) * this.RenderingScale, (e.clientY - rect.top) * this.RenderingScale);
418
+ // every mouse button is delivered with its PointerData (AppoMobi.Gestures): a right click is a Down / Up / Tapped
419
+ // with Button "Right" for games and custom controls, and a ContextMenu on top
420
+ const pd = new PointerData();
421
+ pd.Button = e.button === 0 ? "Left" : e.button === 1 ? "Middle" : e.button === 2 ? "Right" : e.button === 3 ? "XButton1" : e.button === 4 ? "XButton2" : "Extended";
422
+ pd.ButtonNumber = e.button === 1 ? 3 : e.button === 2 ? 2 : e.button + 1;
423
+ pd.DeviceType = e.pointerType === "touch" ? "Touch" : e.pointerType === "pen" ? "Pen" : "Mouse";
424
+ pd.PressedButtons = e.buttons;
425
+ args.Pointer = pd;
287
426
  this.OnTouchAction(args);
427
+ if (type === "Released" || type === "Cancelled") {
428
+ this.claimedTouches.delete(e.pointerId);
429
+ this.UpdateTouchAction();
430
+ }
288
431
  };
432
+ /** The mouse left the canvas: hover ends at once, also while content moves. */
433
+ onPointerLeave = (e) => { if (e.pointerType !== "touch")
434
+ this.Hover.Leave(); };
289
435
  preventTouch = (e) => e.preventDefault();
436
+ /** Gestures="Enabled": touch / pen pointers whose Down a control used (see OnTouchAction). */
437
+ claimedTouches = new Set();
438
+ /** Gestures="Enabled": while a claimed touch is down the page may not take its drag. */
439
+ preventClaimedTouch = (e) => { if (this.claimedTouches.size > 0 && e.cancelable)
440
+ e.preventDefault(); };
290
441
  /** Called when no control handled a context-menu request; return true to suppress the browser's canvas menu. */
291
442
  ContextMenu;
292
443
  /** DOM contextmenu (right click / long press / Menu key) -> ContextMenuEventArgs routed through the tree like a tap. */
@@ -306,32 +457,33 @@ export class Canvas {
306
457
  cursorPointer = false;
307
458
  /**
308
459
  * DrawnUi.Blazor shows `cursor: pointer` over interactive controls through its overlay elements; here the overlay
309
- * is pointer-events:none, so the mouse position is tested against the accessibility snapshot (the accessible
310
- * controls' rects in points, already sorted and rate-limited), each hit control answering WantsPointerCursor
311
- * (itself tappable, or a tappable span of a label), and the canvas element's cursor is switched only when the
312
- * 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.
313
465
  */
314
- UpdateCursor(x, y) {
466
+ UpdateCursor() {
315
467
  let hit = false;
316
- const scale = this.RenderingScale;
317
- for (const n of this.AccessibilityManager.Snapshot) {
318
- if (x < n.Rect.Left || x >= n.Rect.Right || y < n.Rect.Top || y >= n.Rect.Bottom)
319
- continue;
320
- // the control decides (whole control, or a tappable span of a label); point in pixels relative to the control's
321
- // origin, taken from the snapshot rect (already carries the scroll / cache offset; a rotated control gets its bbox)
322
- 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))
323
472
  hit = true;
324
- break;
325
- }
326
473
  }
327
474
  if (hit !== this.cursorPointer) {
328
475
  this.cursorPointer = hit;
329
476
  this.Element.style.cursor = hit ? "pointer" : "";
330
477
  }
331
478
  }
332
- /** Mouse wheel -> TouchActionResult.Wheel (page scroll suppressed while gestures are enabled). */
479
+ /**
480
+ * Mouse wheel -> TouchActionResult.Wheel.
481
+ * Lock keeps every wheel on the canvas. Enabled shares it with the page, like DrawnUi.Blazor's
482
+ * TouchHandlingStyle.Manual: the page scrolls unless a control consumed this wheel. The browser needs that answer
483
+ * inside this handler, so the wheel is processed now instead of at the next frame (Blazor does the same, for the
484
+ * same reason); pending gestures are flushed first so the event order is kept.
485
+ */
333
486
  onWheel = (e) => {
334
- e.preventDefault();
335
487
  const rect = this.Element.getBoundingClientRect();
336
488
  const args = new TouchActionEventArgs();
337
489
  args.Id = -1;
@@ -339,29 +491,129 @@ export class Canvas {
339
491
  args.Scale = this.RenderingScale;
340
492
  args.Location = new SKPoint((e.clientX - rect.left) * this.RenderingScale, (e.clientY - rect.top) * this.RenderingScale);
341
493
  args.StartingLocation = args.Location;
342
- args.Wheel = { Delta: e.deltaY !== 0 ? e.deltaY : e.deltaX };
343
- this.OnGestureEvent(args, "Wheel");
494
+ // lines and pages to pixels, then the dominant axis (the .NET browser heads, fc8980c1); a vertical scroll leaves
495
+ // horizontal events alone (C# ba03cb20)
496
+ const factor = e.deltaMode === 1 ? 40 : e.deltaMode === 2 ? 800 : 1;
497
+ const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY);
498
+ args.Wheel = { Delta: (horizontal ? e.deltaX : e.deltaY) * factor, IsHorizontal: horizontal };
499
+ if (this.gestures === "Lock") {
500
+ e.preventDefault();
501
+ this.OnGestureEvent(args, "Wheel");
502
+ return;
503
+ }
504
+ if (this.ProcessGestureNow(args, "Wheel"))
505
+ e.preventDefault();
506
+ };
507
+ /**
508
+ * Processes one gesture immediately (after whatever is queued) and reports whether a control USED it: a consumer
509
+ * that marked the event Handled, or any consumer that is not a `BlockGesturesBelow` layer. A blocker returns itself
510
+ * for every gesture it keeps from the controls below (a shell page, a popup backdrop), which is not a use: counting
511
+ * it would block the page wheel over every shell app.
512
+ */
513
+ ProcessGestureNow(args, result) {
514
+ this.ProcessPendingGestures();
515
+ const root = this.content;
516
+ const consumed = root ? this.ProcessGestures(root, SkiaGesturesParameters.Create(result, args)) : null;
517
+ this.Update();
518
+ return !!consumed && (args.Handled || !consumed.BlockGesturesBelow);
519
+ }
520
+ /**
521
+ * The page axes a touch pan over the canvas hands to the browser: "pan-y" when the page (or a scrolling ancestor) can
522
+ * scroll vertically, "pan-x" horizontally, both, or "none". Mirrors MAUI's Gestures="Enabled" inside a native scroll
523
+ * view, where the parent takes the pans it scrolls in.
524
+ */
525
+ static PageScrollAxes(el) {
526
+ const scrolls = (v) => v === "auto" || v === "scroll" || v === "overlay";
527
+ const clips = (v) => v === "hidden" || v === "clip";
528
+ let x = false, y = false;
529
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
530
+ const st = getComputedStyle(n);
531
+ if (!y && scrolls(st.overflowY) && n.scrollHeight > n.clientHeight + 1)
532
+ y = true;
533
+ if (!x && scrolls(st.overflowX) && n.scrollWidth > n.clientWidth + 1)
534
+ x = true;
535
+ }
536
+ // the viewport scrolls unless html or body clips it
537
+ const root = document.scrollingElement ?? document.documentElement;
538
+ const hs = getComputedStyle(document.documentElement), bs = getComputedStyle(document.body);
539
+ if (!y && !clips(hs.overflowY) && !clips(bs.overflowY) && root.scrollHeight > root.clientHeight + 1)
540
+ y = true;
541
+ if (!x && !clips(hs.overflowX) && !clips(bs.overflowX) && root.scrollWidth > root.clientWidth + 1)
542
+ x = true;
543
+ // a canvas filling a framed document (a widget in an iframe): the page that scrolls is the parent, which a
544
+ // cross-origin frame cannot inspect, so assume it scrolls vertically; horizontal pans and taps stay on the canvas
545
+ if (!y && window.self !== window.top)
546
+ y = true;
547
+ return x && y ? "pan-x pan-y" : y ? "pan-y" : x ? "pan-x" : "none";
548
+ }
549
+ /**
550
+ * Gestures="Enabled" shares touch pans with the page like MAUI's Enabled inside a native scroll view: along an axis
551
+ * the page can scroll, the browser takes the pan (and sends pointercancel), taps and the other axis stay on the
552
+ * canvas. A page that cannot scroll, and Lock, keep every touch. The browser reads touch-action when a touch starts,
553
+ * so it is kept current ahead of time: on attach, when the window / html / body resize, and after every touch.
554
+ */
555
+ UpdateTouchAction = () => {
556
+ if (this.gestures === "Disabled")
557
+ return;
558
+ const pan = this.gestures === "Enabled" ? Canvas.PageScrollAxes(this.Element) : "none";
559
+ if (this.Element.style.touchAction !== pan)
560
+ this.Element.style.touchAction = pan;
344
561
  };
562
+ pageObserver;
345
563
  AttachInput() {
346
564
  const el = this.Element;
347
- el.style.touchAction = "none";
565
+ this.UpdateTouchAction();
566
+ window.addEventListener("resize", this.UpdateTouchAction);
567
+ this.pageObserver = new ResizeObserver(this.UpdateTouchAction);
568
+ this.pageObserver.observe(document.documentElement);
569
+ this.pageObserver.observe(document.body);
348
570
  el.style.userSelect = "none";
349
571
  for (const t of ["pointerdown", "pointermove", "pointerup", "pointercancel"])
350
572
  el.addEventListener(t, this.onPointer);
573
+ el.addEventListener("pointerleave", this.onPointerLeave);
351
574
  el.addEventListener("contextmenu", this.onContextMenu);
352
575
  el.addEventListener("wheel", this.onWheel, { passive: false });
353
- if (this.gestures === "Lock")
576
+ if (this.gestures === "Lock") {
354
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
+ }
587
+ else if (this.gestures === "Enabled")
588
+ el.addEventListener("touchmove", this.preventClaimedTouch, { passive: false });
355
589
  }
590
+ /** The page's own overscroll-behavior before Lock set it to none (html, body), put back on detach. */
591
+ pageOverscroll;
356
592
  DetachInput() {
357
593
  const el = this.Element;
358
594
  el.style.touchAction = "";
595
+ window.removeEventListener("resize", this.UpdateTouchAction);
596
+ this.pageObserver?.disconnect();
597
+ this.pageObserver = undefined;
359
598
  el.style.userSelect = "";
360
599
  for (const t of ["pointerdown", "pointermove", "pointerup", "pointercancel"])
361
600
  el.removeEventListener(t, this.onPointer);
601
+ el.removeEventListener("pointerleave", this.onPointerLeave);
362
602
  el.removeEventListener("contextmenu", this.onContextMenu);
363
603
  el.removeEventListener("wheel", this.onWheel);
364
604
  el.removeEventListener("touchmove", this.preventTouch);
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
+ }
613
+ this.claimedTouches.clear();
614
+ for (const id of [...this.longPressTimers.keys()])
615
+ this.DisarmLongPress(id);
616
+ this.longPressed.clear();
365
617
  this.activeTouchIds.clear();
366
618
  this.pointerDownArgs.clear();
367
619
  this.previousTouchArgs.clear();
@@ -372,12 +624,23 @@ export class Canvas {
372
624
  return;
373
625
  const id = args.Id;
374
626
  if (args.Type === "Pressed") {
627
+ this.ArmLongPress(id, args);
375
628
  this.activeTouchIds.add(id);
376
629
  args.NumberOfTouches = this.activeTouchIds.size;
377
630
  args.StartingLocation = args.Location;
378
631
  args.IsInContact = true;
379
632
  this.pointerDownArgs.set(id, args);
380
633
  this.previousTouchArgs.set(id, args);
634
+ // Gestures="Enabled", touch or pen: page scrolling was left to touch-action alone, and iOS Safari still takes an
635
+ // off-axis drag from the page axis (pointercancel mid-gesture, w3c/pointerevents#303). So the Down is processed
636
+ // now, like the wheel (queued gestures first, same "used" rule): when a control used it the touch is claimed and
637
+ // its touchmoves are default-prevented until it ends. It is not queued as well, so it is processed exactly once and
638
+ // the Panning / Up that follow keep their order. An unclaimed touch shares the page as before.
639
+ if (this.gestures === "Enabled" && args.Pointer && args.Pointer.DeviceType !== "Mouse") {
640
+ if (this.ProcessGestureNow(args, "Down"))
641
+ this.claimedTouches.add(id);
642
+ return;
643
+ }
381
644
  this.OnGestureEvent(args, "Down");
382
645
  return;
383
646
  }
@@ -386,6 +649,9 @@ export class Canvas {
386
649
  const downArgs = this.pointerDownArgs.get(id);
387
650
  args.StartingLocation = downArgs ? downArgs.StartingLocation : args.Location;
388
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);
389
655
  if (args.Distance.Delta.X !== 0 || args.Distance.Delta.Y !== 0)
390
656
  this.OnGestureEvent(args, "Panning");
391
657
  this.previousTouchArgs.set(id, args);
@@ -393,7 +659,9 @@ export class Canvas {
393
659
  }
394
660
  if (args.Type === "Released" || args.Type === "Cancelled") {
395
661
  args.IsInContact = args.NumberOfTouches > 1;
396
- 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) {
397
665
  const threshold = Canvas.TappedCancelMoveThresholdPoints * Math.max(0.1, this.RenderingScale);
398
666
  if (Math.abs(args.Distance.Total.X) < threshold && Math.abs(args.Distance.Total.Y) < threshold)
399
667
  this.OnGestureEvent(args, "Tapped");
@@ -404,6 +672,30 @@ export class Canvas {
404
672
  this.activeTouchIds.delete(id);
405
673
  }
406
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
+ }
407
699
  OnGestureEvent(args, result) {
408
700
  this.pendingGestures.push(SkiaGesturesParameters.Create(result, args));
409
701
  this.Update();
@@ -418,8 +710,44 @@ export class Canvas {
418
710
  for (const args of batch)
419
711
  this.ProcessGestures(root, args);
420
712
  }
713
+ /**
714
+ * The control that consumed the current gesture keeps it until it lets go (DrawnUi Canvas.HadInput). Without this a
715
+ * gesture is re-routed by the pointer position on every event, so anything the finger drags away from — a drag
716
+ * handle, a slider thumb, a button being held — stops receiving Panning the moment the pointer leaves its box.
717
+ * Single pointer, which is what the web gives us here; C# keeps a set for multi-touch.
718
+ */
719
+ gestureOwner = null;
720
+ /** DrawnUi IsSavedGesture: the types replayed to the owner instead of being routed again. */
721
+ static IsSavedGesture(type) {
722
+ return type === "Panning" || type === "Wheel" || type === "Up";
723
+ }
421
724
  /** Entry into the control tree, same shape as DrawnUi Canvas.ProcessGestures. */
422
725
  ProcessGestures(root, args) {
423
- return root.ProcessGestures(args, new GestureEventProcessingInfo(args.Event.Location, SKPoint.Empty, SKPoint.Empty, null));
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";
731
+ if (args.Type === "Down")
732
+ this.gestureOwner = null;
733
+ else if (this.gestureOwner && Canvas.IsSavedGesture(args.Type) && (!wheel || this.activeTouchIds.size > 0)) {
734
+ const owner = this.gestureOwner;
735
+ const alive = !!owner.Superview && owner.IsVisible && !owner.InputTransparent;
736
+ const consumed = alive ? owner.OnSkiaGestureEvent(args, info()) : null;
737
+ if (consumed) {
738
+ // it still wants the gesture: nobody else sees this one (C# skips the tree pass for a saved gesture)
739
+ if (!wheel)
740
+ this.gestureOwner = args.Type === "Up" ? null : consumed;
741
+ return consumed;
742
+ }
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;
747
+ }
748
+ const consumed = root.ProcessGestures(args, info());
749
+ if (!wheel)
750
+ this.gestureOwner = consumed && args.Type !== "Up" ? consumed : null;
751
+ return consumed;
424
752
  }
425
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
  }