drawnui-react 0.1.0-preview.12 → 0.1.0-preview.14

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 (42) hide show
  1. package/SKIPPED.md +6 -5
  2. package/dist/controls/GridStructure.d.ts +4 -2
  3. package/dist/controls/GridStructure.js +2 -2
  4. package/dist/controls/SkiaButton.d.ts +2 -0
  5. package/dist/controls/SkiaButton.js +2 -0
  6. package/dist/controls/SkiaCarousel.js +4 -0
  7. package/dist/controls/SkiaEditor.d.ts +7 -0
  8. package/dist/controls/SkiaEditor.js +9 -1
  9. package/dist/controls/SkiaGif.js +1 -1
  10. package/dist/controls/SkiaLabelFps.d.ts +16 -0
  11. package/dist/controls/SkiaLabelFps.js +41 -0
  12. package/dist/controls/SkiaLayout.d.ts +32 -6
  13. package/dist/controls/SkiaLayout.js +171 -10
  14. package/dist/controls/SkiaLottie.js +1 -1
  15. package/dist/controls/SkiaScroll.d.ts +5 -4
  16. package/dist/controls/SkiaScroll.js +27 -8
  17. package/dist/controls/SkiaSlider.d.ts +8 -0
  18. package/dist/controls/SkiaSlider.js +39 -0
  19. package/dist/controls/SkiaSvg.js +1 -1
  20. package/dist/core/Accessibility.d.ts +59 -0
  21. package/dist/core/Accessibility.js +303 -3
  22. package/dist/core/Canvas.d.ts +8 -0
  23. package/dist/core/Canvas.js +14 -1
  24. package/dist/core/Gestures.d.ts +5 -1
  25. package/dist/core/Gestures.js +4 -1
  26. package/dist/core/SkiaControl.d.ts +27 -2
  27. package/dist/core/SkiaControl.js +38 -4
  28. package/dist/core/SkiaEffect.d.ts +4 -0
  29. package/dist/core/SkiaEffect.js +17 -3
  30. package/dist/core/SkiaImageManager.js +1 -1
  31. package/dist/core/Super.d.ts +7 -0
  32. package/dist/core/Super.js +15 -1
  33. package/dist/core/ViewsAdapter.d.ts +7 -2
  34. package/dist/core/ViewsAdapter.js +10 -3
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +1 -0
  37. package/dist/react/index.d.ts +4 -1
  38. package/dist/react/index.js +63 -9
  39. package/dist/react/reconciler.js +2 -1
  40. package/dist/vite/index.d.ts +9 -0
  41. package/dist/vite/index.js +36 -3
  42. package/package.json +4 -1
@@ -43,7 +43,10 @@ export class TouchActionEventArgs {
43
43
  Timestamp = performance.now();
44
44
  /** ms since the previous event of the same pointer. */
45
45
  DeltaTimeMs = 0;
46
- /** Mouse wheel: Delta > 0 = wheel down (browser deltaY sign). */
46
+ /**
47
+ * Mouse wheel: Delta > 0 = wheel down / right (browser sign). IsHorizontal: the event is along the X axis (a tilting
48
+ * wheel, the sideways part of a two-finger touchpad swipe), as C# WheelEventArgs.IsHorizontal.
49
+ */
47
50
  Wheel = { Delta: 0 };
48
51
  /**
49
52
  * AppoMobi TouchActionEventArgs.Handled: set by a control that actually used the event. The canvas prevents the
@@ -3,6 +3,7 @@ import { type Color, type LayoutOptions, SKRect, ScaledSize, type SkiaCacheType,
3
3
  import { type AnimatorBase, type IOverlayEffect } from "./Animators";
4
4
  import { Easing } from "./Easing";
5
5
  import type { Canvas } from "./Canvas";
6
+ import type { InputKey } from "./KeyboardManager";
6
7
  import { ContextMenuEventArgs, ControlTappedEventArgs, GestureEventProcessingInfo, type LockTouch, SKPoint, SkiaGesturesInfo, SkiaGesturesParameters } from "./Gestures";
7
8
  import { type CachedTexture, type IPostRendererEffect, type SkiaEffect } from "./SkiaEffect";
8
9
  /** Mirrors DrawnUi DrawingContext: ctx.Context.Canvas / Surface, ctx.Destination (pixels), ctx.Scale. */
@@ -180,7 +181,12 @@ export declare class SkiaControl {
180
181
  protected OnVisualEffectsChanged(): void;
181
182
  /** Post renderers that can render this frame (compiled shaders); an unready effect leaves the control drawn plainly. */
182
183
  private ActivePostRenderers;
183
- /** C# CachedImage: the cached texture and the canvas rect it was rasterized over (Image caches only). */
184
+ /**
185
+ * C# CachedImage: the cached texture (Image caches only) and the canvas rect it covers NOW. Scrolled content is
186
+ * re-arranged here, not drawn through a translated canvas, so a cache recorded before its control moved is blitted
187
+ * at the control's current aligned rect (RenderContent) and its record-time Bounds are stale: an effect sampling it
188
+ * there (the slides of a SkiaShaderCarousel after the page scrolled) was off by the scroll amount.
189
+ */
184
190
  get CachedImage(): CachedTexture | undefined;
185
191
  /** Clip overlay effects to the control's shape (CreateClip). */
186
192
  ClipEffects: boolean;
@@ -251,7 +257,11 @@ export declare class SkiaControl {
251
257
  protected DefaultAccessibilityLabel(): string | undefined;
252
258
  get AccessibilityHint(): string | undefined;
253
259
  set AccessibilityHint(v: string | undefined);
254
- /** Tab stop + activation; unset = true when the control handles Tapped. */
260
+ /**
261
+ * Whether keyboard navigation and assistive technology may focus and use this node: the explicit value, else the class
262
+ * default (true when the control handles Tapped), and in both cases only while a tap could reach the control
263
+ * (CanReceiveGesture), so Tab, Enter / Space and screen-reader activation never use a control the pointer cannot.
264
+ */
255
265
  get AccessibilityCanInteract(): boolean;
256
266
  set AccessibilityCanInteract(v: boolean);
257
267
  protected DefaultAccessibilityCanInteract(): boolean;
@@ -288,6 +298,21 @@ export declare class SkiaControl {
288
298
  OnAccessibilityActivated(): void;
289
299
  /** Overlay focus arrives on / leaves this node (inputs may activate their sink here). */
290
300
  OnAccessibilityFocused(_focused: boolean): void;
301
+ /**
302
+ * Keyboard navigation hands this control the keys it may use while it holds keyboard focus (ArrowLeft / Right / Up /
303
+ * Down, Home, End, PageUp, PageDown, DOM `event.code` names). Return true when the key was used; the default uses none,
304
+ * so the key moves focus inside the arrow-key group around the control.
305
+ */
306
+ OnAccessibilityKey(_key: InputKey): boolean;
307
+ /**
308
+ * Whether a pointer gesture of this kind would reach this control, by the rules the gesture dispatch applies: the
309
+ * control and every ancestor draw, none of them is InputTransparent, no ancestor keeps this gesture from its children
310
+ * (LockChildrenGestures, PassNone included) and the control accepts input (AcceptsInput). Opacity does not count, as
311
+ * for the pointer. Keyboard navigation uses it: Tapped for Tab and Enter / Space, Panning for the arrow keys.
312
+ */
313
+ CanReceiveGesture(gesture: SkiaGesturesParameters["Type"]): boolean;
314
+ /** False while the control ignores gestures itself, e.g. a disabled button. Default true. */
315
+ protected AcceptsInput(): boolean;
291
316
  NotifyAccessibilityFocused(focused: boolean): void;
292
317
  /** DrawnUi SmartMax: larger of two sizes, an infinite one loses. */
293
318
  protected static SmartMax(a: number, b: number): number;
@@ -235,10 +235,18 @@ export class SkiaControl {
235
235
  return [];
236
236
  return this.EffectPostRenderers.filter((e) => e.NeedApply);
237
237
  }
238
- /** C# CachedImage: the cached texture and the canvas rect it was rasterized over (Image caches only). */
238
+ /**
239
+ * C# CachedImage: the cached texture (Image caches only) and the canvas rect it covers NOW. Scrolled content is
240
+ * re-arranged here, not drawn through a translated canvas, so a cache recorded before its control moved is blitted
241
+ * at the control's current aligned rect (RenderContent) and its record-time Bounds are stale: an effect sampling it
242
+ * there (the slides of a SkiaShaderCarousel after the page scrolled) was off by the scroll amount.
243
+ */
239
244
  get CachedImage() {
240
245
  const c = this.RenderObject;
241
- return c?.Image ? { Image: c.Image, Bounds: c.Bounds } : undefined;
246
+ if (!c?.Image)
247
+ return undefined;
248
+ const r = this.AlignedCacheRect(c.Scale);
249
+ return { Image: c.Image, Bounds: SKRect.Create(r.Left, r.Top, c.Bounds.Width, c.Bounds.Height) };
242
250
  }
243
251
  /** Clip overlay effects to the control's shape (CreateClip). */
244
252
  ClipEffects = true;
@@ -417,8 +425,12 @@ export class SkiaControl {
417
425
  this.accessibilityHint = v;
418
426
  this.AccessibilityChanged();
419
427
  } }
420
- /** Tab stop + activation; unset = true when the control handles Tapped. */
421
- get AccessibilityCanInteract() { return this.accessibilityCanInteract ?? this.DefaultAccessibilityCanInteract(); }
428
+ /**
429
+ * Whether keyboard navigation and assistive technology may focus and use this node: the explicit value, else the class
430
+ * default (true when the control handles Tapped), and in both cases only while a tap could reach the control
431
+ * (CanReceiveGesture), so Tab, Enter / Space and screen-reader activation never use a control the pointer cannot.
432
+ */
433
+ get AccessibilityCanInteract() { return (this.accessibilityCanInteract ?? this.DefaultAccessibilityCanInteract()) && this.CanReceiveGesture("Tapped"); }
422
434
  set AccessibilityCanInteract(v) { if (this.accessibilityCanInteract !== v) {
423
435
  this.accessibilityCanInteract = v;
424
436
  this.AccessibilityChanged();
@@ -503,6 +515,28 @@ export class SkiaControl {
503
515
  }
504
516
  /** Overlay focus arrives on / leaves this node (inputs may activate their sink here). */
505
517
  OnAccessibilityFocused(_focused) { }
518
+ /**
519
+ * Keyboard navigation hands this control the keys it may use while it holds keyboard focus (ArrowLeft / Right / Up /
520
+ * Down, Home, End, PageUp, PageDown, DOM `event.code` names). Return true when the key was used; the default uses none,
521
+ * so the key moves focus inside the arrow-key group around the control.
522
+ */
523
+ OnAccessibilityKey(_key) { return false; }
524
+ /**
525
+ * Whether a pointer gesture of this kind would reach this control, by the rules the gesture dispatch applies: the
526
+ * control and every ancestor draw, none of them is InputTransparent, no ancestor keeps this gesture from its children
527
+ * (LockChildrenGestures, PassNone included) and the control accepts input (AcceptsInput). Opacity does not count, as
528
+ * for the pointer. Keyboard navigation uses it: Tapped for Tab and Enter / Space, Panning for the arrow keys.
529
+ */
530
+ CanReceiveGesture(gesture) {
531
+ if (!this.IsVisible || this.IsDisposed || this.InputTransparent || !this.AcceptsInput())
532
+ return false;
533
+ for (let p = this.Parent; p; p = p.Parent)
534
+ if (!p.IsVisible || p.IsDisposed || p.InputTransparent || p.CheckChildrenGesturesLocked(gesture))
535
+ return false;
536
+ return true;
537
+ }
538
+ /** False while the control ignores gestures itself, e.g. a disabled button. Default true. */
539
+ AcceptsInput() { return true; }
506
540
  NotifyAccessibilityFocused(focused) { this.Superview?.AccessibilityManager.NotifyFocused(focused ? this : undefined); }
507
541
  /** DrawnUi SmartMax: larger of two sizes, an infinite one loses. */
508
542
  static SmartMax(a, b) {
@@ -125,6 +125,10 @@ export declare class SkiaShaderEffect extends SkiaEffect implements IPostRendere
125
125
  protected ApplyShaderSource(): void;
126
126
  /** Snapshot of what the parent painted (C# CreateSnapshot): the surface the pixels are on, mapped through its origin. */
127
127
  protected CreateSnapshot(ctx: DrawingContext, destination: SKRect): CachedTexture | undefined;
128
+ /** Where the parent was when the texture was frozen (Once): the texture moves with it, as its cache does. */
129
+ private frozenAt?;
130
+ private ParentOrigin;
131
+ private FollowParent;
128
132
  /** C# GetPrimaryTexture: the parent's cache, a frozen snapshot (Once) or nothing (Never). */
129
133
  protected GetPrimaryTexture(_ctx: DrawingContext, _destination: SKRect): CachedTexture | undefined;
130
134
  /** Image → shader with the effect's sampling, translated so texel (0,0) = bounds top-left; cached per image. */
@@ -2,7 +2,7 @@ import { SkiaValueAnimator } from "./Animators";
2
2
  import { Easing } from "./Easing";
3
3
  import { SkiaImageManager } from "./SkiaImageManager";
4
4
  import { Super } from "./Super";
5
- import { Thickness } from "./Types";
5
+ import { SKRect, Thickness } from "./Types";
6
6
  /**
7
7
  * Mirrors DrawnUi SkiaEffect: something attached to a control through `VisualEffects`. `Update()` re-renders the
8
8
  * parent (C# invalidates the parent's cache and redraws), `GetEffectMargin` reports what it paints outside the box.
@@ -129,6 +129,7 @@ export class SkiaShaderEffect extends SkiaEffect {
129
129
  this.DisposeImage(this.frozen.Image);
130
130
  this.frozen = undefined;
131
131
  this.frozenOwned = false;
132
+ this.frozenAt = undefined;
132
133
  }
133
134
  DisposeImage(image) {
134
135
  const sv = this.Parent?.Superview;
@@ -158,6 +159,18 @@ export class SkiaShaderEffect extends SkiaEffect {
158
159
  return undefined;
159
160
  return { Image: image, Bounds: destination, Origin: { X: ox, Y: oy } };
160
161
  }
162
+ /** Where the parent was when the texture was frozen (Once): the texture moves with it, as its cache does. */
163
+ frozenAt;
164
+ ParentOrigin() { const r = this.Parent?.DrawingRect; return { X: r?.Left ?? 0, Y: r?.Top ?? 0 }; }
165
+ FollowParent(texture) {
166
+ if (!texture || !this.frozenAt)
167
+ return texture;
168
+ const now = this.ParentOrigin(), dx = now.X - this.frozenAt.X, dy = now.Y - this.frozenAt.Y;
169
+ if (dx === 0 && dy === 0)
170
+ return texture;
171
+ const b = texture.Bounds;
172
+ return { Image: texture.Image, Bounds: new SKRect(b.Left + dx, b.Top + dy, b.Right + dx, b.Bottom + dy), Origin: texture.Origin && { X: texture.Origin.X + dx, Y: texture.Origin.Y + dy } };
173
+ }
161
174
  /** C# GetPrimaryTexture: the parent's cache, a frozen snapshot (Once) or nothing (Never). */
162
175
  GetPrimaryTexture(_ctx, _destination) {
163
176
  switch (this.UseBackground) {
@@ -175,9 +188,10 @@ export class SkiaShaderEffect extends SkiaEffect {
175
188
  this.frozen = snapshot;
176
189
  this.frozenOwned = owned;
177
190
  this.AquiredBackground = true;
191
+ this.frozenAt = this.ParentOrigin();
178
192
  }
179
193
  }
180
- return this.frozen;
194
+ return this.FollowParent(this.frozen);
181
195
  default:
182
196
  return this.Parent?.CachedImage;
183
197
  }
@@ -228,7 +242,7 @@ export class SkiaShaderEffect extends SkiaEffect {
228
242
  static async FetchText(url) {
229
243
  let p = sourceCache.get(url);
230
244
  if (!p) {
231
- p = fetch(url).then((r) => { if (!r.ok)
245
+ p = fetch(Super.ResolveAssetUrl(url)).then((r) => { if (!r.ok)
232
246
  throw new Error(`${r.status} ${url}`); return r.text(); });
233
247
  sourceCache.set(url, p);
234
248
  }
@@ -39,7 +39,7 @@ export class SkiaImageManager {
39
39
  return inflight;
40
40
  }
41
41
  async Fetch(source, signal) {
42
- const response = await fetch(source, signal ? { signal } : undefined);
42
+ const response = await fetch(Super.ResolveAssetUrl(source), signal ? { signal } : undefined);
43
43
  if (!response.ok)
44
44
  throw new Error(`DrawnUi: ${response.status} loading image '${source}'`);
45
45
  const image = Super.CK.MakeImageFromEncoded(await response.arrayBuffer());
@@ -52,6 +52,13 @@ export declare class Super {
52
52
  static UseDrawnUi(): DrawnUiBuilder;
53
53
  /** Master switch: false makes every control render uncached (DrawnUi Super.CacheEnabled). */
54
54
  static CacheEnabled: boolean;
55
+ /**
56
+ * Address every engine loader fetches a source from (images, sprites, fonts, SVG, GIF, Lottie, shaders); caches
57
+ * stay keyed by the source itself. Web-only, no C# counterpart. Default: a file listed in
58
+ * `globalThis.DrawnUiAssetStamps` (written into index.html by the `drawnUiAssetStamps()` Vite plugin) gets
59
+ * `?v=<content hash>`, so a browser never keeps an old copy of a changed file. Replace it to map sources elsewhere.
60
+ */
61
+ static ResolveAssetUrl: (source: string) => string;
55
62
  private static readonly colorCache;
56
63
  /**
57
64
  * Color string -> CanvasKit color. Hex follows the MAUI/DrawnUi convention: #RGB, #ARGB, #RRGGBB, #AARRGGBB
@@ -46,7 +46,7 @@ export class DrawnUiBuilder {
46
46
  for (const f of this.fonts.Fonts) {
47
47
  let face = loaded.get(f.Source);
48
48
  if (!face) {
49
- const data = await (await fetch(f.Source)).arrayBuffer();
49
+ const data = await (await fetch(Super.ResolveAssetUrl(f.Source))).arrayBuffer();
50
50
  face = Super.CK.Typeface.MakeFreeTypeFaceFromData(data) ?? undefined;
51
51
  if (!face)
52
52
  throw new Error(`DrawnUi: cannot load font '${f.Source}'`);
@@ -122,6 +122,20 @@ export class Super {
122
122
  static UseDrawnUi() { return new DrawnUiBuilder(); }
123
123
  /** Master switch: false makes every control render uncached (DrawnUi Super.CacheEnabled). */
124
124
  static CacheEnabled = true;
125
+ /**
126
+ * Address every engine loader fetches a source from (images, sprites, fonts, SVG, GIF, Lottie, shaders); caches
127
+ * stay keyed by the source itself. Web-only, no C# counterpart. Default: a file listed in
128
+ * `globalThis.DrawnUiAssetStamps` (written into index.html by the `drawnUiAssetStamps()` Vite plugin) gets
129
+ * `?v=<content hash>`, so a browser never keeps an old copy of a changed file. Replace it to map sources elsewhere.
130
+ */
131
+ static ResolveAssetUrl = (source) => {
132
+ const stamps = globalThis.DrawnUiAssetStamps;
133
+ if (!stamps)
134
+ return source;
135
+ const q = source.indexOf("?");
136
+ const v = stamps[(q < 0 ? source : source.slice(0, q)).replace(/^\.?\//, "")];
137
+ return v ? `${source}${q < 0 ? "?" : "&"}v=${v}` : source;
138
+ };
125
139
  static colorCache = new Map();
126
140
  /**
127
141
  * Color string -> CanvasKit color. Hex follows the MAUI/DrawnUi convention: #RGB, #ARGB, #RRGGBB, #AARRGGBB
@@ -16,6 +16,8 @@ export declare class ViewsAdapter {
16
16
  Created: number;
17
17
  constructor(parent: SkiaControl);
18
18
  get PoolSize(): number;
19
+ /** The items views are bound to. */
20
+ get Items(): readonly unknown[];
19
21
  get InUseCount(): number;
20
22
  /** New template / items / mode: every realized view is dropped (DrawnUi ApplyItemsSource). */
21
23
  Initialize(template: (() => SkiaControl) | undefined, items: readonly unknown[], recycling: RecyclingTemplate): void;
@@ -26,8 +28,11 @@ export declare class ViewsAdapter {
26
28
  ShiftIndices(by: number, items: readonly unknown[]): void;
27
29
  /** The realized view for an index, if any (no creation). */
28
30
  GetViewForIndex(index: number): SkiaControl | undefined;
29
- /** View bound to items[index]: existing, recycled from the pool, or freshly created. */
30
- GetOrCreateViewForIndex(index: number): SkiaControl | undefined;
31
+ /**
32
+ * View bound to items[index]: existing, recycled from the pool, or freshly created. `quiet`: bound before it is
33
+ * attached, so the rebind invalidates the view alone and not the layout, which measures or draws it right away.
34
+ */
35
+ GetOrCreateViewForIndex(index: number, quiet?: boolean): SkiaControl | undefined;
31
36
  /** Index left the visible range: recycled views go back to the pool, non-recycled ones stay realized. */
32
37
  ReleaseViewAt(index: number): void;
33
38
  /** Releases every realized index outside [first, last]. */
@@ -16,6 +16,8 @@ export class ViewsAdapter {
16
16
  this.parent = parent;
17
17
  }
18
18
  get PoolSize() { return this.pool.length; }
19
+ /** The items views are bound to. */
20
+ get Items() { return this.items; }
19
21
  get InUseCount() { return this.inUse.size; }
20
22
  /** New template / items / mode: every realized view is dropped (DrawnUi ApplyItemsSource). */
21
23
  Initialize(template, items, recycling) {
@@ -52,8 +54,11 @@ export class ViewsAdapter {
52
54
  }
53
55
  /** The realized view for an index, if any (no creation). */
54
56
  GetViewForIndex(index) { return this.inUse.get(index); }
55
- /** View bound to items[index]: existing, recycled from the pool, or freshly created. */
56
- GetOrCreateViewForIndex(index) {
57
+ /**
58
+ * View bound to items[index]: existing, recycled from the pool, or freshly created. `quiet`: bound before it is
59
+ * attached, so the rebind invalidates the view alone and not the layout, which measures or draws it right away.
60
+ */
61
+ GetOrCreateViewForIndex(index, quiet = false) {
57
62
  const existing = this.inUse.get(index);
58
63
  if (existing)
59
64
  return existing;
@@ -64,9 +69,11 @@ export class ViewsAdapter {
64
69
  view = this.template();
65
70
  this.Created++;
66
71
  }
67
- view.Parent = this.parent;
72
+ if (!quiet)
73
+ view.Parent = this.parent;
68
74
  view.ContextIndex = index;
69
75
  view.BindingContext = this.items[index];
76
+ view.Parent = this.parent;
70
77
  this.inUse.set(index, view);
71
78
  return view;
72
79
  }
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./core/Accessibility";
12
12
  export * from "./controls/SkiaLabel";
13
13
  export * from "./controls/TextSpan";
14
14
  export * from "./controls/SkiaRichLabel";
15
+ export * from "./controls/SkiaLabelFps";
15
16
  export * from "./core/ControlStyle";
16
17
  export * from "./core/ImageEffects";
17
18
  export * from "./controls/SkiaToggle";
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export * from "./core/Accessibility";
12
12
  export * from "./controls/SkiaLabel";
13
13
  export * from "./controls/TextSpan";
14
14
  export * from "./controls/SkiaRichLabel";
15
+ export * from "./controls/SkiaLabelFps";
15
16
  export * from "./core/ControlStyle";
16
17
  export * from "./core/ImageEffects";
17
18
  export * from "./controls/SkiaToggle";
@@ -4,6 +4,7 @@ import type { SkiaControl } from "../core/SkiaControl";
4
4
  import type { SkiaLabel as SkiaLabelCtrl } from "../controls/SkiaLabel";
5
5
  import type { TextSpan as TextSpanCtrl } from "../controls/TextSpan";
6
6
  import type { SkiaRichLabel as SkiaRichLabelCtrl } from "../controls/SkiaRichLabel";
7
+ import type { SkiaLabelFps as SkiaLabelFpsCtrl } from "../controls/SkiaLabelFps";
7
8
  import type { SkiaSwitch as SkiaSwitchCtrl } from "../controls/SkiaSwitch";
8
9
  import type { SkiaCheckbox as SkiaCheckboxCtrl } from "../controls/SkiaCheckbox";
9
10
  import type { SkiaRadioButton as SkiaRadioButtonCtrl } from "../controls/SkiaRadioButton";
@@ -22,7 +23,7 @@ import type { SkiaScrollBar as SkiaScrollBarCtrl } from "../controls/SkiaScrollB
22
23
  import type { RefreshIndicator as RefreshIndicatorCtrl } from "../controls/RefreshIndicator";
23
24
  import type { SkiaSvg as SkiaSvgCtrl } from "../controls/SkiaSvg";
24
25
  import type { SkiaBackdrop as SkiaBackdropCtrl } from "../controls/SkiaBackdrop";
25
- import type { SkiaEditor as SkiaEditorCtrl } from "../controls/SkiaEditor";
26
+ import { SkiaEditor as SkiaEditorCtrl } from "../controls/SkiaEditor";
26
27
  import type { SkiaSprite as SkiaSpriteCtrl } from "../controls/SkiaSprite";
27
28
  import type { SkiaSpriteSet as SkiaSpriteSetCtrl } from "../controls/SkiaSpriteSet";
28
29
  import type { SkiaLottie as SkiaLottieCtrl } from "../controls/SkiaLottie";
@@ -61,6 +62,8 @@ export declare const SkiaGrid: FC<LayoutProps<SkiaLayoutCtrl>>;
61
62
  /** Grid drawing gradient separator lines in its spacing (C# SkiaDecoratedGrid). */
62
63
  export declare const SkiaDecoratedGrid: FC<LayoutProps<SkiaDecoratedGridCtrl>>;
63
64
  export declare const SkiaLabel: FC<LayoutProps<SkiaLabelCtrl>>;
65
+ /** Canvas FPS counter (C# SkiaLabelFps); its value changes only on frames running animators request anyway. */
66
+ export declare const SkiaLabelFps: FC<LeafProps<SkiaLabelFpsCtrl>>;
64
67
  /** Markdown label (C# SkiaRichLabel): Text is markdown, rendered as spans; LinkTapped for [text](url). */
65
68
  export declare const SkiaRichLabel: FC<LeafProps<SkiaRichLabelCtrl>>;
66
69
  /** Child of <SkiaLabel>: a styled fragment (C# TextSpan). */
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
+ import { SkiaAccessibilityManager } from "../core/Accessibility";
4
+ import { TextInputProxy } from "../core/TextInputProxy";
3
5
  import { Canvas as CanvasView } from "../core/Canvas";
6
+ import { SkiaEditor as SkiaEditorCtrl } from "../controls/SkiaEditor";
4
7
  import { SkiaScroll as SkiaScrollCtrl } from "../controls/SkiaScroll";
5
8
  import { createDrawnRoot } from "./reconciler";
6
9
  /** Typed JSX tags resolved by the reconciler Registry. */
@@ -13,6 +16,8 @@ export const SkiaGrid = "SkiaGrid";
13
16
  /** Grid drawing gradient separator lines in its spacing (C# SkiaDecoratedGrid). */
14
17
  export const SkiaDecoratedGrid = "SkiaDecoratedGrid";
15
18
  export const SkiaLabel = "SkiaLabel";
19
+ /** Canvas FPS counter (C# SkiaLabelFps); its value changes only on frames running animators request anyway. */
20
+ export const SkiaLabelFps = "SkiaLabelFps";
16
21
  /** Markdown label (C# SkiaRichLabel): Text is markdown, rendered as spans; LinkTapped for [text](url). */
17
22
  export const SkiaRichLabel = "SkiaRichLabel";
18
23
  /** Child of <SkiaLabel>: a styled fragment (C# TextSpan). */
@@ -114,19 +119,50 @@ const A11Y_CSS = `
114
119
  .drawnui-a11y-text>span{position:absolute;white-space:pre;color:transparent;user-select:text;-webkit-user-select:text;pointer-events:auto;cursor:text;line-height:1;font-kerning:none;font-feature-settings:"kern" 0,"liga" 0,"calt" 0;text-rendering:geometricPrecision}
115
120
  .drawnui-a11y-text>span::selection{background:rgba(110,168,254,.55);color:transparent}
116
121
  `;
122
+ const NAVIGATION_KEYS = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"]);
117
123
  /**
118
- * Invisible ARIA elements mirroring the accessible drawn controls, positioned over the canvas.
119
- * Improvement over DrawnUi.Blazor: `pointer-events:none`, so hover and every pointer gesture still reach the canvas —
120
- * keyboard (Tab / Enter / Space) and screen-reader activation arrive as DOM events and are routed back as a Tapped.
124
+ * Invisible ARIA elements mirroring the accessible drawn controls, positioned over the canvas (the DrawnUi.Blazor
125
+ * overlay). Improvement over DrawnUi.Blazor: `pointer-events:none`, so hover and every pointer gesture still reach the
126
+ * canvas. Keyboard and screen-reader input arrive as DOM events: Enter / Space activate (a Tapped), the navigation keys
127
+ * go to the focused control and then to its arrow-key group; a group is one Tab stop (roving tabindex).
121
128
  */
122
129
  function AccessibilityOverlay({ view }) {
123
130
  const [nodes, setNodes] = useState(() => view.AccessibilityManager.Snapshot);
131
+ // keyboard focus moved by the manager (arrow keys in a group): focus that node's element once it is rendered
132
+ const [focusRequest, setFocusRequest] = useState();
133
+ // the Tab stop of a group moves with keyboard focus: re-render on focus
134
+ const [, setFocusTick] = useState(0);
124
135
  useEffect(() => {
125
136
  const mgr = view.AccessibilityManager;
126
137
  setNodes(mgr.Snapshot);
127
138
  const off = mgr.OnChanged(() => setNodes(mgr.Snapshot));
128
139
  const offLive = mgr.OnLiveRegionUpdated(() => { mgr.ForceRebuildOnNextFrame(); view.Update(); });
129
- return () => { off(); offLive(); };
140
+ const offFocus = mgr.OnKeyboardFocusRequested((node) => { setNodes(mgr.Snapshot); setFocusRequest({ id: node.AccessibilityId }); });
141
+ return () => { off(); offLive(); offFocus(); };
142
+ }, [view]);
143
+ const elementOf = (id) => overlayRef.current?.querySelector(`[data-a11y-id="${id}"]`) ?? undefined;
144
+ useLayoutEffect(() => {
145
+ if (!focusRequest)
146
+ return;
147
+ setFocusRequest(undefined);
148
+ elementOf(focusRequest.id)?.focus({ preventScroll: true });
149
+ }, [focusRequest]);
150
+ // Tab / Shift+Tab in a text field leave it for the next node, also after a click into it: DOM focus goes to the
151
+ // field's own element (without taking the caret again) and the browser's Tab moves on from there
152
+ const tabbingOut = useRef(0);
153
+ useEffect(() => {
154
+ const onKey = (e) => {
155
+ const editor = SkiaEditorCtrl.Focused;
156
+ if (e.key !== "Tab" || !editor || editor.Superview !== view || !TextInputProxy.IsProxyTarget(e.target))
157
+ return;
158
+ const el = elementOf(editor.AccessibilityId);
159
+ if (!el)
160
+ return;
161
+ tabbingOut.current = editor.AccessibilityId;
162
+ el.focus({ preventScroll: true });
163
+ };
164
+ document.addEventListener("keydown", onKey, true);
165
+ return () => document.removeEventListener("keydown", onKey, true);
130
166
  }, [view]);
131
167
  // selectable text spans take pointer events: the wheel over them is re-dispatched to the canvas so drawn scrolls keep working
132
168
  const overlayRef = useRef(null);
@@ -161,11 +197,29 @@ function AccessibilityOverlay({ view }) {
161
197
  const pin = (e) => { e.currentTarget.scrollTop = 0; e.currentTarget.scrollLeft = 0; };
162
198
  return (_jsxs("div", { className: "drawnui-a11y-overlay", onScroll: pin, ref: overlayRef, children: [_jsx("style", { children: A11Y_CSS }), nodes.map((n) => {
163
199
  const pos = { left: n.Rect.Left, top: n.Rect.Top, width: n.Rect.Width, height: n.Rect.Height };
164
- const activate = () => n.Source.OnAccessibilityActivated();
165
- return n.CanInteract ? (_jsx("div", { role: n.Role, "aria-label": n.Label, title: n.Hint, "aria-pressed": n.Role === "button" ? n.IsPressed : undefined, "aria-checked": n.Role === "switch" || n.Role === "checkbox" || n.Role === "radio" ? n.IsPressed : undefined, "aria-live": n.Live, tabIndex: 0, className: "drawnui-a11y-node", style: { ...pos, fontSize: 0, userSelect: "none" }, onClick: activate, onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") {
166
- e.preventDefault();
167
- activate();
168
- } }, onFocus: (e) => { pin({ currentTarget: e.currentTarget.parentElement }); SkiaScrollCtrl.EnsureVisible(n.Source); n.Source.OnAccessibilityFocused(true); n.Source.NotifyAccessibilityFocused(true); }, onBlur: () => { n.Source.OnAccessibilityFocused(false); n.Source.NotifyAccessibilityFocused(false); }, children: n.Label }, n.Id)) : n.TextLines ? (
200
+ const activate = () => SkiaAccessibilityManager.Activate(n.Source);
201
+ return n.CanInteract ? (_jsx("div", { "data-a11y-id": n.Id, role: n.Role, "aria-label": n.Label, title: n.Hint, "aria-pressed": n.Role === "button" ? n.IsPressed : undefined, "aria-checked": n.Role === "switch" || n.Role === "checkbox" || n.Role === "radio" ? n.IsPressed : undefined, "aria-live": n.Live, tabIndex: view.AccessibilityManager.IsTabStop(n.Source) ? 0 : -1, className: "drawnui-a11y-node", style: { ...pos, fontSize: 0, userSelect: "none" }, onClick: activate, onKeyDown: (e) => {
202
+ if (e.key === "Enter" || e.key === " ") {
203
+ e.preventDefault();
204
+ activate();
205
+ }
206
+ else if (NAVIGATION_KEYS.has(e.key) && SkiaAccessibilityManager.Key(n.Source, e.key))
207
+ e.preventDefault();
208
+ }, onFocus: (e) => {
209
+ pin({ currentTarget: e.currentTarget.parentElement });
210
+ SkiaScrollCtrl.EnsureVisible(n.Source); // inside every enclosing scroll
211
+ if (tabbingOut.current === n.Id)
212
+ tabbingOut.current = 0; // leaving a text field: it keeps no caret
213
+ else
214
+ n.Source.OnAccessibilityFocused(true); // a text field takes the caret, as after a click
215
+ n.Source.NotifyAccessibilityFocused(true);
216
+ setFocusTick((t) => t + 1);
217
+ }, onBlur: (e) => {
218
+ if (TextInputProxy.IsProxyTarget(e.relatedTarget))
219
+ return; // a text field moved DOM focus to its own input
220
+ n.Source.OnAccessibilityFocused(false);
221
+ n.Source.NotifyAccessibilityFocused(false);
222
+ }, children: n.Label }, n.Id)) : n.TextLines ? (
169
223
  // AccessibilityTextSelectable: one positioned span per drawn line, in the drawn font, so the browser selects and copies it like HTML
170
224
  _jsx("div", { role: n.Role, title: n.Hint, "aria-live": n.Live, className: "drawnui-a11y-node drawnui-a11y-text", style: pos, children: n.TextLines.map((l, i) => (_jsxs("span", { "data-w": l.Width, style: { left: l.Left, top: l.Top, height: l.Height, fontFamily: l.FontFamily, fontWeight: l.FontWeight, fontSize: l.FontSize, lineHeight: `${l.Height}px` }, children: [l.Text, i < n.TextLines.length - 1 ? "\n" : ""] }, i))) }, n.Id)) : (
171
225
  // static text is exposed as real (transparent) text content; aria-label only for roles that need a name
@@ -4,6 +4,7 @@ import { ConcurrentRoot, DefaultEventPriority } from "react-reconciler/constants
4
4
  import { SkiaLabel } from "../controls/SkiaLabel";
5
5
  import { TextSpan } from "../controls/TextSpan";
6
6
  import { SkiaRichLabel } from "../controls/SkiaRichLabel";
7
+ import { SkiaLabelFps } from "../controls/SkiaLabelFps";
7
8
  import { SkiaSwitch } from "../controls/SkiaSwitch";
8
9
  import { SkiaCheckbox } from "../controls/SkiaCheckbox";
9
10
  import { SkiaRadioButton } from "../controls/SkiaRadioButton";
@@ -31,7 +32,7 @@ import { SkiaScroll } from "../controls/SkiaScroll";
31
32
  import { SkiaFrame, SkiaShape } from "../controls/SkiaShape";
32
33
  /** JSX tag name -> engine class. Add a control here to expose it to React. */
33
34
  export const Registry = {
34
- SkiaLayout, SkiaStack, SkiaRow, SkiaLayer, SkiaWrap, SkiaGrid, SkiaDecoratedGrid, SkiaBackdrop, SkiaEditor, SkiaSprite, SkiaSpriteSet, SkiaLabel, SkiaRichLabel, TextSpan, SkiaHotspot, SkiaButton, SkiaImage, SkiaImageTiles, SkiaSvg, SkiaLottie, SkiaGif, SkiaScroll, SkiaScrollBar, RefreshIndicator, SkiaShape, SkiaFrame,
35
+ SkiaLayout, SkiaStack, SkiaRow, SkiaLayer, SkiaWrap, SkiaGrid, SkiaDecoratedGrid, SkiaBackdrop, SkiaEditor, SkiaSprite, SkiaSpriteSet, SkiaLabel, SkiaLabelFps, SkiaRichLabel, TextSpan, SkiaHotspot, SkiaButton, SkiaImage, SkiaImageTiles, SkiaSvg, SkiaLottie, SkiaGif, SkiaScroll, SkiaScrollBar, RefreshIndicator, SkiaShape, SkiaFrame,
35
36
  SkiaSwitch, SkiaCheckbox, SkiaRadioButton, SkiaProgress, SkiaSlider, SkiaCarousel, SkiaShaderCarousel, SkiaDrawer,
36
37
  };
37
38
  const SKIP = new Set(["children", "key", "ref"]);
@@ -1,4 +1,13 @@
1
1
  import type { Plugin } from "vite";
2
+ /**
3
+ * Versioned addresses for the files of the public folder (fonts, images, sprites, shaders, Lottie, favicon). Their
4
+ * names never change, and a browser or CDN may keep them for hours, so after a publish a returning visitor could
5
+ * get new code with an old file. On build every public file gets a short content hash: index.html receives the map
6
+ * as `globalThis.DrawnUiAssetStamps` (read by `Super.ResolveAssetUrl`, which every engine loader fetches through)
7
+ * and its own `href` / `src` links to public files get `?v=<hash>`. A changed file gets a new address, an unchanged
8
+ * one keeps its address and stays cached. Bundled files (hashed names) need nothing. The dev server is left alone.
9
+ */
10
+ export declare function drawnUiAssetStamps(): Plugin;
2
11
  export interface DrawnUiStaticOptions {
3
12
  /**
4
13
  * Pages to generate, as URL hashes / paths appended to the site root ("" = the root page, "#/shapes"...).
@@ -1,9 +1,42 @@
1
1
  /**
2
- * Vite helpers for DrawnUi.React apps: `import { drawnUiStatic } from "drawnui-react/vite"`.
2
+ * Vite helpers for DrawnUi.React apps: `import { drawnUiStatic, drawnUiAssetStamps } from "drawnui-react/vite"`.
3
3
  * Build-time only; the runtime bundle never imports this module and the frame loop knows nothing about it.
4
4
  */
5
- import { readFileSync, writeFileSync } from "node:fs";
6
- import { resolve } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ /**
9
+ * Versioned addresses for the files of the public folder (fonts, images, sprites, shaders, Lottie, favicon). Their
10
+ * names never change, and a browser or CDN may keep them for hours, so after a publish a returning visitor could
11
+ * get new code with an old file. On build every public file gets a short content hash: index.html receives the map
12
+ * as `globalThis.DrawnUiAssetStamps` (read by `Super.ResolveAssetUrl`, which every engine loader fetches through)
13
+ * and its own `href` / `src` links to public files get `?v=<hash>`. A changed file gets a new address, an unchanged
14
+ * one keeps its address and stays cached. Bundled files (hashed names) need nothing. The dev server is left alone.
15
+ */
16
+ export function drawnUiAssetStamps() {
17
+ let stamps = {};
18
+ return {
19
+ name: "drawnui-asset-stamps",
20
+ apply: "build",
21
+ configResolved(config) {
22
+ const dir = config.publicDir;
23
+ if (!dir)
24
+ return;
25
+ for (const rel of readdirSync(dir, { recursive: true })) {
26
+ const file = join(dir, rel);
27
+ if (statSync(file).isFile())
28
+ stamps[rel.replace(/\\/g, "/")] = createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 10);
29
+ }
30
+ },
31
+ transformIndexHtml(html) {
32
+ const stamped = html.replace(/\b(href|src)="([^"?#:]+)"/g, (m, attr, url) => {
33
+ const v = stamps[url.replace(/^\.?\//, "")];
34
+ return v ? `${attr}="${url}?v=${v}"` : m;
35
+ });
36
+ return { html: stamped, tags: [{ tag: "script", children: `globalThis.DrawnUiAssetStamps=${JSON.stringify(stamps)}`, injectTo: "head-prepend" }] };
37
+ },
38
+ };
39
+ }
7
40
  /**
8
41
  * Generates visible, semantic HTML for crawlers and AI agents from the app's own accessibility tree, at build time.
9
42
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drawnui-react",
3
- "version": "0.1.0-preview.12",
3
+ "version": "0.1.0-preview.14",
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)",
@@ -53,6 +53,9 @@
53
53
  "typecheck": "tsc --noEmit",
54
54
  "build": "tsc --noEmit && node dev/build-samples.mjs",
55
55
  "sync:skills": "node dev/sync-skills.mjs",
56
+ "check:recycling": "node dev/check.mjs dev/check-recycling.ts",
57
+ "check:keyboard": "node dev/check.mjs dev/check-keyboard.ts",
58
+ "check:wheel": "node dev/check.mjs dev/check-wheel.ts",
56
59
  "site:extras": "node dev/site-extras.mjs dist/demo",
57
60
  "build:lib": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
58
61
  "prepublishOnly": "npm run build:lib",