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.
- package/SKIPPED.md +6 -5
- package/dist/controls/GridStructure.d.ts +4 -2
- package/dist/controls/GridStructure.js +2 -2
- package/dist/controls/SkiaButton.d.ts +2 -0
- package/dist/controls/SkiaButton.js +2 -0
- package/dist/controls/SkiaCarousel.js +4 -0
- package/dist/controls/SkiaEditor.d.ts +7 -0
- package/dist/controls/SkiaEditor.js +9 -1
- package/dist/controls/SkiaGif.js +1 -1
- package/dist/controls/SkiaLabelFps.d.ts +16 -0
- package/dist/controls/SkiaLabelFps.js +41 -0
- package/dist/controls/SkiaLayout.d.ts +32 -6
- package/dist/controls/SkiaLayout.js +171 -10
- package/dist/controls/SkiaLottie.js +1 -1
- package/dist/controls/SkiaScroll.d.ts +5 -4
- package/dist/controls/SkiaScroll.js +27 -8
- package/dist/controls/SkiaSlider.d.ts +8 -0
- package/dist/controls/SkiaSlider.js +39 -0
- package/dist/controls/SkiaSvg.js +1 -1
- package/dist/core/Accessibility.d.ts +59 -0
- package/dist/core/Accessibility.js +303 -3
- package/dist/core/Canvas.d.ts +8 -0
- package/dist/core/Canvas.js +14 -1
- package/dist/core/Gestures.d.ts +5 -1
- package/dist/core/Gestures.js +4 -1
- package/dist/core/SkiaControl.d.ts +27 -2
- package/dist/core/SkiaControl.js +38 -4
- package/dist/core/SkiaEffect.d.ts +4 -0
- package/dist/core/SkiaEffect.js +17 -3
- package/dist/core/SkiaImageManager.js +1 -1
- package/dist/core/Super.d.ts +7 -0
- package/dist/core/Super.js +15 -1
- package/dist/core/ViewsAdapter.d.ts +7 -2
- package/dist/core/ViewsAdapter.js +10 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/react/index.d.ts +4 -1
- package/dist/react/index.js +63 -9
- package/dist/react/reconciler.js +2 -1
- package/dist/vite/index.d.ts +9 -0
- package/dist/vite/index.js +36 -3
- package/package.json +4 -1
|
@@ -9,7 +9,7 @@ import { RubberBandUtils, ScrollFlingAnimator, Spring, SpringWithVelocityAnimato
|
|
|
9
9
|
* Mirrors DrawnUi SkiaScroll (plain content, no header/footer/refresh/virtualization yet).
|
|
10
10
|
* Offsets are in POINTS and <= 0 while inside bounds (content moves up/left as you scroll).
|
|
11
11
|
* Physics ported 1:1: deceleration fling (rate = 1 - FrictionScrolled/100) cut at the content edge,
|
|
12
|
-
* rubber-band overscroll while dragging, spring bounce back on release, wheel = WheelLineSize per notch.
|
|
12
|
+
* rubber-band overscroll while dragging, spring bounce back on release, wheel = WheelLineSize per notch (an event moves by its share of a notch).
|
|
13
13
|
*/
|
|
14
14
|
export class SkiaScroll extends SkiaControl {
|
|
15
15
|
static WheelLineSize = 150;
|
|
@@ -901,6 +901,9 @@ export class SkiaScroll extends SkiaControl {
|
|
|
901
901
|
return child;
|
|
902
902
|
if (!this.RespondsToGestures || this.Orientation === "Neither")
|
|
903
903
|
return child ?? consumedDefault; // Neither: not used, a parent scroll may take it
|
|
904
|
+
// a vertical scroll leaves horizontal events (the sideways part of a diagonal swipe) to whatever is around it
|
|
905
|
+
if (e.Wheel.IsHorizontal && this.Orientation === "Vertical")
|
|
906
|
+
return child ?? consumedDefault;
|
|
904
907
|
if (!this.ApplyWheelScroll(e.Wheel.Delta))
|
|
905
908
|
return consumedDefault; // at its edge: not used, the page may take it
|
|
906
909
|
e.Handled = true;
|
|
@@ -1041,15 +1044,20 @@ export class SkiaScroll extends SkiaControl {
|
|
|
1041
1044
|
return true;
|
|
1042
1045
|
}
|
|
1043
1046
|
/**
|
|
1044
|
-
* One wheel notch = WheelLineSize points, hard-clamped, animated over AutoScrollingSpeedMs.
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1047
|
+
* One wheel notch = WheelLineSize points, hard-clamped, animated over AutoScrollingSpeedMs. An event moves by its share
|
|
1048
|
+
* of a notch (Canvas.WheelDeltaPerNotch), so a touchpad's many small events scroll as far as the fingers moved (C#
|
|
1049
|
+
* aaa871c3); an event under half a notch moves the content at once (no glide). Events arriving while the previous one is still animating add onto its target, so a fast wheel spin
|
|
1050
|
+
* travels N steps instead of restarting from the barely-moved current offset.
|
|
1047
1051
|
*/
|
|
1048
1052
|
ApplyWheelScroll(delta) {
|
|
1049
|
-
const
|
|
1053
|
+
const perNotch = this.Superview?.WheelDeltaPerNotch ?? 0;
|
|
1054
|
+
const lines = perNotch > 0 ? delta / perNotch : Math.sign(delta);
|
|
1055
|
+
const step = -SkiaScroll.WheelLineSize * lines; // wheel down = content up = more negative offset
|
|
1050
1056
|
const horizontal = this.Orientation === "Horizontal";
|
|
1051
|
-
|
|
1052
|
-
|
|
1057
|
+
// the target the running wheel / ScrollTo move lands on: Parameters.Destination is where its deceleration curve would
|
|
1058
|
+
// end if it ran on, beyond that target, so continuing from it overshot more with every event (a touchpad swipe flew away)
|
|
1059
|
+
const running = horizontal ? this.animatorFlingX : this.animatorFlingY, target = horizontal ? this.scrollToTargetX : this.scrollToTargetY;
|
|
1060
|
+
const base = running.IsRunning && target !== null ? target : horizontal ? this.offsetX : this.offsetY;
|
|
1053
1061
|
const b = this.ContentOffsetBounds;
|
|
1054
1062
|
const min = horizontal ? b.Left : b.Top, max = horizontal ? b.Right : b.Bottom;
|
|
1055
1063
|
if ((step < 0 && base <= min) || (step > 0 && base >= max))
|
|
@@ -1059,7 +1067,18 @@ export class SkiaScroll extends SkiaControl {
|
|
|
1059
1067
|
x = base + step;
|
|
1060
1068
|
else
|
|
1061
1069
|
y = base + step;
|
|
1062
|
-
|
|
1070
|
+
// a notch glides over AutoScrollingSpeedMs; smaller events (a touchpad, a free-spinning or high-resolution wheel) come
|
|
1071
|
+
// as a stream and move the content at once, as the browser scrolls a page: easing each one over 600 ms kept the
|
|
1072
|
+
// content behind the fingers, a visible delay before it started to move
|
|
1073
|
+
const glide = perNotch <= 0 || Math.abs(delta) >= perNotch / 2;
|
|
1074
|
+
this.ScrollTo(x, y, glide ? this.AutoScrollingSpeedMs / 1000 : 0, true);
|
|
1075
|
+
// a restarted glide starts at the event, not at its first frame: a fast trackpad swipe sends a notch-sized event
|
|
1076
|
+
// before every frame, and a glide drawn at progress 0 on each of them left the content standing still until the
|
|
1077
|
+
// events stopped, then it jumped (DrawnUi.Rust dfe36fe). Never before the last frame, at most one frame back.
|
|
1078
|
+
if (glide && running.IsRunning) {
|
|
1079
|
+
const event = performance.now() * 1_000_000;
|
|
1080
|
+
running.StartFrameTimeNanos = running.LastFrameTimeNanos = Math.max(this.Superview?.FrameTimeNanos ?? 0, event - 16_666_667);
|
|
1081
|
+
}
|
|
1063
1082
|
return true;
|
|
1064
1083
|
}
|
|
1065
1084
|
}
|
|
@@ -70,5 +70,13 @@ export declare class SkiaSlider extends SkiaControl {
|
|
|
70
70
|
protected Paint(ctx: DrawingContext): void;
|
|
71
71
|
ProcessGestures(args: SkiaGesturesParameters, apply: GestureEventProcessingInfo): SkiaControl | null;
|
|
72
72
|
protected DefaultAccessibilityCanInteract(): boolean;
|
|
73
|
+
/** Enter / Space do nothing: the base taps the center, which would jump the value to the middle. */
|
|
74
|
+
OnAccessibilityActivated(): void;
|
|
75
|
+
/**
|
|
76
|
+
* Keyboard adjustment while the slider holds keyboard focus (C# SkiaSlider.OnAccessibilityKey): Right / Up increase and
|
|
77
|
+
* Left / Down decrease by Step (a hundredth of the range when Step is 0), PageUp / PageDown move a tenth of the range,
|
|
78
|
+
* Home / End go to Min / Max. A ranged slider moves its End thumb, which stops at Start.
|
|
79
|
+
*/
|
|
80
|
+
OnAccessibilityKey(key: string): boolean;
|
|
73
81
|
protected DefaultAccessibilityLabel(): string | undefined;
|
|
74
82
|
}
|
|
@@ -239,5 +239,44 @@ export class SkiaSlider extends SkiaControl {
|
|
|
239
239
|
return super.ProcessGestures(args, apply);
|
|
240
240
|
}
|
|
241
241
|
DefaultAccessibilityCanInteract() { return this.RespondsToGestures; }
|
|
242
|
+
/** Enter / Space do nothing: the base taps the center, which would jump the value to the middle. */
|
|
243
|
+
OnAccessibilityActivated() { }
|
|
244
|
+
/**
|
|
245
|
+
* Keyboard adjustment while the slider holds keyboard focus (C# SkiaSlider.OnAccessibilityKey): Right / Up increase and
|
|
246
|
+
* Left / Down decrease by Step (a hundredth of the range when Step is 0), PageUp / PageDown move a tenth of the range,
|
|
247
|
+
* Home / End go to Min / Max. A ranged slider moves its End thumb, which stops at Start.
|
|
248
|
+
*/
|
|
249
|
+
OnAccessibilityKey(key) {
|
|
250
|
+
if (!this.RespondsToGestures)
|
|
251
|
+
return false;
|
|
252
|
+
const range = this.Max - this.Min, small = this.Step > 0 ? this.Step : range / 100, large = Math.max(small, range / 10);
|
|
253
|
+
const low = this.EnableRange ? this.Start : this.Min;
|
|
254
|
+
let value;
|
|
255
|
+
switch (key) {
|
|
256
|
+
case "ArrowRight":
|
|
257
|
+
case "ArrowUp":
|
|
258
|
+
value = this.End + small;
|
|
259
|
+
break;
|
|
260
|
+
case "ArrowLeft":
|
|
261
|
+
case "ArrowDown":
|
|
262
|
+
value = this.End - small;
|
|
263
|
+
break;
|
|
264
|
+
case "PageUp":
|
|
265
|
+
value = this.End + large;
|
|
266
|
+
break;
|
|
267
|
+
case "PageDown":
|
|
268
|
+
value = this.End - large;
|
|
269
|
+
break;
|
|
270
|
+
case "Home":
|
|
271
|
+
value = low;
|
|
272
|
+
break;
|
|
273
|
+
case "End":
|
|
274
|
+
value = this.Max;
|
|
275
|
+
break;
|
|
276
|
+
default: return false;
|
|
277
|
+
}
|
|
278
|
+
this.End = Math.min(this.Max, Math.max(low, value));
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
242
281
|
DefaultAccessibilityLabel() { return this.EnableRange ? `${this.start} – ${this.end}` : `${this.end}`; }
|
|
243
282
|
}
|
package/dist/controls/SkiaSvg.js
CHANGED
|
@@ -42,7 +42,7 @@ export class SkiaSvg extends SkiaControl {
|
|
|
42
42
|
}
|
|
43
43
|
const generation = ++this.loadGeneration;
|
|
44
44
|
this.IsLoading = true;
|
|
45
|
-
fetch(value)
|
|
45
|
+
fetch(Super.ResolveAssetUrl(value))
|
|
46
46
|
.then((r) => { if (!r.ok)
|
|
47
47
|
throw new Error(`DrawnUi: ${r.status} loading svg '${value}'`); return r.text(); })
|
|
48
48
|
.then((text) => { if (generation === this.loadGeneration)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { InputKey } from "./KeyboardManager";
|
|
1
2
|
import type { SkiaControl } from "./SkiaControl";
|
|
2
3
|
import { SKRect } from "./Types";
|
|
3
4
|
/** ARIA role constants (DrawnUi.Models.Aria) for `AccessibilityRole`. */
|
|
@@ -38,10 +39,19 @@ export declare const Aria: {
|
|
|
38
39
|
readonly RoleRegion: "region";
|
|
39
40
|
readonly RoleNavigation: "navigation";
|
|
40
41
|
readonly RoleMain: "main";
|
|
42
|
+
readonly RoleGrid: "grid";
|
|
43
|
+
readonly RoleToolbar: "toolbar";
|
|
44
|
+
readonly RoleRadioGroup: "radiogroup";
|
|
45
|
+
readonly RoleMenuBar: "menubar";
|
|
41
46
|
/** Removes the control from the accessibility tree even when a default role would apply (inner label of a button). */
|
|
42
47
|
readonly RolePresentation: "presentation";
|
|
43
48
|
readonly LivePolite: "polite";
|
|
44
49
|
readonly LiveAssertive: "assertive";
|
|
50
|
+
/**
|
|
51
|
+
* Container roles whose items the arrow keys walk (C# Aria.IsCompositeRole): a layout with one of them is one Tab stop,
|
|
52
|
+
* its current item, and the arrow keys move between its items.
|
|
53
|
+
*/
|
|
54
|
+
readonly IsCompositeRole: (role?: string) => boolean;
|
|
45
55
|
};
|
|
46
56
|
/** One laid-out text line exposed for native selection (AccessibilityTextSelectable): CSS px relative to the node. */
|
|
47
57
|
export interface AccessibilityTextLine {
|
|
@@ -81,6 +91,7 @@ export declare class SkiaAccessibilityManager {
|
|
|
81
91
|
private pending;
|
|
82
92
|
private readonly changed;
|
|
83
93
|
private readonly liveUpdated;
|
|
94
|
+
private readonly keyboardFocusRequested;
|
|
84
95
|
/** Minimum milliseconds between snapshot rebuilds. */
|
|
85
96
|
MinUpdateIntervalMs: number;
|
|
86
97
|
Snapshot: AccessibilityNode[];
|
|
@@ -89,7 +100,54 @@ export declare class SkiaAccessibilityManager {
|
|
|
89
100
|
OnChanged(cb: () => void): () => void;
|
|
90
101
|
/** Fired immediately (bypassing the rate limit) when a live-region node's value changes. */
|
|
91
102
|
OnLiveRegionUpdated(cb: (node: SkiaControl) => void): () => void;
|
|
103
|
+
/**
|
|
104
|
+
* Keyboard focus moved by the manager itself (arrow keys in a group): the overlay moves DOM focus to that node's
|
|
105
|
+
* element (C# KeyboardFocusRequested). Returns the unsubscribe function.
|
|
106
|
+
*/
|
|
107
|
+
OnKeyboardFocusRequested(cb: (node: SkiaControl) => void): () => void;
|
|
92
108
|
NotifyFocused(node?: SkiaControl): void;
|
|
109
|
+
/**
|
|
110
|
+
* Enter / Space from keyboard navigation or a screen reader: activates the node only while a tap could reach it
|
|
111
|
+
* (AccessibilityCanInteract), the rule every head applies.
|
|
112
|
+
*/
|
|
113
|
+
static Activate(node?: SkiaControl): boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Arrow keys, Home / End, PageUp / PageDown for the node in keyboard focus: the control gets them first, only while a
|
|
116
|
+
* pan could reach it (a slider steps its value); keys it does not use move keyboard focus between the items of the
|
|
117
|
+
* group around it (MoveInGroup). True when the key was used.
|
|
118
|
+
*/
|
|
119
|
+
static Key(node: SkiaControl | undefined, key: InputKey): boolean;
|
|
120
|
+
/** Last focused node of each group: where Tab enters the group. */
|
|
121
|
+
private readonly groupFocus;
|
|
122
|
+
private groupRequest?;
|
|
123
|
+
/**
|
|
124
|
+
* The arrow-key group around `control`: its nearest ancestor with a composite role (Aria.IsCompositeRole, e.g.
|
|
125
|
+
* Aria.RoleList on a SkiaStack), and the item of that group holding the control (one of its children, a cell).
|
|
126
|
+
*/
|
|
127
|
+
static TryFindGroup(control: SkiaControl): {
|
|
128
|
+
Group: SkiaControl;
|
|
129
|
+
Item: SkiaControl;
|
|
130
|
+
} | undefined;
|
|
131
|
+
/** Remembers where keyboard focus is inside its group, so the next Tab into the group lands there. */
|
|
132
|
+
private NoteFocus;
|
|
133
|
+
/**
|
|
134
|
+
* Whether Tab / Shift+Tab stop on this node. A group of items is one Tab stop, like a native list: its current item
|
|
135
|
+
* (the one keyboard focus last had there, else the group's first usable node) plus the controls inside that same
|
|
136
|
+
* item; the arrow keys move between items. Every node outside a group is a stop.
|
|
137
|
+
*/
|
|
138
|
+
IsTabStop(node: SkiaControl): boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Moves keyboard focus between the items of the group around `focused`, by item index like a native list: Down / Up in
|
|
141
|
+
* a Column, Right / Left in a Row, all four in a Wrap, a Grid or a Split layout (Up / Down by one row), Home / End to the
|
|
142
|
+
* first / last item, PageDown / PageUp by one viewport of the scroll around it. No wrap at the ends. The target is the
|
|
143
|
+
* item itself when it is an interactive node, else its first interactive node; items the pointer cannot use are
|
|
144
|
+
* skipped. An item not realized (recycled cells) is scrolled in with ScrollToIndex when the group is the scroll's
|
|
145
|
+
* content, and focused once drawn. True when the key belongs to the group.
|
|
146
|
+
*/
|
|
147
|
+
MoveInGroup(focused: SkiaControl, key: InputKey): boolean;
|
|
148
|
+
/** Frame end: focus the requested item once it is drawn, skipping items that cannot take input. */
|
|
149
|
+
private ProcessGroupFocus;
|
|
150
|
+
private FocusFromKeyboard;
|
|
93
151
|
Register(node: SkiaControl): void;
|
|
94
152
|
NotifyUpdated(node: SkiaControl): void;
|
|
95
153
|
ForceRebuildOnNextFrame(): void;
|
|
@@ -100,5 +158,6 @@ export declare class SkiaAccessibilityManager {
|
|
|
100
158
|
* per MinUpdateIntervalMs otherwise (keeps rects in sync with scrolling); notifies only when the snapshot differs.
|
|
101
159
|
*/
|
|
102
160
|
OnFrameEnd(scale: number, canvasWidthPx: number, canvasHeightPx: number, requestFrame: () => void): void;
|
|
161
|
+
private Rebuild;
|
|
103
162
|
private static Same;
|
|
104
163
|
}
|
|
@@ -12,11 +12,23 @@ export const Aria = {
|
|
|
12
12
|
RoleSeparator: "separator", RoleProgressBar: "progressbar", RoleTooltip: "tooltip", RoleDialog: "dialog",
|
|
13
13
|
RoleAlertDialog: "alertdialog", RoleStatus: "status", RoleAlert: "alert", RoleGroup: "group", RoleRegion: "region",
|
|
14
14
|
RoleNavigation: "navigation", RoleMain: "main",
|
|
15
|
+
// containers of items: with one of these roles a layout is an arrow-key group (IsCompositeRole)
|
|
16
|
+
RoleGrid: "grid", RoleToolbar: "toolbar", RoleRadioGroup: "radiogroup", RoleMenuBar: "menubar",
|
|
15
17
|
/** Removes the control from the accessibility tree even when a default role would apply (inner label of a button). */
|
|
16
18
|
RolePresentation: "presentation",
|
|
17
19
|
// live regions
|
|
18
20
|
LivePolite: "polite", LiveAssertive: "assertive",
|
|
21
|
+
/**
|
|
22
|
+
* Container roles whose items the arrow keys walk (C# Aria.IsCompositeRole): a layout with one of them is one Tab stop,
|
|
23
|
+
* its current item, and the arrow keys move between its items.
|
|
24
|
+
*/
|
|
25
|
+
IsCompositeRole(role) {
|
|
26
|
+
return role === "list" || role === "listbox" || role === "grid" || role === "toolbar" || role === "radiogroup"
|
|
27
|
+
|| role === "tablist" || role === "menu" || role === "menubar";
|
|
28
|
+
},
|
|
19
29
|
};
|
|
30
|
+
const asLayout = (c) => ("ChildrenFactory" in c && "Split" in c ? c : undefined);
|
|
31
|
+
const asScroll = (c) => ("ScrollToIndex" in c && "ViewportOffsetY" in c ? c : undefined);
|
|
20
32
|
/**
|
|
21
33
|
* Mirrors DrawnUi SkiaAccessibilityManager: registry of accessible controls + a rate-limited snapshot
|
|
22
34
|
* (at most one rebuild per MinUpdateIntervalMs, taken at frame end) that the DOM overlay renders.
|
|
@@ -30,6 +42,7 @@ export class SkiaAccessibilityManager {
|
|
|
30
42
|
pending = 0;
|
|
31
43
|
changed = new Set();
|
|
32
44
|
liveUpdated = new Set();
|
|
45
|
+
keyboardFocusRequested = new Set();
|
|
33
46
|
/** Minimum milliseconds between snapshot rebuilds. */
|
|
34
47
|
MinUpdateIntervalMs = 1000;
|
|
35
48
|
Snapshot = [];
|
|
@@ -38,8 +51,191 @@ export class SkiaAccessibilityManager {
|
|
|
38
51
|
OnChanged(cb) { this.changed.add(cb); return () => this.changed.delete(cb); }
|
|
39
52
|
/** Fired immediately (bypassing the rate limit) when a live-region node's value changes. */
|
|
40
53
|
OnLiveRegionUpdated(cb) { this.liveUpdated.add(cb); return () => this.liveUpdated.delete(cb); }
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Keyboard focus moved by the manager itself (arrow keys in a group): the overlay moves DOM focus to that node's
|
|
56
|
+
* element (C# KeyboardFocusRequested). Returns the unsubscribe function.
|
|
57
|
+
*/
|
|
58
|
+
OnKeyboardFocusRequested(cb) { this.keyboardFocusRequested.add(cb); return () => this.keyboardFocusRequested.delete(cb); }
|
|
59
|
+
NotifyFocused(node) {
|
|
60
|
+
if (this.FocusedNode === node)
|
|
61
|
+
return;
|
|
62
|
+
this.NoteFocus(node);
|
|
63
|
+
this.FocusedNode = node;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Enter / Space from keyboard navigation or a screen reader: activates the node only while a tap could reach it
|
|
67
|
+
* (AccessibilityCanInteract), the rule every head applies.
|
|
68
|
+
*/
|
|
69
|
+
static Activate(node) {
|
|
70
|
+
if (!node || !node.AccessibilityCanInteract)
|
|
71
|
+
return false;
|
|
72
|
+
node.OnAccessibilityActivated();
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Arrow keys, Home / End, PageUp / PageDown for the node in keyboard focus: the control gets them first, only while a
|
|
77
|
+
* pan could reach it (a slider steps its value); keys it does not use move keyboard focus between the items of the
|
|
78
|
+
* group around it (MoveInGroup). True when the key was used.
|
|
79
|
+
*/
|
|
80
|
+
static Key(node, key) {
|
|
81
|
+
if (!node)
|
|
82
|
+
return false;
|
|
83
|
+
if (node.CanReceiveGesture("Panning") && node.OnAccessibilityKey(key))
|
|
84
|
+
return true;
|
|
85
|
+
return node.Superview?.AccessibilityManager.MoveInGroup(node, key) ?? false;
|
|
86
|
+
}
|
|
87
|
+
// ---- arrow-key groups (C# SkiaAccessibilityManager, "Arrow-key groups") ----
|
|
88
|
+
/** Last focused node of each group: where Tab enters the group. */
|
|
89
|
+
groupFocus = new WeakMap();
|
|
90
|
+
groupRequest;
|
|
91
|
+
/**
|
|
92
|
+
* The arrow-key group around `control`: its nearest ancestor with a composite role (Aria.IsCompositeRole, e.g.
|
|
93
|
+
* Aria.RoleList on a SkiaStack), and the item of that group holding the control (one of its children, a cell).
|
|
94
|
+
*/
|
|
95
|
+
static TryFindGroup(control) {
|
|
96
|
+
for (let child = control, parent = control.Parent; parent; child = parent, parent = parent.Parent) {
|
|
97
|
+
if (Aria.IsCompositeRole(parent.AccessibilityRole))
|
|
98
|
+
return { Group: parent, Item: child };
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
/** Remembers where keyboard focus is inside its group, so the next Tab into the group lands there. */
|
|
103
|
+
NoteFocus(node) {
|
|
104
|
+
const found = node && SkiaAccessibilityManager.TryFindGroup(node);
|
|
105
|
+
if (found)
|
|
106
|
+
this.groupFocus.set(found.Group, node);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether Tab / Shift+Tab stop on this node. A group of items is one Tab stop, like a native list: its current item
|
|
110
|
+
* (the one keyboard focus last had there, else the group's first usable node) plus the controls inside that same
|
|
111
|
+
* item; the arrow keys move between items. Every node outside a group is a stop.
|
|
112
|
+
*/
|
|
113
|
+
IsTabStop(node) {
|
|
114
|
+
const found = SkiaAccessibilityManager.TryFindGroup(node);
|
|
115
|
+
if (!found)
|
|
116
|
+
return true;
|
|
117
|
+
let stopItem;
|
|
118
|
+
const remembered = this.groupFocus.get(found.Group);
|
|
119
|
+
const current = remembered && !remembered.IsDisposed && remembered.AccessibilityCanInteract && this.Snapshot.some((n) => n.Source === remembered)
|
|
120
|
+
? SkiaAccessibilityManager.TryFindGroup(remembered) : undefined;
|
|
121
|
+
if (current && current.Group === found.Group)
|
|
122
|
+
stopItem = current.Item;
|
|
123
|
+
else {
|
|
124
|
+
for (const n of this.Snapshot) {
|
|
125
|
+
if (!n.Source.AccessibilityCanInteract)
|
|
126
|
+
continue;
|
|
127
|
+
const g = SkiaAccessibilityManager.TryFindGroup(n.Source);
|
|
128
|
+
if (g && g.Group === found.Group) {
|
|
129
|
+
stopItem = g.Item;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return !stopItem || stopItem === found.Item;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Moves keyboard focus between the items of the group around `focused`, by item index like a native list: Down / Up in
|
|
138
|
+
* a Column, Right / Left in a Row, all four in a Wrap, a Grid or a Split layout (Up / Down by one row), Home / End to the
|
|
139
|
+
* first / last item, PageDown / PageUp by one viewport of the scroll around it. No wrap at the ends. The target is the
|
|
140
|
+
* item itself when it is an interactive node, else its first interactive node; items the pointer cannot use are
|
|
141
|
+
* skipped. An item not realized (recycled cells) is scrolled in with ScrollToIndex when the group is the scroll's
|
|
142
|
+
* content, and focused once drawn. True when the key belongs to the group.
|
|
143
|
+
*/
|
|
144
|
+
MoveInGroup(focused, key) {
|
|
145
|
+
const found = SkiaAccessibilityManager.TryFindGroup(focused);
|
|
146
|
+
if (!found)
|
|
147
|
+
return false;
|
|
148
|
+
const { Group: group, Item: item } = found;
|
|
149
|
+
const scroll = FindScroll(group), axis = AxisOf(group), count = ItemCount(group);
|
|
150
|
+
const current = this.groupRequest?.Group === group ? this.groupRequest.Index : IndexOf(group, item);
|
|
151
|
+
if (current < 0 || count < 1)
|
|
152
|
+
return false;
|
|
153
|
+
const row = axis === "Both" ? RowLength(group, item) : 1;
|
|
154
|
+
const vertical = axis !== "Horizontal";
|
|
155
|
+
const page = () => PageSize(scroll, item, vertical) * row;
|
|
156
|
+
let target, step;
|
|
157
|
+
if ((key === "ArrowDown" && axis === "Vertical") || (key === "ArrowRight" && axis !== "Vertical")) {
|
|
158
|
+
target = current + 1;
|
|
159
|
+
step = 1;
|
|
160
|
+
}
|
|
161
|
+
else if ((key === "ArrowUp" && axis === "Vertical") || (key === "ArrowLeft" && axis !== "Vertical")) {
|
|
162
|
+
target = current - 1;
|
|
163
|
+
step = -1;
|
|
164
|
+
}
|
|
165
|
+
else if (key === "ArrowDown" && axis === "Both") {
|
|
166
|
+
target = Math.min(count - 1, current + row);
|
|
167
|
+
step = 1;
|
|
168
|
+
}
|
|
169
|
+
else if (key === "ArrowUp" && axis === "Both") {
|
|
170
|
+
target = Math.max(0, current - row);
|
|
171
|
+
step = -1;
|
|
172
|
+
}
|
|
173
|
+
else if (key === "Home") {
|
|
174
|
+
target = 0;
|
|
175
|
+
step = 1;
|
|
176
|
+
}
|
|
177
|
+
else if (key === "End") {
|
|
178
|
+
target = count - 1;
|
|
179
|
+
step = -1;
|
|
180
|
+
}
|
|
181
|
+
else if (key === "PageDown" && scroll) {
|
|
182
|
+
target = Math.min(count - 1, current + page());
|
|
183
|
+
step = 1;
|
|
184
|
+
}
|
|
185
|
+
else if (key === "PageUp" && scroll) {
|
|
186
|
+
target = Math.max(0, current - page());
|
|
187
|
+
step = -1;
|
|
188
|
+
}
|
|
189
|
+
else
|
|
190
|
+
return false;
|
|
191
|
+
if (target < 0 || target >= count || target === current)
|
|
192
|
+
return true; // at the end of the group nothing happens, the key still belongs to it
|
|
193
|
+
this.groupRequest = { Group: group, Scroll: scroll, Index: target, Step: step, Deadline: performance.now() + 3000, ScrollIssued: false };
|
|
194
|
+
group.Superview?.Update(); // resolved at the end of the next frame, together with the snapshot
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
/** Frame end: focus the requested item once it is drawn, skipping items that cannot take input. */
|
|
198
|
+
ProcessGroupFocus(scale, w, h, requestFrame) {
|
|
199
|
+
const request = this.groupRequest;
|
|
200
|
+
if (!request)
|
|
201
|
+
return;
|
|
202
|
+
const cell = ItemAt(request.Group, request.Index);
|
|
203
|
+
if (cell && IsDrawn(cell)) {
|
|
204
|
+
const node = FindTarget(cell);
|
|
205
|
+
if (node) {
|
|
206
|
+
this.groupRequest = undefined;
|
|
207
|
+
this.FocusFromKeyboard(node, scale, w, h);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// this item cannot take input: go on to the next one in the same direction
|
|
211
|
+
request.Index += request.Step;
|
|
212
|
+
if (request.Index < 0 || request.Index >= ItemCount(request.Group)) {
|
|
213
|
+
this.groupRequest = undefined;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
request.Deadline = performance.now() + 3000;
|
|
217
|
+
request.ScrollIssued = false;
|
|
218
|
+
}
|
|
219
|
+
else if (performance.now() > request.Deadline || request.Group.IsDisposed) {
|
|
220
|
+
this.groupRequest = undefined;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!request.ScrollIssued) {
|
|
224
|
+
request.ScrollIssued = true;
|
|
225
|
+
const next = ItemAt(request.Group, request.Index);
|
|
226
|
+
if ((!next || !IsDrawn(next)) && request.Scroll && request.Scroll.Content === request.Group) {
|
|
227
|
+
request.Scroll.ScrollToIndex(request.Index, true, request.Step > 0 ? "End" : "Start");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
requestFrame(); // keep frames coming until the item is drawn
|
|
231
|
+
}
|
|
232
|
+
FocusFromKeyboard(node, scale, w, h) {
|
|
233
|
+
this.Rebuild(scale, w, h); // the overlay looks the node up in the snapshot: make sure it is there now
|
|
234
|
+
this.NoteFocus(node);
|
|
235
|
+
this.FocusedNode = node;
|
|
236
|
+
for (const cb of this.keyboardFocusRequested)
|
|
237
|
+
cb(node);
|
|
238
|
+
}
|
|
43
239
|
Register(node) { this.nodes.add(node); this.dirty = true; }
|
|
44
240
|
NotifyUpdated(node) {
|
|
45
241
|
if (!this.nodes.has(node))
|
|
@@ -70,6 +266,7 @@ export class SkiaAccessibilityManager {
|
|
|
70
266
|
* per MinUpdateIntervalMs otherwise (keeps rects in sync with scrolling); notifies only when the snapshot differs.
|
|
71
267
|
*/
|
|
72
268
|
OnFrameEnd(scale, canvasWidthPx, canvasHeightPx, requestFrame) {
|
|
269
|
+
this.ProcessGroupFocus(scale, canvasWidthPx, canvasHeightPx, requestFrame);
|
|
73
270
|
if (this.nodes.size === 0 && this.Snapshot.length === 0)
|
|
74
271
|
return;
|
|
75
272
|
const now = performance.now();
|
|
@@ -80,8 +277,11 @@ export class SkiaAccessibilityManager {
|
|
|
80
277
|
this.pending = window.setTimeout(() => { this.pending = 0; requestFrame(); }, wait);
|
|
81
278
|
return;
|
|
82
279
|
}
|
|
280
|
+
this.Rebuild(scale, canvasWidthPx, canvasHeightPx);
|
|
281
|
+
}
|
|
282
|
+
Rebuild(scale, canvasWidthPx, canvasHeightPx) {
|
|
83
283
|
this.dirty = false;
|
|
84
|
-
this.lastRebuild = now;
|
|
284
|
+
this.lastRebuild = performance.now();
|
|
85
285
|
const list = [];
|
|
86
286
|
for (const n of this.nodes) {
|
|
87
287
|
if (!n.Superview) {
|
|
@@ -108,6 +308,21 @@ export class SkiaAccessibilityManager {
|
|
|
108
308
|
});
|
|
109
309
|
}
|
|
110
310
|
list.sort((a, b) => a.Rect.Top - b.Rect.Top || a.Rect.Left - b.Rect.Left);
|
|
311
|
+
// reading order: nodes whose tops are within half the smaller height form one row, read left to right, so a row of
|
|
312
|
+
// vertically centered controls of different heights keeps its visual order (C# RectComparer + LeftComparer)
|
|
313
|
+
for (let start = 0, i = 1; i <= list.length; i++) {
|
|
314
|
+
if (i < list.length) {
|
|
315
|
+
const first = list[start].Rect, next = list[i].Rect;
|
|
316
|
+
if (next.Top - first.Top < Math.min(first.Height, next.Height) / 2)
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (i - start > 1) {
|
|
320
|
+
const row = list.slice(start, i).sort((a, b) => a.Rect.Left - b.Rect.Left);
|
|
321
|
+
for (let k = 0; k < row.length; k++)
|
|
322
|
+
list[start + k] = row[k];
|
|
323
|
+
}
|
|
324
|
+
start = i;
|
|
325
|
+
}
|
|
111
326
|
if (SkiaAccessibilityManager.Same(list, this.Snapshot))
|
|
112
327
|
return;
|
|
113
328
|
this.Snapshot = list;
|
|
@@ -138,6 +353,91 @@ export class SkiaAccessibilityManager {
|
|
|
138
353
|
return true;
|
|
139
354
|
}
|
|
140
355
|
}
|
|
356
|
+
// ---- group helpers (duck-typed layouts and scrolls) ----
|
|
357
|
+
/** Children of a control (a layout's Views, a scroll's Content). */
|
|
358
|
+
function ViewsOf(c) {
|
|
359
|
+
if ("Views" in c)
|
|
360
|
+
return c.Views;
|
|
361
|
+
const content = c.Content;
|
|
362
|
+
return content ? [content] : [];
|
|
363
|
+
}
|
|
364
|
+
function FindScroll(group) {
|
|
365
|
+
for (let p = group.Parent; p; p = p.Parent) {
|
|
366
|
+
const s = asScroll(p);
|
|
367
|
+
if (s)
|
|
368
|
+
return s;
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
/** Down / Up in a Column, Right / Left in a Row, all four in a Wrap, a Grid or a Split layout. */
|
|
373
|
+
function AxisOf(group) {
|
|
374
|
+
const l = asLayout(group);
|
|
375
|
+
if (!l)
|
|
376
|
+
return "Vertical";
|
|
377
|
+
if (l.Type === "Column")
|
|
378
|
+
return l.Split <= 1 ? "Vertical" : "Both";
|
|
379
|
+
if (l.Type === "Row")
|
|
380
|
+
return "Horizontal";
|
|
381
|
+
if (l.Type === "Absolute")
|
|
382
|
+
return "Vertical";
|
|
383
|
+
return "Both";
|
|
384
|
+
}
|
|
385
|
+
/** Items per row of a 2D group: Split when set, else the items sharing the row of `item`. */
|
|
386
|
+
function RowLength(group, item) {
|
|
387
|
+
const l = asLayout(group);
|
|
388
|
+
if (l && l.Split > 1)
|
|
389
|
+
return l.Split;
|
|
390
|
+
const top = item.GetAccessibilityPixelRect();
|
|
391
|
+
if (top.Height <= 0)
|
|
392
|
+
return 1;
|
|
393
|
+
const mid = (top.Top + top.Bottom) / 2;
|
|
394
|
+
let count = 0;
|
|
395
|
+
for (const child of ViewsOf(group)) {
|
|
396
|
+
const r = child.GetAccessibilityPixelRect();
|
|
397
|
+
if (r.Height > 0 && Math.abs((r.Top + r.Bottom) / 2 - mid) < top.Height / 2)
|
|
398
|
+
count++;
|
|
399
|
+
}
|
|
400
|
+
return Math.max(1, count);
|
|
401
|
+
}
|
|
402
|
+
function ItemCount(group) {
|
|
403
|
+
const l = asLayout(group);
|
|
404
|
+
return l?.IsTemplated ? l.ItemsSource?.length ?? 0 : ViewsOf(group).length;
|
|
405
|
+
}
|
|
406
|
+
function IndexOf(group, item) {
|
|
407
|
+
const l = asLayout(group);
|
|
408
|
+
return l?.IsTemplated ? item.ContextIndex : ViewsOf(group).indexOf(item);
|
|
409
|
+
}
|
|
410
|
+
/** The view of an item: the realized cell of a templated layout (none when its cell is not in use), else the child. */
|
|
411
|
+
function ItemAt(group, index) {
|
|
412
|
+
const l = asLayout(group);
|
|
413
|
+
if (l?.IsTemplated) {
|
|
414
|
+
const v = l.ChildrenFactory.GetViewForIndex(index);
|
|
415
|
+
return v && v.ContextIndex === index ? v : undefined;
|
|
416
|
+
}
|
|
417
|
+
return index >= 0 && index < ViewsOf(group).length ? ViewsOf(group)[index] : undefined;
|
|
418
|
+
}
|
|
419
|
+
/** Arranged on the canvas: realized and laid out (a templated layout keeps only the cells it draws in use). */
|
|
420
|
+
function IsDrawn(control) {
|
|
421
|
+
const r = control.GetAccessibilityPixelRect();
|
|
422
|
+
return !!control.Superview && r.Width > 0 && r.Height > 0;
|
|
423
|
+
}
|
|
424
|
+
/** Items per viewport of the scroll around the group. */
|
|
425
|
+
function PageSize(scroll, item, vertical) {
|
|
426
|
+
const v = scroll.DrawingRect, size = item.MeasuredSize.Pixels;
|
|
427
|
+
const viewport = vertical ? v.Height : v.Width, s = vertical ? size.Height : size.Width;
|
|
428
|
+
return s > 0 ? Math.max(1, Math.floor(viewport / s)) : 1;
|
|
429
|
+
}
|
|
430
|
+
/** The item itself when it is an interactive node, else its first interactive node in tree order. */
|
|
431
|
+
function FindTarget(item) {
|
|
432
|
+
if (item.IsAccessibilityElement && item.AccessibilityCanInteract)
|
|
433
|
+
return item;
|
|
434
|
+
for (const child of ViewsOf(item)) {
|
|
435
|
+
const found = FindTarget(child);
|
|
436
|
+
if (found)
|
|
437
|
+
return found;
|
|
438
|
+
}
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
141
441
|
/** True when any ancestor is hidden (IsVisible false or Opacity 0): the subtree is not drawn, so it gets no nodes. */
|
|
142
442
|
function AccessibilityManagerHiddenByAncestor(control) {
|
|
143
443
|
let p = control.Parent;
|
package/dist/core/Canvas.d.ts
CHANGED
|
@@ -77,6 +77,14 @@ export declare class Canvas {
|
|
|
77
77
|
protected ExecuteAnimators(frameTimeNanos: number): number;
|
|
78
78
|
/** Frames drawn so far (SkiaBackdrop uses it to refresh once after a cached record). */
|
|
79
79
|
FrameIndex: number;
|
|
80
|
+
/**
|
|
81
|
+
* DrawnView.WheelDeltaPerNotch: the Wheel.Delta one mouse-wheel notch produces, 100 CSS pixels in Chrome and Edge. A
|
|
82
|
+
* scroll moves by the event's share of a notch, so a touchpad, which sends many small events, scrolls as far as the
|
|
83
|
+
* fingers moved. 0 = units unknown: every event scrolls one line.
|
|
84
|
+
*/
|
|
85
|
+
WheelDeltaPerNotch: number;
|
|
86
|
+
/** Vsync time of the last frame the animators ran on (nanoseconds, the performance.now() timeline). */
|
|
87
|
+
FrameTimeNanos: number;
|
|
80
88
|
/** On-screen surface (SkiaBackdrop snapshots it). */
|
|
81
89
|
get Surface(): Surface | undefined;
|
|
82
90
|
/**
|
package/dist/core/Canvas.js
CHANGED
|
@@ -140,6 +140,7 @@ export class Canvas {
|
|
|
140
140
|
// (measured 12..23 ms between frames shown 16.7 ms apart), and a game loop moving by speed * delta then
|
|
141
141
|
// jumps unevenly on screen.
|
|
142
142
|
const frameTime = Number(document.timeline?.currentTime ?? started);
|
|
143
|
+
this.FrameTimeNanos = Math.round(frameTime * 1_000_000);
|
|
143
144
|
const executed = this.ExecuteAnimators(Math.round(frameTime * 1_000_000));
|
|
144
145
|
const canRender = this.CanRender;
|
|
145
146
|
if (canRender && !this.WasRendered)
|
|
@@ -203,6 +204,14 @@ export class Canvas {
|
|
|
203
204
|
}
|
|
204
205
|
/** Frames drawn so far (SkiaBackdrop uses it to refresh once after a cached record). */
|
|
205
206
|
FrameIndex = 0;
|
|
207
|
+
/**
|
|
208
|
+
* DrawnView.WheelDeltaPerNotch: the Wheel.Delta one mouse-wheel notch produces, 100 CSS pixels in Chrome and Edge. A
|
|
209
|
+
* scroll moves by the event's share of a notch, so a touchpad, which sends many small events, scrolls as far as the
|
|
210
|
+
* fingers moved. 0 = units unknown: every event scrolls one line.
|
|
211
|
+
*/
|
|
212
|
+
WheelDeltaPerNotch = 100;
|
|
213
|
+
/** Vsync time of the last frame the animators ran on (nanoseconds, the performance.now() timeline). */
|
|
214
|
+
FrameTimeNanos = 0;
|
|
206
215
|
/** On-screen surface (SkiaBackdrop snapshots it). */
|
|
207
216
|
get Surface() { return this.surface; }
|
|
208
217
|
/**
|
|
@@ -383,7 +392,11 @@ export class Canvas {
|
|
|
383
392
|
args.Scale = this.RenderingScale;
|
|
384
393
|
args.Location = new SKPoint((e.clientX - rect.left) * this.RenderingScale, (e.clientY - rect.top) * this.RenderingScale);
|
|
385
394
|
args.StartingLocation = args.Location;
|
|
386
|
-
|
|
395
|
+
// lines and pages to pixels, then the dominant axis (the .NET browser heads, fc8980c1); a vertical scroll leaves
|
|
396
|
+
// horizontal events alone (C# ba03cb20)
|
|
397
|
+
const factor = e.deltaMode === 1 ? 40 : e.deltaMode === 2 ? 800 : 1;
|
|
398
|
+
const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY);
|
|
399
|
+
args.Wheel = { Delta: (horizontal ? e.deltaX : e.deltaY) * factor, IsHorizontal: horizontal };
|
|
387
400
|
if (this.gestures === "Lock") {
|
|
388
401
|
e.preventDefault();
|
|
389
402
|
this.OnGestureEvent(args, "Wheel");
|
package/dist/core/Gestures.d.ts
CHANGED
|
@@ -49,9 +49,13 @@ export declare class TouchActionEventArgs {
|
|
|
49
49
|
Timestamp: number;
|
|
50
50
|
/** ms since the previous event of the same pointer. */
|
|
51
51
|
DeltaTimeMs: number;
|
|
52
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Mouse wheel: Delta > 0 = wheel down / right (browser sign). IsHorizontal: the event is along the X axis (a tilting
|
|
54
|
+
* wheel, the sideways part of a two-finger touchpad swipe), as C# WheelEventArgs.IsHorizontal.
|
|
55
|
+
*/
|
|
53
56
|
Wheel: {
|
|
54
57
|
Delta: number;
|
|
58
|
+
IsHorizontal?: boolean;
|
|
55
59
|
};
|
|
56
60
|
/**
|
|
57
61
|
* AppoMobi TouchActionEventArgs.Handled: set by a control that actually used the event. The canvas prevents the
|