drawnui-react 0.1.0-preview.1
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/LICENSE +21 -0
- package/PARITY.md +287 -0
- package/README.md +92 -0
- package/SKIPPED.md +107 -0
- package/dist/controls/AnimatedFramesRenderer.d.ts +58 -0
- package/dist/controls/AnimatedFramesRenderer.js +133 -0
- package/dist/controls/GridStructure.d.ts +79 -0
- package/dist/controls/GridStructure.js +403 -0
- package/dist/controls/RefreshIndicator.d.ts +35 -0
- package/dist/controls/RefreshIndicator.js +82 -0
- package/dist/controls/SkiaBackdrop.d.ts +25 -0
- package/dist/controls/SkiaBackdrop.js +80 -0
- package/dist/controls/SkiaButton.d.ts +58 -0
- package/dist/controls/SkiaButton.js +148 -0
- package/dist/controls/SkiaCarousel.d.ts +133 -0
- package/dist/controls/SkiaCarousel.js +603 -0
- package/dist/controls/SkiaCheckbox.d.ts +21 -0
- package/dist/controls/SkiaCheckbox.js +118 -0
- package/dist/controls/SkiaDecoratedGrid.d.ts +15 -0
- package/dist/controls/SkiaDecoratedGrid.js +46 -0
- package/dist/controls/SkiaDrawer.d.ts +46 -0
- package/dist/controls/SkiaDrawer.js +212 -0
- package/dist/controls/SkiaDynamicDrawnCell.d.ts +11 -0
- package/dist/controls/SkiaDynamicDrawnCell.js +17 -0
- package/dist/controls/SkiaEditor.d.ts +163 -0
- package/dist/controls/SkiaEditor.js +803 -0
- package/dist/controls/SkiaGif.d.ts +54 -0
- package/dist/controls/SkiaGif.js +206 -0
- package/dist/controls/SkiaHotspot.d.ts +13 -0
- package/dist/controls/SkiaHotspot.js +36 -0
- package/dist/controls/SkiaImage.d.ts +75 -0
- package/dist/controls/SkiaImage.js +222 -0
- package/dist/controls/SkiaImageTiles.d.ts +27 -0
- package/dist/controls/SkiaImageTiles.js +76 -0
- package/dist/controls/SkiaLabel.d.ts +160 -0
- package/dist/controls/SkiaLabel.js +580 -0
- package/dist/controls/SkiaLayout.d.ts +163 -0
- package/dist/controls/SkiaLayout.js +723 -0
- package/dist/controls/SkiaLottie.d.ts +75 -0
- package/dist/controls/SkiaLottie.js +290 -0
- package/dist/controls/SkiaProgress.d.ts +40 -0
- package/dist/controls/SkiaProgress.js +80 -0
- package/dist/controls/SkiaRadioButton.d.ts +32 -0
- package/dist/controls/SkiaRadioButton.js +157 -0
- package/dist/controls/SkiaRichLabel.d.ts +77 -0
- package/dist/controls/SkiaRichLabel.js +274 -0
- package/dist/controls/SkiaScroll.d.ts +231 -0
- package/dist/controls/SkiaScroll.js +958 -0
- package/dist/controls/SkiaScrollBar.d.ts +56 -0
- package/dist/controls/SkiaScrollBar.js +158 -0
- package/dist/controls/SkiaShaderCarousel.d.ts +67 -0
- package/dist/controls/SkiaShaderCarousel.js +246 -0
- package/dist/controls/SkiaShape.d.ts +63 -0
- package/dist/controls/SkiaShape.js +317 -0
- package/dist/controls/SkiaSlider.d.ts +74 -0
- package/dist/controls/SkiaSlider.js +243 -0
- package/dist/controls/SkiaSprite.d.ts +99 -0
- package/dist/controls/SkiaSprite.js +324 -0
- package/dist/controls/SkiaSpriteSet.d.ts +27 -0
- package/dist/controls/SkiaSpriteSet.js +75 -0
- package/dist/controls/SkiaSvg.d.ts +43 -0
- package/dist/controls/SkiaSvg.js +181 -0
- package/dist/controls/SkiaSwitch.d.ts +26 -0
- package/dist/controls/SkiaSwitch.js +147 -0
- package/dist/controls/SkiaToggle.d.ts +50 -0
- package/dist/controls/SkiaToggle.js +71 -0
- package/dist/controls/SnappingLayout.d.ts +78 -0
- package/dist/controls/SnappingLayout.js +217 -0
- package/dist/controls/TextSpan.d.ts +70 -0
- package/dist/controls/TextSpan.js +76 -0
- package/dist/core/Accessibility.d.ts +104 -0
- package/dist/core/Accessibility.js +150 -0
- package/dist/core/Animators.d.ts +82 -0
- package/dist/core/Animators.js +247 -0
- package/dist/core/Canvas.d.ts +89 -0
- package/dist/core/Canvas.js +328 -0
- package/dist/core/ControlStyle.d.ts +6 -0
- package/dist/core/ControlStyle.js +11 -0
- package/dist/core/Easing.d.ts +13 -0
- package/dist/core/Easing.js +15 -0
- package/dist/core/Gestures.d.ts +74 -0
- package/dist/core/Gestures.js +104 -0
- package/dist/core/ImageEffects.d.ts +29 -0
- package/dist/core/ImageEffects.js +55 -0
- package/dist/core/KeyboardManager.d.ts +25 -0
- package/dist/core/KeyboardManager.js +54 -0
- package/dist/core/ScrollAnimators.d.ts +90 -0
- package/dist/core/ScrollAnimators.js +244 -0
- package/dist/core/SkiaControl.d.ts +401 -0
- package/dist/core/SkiaControl.js +1239 -0
- package/dist/core/SkiaEffect.d.ts +216 -0
- package/dist/core/SkiaEffect.js +571 -0
- package/dist/core/SkiaImageManager.d.ts +43 -0
- package/dist/core/SkiaImageManager.js +122 -0
- package/dist/core/Super.d.ts +61 -0
- package/dist/core/Super.js +178 -0
- package/dist/core/TextInputProxy.d.ts +31 -0
- package/dist/core/TextInputProxy.js +144 -0
- package/dist/core/Types.d.ts +177 -0
- package/dist/core/Types.js +122 -0
- package/dist/core/ViewsAdapter.d.ts +41 -0
- package/dist/core/ViewsAdapter.js +113 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +48 -0
- package/dist/react/SkiaShell.d.ts +144 -0
- package/dist/react/SkiaShell.js +490 -0
- package/dist/react/index.d.ts +110 -0
- package/dist/react/index.js +158 -0
- package/dist/react/reconciler.d.ts +14 -0
- package/dist/react/reconciler.js +160 -0
- package/package.json +60 -0
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
import { SKPoint } from "../core/Gestures";
|
|
2
|
+
import { Super } from "../core/Super";
|
|
3
|
+
import { SkiaValueAnimator } from "../core/Animators";
|
|
4
|
+
import { ResolveControlStyle } from "../core/ControlStyle";
|
|
5
|
+
import { KeyboardManager } from "../core/KeyboardManager";
|
|
6
|
+
import { TextInputProxy } from "../core/TextInputProxy";
|
|
7
|
+
import { Colors, ScaledSize, SKRect, Thickness } from "../core/Types";
|
|
8
|
+
import { SkiaLabel } from "./SkiaLabel";
|
|
9
|
+
import { SkiaShape } from "./SkiaShape";
|
|
10
|
+
const ParagraphBreak = "\n";
|
|
11
|
+
/**
|
|
12
|
+
* Mirrors DrawnUi SkiaEditor: a drawn text input. The text is a SkiaLabel inside a shape with padding, a
|
|
13
|
+
* placeholder label, a blinking caret and a selection overlay painted by the editor; single line
|
|
14
|
+
* (`MaxLines` 1, horizontal scrolling to the caret) or multiline (`MaxLines` != 1, `AutoHeight`). Keys come from
|
|
15
|
+
* the window-level `KeyboardManager` like the DrawnUi.Blazor / Wasm heads (no DOM text input: no IME, no mobile
|
|
16
|
+
* soft keyboard) and are applied by the same stub editing methods (`StubTypeText`, `StubBackspace`, arrows,
|
|
17
|
+
* Home / End, Ctrl+A / C / X / V). Taps place the caret, drags select, a long press selects the word, Enter submits
|
|
18
|
+
* (single line, or multiline with `ReturnType` Send).
|
|
19
|
+
*/
|
|
20
|
+
export class SkiaEditor extends SkiaShape {
|
|
21
|
+
/** The editor currently holding the keyboard, if any. */
|
|
22
|
+
static Focused;
|
|
23
|
+
static DefaultAccessibilityRole = "textbox";
|
|
24
|
+
TextChanged;
|
|
25
|
+
FocusChanged;
|
|
26
|
+
CursorMoved;
|
|
27
|
+
TextSubmitted;
|
|
28
|
+
// ---- text / look (same names as C#) ----
|
|
29
|
+
text = "";
|
|
30
|
+
placeholderText = "";
|
|
31
|
+
PlaceholderColor = "#9AA0A6";
|
|
32
|
+
PlaceholderHorizontalAlignment = "Start";
|
|
33
|
+
fontSize = 12;
|
|
34
|
+
fontFamily = "";
|
|
35
|
+
fontWeight = 0;
|
|
36
|
+
textColor = Colors.Black;
|
|
37
|
+
TextGradient;
|
|
38
|
+
lineHeight = 1;
|
|
39
|
+
horizontalTextAlignment = "Start";
|
|
40
|
+
verticalTextAlignment = "Start";
|
|
41
|
+
maxLines = 1;
|
|
42
|
+
AutoHeight = false;
|
|
43
|
+
isPassword = false;
|
|
44
|
+
/** Enter on a multiline editor: Send submits, anything else inserts a line break. */
|
|
45
|
+
ReturnType = "Done";
|
|
46
|
+
KeyboardType = "Default";
|
|
47
|
+
fontFamilyFallback = "";
|
|
48
|
+
/** Fallback font aliases for glyphs the main font lacks (symbols, emoji), forwarded to the text and placeholder labels. */
|
|
49
|
+
get FontFamilyFallback() { return this.fontFamilyFallback; }
|
|
50
|
+
set FontFamilyFallback(v) { if (this.fontFamilyFallback !== v) {
|
|
51
|
+
this.fontFamilyFallback = v ?? "";
|
|
52
|
+
this.UpdateLabel();
|
|
53
|
+
} }
|
|
54
|
+
IsSpellCheckEnabled = true;
|
|
55
|
+
/** Accepted for parity: the React label always renders unicode; markdown formatting while editing is not ported. */
|
|
56
|
+
UseMarkdown = false;
|
|
57
|
+
UseUnicode = true;
|
|
58
|
+
CursorColor = Colors.Black;
|
|
59
|
+
CanShowCursor = true;
|
|
60
|
+
SelectionColor = "#5590CFFE";
|
|
61
|
+
cursorPosition = 0;
|
|
62
|
+
selectionLength = 0;
|
|
63
|
+
isFocused = false;
|
|
64
|
+
controlStyle = "Unset";
|
|
65
|
+
// ---- children ----
|
|
66
|
+
Label = new SkiaLabel();
|
|
67
|
+
placeholder = new SkiaLabel();
|
|
68
|
+
scrollX = 0;
|
|
69
|
+
scrollY = 0;
|
|
70
|
+
blink = new SkiaValueAnimator(this);
|
|
71
|
+
caretVisible = true;
|
|
72
|
+
subscribed = false;
|
|
73
|
+
stubSelectionStop = -1;
|
|
74
|
+
selectionMovingEdge = -1;
|
|
75
|
+
onKeyDown = (key, e) => this.OnKeyDown(key, e);
|
|
76
|
+
onKeyChar = (ch, e) => { e.preventDefault(); this.StubTypeText(ch); };
|
|
77
|
+
selectionAnchor = -1;
|
|
78
|
+
dragSelecting = false;
|
|
79
|
+
styleApplied = {};
|
|
80
|
+
layoutVersion = -1;
|
|
81
|
+
boxes = [];
|
|
82
|
+
constructor() {
|
|
83
|
+
super();
|
|
84
|
+
this.Type = "Rectangle";
|
|
85
|
+
this.UseCache = "Operations";
|
|
86
|
+
this.HorizontalOptions = "Fill";
|
|
87
|
+
this.Padding = new Thickness(12, 8);
|
|
88
|
+
this.Label.KeepSpacesOnLineBreaks = true;
|
|
89
|
+
this.Label.NeedsGlyphPositions = true;
|
|
90
|
+
this.Label.LineBreakMode = "NoWrap";
|
|
91
|
+
this.Label.UseCache = "None";
|
|
92
|
+
this.placeholder.UseCache = "None";
|
|
93
|
+
this.placeholder.IsVisible = false;
|
|
94
|
+
this.AddSubView(this.placeholder);
|
|
95
|
+
this.AddSubView(this.Label);
|
|
96
|
+
this.blink.mMinValue = 0;
|
|
97
|
+
this.blink.mMaxValue = 1;
|
|
98
|
+
this.blink.Speed = 1000;
|
|
99
|
+
this.blink.Repeat = -1;
|
|
100
|
+
this.blink.OnUpdated = (v) => { const visible = v < 0.5; if (visible !== this.caretVisible) {
|
|
101
|
+
this.caretVisible = visible;
|
|
102
|
+
this.InvalidateCache();
|
|
103
|
+
this.RepaintComposition();
|
|
104
|
+
} };
|
|
105
|
+
this.ApplyControlStyleVisuals();
|
|
106
|
+
this.UpdateLabel();
|
|
107
|
+
}
|
|
108
|
+
// ---- properties ----
|
|
109
|
+
get Text() { return this.text; }
|
|
110
|
+
set Text(v) {
|
|
111
|
+
const normalized = SkiaEditor.NormalizeLineBreaks(v ?? "");
|
|
112
|
+
const value = this.IsMultiline ? normalized : normalized.replace(/\n/g, " ");
|
|
113
|
+
if (this.text === value)
|
|
114
|
+
return;
|
|
115
|
+
this.text = value;
|
|
116
|
+
if (this.cursorPosition > value.length)
|
|
117
|
+
this.cursorPosition = value.length;
|
|
118
|
+
if (this.cursorPosition + this.selectionLength > value.length)
|
|
119
|
+
this.selectionLength = Math.max(0, value.length - this.cursorPosition);
|
|
120
|
+
this.UpdateLabel();
|
|
121
|
+
TextInputProxy.Sync(this);
|
|
122
|
+
this.TextChanged?.(this, value);
|
|
123
|
+
}
|
|
124
|
+
get PlaceholderText() { return this.placeholderText; }
|
|
125
|
+
set PlaceholderText(v) { if (this.placeholderText !== v) {
|
|
126
|
+
this.placeholderText = v ?? "";
|
|
127
|
+
this.UpdateLabel();
|
|
128
|
+
} }
|
|
129
|
+
get FontSize() { return this.fontSize; }
|
|
130
|
+
set FontSize(v) { if (this.fontSize !== v) {
|
|
131
|
+
this.fontSize = v;
|
|
132
|
+
this.UpdateLabel();
|
|
133
|
+
} }
|
|
134
|
+
get FontFamily() { return this.fontFamily; }
|
|
135
|
+
set FontFamily(v) { if (this.fontFamily !== v) {
|
|
136
|
+
this.fontFamily = v ?? "";
|
|
137
|
+
this.UpdateLabel();
|
|
138
|
+
} }
|
|
139
|
+
get FontWeight() { return this.fontWeight; }
|
|
140
|
+
set FontWeight(v) { if (this.fontWeight !== v) {
|
|
141
|
+
this.fontWeight = v;
|
|
142
|
+
this.UpdateLabel();
|
|
143
|
+
} }
|
|
144
|
+
get TextColor() { return this.textColor; }
|
|
145
|
+
set TextColor(v) { if (this.textColor !== v) {
|
|
146
|
+
this.textColor = v;
|
|
147
|
+
this.UpdateLabel();
|
|
148
|
+
} }
|
|
149
|
+
get LineHeight() { return this.lineHeight; }
|
|
150
|
+
set LineHeight(v) { if (this.lineHeight !== v) {
|
|
151
|
+
this.lineHeight = v;
|
|
152
|
+
this.UpdateLabel();
|
|
153
|
+
} }
|
|
154
|
+
get HorizontalTextAlignment() { return this.horizontalTextAlignment; }
|
|
155
|
+
set HorizontalTextAlignment(v) { if (this.horizontalTextAlignment !== v) {
|
|
156
|
+
this.horizontalTextAlignment = v;
|
|
157
|
+
this.UpdateLabel();
|
|
158
|
+
} }
|
|
159
|
+
get VerticalTextAlignment() { return this.verticalTextAlignment; }
|
|
160
|
+
set VerticalTextAlignment(v) { if (this.verticalTextAlignment !== v) {
|
|
161
|
+
this.verticalTextAlignment = v;
|
|
162
|
+
this.UpdateLabel();
|
|
163
|
+
} }
|
|
164
|
+
/** 1 = single line (default); anything else = multiline, -1 unbounded. */
|
|
165
|
+
get MaxLines() { return this.maxLines; }
|
|
166
|
+
set MaxLines(v) { if (this.maxLines !== v) {
|
|
167
|
+
this.maxLines = v;
|
|
168
|
+
this.UpdateLabel();
|
|
169
|
+
} }
|
|
170
|
+
get IsPassword() { return this.isPassword; }
|
|
171
|
+
set IsPassword(v) { if (this.isPassword !== v) {
|
|
172
|
+
this.isPassword = v;
|
|
173
|
+
this.UpdateLabel();
|
|
174
|
+
} }
|
|
175
|
+
get IsMultiline() { return this.maxLines !== 1; }
|
|
176
|
+
get HasSelection() { return this.selectionLength > 0; }
|
|
177
|
+
get ShouldSubmitOnEnter() { return this.IsMultiline && this.ReturnType === "Send"; }
|
|
178
|
+
/** Caret index (UTF-16 units) = the left edge of the selection when one exists. */
|
|
179
|
+
get CursorPosition() { return this.cursorPosition; }
|
|
180
|
+
set CursorPosition(v) {
|
|
181
|
+
const p = Math.max(0, Math.min(this.text.length, v | 0));
|
|
182
|
+
if (p === this.cursorPosition)
|
|
183
|
+
return;
|
|
184
|
+
this.cursorPosition = p;
|
|
185
|
+
this.OnCursorMoved();
|
|
186
|
+
}
|
|
187
|
+
get SelectionLength() { return this.selectionLength; }
|
|
188
|
+
set SelectionLength(v) {
|
|
189
|
+
const l = Math.max(0, Math.min(this.text.length - this.cursorPosition, v | 0));
|
|
190
|
+
if (l === this.selectionLength)
|
|
191
|
+
return;
|
|
192
|
+
this.selectionLength = l;
|
|
193
|
+
this.OnCursorMoved();
|
|
194
|
+
}
|
|
195
|
+
OnCursorMoved() {
|
|
196
|
+
this.ScrollToCaret();
|
|
197
|
+
this.caretVisible = true;
|
|
198
|
+
TextInputProxy.Sync(this);
|
|
199
|
+
this.CursorMoved?.(this);
|
|
200
|
+
this.InvalidateCache();
|
|
201
|
+
this.RepaintComposition();
|
|
202
|
+
}
|
|
203
|
+
get IsFocused() { return this.isFocused; }
|
|
204
|
+
set IsFocused(v) {
|
|
205
|
+
if (this.isFocused === v)
|
|
206
|
+
return;
|
|
207
|
+
this.isFocused = v;
|
|
208
|
+
if (v) {
|
|
209
|
+
if (SkiaEditor.Focused && SkiaEditor.Focused !== this)
|
|
210
|
+
SkiaEditor.Focused.IsFocused = false;
|
|
211
|
+
SkiaEditor.Focused = this;
|
|
212
|
+
this.SetFocusNative(true);
|
|
213
|
+
this.caretVisible = true;
|
|
214
|
+
this.blink.Start();
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
if (SkiaEditor.Focused === this)
|
|
218
|
+
SkiaEditor.Focused = undefined;
|
|
219
|
+
this.selectionLength = 0;
|
|
220
|
+
this.dragSelecting = false;
|
|
221
|
+
this.stubSelectionStop = -1;
|
|
222
|
+
this.selectionMovingEdge = -1;
|
|
223
|
+
this.blink.Stop();
|
|
224
|
+
this.SetFocusNative(false);
|
|
225
|
+
}
|
|
226
|
+
this.FocusChanged?.(this, v);
|
|
227
|
+
this.NotifyAccessibility();
|
|
228
|
+
this.InvalidateCache();
|
|
229
|
+
this.RepaintComposition();
|
|
230
|
+
}
|
|
231
|
+
get ControlStyle() { return this.controlStyle; }
|
|
232
|
+
set ControlStyle(v) { if (this.controlStyle !== v) {
|
|
233
|
+
this.controlStyle = v;
|
|
234
|
+
this.ApplyControlStyleVisuals();
|
|
235
|
+
this.Update();
|
|
236
|
+
} }
|
|
237
|
+
/** C# ApplyControlStyleVisuals: background, corners, border, cursor per style, applied only over values the app has not changed. */
|
|
238
|
+
ApplyControlStyleVisuals() {
|
|
239
|
+
const style = ResolveControlStyle(this.controlStyle);
|
|
240
|
+
const look = style === "Cupertino" ? { bg: "#FFFFFF", corner: 10, stroke: "#C7C7CC", strokeW: 1, cursor: "#007AFF" }
|
|
241
|
+
: style === "Material" ? { bg: "#EFEBF4", corner: 4, stroke: Colors.Transparent, strokeW: 0, cursor: "#2196F3" }
|
|
242
|
+
: style === "Material3" ? { bg: "#E6E0E9", corner: 4, stroke: Colors.Transparent, strokeW: 0, cursor: "#6750A4" }
|
|
243
|
+
: style === "Windows" ? { bg: "#FFFFFF", corner: 2, stroke: "#8A8A8A", strokeW: 1, cursor: "#0078D4" }
|
|
244
|
+
: { bg: "#F2F3F5", corner: 8, stroke: Colors.Transparent, strokeW: 0, cursor: "#DC143C" };
|
|
245
|
+
const apply = (key, current, next, set) => {
|
|
246
|
+
if (!(key in this.styleApplied) || this.styleApplied[key] === current) {
|
|
247
|
+
set();
|
|
248
|
+
this.styleApplied[key] = next;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
apply("bg", this.BackgroundColor, look.bg, () => { this.BackgroundColor = look.bg; });
|
|
252
|
+
apply("corner", this.CornerRadius, look.corner, () => { this.CornerRadius = look.corner; });
|
|
253
|
+
apply("stroke", this.StrokeColor, look.stroke, () => { this.StrokeColor = look.stroke; });
|
|
254
|
+
apply("strokeW", this.StrokeWidth, look.strokeW, () => { this.StrokeWidth = look.strokeW; });
|
|
255
|
+
apply("cursor", this.CursorColor, look.cursor, () => { this.CursorColor = look.cursor; });
|
|
256
|
+
}
|
|
257
|
+
// ---- label ----
|
|
258
|
+
static NormalizeLineBreaks(v) { return v.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\u2029/g, ParagraphBreak); }
|
|
259
|
+
/** C# UpdateLabel: pushes the editor's text props into the content and placeholder labels. */
|
|
260
|
+
UpdateLabel() {
|
|
261
|
+
let display = this.isPassword && this.text ? "•".repeat(this.text.length) : this.text;
|
|
262
|
+
if (display.length === 0 || (this.IsMultiline && display.endsWith(ParagraphBreak)))
|
|
263
|
+
display += "\u200B"; // C#: keeps the empty (last) line for the caret
|
|
264
|
+
const l = this.Label;
|
|
265
|
+
l.Text = display;
|
|
266
|
+
l.FontFamily = this.fontFamily;
|
|
267
|
+
l.FontFamilyFallback = this.fontFamilyFallback;
|
|
268
|
+
l.FontSize = this.fontSize;
|
|
269
|
+
l.TextColor = this.textColor;
|
|
270
|
+
l.FontWeight = this.fontWeight;
|
|
271
|
+
l.FillGradient = this.TextGradient;
|
|
272
|
+
l.HorizontalTextAlignment = this.horizontalTextAlignment;
|
|
273
|
+
l.VerticalTextAlignment = this.verticalTextAlignment;
|
|
274
|
+
l.LineHeight = this.lineHeight;
|
|
275
|
+
l.MaxLines = -1;
|
|
276
|
+
l.LineBreakMode = this.IsMultiline ? "WordWrap" : "NoWrap";
|
|
277
|
+
l.HorizontalOptions = this.IsMultiline ? "Fill" : "Start";
|
|
278
|
+
const p = this.placeholder;
|
|
279
|
+
p.Text = this.placeholderText;
|
|
280
|
+
p.TextColor = this.PlaceholderColor;
|
|
281
|
+
p.HorizontalTextAlignment = this.PlaceholderHorizontalAlignment;
|
|
282
|
+
p.FontFamily = this.fontFamily;
|
|
283
|
+
p.FontFamilyFallback = this.fontFamilyFallback;
|
|
284
|
+
p.FontSize = this.fontSize;
|
|
285
|
+
p.FontWeight = this.fontWeight;
|
|
286
|
+
p.LineHeight = this.lineHeight;
|
|
287
|
+
p.MaxLines = this.IsMultiline ? -1 : 1;
|
|
288
|
+
p.HorizontalOptions = "Fill";
|
|
289
|
+
p.IsVisible = this.text.length === 0 && this.placeholderText.length > 0;
|
|
290
|
+
this.Update();
|
|
291
|
+
}
|
|
292
|
+
/** Visual height of one line in points (C# GetSingleLineHeightPts). */
|
|
293
|
+
GetSingleLineHeightPts() {
|
|
294
|
+
const scale = this.RenderingScale || 1;
|
|
295
|
+
const px = this.Label.MeasuredLineHeight || this.placeholder.MeasuredLineHeight;
|
|
296
|
+
if (px > 0)
|
|
297
|
+
return Math.ceil(px / scale);
|
|
298
|
+
return Math.ceil((this.fontSize > 0 ? this.fontSize : 20) * 1.2 * Math.max(this.lineHeight, 1));
|
|
299
|
+
}
|
|
300
|
+
// ---- layout ----
|
|
301
|
+
Inner(rect, scale) {
|
|
302
|
+
const p = this.Padding;
|
|
303
|
+
return new SKRect(rect.Left + p.Left * scale, rect.Top + p.Top * scale, rect.Right - p.Right * scale, rect.Bottom - p.Bottom * scale);
|
|
304
|
+
}
|
|
305
|
+
MeasureAbsolute(w, h, scale) {
|
|
306
|
+
const px = this.Padding.HorizontalThickness * scale, py = this.Padding.VerticalThickness * scale;
|
|
307
|
+
const innerW = isFinite(w) ? Math.max(0, w - px) : Infinity;
|
|
308
|
+
const labelSize = this.Label.Measure(this.IsMultiline ? innerW : Infinity, Infinity, scale);
|
|
309
|
+
if (this.placeholder.IsVisible)
|
|
310
|
+
this.placeholder.Measure(innerW, Infinity, scale);
|
|
311
|
+
const lineH = this.Label.MeasuredLineHeight || this.placeholder.MeasuredLineHeight || this.GetSingleLineHeightPts() * scale;
|
|
312
|
+
let lines = this.maxLines > 0 ? this.maxLines : 1;
|
|
313
|
+
if (this.AutoHeight && this.IsMultiline) {
|
|
314
|
+
const actual = Math.max(1, this.Label.LinesCount);
|
|
315
|
+
lines = this.maxLines > 0 ? Math.min(actual, this.maxLines) : actual;
|
|
316
|
+
}
|
|
317
|
+
const contentH = lineH * lines;
|
|
318
|
+
const width = isFinite(w) ? w : labelSize.Pixels.Width + px;
|
|
319
|
+
return ScaledSize.FromPixels(width, Math.ceil(contentH) + py, scale);
|
|
320
|
+
}
|
|
321
|
+
OnLayoutChanged() {
|
|
322
|
+
const scale = this.RenderingScale;
|
|
323
|
+
const inner = this.Inner(this.DrawingRect, scale);
|
|
324
|
+
this.ClampScroll(inner);
|
|
325
|
+
const l = this.Label.MeasuredSize.Pixels;
|
|
326
|
+
const lw = this.IsMultiline ? inner.Width : Math.max(l.Width, inner.Width);
|
|
327
|
+
const lh = Math.max(l.Height, inner.Height);
|
|
328
|
+
this.Label.Arrange(SKRect.Create(inner.Left - this.scrollX, inner.Top - this.scrollY, lw, lh), -1, -1, scale);
|
|
329
|
+
if (this.placeholder.IsVisible)
|
|
330
|
+
this.placeholder.Arrange(inner, -1, -1, scale);
|
|
331
|
+
}
|
|
332
|
+
ClampScroll(inner) {
|
|
333
|
+
const l = this.Label.MeasuredSize.Pixels;
|
|
334
|
+
this.scrollX = Math.max(0, Math.min(this.scrollX, l.Width - inner.Width));
|
|
335
|
+
this.scrollY = Math.max(0, Math.min(this.scrollY, l.Height - inner.Height));
|
|
336
|
+
}
|
|
337
|
+
/** Keeps the caret inside the padded box by scrolling the label (C#: SkiaScroll under the label). */
|
|
338
|
+
ScrollToCaret() {
|
|
339
|
+
const rect = this.CaretRectLabelPx();
|
|
340
|
+
if (!rect)
|
|
341
|
+
return;
|
|
342
|
+
const inner = this.Inner(this.DrawingRect, this.RenderingScale);
|
|
343
|
+
if (inner.Width <= 0)
|
|
344
|
+
return;
|
|
345
|
+
const caretW = 2 * this.RenderingScale;
|
|
346
|
+
if (rect.Left - this.scrollX < 0)
|
|
347
|
+
this.scrollX = rect.Left;
|
|
348
|
+
else if (rect.Left + caretW - this.scrollX > inner.Width)
|
|
349
|
+
this.scrollX = rect.Left + caretW - inner.Width;
|
|
350
|
+
if (rect.Top - this.scrollY < 0)
|
|
351
|
+
this.scrollY = rect.Top;
|
|
352
|
+
else if (rect.Bottom - this.scrollY > inner.Height)
|
|
353
|
+
this.scrollY = rect.Bottom - inner.Height;
|
|
354
|
+
this.ClampScroll(inner);
|
|
355
|
+
this.OnLayoutChanged();
|
|
356
|
+
}
|
|
357
|
+
// ---- caret / selection geometry (pixels relative to the label's DrawingRect) ----
|
|
358
|
+
Boxes() {
|
|
359
|
+
if (this.layoutVersion !== this.Label.LayoutVersion) {
|
|
360
|
+
this.boxes = this.Label.GetGlyphBoxes();
|
|
361
|
+
this.layoutVersion = this.Label.LayoutVersion;
|
|
362
|
+
}
|
|
363
|
+
return this.boxes;
|
|
364
|
+
}
|
|
365
|
+
/** Rect of the caret before character `index` (label pixels), or undefined before the first layout. */
|
|
366
|
+
CaretRectLabelPx(index = this.cursorPosition + this.selectionLength) {
|
|
367
|
+
const boxes = this.Boxes();
|
|
368
|
+
const lines = this.Label.GetLineBoxes();
|
|
369
|
+
if (lines.length === 0)
|
|
370
|
+
return undefined;
|
|
371
|
+
const caretW = 2 * this.RenderingScale;
|
|
372
|
+
const at = boxes.find((b) => b.Index === index);
|
|
373
|
+
if (at)
|
|
374
|
+
return SKRect.Create(at.Left, at.Top, caretW, at.Height);
|
|
375
|
+
let prev;
|
|
376
|
+
for (const b of boxes) {
|
|
377
|
+
if (b.Index < index)
|
|
378
|
+
prev = b;
|
|
379
|
+
else
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
if (prev)
|
|
383
|
+
return SKRect.Create(prev.Left + prev.Width, prev.Top, caretW, prev.Height);
|
|
384
|
+
const first = lines[0];
|
|
385
|
+
return SKRect.Create(first.Left, first.Top, caretW, first.Height);
|
|
386
|
+
}
|
|
387
|
+
/** C# GetCursorPosition: the text index nearest to a point in label pixels. */
|
|
388
|
+
HitTestIndex(x, y) {
|
|
389
|
+
const lines = this.Label.GetLineBoxes();
|
|
390
|
+
if (lines.length === 0)
|
|
391
|
+
return this.text.length;
|
|
392
|
+
let line = lines[lines.length - 1];
|
|
393
|
+
for (const l of lines)
|
|
394
|
+
if (y < l.Top + l.Height) {
|
|
395
|
+
line = l;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
const boxes = this.Boxes().filter((b) => b.Line === line.Line);
|
|
399
|
+
if (boxes.length === 0)
|
|
400
|
+
return Math.min(line.Start, this.text.length);
|
|
401
|
+
if (x <= boxes[0].Left)
|
|
402
|
+
return boxes[0].Index;
|
|
403
|
+
for (const b of boxes)
|
|
404
|
+
if (x < b.Left + b.Width)
|
|
405
|
+
return x < b.Left + b.Width / 2 ? b.Index : b.Index + b.Length;
|
|
406
|
+
const last = boxes[boxes.length - 1];
|
|
407
|
+
return Math.min(last.Index + last.Length, this.text.length);
|
|
408
|
+
}
|
|
409
|
+
LabelPoint(args, apply, starting = false) {
|
|
410
|
+
const loc = starting ? args.Event.StartingLocation : apply.MappedLocation;
|
|
411
|
+
const r = this.Label.DrawingRect;
|
|
412
|
+
return new SKPoint(loc.X + apply.ChildOffset.X - r.Left, loc.Y + apply.ChildOffset.Y - r.Top);
|
|
413
|
+
}
|
|
414
|
+
// ---- painting: selection under the text, caret above ----
|
|
415
|
+
Paint(ctx) {
|
|
416
|
+
const CK = Super.CK;
|
|
417
|
+
const canvas = ctx.Context.Canvas;
|
|
418
|
+
const inner = this.Inner(ctx.Destination, ctx.Scale);
|
|
419
|
+
const clip = CK.LTRBRect(inner.Left, inner.Top, inner.Right, inner.Bottom);
|
|
420
|
+
const lr = this.Label.DrawingRect;
|
|
421
|
+
if (this.isFocused && this.selectionLength > 0) {
|
|
422
|
+
const saved = canvas.save();
|
|
423
|
+
canvas.clipRect(clip, CK.ClipOp.Intersect, true);
|
|
424
|
+
const paint = new CK.Paint();
|
|
425
|
+
paint.setColor(Super.ParseColor(this.SelectionColor));
|
|
426
|
+
const end = this.cursorPosition + this.selectionLength;
|
|
427
|
+
for (const b of this.Boxes())
|
|
428
|
+
if (b.Index >= this.cursorPosition && b.Index < end)
|
|
429
|
+
canvas.drawRect(CK.LTRBRect(lr.Left + b.Left, lr.Top + b.Top, lr.Left + b.Left + Math.max(b.Width, 2), lr.Top + b.Top + b.Height), paint);
|
|
430
|
+
paint.delete();
|
|
431
|
+
canvas.restoreToCount(saved);
|
|
432
|
+
}
|
|
433
|
+
super.Paint(ctx);
|
|
434
|
+
if (this.isFocused && this.CanShowCursor && this.caretVisible) {
|
|
435
|
+
const rect = this.CaretRectLabelPx();
|
|
436
|
+
if (rect) {
|
|
437
|
+
const saved = canvas.save();
|
|
438
|
+
canvas.clipRect(clip, CK.ClipOp.Intersect, true);
|
|
439
|
+
const paint = new CK.Paint();
|
|
440
|
+
paint.setColor(Super.ParseColor(this.CursorColor));
|
|
441
|
+
canvas.drawRect(CK.LTRBRect(lr.Left + rect.Left, lr.Top + rect.Top, lr.Left + rect.Right, lr.Top + rect.Bottom), paint);
|
|
442
|
+
paint.delete();
|
|
443
|
+
canvas.restoreToCount(saved);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// ---- gestures (C# ProcessGestures: tap places the caret, drag selects, long press selects the word) ----
|
|
448
|
+
ProcessGestures(args, apply) {
|
|
449
|
+
switch (args.Type) {
|
|
450
|
+
case "Down": {
|
|
451
|
+
const p = this.LabelPoint(args, apply);
|
|
452
|
+
const pos = this.HitTestIndex(p.X, p.Y);
|
|
453
|
+
this.dragSelecting = true;
|
|
454
|
+
if (KeyboardManager.IsShiftPressed && this.isFocused) {
|
|
455
|
+
// C#: shift + tap extends the selection from the far edge of the current one
|
|
456
|
+
const left = this.cursorPosition, right = this.cursorPosition + this.selectionLength;
|
|
457
|
+
const anchor = this.selectionLength > 0 ? (pos - left <= right - pos ? right : left) : this.cursorPosition;
|
|
458
|
+
this.selectionAnchor = anchor;
|
|
459
|
+
this.cursorPosition = Math.min(anchor, pos);
|
|
460
|
+
this.selectionLength = Math.abs(anchor - pos);
|
|
461
|
+
this.stubSelectionStop = anchor;
|
|
462
|
+
this.selectionMovingEdge = pos;
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
this.selectionAnchor = pos;
|
|
466
|
+
this.selectionLength = 0;
|
|
467
|
+
this.cursorPosition = pos;
|
|
468
|
+
this.stubSelectionStop = -1;
|
|
469
|
+
this.selectionMovingEdge = -1;
|
|
470
|
+
}
|
|
471
|
+
if (!this.isFocused)
|
|
472
|
+
this.IsFocused = true;
|
|
473
|
+
this.OnCursorMoved();
|
|
474
|
+
return this;
|
|
475
|
+
}
|
|
476
|
+
case "Panning": {
|
|
477
|
+
if (!this.dragSelecting)
|
|
478
|
+
return this;
|
|
479
|
+
const p = this.LabelPoint(args, apply);
|
|
480
|
+
const pos = this.HitTestIndex(p.X, p.Y);
|
|
481
|
+
const start = Math.min(pos, this.selectionAnchor), end = Math.max(pos, this.selectionAnchor);
|
|
482
|
+
this.cursorPosition = start;
|
|
483
|
+
this.selectionLength = end - start;
|
|
484
|
+
this.OnCursorMoved();
|
|
485
|
+
return this;
|
|
486
|
+
}
|
|
487
|
+
case "LongPressing": {
|
|
488
|
+
const p = this.LabelPoint(args, apply, true);
|
|
489
|
+
this.SelectWord(this.HitTestIndex(p.X, p.Y));
|
|
490
|
+
this.dragSelecting = true;
|
|
491
|
+
this.selectionAnchor = this.cursorPosition;
|
|
492
|
+
if (!this.isFocused)
|
|
493
|
+
this.IsFocused = true;
|
|
494
|
+
return this;
|
|
495
|
+
}
|
|
496
|
+
case "Up":
|
|
497
|
+
this.dragSelecting = false;
|
|
498
|
+
return this;
|
|
499
|
+
case "Tapped": return this;
|
|
500
|
+
}
|
|
501
|
+
return this;
|
|
502
|
+
}
|
|
503
|
+
OnAccessibilityActivated() { this.IsFocused = true; }
|
|
504
|
+
DefaultAccessibilityLabel() { return this.text || this.placeholderText || undefined; }
|
|
505
|
+
// ---- editing API (C# names) ----
|
|
506
|
+
Submit() {
|
|
507
|
+
if (this.IsMultiline && !this.ShouldSubmitOnEnter) {
|
|
508
|
+
this.InsertAtCursor(ParagraphBreak);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
this.ExecuteSubmit(!this.IsMultiline);
|
|
512
|
+
}
|
|
513
|
+
ExecuteSubmit(clearFocus) {
|
|
514
|
+
if (clearFocus)
|
|
515
|
+
this.IsFocused = false;
|
|
516
|
+
this.TextSubmitted?.(this, this.text);
|
|
517
|
+
}
|
|
518
|
+
SelectAll() { this.SetSelection(0, this.text.length); }
|
|
519
|
+
SetSelection(start, end) {
|
|
520
|
+
const s = Math.max(0, Math.min(this.text.length, Math.min(start, end))), e = Math.max(0, Math.min(this.text.length, Math.max(start, end)));
|
|
521
|
+
this.cursorPosition = s;
|
|
522
|
+
this.selectionLength = e - s;
|
|
523
|
+
this.OnCursorMoved();
|
|
524
|
+
}
|
|
525
|
+
/** Selects the word around a character index (C# SelectWord). */
|
|
526
|
+
SelectWord(index) {
|
|
527
|
+
const t = this.text;
|
|
528
|
+
if (t.length === 0)
|
|
529
|
+
return;
|
|
530
|
+
const i = Math.max(0, Math.min(t.length - 1, index));
|
|
531
|
+
const isWord = (c) => /[\p{L}\p{N}_]/u.test(c);
|
|
532
|
+
if (!isWord(t[i])) {
|
|
533
|
+
this.SetSelection(i, i + 1);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
let s = i, e = i + 1;
|
|
537
|
+
while (s > 0 && isWord(t[s - 1]))
|
|
538
|
+
s--;
|
|
539
|
+
while (e < t.length && isWord(t[e]))
|
|
540
|
+
e++;
|
|
541
|
+
this.SetSelection(s, e);
|
|
542
|
+
}
|
|
543
|
+
GetSelectedText() { return this.text.substr(this.cursorPosition, this.selectionLength); }
|
|
544
|
+
DeleteSelection() { if (this.selectionLength > 0)
|
|
545
|
+
this.ReplaceSelection(""); }
|
|
546
|
+
InsertAtCursor(value) { this.ReplaceSelection(value ?? ""); }
|
|
547
|
+
CutSelection() { const s = this.GetSelectedText(); if (s) {
|
|
548
|
+
void navigator.clipboard?.writeText(s);
|
|
549
|
+
this.DeleteSelection();
|
|
550
|
+
} }
|
|
551
|
+
CopySelection() { const s = this.GetSelectedText(); if (s)
|
|
552
|
+
void navigator.clipboard?.writeText(s); }
|
|
553
|
+
PasteFromClipboard() { void navigator.clipboard?.readText().then((t) => { if (t)
|
|
554
|
+
this.ReplaceSelection(t); }); }
|
|
555
|
+
ReplaceSelection(value) {
|
|
556
|
+
const start = this.cursorPosition, end = start + this.selectionLength;
|
|
557
|
+
const next = this.text.slice(0, start) + value + this.text.slice(end);
|
|
558
|
+
this.selectionLength = 0;
|
|
559
|
+
this.cursorPosition = start + value.length;
|
|
560
|
+
this.stubSelectionStop = -1;
|
|
561
|
+
this.selectionMovingEdge = -1;
|
|
562
|
+
this.Text = next;
|
|
563
|
+
this.OnCursorMoved();
|
|
564
|
+
}
|
|
565
|
+
// ---- keyboard (port of SkiaEditor.Blazor.cs: KeyboardManager subscription + stub editing) ----
|
|
566
|
+
SetFocusNative(focus) {
|
|
567
|
+
if (focus === this.subscribed)
|
|
568
|
+
return;
|
|
569
|
+
this.subscribed = focus;
|
|
570
|
+
if (focus) {
|
|
571
|
+
KeyboardManager.Subscribe(this.onKeyDown, this.onKeyChar);
|
|
572
|
+
KeyboardManager.BlurExternalTextInput();
|
|
573
|
+
TextInputProxy.Attach(this);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
TextInputProxy.Detach(this);
|
|
577
|
+
KeyboardManager.Unsubscribe(this.onKeyDown, this.onKeyChar);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
OnKeyDown(key, e) {
|
|
581
|
+
const shift = KeyboardManager.IsShiftPressed, ctrl = KeyboardManager.IsControlPressed, alt = KeyboardManager.IsAltPressed;
|
|
582
|
+
const handled = () => e.preventDefault();
|
|
583
|
+
// with the hidden textarea focused these keys become input / clipboard events there and are applied from those
|
|
584
|
+
if (TextInputProxy.IsActive(this) && TextInputProxy.IsProxyTarget(e.target) && (key === "Backspace" || key === "Delete" || key === "Enter" || key === "NumpadEnter" || (ctrl && (key === "KeyC" || key === "KeyX" || key === "KeyV"))))
|
|
585
|
+
return;
|
|
586
|
+
switch (key) {
|
|
587
|
+
case "Backspace":
|
|
588
|
+
handled();
|
|
589
|
+
this.StubBackspace();
|
|
590
|
+
break;
|
|
591
|
+
case "Delete":
|
|
592
|
+
handled();
|
|
593
|
+
this.StubDelete();
|
|
594
|
+
break;
|
|
595
|
+
case "Enter":
|
|
596
|
+
case "NumpadEnter":
|
|
597
|
+
handled();
|
|
598
|
+
this.StubPressEnter(alt, shift);
|
|
599
|
+
break;
|
|
600
|
+
case "ArrowLeft":
|
|
601
|
+
handled();
|
|
602
|
+
this.StubMoveCursor(-1, shift);
|
|
603
|
+
break;
|
|
604
|
+
case "ArrowRight":
|
|
605
|
+
handled();
|
|
606
|
+
this.StubMoveCursor(1, shift);
|
|
607
|
+
break;
|
|
608
|
+
case "ArrowUp":
|
|
609
|
+
if (this.IsMultiline) {
|
|
610
|
+
handled();
|
|
611
|
+
this.HandleVerticalArrow(true);
|
|
612
|
+
}
|
|
613
|
+
break;
|
|
614
|
+
case "ArrowDown":
|
|
615
|
+
if (this.IsMultiline) {
|
|
616
|
+
handled();
|
|
617
|
+
this.HandleVerticalArrow(false);
|
|
618
|
+
}
|
|
619
|
+
break;
|
|
620
|
+
case "Home":
|
|
621
|
+
handled();
|
|
622
|
+
this.StubMoveCursor(-this.cursorPosition, shift);
|
|
623
|
+
break;
|
|
624
|
+
case "End":
|
|
625
|
+
handled();
|
|
626
|
+
this.StubMoveCursor(this.text.length - this.cursorPosition, shift);
|
|
627
|
+
break;
|
|
628
|
+
case "Escape":
|
|
629
|
+
handled();
|
|
630
|
+
this.IsFocused = false;
|
|
631
|
+
break;
|
|
632
|
+
case "KeyA":
|
|
633
|
+
if (ctrl) {
|
|
634
|
+
handled();
|
|
635
|
+
this.StubSelectAll();
|
|
636
|
+
}
|
|
637
|
+
break;
|
|
638
|
+
case "KeyC":
|
|
639
|
+
if (ctrl) {
|
|
640
|
+
handled();
|
|
641
|
+
this.CopySelection();
|
|
642
|
+
}
|
|
643
|
+
break;
|
|
644
|
+
case "KeyX":
|
|
645
|
+
if (ctrl) {
|
|
646
|
+
handled();
|
|
647
|
+
this.CutSelection();
|
|
648
|
+
}
|
|
649
|
+
break;
|
|
650
|
+
case "KeyV":
|
|
651
|
+
if (ctrl) {
|
|
652
|
+
handled();
|
|
653
|
+
this.PasteFromClipboard();
|
|
654
|
+
}
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
StubTypeText(value) { if (value)
|
|
659
|
+
this.ReplaceSelection(value); }
|
|
660
|
+
StubPressEnter(splitLine = false, shift = false) {
|
|
661
|
+
if (this.IsMultiline) {
|
|
662
|
+
if (!splitLine && !shift && this.ShouldSubmitOnEnter) {
|
|
663
|
+
this.ExecuteSubmit(false);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
this.ReplaceSelection(ParagraphBreak);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
this.ExecuteSubmit(false);
|
|
670
|
+
}
|
|
671
|
+
StubBackspace(count = 1) {
|
|
672
|
+
if (count <= 0)
|
|
673
|
+
return;
|
|
674
|
+
if (this.HasSelection) {
|
|
675
|
+
this.ReplaceSelection("");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (this.text.length === 0)
|
|
679
|
+
return;
|
|
680
|
+
const remove = SkiaEditor.CodeUnitsBeforeCaret(this.text, this.cursorPosition, count);
|
|
681
|
+
if (remove === 0)
|
|
682
|
+
return;
|
|
683
|
+
const start = this.cursorPosition - remove;
|
|
684
|
+
this.cursorPosition = start;
|
|
685
|
+
this.selectionLength = 0;
|
|
686
|
+
this.Text = this.text.slice(0, start) + this.text.slice(start + remove);
|
|
687
|
+
this.OnCursorMoved();
|
|
688
|
+
}
|
|
689
|
+
StubDelete(count = 1) {
|
|
690
|
+
if (count <= 0)
|
|
691
|
+
return;
|
|
692
|
+
if (this.HasSelection) {
|
|
693
|
+
this.ReplaceSelection("");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (this.text.length === 0 || this.cursorPosition >= this.text.length)
|
|
697
|
+
return;
|
|
698
|
+
const remove = SkiaEditor.CodeUnitsAfterCaret(this.text, this.cursorPosition, count);
|
|
699
|
+
if (remove === 0)
|
|
700
|
+
return;
|
|
701
|
+
this.selectionLength = 0;
|
|
702
|
+
this.Text = this.text.slice(0, this.cursorPosition) + this.text.slice(this.cursorPosition + remove);
|
|
703
|
+
this.OnCursorMoved();
|
|
704
|
+
}
|
|
705
|
+
/** Arrow / Home / End: the anchor stays, the moving edge follows (shift), or the selection collapses in the move direction. */
|
|
706
|
+
StubMoveCursor(delta, extendSelection = false) {
|
|
707
|
+
const len = this.text.length;
|
|
708
|
+
if (extendSelection) {
|
|
709
|
+
if (!this.HasSelection) {
|
|
710
|
+
this.stubSelectionStop = this.cursorPosition;
|
|
711
|
+
this.selectionMovingEdge = this.cursorPosition;
|
|
712
|
+
}
|
|
713
|
+
this.selectionMovingEdge = Math.max(0, Math.min(len, this.selectionMovingEdge + delta));
|
|
714
|
+
this.selectionLength = Math.abs(this.selectionMovingEdge - this.stubSelectionStop);
|
|
715
|
+
this.cursorPosition = Math.min(this.selectionMovingEdge, this.stubSelectionStop);
|
|
716
|
+
this.OnCursorMoved();
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (this.HasSelection)
|
|
720
|
+
this.cursorPosition = delta < 0 ? this.cursorPosition : this.cursorPosition + this.selectionLength;
|
|
721
|
+
else
|
|
722
|
+
this.cursorPosition = Math.max(0, Math.min(len, this.cursorPosition + delta));
|
|
723
|
+
this.selectionLength = 0;
|
|
724
|
+
this.stubSelectionStop = -1;
|
|
725
|
+
this.selectionMovingEdge = -1;
|
|
726
|
+
this.OnCursorMoved();
|
|
727
|
+
}
|
|
728
|
+
StubSelectRange(start, length) {
|
|
729
|
+
const len = this.text.length;
|
|
730
|
+
const s = Math.max(0, Math.min(len, start)), l = Math.max(0, Math.min(len - s, length));
|
|
731
|
+
this.cursorPosition = s;
|
|
732
|
+
this.selectionLength = l;
|
|
733
|
+
this.stubSelectionStop = s;
|
|
734
|
+
this.selectionMovingEdge = s + l;
|
|
735
|
+
this.OnCursorMoved();
|
|
736
|
+
}
|
|
737
|
+
StubSelectAll() { this.StubSelectRange(0, this.text.length); }
|
|
738
|
+
/** Up / Down keep the caret x on the neighbouring line (C# HandleVerticalArrow). */
|
|
739
|
+
HandleVerticalArrow(up) {
|
|
740
|
+
const lines = this.Label.GetLineBoxes();
|
|
741
|
+
if (lines.length <= 1)
|
|
742
|
+
return;
|
|
743
|
+
const caret = this.CaretRectLabelPx(this.cursorPosition);
|
|
744
|
+
if (!caret)
|
|
745
|
+
return;
|
|
746
|
+
const current = lines.findIndex((l) => caret.Top >= l.Top && caret.Top < l.Top + l.Height);
|
|
747
|
+
const target = (current < 0 ? 0 : current) + (up ? -1 : 1);
|
|
748
|
+
if (target < 0 || target >= lines.length)
|
|
749
|
+
return;
|
|
750
|
+
const line = lines[target];
|
|
751
|
+
this.selectionLength = 0;
|
|
752
|
+
this.stubSelectionStop = -1;
|
|
753
|
+
this.selectionMovingEdge = -1;
|
|
754
|
+
this.cursorPosition = this.HitTestIndex(caret.Left, line.Top + line.Height / 2);
|
|
755
|
+
this.OnCursorMoved();
|
|
756
|
+
}
|
|
757
|
+
/** UTF-16 units of `count` grapheme clusters before the caret (C# CodeUnitsBeforeCaret). */
|
|
758
|
+
static CodeUnitsBeforeCaret(text, caret, count) {
|
|
759
|
+
if (!text || caret <= 0 || count <= 0)
|
|
760
|
+
return 0;
|
|
761
|
+
caret = Math.min(caret, text.length);
|
|
762
|
+
const starts = [];
|
|
763
|
+
for (const seg of SkiaEditor.Graphemes(text)) {
|
|
764
|
+
if (seg.index >= caret)
|
|
765
|
+
break;
|
|
766
|
+
starts.push(seg.index);
|
|
767
|
+
}
|
|
768
|
+
if (starts.length === 0)
|
|
769
|
+
return caret;
|
|
770
|
+
const idx = starts.length - count;
|
|
771
|
+
return caret - (idx > 0 ? starts[idx] : 0);
|
|
772
|
+
}
|
|
773
|
+
static CodeUnitsAfterCaret(text, caret, count) {
|
|
774
|
+
if (!text || caret >= text.length || count <= 0)
|
|
775
|
+
return 0;
|
|
776
|
+
let pos = Math.max(0, caret), n = 0;
|
|
777
|
+
for (const seg of SkiaEditor.Graphemes(text.slice(pos))) {
|
|
778
|
+
if (n++ >= count)
|
|
779
|
+
break;
|
|
780
|
+
pos = caret + seg.index + seg.segment.length;
|
|
781
|
+
}
|
|
782
|
+
return pos - caret;
|
|
783
|
+
}
|
|
784
|
+
static Graphemes(text) {
|
|
785
|
+
const Seg = Intl.Segmenter;
|
|
786
|
+
if (Seg)
|
|
787
|
+
return [...new Seg(undefined, { granularity: "grapheme" }).segment(text)];
|
|
788
|
+
const out = [];
|
|
789
|
+
let i = 0;
|
|
790
|
+
for (const cp of Array.from(text)) {
|
|
791
|
+
out.push({ index: i, segment: cp });
|
|
792
|
+
i += cp.length;
|
|
793
|
+
}
|
|
794
|
+
return out;
|
|
795
|
+
}
|
|
796
|
+
OnDisposing() {
|
|
797
|
+
this.blink.Stop();
|
|
798
|
+
this.blink.Dispose();
|
|
799
|
+
if (SkiaEditor.Focused === this)
|
|
800
|
+
SkiaEditor.Focused = undefined;
|
|
801
|
+
this.SetFocusNative(false);
|
|
802
|
+
}
|
|
803
|
+
}
|