drawnui-react 0.1.0-preview.1 → 0.1.0-preview.11

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 (49) hide show
  1. package/README.md +98 -29
  2. package/SKIPPED.md +23 -16
  3. package/dist/controls/DrawnGame.d.ts +37 -0
  4. package/dist/controls/DrawnGame.js +60 -0
  5. package/dist/controls/SkiaButton.d.ts +4 -1
  6. package/dist/controls/SkiaButton.js +20 -7
  7. package/dist/controls/SkiaCarousel.d.ts +6 -0
  8. package/dist/controls/SkiaCarousel.js +7 -1
  9. package/dist/controls/SkiaCheckbox.d.ts +0 -1
  10. package/dist/controls/SkiaCheckbox.js +0 -6
  11. package/dist/controls/SkiaEditor.js +8 -0
  12. package/dist/controls/SkiaLabel.d.ts +2 -0
  13. package/dist/controls/SkiaLabel.js +9 -0
  14. package/dist/controls/SkiaLayout.d.ts +10 -0
  15. package/dist/controls/SkiaLayout.js +64 -1
  16. package/dist/controls/SkiaRadioButton.js +1 -2
  17. package/dist/controls/SkiaScroll.d.ts +9 -0
  18. package/dist/controls/SkiaScroll.js +120 -13
  19. package/dist/controls/SkiaScrollBar.d.ts +27 -0
  20. package/dist/controls/SkiaScrollBar.js +51 -2
  21. package/dist/controls/SkiaShaderCarousel.d.ts +5 -0
  22. package/dist/controls/SkiaShaderCarousel.js +5 -0
  23. package/dist/controls/SkiaSwitch.d.ts +0 -1
  24. package/dist/controls/SkiaSwitch.js +0 -6
  25. package/dist/controls/SkiaToggle.d.ts +18 -4
  26. package/dist/controls/SkiaToggle.js +45 -3
  27. package/dist/core/Animators.d.ts +9 -0
  28. package/dist/core/Animators.js +26 -1
  29. package/dist/core/Canvas.d.ts +82 -4
  30. package/dist/core/Canvas.js +261 -10
  31. package/dist/core/Gestures.d.ts +47 -1
  32. package/dist/core/Gestures.js +43 -1
  33. package/dist/core/SkiaControl.d.ts +38 -2
  34. package/dist/core/SkiaControl.js +76 -3
  35. package/dist/core/Styles.d.ts +51 -0
  36. package/dist/core/Styles.js +92 -0
  37. package/dist/core/Super.d.ts +10 -1
  38. package/dist/core/Super.js +20 -3
  39. package/dist/core/Types.d.ts +7 -2
  40. package/dist/index.d.ts +2 -0
  41. package/dist/index.js +2 -0
  42. package/dist/react/SkiaShell.js +5 -2
  43. package/dist/react/index.d.ts +22 -4
  44. package/dist/react/index.js +25 -4
  45. package/dist/react/reconciler.js +2 -0
  46. package/dist/vite/index.d.ts +74 -0
  47. package/dist/vite/index.js +204 -0
  48. package/package.json +35 -4
  49. package/PARITY.md +0 -287
@@ -29,7 +29,14 @@ export class AnimatorBase {
29
29
  if (!parent.PostAnimators.includes(effect))
30
30
  parent.PostAnimators.push(effect);
31
31
  }
32
- const ok = parent.Superview?.RegisterAnimator(this) ?? false;
32
+ const superview = parent.Superview;
33
+ const ok = superview?.RegisterAnimator(this) ?? false;
34
+ // C# SkiaControl.RegisterAnimator: not attached yet, queue it; the parent starts it on its first arrange
35
+ if (!superview && !parent.IsDisposed) {
36
+ const pending = parent.PendingUnattachedAnimators ??= [];
37
+ if (!pending.includes(this))
38
+ pending.push(this);
39
+ }
33
40
  parent.Repaint();
34
41
  return ok;
35
42
  }
@@ -42,6 +49,9 @@ export class AnimatorBase {
42
49
  if (i >= 0)
43
50
  parent.PostAnimators.splice(i, 1);
44
51
  }
52
+ const pending = parent.PendingUnattachedAnimators?.indexOf(this) ?? -1;
53
+ if (pending >= 0)
54
+ parent.PendingUnattachedAnimators.splice(pending, 1);
45
55
  parent.Superview?.UnregisterAnimator(this.Uid);
46
56
  }
47
57
  Start(delayMs = 0) {
@@ -96,6 +106,21 @@ export class AnimatorBase {
96
106
  this.OnStop?.();
97
107
  }
98
108
  }
109
+ /**
110
+ * DrawnUi ActionOnTickAnimator: runs `action(frameTimeNanos)` on every frame until stopped, a game loop for
111
+ * DrawnGame or any per-frame work.
112
+ */
113
+ export class ActionOnTickAnimator extends AnimatorBase {
114
+ action;
115
+ constructor(parent, action) {
116
+ super(parent);
117
+ this.action = action;
118
+ }
119
+ TickFrame(frameTimeNanos) {
120
+ this.action(frameTimeNanos);
121
+ return super.TickFrame(frameTimeNanos);
122
+ }
123
+ }
99
124
  /** DrawnUi SkiaValueAnimator: drives mValue from mMinValue to mMaxValue over Speed ms with Easing. */
100
125
  export class SkiaValueAnimator extends AnimatorBase {
101
126
  mValue = 0;
@@ -1,9 +1,9 @@
1
1
  import type { Surface } from "canvaskit-wasm";
2
- import type { SkiaControl } from "./SkiaControl";
2
+ import type { DrawingContext, SkiaControl } from "./SkiaControl";
3
3
  import type { AnimatorBase } from "./Animators";
4
4
  import { SkiaAccessibilityManager } from "./Accessibility";
5
5
  import { type Color, type RenderingModeType } from "./Types";
6
- import { type GesturesMode, SkiaGesturesParameters, TouchActionEventArgs } from "./Gestures";
6
+ import { ContextMenuEventArgs, type GesturesMode, SkiaGesturesParameters, TouchActionEventArgs } from "./Gestures";
7
7
  /**
8
8
  * Mirrors DrawnUi Canvas (DrawnView): hosts one Content control on an HTML canvas element,
9
9
  * owns RenderingScale (devicePixelRatio), the surface, the on-demand frame loop and raw input.
@@ -23,6 +23,18 @@ export declare class Canvas {
23
23
  /** Frames per second over the last second of drawn frames. */
24
24
  FPS: number;
25
25
  private frameTimes;
26
+ /** DrawnView.WasRendered: a frame has been drawn on a ready surface (set after the first one completes). */
27
+ WasRendered: boolean;
28
+ /** DrawnView.CanRender: the surface (and its GPU context when accelerated) is ready to draw. */
29
+ get CanRender(): boolean;
30
+ /**
31
+ * DrawnView.WillFirstTimeDraw: raised once, right before the first frame drawn on a ready surface, with that
32
+ * frame's drawing context. The engine draws its first frame while it is being constructed (so the page never shows
33
+ * an unpainted canvas): pass it through the constructor's `init` to receive that frame.
34
+ */
35
+ WillFirstTimeDraw?: (sender: Canvas, context: DrawingContext["Context"]) => void;
36
+ /** DrawnView.WasDrawn: raised after every frame has been drawn (the C# event passes no context). */
37
+ WasDrawn?: (sender: Canvas) => void;
26
38
  /** Registry + rate-limited snapshot of accessible controls, rendered by the DOM overlay (DrawnUi AccessibilityManager). */
27
39
  readonly AccessibilityManager: SkiaAccessibilityManager;
28
40
  private content?;
@@ -35,7 +47,8 @@ export declare class Canvas {
35
47
  private frameId;
36
48
  private disposed;
37
49
  private readonly observer;
38
- constructor(Element: HTMLCanvasElement);
50
+ /** `init` runs before the first frame is drawn: the place to attach `WillFirstTimeDraw` / `WasDrawn` for that frame. */
51
+ constructor(Element: HTMLCanvasElement, init?: (canvas: Canvas) => void);
39
52
  /** Request a redraw (full measure + arrange + render) on the next animation frame. */
40
53
  Update(): void;
41
54
  /** Deletes the current surface; a pending frame on it is cancelled first (drawing on a deleted surface faults). */
@@ -66,6 +79,18 @@ export declare class Canvas {
66
79
  FrameIndex: number;
67
80
  /** On-screen surface (SkiaBackdrop snapshots it). */
68
81
  get Surface(): Surface | undefined;
82
+ /**
83
+ * A picture of what is on screen right now, as PNG bytes (C# DrawnView.TakeScreenShot).
84
+ *
85
+ * The on-screen canvas cannot simply be read: an accelerated surface lives in a WebGL drawing
86
+ * buffer the browser clears after compositing, so both `canvas.toDataURL()` and a snapshot of
87
+ * the live surface come back blank. The content is therefore drawn once more into an offscreen
88
+ * RASTER surface, which can be read back anywhere.
89
+ *
90
+ * This is a still picture, not a frame of the loop: animators are not ticked and the frame
91
+ * counters do not move, so taking one never changes what the next real frame shows.
92
+ */
93
+ TakeScreenShot(): Uint8Array | null;
69
94
  Dispose(): void;
70
95
  private gestures;
71
96
  get Gestures(): GesturesMode;
@@ -76,14 +101,67 @@ export declare class Canvas {
76
101
  private readonly pendingGestures;
77
102
  private readonly onPointer;
78
103
  private readonly preventTouch;
79
- /** Mouse wheel -> TouchActionResult.Wheel (page scroll suppressed while gestures are enabled). */
104
+ /** Gestures="Enabled": touch / pen pointers whose Down a control used (see OnTouchAction). */
105
+ private readonly claimedTouches;
106
+ /** Gestures="Enabled": while a claimed touch is down the page may not take its drag. */
107
+ private readonly preventClaimedTouch;
108
+ /** Called when no control handled a context-menu request; return true to suppress the browser's canvas menu. */
109
+ ContextMenu?: (sender: Canvas, e: ContextMenuEventArgs) => boolean | void;
110
+ /** DOM contextmenu (right click / long press / Menu key) -> ContextMenuEventArgs routed through the tree like a tap. */
111
+ private readonly onContextMenu;
112
+ private cursorPointer;
113
+ /**
114
+ * DrawnUi.Blazor shows `cursor: pointer` over interactive controls through its overlay elements; here the overlay
115
+ * is pointer-events:none, so the mouse position is tested against the accessibility snapshot (the accessible
116
+ * controls' rects in points, already sorted and rate-limited), each hit control answering WantsPointerCursor
117
+ * (itself tappable, or a tappable span of a label), and the canvas element's cursor is switched only when the
118
+ * answer changes. Mouse moves only, no work without a mouse and none in the frame loop.
119
+ */
120
+ private UpdateCursor;
121
+ /**
122
+ * Mouse wheel -> TouchActionResult.Wheel.
123
+ * Lock keeps every wheel on the canvas. Enabled shares it with the page, like DrawnUi.Blazor's
124
+ * TouchHandlingStyle.Manual: the page scrolls unless a control consumed this wheel. The browser needs that answer
125
+ * inside this handler, so the wheel is processed now instead of at the next frame (Blazor does the same, for the
126
+ * same reason); pending gestures are flushed first so the event order is kept.
127
+ */
80
128
  private readonly onWheel;
129
+ /**
130
+ * Processes one gesture immediately (after whatever is queued) and reports whether a control USED it: a consumer
131
+ * that marked the event Handled, or any consumer that is not a `BlockGesturesBelow` layer. A blocker returns itself
132
+ * for every gesture it keeps from the controls below (a shell page, a popup backdrop), which is not a use: counting
133
+ * it would block the page wheel over every shell app.
134
+ */
135
+ private ProcessGestureNow;
136
+ /**
137
+ * The page axes a touch pan over the canvas hands to the browser: "pan-y" when the page (or a scrolling ancestor) can
138
+ * scroll vertically, "pan-x" horizontally, both, or "none". Mirrors MAUI's Gestures="Enabled" inside a native scroll
139
+ * view, where the parent takes the pans it scrolls in.
140
+ */
141
+ private static PageScrollAxes;
142
+ /**
143
+ * Gestures="Enabled" shares touch pans with the page like MAUI's Enabled inside a native scroll view: along an axis
144
+ * the page can scroll, the browser takes the pan (and sends pointercancel), taps and the other axis stay on the
145
+ * canvas. A page that cannot scroll, and Lock, keep every touch. The browser reads touch-action when a touch starts,
146
+ * so it is kept current ahead of time: on attach, when the window / html / body resize, and after every touch.
147
+ */
148
+ private readonly UpdateTouchAction;
149
+ private pageObserver?;
81
150
  private AttachInput;
82
151
  private DetachInput;
83
152
  /** Port of DrawnUi.Blazor Canvas.OnTouchAction: per-pointer state machine producing Down / Panning / Tapped / Up. */
84
153
  OnTouchAction(args: TouchActionEventArgs): void;
85
154
  private OnGestureEvent;
86
155
  private ProcessPendingGestures;
156
+ /**
157
+ * The control that consumed the current gesture keeps it until it lets go (DrawnUi Canvas.HadInput). Without this a
158
+ * gesture is re-routed by the pointer position on every event, so anything the finger drags away from — a drag
159
+ * handle, a slider thumb, a button being held — stops receiving Panning the moment the pointer leaves its box.
160
+ * Single pointer, which is what the web gives us here; C# keeps a set for multi-touch.
161
+ */
162
+ private gestureOwner;
163
+ /** DrawnUi IsSavedGesture: the types replayed to the owner instead of being routed again. */
164
+ private static IsSavedGesture;
87
165
  /** Entry into the control tree, same shape as DrawnUi Canvas.ProcessGestures. */
88
166
  protected ProcessGestures(root: SkiaControl, args: SkiaGesturesParameters): SkiaControl | null;
89
167
  }
@@ -1,7 +1,7 @@
1
1
  import { Super } from "./Super";
2
2
  import { SkiaAccessibilityManager } from "./Accessibility";
3
3
  import { Colors, SKRect } from "./Types";
4
- import { GestureEventProcessingInfo, SKPoint, SkiaGesturesParameters, TouchActionEventArgs, } from "./Gestures";
4
+ import { ContextMenuEventArgs, GestureEventProcessingInfo, PointerData, SKPoint, SkiaGesturesParameters, TouchActionEventArgs, } from "./Gestures";
5
5
  /**
6
6
  * Mirrors DrawnUi Canvas (DrawnView): hosts one Content control on an HTML canvas element,
7
7
  * owns RenderingScale (devicePixelRatio), the surface, the on-demand frame loop and raw input.
@@ -21,6 +21,18 @@ export class Canvas {
21
21
  /** Frames per second over the last second of drawn frames. */
22
22
  FPS = 0;
23
23
  frameTimes = [];
24
+ /** DrawnView.WasRendered: a frame has been drawn on a ready surface (set after the first one completes). */
25
+ WasRendered = false;
26
+ /** DrawnView.CanRender: the surface (and its GPU context when accelerated) is ready to draw. */
27
+ get CanRender() { return !!this.surface && !this.disposed; }
28
+ /**
29
+ * DrawnView.WillFirstTimeDraw: raised once, right before the first frame drawn on a ready surface, with that
30
+ * frame's drawing context. The engine draws its first frame while it is being constructed (so the page never shows
31
+ * an unpainted canvas): pass it through the constructor's `init` to receive that frame.
32
+ */
33
+ WillFirstTimeDraw;
34
+ /** DrawnView.WasDrawn: raised after every frame has been drawn (the C# event passes no context). */
35
+ WasDrawn;
24
36
  /** Registry + rate-limited snapshot of accessible controls, rendered by the DOM overlay (DrawnUi AccessibilityManager). */
25
37
  AccessibilityManager = new SkiaAccessibilityManager();
26
38
  content;
@@ -42,10 +54,12 @@ export class Canvas {
42
54
  frameId = 0;
43
55
  disposed = false;
44
56
  observer;
45
- constructor(Element) {
57
+ /** `init` runs before the first frame is drawn: the place to attach `WillFirstTimeDraw` / `WasDrawn` for that frame. */
58
+ constructor(Element, init) {
46
59
  this.Element = Element;
47
60
  if (!Super.CK)
48
61
  throw new Error("DrawnUi: call Super.UseDrawnUi()...BuildAsync() before creating a Canvas");
62
+ init?.(this);
49
63
  this.observer = new ResizeObserver(() => this.OnResized());
50
64
  this.observer.observe(Element);
51
65
  this.OnResized();
@@ -121,7 +135,15 @@ export class Canvas {
121
135
  Draw(canvas) {
122
136
  const started = performance.now();
123
137
  this.ProcessPendingGestures();
124
- const executed = this.ExecuteAnimators(Math.round(started * 1_000_000));
138
+ // Animators run on the frame's vsync time (inside a rAF callback document.timeline.currentTime is the frame
139
+ // timestamp), not on when this callback happened to start: callback starts drift by several ms around vsync
140
+ // (measured 12..23 ms between frames shown 16.7 ms apart), and a game loop moving by speed * delta then
141
+ // jumps unevenly on screen.
142
+ const frameTime = Number(document.timeline?.currentTime ?? started);
143
+ const executed = this.ExecuteAnimators(Math.round(frameTime * 1_000_000));
144
+ const canRender = this.CanRender;
145
+ if (canRender && !this.WasRendered)
146
+ this.WillFirstTimeDraw?.(this, { Canvas: canvas, Surface: this.surface });
125
147
  canvas.clear(Super.ParseColor(this.BackgroundColor));
126
148
  const root = this.content;
127
149
  if (root) {
@@ -141,6 +163,10 @@ export class Canvas {
141
163
  this.FPS = this.frameTimes.length;
142
164
  if (executed > 0)
143
165
  this.Update(); // animators running: keep frames coming
166
+ // C# order: WasDrawn at the end of the frame, then the frame counts as rendered
167
+ this.WasDrawn?.(this);
168
+ if (!this.WasRendered && canRender)
169
+ this.WasRendered = true;
144
170
  }
145
171
  // ---- deferred disposal (DrawnUi DisposeObject: never delete Skia objects mid-frame) ----
146
172
  disposeQueue = [];
@@ -179,6 +205,50 @@ export class Canvas {
179
205
  FrameIndex = 0;
180
206
  /** On-screen surface (SkiaBackdrop snapshots it). */
181
207
  get Surface() { return this.surface; }
208
+ /**
209
+ * A picture of what is on screen right now, as PNG bytes (C# DrawnView.TakeScreenShot).
210
+ *
211
+ * The on-screen canvas cannot simply be read: an accelerated surface lives in a WebGL drawing
212
+ * buffer the browser clears after compositing, so both `canvas.toDataURL()` and a snapshot of
213
+ * the live surface come back blank. The content is therefore drawn once more into an offscreen
214
+ * RASTER surface, which can be read back anywhere.
215
+ *
216
+ * This is a still picture, not a frame of the loop: animators are not ticked and the frame
217
+ * counters do not move, so taking one never changes what the next real frame shows.
218
+ */
219
+ TakeScreenShot() {
220
+ const CK = Super.CK;
221
+ const w = this.Element.width, h = this.Element.height;
222
+ if (!CK || !w || !h)
223
+ return null;
224
+ const surface = CK.MakeSurface(w, h);
225
+ if (!surface)
226
+ return null;
227
+ try {
228
+ const canvas = surface.getCanvas();
229
+ canvas.clear(Super.ParseColor(this.BackgroundColor));
230
+ const root = this.content;
231
+ if (root) {
232
+ const scale = this.RenderingScale;
233
+ root.Measure(w, h, scale);
234
+ root.Arrange(new SKRect(0, 0, w, h), root.WidthRequest, root.HeightRequest, scale);
235
+ root.Render({ Context: { Canvas: canvas, Surface: surface }, Destination: new SKRect(0, 0, w, h), Scale: scale });
236
+ }
237
+ surface.flush();
238
+ const image = surface.makeImageSnapshot();
239
+ if (!image)
240
+ return null;
241
+ try {
242
+ return image.encodeToBytes();
243
+ }
244
+ finally {
245
+ image.delete();
246
+ }
247
+ }
248
+ finally {
249
+ surface.delete();
250
+ }
251
+ }
182
252
  Dispose() {
183
253
  this.disposed = true;
184
254
  this.Gestures = "Disabled";
@@ -216,8 +286,13 @@ export class Canvas {
216
286
  e.type === "pointercancel" ? "Cancelled" : undefined;
217
287
  if (!type)
218
288
  return;
219
- if (type === "Moved" && !this.activeTouchIds.has(e.pointerId))
220
- return; // hover not ported (TouchActionResult.Pointer)
289
+ if (type === "Moved" && !this.activeTouchIds.has(e.pointerId)) {
290
+ if (e.pointerType === "mouse")
291
+ this.UpdateCursor(e.offsetX, e.offsetY);
292
+ return;
293
+ } // hover not ported (TouchActionResult.Pointer)
294
+ if ((type === "Released" || type === "Cancelled") && !this.activeTouchIds.has(e.pointerId))
295
+ return; // Up of a pointer that never pressed here
221
296
  // Capture so Up outside the element still arrives; throws for synthetic events (tests) — harmless.
222
297
  if (type === "Pressed") {
223
298
  try {
@@ -231,12 +306,76 @@ export class Canvas {
231
306
  args.Type = type;
232
307
  args.Scale = this.RenderingScale;
233
308
  args.Location = new SKPoint((e.clientX - rect.left) * this.RenderingScale, (e.clientY - rect.top) * this.RenderingScale);
309
+ // every mouse button is delivered with its PointerData (AppoMobi.Gestures): a right click is a Down / Up / Tapped
310
+ // with Button "Right" for games and custom controls, and a ContextMenu on top
311
+ const pd = new PointerData();
312
+ pd.Button = e.button === 0 ? "Left" : e.button === 1 ? "Middle" : e.button === 2 ? "Right" : e.button === 3 ? "XButton1" : e.button === 4 ? "XButton2" : "Extended";
313
+ pd.ButtonNumber = e.button === 1 ? 3 : e.button === 2 ? 2 : e.button + 1;
314
+ pd.DeviceType = e.pointerType === "touch" ? "Touch" : e.pointerType === "pen" ? "Pen" : "Mouse";
315
+ pd.PressedButtons = e.buttons;
316
+ args.Pointer = pd;
234
317
  this.OnTouchAction(args);
318
+ if (type === "Released" || type === "Cancelled") {
319
+ this.claimedTouches.delete(e.pointerId);
320
+ this.UpdateTouchAction();
321
+ }
235
322
  };
236
323
  preventTouch = (e) => e.preventDefault();
237
- /** Mouse wheel -> TouchActionResult.Wheel (page scroll suppressed while gestures are enabled). */
324
+ /** Gestures="Enabled": touch / pen pointers whose Down a control used (see OnTouchAction). */
325
+ claimedTouches = new Set();
326
+ /** Gestures="Enabled": while a claimed touch is down the page may not take its drag. */
327
+ preventClaimedTouch = (e) => { if (this.claimedTouches.size > 0 && e.cancelable)
328
+ e.preventDefault(); };
329
+ /** Called when no control handled a context-menu request; return true to suppress the browser's canvas menu. */
330
+ ContextMenu;
331
+ /** DOM contextmenu (right click / long press / Menu key) -> ContextMenuEventArgs routed through the tree like a tap. */
332
+ onContextMenu = (e) => {
333
+ const rect = this.Element.getBoundingClientRect();
334
+ const scale = this.RenderingScale;
335
+ const x = e.clientX - rect.left, y = e.clientY - rect.top;
336
+ const pointerType = e.pointerType;
337
+ const source = pointerType === "touch" || pointerType === "pen" ? "touch" : pointerType === "mouse" || e.button === 2 ? "mouse" : "keyboard";
338
+ const args = new ContextMenuEventArgs(new SKPoint(x, y), new SKPoint(x * scale, y * scale), source, e);
339
+ let handled = this.content?.ProcessContextMenu(args.Pixels, args) ?? false;
340
+ if (!handled && this.ContextMenu)
341
+ handled = this.ContextMenu(this, args) === true;
342
+ if (handled)
343
+ e.preventDefault();
344
+ };
345
+ cursorPointer = false;
346
+ /**
347
+ * DrawnUi.Blazor shows `cursor: pointer` over interactive controls through its overlay elements; here the overlay
348
+ * is pointer-events:none, so the mouse position is tested against the accessibility snapshot (the accessible
349
+ * controls' rects in points, already sorted and rate-limited), each hit control answering WantsPointerCursor
350
+ * (itself tappable, or a tappable span of a label), and the canvas element's cursor is switched only when the
351
+ * answer changes. Mouse moves only, no work without a mouse and none in the frame loop.
352
+ */
353
+ UpdateCursor(x, y) {
354
+ let hit = false;
355
+ const scale = this.RenderingScale;
356
+ for (const n of this.AccessibilityManager.Snapshot) {
357
+ if (x < n.Rect.Left || x >= n.Rect.Right || y < n.Rect.Top || y >= n.Rect.Bottom)
358
+ continue;
359
+ // the control decides (whole control, or a tappable span of a label); point in pixels relative to the control's
360
+ // origin, taken from the snapshot rect (already carries the scroll / cache offset; a rotated control gets its bbox)
361
+ if (n.Source.WantsPointerCursor((x - n.Rect.Left) * scale, (y - n.Rect.Top) * scale)) {
362
+ hit = true;
363
+ break;
364
+ }
365
+ }
366
+ if (hit !== this.cursorPointer) {
367
+ this.cursorPointer = hit;
368
+ this.Element.style.cursor = hit ? "pointer" : "";
369
+ }
370
+ }
371
+ /**
372
+ * Mouse wheel -> TouchActionResult.Wheel.
373
+ * Lock keeps every wheel on the canvas. Enabled shares it with the page, like DrawnUi.Blazor's
374
+ * TouchHandlingStyle.Manual: the page scrolls unless a control consumed this wheel. The browser needs that answer
375
+ * inside this handler, so the wheel is processed now instead of at the next frame (Blazor does the same, for the
376
+ * same reason); pending gestures are flushed first so the event order is kept.
377
+ */
238
378
  onWheel = (e) => {
239
- e.preventDefault();
240
379
  const rect = this.Element.getBoundingClientRect();
241
380
  const args = new TouchActionEventArgs();
242
381
  args.Id = -1;
@@ -245,26 +384,101 @@ export class Canvas {
245
384
  args.Location = new SKPoint((e.clientX - rect.left) * this.RenderingScale, (e.clientY - rect.top) * this.RenderingScale);
246
385
  args.StartingLocation = args.Location;
247
386
  args.Wheel = { Delta: e.deltaY !== 0 ? e.deltaY : e.deltaX };
248
- this.OnGestureEvent(args, "Wheel");
387
+ if (this.gestures === "Lock") {
388
+ e.preventDefault();
389
+ this.OnGestureEvent(args, "Wheel");
390
+ return;
391
+ }
392
+ if (this.ProcessGestureNow(args, "Wheel"))
393
+ e.preventDefault();
394
+ };
395
+ /**
396
+ * Processes one gesture immediately (after whatever is queued) and reports whether a control USED it: a consumer
397
+ * that marked the event Handled, or any consumer that is not a `BlockGesturesBelow` layer. A blocker returns itself
398
+ * for every gesture it keeps from the controls below (a shell page, a popup backdrop), which is not a use: counting
399
+ * it would block the page wheel over every shell app.
400
+ */
401
+ ProcessGestureNow(args, result) {
402
+ this.ProcessPendingGestures();
403
+ const root = this.content;
404
+ const consumed = root ? this.ProcessGestures(root, SkiaGesturesParameters.Create(result, args)) : null;
405
+ this.Update();
406
+ return !!consumed && (args.Handled || !consumed.BlockGesturesBelow);
407
+ }
408
+ /**
409
+ * The page axes a touch pan over the canvas hands to the browser: "pan-y" when the page (or a scrolling ancestor) can
410
+ * scroll vertically, "pan-x" horizontally, both, or "none". Mirrors MAUI's Gestures="Enabled" inside a native scroll
411
+ * view, where the parent takes the pans it scrolls in.
412
+ */
413
+ static PageScrollAxes(el) {
414
+ const scrolls = (v) => v === "auto" || v === "scroll" || v === "overlay";
415
+ const clips = (v) => v === "hidden" || v === "clip";
416
+ let x = false, y = false;
417
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
418
+ const st = getComputedStyle(n);
419
+ if (!y && scrolls(st.overflowY) && n.scrollHeight > n.clientHeight + 1)
420
+ y = true;
421
+ if (!x && scrolls(st.overflowX) && n.scrollWidth > n.clientWidth + 1)
422
+ x = true;
423
+ }
424
+ // the viewport scrolls unless html or body clips it
425
+ const root = document.scrollingElement ?? document.documentElement;
426
+ const hs = getComputedStyle(document.documentElement), bs = getComputedStyle(document.body);
427
+ if (!y && !clips(hs.overflowY) && !clips(bs.overflowY) && root.scrollHeight > root.clientHeight + 1)
428
+ y = true;
429
+ if (!x && !clips(hs.overflowX) && !clips(bs.overflowX) && root.scrollWidth > root.clientWidth + 1)
430
+ x = true;
431
+ // a canvas filling a framed document (a widget in an iframe): the page that scrolls is the parent, which a
432
+ // cross-origin frame cannot inspect, so assume it scrolls vertically; horizontal pans and taps stay on the canvas
433
+ if (!y && window.self !== window.top)
434
+ y = true;
435
+ return x && y ? "pan-x pan-y" : y ? "pan-y" : x ? "pan-x" : "none";
436
+ }
437
+ /**
438
+ * Gestures="Enabled" shares touch pans with the page like MAUI's Enabled inside a native scroll view: along an axis
439
+ * the page can scroll, the browser takes the pan (and sends pointercancel), taps and the other axis stay on the
440
+ * canvas. A page that cannot scroll, and Lock, keep every touch. The browser reads touch-action when a touch starts,
441
+ * so it is kept current ahead of time: on attach, when the window / html / body resize, and after every touch.
442
+ */
443
+ UpdateTouchAction = () => {
444
+ if (this.gestures === "Disabled")
445
+ return;
446
+ const pan = this.gestures === "Enabled" ? Canvas.PageScrollAxes(this.Element) : "none";
447
+ if (this.Element.style.touchAction !== pan)
448
+ this.Element.style.touchAction = pan;
249
449
  };
450
+ pageObserver;
250
451
  AttachInput() {
251
452
  const el = this.Element;
252
- el.style.touchAction = "none";
453
+ this.UpdateTouchAction();
454
+ window.addEventListener("resize", this.UpdateTouchAction);
455
+ this.pageObserver = new ResizeObserver(this.UpdateTouchAction);
456
+ this.pageObserver.observe(document.documentElement);
457
+ this.pageObserver.observe(document.body);
253
458
  el.style.userSelect = "none";
254
459
  for (const t of ["pointerdown", "pointermove", "pointerup", "pointercancel"])
255
460
  el.addEventListener(t, this.onPointer);
461
+ el.addEventListener("contextmenu", this.onContextMenu);
256
462
  el.addEventListener("wheel", this.onWheel, { passive: false });
257
463
  if (this.gestures === "Lock")
258
464
  el.addEventListener("touchmove", this.preventTouch, { passive: false });
465
+ else if (this.gestures === "Enabled")
466
+ el.addEventListener("touchmove", this.preventClaimedTouch, { passive: false });
259
467
  }
260
468
  DetachInput() {
261
469
  const el = this.Element;
262
470
  el.style.touchAction = "";
471
+ window.removeEventListener("resize", this.UpdateTouchAction);
472
+ this.pageObserver?.disconnect();
473
+ this.pageObserver = undefined;
263
474
  el.style.userSelect = "";
264
475
  for (const t of ["pointerdown", "pointermove", "pointerup", "pointercancel"])
265
476
  el.removeEventListener(t, this.onPointer);
477
+ el.removeEventListener("contextmenu", this.onContextMenu);
266
478
  el.removeEventListener("wheel", this.onWheel);
267
479
  el.removeEventListener("touchmove", this.preventTouch);
480
+ el.removeEventListener("touchmove", this.preventClaimedTouch);
481
+ this.claimedTouches.clear();
268
482
  this.activeTouchIds.clear();
269
483
  this.pointerDownArgs.clear();
270
484
  this.previousTouchArgs.clear();
@@ -281,6 +495,16 @@ export class Canvas {
281
495
  args.IsInContact = true;
282
496
  this.pointerDownArgs.set(id, args);
283
497
  this.previousTouchArgs.set(id, args);
498
+ // Gestures="Enabled", touch or pen: page scrolling was left to touch-action alone, and iOS Safari still takes an
499
+ // off-axis drag from the page axis (pointercancel mid-gesture, w3c/pointerevents#303). So the Down is processed
500
+ // now, like the wheel (queued gestures first, same "used" rule): when a control used it the touch is claimed and
501
+ // its touchmoves are default-prevented until it ends. It is not queued as well, so it is processed exactly once and
502
+ // the Panning / Up that follow keep their order. An unclaimed touch shares the page as before.
503
+ if (this.gestures === "Enabled" && args.Pointer && args.Pointer.DeviceType !== "Mouse") {
504
+ if (this.ProcessGestureNow(args, "Down"))
505
+ this.claimedTouches.add(id);
506
+ return;
507
+ }
284
508
  this.OnGestureEvent(args, "Down");
285
509
  return;
286
510
  }
@@ -321,8 +545,35 @@ export class Canvas {
321
545
  for (const args of batch)
322
546
  this.ProcessGestures(root, args);
323
547
  }
548
+ /**
549
+ * The control that consumed the current gesture keeps it until it lets go (DrawnUi Canvas.HadInput). Without this a
550
+ * gesture is re-routed by the pointer position on every event, so anything the finger drags away from — a drag
551
+ * handle, a slider thumb, a button being held — stops receiving Panning the moment the pointer leaves its box.
552
+ * Single pointer, which is what the web gives us here; C# keeps a set for multi-touch.
553
+ */
554
+ gestureOwner = null;
555
+ /** DrawnUi IsSavedGesture: the types replayed to the owner instead of being routed again. */
556
+ static IsSavedGesture(type) {
557
+ return type === "Panning" || type === "Wheel" || type === "Up";
558
+ }
324
559
  /** Entry into the control tree, same shape as DrawnUi Canvas.ProcessGestures. */
325
560
  ProcessGestures(root, args) {
326
- return root.ProcessGestures(args, new GestureEventProcessingInfo(args.Event.Location, SKPoint.Empty, SKPoint.Empty, null));
561
+ const info = () => new GestureEventProcessingInfo(args.Event.Location, SKPoint.Empty, SKPoint.Empty, null);
562
+ if (args.Type === "Down")
563
+ this.gestureOwner = null;
564
+ else if (this.gestureOwner && Canvas.IsSavedGesture(args.Type)) {
565
+ const owner = this.gestureOwner;
566
+ const alive = !!owner.Superview && owner.IsVisible && !owner.InputTransparent;
567
+ const consumed = alive ? owner.OnSkiaGestureEvent(args, info()) : null;
568
+ if (consumed) {
569
+ // it still wants the gesture: nobody else sees this one (C# skips the tree pass for a saved gesture)
570
+ this.gestureOwner = args.Type === "Up" ? null : consumed;
571
+ return consumed;
572
+ }
573
+ this.gestureOwner = null; // it let go (a button whose press turned into a pan): route normally again
574
+ }
575
+ const consumed = root.ProcessGestures(args, info());
576
+ this.gestureOwner = consumed && args.Type !== "Up" ? consumed : null;
577
+ return consumed;
327
578
  }
328
579
  }
@@ -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/Wheel/Pointer/Touch declared for parity, not produced yet. */
12
+ /** Recognized gesture (TouchActionResult). LongPressing/Pointer/Touch declared for parity, not produced yet. */
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,9 +21,23 @@ export declare class DistanceInfo {
21
21
  Total: SKPoint;
22
22
  Velocity: SKPoint;
23
23
  }
24
+ /** AppoMobi.Gestures MouseButton: which button pressed / released (DOM button 0..4). */
25
+ export type MouseButton = "Left" | "Middle" | "Right" | "XButton1" | "XButton2" | "Extended";
26
+ export type PointerDeviceType = "Mouse" | "Touch" | "Pen";
27
+ /** AppoMobi.Gestures PointerData: the device and button behind a Down / Up / Tapped (every button is delivered). */
28
+ export declare class PointerData {
29
+ Button: MouseButton;
30
+ /** 1 = Left, 2 = Right, 3 = Middle, 4+ = extended, like AppoMobi.Gestures. */
31
+ ButtonNumber: number;
32
+ DeviceType: PointerDeviceType;
33
+ /** DOM `buttons` bitmask of the buttons held (1 left, 2 right, 4 middle, 8 back, 16 forward). */
34
+ PressedButtons: number;
35
+ }
24
36
  /** AppoMobi.Gestures TouchActionEventArgs. */
25
37
  export declare class TouchActionEventArgs {
26
38
  Id: number;
39
+ /** Device and button of this event (mouse, pen, touch); undefined for wheel. */
40
+ Pointer?: PointerData;
27
41
  Type: TouchActionType;
28
42
  /** Pixels. */
29
43
  Location: SKPoint;
@@ -39,6 +53,11 @@ export declare class TouchActionEventArgs {
39
53
  Wheel: {
40
54
  Delta: number;
41
55
  };
56
+ /**
57
+ * AppoMobi TouchActionEventArgs.Handled: set by a control that actually used the event. The canvas prevents the
58
+ * browser default (the page scrolling under the wheel) only for a Handled event in `Gestures="Enabled"`.
59
+ */
60
+ Handled: boolean;
42
61
  /** Same as the .NET helper: derives Start/End/Delta/Total from the previous event of the same pointer. */
43
62
  static FillDistanceInfo(current: TouchActionEventArgs, previous: TouchActionEventArgs | undefined): void;
44
63
  }
@@ -66,6 +85,33 @@ export declare class SkiaGesturesInfo {
66
85
  constructor(Args: SkiaGesturesParameters, Info: GestureEventProcessingInfo);
67
86
  }
68
87
  /** DrawnUi ControlTappedEventArgs. */
88
+ /** Where a context-menu request came from: right click, long press (Android fires contextmenu), or the keyboard Menu key. */
89
+ export type ContextMenuSource = "mouse" | "touch" | "keyboard";
90
+ /**
91
+ * Arguments of SkiaControl.ContextMenu / Canvas.ContextMenu: a browser `contextmenu` request on the canvas (right
92
+ * click, long press on touch, the Menu key). Handlers return true to take it: the browser's own menu ("Save image")
93
+ * is then suppressed; otherwise it shows as usual.
94
+ */
95
+ export declare class ContextMenuEventArgs {
96
+ /** Point on the canvas, in points (CSS px). */
97
+ Location: SKPoint;
98
+ /** Same point in pixels (canvas space). */
99
+ Pixels: SKPoint;
100
+ Source: ContextMenuSource;
101
+ /** The DOM event: modifiers, target, preventDefault if you need it yourself. */
102
+ Native: MouseEvent;
103
+ /** Deepest control under the point that had a ContextMenu handler (set while routing). */
104
+ Control?: SkiaControl;
105
+ /** Point inside Control, in pixels relative to its DrawingRect origin (set while routing). */
106
+ Local: SKPoint;
107
+ constructor(
108
+ /** Point on the canvas, in points (CSS px). */
109
+ Location: SKPoint,
110
+ /** Same point in pixels (canvas space). */
111
+ Pixels: SKPoint, Source: ContextMenuSource,
112
+ /** The DOM event: modifiers, target, preventDefault if you need it yourself. */
113
+ Native: MouseEvent);
114
+ }
69
115
  export declare class ControlTappedEventArgs {
70
116
  Control: SkiaControl;
71
117
  Parameters: SkiaGesturesParameters;