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

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