drawnui-react 0.1.0-preview.4 → 0.1.0-preview.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKIPPED.md CHANGED
@@ -47,7 +47,7 @@ DrawnUi name and semantics; nothing here is a redesign, only an omission.
47
47
  | Area | Status | Notes |
48
48
  |---|---|---|
49
49
  | `RenderingMode` | partial | `Accelerated` (WebGL) with automatic fallback to software; read once at first surface creation. |
50
- | `Gestures` param | ported | Enabled applies `touch-action:none; user-select:none`; Lock also blocks `touchmove` default. |
50
+ | `Gestures` param | ported | Touch: `Lock` applies `touch-action:none`; `Enabled` behaves like MAUI's Enabled inside a native scroll view: `touch-action` is the axes the page (or a scrolling ancestor) can scroll (`pan-y` / `pan-x` / both, `none` when it cannot), recomputed on attach, on window / html / body resize and after every touch, so a finger pan along a page axis scrolls the page and the canvas pointer is cancelled (a cancelled pointer never flings a `SkiaScroll`), taps and the other axis stay on the canvas, and a full-page app keeps every touch. `user-select:none` in both. Mouse wheel: `Enabled` shares it with the page like Blazor's `TouchHandlingStyle.Manual` (the wheel is processed in the DOM handler and the browser default is prevented only when a control used it: a scroll that moved, a `ConsumeGestures` handler that consumed, or any other consumer that is not a `BlockGesturesBelow` layer merely blocking the controls below, so an embedded canvas lets the page scroll over anything that does not scroll, including a shell page, and a scroll at its edge hands the wheel to the page; `TouchActionEventArgs.Handled` is the AppoMobi marker for that use); `Lock` prevents every wheel and also blocks `touchmove` default. Not ported: Blazor's pointer capture policy for `Enabled` (capture only while locked, release when a pan is not consumed); React captures every press. |
51
51
  | Mouse cursor over interactive controls (Blazor `Canvas.razor` `.xaml-a11y-interactive { cursor: pointer }`) | ported (differently) | The overlay here is `pointer-events: none`, so `Canvas.UpdateCursor` hit-tests mouse moves with no active pointer against the accessibility snapshot rects (`CanInteract` = has `Tapped`, buttons, links) and asks each hit control `WantsPointerCursor(x, y)` (default `AccessibilityCanInteract`; `SkiaLabel` also answers for its tappable `TextSpan`s), setting the canvas element's `cursor` only when the answer changes. Touch pointers do nothing. Engine hover (`IsHovered` / `OnHover`, `TouchActionResult.Pointer`) stays skipped, see Gestures. |
52
52
  | `ContextMenu` (right click / long press / Menu key, React extension) | ported (web-only) | `SkiaControl.ContextMenu` / `Canvas.ContextMenu` handlers with `ContextMenuEventArgs` (`Location` points, `Pixels`, `Local` px in the control, `Source`, `Native`); the DOM `contextmenu` event is routed like a tap (`ProcessContextMenu`: visible non-transparent children top-most first through `TransformPointToLocalSpace`, then the control, then the canvas). `true` = handled, browser menu suppressed; nothing handled = browser menu. Not in DrawnUi.Net. |
53
53
  | Keyboard, focus (`FocusedChild`) | skipped | |
@@ -560,6 +560,15 @@ export class SkiaScroll extends SkiaControl {
560
560
  const width = Math.max(0, this.ContentSize.Units.Width + (this.Orientation === "Horizontal" ? extra : 0) - viewportW);
561
561
  const height = Math.max(0, this.ContentSize.Units.Height + (this.Orientation !== "Horizontal" ? extra : 0) - viewportH);
562
562
  this.ContentOffsetBounds = new SKRect(-width, -height, 0, 0);
563
+ // C# InitializeViewport: pull an offset the new bounds no longer contain back inside them, here where
564
+ // the bounds change. Shrinking content (fewer items) otherwise leaves the viewport parked past its end
565
+ // with nothing on screen. Skipped under a live pan, a fling / bounce or pull-to-refresh, where being
566
+ // past the edge is intentional (rubber band, refresh indicator).
567
+ if (!this.IsUserPanning && !this.IsScrolling && !this.isRefreshing) {
568
+ const c = this.ClampOffset(this.offsetX, this.offsetY, this.ContentOffsetBounds, true);
569
+ this.ViewportOffsetX = c.X;
570
+ this.ViewportOffsetY = c.Y;
571
+ }
563
572
  this.ArrangeContent();
564
573
  this.CheckLoadMore();
565
574
  }
@@ -754,13 +763,35 @@ export class SkiaScroll extends SkiaControl {
754
763
  return;
755
764
  const i = Math.max(0, Math.min(items.length - 1, index));
756
765
  const scale = this.RenderingScale;
757
- const layoutTopPts = (layout.DrawingRect.Top - this.content.DrawingRect.Top) / scale;
758
- let target = layoutTopPts + layout.GetItemOffsetPixels(i) / scale;
766
+ const horizontal = this.Orientation === "Horizontal";
767
+ const viewport = (horizontal ? this.DrawingRect.Width : this.DrawingRect.Height) / scale;
768
+ // Item start and size in points, relative to the content start along the scroll axis.
769
+ let itemStart, itemSize;
770
+ if (!horizontal && layout.Type === "Column" && layout.Split <= 1) {
771
+ // The virtualized list: cells past the viewport are not realized, the structure knows their offsets.
772
+ itemStart = (layout.DrawingRect.Top - this.content.DrawingRect.Top + layout.GetItemOffsetPixels(i)) / scale;
773
+ itemSize = (layout.GetItemOffsetPixels(i + 1) - layout.GetItemOffsetPixels(i)) / scale;
774
+ }
775
+ else {
776
+ // A templated Row / Wrap / Grid realizes every item (C# non-list layouts are not virtualized): read the cell's
777
+ // arranged rect, like C# reads the structure cell's Destination.
778
+ const cell = layout.ChildrenFactory.GetViewForIndex(i);
779
+ if (!cell)
780
+ return;
781
+ const r = cell.DrawingRect, c = this.content.DrawingRect;
782
+ itemStart = (horizontal ? r.Left - c.Left : r.Top - c.Top) / scale;
783
+ itemSize = (horizontal ? r.Width : r.Height) / scale;
784
+ }
785
+ let target = itemStart;
759
786
  if (option === "End")
760
- target -= this.DrawingRect.Height / scale - layout.GetItemOffsetPixels(i + 1) / scale + layout.GetItemOffsetPixels(i) / scale;
787
+ target -= viewport - itemSize;
761
788
  else if (option === "Center")
762
- target -= this.DrawingRect.Height / scale / 2;
763
- this.ScrollTo(this.offsetX, -target, animate ? this.ScrollingSpeedMs / 1000 : 0, true);
789
+ target -= (viewport - itemSize) / 2;
790
+ const time = animate ? this.ScrollingSpeedMs / 1000 : 0;
791
+ if (horizontal)
792
+ this.ScrollTo(-target, this.offsetY, time, true);
793
+ else
794
+ this.ScrollTo(this.offsetX, -target, time, true);
764
795
  }
765
796
  StopAnimators() {
766
797
  this.flingEdgeX = null;
@@ -803,7 +834,10 @@ export class SkiaScroll extends SkiaControl {
803
834
  return child;
804
835
  if (!this.RespondsToGestures)
805
836
  return child ?? consumedDefault;
806
- return this.ApplyWheelScroll(e.Wheel.Delta) ? this : consumedDefault;
837
+ if (!this.ApplyWheelScroll(e.Wheel.Delta))
838
+ return consumedDefault; // at its edge: not used, the page may take it
839
+ e.Handled = true;
840
+ return this;
807
841
  }
808
842
  this.velocityY = e.Distance.Velocity.Y / scale;
809
843
  this.velocityX = e.Distance.Velocity.X / scale;
@@ -860,6 +894,12 @@ export class SkiaScroll extends SkiaControl {
860
894
  const swipeThreshold = SkiaScroll.ThesholdSwipeOnUp * scale;
861
895
  let vx = finalVelocity.X * this.ChangeVelocityScrolled;
862
896
  let vy = finalVelocity.Y * this.ChangeVelocityScrolled;
897
+ // a cancelled pointer (the browser took the pan to scroll the page, or a parent intercepted it) settles in place:
898
+ // flinging from the few moves that arrived before the cancel would scroll the list while the page scrolls
899
+ if (e.Type === "Cancelled") {
900
+ vx = 0;
901
+ vy = 0;
902
+ }
863
903
  let fling = false;
864
904
  if (this.OverScrolled) {
865
905
  const rest = this.ClampOffset(this.offsetX, this.offsetY, this.ContentOffsetBounds, true);
@@ -101,8 +101,35 @@ export declare class Canvas {
101
101
  * answer changes. Mouse moves only, no work without a mouse and none in the frame loop.
102
102
  */
103
103
  private UpdateCursor;
104
- /** Mouse wheel -> TouchActionResult.Wheel (page scroll suppressed while gestures are enabled). */
104
+ /**
105
+ * Mouse wheel -> TouchActionResult.Wheel.
106
+ * Lock keeps every wheel on the canvas. Enabled shares it with the page, like DrawnUi.Blazor's
107
+ * TouchHandlingStyle.Manual: the page scrolls unless a control consumed this wheel. The browser needs that answer
108
+ * inside this handler, so the wheel is processed now instead of at the next frame (Blazor does the same, for the
109
+ * same reason); pending gestures are flushed first so the event order is kept.
110
+ */
105
111
  private readonly onWheel;
112
+ /**
113
+ * Processes one gesture immediately (after whatever is queued) and reports whether a control USED it: a consumer
114
+ * that marked the event Handled, or any consumer that is not a `BlockGesturesBelow` layer. A blocker returns itself
115
+ * for every gesture it keeps from the controls below (a shell page, a popup backdrop), which is not a use: counting
116
+ * it would block the page wheel over every shell app.
117
+ */
118
+ private ProcessGestureNow;
119
+ /**
120
+ * The page axes a touch pan over the canvas hands to the browser: "pan-y" when the page (or a scrolling ancestor) can
121
+ * scroll vertically, "pan-x" horizontally, both, or "none". Mirrors MAUI's Gestures="Enabled" inside a native scroll
122
+ * view, where the parent takes the pans it scrolls in.
123
+ */
124
+ private static PageScrollAxes;
125
+ /**
126
+ * Gestures="Enabled" shares touch pans with the page like MAUI's Enabled inside a native scroll view: along an axis
127
+ * the page can scroll, the browser takes the pan (and sends pointercancel), taps and the other axis stay on the
128
+ * canvas. A page that cannot scroll, and Lock, keep every touch. The browser reads touch-action when a touch starts,
129
+ * so it is kept current ahead of time: on attach, when the window / html / body resize, and after every touch.
130
+ */
131
+ private readonly UpdateTouchAction;
132
+ private pageObserver?;
106
133
  private AttachInput;
107
134
  private DetachInput;
108
135
  /** Port of DrawnUi.Blazor Canvas.OnTouchAction: per-pointer state machine producing Down / Panning / Tapped / Up. */
@@ -289,6 +289,8 @@ export class Canvas {
289
289
  pd.PressedButtons = e.buttons;
290
290
  args.Pointer = pd;
291
291
  this.OnTouchAction(args);
292
+ if (type === "Released" || type === "Cancelled")
293
+ this.UpdateTouchAction();
292
294
  };
293
295
  preventTouch = (e) => e.preventDefault();
294
296
  /** Called when no control handled a context-menu request; return true to suppress the browser's canvas menu. */
@@ -333,9 +335,14 @@ export class Canvas {
333
335
  this.Element.style.cursor = hit ? "pointer" : "";
334
336
  }
335
337
  }
336
- /** Mouse wheel -> TouchActionResult.Wheel (page scroll suppressed while gestures are enabled). */
338
+ /**
339
+ * Mouse wheel -> TouchActionResult.Wheel.
340
+ * Lock keeps every wheel on the canvas. Enabled shares it with the page, like DrawnUi.Blazor's
341
+ * TouchHandlingStyle.Manual: the page scrolls unless a control consumed this wheel. The browser needs that answer
342
+ * inside this handler, so the wheel is processed now instead of at the next frame (Blazor does the same, for the
343
+ * same reason); pending gestures are flushed first so the event order is kept.
344
+ */
337
345
  onWheel = (e) => {
338
- e.preventDefault();
339
346
  const rect = this.Element.getBoundingClientRect();
340
347
  const args = new TouchActionEventArgs();
341
348
  args.Id = -1;
@@ -344,11 +351,73 @@ export class Canvas {
344
351
  args.Location = new SKPoint((e.clientX - rect.left) * this.RenderingScale, (e.clientY - rect.top) * this.RenderingScale);
345
352
  args.StartingLocation = args.Location;
346
353
  args.Wheel = { Delta: e.deltaY !== 0 ? e.deltaY : e.deltaX };
347
- this.OnGestureEvent(args, "Wheel");
354
+ if (this.gestures === "Lock") {
355
+ e.preventDefault();
356
+ this.OnGestureEvent(args, "Wheel");
357
+ return;
358
+ }
359
+ if (this.ProcessGestureNow(args, "Wheel"))
360
+ e.preventDefault();
361
+ };
362
+ /**
363
+ * Processes one gesture immediately (after whatever is queued) and reports whether a control USED it: a consumer
364
+ * that marked the event Handled, or any consumer that is not a `BlockGesturesBelow` layer. A blocker returns itself
365
+ * for every gesture it keeps from the controls below (a shell page, a popup backdrop), which is not a use: counting
366
+ * it would block the page wheel over every shell app.
367
+ */
368
+ ProcessGestureNow(args, result) {
369
+ this.ProcessPendingGestures();
370
+ const root = this.content;
371
+ const consumed = root ? this.ProcessGestures(root, SkiaGesturesParameters.Create(result, args)) : null;
372
+ this.Update();
373
+ return !!consumed && (args.Handled || !consumed.BlockGesturesBelow);
374
+ }
375
+ /**
376
+ * The page axes a touch pan over the canvas hands to the browser: "pan-y" when the page (or a scrolling ancestor) can
377
+ * scroll vertically, "pan-x" horizontally, both, or "none". Mirrors MAUI's Gestures="Enabled" inside a native scroll
378
+ * view, where the parent takes the pans it scrolls in.
379
+ */
380
+ static PageScrollAxes(el) {
381
+ const scrolls = (v) => v === "auto" || v === "scroll" || v === "overlay";
382
+ const clips = (v) => v === "hidden" || v === "clip";
383
+ let x = false, y = false;
384
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
385
+ const st = getComputedStyle(n);
386
+ if (!y && scrolls(st.overflowY) && n.scrollHeight > n.clientHeight + 1)
387
+ y = true;
388
+ if (!x && scrolls(st.overflowX) && n.scrollWidth > n.clientWidth + 1)
389
+ x = true;
390
+ }
391
+ // the viewport scrolls unless html or body clips it
392
+ const root = document.scrollingElement ?? document.documentElement;
393
+ const hs = getComputedStyle(document.documentElement), bs = getComputedStyle(document.body);
394
+ if (!y && !clips(hs.overflowY) && !clips(bs.overflowY) && root.scrollHeight > root.clientHeight + 1)
395
+ y = true;
396
+ if (!x && !clips(hs.overflowX) && !clips(bs.overflowX) && root.scrollWidth > root.clientWidth + 1)
397
+ x = true;
398
+ return x && y ? "pan-x pan-y" : y ? "pan-y" : x ? "pan-x" : "none";
399
+ }
400
+ /**
401
+ * Gestures="Enabled" shares touch pans with the page like MAUI's Enabled inside a native scroll view: along an axis
402
+ * the page can scroll, the browser takes the pan (and sends pointercancel), taps and the other axis stay on the
403
+ * canvas. A page that cannot scroll, and Lock, keep every touch. The browser reads touch-action when a touch starts,
404
+ * so it is kept current ahead of time: on attach, when the window / html / body resize, and after every touch.
405
+ */
406
+ UpdateTouchAction = () => {
407
+ if (this.gestures === "Disabled")
408
+ return;
409
+ const pan = this.gestures === "Enabled" ? Canvas.PageScrollAxes(this.Element) : "none";
410
+ if (this.Element.style.touchAction !== pan)
411
+ this.Element.style.touchAction = pan;
348
412
  };
413
+ pageObserver;
349
414
  AttachInput() {
350
415
  const el = this.Element;
351
- el.style.touchAction = "none";
416
+ this.UpdateTouchAction();
417
+ window.addEventListener("resize", this.UpdateTouchAction);
418
+ this.pageObserver = new ResizeObserver(this.UpdateTouchAction);
419
+ this.pageObserver.observe(document.documentElement);
420
+ this.pageObserver.observe(document.body);
352
421
  el.style.userSelect = "none";
353
422
  for (const t of ["pointerdown", "pointermove", "pointerup", "pointercancel"])
354
423
  el.addEventListener(t, this.onPointer);
@@ -360,6 +429,9 @@ export class Canvas {
360
429
  DetachInput() {
361
430
  const el = this.Element;
362
431
  el.style.touchAction = "";
432
+ window.removeEventListener("resize", this.UpdateTouchAction);
433
+ this.pageObserver?.disconnect();
434
+ this.pageObserver = undefined;
363
435
  el.style.userSelect = "";
364
436
  for (const t of ["pointerdown", "pointermove", "pointerup", "pointercancel"])
365
437
  el.removeEventListener(t, this.onPointer);
@@ -53,6 +53,11 @@ export declare class TouchActionEventArgs {
53
53
  Wheel: {
54
54
  Delta: number;
55
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;
56
61
  /** Same as the .NET helper: derives Start/End/Delta/Total from the previous event of the same pointer. */
57
62
  static FillDistanceInfo(current: TouchActionEventArgs, previous: TouchActionEventArgs | undefined): void;
58
63
  }
@@ -45,6 +45,11 @@ export class TouchActionEventArgs {
45
45
  DeltaTimeMs = 0;
46
46
  /** Mouse wheel: Delta > 0 = wheel down (browser deltaY sign). */
47
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;
48
53
  /** Same as the .NET helper: derives Start/End/Delta/Total from the previous event of the same pointer. */
49
54
  static FillDistanceInfo(current, previous) {
50
55
  if (!previous) {
@@ -1150,15 +1150,19 @@ export class SkiaControl {
1150
1150
  if (this.ConsumeGestures) {
1151
1151
  const sent = new SkiaGesturesInfo(args, apply);
1152
1152
  this.ConsumeGestures(this, sent);
1153
- if (args.Type !== "Up" && sent.Consumed)
1153
+ if (args.Type !== "Up" && sent.Consumed) {
1154
+ args.Event.Handled = true;
1154
1155
  return this;
1156
+ }
1155
1157
  }
1156
1158
  // C# EffectsGestureProcessors: attached effects that process gestures (ISkiaGestureProcessor) see them first
1157
1159
  if (!this.DisableEffects)
1158
1160
  for (const e of this.visualEffects) {
1159
1161
  const p = e.ProcessGestures;
1160
- if (typeof p === "function" && p.call(e, args, apply) && args.Type !== "Up")
1162
+ if (typeof p === "function" && p.call(e, args, apply) && args.Type !== "Up") {
1163
+ args.Event.Handled = true;
1161
1164
  return this;
1165
+ }
1162
1166
  }
1163
1167
  if (this.CheckChildrenGesturesLocked(args.Type))
1164
1168
  return consumedDefault;
@@ -100,7 +100,11 @@ export declare const SkiaDrawer: FC<LayoutProps<SkiaDrawerCtrl>>;
100
100
  export interface CanvasProps {
101
101
  BackgroundColor?: Color;
102
102
  RenderingMode?: RenderingModeType;
103
- /** Disabled (default) / Enabled / Lock, like DrawnUi Canvas.Gestures. */
103
+ /**
104
+ * Disabled (default) / Enabled / Lock, like DrawnUi Canvas.Gestures. Enabled shares input with the page like MAUI's
105
+ * Enabled inside a native scroll view: a touch pan along an axis the page can scroll scrolls the page, and the wheel
106
+ * scrolls it unless a control used the wheel. A page that cannot scroll keeps every touch. Lock keeps all input.
107
+ */
104
108
  Gestures?: GesturesMode;
105
109
  children?: ReactNode;
106
110
  style?: CSSProperties;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drawnui-react",
3
- "version": "0.1.0-preview.4",
3
+ "version": "0.1.0-preview.6",
4
4
  "description": "DrawnUi for React: the DrawnUi (.NET) drawn-UI engine in TypeScript on CanvasKit (Skia WASM). Same controls, same PascalCase API as DrawnUi.Net.",
5
5
  "license": "MIT",
6
6
  "author": "Nick Kovalsky (AppoMobi)",