@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.
@@ -0,0 +1,790 @@
1
+ import { invalidateNodeAppearance } from '@flighthq/node';
2
+ import { setRichTextScrollH, setRichTextScrollV } from '@flighthq/text';
3
+ import { getRichTextSelectionRectangles, TEXT_BOUNDS_GUTTER } from '@flighthq/textlayout';
4
+ import { KeyCode } from '@flighthq/types';
5
+ import { getTextInputState } from './textInput';
6
+ // Horizontal navigation resets the desired-x column so the next vertical motion anchors to the new
7
+ // caret position. Vertical navigation reads (and on first use, sets) desiredCaretX.
8
+ const DESIRED_CARET_X_UNSET = -1;
9
+ export function appendTextInput(source, text) {
10
+ replaceTextInput(source, source.data.text.length, source.data.text.length, text);
11
+ }
12
+ export function applyTextInputRestriction(source, text, replaceLength = 0) {
13
+ const data = source.data;
14
+ let value = text;
15
+ if (!data.multiline)
16
+ value = value.replace(/[\n\r]+/g, '');
17
+ value = restrictTextInput(value, getInputState(source).restrict);
18
+ if (data.maxChars > 0) {
19
+ const maxLength = data.maxChars - data.text.length + replaceLength;
20
+ if (maxLength <= 0)
21
+ return '';
22
+ if (value.length > maxLength)
23
+ value = value.slice(0, maxLength);
24
+ }
25
+ return value;
26
+ }
27
+ // Returns true if there is an edit record available to redo (i.e. the history cursor is not at the
28
+ // most-recent record).
29
+ export function canRedoTextInput(source) {
30
+ const state = getInputState(source);
31
+ return state.historyIndex < state.history.length - 1;
32
+ }
33
+ // Returns true if there is an edit record available to undo (i.e. at least one edit has been recorded
34
+ // and the history cursor is not before the first record).
35
+ export function canUndoTextInput(source) {
36
+ return getInputState(source).historyIndex >= 0;
37
+ }
38
+ // Clears the undo/redo history for this field. Does not change the text or selection.
39
+ export function clearTextInputHistory(source) {
40
+ const state = getInputState(source);
41
+ state.history = [];
42
+ state.historyIndex = -1;
43
+ }
44
+ export function deleteTextInputBackward(source) {
45
+ const state = getInputState(source);
46
+ const start = getTextInputSelectionBeginIndex(source);
47
+ const end = getTextInputSelectionEndIndex(source);
48
+ if (start !== end) {
49
+ replaceTextInput(source, start, end, '');
50
+ }
51
+ else if (start > 0) {
52
+ replaceTextInput(source, start - 1, start, '');
53
+ }
54
+ state.selectionIndex = state.caretIndex;
55
+ }
56
+ export function deleteTextInputForward(source) {
57
+ const start = getTextInputSelectionBeginIndex(source);
58
+ const end = getTextInputSelectionEndIndex(source);
59
+ if (start !== end) {
60
+ replaceTextInput(source, start, end, '');
61
+ }
62
+ else if (start < source.data.text.length) {
63
+ replaceTextInput(source, start, start + 1, '');
64
+ }
65
+ }
66
+ // Deletes backward by one word — from the caret position to the beginning of the previous word
67
+ // (or the beginning of the text if no word boundary precedes the caret). If a range is selected,
68
+ // deletes the selection instead, matching typical Ctrl/Alt+Backspace behavior.
69
+ export function deleteTextInputWordBackward(source) {
70
+ const start = getTextInputSelectionBeginIndex(source);
71
+ const end = getTextInputSelectionEndIndex(source);
72
+ if (start !== end) {
73
+ replaceTextInput(source, start, end, '');
74
+ return;
75
+ }
76
+ const wordStart = findWordStartBefore(source.data.text, start);
77
+ if (wordStart < start)
78
+ replaceTextInput(source, wordStart, start, '');
79
+ }
80
+ // Deletes forward by one word — from the caret position to the end of the next word
81
+ // (or the end of the text if no word boundary follows the caret). If a range is selected,
82
+ // deletes the selection instead, matching typical Ctrl/Alt+Delete behavior.
83
+ export function deleteTextInputWordForward(source) {
84
+ const start = getTextInputSelectionBeginIndex(source);
85
+ const end = getTextInputSelectionEndIndex(source);
86
+ if (start !== end) {
87
+ replaceTextInput(source, start, end, '');
88
+ return;
89
+ }
90
+ const wordEnd = findWordEndAfter(source.data.text, start);
91
+ if (wordEnd > start)
92
+ replaceTextInput(source, start, wordEnd, '');
93
+ }
94
+ export function getTextInputCaretIndex(source) {
95
+ return clampIndex(getInputState(source).caretIndex, source.data.text.length);
96
+ }
97
+ export function getTextInputCaretRectangle(out, source, layout) {
98
+ const caretIndex = getTextInputCaretIndex(source);
99
+ const group = getTextLayoutGroupAtIndex(layout, caretIndex);
100
+ if (group === null) {
101
+ out.x = TEXT_BOUNDS_GUTTER;
102
+ out.y = TEXT_BOUNDS_GUTTER;
103
+ out.width = 1;
104
+ out.height = getFallbackLineHeight(layout);
105
+ out.lineIndex = 0;
106
+ return;
107
+ }
108
+ out.x = getTextLayoutGroupCaretX(group, caretIndex);
109
+ out.y = group.offsetY;
110
+ out.width = 1;
111
+ out.height = group.height;
112
+ out.lineIndex = group.lineIndex;
113
+ }
114
+ export function getTextInputCharacterIndexAtPoint(source, layout, x, y) {
115
+ if (layout.groups.length === 0)
116
+ return 0;
117
+ let closestLineIndex = 0;
118
+ let closestLineDistance = Infinity;
119
+ for (let i = 0; i < layout.lineHeights.length; i++) {
120
+ const lineTop = getLineOffsetY(layout, i);
121
+ const lineBottom = lineTop + layout.lineHeights[i];
122
+ const distance = y < lineTop ? lineTop - y : y > lineBottom ? y - lineBottom : 0;
123
+ if (distance < closestLineDistance) {
124
+ closestLineDistance = distance;
125
+ closestLineIndex = i;
126
+ }
127
+ }
128
+ let lineStart = source.data.text.length;
129
+ let lineEnd = 0;
130
+ for (const group of layout.groups) {
131
+ if (group.lineIndex !== closestLineIndex)
132
+ continue;
133
+ lineStart = Math.min(lineStart, group.startIndex);
134
+ lineEnd = Math.max(lineEnd, group.endIndex);
135
+ if (x <= group.offsetX)
136
+ return group.startIndex;
137
+ if (x <= group.offsetX + group.width)
138
+ return getTextLayoutGroupCharacterIndexAtX(group, x);
139
+ }
140
+ return lineEnd > 0 ? lineEnd : lineStart;
141
+ }
142
+ export function getTextInputDisplayText(source) {
143
+ const state = getInputState(source);
144
+ if (!state.displayAsPassword)
145
+ return source.data.text;
146
+ const passwordCharacter = state.passwordCharacter.length > 0 ? state.passwordCharacter.charAt(0) : '•';
147
+ return passwordCharacter.repeat(source.data.text.length);
148
+ }
149
+ export function getTextInputSelectionBeginIndex(source) {
150
+ const state = getInputState(source);
151
+ return Math.min(clampIndex(state.caretIndex, source.data.text.length), clampIndex(state.selectionIndex, source.data.text.length));
152
+ }
153
+ export function getTextInputSelectionEndIndex(source) {
154
+ const state = getInputState(source);
155
+ return Math.max(clampIndex(state.caretIndex, source.data.text.length), clampIndex(state.selectionIndex, source.data.text.length));
156
+ }
157
+ export function getTextInputSelectionRectangles(out, source, layout) {
158
+ getRichTextSelectionRectangles(out, getTextInputSelectionBeginIndex(source), getTextInputSelectionEndIndex(source), layout);
159
+ }
160
+ export function getTextInputSelectionText(source) {
161
+ return source.data.text.slice(getTextInputSelectionBeginIndex(source), getTextInputSelectionEndIndex(source));
162
+ }
163
+ export function handleTextInputKeyboard(source, data, options) {
164
+ const command = getKeyboardCommand(data);
165
+ if (command === 'none')
166
+ return false;
167
+ switch (command) {
168
+ case 'backspace':
169
+ deleteTextInputBackward(source);
170
+ return true;
171
+ case 'copy': {
172
+ const copyText = getTextInputSelectionText(source);
173
+ if (copyText.length > 0)
174
+ options?.onCopy?.(copyText);
175
+ return true;
176
+ }
177
+ case 'cut': {
178
+ const cutText = getTextInputSelectionText(source);
179
+ if (cutText.length > 0) {
180
+ options?.onCopy?.(cutText);
181
+ replaceSelectedTextInput(source, '');
182
+ }
183
+ return true;
184
+ }
185
+ case 'delete':
186
+ deleteTextInputForward(source);
187
+ return true;
188
+ case 'deleteWordBackward':
189
+ deleteTextInputWordBackward(source);
190
+ return true;
191
+ case 'deleteWordForward':
192
+ deleteTextInputWordForward(source);
193
+ return true;
194
+ case 'documentEnd':
195
+ moveTextInputCaret(source, source.data.text.length, data.shiftKey);
196
+ return true;
197
+ case 'documentStart':
198
+ moveTextInputCaret(source, 0, data.shiftKey);
199
+ return true;
200
+ case 'down':
201
+ moveTextInputCaretDown(source, options?.layout, data.shiftKey);
202
+ return true;
203
+ case 'end':
204
+ moveTextInputCaretToLineEnd(source, options?.layout, data.shiftKey);
205
+ return true;
206
+ case 'home':
207
+ moveTextInputCaretToLineStart(source, options?.layout, data.shiftKey);
208
+ return true;
209
+ case 'left':
210
+ moveTextInputCaret(source, getTextInputCaretIndex(source) - 1, data.shiftKey);
211
+ return true;
212
+ case 'paste':
213
+ insertTextInput(source, options?.clipboardText ?? '');
214
+ return true;
215
+ case 'return':
216
+ if (!source.data.multiline)
217
+ return false;
218
+ insertTextInput(source, '\n');
219
+ return true;
220
+ case 'right':
221
+ moveTextInputCaret(source, getTextInputCaretIndex(source) + 1, data.shiftKey);
222
+ return true;
223
+ case 'selectAll':
224
+ selectAllTextInput(source);
225
+ return true;
226
+ case 'up':
227
+ moveTextInputCaretUp(source, options?.layout, data.shiftKey);
228
+ return true;
229
+ case 'wordLeft':
230
+ moveTextInputCaretByWord(source, -1, data.shiftKey);
231
+ return true;
232
+ case 'wordRight':
233
+ moveTextInputCaretByWord(source, 1, data.shiftKey);
234
+ return true;
235
+ }
236
+ }
237
+ export function insertTextInput(source, text) {
238
+ replaceSelectedTextInput(source, text, { applyInputRules: true });
239
+ }
240
+ export function moveTextInputCaret(source, index, extendSelection = false) {
241
+ const caret = clampIndex(index, source.data.text.length);
242
+ const state = getInputState(source);
243
+ state.caretIndex = caret;
244
+ if (!extendSelection)
245
+ state.selectionIndex = caret;
246
+ // Horizontal motion resets the desired-x column so subsequent vertical navigation re-anchors.
247
+ state.desiredCaretX = DESIRED_CARET_X_UNSET;
248
+ invalidateNodeAppearance(source);
249
+ }
250
+ // Moves the caret by one word in the given direction (negative = backward/left, positive = forward/right).
251
+ // Reuses the isWordChar boundary logic from word selection. When extendSelection is false, the anchor
252
+ // collapses to the new caret position; when true, the anchor stays and the selection extends.
253
+ export function moveTextInputCaretByWord(source, direction, extendSelection = false) {
254
+ const caretIndex = getTextInputCaretIndex(source);
255
+ const text = source.data.text;
256
+ let target;
257
+ if (direction < 0) {
258
+ target = findWordStartBefore(text, caretIndex);
259
+ }
260
+ else {
261
+ target = findWordEndAfter(text, caretIndex);
262
+ }
263
+ moveTextInputCaret(source, target, extendSelection);
264
+ }
265
+ // Moves the caret down one line, preserving the desired-x column for continuous vertical navigation.
266
+ // Requires the current layout to resolve the target character index. When layout is absent, falls back
267
+ // to moving to the end of the text (matching the "no layout" degenerate case).
268
+ export function moveTextInputCaretDown(source, layout, extendSelection = false) {
269
+ if (layout === null || layout === undefined) {
270
+ moveTextInputCaret(source, source.data.text.length, extendSelection);
271
+ return;
272
+ }
273
+ const state = getInputState(source);
274
+ const out = scratchRect;
275
+ getTextInputCaretRectangle(out, source, layout);
276
+ if (state.desiredCaretX === DESIRED_CARET_X_UNSET)
277
+ state.desiredCaretX = out.x;
278
+ const targetLineIndex = out.lineIndex + 1;
279
+ if (targetLineIndex >= layout.numLines) {
280
+ moveTextInputCaret(source, source.data.text.length, extendSelection);
281
+ return;
282
+ }
283
+ const targetY = getLineOffsetY(layout, targetLineIndex) + layout.lineHeights[targetLineIndex] / 2;
284
+ const targetIndex = getTextInputCharacterIndexAtPoint(source, layout, state.desiredCaretX, targetY);
285
+ const newCaret = clampIndex(targetIndex, source.data.text.length);
286
+ state.caretIndex = newCaret;
287
+ if (!extendSelection)
288
+ state.selectionIndex = newCaret;
289
+ // Preserve desiredCaretX across vertical steps (do not reset it).
290
+ invalidateNodeAppearance(source);
291
+ }
292
+ // Moves the caret to the end of the current line using layout. Falls back to the end of the text
293
+ // when layout is absent. In single-line fields "current line" is always the only line.
294
+ export function moveTextInputCaretToLineEnd(source, layout, extendSelection = false) {
295
+ if (layout === null || layout === undefined) {
296
+ moveTextInputCaret(source, source.data.text.length, extendSelection);
297
+ return;
298
+ }
299
+ const lineIndex = getCaretLineIndex(source, layout);
300
+ const lineEnd = getLineEndIndex(layout, lineIndex, source.data.text.length);
301
+ moveTextInputCaret(source, lineEnd, extendSelection);
302
+ }
303
+ // Moves the caret to the start of the current line using layout. Falls back to the start of the
304
+ // text when layout is absent. In single-line fields "current line" is always the only line.
305
+ export function moveTextInputCaretToLineStart(source, layout, extendSelection = false) {
306
+ if (layout === null || layout === undefined) {
307
+ moveTextInputCaret(source, 0, extendSelection);
308
+ return;
309
+ }
310
+ const lineIndex = getCaretLineIndex(source, layout);
311
+ const lineStart = getLineStartIndex(layout, lineIndex);
312
+ moveTextInputCaret(source, lineStart, extendSelection);
313
+ }
314
+ // Moves the caret up one line, preserving the desired-x column for continuous vertical navigation.
315
+ // Requires the current layout to resolve the target character index. When layout is absent, falls back
316
+ // to moving to the beginning of the text.
317
+ export function moveTextInputCaretUp(source, layout, extendSelection = false) {
318
+ if (layout === null || layout === undefined) {
319
+ moveTextInputCaret(source, 0, extendSelection);
320
+ return;
321
+ }
322
+ const state = getInputState(source);
323
+ const out = scratchRect;
324
+ getTextInputCaretRectangle(out, source, layout);
325
+ if (state.desiredCaretX === DESIRED_CARET_X_UNSET)
326
+ state.desiredCaretX = out.x;
327
+ const targetLineIndex = out.lineIndex - 1;
328
+ if (targetLineIndex < 0) {
329
+ moveTextInputCaret(source, 0, extendSelection);
330
+ return;
331
+ }
332
+ const targetY = getLineOffsetY(layout, targetLineIndex) + layout.lineHeights[targetLineIndex] / 2;
333
+ const targetIndex = getTextInputCharacterIndexAtPoint(source, layout, state.desiredCaretX, targetY);
334
+ const newCaret = clampIndex(targetIndex, source.data.text.length);
335
+ state.caretIndex = newCaret;
336
+ if (!extendSelection)
337
+ state.selectionIndex = newCaret;
338
+ // Preserve desiredCaretX across vertical steps (do not reset it).
339
+ invalidateNodeAppearance(source);
340
+ }
341
+ // Reapplies the next edit record in the history, moving the history cursor forward.
342
+ // Does nothing when there is nothing to redo (canRedoTextInput is false).
343
+ export function redoTextInput(source) {
344
+ const state = getInputState(source);
345
+ if (!canRedoTextInput(source))
346
+ return;
347
+ state.historyIndex++;
348
+ const record = state.history[state.historyIndex];
349
+ applyHistoryRecord(source, state, record.textAfter, record.caretIndexAfter, record.selectionIndexAfter);
350
+ }
351
+ export function replaceSelectedTextInput(source, text, options) {
352
+ replaceTextInput(source, getTextInputSelectionBeginIndex(source), getTextInputSelectionEndIndex(source), text, options);
353
+ }
354
+ export function replaceTextInput(source, beginIndex, endIndex, text, options) {
355
+ const data = source.data;
356
+ let start = clampIndex(beginIndex, data.text.length);
357
+ let end = clampIndex(endIndex, data.text.length);
358
+ if (end < start) {
359
+ const swap = start;
360
+ start = end;
361
+ end = swap;
362
+ }
363
+ const value = options?.applyInputRules === true ? applyTextInputRestriction(source, text, end - start) : text;
364
+ if (value.length === 0 && start === end)
365
+ return;
366
+ const state = getInputState(source);
367
+ const textBefore = data.text;
368
+ const caretBefore = clampIndex(state.caretIndex, textBefore.length);
369
+ const selectionBefore = clampIndex(state.selectionIndex, textBefore.length);
370
+ data.text = textBefore.slice(0, start) + value + textBefore.slice(end);
371
+ adjustTextFormatRanges(data.textFormatRanges, data.defaultTextFormat, start, end, value.length);
372
+ // An edit changes the caret position, so any stored desired-x column is no longer valid.
373
+ state.desiredCaretX = DESIRED_CARET_X_UNSET;
374
+ setTextInputSelection(source, start + value.length, start + value.length);
375
+ if (options?.skipHistory !== true && state.historyLimit > 0) {
376
+ recordTextInputEdit(state, textBefore, data.text, caretBefore, selectionBefore, options?.mergeKind ?? null);
377
+ }
378
+ invalidateNodeAppearance(source);
379
+ }
380
+ // Scrolls the text field so the caret is visible within the given viewport dimensions. Uses the
381
+ // field's layout to locate the caret rectangle, then adjusts scrollV (vertical) and scrollH
382
+ // (horizontal) as needed to bring the caret into the visible region with a small margin.
383
+ // Pass the layout used to render this field and the display dimensions of the field's visible area.
384
+ export function scrollTextInputCaretIntoView(source, layout, viewportWidth, viewportHeight) {
385
+ const out = scratchRect;
386
+ getTextInputCaretRectangle(out, source, layout);
387
+ // Vertical scroll (line-based: scrollV is 1-based line number).
388
+ const caretTop = out.y;
389
+ const caretBottom = out.y + out.height;
390
+ // Compute the pixel offset of the current scrollV (0-based line index = scrollV - 1).
391
+ const scrollVLine = (source.data.scrollV ?? 1) - 1;
392
+ let viewTop = 0;
393
+ for (let i = 0; i < scrollVLine; i++)
394
+ viewTop += layout.lineHeights[i] ?? 0;
395
+ const viewBottom = viewTop + viewportHeight;
396
+ if (caretTop < viewTop) {
397
+ // Caret is above the viewport: find the line that contains the caret and scroll to it.
398
+ setRichTextScrollV(source, out.lineIndex + 1, layout);
399
+ }
400
+ else if (caretBottom > viewBottom) {
401
+ // Caret is below the viewport: scroll down so the caret line is at the bottom.
402
+ let pixelOffset = 0;
403
+ let firstVisibleLine = 0;
404
+ for (let i = 0; i < layout.numLines; i++) {
405
+ if (pixelOffset + layout.lineHeights[i] > caretBottom - viewportHeight) {
406
+ firstVisibleLine = i;
407
+ break;
408
+ }
409
+ pixelOffset += layout.lineHeights[i];
410
+ }
411
+ setRichTextScrollV(source, firstVisibleLine + 1, layout);
412
+ }
413
+ // Horizontal scroll (pixel-based). Add a small margin so the caret is not right at the edge.
414
+ const CARET_SCROLL_MARGIN = 8;
415
+ const scrollH = source.data.scrollH ?? 0;
416
+ const caretLeft = out.x - scrollH;
417
+ const caretRight = caretLeft + out.width;
418
+ if (caretLeft < 0) {
419
+ setRichTextScrollH(source, Math.max(0, out.x - CARET_SCROLL_MARGIN), layout);
420
+ }
421
+ else if (caretRight + CARET_SCROLL_MARGIN > viewportWidth) {
422
+ setRichTextScrollH(source, out.x + out.width + CARET_SCROLL_MARGIN - viewportWidth, layout);
423
+ }
424
+ }
425
+ export function selectAllTextInput(source) {
426
+ setTextInputSelection(source, 0, source.data.text.length);
427
+ }
428
+ export function selectLineAtTextInputIndex(source, index) {
429
+ const text = source.data.text;
430
+ const clamped = Math.max(0, Math.min(text.length, index));
431
+ let start = clamped;
432
+ let end = clamped;
433
+ while (start > 0 && text.charAt(start - 1) !== '\n')
434
+ start--;
435
+ while (end < text.length && text.charAt(end) !== '\n')
436
+ end++;
437
+ setTextInputSelection(source, start, end);
438
+ }
439
+ export function selectWordAtTextInputIndex(source, index) {
440
+ const text = source.data.text;
441
+ const clamped = Math.max(0, Math.min(text.length, index));
442
+ let start = clamped;
443
+ let end = clamped;
444
+ while (start > 0 && isWordChar(text.charAt(start - 1)))
445
+ start--;
446
+ while (end < text.length && isWordChar(text.charAt(end)))
447
+ end++;
448
+ if (start === end) {
449
+ while (start > 0 && !isWordChar(text.charAt(start - 1)))
450
+ start--;
451
+ while (end < text.length && !isWordChar(text.charAt(end)))
452
+ end++;
453
+ }
454
+ setTextInputSelection(source, start, end);
455
+ }
456
+ export function setTextInputSelection(source, beginIndex, endIndex) {
457
+ const state = getInputState(source);
458
+ state.selectionIndex = clampIndex(beginIndex, source.data.text.length);
459
+ state.caretIndex = clampIndex(endIndex, source.data.text.length);
460
+ invalidateNodeAppearance(source);
461
+ }
462
+ // Restores the most-recent edit, moving the history cursor backward.
463
+ // Does nothing when there is nothing to undo (canUndoTextInput is false).
464
+ export function undoTextInput(source) {
465
+ const state = getInputState(source);
466
+ if (!canUndoTextInput(source))
467
+ return;
468
+ const record = state.history[state.historyIndex];
469
+ state.historyIndex--;
470
+ applyHistoryRecord(source, state, record.textBefore, record.caretIndexBefore, record.selectionIndexBefore);
471
+ }
472
+ function adjustTextFormatRanges(ranges, defaultFormat, beginIndex, endIndex, insertLength) {
473
+ const removeLength = endIndex - beginIndex;
474
+ const offset = insertLength - removeLength;
475
+ for (let i = 0; i < ranges.length; i++) {
476
+ const range = ranges[i];
477
+ if (beginIndex === endIndex) {
478
+ if (range.end < beginIndex) {
479
+ continue;
480
+ }
481
+ else if (range.start >= beginIndex) {
482
+ range.start += offset;
483
+ range.end += offset;
484
+ }
485
+ else if (range.start < beginIndex && range.end >= beginIndex) {
486
+ range.end += offset;
487
+ }
488
+ }
489
+ else if (range.end <= beginIndex) {
490
+ continue;
491
+ }
492
+ else if (range.start > endIndex) {
493
+ range.start += offset;
494
+ range.end += offset;
495
+ }
496
+ else if (range.start <= beginIndex && range.end > endIndex) {
497
+ range.end += offset;
498
+ }
499
+ else if (range.start >= beginIndex && range.end <= endIndex) {
500
+ ranges.splice(i--, 1);
501
+ }
502
+ else if (range.end > endIndex && range.start > beginIndex && range.start <= endIndex) {
503
+ range.start = beginIndex;
504
+ range.end += offset;
505
+ }
506
+ else if (range.start < beginIndex && range.end > beginIndex && range.end <= endIndex) {
507
+ range.end = beginIndex;
508
+ }
509
+ }
510
+ for (let i = ranges.length - 1; i >= 0; i--) {
511
+ if (ranges[i].start >= ranges[i].end)
512
+ ranges.splice(i, 1);
513
+ }
514
+ if (ranges.length === 0 && insertLength > 0) {
515
+ ranges.push({ end: beginIndex + insertLength, format: { ...defaultFormat }, start: beginIndex });
516
+ }
517
+ }
518
+ // Restores a recorded text/caret/selection snapshot onto the field. Used by undoTextInput and
519
+ // redoTextInput, which only differ in which side of the record (before/after) they restore.
520
+ // Sets the text directly without recording a new history entry, resets desiredCaretX, and invalidates.
521
+ function applyHistoryRecord(source, state, text, caretIndex, selectionIndex) {
522
+ source.data.text = text;
523
+ state.caretIndex = clampIndex(caretIndex, text.length);
524
+ state.selectionIndex = clampIndex(selectionIndex, text.length);
525
+ state.desiredCaretX = DESIRED_CARET_X_UNSET;
526
+ invalidateNodeAppearance(source);
527
+ }
528
+ function clampIndex(value, length) {
529
+ if (!Number.isFinite(value))
530
+ return 0;
531
+ return Math.max(0, Math.min(length, Math.trunc(value)));
532
+ }
533
+ // Returns the layout line index that the caret currently sits on. Reads the caret rectangle (which
534
+ // resolves the caret's layout group) and returns its lineIndex. Falls back to 0 for an empty layout.
535
+ function getCaretLineIndex(source, layout) {
536
+ const out = scratchRect;
537
+ getTextInputCaretRectangle(out, source, layout);
538
+ return out.lineIndex;
539
+ }
540
+ function getFallbackLineHeight(layout) {
541
+ return layout.lineHeights[0] ?? 12;
542
+ }
543
+ // Editing operates on the editable-input slot enableTextInput attaches; calling an editing function on
544
+ // a RichText that never enabled input is API misuse, so this throws rather than returning a sentinel.
545
+ function getInputState(source) {
546
+ const state = getTextInputState(source);
547
+ if (state === null)
548
+ throw new Error('text input is not enabled on this RichText; call enableTextInput first');
549
+ return state;
550
+ }
551
+ function getKeyboardCommand(data) {
552
+ if (data.ctrlKey || data.metaKey) {
553
+ const key = data.key.toLowerCase();
554
+ if (key === 'a' || data.keyCode === KeyCode.A)
555
+ return 'selectAll';
556
+ if (key === 'c' || data.keyCode === KeyCode.C)
557
+ return 'copy';
558
+ if (key === 'v' || data.keyCode === KeyCode.V)
559
+ return 'paste';
560
+ if (key === 'x' || data.keyCode === KeyCode.X)
561
+ return 'cut';
562
+ // Word-motion: Ctrl+Left / Ctrl+Right (Windows/Linux); Alt+Left / Alt+Right handled via altKey below.
563
+ if (data.keyCode === KeyCode.LEFT || data.key === 'ArrowLeft')
564
+ return 'wordLeft';
565
+ if (data.keyCode === KeyCode.RIGHT || data.key === 'ArrowRight')
566
+ return 'wordRight';
567
+ // Word-delete: Ctrl+Backspace / Ctrl+Delete.
568
+ if (data.keyCode === KeyCode.BACKSPACE || data.key === 'Backspace')
569
+ return 'deleteWordBackward';
570
+ if (data.keyCode === KeyCode.DELETE || data.key === 'Delete')
571
+ return 'deleteWordForward';
572
+ // Document-level Home/End: Ctrl+Home / Ctrl+End (Windows/Linux), Cmd+Home / Cmd+End (macOS).
573
+ if (data.keyCode === KeyCode.HOME || data.key === 'Home')
574
+ return 'documentStart';
575
+ if (data.keyCode === KeyCode.END || data.key === 'End')
576
+ return 'documentEnd';
577
+ return 'none';
578
+ }
579
+ // Alt+Left / Alt+Right: word motion on macOS.
580
+ if (data.altKey) {
581
+ if (data.keyCode === KeyCode.LEFT || data.key === 'ArrowLeft')
582
+ return 'wordLeft';
583
+ if (data.keyCode === KeyCode.RIGHT || data.key === 'ArrowRight')
584
+ return 'wordRight';
585
+ if (data.keyCode === KeyCode.BACKSPACE || data.key === 'Backspace')
586
+ return 'deleteWordBackward';
587
+ if (data.keyCode === KeyCode.DELETE || data.key === 'Delete')
588
+ return 'deleteWordForward';
589
+ }
590
+ if (data.keyCode === KeyCode.BACKSPACE || data.key === 'Backspace')
591
+ return 'backspace';
592
+ if (data.keyCode === KeyCode.DELETE || data.key === 'Delete')
593
+ return 'delete';
594
+ if (data.keyCode === KeyCode.DOWN || data.key === 'ArrowDown')
595
+ return 'down';
596
+ if (data.keyCode === KeyCode.END || data.key === 'End')
597
+ return 'end';
598
+ if (data.keyCode === KeyCode.HOME || data.key === 'Home')
599
+ return 'home';
600
+ if (data.keyCode === KeyCode.LEFT || data.key === 'ArrowLeft')
601
+ return 'left';
602
+ if (data.keyCode === KeyCode.RETURN || data.key === 'Enter')
603
+ return 'return';
604
+ if (data.keyCode === KeyCode.RIGHT || data.key === 'ArrowRight')
605
+ return 'right';
606
+ if (data.keyCode === KeyCode.UP || data.key === 'ArrowUp')
607
+ return 'up';
608
+ return 'none';
609
+ }
610
+ // Returns the character index at the end of the given layout line — the largest endIndex among the
611
+ // line's groups. Falls back to textLength when the line has no groups (e.g. a trailing empty line).
612
+ function getLineEndIndex(layout, lineIndex, textLength) {
613
+ let end = -1;
614
+ for (const group of layout.groups) {
615
+ if (group.lineIndex === lineIndex && group.endIndex > end)
616
+ end = group.endIndex;
617
+ }
618
+ return end < 0 ? textLength : end;
619
+ }
620
+ function getLineOffsetY(layout, lineIndex) {
621
+ for (const group of layout.groups) {
622
+ if (group.lineIndex === lineIndex)
623
+ return group.offsetY;
624
+ }
625
+ let y = TEXT_BOUNDS_GUTTER;
626
+ for (let i = 0; i < lineIndex; i++)
627
+ y += layout.lineHeights[i] ?? 0;
628
+ return y;
629
+ }
630
+ // Returns the character index at the start of the given layout line — the smallest startIndex among
631
+ // the line's groups. Falls back to 0 when the line has no groups.
632
+ function getLineStartIndex(layout, lineIndex) {
633
+ let start = -1;
634
+ for (const group of layout.groups) {
635
+ if (group.lineIndex === lineIndex && (start < 0 || group.startIndex < start))
636
+ start = group.startIndex;
637
+ }
638
+ return start < 0 ? 0 : start;
639
+ }
640
+ function getTextLayoutGroupAtIndex(layout, index) {
641
+ for (const group of layout.groups) {
642
+ if (index >= group.startIndex && index <= group.endIndex)
643
+ return group;
644
+ }
645
+ return layout.groups[layout.groups.length - 1] ?? null;
646
+ }
647
+ function getTextLayoutGroupCaretX(group, index) {
648
+ let x = group.offsetX;
649
+ const limit = Math.max(0, Math.min(index, group.endIndex) - group.startIndex);
650
+ for (let i = 0; i < limit; i++)
651
+ x += group.positions[i] ?? 0;
652
+ return x;
653
+ }
654
+ function getTextLayoutGroupCharacterIndexAtX(group, x) {
655
+ let currentX = group.offsetX;
656
+ for (let i = 0; i < group.positions.length; i++) {
657
+ const advance = group.positions[i] ?? 0;
658
+ if (x < currentX + advance / 2)
659
+ return group.startIndex + i;
660
+ currentX += advance;
661
+ }
662
+ return group.endIndex;
663
+ }
664
+ // Appends an edit record to the history. When the new edit shares a non-null mergeKind with the record
665
+ // the cursor currently points at, the two are coalesced (before from the existing record, after from
666
+ // the new one) so a run of same-kind keystrokes collapses into a single undo step. Any records ahead of
667
+ // the cursor (the redo tail) are discarded first, and the history is trimmed to historyLimit.
668
+ function recordTextInputEdit(state, textBefore, textAfter, caretIndexBefore, selectionIndexBefore, mergeKind) {
669
+ // Drop any redo tail: a fresh edit makes the previously-undone records unreachable.
670
+ if (state.historyIndex < state.history.length - 1) {
671
+ state.history.length = state.historyIndex + 1;
672
+ }
673
+ const previous = state.historyIndex >= 0 ? state.history[state.historyIndex] : undefined;
674
+ if (previous !== undefined && mergeKind !== null && previous.mergeKind === mergeKind) {
675
+ previous.textAfter = textAfter;
676
+ previous.caretIndexAfter = state.caretIndex;
677
+ previous.selectionIndexAfter = state.selectionIndex;
678
+ return;
679
+ }
680
+ state.history.push({
681
+ caretIndexAfter: state.caretIndex,
682
+ caretIndexBefore,
683
+ mergeKind,
684
+ selectionIndexAfter: state.selectionIndex,
685
+ selectionIndexBefore,
686
+ textAfter,
687
+ textBefore,
688
+ });
689
+ state.historyIndex = state.history.length - 1;
690
+ // Trim the oldest records past the limit, keeping the cursor pointed at the newest record.
691
+ if (state.history.length > state.historyLimit) {
692
+ const overflow = state.history.length - state.historyLimit;
693
+ state.history.splice(0, overflow);
694
+ state.historyIndex = state.history.length - 1;
695
+ }
696
+ }
697
+ function restrictTextInput(text, restrict) {
698
+ if (restrict.length === 0 || text.length === 0)
699
+ return text;
700
+ const { accepted, declined } = splitRestrictRanges(restrict);
701
+ let out = '';
702
+ for (const char of text) {
703
+ const acceptedMatch = accepted === '' || matchesRestrictRanges(char, accepted);
704
+ const declinedMatch = declined !== '' && matchesRestrictRanges(char, declined);
705
+ if (acceptedMatch && !declinedMatch)
706
+ out += char;
707
+ }
708
+ return out;
709
+ }
710
+ function matchesRestrictRanges(char, ranges) {
711
+ for (let i = 0; i < ranges.length; i++) {
712
+ const current = ranges.charAt(i);
713
+ if (current === '\\' && i + 1 < ranges.length) {
714
+ if (char === ranges.charAt(i + 1))
715
+ return true;
716
+ i++;
717
+ }
718
+ else if (i + 2 < ranges.length && ranges.charAt(i + 1) === '-') {
719
+ const end = ranges.charAt(i + 2);
720
+ const code = char.charCodeAt(0);
721
+ if (code >= current.charCodeAt(0) && code <= end.charCodeAt(0))
722
+ return true;
723
+ i += 2;
724
+ }
725
+ else if (char === current) {
726
+ return true;
727
+ }
728
+ }
729
+ return false;
730
+ }
731
+ function splitRestrictRanges(restrict) {
732
+ let accepted = '';
733
+ let declined = '';
734
+ let declining = false;
735
+ for (let i = 0; i < restrict.length; i++) {
736
+ const char = restrict.charAt(i);
737
+ if (char === '\\' && i + 1 < restrict.length) {
738
+ const escaped = char + restrict.charAt(i + 1);
739
+ if (declining)
740
+ declined += escaped;
741
+ else
742
+ accepted += escaped;
743
+ i++;
744
+ }
745
+ else if (char === '^') {
746
+ declining = !declining;
747
+ }
748
+ else if (declining) {
749
+ declined += char;
750
+ }
751
+ else {
752
+ accepted += char;
753
+ }
754
+ }
755
+ return { accepted, declined };
756
+ }
757
+ // Returns the index of the start of the word preceding `index` in `text`. Skips non-word chars first
758
+ // (to step out of whitespace/punctuation), then scans backward through word chars. Returns 0 if
759
+ // already at the beginning.
760
+ function findWordStartBefore(text, index) {
761
+ let i = index;
762
+ while (i > 0 && !isWordChar(text.charAt(i - 1)))
763
+ i--;
764
+ while (i > 0 && isWordChar(text.charAt(i - 1)))
765
+ i--;
766
+ return i;
767
+ }
768
+ // Returns the index of the end of the word following `index` in `text`. Skips non-word chars first,
769
+ // then scans forward through word chars. Returns text.length if already at the end.
770
+ function findWordEndAfter(text, index) {
771
+ let i = index;
772
+ while (i < text.length && !isWordChar(text.charAt(i)))
773
+ i++;
774
+ while (i < text.length && isWordChar(text.charAt(i)))
775
+ i++;
776
+ return i;
777
+ }
778
+ function isWordChar(char) {
779
+ return /\w/.test(char);
780
+ }
781
+ // TEXT_BOUNDS_GUTTER is imported from '@flighthq/textlayout' above.
782
+ // Scratch rectangle reused by vertical navigation to avoid allocating on every keystroke.
783
+ const scratchRect = {
784
+ height: 0,
785
+ lineIndex: 0,
786
+ width: 0,
787
+ x: 0,
788
+ y: 0,
789
+ };
790
+ //# sourceMappingURL=textInputEditing.js.map