@flighthq/textinput 0.1.0
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/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/selectableRichTextManager.d.ts +10 -0
- package/dist/selectableRichTextManager.d.ts.map +1 -0
- package/dist/selectableRichTextManager.js +87 -0
- package/dist/selectableRichTextManager.js.map +1 -0
- package/dist/textInput.d.ts +6 -0
- package/dist/textInput.d.ts.map +1 -0
- package/dist/textInput.js +73 -0
- package/dist/textInput.js.map +1 -0
- package/dist/textInputEditing.d.ts +36 -0
- package/dist/textInputEditing.d.ts.map +1 -0
- package/dist/textInputEditing.js +790 -0
- package/dist/textInputEditing.js.map +1 -0
- package/dist/textInputManager.d.ts +11 -0
- package/dist/textInputManager.d.ts.map +1 -0
- package/dist/textInputManager.js +96 -0
- package/dist/textInputManager.js.map +1 -0
- package/package.json +41 -0
- package/src/selectableRichTextManager.test.ts +189 -0
- package/src/textInput.test.ts +61 -0
- package/src/textInputEditing.test.ts +914 -0
- package/src/textInputManager.test.ts +175 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
import { getNodeAppearanceRevision } from '@flighthq/node';
|
|
2
|
+
import { createRichText, setRichTextFormatRange } from '@flighthq/text';
|
|
3
|
+
import type { KeyboardEventData, RichText, RichTextData, TextInputOptions, TextLayoutResult } from '@flighthq/types';
|
|
4
|
+
import { KeyCode } from '@flighthq/types';
|
|
5
|
+
|
|
6
|
+
import { enableTextInput, getTextInputState } from './textInput';
|
|
7
|
+
import {
|
|
8
|
+
appendTextInput,
|
|
9
|
+
applyTextInputRestriction,
|
|
10
|
+
canRedoTextInput,
|
|
11
|
+
canUndoTextInput,
|
|
12
|
+
clearTextInputHistory,
|
|
13
|
+
deleteTextInputBackward,
|
|
14
|
+
deleteTextInputForward,
|
|
15
|
+
deleteTextInputWordBackward,
|
|
16
|
+
deleteTextInputWordForward,
|
|
17
|
+
getTextInputCaretIndex,
|
|
18
|
+
getTextInputCaretRectangle,
|
|
19
|
+
getTextInputCharacterIndexAtPoint,
|
|
20
|
+
getTextInputDisplayText,
|
|
21
|
+
getTextInputSelectionBeginIndex,
|
|
22
|
+
getTextInputSelectionEndIndex,
|
|
23
|
+
getTextInputSelectionRectangles,
|
|
24
|
+
getTextInputSelectionText,
|
|
25
|
+
handleTextInputKeyboard,
|
|
26
|
+
insertTextInput,
|
|
27
|
+
moveTextInputCaret,
|
|
28
|
+
moveTextInputCaretByWord,
|
|
29
|
+
moveTextInputCaretDown,
|
|
30
|
+
moveTextInputCaretToLineEnd,
|
|
31
|
+
moveTextInputCaretToLineStart,
|
|
32
|
+
moveTextInputCaretUp,
|
|
33
|
+
redoTextInput,
|
|
34
|
+
replaceSelectedTextInput,
|
|
35
|
+
replaceTextInput,
|
|
36
|
+
scrollTextInputCaretIntoView,
|
|
37
|
+
selectAllTextInput,
|
|
38
|
+
selectLineAtTextInputIndex,
|
|
39
|
+
selectWordAtTextInputIndex,
|
|
40
|
+
setTextInputSelection,
|
|
41
|
+
undoTextInput,
|
|
42
|
+
} from './textInputEditing';
|
|
43
|
+
|
|
44
|
+
// Editing operates on a RichText with the input capability enabled. Field/maxChars/multiline live on
|
|
45
|
+
// RichTextData; restrict/password live on the enableTextInput options (the TextInputState slot).
|
|
46
|
+
function createInput(data: Partial<RichTextData> = {}, options: TextInputOptions = {}): RichText {
|
|
47
|
+
const text = createRichText({ data });
|
|
48
|
+
enableTextInput(text, options);
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createKeyboardData(data: Partial<KeyboardEventData>): KeyboardEventData {
|
|
53
|
+
return {
|
|
54
|
+
altKey: false,
|
|
55
|
+
ctrlKey: false,
|
|
56
|
+
key: '',
|
|
57
|
+
keyCode: 0,
|
|
58
|
+
metaKey: false,
|
|
59
|
+
shiftKey: false,
|
|
60
|
+
...data,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createLayout(): TextLayoutResult {
|
|
65
|
+
return {
|
|
66
|
+
groups: [
|
|
67
|
+
{
|
|
68
|
+
ascent: 10,
|
|
69
|
+
descent: 2,
|
|
70
|
+
endIndex: 3,
|
|
71
|
+
format: {},
|
|
72
|
+
height: 12,
|
|
73
|
+
leading: 0,
|
|
74
|
+
lineIndex: 0,
|
|
75
|
+
offsetX: 2,
|
|
76
|
+
offsetY: 2,
|
|
77
|
+
positions: [10, 10, 10],
|
|
78
|
+
startIndex: 0,
|
|
79
|
+
width: 30,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
ascent: 10,
|
|
83
|
+
descent: 2,
|
|
84
|
+
endIndex: 7,
|
|
85
|
+
format: {},
|
|
86
|
+
height: 12,
|
|
87
|
+
leading: 0,
|
|
88
|
+
lineIndex: 1,
|
|
89
|
+
offsetX: 2,
|
|
90
|
+
offsetY: 14,
|
|
91
|
+
positions: [10, 10, 10, 10],
|
|
92
|
+
startIndex: 3,
|
|
93
|
+
width: 40,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
lineAscents: [10, 10],
|
|
97
|
+
lineDescents: [2, 2],
|
|
98
|
+
lineHeights: [12, 12],
|
|
99
|
+
lineLeadings: [0, 0],
|
|
100
|
+
lineWidths: [30, 40],
|
|
101
|
+
numLines: 2,
|
|
102
|
+
textHeight: 24,
|
|
103
|
+
textWidth: 40,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('appendTextInput', () => {
|
|
108
|
+
it('appends without input restrictions', () => {
|
|
109
|
+
const text = createInput({ maxChars: 3, text: 'abc' });
|
|
110
|
+
appendTextInput(text, 'def');
|
|
111
|
+
expect(text.data.text).toBe('abcdef');
|
|
112
|
+
expect(getTextInputCaretIndex(text)).toBe(6);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('applyTextInputRestriction', () => {
|
|
117
|
+
it('allows accepted ranges', () => {
|
|
118
|
+
const text = createInput({}, { restrict: 'A-Z 0-9' });
|
|
119
|
+
expect(applyTextInputRestriction(text, 'A1a!')).toBe('A1');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('declines ranges after a caret', () => {
|
|
123
|
+
const text = createInput({}, { restrict: '^a-z' });
|
|
124
|
+
expect(applyTextInputRestriction(text, 'Abc1')).toBe('A1');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('supports escaping literal carets and hyphens', () => {
|
|
128
|
+
const text = createInput({}, { restrict: '\\-\\^' });
|
|
129
|
+
expect(applyTextInputRestriction(text, '-^A')).toBe('-^');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('applies maxChars to inserted user input', () => {
|
|
133
|
+
const text = createInput({ maxChars: 5, text: 'abc' });
|
|
134
|
+
expect(applyTextInputRestriction(text, 'def')).toBe('de');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('removes line breaks for single-line input', () => {
|
|
138
|
+
const text = createInput({ multiline: false });
|
|
139
|
+
expect(applyTextInputRestriction(text, 'a\nb\rc')).toBe('abc');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('canRedoTextInput', () => {
|
|
144
|
+
it('returns false when nothing has been undone', () => {
|
|
145
|
+
const text = createInput({ text: 'abc' });
|
|
146
|
+
insertTextInput(text, 'X');
|
|
147
|
+
expect(canRedoTextInput(text)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('returns true after an edit has been undone', () => {
|
|
151
|
+
const text = createInput({ text: 'abc' });
|
|
152
|
+
insertTextInput(text, 'X');
|
|
153
|
+
undoTextInput(text);
|
|
154
|
+
expect(canRedoTextInput(text)).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('canUndoTextInput', () => {
|
|
159
|
+
it('returns false on a freshly enabled field with no edits', () => {
|
|
160
|
+
const text = createInput({ text: 'abc' });
|
|
161
|
+
expect(canUndoTextInput(text)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('returns true after a recorded edit', () => {
|
|
165
|
+
const text = createInput({ text: 'abc' });
|
|
166
|
+
insertTextInput(text, 'X');
|
|
167
|
+
expect(canUndoTextInput(text)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('clearTextInputHistory', () => {
|
|
172
|
+
it('empties the history and resets the cursor without changing text', () => {
|
|
173
|
+
const text = createInput({ text: 'abc' });
|
|
174
|
+
insertTextInput(text, 'X');
|
|
175
|
+
expect(canUndoTextInput(text)).toBe(true);
|
|
176
|
+
clearTextInputHistory(text);
|
|
177
|
+
expect(canUndoTextInput(text)).toBe(false);
|
|
178
|
+
expect(canRedoTextInput(text)).toBe(false);
|
|
179
|
+
expect(text.data.text).toBe('Xabc');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('deleteTextInputBackward', () => {
|
|
184
|
+
it('deletes the previous character for a collapsed selection', () => {
|
|
185
|
+
const text = createInput({ text: 'abc' });
|
|
186
|
+
setTextInputSelection(text, 2, 2);
|
|
187
|
+
deleteTextInputBackward(text);
|
|
188
|
+
expect(text.data.text).toBe('ac');
|
|
189
|
+
expect(getTextInputCaretIndex(text)).toBe(1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('deletes the selected range', () => {
|
|
193
|
+
const text = createInput({ text: 'abcd' });
|
|
194
|
+
setTextInputSelection(text, 1, 3);
|
|
195
|
+
deleteTextInputBackward(text);
|
|
196
|
+
expect(text.data.text).toBe('ad');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('deleteTextInputForward', () => {
|
|
201
|
+
it('deletes the next character for a collapsed selection', () => {
|
|
202
|
+
const text = createInput({ text: 'abc' });
|
|
203
|
+
setTextInputSelection(text, 1, 1);
|
|
204
|
+
deleteTextInputForward(text);
|
|
205
|
+
expect(text.data.text).toBe('ac');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('deleteTextInputWordBackward', () => {
|
|
210
|
+
it('deletes the word before the caret', () => {
|
|
211
|
+
const text = createInput({ text: 'hello world' });
|
|
212
|
+
setTextInputSelection(text, 11, 11);
|
|
213
|
+
deleteTextInputWordBackward(text);
|
|
214
|
+
expect(text.data.text).toBe('hello ');
|
|
215
|
+
expect(getTextInputCaretIndex(text)).toBe(6);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('deletes across whitespace to reach the prior word', () => {
|
|
219
|
+
// caret at index 6 (before 'w') — skips the space at 5, then removes 'hello' (indices 0–5).
|
|
220
|
+
const text = createInput({ text: 'hello world' });
|
|
221
|
+
setTextInputSelection(text, 6, 6);
|
|
222
|
+
deleteTextInputWordBackward(text);
|
|
223
|
+
expect(text.data.text).toBe('world');
|
|
224
|
+
expect(getTextInputCaretIndex(text)).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('deletes the selected range instead of a word when selection is non-collapsed', () => {
|
|
228
|
+
const text = createInput({ text: 'hello world' });
|
|
229
|
+
setTextInputSelection(text, 0, 5);
|
|
230
|
+
deleteTextInputWordBackward(text);
|
|
231
|
+
expect(text.data.text).toBe(' world');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('does nothing at the beginning of text', () => {
|
|
235
|
+
const text = createInput({ text: 'abc' });
|
|
236
|
+
setTextInputSelection(text, 0, 0);
|
|
237
|
+
deleteTextInputWordBackward(text);
|
|
238
|
+
expect(text.data.text).toBe('abc');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('deleteTextInputWordForward', () => {
|
|
243
|
+
it('deletes the word after the caret', () => {
|
|
244
|
+
const text = createInput({ text: 'hello world' });
|
|
245
|
+
setTextInputSelection(text, 6, 6);
|
|
246
|
+
deleteTextInputWordForward(text);
|
|
247
|
+
expect(text.data.text).toBe('hello ');
|
|
248
|
+
expect(getTextInputCaretIndex(text)).toBe(6);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('deletes the selected range instead of a word when selection is non-collapsed', () => {
|
|
252
|
+
const text = createInput({ text: 'hello world' });
|
|
253
|
+
setTextInputSelection(text, 6, 11);
|
|
254
|
+
deleteTextInputWordForward(text);
|
|
255
|
+
expect(text.data.text).toBe('hello ');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('does nothing at the end of text', () => {
|
|
259
|
+
const text = createInput({ text: 'abc' });
|
|
260
|
+
setTextInputSelection(text, 3, 3);
|
|
261
|
+
deleteTextInputWordForward(text);
|
|
262
|
+
expect(text.data.text).toBe('abc');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('getTextInputCaretIndex', () => {
|
|
267
|
+
it('clamps the runtime caret to text length', () => {
|
|
268
|
+
const text = createInput({ text: 'abc' });
|
|
269
|
+
setTextInputSelection(text, 0, 99);
|
|
270
|
+
expect(getTextInputCaretIndex(text)).toBe(3);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('getTextInputCaretRectangle', () => {
|
|
275
|
+
it('writes a caret rectangle from the layout group', () => {
|
|
276
|
+
const text = createInput({ text: 'abcdefg' });
|
|
277
|
+
const out = { height: 0, lineIndex: 0, width: 0, x: 0, y: 0 };
|
|
278
|
+
setTextInputSelection(text, 2, 2);
|
|
279
|
+
getTextInputCaretRectangle(out, text, createLayout());
|
|
280
|
+
expect(out).toEqual({ height: 12, lineIndex: 0, width: 1, x: 22, y: 2 });
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('getTextInputCharacterIndexAtPoint', () => {
|
|
285
|
+
it('returns the nearest character index on a line', () => {
|
|
286
|
+
const text = createInput({ text: 'abcdefg' });
|
|
287
|
+
expect(getTextInputCharacterIndexAtPoint(text, createLayout(), 27, 3)).toBe(3);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('selects the closest line by y', () => {
|
|
291
|
+
const text = createInput({ text: 'abcdefg' });
|
|
292
|
+
expect(getTextInputCharacterIndexAtPoint(text, createLayout(), 2, 20)).toBe(3);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('getTextInputDisplayText', () => {
|
|
297
|
+
it('returns plain text by default', () => {
|
|
298
|
+
const text = createInput({ text: 'secret' });
|
|
299
|
+
expect(getTextInputDisplayText(text)).toBe('secret');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('returns password characters when displayAsPassword is enabled', () => {
|
|
303
|
+
const text = createInput({ text: 'secret' }, { displayAsPassword: true, passwordCharacter: '*' });
|
|
304
|
+
expect(getTextInputDisplayText(text)).toBe('******');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('getTextInputSelectionBeginIndex', () => {
|
|
309
|
+
it('returns the lower selected index', () => {
|
|
310
|
+
const text = createInput({ text: 'abcd' });
|
|
311
|
+
setTextInputSelection(text, 3, 1);
|
|
312
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(1);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('getTextInputSelectionEndIndex', () => {
|
|
317
|
+
it('returns the higher selected index', () => {
|
|
318
|
+
const text = createInput({ text: 'abcd' });
|
|
319
|
+
setTextInputSelection(text, 3, 1);
|
|
320
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(3);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('getTextInputSelectionRectangles', () => {
|
|
325
|
+
it('writes one rectangle per selected layout group', () => {
|
|
326
|
+
const text = createInput({ text: 'abcdefg' });
|
|
327
|
+
const out: { height: number; lineIndex: number; width: number; x: number; y: number }[] = [];
|
|
328
|
+
setTextInputSelection(text, 1, 5);
|
|
329
|
+
getTextInputSelectionRectangles(out, text, createLayout());
|
|
330
|
+
expect(out).toEqual([
|
|
331
|
+
{ height: 12, lineIndex: 0, width: 20, x: 12, y: 2 },
|
|
332
|
+
{ height: 12, lineIndex: 1, width: 20, x: 2, y: 14 },
|
|
333
|
+
]);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('getTextInputSelectionText', () => {
|
|
338
|
+
it('returns the selected slice of text', () => {
|
|
339
|
+
const text = createInput({ text: 'hello world' });
|
|
340
|
+
setTextInputSelection(text, 6, 11);
|
|
341
|
+
expect(getTextInputSelectionText(text)).toBe('world');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('returns empty string for a collapsed selection', () => {
|
|
345
|
+
const text = createInput({ text: 'hello' });
|
|
346
|
+
setTextInputSelection(text, 2, 2);
|
|
347
|
+
expect(getTextInputSelectionText(text)).toBe('');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('handleTextInputKeyboard', () => {
|
|
352
|
+
it('handles arrow movement', () => {
|
|
353
|
+
const text = createInput({ text: 'abc' });
|
|
354
|
+
setTextInputSelection(text, 2, 2);
|
|
355
|
+
expect(handleTextInputKeyboard(text, createKeyboardData({ key: 'ArrowLeft', keyCode: KeyCode.LEFT }))).toBe(true);
|
|
356
|
+
expect(getTextInputCaretIndex(text)).toBe(1);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('extends selection when shift is held', () => {
|
|
360
|
+
const text = createInput({ text: 'abc' });
|
|
361
|
+
setTextInputSelection(text, 1, 1);
|
|
362
|
+
handleTextInputKeyboard(text, createKeyboardData({ key: 'ArrowRight', keyCode: KeyCode.RIGHT, shiftKey: true }));
|
|
363
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(1);
|
|
364
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(2);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('handles backspace', () => {
|
|
368
|
+
const text = createInput({ text: 'abc' });
|
|
369
|
+
setTextInputSelection(text, 2, 2);
|
|
370
|
+
handleTextInputKeyboard(text, createKeyboardData({ key: 'Backspace', keyCode: KeyCode.BACKSPACE }));
|
|
371
|
+
expect(text.data.text).toBe('ac');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('handles ctrl-a selection', () => {
|
|
375
|
+
const text = createInput({ text: 'abc' });
|
|
376
|
+
handleTextInputKeyboard(text, createKeyboardData({ ctrlKey: true, key: 'a', keyCode: KeyCode.A }));
|
|
377
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(0);
|
|
378
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(3);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('handles paste using input restrictions', () => {
|
|
382
|
+
const text = createInput({ text: 'a' }, { restrict: '0-9' });
|
|
383
|
+
setTextInputSelection(text, 1, 1);
|
|
384
|
+
handleTextInputKeyboard(text, createKeyboardData({ ctrlKey: true, key: 'v', keyCode: KeyCode.V }), {
|
|
385
|
+
clipboardText: 'b23',
|
|
386
|
+
});
|
|
387
|
+
expect(text.data.text).toBe('a23');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('inserts returns only for multiline input', () => {
|
|
391
|
+
const text = createInput({ multiline: true, text: 'a' });
|
|
392
|
+
setTextInputSelection(text, 1, 1);
|
|
393
|
+
expect(handleTextInputKeyboard(text, createKeyboardData({ key: 'Enter', keyCode: KeyCode.RETURN }))).toBe(true);
|
|
394
|
+
expect(text.data.text).toBe('a\n');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('handles ctrl+left word-left motion', () => {
|
|
398
|
+
const text = createInput({ text: 'hello world' });
|
|
399
|
+
setTextInputSelection(text, 11, 11);
|
|
400
|
+
handleTextInputKeyboard(text, createKeyboardData({ ctrlKey: true, key: 'ArrowLeft', keyCode: KeyCode.LEFT }));
|
|
401
|
+
expect(getTextInputCaretIndex(text)).toBe(6);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('handles ctrl+right word-right motion', () => {
|
|
405
|
+
const text = createInput({ text: 'hello world' });
|
|
406
|
+
setTextInputSelection(text, 0, 0);
|
|
407
|
+
handleTextInputKeyboard(text, createKeyboardData({ ctrlKey: true, key: 'ArrowRight', keyCode: KeyCode.RIGHT }));
|
|
408
|
+
expect(getTextInputCaretIndex(text)).toBe(5);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('handles ctrl+backspace word-delete backward', () => {
|
|
412
|
+
const text = createInput({ text: 'hello world' });
|
|
413
|
+
setTextInputSelection(text, 11, 11);
|
|
414
|
+
handleTextInputKeyboard(text, createKeyboardData({ ctrlKey: true, key: 'Backspace', keyCode: KeyCode.BACKSPACE }));
|
|
415
|
+
expect(text.data.text).toBe('hello ');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('handles ctrl+delete word-delete forward', () => {
|
|
419
|
+
const text = createInput({ text: 'hello world' });
|
|
420
|
+
setTextInputSelection(text, 6, 6);
|
|
421
|
+
handleTextInputKeyboard(text, createKeyboardData({ ctrlKey: true, key: 'Delete', keyCode: KeyCode.DELETE }));
|
|
422
|
+
expect(text.data.text).toBe('hello ');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('handles down arrow with layout (moves to next line)', () => {
|
|
426
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
427
|
+
setTextInputSelection(text, 1, 1);
|
|
428
|
+
handleTextInputKeyboard(text, createKeyboardData({ key: 'ArrowDown', keyCode: KeyCode.DOWN }), {
|
|
429
|
+
layout: createLayout(),
|
|
430
|
+
});
|
|
431
|
+
// Should land somewhere on line 1 (indices 3–7).
|
|
432
|
+
expect(getTextInputCaretIndex(text)).toBeGreaterThanOrEqual(3);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('handles up arrow with layout (moves to prev line)', () => {
|
|
436
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
437
|
+
setTextInputSelection(text, 5, 5);
|
|
438
|
+
handleTextInputKeyboard(text, createKeyboardData({ key: 'ArrowUp', keyCode: KeyCode.UP }), {
|
|
439
|
+
layout: createLayout(),
|
|
440
|
+
});
|
|
441
|
+
// Should land somewhere on line 0 (indices 0–3).
|
|
442
|
+
expect(getTextInputCaretIndex(text)).toBeLessThanOrEqual(3);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('handles down at last line by moving to end', () => {
|
|
446
|
+
const text = createInput({ text: 'abcdefg' });
|
|
447
|
+
setTextInputSelection(text, 5, 5);
|
|
448
|
+
handleTextInputKeyboard(text, createKeyboardData({ key: 'ArrowDown', keyCode: KeyCode.DOWN }), {
|
|
449
|
+
layout: createLayout(),
|
|
450
|
+
});
|
|
451
|
+
expect(getTextInputCaretIndex(text)).toBe(7);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('handles up at first line by moving to start', () => {
|
|
455
|
+
const text = createInput({ text: 'abcdefg' });
|
|
456
|
+
setTextInputSelection(text, 1, 1);
|
|
457
|
+
handleTextInputKeyboard(text, createKeyboardData({ key: 'ArrowUp', keyCode: KeyCode.UP }), {
|
|
458
|
+
layout: createLayout(),
|
|
459
|
+
});
|
|
460
|
+
expect(getTextInputCaretIndex(text)).toBe(0);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('Home moves to the start of the current line (line-relative)', () => {
|
|
464
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
465
|
+
setTextInputSelection(text, 6, 6);
|
|
466
|
+
handleTextInputKeyboard(text, createKeyboardData({ key: 'Home', keyCode: KeyCode.HOME }), {
|
|
467
|
+
layout: createLayout(),
|
|
468
|
+
});
|
|
469
|
+
// Caret is on line 1 (indices 3–7); Home lands on that line's start, not document start.
|
|
470
|
+
expect(getTextInputCaretIndex(text)).toBe(3);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('End moves to the end of the current line (line-relative)', () => {
|
|
474
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
475
|
+
setTextInputSelection(text, 1, 1);
|
|
476
|
+
handleTextInputKeyboard(text, createKeyboardData({ key: 'End', keyCode: KeyCode.END }), {
|
|
477
|
+
layout: createLayout(),
|
|
478
|
+
});
|
|
479
|
+
// Caret is on line 0 (indices 0–3); End lands on that line's end, not document end.
|
|
480
|
+
expect(getTextInputCaretIndex(text)).toBe(3);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('Ctrl+Home moves to document start', () => {
|
|
484
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
485
|
+
setTextInputSelection(text, 6, 6);
|
|
486
|
+
handleTextInputKeyboard(text, createKeyboardData({ ctrlKey: true, key: 'Home', keyCode: KeyCode.HOME }), {
|
|
487
|
+
layout: createLayout(),
|
|
488
|
+
});
|
|
489
|
+
expect(getTextInputCaretIndex(text)).toBe(0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('Ctrl+End moves to document end', () => {
|
|
493
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
494
|
+
setTextInputSelection(text, 1, 1);
|
|
495
|
+
handleTextInputKeyboard(text, createKeyboardData({ ctrlKey: true, key: 'End', keyCode: KeyCode.END }), {
|
|
496
|
+
layout: createLayout(),
|
|
497
|
+
});
|
|
498
|
+
expect(getTextInputCaretIndex(text)).toBe(7);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('invokes onCopy callback for copy command', () => {
|
|
502
|
+
const text = createInput({ text: 'hello' });
|
|
503
|
+
setTextInputSelection(text, 0, 5);
|
|
504
|
+
const copied: string[] = [];
|
|
505
|
+
handleTextInputKeyboard(text, createKeyboardData({ ctrlKey: true, key: 'c', keyCode: KeyCode.C }), {
|
|
506
|
+
onCopy: (t) => copied.push(t),
|
|
507
|
+
});
|
|
508
|
+
expect(copied).toEqual(['hello']);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('returns false for unhandled keys', () => {
|
|
512
|
+
const text = createInput({ text: 'abc' });
|
|
513
|
+
expect(handleTextInputKeyboard(text, createKeyboardData({ key: 'F1', keyCode: 112 }))).toBe(false);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
describe('insertTextInput', () => {
|
|
518
|
+
it('replaces the current selection using input restrictions', () => {
|
|
519
|
+
const text = createInput({ text: 'ab' }, { restrict: '0-9' });
|
|
520
|
+
setTextInputSelection(text, 1, 1);
|
|
521
|
+
insertTextInput(text, 'c3');
|
|
522
|
+
expect(text.data.text).toBe('a3b');
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
describe('moveTextInputCaret', () => {
|
|
527
|
+
it('moves and collapses selection by default', () => {
|
|
528
|
+
const text = createInput({ text: 'abc' });
|
|
529
|
+
moveTextInputCaret(text, 2);
|
|
530
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(2);
|
|
531
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(2);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('resets desiredCaretX on horizontal move', () => {
|
|
535
|
+
const text = createInput({ text: 'abc' });
|
|
536
|
+
const state = getTextInputState(text)!;
|
|
537
|
+
state.desiredCaretX = 50;
|
|
538
|
+
moveTextInputCaret(text, 1);
|
|
539
|
+
expect(state.desiredCaretX).toBe(-1);
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe('moveTextInputCaretByWord', () => {
|
|
544
|
+
it('moves backward by one word', () => {
|
|
545
|
+
const text = createInput({ text: 'hello world' });
|
|
546
|
+
setTextInputSelection(text, 11, 11);
|
|
547
|
+
moveTextInputCaretByWord(text, -1);
|
|
548
|
+
expect(getTextInputCaretIndex(text)).toBe(6);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('moves forward by one word', () => {
|
|
552
|
+
const text = createInput({ text: 'hello world' });
|
|
553
|
+
setTextInputSelection(text, 0, 0);
|
|
554
|
+
moveTextInputCaretByWord(text, 1);
|
|
555
|
+
expect(getTextInputCaretIndex(text)).toBe(5);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('extends selection when extendSelection is true', () => {
|
|
559
|
+
const text = createInput({ text: 'hello world' });
|
|
560
|
+
setTextInputSelection(text, 0, 0);
|
|
561
|
+
moveTextInputCaretByWord(text, 1, true);
|
|
562
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(0);
|
|
563
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(5);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('clamps to text start when moving backward past the beginning', () => {
|
|
567
|
+
const text = createInput({ text: 'hi' });
|
|
568
|
+
setTextInputSelection(text, 1, 1);
|
|
569
|
+
moveTextInputCaretByWord(text, -1);
|
|
570
|
+
expect(getTextInputCaretIndex(text)).toBe(0);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('clamps to text end when moving forward past the end', () => {
|
|
574
|
+
const text = createInput({ text: 'hi' });
|
|
575
|
+
setTextInputSelection(text, 2, 2);
|
|
576
|
+
moveTextInputCaretByWord(text, 1);
|
|
577
|
+
expect(getTextInputCaretIndex(text)).toBe(2);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe('moveTextInputCaretDown', () => {
|
|
582
|
+
it('moves caret to the next line at the same x', () => {
|
|
583
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
584
|
+
setTextInputSelection(text, 1, 1);
|
|
585
|
+
moveTextInputCaretDown(text, createLayout());
|
|
586
|
+
expect(getTextInputCaretIndex(text)).toBeGreaterThanOrEqual(3);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('moves to end of text when already on the last line', () => {
|
|
590
|
+
const text = createInput({ text: 'abcdefg' });
|
|
591
|
+
setTextInputSelection(text, 5, 5);
|
|
592
|
+
moveTextInputCaretDown(text, createLayout());
|
|
593
|
+
expect(getTextInputCaretIndex(text)).toBe(7);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('falls back to end of text when layout is null', () => {
|
|
597
|
+
const text = createInput({ text: 'abc' });
|
|
598
|
+
setTextInputSelection(text, 1, 1);
|
|
599
|
+
moveTextInputCaretDown(text, null);
|
|
600
|
+
expect(getTextInputCaretIndex(text)).toBe(3);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('preserves desiredCaretX across consecutive down moves', () => {
|
|
604
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
605
|
+
setTextInputSelection(text, 1, 1);
|
|
606
|
+
const state = getTextInputState(text)!;
|
|
607
|
+
moveTextInputCaretDown(text, createLayout());
|
|
608
|
+
// desiredCaretX should be set and not reset between vertical steps.
|
|
609
|
+
expect(state.desiredCaretX).not.toBe(-1);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('extends selection when extendSelection is true', () => {
|
|
613
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
614
|
+
setTextInputSelection(text, 1, 1);
|
|
615
|
+
moveTextInputCaretDown(text, createLayout(), true);
|
|
616
|
+
// caret moved forward; anchor (selectionIndex) stayed at 1.
|
|
617
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(1);
|
|
618
|
+
expect(getTextInputSelectionEndIndex(text)).toBeGreaterThan(1);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
describe('moveTextInputCaretToLineEnd', () => {
|
|
623
|
+
it('moves the caret to the end of the current layout line', () => {
|
|
624
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
625
|
+
setTextInputSelection(text, 1, 1);
|
|
626
|
+
moveTextInputCaretToLineEnd(text, createLayout());
|
|
627
|
+
// Caret is on line 0 (groups 0..3); the line's end index is 3.
|
|
628
|
+
expect(getTextInputCaretIndex(text)).toBe(3);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('falls back to end of text when layout is null', () => {
|
|
632
|
+
const text = createInput({ text: 'abc' });
|
|
633
|
+
setTextInputSelection(text, 1, 1);
|
|
634
|
+
moveTextInputCaretToLineEnd(text, null);
|
|
635
|
+
expect(getTextInputCaretIndex(text)).toBe(3);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('extends selection when extendSelection is true', () => {
|
|
639
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
640
|
+
setTextInputSelection(text, 1, 1);
|
|
641
|
+
moveTextInputCaretToLineEnd(text, createLayout(), true);
|
|
642
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(1);
|
|
643
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(3);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
describe('moveTextInputCaretToLineStart', () => {
|
|
648
|
+
it('moves the caret to the start of the current layout line', () => {
|
|
649
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
650
|
+
setTextInputSelection(text, 6, 6);
|
|
651
|
+
moveTextInputCaretToLineStart(text, createLayout());
|
|
652
|
+
// Caret is on line 1 (groups 3..7); the line's start index is 3.
|
|
653
|
+
expect(getTextInputCaretIndex(text)).toBe(3);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('falls back to start of text when layout is null', () => {
|
|
657
|
+
const text = createInput({ text: 'abc' });
|
|
658
|
+
setTextInputSelection(text, 2, 2);
|
|
659
|
+
moveTextInputCaretToLineStart(text, null);
|
|
660
|
+
expect(getTextInputCaretIndex(text)).toBe(0);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('extends selection when extendSelection is true', () => {
|
|
664
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
665
|
+
setTextInputSelection(text, 6, 6);
|
|
666
|
+
moveTextInputCaretToLineStart(text, createLayout(), true);
|
|
667
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(3);
|
|
668
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(6);
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
describe('moveTextInputCaretUp', () => {
|
|
673
|
+
it('moves caret to the previous line at the same x', () => {
|
|
674
|
+
const text = createInput({ multiline: true, text: 'abcdefg' });
|
|
675
|
+
setTextInputSelection(text, 5, 5);
|
|
676
|
+
moveTextInputCaretUp(text, createLayout());
|
|
677
|
+
expect(getTextInputCaretIndex(text)).toBeLessThanOrEqual(3);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('moves to start of text when already on the first line', () => {
|
|
681
|
+
const text = createInput({ text: 'abcdefg' });
|
|
682
|
+
setTextInputSelection(text, 1, 1);
|
|
683
|
+
moveTextInputCaretUp(text, createLayout());
|
|
684
|
+
expect(getTextInputCaretIndex(text)).toBe(0);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('falls back to start of text when layout is null', () => {
|
|
688
|
+
const text = createInput({ text: 'abc' });
|
|
689
|
+
setTextInputSelection(text, 2, 2);
|
|
690
|
+
moveTextInputCaretUp(text, null);
|
|
691
|
+
expect(getTextInputCaretIndex(text)).toBe(0);
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
describe('redoTextInput', () => {
|
|
696
|
+
it('reapplies an undone edit', () => {
|
|
697
|
+
const text = createInput({ text: 'abc' });
|
|
698
|
+
insertTextInput(text, 'X');
|
|
699
|
+
expect(text.data.text).toBe('Xabc');
|
|
700
|
+
undoTextInput(text);
|
|
701
|
+
expect(text.data.text).toBe('abc');
|
|
702
|
+
redoTextInput(text);
|
|
703
|
+
expect(text.data.text).toBe('Xabc');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('does nothing when there is nothing to redo', () => {
|
|
707
|
+
const text = createInput({ text: 'abc' });
|
|
708
|
+
insertTextInput(text, 'X');
|
|
709
|
+
redoTextInput(text);
|
|
710
|
+
expect(text.data.text).toBe('Xabc');
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('restores the caret position recorded after the edit', () => {
|
|
714
|
+
const text = createInput({ text: 'abc' });
|
|
715
|
+
setTextInputSelection(text, 0, 0);
|
|
716
|
+
insertTextInput(text, 'X');
|
|
717
|
+
undoTextInput(text);
|
|
718
|
+
redoTextInput(text);
|
|
719
|
+
expect(getTextInputCaretIndex(text)).toBe(1);
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
describe('replaceSelectedTextInput', () => {
|
|
724
|
+
it('does not apply input restrictions by default', () => {
|
|
725
|
+
const text = createInput({ maxChars: 3, text: 'abc' }, { restrict: '0-9' });
|
|
726
|
+
setTextInputSelection(text, 1, 2);
|
|
727
|
+
replaceSelectedTextInput(text, 'XYZ');
|
|
728
|
+
expect(text.data.text).toBe('aXYZc');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('can apply input restrictions for user input paths', () => {
|
|
732
|
+
const text = createInput({ maxChars: 4, text: 'ab' }, { restrict: '0-9' });
|
|
733
|
+
setTextInputSelection(text, 1, 1);
|
|
734
|
+
replaceSelectedTextInput(text, 'c345', { applyInputRules: true });
|
|
735
|
+
expect(text.data.text).toBe('a34b');
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
describe('replaceTextInput', () => {
|
|
740
|
+
it('replaces a range and collapses selection after inserted text', () => {
|
|
741
|
+
const text = createInput({ text: 'hello world' });
|
|
742
|
+
const before = getNodeAppearanceRevision(text);
|
|
743
|
+
replaceTextInput(text, 6, 11, 'Flight');
|
|
744
|
+
expect(text.data.text).toBe('hello Flight');
|
|
745
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(12);
|
|
746
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(12);
|
|
747
|
+
expect(getNodeAppearanceRevision(text)).not.toBe(before);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('updates serialized text format ranges after insertion', () => {
|
|
751
|
+
const text = createInput({ text: 'abcd' });
|
|
752
|
+
setRichTextFormatRange(text, { bold: true }, 0, 4);
|
|
753
|
+
replaceTextInput(text, 2, 2, 'XY');
|
|
754
|
+
expect(text.data.textFormatRanges).toEqual([{ start: 0, end: 6, format: { bold: true } }]);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('updates serialized text format ranges after replacement', () => {
|
|
758
|
+
const text = createInput({ text: 'abcd' });
|
|
759
|
+
setRichTextFormatRange(text, { bold: true }, 0, 2);
|
|
760
|
+
setRichTextFormatRange(text, { italic: true }, 2, 4);
|
|
761
|
+
replaceTextInput(text, 1, 3, 'Z');
|
|
762
|
+
expect(text.data.textFormatRanges).toEqual([
|
|
763
|
+
{ start: 0, end: 1, format: { bold: true } },
|
|
764
|
+
{ start: 1, end: 3, format: { italic: true } },
|
|
765
|
+
]);
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
describe('scrollTextInputCaretIntoView', () => {
|
|
770
|
+
function createTallLayout(): TextLayoutResult {
|
|
771
|
+
const groups = [];
|
|
772
|
+
for (let line = 0; line < 4; line++) {
|
|
773
|
+
groups.push({
|
|
774
|
+
ascent: 10,
|
|
775
|
+
descent: 2,
|
|
776
|
+
endIndex: line + 1,
|
|
777
|
+
format: {},
|
|
778
|
+
height: 12,
|
|
779
|
+
leading: 0,
|
|
780
|
+
lineIndex: line,
|
|
781
|
+
offsetX: 2,
|
|
782
|
+
offsetY: 2 + line * 12,
|
|
783
|
+
positions: [10],
|
|
784
|
+
startIndex: line,
|
|
785
|
+
width: 10,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
groups,
|
|
790
|
+
lineAscents: [10, 10, 10, 10],
|
|
791
|
+
lineDescents: [2, 2, 2, 2],
|
|
792
|
+
lineHeights: [12, 12, 12, 12],
|
|
793
|
+
lineLeadings: [0, 0, 0, 0],
|
|
794
|
+
lineWidths: [10, 10, 10, 10],
|
|
795
|
+
numLines: 4,
|
|
796
|
+
textHeight: 48,
|
|
797
|
+
textWidth: 10,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
it('scrolls down so a caret below the viewport becomes visible', () => {
|
|
802
|
+
const text = createInput({ autoSize: 'none', height: 28, multiline: true, text: 'abcd', width: 100 });
|
|
803
|
+
// Caret on the last line (index 4), viewport ~2 lines tall.
|
|
804
|
+
setTextInputSelection(text, 4, 4);
|
|
805
|
+
scrollTextInputCaretIntoView(text, createTallLayout(), 100, 24);
|
|
806
|
+
expect(text.data.scrollV).toBeGreaterThan(1);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('leaves scroll unchanged when the caret is already visible', () => {
|
|
810
|
+
const text = createInput({ autoSize: 'none', height: 60, multiline: true, text: 'abcd', width: 100 });
|
|
811
|
+
setTextInputSelection(text, 0, 0);
|
|
812
|
+
scrollTextInputCaretIntoView(text, createTallLayout(), 100, 60);
|
|
813
|
+
expect(text.data.scrollV).toBe(1);
|
|
814
|
+
expect(text.data.scrollH).toBe(0);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('scrolls horizontally so a caret past the right edge becomes visible', () => {
|
|
818
|
+
const text = createInput({ autoSize: 'none', height: 40, text: 'abcdefg', width: 20 });
|
|
819
|
+
// A wide single line; caret at the far right of line 1 of the standard fixture (x ~= 42).
|
|
820
|
+
setTextInputSelection(text, 7, 7);
|
|
821
|
+
scrollTextInputCaretIntoView(text, createLayout(), 20, 40);
|
|
822
|
+
expect(text.data.scrollH).toBeGreaterThan(0);
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
describe('selectAllTextInput', () => {
|
|
827
|
+
it('selects the full input text range', () => {
|
|
828
|
+
const text = createInput({ text: 'abc' });
|
|
829
|
+
selectAllTextInput(text);
|
|
830
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(0);
|
|
831
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(3);
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
describe('selectLineAtTextInputIndex', () => {
|
|
836
|
+
it('selects from the start to the end of the line', () => {
|
|
837
|
+
const text = createInput({ text: 'hello\nworld' });
|
|
838
|
+
selectLineAtTextInputIndex(text, 8);
|
|
839
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(6);
|
|
840
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(11);
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
describe('selectWordAtTextInputIndex', () => {
|
|
845
|
+
it('selects the word at the given index', () => {
|
|
846
|
+
const text = createInput({ text: 'hello world' });
|
|
847
|
+
selectWordAtTextInputIndex(text, 1);
|
|
848
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(0);
|
|
849
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(5);
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
describe('setTextInputSelection', () => {
|
|
854
|
+
it('clamps selection to the text range', () => {
|
|
855
|
+
const text = createInput({ text: 'abc' });
|
|
856
|
+
setTextInputSelection(text, -10, 10);
|
|
857
|
+
expect(getTextInputSelectionBeginIndex(text)).toBe(0);
|
|
858
|
+
expect(getTextInputSelectionEndIndex(text)).toBe(3);
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
describe('undoTextInput', () => {
|
|
863
|
+
it('restores the text before the most recent edit', () => {
|
|
864
|
+
const text = createInput({ text: 'abc' });
|
|
865
|
+
insertTextInput(text, 'X');
|
|
866
|
+
expect(text.data.text).toBe('Xabc');
|
|
867
|
+
undoTextInput(text);
|
|
868
|
+
expect(text.data.text).toBe('abc');
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('does nothing when there is nothing to undo', () => {
|
|
872
|
+
const text = createInput({ text: 'abc' });
|
|
873
|
+
undoTextInput(text);
|
|
874
|
+
expect(text.data.text).toBe('abc');
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('walks back through multiple non-merged edits in order', () => {
|
|
878
|
+
const text = createInput({ text: '' });
|
|
879
|
+
replaceTextInput(text, 0, 0, 'a', { mergeKind: null });
|
|
880
|
+
replaceTextInput(text, 1, 1, 'b', { mergeKind: null });
|
|
881
|
+
expect(text.data.text).toBe('ab');
|
|
882
|
+
undoTextInput(text);
|
|
883
|
+
expect(text.data.text).toBe('a');
|
|
884
|
+
undoTextInput(text);
|
|
885
|
+
expect(text.data.text).toBe('');
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('coalesces consecutive edits sharing a non-null mergeKind into one undo step', () => {
|
|
889
|
+
const text = createInput({ text: '' });
|
|
890
|
+
replaceTextInput(text, 0, 0, 'a', { mergeKind: 'type' });
|
|
891
|
+
replaceTextInput(text, 1, 1, 'b', { mergeKind: 'type' });
|
|
892
|
+
expect(text.data.text).toBe('ab');
|
|
893
|
+
undoTextInput(text);
|
|
894
|
+
// Both keystrokes collapse into a single record, so one undo clears them both.
|
|
895
|
+
expect(text.data.text).toBe('');
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it('does not record history when historyLimit is 0', () => {
|
|
899
|
+
const text = createInput({ text: 'abc' }, { historyLimit: 0 });
|
|
900
|
+
insertTextInput(text, 'X');
|
|
901
|
+
expect(canUndoTextInput(text)).toBe(false);
|
|
902
|
+
undoTextInput(text);
|
|
903
|
+
expect(text.data.text).toBe('Xabc');
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('restores the caret position recorded before the edit', () => {
|
|
907
|
+
const text = createInput({ text: 'abc' });
|
|
908
|
+
setTextInputSelection(text, 1, 1);
|
|
909
|
+
insertTextInput(text, 'X');
|
|
910
|
+
expect(getTextInputCaretIndex(text)).toBe(2);
|
|
911
|
+
undoTextInput(text);
|
|
912
|
+
expect(getTextInputCaretIndex(text)).toBe(1);
|
|
913
|
+
});
|
|
914
|
+
});
|