drawnui-react 0.1.0-preview.3 → 0.1.0-preview.5

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
@@ -9,7 +9,7 @@ DrawnUi name and semantics; nothing here is a redesign, only an omission.
9
9
  |---|---|---|
10
10
  | `UseCache` | partial | `None`, `Operations` / `OperationsFull` (SkPicture replay), `Image` (offscreen surface snapshot, GPU-backed on WebGL, nearest-sampled blit), `ImageDoubleBuffered` (`RenderObjectPrevious` kept and drawn when a new cache cannot be produced yet, `DrawPlaceholder(ctx)` hook when there is none; recording itself is synchronous — no background thread, same as DrawnUi.Blazor with `CanUseCacheDoubleBuffering = false`), `ImageComposite` / `ImageCompositeGPU` (per-child composite, see the `UseCache="ImageComposite"` row below). `GPU` resolves to `Image` and `ImageCompositeGPU` to `ImageComposite`: no separate GPU-only path. `Super.CacheEnabled`, `RenderObject`/`CachedObject`, `InvalidateCache`, `DestroyRenderingObject`, `Canvas.DisposeObject` (deleted after flush). Defaults per C#: `SkiaLabel`/`SkiaSvg` Operations, everything else None. Not ported: `CacheSharing`, `AllowCaching`, offscreen bake threads, cache validity by GRContext. |
11
11
  | Incremental invalidation | partial | `Measure` returns the previous size when `!NeedMeasure` and constraints+scale are unchanged; `Update()`/`InvalidateMeasure()` bubble up and stale every ancestor cache; `Repaint()` keeps caches. Arrange still runs per frame for the whole tree; no dirty regions, no `DirtyChildrenTracker`. Post-animators on a control nested INSIDE a cached ancestor are drawn only when that ancestor re-records. |
12
- | Gestures core: `Canvas.Gestures` (Disabled/Enabled/Lock), `ProcessGestures(args, apply)`, `ConsumeGestures`, `Tapped`/`ChildTapped`, `InputTransparent`, `BlockGesturesBelow`, `LockChildrenGestures`, `HitBoxAuto`/`HitIsInside`/`IsGestureForChild`, `SkiaGesturesParameters`/`GestureEventProcessingInfo`/`SkiaGesturesInfo`/`ControlTappedEventArgs` | ported | Pointer events -> per-pointer `OnTouchAction` state machine (port of DrawnUi.Blazor) -> Down/Panning/Tapped/Up, queued and processed at frame start. Tap slop = 16pt like AppoMobi TouchEffect. Every mouse button is delivered (`Event.Pointer`: `Button`, `ButtonNumber`, `DeviceType`, `PressedButtons`, the AppoMobi.Gestures PointerData): a right click is Down / Up / Tapped with `Button` "Right" plus a `ContextMenu`; handlers that must be primary-only check `e.Parameters.Event.Pointer?.Button`. |
12
+ | Gestures core: `Canvas.Gestures` (Disabled/Enabled/Lock), `ProcessGestures(args, apply)`, `ConsumeGestures`, `Tapped`/`ChildTapped`, `InputTransparent`, `BlockGesturesBelow`, `LockChildrenGestures`, `HitBoxAuto`/`HitIsInside`/`IsGestureForChild`, `SkiaGesturesParameters`/`GestureEventProcessingInfo`/`SkiaGesturesInfo`/`ControlTappedEventArgs` | ported | Pointer events -> per-pointer `OnTouchAction` state machine (port of DrawnUi.Blazor) -> Down/Panning/Tapped/Up, queued and processed at frame start. Tap slop = 16pt like AppoMobi TouchEffect. The control that consumes a gesture keeps receiving it until it lets go (DrawnUi `Canvas.HadInput`, one pointer here instead of C#'s set): without it a drag handle, a slider thumb or a held button stops getting `Panning` the moment the finger leaves its box, and a control that stops consuming hands routing straight back to the tree (a button whose press turns into a scroll pan). Every mouse button is delivered (`Event.Pointer`: `Button`, `ButtonNumber`, `DeviceType`, `PressedButtons`, the AppoMobi.Gestures PointerData): a right click is Down / Up / Tapped with `Button` "Right" plus a `ContextMenu`; handlers that must be primary-only check `e.Parameters.Event.Pointer?.Button`. |
13
13
  | Gestures: `LongPressing`, `Pointer` (hover), multi-touch pinch, `SoftLock`, `AddGestures` attached props, `OnGestures` delegate | skipped | The enum members exist for parity and are never produced; there is no pinch member at all. Ported instead of skipped: velocity (`Gestures.ts` fills `Distance.Velocity` per move, the scroll / snapping physics run on it) and transform-aware mapping — a child receives the point through the inverse `RenderTransformMatrix` (`TransformPointToLocalSpace`), so rotated / scaled / translated controls hit-test correctly. `ChildOffset` stays zero because that inverse matrix replaces the C# cache-offset math (`TranslateInputCoords`), not because transforms are missing. `Wheel` IS produced (`Canvas.onWheel`). |
14
14
  | C# multi-subscriber `event`s | changed shape | One callback per event prop (`Tapped={fn}`), same names. `Command*` (ICommand) variants not ported. |
15
15
  | `Opacity`, `TranslationX/Y`, `Rotation`, `ScaleX/Y` (+`Scale`), `SkewX/Y`, `AnchorX/Y`, `HasTransform`, `RenderTransformMatrix`, `TransformPointToLocalSpace` | ported | Same matrix order as C# `ApplyTransforms` (pivot → rotation → scale/skew → translation), opacity = `saveLayer` alpha over the subtree, gestures mapped through the inverse matrix (`IsGestureForChild`, child receives the local point), accessibility rect = transformed bounds. Animations: `AnimateAsync`, `AnimateRangeAsync`, `FadeToAsync`, `ScaleToAsync`, `TranslateToAsync`, `RotateToAsync` as Promises with `AbortSignal` (one running per kind, like the C# per-property cancellation). Not ported: `RotationX/Y/Z`, `TranslationZ`, `Perspective1/2`, `CameraAngle`, `LinkTransforms`, `AddTranslationX/Y`, `UseTranslation*` overrides, `CustomizeLayerPaint`, `SkipRenderingOutOfBounds`. |
@@ -61,7 +61,7 @@ DrawnUi name and semantics; nothing here is a redesign, only an omission.
61
61
  | `SkiaLayout` Absolute / Column / Row (+ `SkiaStack`, `SkiaRow`, `SkiaLayer`) | ported | `Spacing`, `Padding`, `Children`/`Views`, `AddSubView`/`RemoveSubView`/`InsertSubView`. |
62
62
  | `SkiaLayout` templated (Column only): `ItemsSource` (array), `ItemTemplate` (factory `() => SkiaControl`), `RecyclingTemplate` Enabled/Disabled, `MeasureItemsStrategy` `MeasureFirst` (uniform, O(1) for 100k items) / `MeasureAll`, `ChildrenFactory` (ViewsAdapter pool: `GetOrCreateViewForIndex`, `ReleaseViewAt`), `FirstVisibleIndex`/`LastVisibleIndex`, `DebugString`, `ApplyItemsSource`, `VirtualisationInflated`, `BindingContext`/`ContextIndex` on every control, `SkiaDynamicDrawnCell.SetContent` | ported | Cells realized/bound/arranged/drawn only for the visible viewport (+inflation) each frame. |
63
63
  | `LoadMoreCommand` / `LoadMoreTopCommand` / `LoadMoreOffset` / `LoadMoreTopOffset` | ported | Callbacks instead of ICommand. Bottom fires once per content extent when within the offset of the end (or when the content underfills the viewport), re-arms when the content grows or the user moves away (>offset+100 pt, >2 s) — C# CheckNeedToLoadMore. Top arms only after the offset left the top edge, fires when it comes back. Not ported: opposite-direction cooldown (`IsOppositeLoadMoreBlocked`), `IInsideViewport.ShouldTriggerLoadMore`. |
64
- | Structure-preserving `ItemsSource` updates | ported (array diff) | C# listens to `ObservableCollection` Add/Insert; React apps replace the array, so the setter detects **append** (old is a prefix: measured heights kept, tail measured lazily) and **prepend** (old is a suffix: view indices shifted without rebinding, new head measured synchronously up to 200 items, `ItemsInsertedAtStart(px)` lets the `SkiaScroll` move its offset so visible rows stay put — C# head-insert rebase). Any other change rebuilds the structure (heights re-measured, offset kept). Not ported: removals/moves in place, `Replace`. |
64
+ | Structure-preserving `ItemsSource` updates | ported (array diff) | C# listens to `ObservableCollection` Add/Insert; React apps replace the array, so the setter detects **append** (old is a prefix: measured heights kept, tail measured lazily) and **prepend** (old is a suffix: view indices shifted without rebinding, new head measured synchronously up to 200 items, `ItemsInsertedAtStart(px)` lets the `SkiaScroll` move its offset so visible rows stay put — C# head-insert rebase). and **reorder** (same items permuted, the C# `ObservableCollection.Move` case: contexts rebound, the index-keyed measured heights permuted with their items, nothing re-measured, extent and scroll offset unchanged). Any other change rebuilds the structure (heights re-measured, offset kept). Not ported: removals in place, `Replace`. |
65
65
  | Templated Row/Grid/Wrap, `Split`, `ItemTemplateSelector`, `ItemTemplatePoolSize`/`ReserveTemplates`, `UsePreparedViews`, `SkiaCachedStack`, measure memo | skipped | |
66
66
  | `ItemsSourceWindow` / `WindowSourceThreshold` (built-in window over huge sources) | not needed | The C# window bounds pipeline work (structures, pools, measures) for `MeasureFirst`/`MeasureAll` over big collections. React `MeasureVisible` measures on demand and keeps only visible cells realized, so the pipeline is already O(visible); memory of the array itself belongs to the app (paginate with `LoadMore*`). Revisit only if `MeasureAll` over 100k+ items becomes a requirement. |
67
67
  | `MeasureItemsStrategy="MeasureVisible"` + background measurement | ported | Initial pass measures enough items to fill the viewport (≥ `BackgroundMeasurementBatchSize`), content extent = exact prefix + average × remaining (C# MeasureList estimate), visible cells measured on demand and laid out with their real heights, the prefix grows in `requestIdleCallback` slices (time-budgeted, then one `InvalidateMeasure`), `LastMeasuredIndex`, `DebugString` shows `measured n/N`. Not ported: cancellation/`_pendingStructureChanges` interplay, `UsePreparedViews`, windowed sources, anchor correction when an estimate above the viewport changes (content may shift by the delta). |
@@ -70,6 +70,16 @@ export declare class SkiaLayout extends SkiaControl {
70
70
  * anything else rebuilds. Returns false when a full rebuild is needed.
71
71
  */
72
72
  private ApplyIncrementalChange;
73
+ /**
74
+ * Same items in another order — a drag-to-reorder, or any code writing a permuted array. DrawnUi handles the
75
+ * matching `ObservableCollection.Move` by keeping the structure and only rebinding the contexts, because its
76
+ * arrange re-flows each row from its bound view (`HandleStructurePreservingMove` / `ApplyMoveChange`, 2026-09-08).
77
+ * The measured heights here are index-keyed and authoritative, so they travel with their items instead: nothing is
78
+ * remeasured, the extent cannot change, and the scroll keeps its offset. Rebuilding instead would reset
79
+ * `MeasureVisible` to "exact for the first screen, estimated after" and throw a scrolled list back towards the top.
80
+ * Returns false when the two arrays are not a permutation of each other, so a real content change still rebuilds.
81
+ */
82
+ private ApplyReorderChange;
73
83
  /** Factory creating one cell (DrawnUi DataTemplate). Cells receive the item as BindingContext. */
74
84
  get ItemTemplate(): (() => SkiaControl) | undefined;
75
85
  set ItemTemplate(value: (() => SkiaControl) | undefined);
@@ -87,7 +87,11 @@ export class SkiaLayout extends SkiaControl {
87
87
  */
88
88
  ApplyIncrementalChange(old, items) {
89
89
  const n = items.length, o = old.length;
90
- if (n <= o || o === 0)
90
+ if (o === 0)
91
+ return false;
92
+ if (n === o)
93
+ return this.ApplyReorderChange(old, items);
94
+ if (n < o)
91
95
  return false;
92
96
  const k = n - o;
93
97
  const scale = this.RenderingScale, gap = this.Spacing * scale, w = this.measuredWidthPx;
@@ -143,6 +147,65 @@ export class SkiaLayout extends SkiaControl {
143
147
  }
144
148
  return false;
145
149
  }
150
+ /**
151
+ * Same items in another order — a drag-to-reorder, or any code writing a permuted array. DrawnUi handles the
152
+ * matching `ObservableCollection.Move` by keeping the structure and only rebinding the contexts, because its
153
+ * arrange re-flows each row from its bound view (`HandleStructurePreservingMove` / `ApplyMoveChange`, 2026-09-08).
154
+ * The measured heights here are index-keyed and authoritative, so they travel with their items instead: nothing is
155
+ * remeasured, the extent cannot change, and the scroll keeps its offset. Rebuilding instead would reset
156
+ * `MeasureVisible` to "exact for the first screen, estimated after" and throw a scrolled list back towards the top.
157
+ * Returns false when the two arrays are not a permutation of each other, so a real content change still rebuilds.
158
+ */
159
+ ApplyReorderChange(old, items) {
160
+ const n = items.length;
161
+ if (this.MeasureItemsStrategy === "MeasureAll" && this.itemHeights.length !== n)
162
+ return false;
163
+ if (this.MeasureItemsStrategy === "MeasureVisible" && this.mvHeights.length !== n)
164
+ return false;
165
+ const moved = [];
166
+ for (let i = 0; i < n; i++)
167
+ if (old[i] !== items[i])
168
+ moved.push(i);
169
+ if (moved.length === 0) {
170
+ this.ChildrenFactory.UpdateItems(items);
171
+ return true;
172
+ } // same order, new array reference
173
+ // every slot that changed must hold an item that was in one of those slots before, otherwise this is not a reorder
174
+ const free = new Map();
175
+ for (const i of moved) {
176
+ const q = free.get(old[i]);
177
+ if (q)
178
+ q.push(i);
179
+ else
180
+ free.set(old[i], [i]);
181
+ }
182
+ const from = new Int32Array(n);
183
+ for (let i = 0; i < n; i++)
184
+ from[i] = i;
185
+ for (const i of moved) {
186
+ const q = free.get(items[i]);
187
+ if (!q || q.length === 0)
188
+ return false;
189
+ from[i] = q.shift();
190
+ }
191
+ if (this.MeasureItemsStrategy === "MeasureAll") {
192
+ const was = this.itemHeights;
193
+ this.itemHeights = Array.from({ length: n }, (_, i) => was[from[i]]);
194
+ }
195
+ else if (this.MeasureItemsStrategy === "MeasureVisible") {
196
+ const was = this.mvHeights, heights = new Float64Array(n);
197
+ for (let i = 0; i < n; i++)
198
+ heights[i] = was[from[i]];
199
+ this.mvHeights = heights;
200
+ // an item that was never measured can land inside the exact prefix: rebuild it over the leading measured run
201
+ this.mvPrefix = new Float64Array(n + 1);
202
+ this.mvMeasured = 0;
203
+ this.MvExtendPrefix(this.Spacing * this.RenderingScale);
204
+ }
205
+ // MeasureFirst has one height for every row, so a reorder is a pure rebind there (same as the C# note).
206
+ this.ChildrenFactory.UpdateItems(items);
207
+ return true;
208
+ }
146
209
  /** Factory creating one cell (DrawnUi DataTemplate). Cells receive the item as BindingContext. */
147
210
  get ItemTemplate() { return this.itemTemplate; }
148
211
  set ItemTemplate(value) {
@@ -109,6 +109,15 @@ export declare class Canvas {
109
109
  OnTouchAction(args: TouchActionEventArgs): void;
110
110
  private OnGestureEvent;
111
111
  private ProcessPendingGestures;
112
+ /**
113
+ * The control that consumed the current gesture keeps it until it lets go (DrawnUi Canvas.HadInput). Without this a
114
+ * gesture is re-routed by the pointer position on every event, so anything the finger drags away from — a drag
115
+ * handle, a slider thumb, a button being held — stops receiving Panning the moment the pointer leaves its box.
116
+ * Single pointer, which is what the web gives us here; C# keeps a set for multi-touch.
117
+ */
118
+ private gestureOwner;
119
+ /** DrawnUi IsSavedGesture: the types replayed to the owner instead of being routed again. */
120
+ private static IsSavedGesture;
112
121
  /** Entry into the control tree, same shape as DrawnUi Canvas.ProcessGestures. */
113
122
  protected ProcessGestures(root: SkiaControl, args: SkiaGesturesParameters): SkiaControl | null;
114
123
  }
@@ -422,8 +422,35 @@ export class Canvas {
422
422
  for (const args of batch)
423
423
  this.ProcessGestures(root, args);
424
424
  }
425
+ /**
426
+ * The control that consumed the current gesture keeps it until it lets go (DrawnUi Canvas.HadInput). Without this a
427
+ * gesture is re-routed by the pointer position on every event, so anything the finger drags away from — a drag
428
+ * handle, a slider thumb, a button being held — stops receiving Panning the moment the pointer leaves its box.
429
+ * Single pointer, which is what the web gives us here; C# keeps a set for multi-touch.
430
+ */
431
+ gestureOwner = null;
432
+ /** DrawnUi IsSavedGesture: the types replayed to the owner instead of being routed again. */
433
+ static IsSavedGesture(type) {
434
+ return type === "Panning" || type === "Wheel" || type === "Up";
435
+ }
425
436
  /** Entry into the control tree, same shape as DrawnUi Canvas.ProcessGestures. */
426
437
  ProcessGestures(root, args) {
427
- return root.ProcessGestures(args, new GestureEventProcessingInfo(args.Event.Location, SKPoint.Empty, SKPoint.Empty, null));
438
+ const info = () => new GestureEventProcessingInfo(args.Event.Location, SKPoint.Empty, SKPoint.Empty, null);
439
+ if (args.Type === "Down")
440
+ this.gestureOwner = null;
441
+ else if (this.gestureOwner && Canvas.IsSavedGesture(args.Type)) {
442
+ const owner = this.gestureOwner;
443
+ const alive = !!owner.Superview && owner.IsVisible && !owner.InputTransparent;
444
+ const consumed = alive ? owner.OnSkiaGestureEvent(args, info()) : null;
445
+ if (consumed) {
446
+ // it still wants the gesture: nobody else sees this one (C# skips the tree pass for a saved gesture)
447
+ this.gestureOwner = args.Type === "Up" ? null : consumed;
448
+ return consumed;
449
+ }
450
+ this.gestureOwner = null; // it let go (a button whose press turned into a pan): route normally again
451
+ }
452
+ const consumed = root.ProcessGestures(args, info());
453
+ this.gestureOwner = consumed && args.Type !== "Up" ? consumed : null;
454
+ return consumed;
428
455
  }
429
456
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drawnui-react",
3
- "version": "0.1.0-preview.3",
3
+ "version": "0.1.0-preview.5",
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)",
@@ -65,8 +65,12 @@
65
65
  "vite": ">=5"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
- "playwright-core": { "optional": true },
69
- "vite": { "optional": true }
68
+ "playwright-core": {
69
+ "optional": true
70
+ },
71
+ "vite": {
72
+ "optional": true
73
+ }
70
74
  },
71
75
  "dependencies": {
72
76
  "canvaskit-wasm": "^0.42.0",