@nasser-sw/fabric 7.0.1-beta10 → 7.0.1-beta12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/index.js +177 -117
  2. package/dist/index.js.map +1 -1
  3. package/dist/index.min.js +1 -1
  4. package/dist/index.min.js.map +1 -1
  5. package/dist/index.min.mjs +1 -1
  6. package/dist/index.min.mjs.map +1 -1
  7. package/dist/index.mjs +177 -117
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/index.node.cjs +177 -117
  10. package/dist/index.node.cjs.map +1 -1
  11. package/dist/index.node.mjs +177 -117
  12. package/dist/index.node.mjs.map +1 -1
  13. package/dist/package.json.min.mjs +1 -1
  14. package/dist/package.json.mjs +1 -1
  15. package/dist/src/shapes/IText/ITextBehavior.d.ts.map +1 -1
  16. package/dist/src/shapes/IText/ITextBehavior.min.mjs +1 -1
  17. package/dist/src/shapes/IText/ITextBehavior.min.mjs.map +1 -1
  18. package/dist/src/shapes/IText/ITextBehavior.mjs +10 -0
  19. package/dist/src/shapes/IText/ITextBehavior.mjs.map +1 -1
  20. package/dist/src/shapes/IText/ITextKeyBehavior.d.ts.map +1 -1
  21. package/dist/src/shapes/IText/ITextKeyBehavior.min.mjs +1 -1
  22. package/dist/src/shapes/IText/ITextKeyBehavior.min.mjs.map +1 -1
  23. package/dist/src/shapes/IText/ITextKeyBehavior.mjs +162 -116
  24. package/dist/src/shapes/IText/ITextKeyBehavior.mjs.map +1 -1
  25. package/dist/src/shapes/Text/Text.d.ts.map +1 -1
  26. package/dist/src/shapes/Text/Text.min.mjs +1 -1
  27. package/dist/src/shapes/Text/Text.min.mjs.map +1 -1
  28. package/dist/src/shapes/Text/Text.mjs +4 -0
  29. package/dist/src/shapes/Text/Text.mjs.map +1 -1
  30. package/dist/src/shapes/Text/constants.d.ts +1 -1
  31. package/dist/src/shapes/Text/constants.d.ts.map +1 -1
  32. package/dist/src/shapes/Text/constants.min.mjs +1 -1
  33. package/dist/src/shapes/Text/constants.min.mjs.map +1 -1
  34. package/dist/src/shapes/Text/constants.mjs +1 -1
  35. package/dist/src/shapes/Text/constants.mjs.map +1 -1
  36. package/dist-extensions/src/shapes/IText/ITextBehavior.d.ts.map +1 -1
  37. package/dist-extensions/src/shapes/IText/ITextKeyBehavior.d.ts.map +1 -1
  38. package/dist-extensions/src/shapes/Text/Text.d.ts.map +1 -1
  39. package/dist-extensions/src/shapes/Text/constants.d.ts +1 -1
  40. package/dist-extensions/src/shapes/Text/constants.d.ts.map +1 -1
  41. package/fabric-test-editor.html +1 -1
  42. package/package.json +1 -1
  43. package/rtl-debug.html +745 -0
  44. package/src/shapes/IText/ITextBehavior.ts +11 -0
  45. package/src/shapes/IText/ITextKeyBehavior.ts +732 -691
  46. package/src/shapes/Text/Text.ts +4 -0
  47. package/src/shapes/Text/constants.ts +1 -0
@@ -1,691 +1,732 @@
1
- import { config } from '../../config';
2
- import { getFabricDocument, getEnv } from '../../env';
3
- import { capValue } from '../../util/misc/capValue';
4
- import type { ITextEvents } from './ITextBehavior';
5
- import { ITextBehavior } from './ITextBehavior';
6
- import type { TKeyMapIText } from './constants';
7
- import type { TOptions } from '../../typedefs';
8
- import type { TextProps, SerializedTextProps } from '../Text/Text';
9
- import { getDocumentFromElement } from '../../util/dom_misc';
10
- import { CHANGED, LEFT, RIGHT } from '../../constants';
11
- import type { IText } from './IText';
12
- import type { TextStyleDeclaration } from '../Text/StyledText';
13
-
14
- export abstract class ITextKeyBehavior<
15
- Props extends TOptions<TextProps> = Partial<TextProps>,
16
- SProps extends SerializedTextProps = SerializedTextProps,
17
- EventSpec extends ITextEvents = ITextEvents,
18
- > extends ITextBehavior<Props, SProps, EventSpec> {
19
- /**
20
- * For functionalities on keyDown
21
- * Map a special key to a function of the instance/prototype
22
- * If you need different behavior for ESC or TAB or arrows, you have to change
23
- * this map setting the name of a function that you build on the IText or
24
- * your prototype.
25
- * the map change will affect all Instances unless you need for only some text Instances
26
- * in that case you have to clone this object and assign your Instance.
27
- * this.keysMap = Object.assign({}, this.keysMap);
28
- * The function must be in IText.prototype.myFunction And will receive event as args[0]
29
- */
30
- declare keysMap: TKeyMapIText;
31
-
32
- declare keysMapRtl: TKeyMapIText;
33
-
34
- /**
35
- * For functionalities on keyUp + ctrl || cmd
36
- */
37
- declare ctrlKeysMapUp: TKeyMapIText;
38
-
39
- /**
40
- * For functionalities on keyDown + ctrl || cmd
41
- */
42
- declare ctrlKeysMapDown: TKeyMapIText;
43
-
44
- declare hiddenTextarea: HTMLTextAreaElement | null;
45
-
46
- /**
47
- * DOM container to append the hiddenTextarea.
48
- * An alternative to attaching to the document.body.
49
- * Useful to reduce laggish redraw of the full document.body tree and
50
- * also with modals event capturing that won't let the textarea take focus.
51
- * @type HTMLElement
52
- */
53
- declare hiddenTextareaContainer?: HTMLElement | null;
54
-
55
- declare private _clickHandlerInitialized: boolean;
56
- declare private _copyDone: boolean;
57
- declare private fromPaste: boolean;
58
-
59
- /**
60
- * Initializes hidden textarea (needed to bring up keyboard in iOS)
61
- */
62
- initHiddenTextarea() {
63
- const doc =
64
- (this.canvas && getDocumentFromElement(this.canvas.getElement())) ||
65
- getFabricDocument();
66
- const textarea = doc.createElement('textarea');
67
- Object.entries({
68
- autocapitalize: 'off',
69
- autocorrect: 'off',
70
- autocomplete: 'off',
71
- spellcheck: 'false',
72
- 'data-fabric': 'textarea',
73
- wrap: 'off',
74
- }).map(([attribute, value]) => textarea.setAttribute(attribute, value));
75
- const { top, left, fontSize } = this._calcTextareaPosition();
76
- // line-height: 1px; was removed from the style to fix this:
77
- // https://bugs.chromium.org/p/chromium/issues/detail?id=870966
78
- textarea.style.cssText = `position: absolute; top: ${top}; left: ${left}; z-index: -999; opacity: 0; width: 1px; height: 1px; font-size: 1px; padding-top: ${fontSize};`;
79
-
80
- (this.hiddenTextareaContainer || doc.body).appendChild(textarea);
81
-
82
- Object.entries({
83
- blur: 'blur',
84
- keydown: 'onKeyDown',
85
- keyup: 'onKeyUp',
86
- input: 'onInput',
87
- copy: 'copy',
88
- cut: 'copy',
89
- paste: 'paste',
90
- compositionstart: 'onCompositionStart',
91
- compositionupdate: 'onCompositionUpdate',
92
- compositionend: 'onCompositionEnd',
93
- } as Record<string, keyof this>).map(([eventName, handler]) =>
94
- textarea.addEventListener(
95
- eventName,
96
- (this[handler] as EventListener).bind(this),
97
- ),
98
- );
99
- this.hiddenTextarea = textarea;
100
- }
101
-
102
- /**
103
- * Override this method to customize cursor behavior on textbox blur
104
- */
105
- blur() {
106
- this.abortCursorAnimation();
107
- }
108
-
109
- /**
110
- * Handles keydown event
111
- * only used for arrows and combination of modifier keys.
112
- * @param {KeyboardEvent} e Event object
113
- */
114
- onKeyDown(e: KeyboardEvent) {
115
- if (!this.isEditing) {
116
- return;
117
- }
118
- const keyMap = this.direction === 'rtl' ? this.keysMapRtl : this.keysMap;
119
- if (e.keyCode in keyMap) {
120
- (this[keyMap[e.keyCode] as keyof this] as (arg: KeyboardEvent) => void)(
121
- e,
122
- );
123
- } else if (e.keyCode in this.ctrlKeysMapDown && (e.ctrlKey || e.metaKey)) {
124
- (
125
- this[this.ctrlKeysMapDown[e.keyCode] as keyof this] as (
126
- arg: KeyboardEvent,
127
- ) => void
128
- )(e);
129
- } else {
130
- return;
131
- }
132
- e.stopImmediatePropagation();
133
- e.preventDefault();
134
- if (e.keyCode >= 33 && e.keyCode <= 40) {
135
- // if i press an arrow key just update selection
136
- this.inCompositionMode = false;
137
- this.clearContextTop();
138
- this.renderCursorOrSelection();
139
- } else {
140
- this.canvas && this.canvas.requestRenderAll();
141
- }
142
- }
143
-
144
- /**
145
- * Handles keyup event
146
- * We handle KeyUp because ie11 and edge have difficulties copy/pasting
147
- * if a copy/cut event fired, keyup is dismissed
148
- * @param {KeyboardEvent} e Event object
149
- */
150
- onKeyUp(e: KeyboardEvent) {
151
- if (!this.isEditing || this._copyDone || this.inCompositionMode) {
152
- this._copyDone = false;
153
- return;
154
- }
155
- if (e.keyCode in this.ctrlKeysMapUp && (e.ctrlKey || e.metaKey)) {
156
- (
157
- this[this.ctrlKeysMapUp[e.keyCode] as keyof this] as (
158
- arg: KeyboardEvent,
159
- ) => void
160
- )(e);
161
- } else {
162
- return;
163
- }
164
- e.stopImmediatePropagation();
165
- e.preventDefault();
166
- this.canvas && this.canvas.requestRenderAll();
167
- }
168
-
169
- /**
170
- * Handles onInput event
171
- * @param {Event} e Event object
172
- */
173
- onInput(this: this & { hiddenTextarea: HTMLTextAreaElement }, e: Event) {
174
- const fromPaste = this.fromPaste;
175
- const { value, selectionStart, selectionEnd } = this.hiddenTextarea;
176
- this.fromPaste = false;
177
- e && e.stopPropagation();
178
- if (!this.isEditing) {
179
- return;
180
- }
181
- const updateAndFire = () => {
182
- this.updateFromTextArea();
183
- this.fire(CHANGED);
184
- if (this.canvas) {
185
- this.canvas.fire('text:changed', { target: this as unknown as IText });
186
- this.canvas.requestRenderAll();
187
- }
188
- };
189
- if (this.hiddenTextarea.value === '') {
190
- this.styles = {};
191
- updateAndFire();
192
- return;
193
- }
194
- // decisions about style changes.
195
- const nextText = this._splitTextIntoLines(value).graphemeText,
196
- charCount = this._text.length,
197
- nextCharCount = nextText.length,
198
- _selectionStart = this.selectionStart,
199
- _selectionEnd = this.selectionEnd,
200
- selection = _selectionStart !== _selectionEnd;
201
- let copiedStyle: TextStyleDeclaration[] | undefined,
202
- removedText,
203
- charDiff = nextCharCount - charCount,
204
- removeFrom,
205
- removeTo;
206
-
207
- const textareaSelection = this.fromStringToGraphemeSelection(
208
- selectionStart,
209
- selectionEnd,
210
- value,
211
- );
212
- const backDelete = _selectionStart > textareaSelection.selectionStart;
213
-
214
- if (selection) {
215
- removedText = this._text.slice(_selectionStart, _selectionEnd);
216
- charDiff += _selectionEnd - _selectionStart;
217
- } else if (nextCharCount < charCount) {
218
- if (backDelete) {
219
- removedText = this._text.slice(_selectionEnd + charDiff, _selectionEnd);
220
- } else {
221
- removedText = this._text.slice(
222
- _selectionStart,
223
- _selectionStart - charDiff,
224
- );
225
- }
226
- }
227
- const insertedText = nextText.slice(
228
- textareaSelection.selectionEnd - charDiff,
229
- textareaSelection.selectionEnd,
230
- );
231
- if (removedText && removedText.length) {
232
- if (insertedText.length) {
233
- // let's copy some style before deleting.
234
- // we want to copy the style before the cursor OR the style at the cursor if selection
235
- // is bigger than 0.
236
- copiedStyle = this.getSelectionStyles(
237
- _selectionStart,
238
- _selectionStart + 1,
239
- false,
240
- );
241
- // now duplicate the style one for each inserted text.
242
- copiedStyle = insertedText.map(
243
- () =>
244
- // this return an array of references, but that is fine since we are
245
- // copying the style later.
246
- copiedStyle![0],
247
- );
248
- }
249
- if (selection) {
250
- removeFrom = _selectionStart;
251
- removeTo = _selectionEnd;
252
- } else if (backDelete) {
253
- // detect differences between forwardDelete and backDelete
254
- removeFrom = _selectionEnd - removedText.length;
255
- removeTo = _selectionEnd;
256
- } else {
257
- removeFrom = _selectionEnd;
258
- removeTo = _selectionEnd + removedText.length;
259
- }
260
- this.removeStyleFromTo(removeFrom, removeTo);
261
- }
262
- if (insertedText.length) {
263
- const { copyPasteData } = getEnv();
264
- if (
265
- fromPaste &&
266
- insertedText.join('') === copyPasteData.copiedText &&
267
- !config.disableStyleCopyPaste
268
- ) {
269
- copiedStyle = copyPasteData.copiedTextStyle;
270
- }
271
- this.insertNewStyleBlock(insertedText, _selectionStart, copiedStyle);
272
- }
273
- updateAndFire();
274
- }
275
-
276
- /**
277
- * Composition start
278
- */
279
- onCompositionStart() {
280
- this.inCompositionMode = true;
281
- }
282
-
283
- /**
284
- * Composition end
285
- */
286
- onCompositionEnd() {
287
- this.inCompositionMode = false;
288
- }
289
-
290
- onCompositionUpdate({ target }: CompositionEvent) {
291
- const { selectionStart, selectionEnd } = target as HTMLTextAreaElement;
292
- this.compositionStart = selectionStart;
293
- this.compositionEnd = selectionEnd;
294
- this.updateTextareaPosition();
295
- }
296
-
297
- /**
298
- * Copies selected text
299
- */
300
- copy() {
301
- if (this.selectionStart === this.selectionEnd) {
302
- //do not cut-copy if no selection
303
- return;
304
- }
305
- const { copyPasteData } = getEnv();
306
- copyPasteData.copiedText = this.getSelectedText();
307
- if (!config.disableStyleCopyPaste) {
308
- copyPasteData.copiedTextStyle = this.getSelectionStyles(
309
- this.selectionStart,
310
- this.selectionEnd,
311
- true,
312
- );
313
- } else {
314
- copyPasteData.copiedTextStyle = undefined;
315
- }
316
- this._copyDone = true;
317
- }
318
-
319
- /**
320
- * Pastes text
321
- */
322
- paste() {
323
- this.fromPaste = true;
324
- }
325
-
326
- /**
327
- * Finds the width in pixels before the cursor on the same line
328
- * @private
329
- * @param {Number} lineIndex
330
- * @param {Number} charIndex
331
- * @return {Number} widthBeforeCursor width before cursor
332
- */
333
- _getWidthBeforeCursor(lineIndex: number, charIndex: number): number {
334
- let widthBeforeCursor = this._getLineLeftOffset(lineIndex),
335
- bound;
336
-
337
- if (charIndex > 0) {
338
- bound = this.__charBounds[lineIndex][charIndex - 1];
339
- widthBeforeCursor += bound.left + bound.width;
340
- }
341
- return widthBeforeCursor;
342
- }
343
-
344
- /**
345
- * Gets start offset of a selection
346
- * @param {KeyboardEvent} e Event object
347
- * @param {Boolean} isRight
348
- * @return {Number}
349
- */
350
- getDownCursorOffset(e: KeyboardEvent, isRight: boolean): number {
351
- const selectionProp = this._getSelectionForOffset(e, isRight),
352
- cursorLocation = this.get2DCursorLocation(selectionProp),
353
- lineIndex = cursorLocation.lineIndex;
354
- // if on last line, down cursor goes to end of line
355
- if (
356
- lineIndex === this._textLines.length - 1 ||
357
- e.metaKey ||
358
- e.keyCode === 34
359
- ) {
360
- // move to the end of a text
361
- return this._text.length - selectionProp;
362
- }
363
- const charIndex = cursorLocation.charIndex,
364
- widthBeforeCursor = this._getWidthBeforeCursor(lineIndex, charIndex),
365
- indexOnOtherLine = this._getIndexOnLine(lineIndex + 1, widthBeforeCursor),
366
- textAfterCursor = this._textLines[lineIndex].slice(charIndex);
367
- return (
368
- textAfterCursor.length +
369
- indexOnOtherLine +
370
- 1 +
371
- this.missingNewlineOffset(lineIndex)
372
- );
373
- }
374
-
375
- /**
376
- * private
377
- * Helps finding if the offset should be counted from Start or End
378
- * @param {KeyboardEvent} e Event object
379
- * @param {Boolean} isRight
380
- * @return {Number}
381
- */
382
- _getSelectionForOffset(e: KeyboardEvent, isRight: boolean): number {
383
- if (e.shiftKey && this.selectionStart !== this.selectionEnd && isRight) {
384
- return this.selectionEnd;
385
- } else {
386
- return this.selectionStart;
387
- }
388
- }
389
-
390
- /**
391
- * @param {KeyboardEvent} e Event object
392
- * @param {Boolean} isRight
393
- * @return {Number}
394
- */
395
- getUpCursorOffset(e: KeyboardEvent, isRight: boolean): number {
396
- const selectionProp = this._getSelectionForOffset(e, isRight),
397
- cursorLocation = this.get2DCursorLocation(selectionProp),
398
- lineIndex = cursorLocation.lineIndex;
399
- if (lineIndex === 0 || e.metaKey || e.keyCode === 33) {
400
- // if on first line, up cursor goes to start of line
401
- return -selectionProp;
402
- }
403
- const charIndex = cursorLocation.charIndex,
404
- widthBeforeCursor = this._getWidthBeforeCursor(lineIndex, charIndex),
405
- indexOnOtherLine = this._getIndexOnLine(lineIndex - 1, widthBeforeCursor),
406
- textBeforeCursor = this._textLines[lineIndex].slice(0, charIndex),
407
- missingNewlineOffset = this.missingNewlineOffset(lineIndex - 1);
408
- // return a negative offset
409
- return (
410
- -this._textLines[lineIndex - 1].length +
411
- indexOnOtherLine -
412
- textBeforeCursor.length +
413
- (1 - missingNewlineOffset)
414
- );
415
- }
416
-
417
- /**
418
- * for a given width it founds the matching character.
419
- * @private
420
- */
421
- _getIndexOnLine(lineIndex: number, width: number) {
422
- const line = this._textLines[lineIndex],
423
- lineLeftOffset = this._getLineLeftOffset(lineIndex);
424
- let widthOfCharsOnLine = lineLeftOffset,
425
- indexOnLine = 0,
426
- charWidth,
427
- foundMatch;
428
-
429
- for (let j = 0, jlen = line.length; j < jlen; j++) {
430
- charWidth = this.__charBounds[lineIndex][j].width;
431
- widthOfCharsOnLine += charWidth;
432
- if (widthOfCharsOnLine > width) {
433
- foundMatch = true;
434
- const leftEdge = widthOfCharsOnLine - charWidth,
435
- rightEdge = widthOfCharsOnLine,
436
- offsetFromLeftEdge = Math.abs(leftEdge - width),
437
- offsetFromRightEdge = Math.abs(rightEdge - width);
438
-
439
- indexOnLine = offsetFromRightEdge < offsetFromLeftEdge ? j : j - 1;
440
- break;
441
- }
442
- }
443
-
444
- // reached end
445
- if (!foundMatch) {
446
- indexOnLine = line.length - 1;
447
- }
448
-
449
- return indexOnLine;
450
- }
451
-
452
- /**
453
- * Moves cursor down
454
- * @param {KeyboardEvent} e Event object
455
- */
456
- moveCursorDown(e: KeyboardEvent) {
457
- if (
458
- this.selectionStart >= this._text.length &&
459
- this.selectionEnd >= this._text.length
460
- ) {
461
- return;
462
- }
463
- this._moveCursorUpOrDown('Down', e);
464
- }
465
-
466
- /**
467
- * Moves cursor up
468
- * @param {KeyboardEvent} e Event object
469
- */
470
- moveCursorUp(e: KeyboardEvent) {
471
- if (this.selectionStart === 0 && this.selectionEnd === 0) {
472
- return;
473
- }
474
- this._moveCursorUpOrDown('Up', e);
475
- }
476
-
477
- /**
478
- * Moves cursor up or down, fires the events
479
- * @param {String} direction 'Up' or 'Down'
480
- * @param {KeyboardEvent} e Event object
481
- */
482
- _moveCursorUpOrDown(direction: 'Up' | 'Down', e: KeyboardEvent) {
483
- const offset = this[`get${direction}CursorOffset`](
484
- e,
485
- this._selectionDirection === RIGHT,
486
- );
487
- if (e.shiftKey) {
488
- this.moveCursorWithShift(offset);
489
- } else {
490
- this.moveCursorWithoutShift(offset);
491
- }
492
- if (offset !== 0) {
493
- const max = this.text.length;
494
- this.selectionStart = capValue(0, this.selectionStart, max);
495
- this.selectionEnd = capValue(0, this.selectionEnd, max);
496
- // TODO fix: abort and init should be an alternative depending
497
- // on selectionStart/End being equal or different
498
- this.abortCursorAnimation();
499
- this.initDelayedCursor();
500
- this._fireSelectionChanged();
501
- this._updateTextarea();
502
- }
503
- }
504
-
505
- /**
506
- * Moves cursor with shift
507
- * @param {Number} offset
508
- */
509
- moveCursorWithShift(offset: number) {
510
- const newSelection =
511
- this._selectionDirection === LEFT
512
- ? this.selectionStart + offset
513
- : this.selectionEnd + offset;
514
- this.setSelectionStartEndWithShift(
515
- this.selectionStart,
516
- this.selectionEnd,
517
- newSelection,
518
- );
519
- return offset !== 0;
520
- }
521
-
522
- /**
523
- * Moves cursor up without shift
524
- * @param {Number} offset
525
- */
526
- moveCursorWithoutShift(offset: number) {
527
- if (offset < 0) {
528
- this.selectionStart += offset;
529
- this.selectionEnd = this.selectionStart;
530
- } else {
531
- this.selectionEnd += offset;
532
- this.selectionStart = this.selectionEnd;
533
- }
534
- return offset !== 0;
535
- }
536
-
537
- /**
538
- * Moves cursor left
539
- * @param {KeyboardEvent} e Event object
540
- */
541
- moveCursorLeft(e: KeyboardEvent) {
542
- if (this.selectionStart === 0 && this.selectionEnd === 0) {
543
- return;
544
- }
545
- this._moveCursorLeftOrRight('Left', e);
546
- }
547
-
548
- /**
549
- * @private
550
- * @return {Boolean} true if a change happened
551
- *
552
- * @todo refactor not to use method name composition
553
- */
554
- _move(
555
- e: KeyboardEvent,
556
- prop: 'selectionStart' | 'selectionEnd',
557
- direction: 'Left' | 'Right',
558
- ): boolean {
559
- let newValue: number | undefined;
560
- if (e.altKey) {
561
- newValue = this[`findWordBoundary${direction}`](this[prop]);
562
- } else if (e.metaKey || e.keyCode === 35 || e.keyCode === 36) {
563
- newValue = this[`findLineBoundary${direction}`](this[prop]);
564
- } else {
565
- this[prop] += direction === 'Left' ? -1 : 1;
566
- return true;
567
- }
568
- if (typeof newValue !== 'undefined' && this[prop] !== newValue) {
569
- this[prop] = newValue;
570
- return true;
571
- }
572
- return false;
573
- }
574
-
575
- /**
576
- * @private
577
- */
578
- _moveLeft(e: KeyboardEvent, prop: 'selectionStart' | 'selectionEnd') {
579
- return this._move(e, prop, 'Left');
580
- }
581
-
582
- /**
583
- * @private
584
- */
585
- _moveRight(e: KeyboardEvent, prop: 'selectionStart' | 'selectionEnd') {
586
- return this._move(e, prop, 'Right');
587
- }
588
-
589
- /**
590
- * Moves cursor left without keeping selection
591
- * @param {KeyboardEvent} e
592
- */
593
- moveCursorLeftWithoutShift(e: KeyboardEvent) {
594
- let change = true;
595
- this._selectionDirection = LEFT;
596
-
597
- // only move cursor when there is no selection,
598
- // otherwise we discard it, and leave cursor on same place
599
- if (
600
- this.selectionEnd === this.selectionStart &&
601
- this.selectionStart !== 0
602
- ) {
603
- change = this._moveLeft(e, 'selectionStart');
604
- }
605
- this.selectionEnd = this.selectionStart;
606
- return change;
607
- }
608
-
609
- /**
610
- * Moves cursor left while keeping selection
611
- * @param {KeyboardEvent} e
612
- */
613
- moveCursorLeftWithShift(e: KeyboardEvent) {
614
- if (
615
- this._selectionDirection === RIGHT &&
616
- this.selectionStart !== this.selectionEnd
617
- ) {
618
- return this._moveLeft(e, 'selectionEnd');
619
- } else if (this.selectionStart !== 0) {
620
- this._selectionDirection = LEFT;
621
- return this._moveLeft(e, 'selectionStart');
622
- }
623
- }
624
-
625
- /**
626
- * Moves cursor right
627
- * @param {KeyboardEvent} e Event object
628
- */
629
- moveCursorRight(e: KeyboardEvent) {
630
- if (
631
- this.selectionStart >= this._text.length &&
632
- this.selectionEnd >= this._text.length
633
- ) {
634
- return;
635
- }
636
- this._moveCursorLeftOrRight('Right', e);
637
- }
638
-
639
- /**
640
- * Moves cursor right or Left, fires event
641
- * @param {String} direction 'Left', 'Right'
642
- * @param {KeyboardEvent} e Event object
643
- */
644
- _moveCursorLeftOrRight(direction: 'Left' | 'Right', e: KeyboardEvent) {
645
- const actionName = `moveCursor${direction}${
646
- e.shiftKey ? 'WithShift' : 'WithoutShift'
647
- }` as const;
648
- this._currentCursorOpacity = 1;
649
- if (this[actionName](e)) {
650
- // TODO fix: abort and init should be an alternative depending
651
- // on selectionStart/End being equal or different
652
- this.abortCursorAnimation();
653
- this.initDelayedCursor();
654
- this._fireSelectionChanged();
655
- this._updateTextarea();
656
- }
657
- }
658
-
659
- /**
660
- * Moves cursor right while keeping selection
661
- * @param {KeyboardEvent} e
662
- */
663
- moveCursorRightWithShift(e: KeyboardEvent) {
664
- if (
665
- this._selectionDirection === LEFT &&
666
- this.selectionStart !== this.selectionEnd
667
- ) {
668
- return this._moveRight(e, 'selectionStart');
669
- } else if (this.selectionEnd !== this._text.length) {
670
- this._selectionDirection = RIGHT;
671
- return this._moveRight(e, 'selectionEnd');
672
- }
673
- }
674
-
675
- /**
676
- * Moves cursor right without keeping selection
677
- * @param {KeyboardEvent} e Event object
678
- */
679
- moveCursorRightWithoutShift(e: KeyboardEvent) {
680
- let changed = true;
681
- this._selectionDirection = RIGHT;
682
-
683
- if (this.selectionStart === this.selectionEnd) {
684
- changed = this._moveRight(e, 'selectionStart');
685
- this.selectionEnd = this.selectionStart;
686
- } else {
687
- this.selectionStart = this.selectionEnd;
688
- }
689
- return changed;
690
- }
691
- }
1
+ import { config } from '../../config';
2
+ import { getFabricDocument, getEnv } from '../../env';
3
+ import { capValue } from '../../util/misc/capValue';
4
+ import type { ITextEvents } from './ITextBehavior';
5
+ import { ITextBehavior } from './ITextBehavior';
6
+ import type { TKeyMapIText } from './constants';
7
+ import type { TOptions } from '../../typedefs';
8
+ import type { TextProps, SerializedTextProps } from '../Text/Text';
9
+ import { getDocumentFromElement } from '../../util/dom_misc';
10
+ import { CHANGED, LEFT, RIGHT } from '../../constants';
11
+ import type { IText } from './IText';
12
+ import type { TextStyleDeclaration } from '../Text/StyledText';
13
+
14
+ export abstract class ITextKeyBehavior<
15
+ Props extends TOptions<TextProps> = Partial<TextProps>,
16
+ SProps extends SerializedTextProps = SerializedTextProps,
17
+ EventSpec extends ITextEvents = ITextEvents,
18
+ > extends ITextBehavior<Props, SProps, EventSpec> {
19
+ /**
20
+ * For functionalities on keyDown
21
+ * Map a special key to a function of the instance/prototype
22
+ * If you need different behavior for ESC or TAB or arrows, you have to change
23
+ * this map setting the name of a function that you build on the IText or
24
+ * your prototype.
25
+ * the map change will affect all Instances unless you need for only some text Instances
26
+ * in that case you have to clone this object and assign your Instance.
27
+ * this.keysMap = Object.assign({}, this.keysMap);
28
+ * The function must be in IText.prototype.myFunction And will receive event as args[0]
29
+ */
30
+ declare keysMap: TKeyMapIText;
31
+
32
+ declare keysMapRtl: TKeyMapIText;
33
+
34
+ /**
35
+ * For functionalities on keyUp + ctrl || cmd
36
+ */
37
+ declare ctrlKeysMapUp: TKeyMapIText;
38
+
39
+ /**
40
+ * For functionalities on keyDown + ctrl || cmd
41
+ */
42
+ declare ctrlKeysMapDown: TKeyMapIText;
43
+
44
+ declare hiddenTextarea: HTMLTextAreaElement | null;
45
+
46
+ /**
47
+ * DOM container to append the hiddenTextarea.
48
+ * An alternative to attaching to the document.body.
49
+ * Useful to reduce laggish redraw of the full document.body tree and
50
+ * also with modals event capturing that won't let the textarea take focus.
51
+ * @type HTMLElement
52
+ */
53
+ declare hiddenTextareaContainer?: HTMLElement | null;
54
+
55
+ declare private _clickHandlerInitialized: boolean;
56
+ declare private _copyDone: boolean;
57
+ declare private fromPaste: boolean;
58
+
59
+ /**
60
+ * Initializes hidden textarea (needed to bring up keyboard in iOS)
61
+ */
62
+ initHiddenTextarea() {
63
+ const doc =
64
+ (this.canvas && getDocumentFromElement(this.canvas.getElement())) ||
65
+ getFabricDocument();
66
+ const textarea = doc.createElement('textarea');
67
+ Object.entries({
68
+ autocapitalize: 'off',
69
+ autocorrect: 'off',
70
+ autocomplete: 'off',
71
+ spellcheck: 'false',
72
+ 'data-fabric': 'textarea',
73
+ wrap: 'off',
74
+ }).map(([attribute, value]) => textarea.setAttribute(attribute, value));
75
+ const { top, left, fontSize } = this._calcTextareaPosition();
76
+ // line-height: 1px; was removed from the style to fix this:
77
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=870966
78
+ textarea.style.cssText = `position: absolute; top: ${top}; left: ${left}; z-index: -999; opacity: 0; width: 1px; height: 1px; font-size: 1px; padding-top: ${fontSize};`;
79
+
80
+ (this.hiddenTextareaContainer || doc.body).appendChild(textarea);
81
+
82
+ Object.entries({
83
+ blur: 'blur',
84
+ keydown: 'onKeyDown',
85
+ keyup: 'onKeyUp',
86
+ input: 'onInput',
87
+ copy: 'copy',
88
+ cut: 'copy',
89
+ paste: 'paste',
90
+ compositionstart: 'onCompositionStart',
91
+ compositionupdate: 'onCompositionUpdate',
92
+ compositionend: 'onCompositionEnd',
93
+ } as Record<string, keyof this>).map(([eventName, handler]) =>
94
+ textarea.addEventListener(
95
+ eventName,
96
+ (this[handler] as EventListener).bind(this),
97
+ ),
98
+ );
99
+ this.hiddenTextarea = textarea;
100
+ }
101
+
102
+ /**
103
+ * Override this method to customize cursor behavior on textbox blur
104
+ */
105
+ blur() {
106
+ this.abortCursorAnimation();
107
+ }
108
+
109
+ /**
110
+ * Handles keydown event
111
+ * only used for arrows and combination of modifier keys.
112
+ * @param {KeyboardEvent} e Event object
113
+ */
114
+ onKeyDown(e: KeyboardEvent) {
115
+ if (!this.isEditing) {
116
+ return;
117
+ }
118
+ const keyMap = this.direction === 'rtl' ? this.keysMapRtl : this.keysMap;
119
+ if (e.keyCode in keyMap) {
120
+ (this[keyMap[e.keyCode] as keyof this] as (arg: KeyboardEvent) => void)(
121
+ e,
122
+ );
123
+ } else if (e.keyCode in this.ctrlKeysMapDown && (e.ctrlKey || e.metaKey)) {
124
+ (
125
+ this[this.ctrlKeysMapDown[e.keyCode] as keyof this] as (
126
+ arg: KeyboardEvent,
127
+ ) => void
128
+ )(e);
129
+ } else {
130
+ return;
131
+ }
132
+ e.stopImmediatePropagation();
133
+ e.preventDefault();
134
+ if (e.keyCode >= 33 && e.keyCode <= 40) {
135
+ // if i press an arrow key just update selection
136
+ this.inCompositionMode = false;
137
+ this.clearContextTop();
138
+ this.renderCursorOrSelection();
139
+ } else {
140
+ this.canvas && this.canvas.requestRenderAll();
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Handles keyup event
146
+ * We handle KeyUp because ie11 and edge have difficulties copy/pasting
147
+ * if a copy/cut event fired, keyup is dismissed
148
+ * @param {KeyboardEvent} e Event object
149
+ */
150
+ onKeyUp(e: KeyboardEvent) {
151
+ if (!this.isEditing || this._copyDone || this.inCompositionMode) {
152
+ this._copyDone = false;
153
+ return;
154
+ }
155
+ if (e.keyCode in this.ctrlKeysMapUp && (e.ctrlKey || e.metaKey)) {
156
+ (
157
+ this[this.ctrlKeysMapUp[e.keyCode] as keyof this] as (
158
+ arg: KeyboardEvent,
159
+ ) => void
160
+ )(e);
161
+ } else {
162
+ return;
163
+ }
164
+ e.stopImmediatePropagation();
165
+ e.preventDefault();
166
+ this.canvas && this.canvas.requestRenderAll();
167
+ }
168
+
169
+ /**
170
+ * Handles onInput event
171
+ * @param {Event} e Event object
172
+ */
173
+ onInput(this: this & { hiddenTextarea: HTMLTextAreaElement }, e: Event) {
174
+ const fromPaste = this.fromPaste;
175
+ const { value, selectionStart, selectionEnd } = this.hiddenTextarea;
176
+ this.fromPaste = false;
177
+ e && e.stopPropagation();
178
+ if (!this.isEditing) {
179
+ return;
180
+ }
181
+
182
+ // Debug log to track the double keypress issue
183
+ console.log('🔤 onInput debug:', {
184
+ fabricText: this.text,
185
+ textareaValue: value,
186
+ fabricSelection: { start: this.selectionStart, end: this.selectionEnd },
187
+ textareaSelection: { start: selectionStart, end: selectionEnd },
188
+ fromPaste,
189
+ inComposition: this.inCompositionMode
190
+ });
191
+
192
+ // Immediate sync for simple character replacement - fix for double keypress issue
193
+ if (this.text !== value && !this.inCompositionMode) {
194
+ console.log('🔤 Immediate sync: fabric text differs from textarea, syncing immediately');
195
+ console.log('🔤 Before sync - fabric text:', this.text);
196
+ console.log('🔤 Before sync - textarea value:', value);
197
+ console.log('🔤 fromPaste:', fromPaste);
198
+
199
+ // Clear all relevant caches that might prevent visual updates
200
+ this.cursorOffsetCache = {};
201
+ (this as any)._browserWrapCache = null;
202
+ (this as any)._lastDimensionState = null;
203
+ this._forceClearCache = true;
204
+
205
+ console.log('🔤 Cleared all caches');
206
+
207
+ // Use the same logic as updateAndFire but immediately
208
+ this.updateFromTextArea();
209
+ this.fire(CHANGED);
210
+ if (this.canvas) {
211
+ this.canvas.fire('text:changed', { target: this as unknown as IText });
212
+ // ONLY use synchronous rendering to avoid race conditions
213
+ // Remove requestRenderAll() which queues for next animation frame
214
+ this.canvas.renderAll();
215
+ }
216
+
217
+ console.log('🔤 After updateFromTextArea - fabric text:', this.text);
218
+ console.log('🔤 Sync complete, caches cleared, synchronous render only');
219
+ return;
220
+ }
221
+
222
+ const updateAndFire = () => {
223
+ this.updateFromTextArea();
224
+ this.fire(CHANGED);
225
+ if (this.canvas) {
226
+ this.canvas.fire('text:changed', { target: this as unknown as IText });
227
+ this.canvas.requestRenderAll();
228
+ }
229
+ };
230
+ if (this.hiddenTextarea.value === '') {
231
+ this.styles = {};
232
+ updateAndFire();
233
+ return;
234
+ }
235
+ // decisions about style changes.
236
+ const nextText = this._splitTextIntoLines(value).graphemeText,
237
+ charCount = this._text.length,
238
+ nextCharCount = nextText.length,
239
+ _selectionStart = this.selectionStart,
240
+ _selectionEnd = this.selectionEnd,
241
+ selection = _selectionStart !== _selectionEnd;
242
+ let copiedStyle: TextStyleDeclaration[] | undefined,
243
+ removedText,
244
+ charDiff = nextCharCount - charCount,
245
+ removeFrom,
246
+ removeTo;
247
+
248
+ const textareaSelection = this.fromStringToGraphemeSelection(
249
+ selectionStart,
250
+ selectionEnd,
251
+ value,
252
+ );
253
+ const backDelete = _selectionStart > textareaSelection.selectionStart;
254
+
255
+ if (selection) {
256
+ removedText = this._text.slice(_selectionStart, _selectionEnd);
257
+ charDiff += _selectionEnd - _selectionStart;
258
+ } else if (nextCharCount < charCount) {
259
+ if (backDelete) {
260
+ removedText = this._text.slice(_selectionEnd + charDiff, _selectionEnd);
261
+ } else {
262
+ removedText = this._text.slice(
263
+ _selectionStart,
264
+ _selectionStart - charDiff,
265
+ );
266
+ }
267
+ }
268
+ const insertedText = nextText.slice(
269
+ textareaSelection.selectionEnd - charDiff,
270
+ textareaSelection.selectionEnd,
271
+ );
272
+ if (removedText && removedText.length) {
273
+ if (insertedText.length) {
274
+ // let's copy some style before deleting.
275
+ // we want to copy the style before the cursor OR the style at the cursor if selection
276
+ // is bigger than 0.
277
+ copiedStyle = this.getSelectionStyles(
278
+ _selectionStart,
279
+ _selectionStart + 1,
280
+ false,
281
+ );
282
+ // now duplicate the style one for each inserted text.
283
+ copiedStyle = insertedText.map(
284
+ () =>
285
+ // this return an array of references, but that is fine since we are
286
+ // copying the style later.
287
+ copiedStyle![0],
288
+ );
289
+ }
290
+ if (selection) {
291
+ removeFrom = _selectionStart;
292
+ removeTo = _selectionEnd;
293
+ } else if (backDelete) {
294
+ // detect differences between forwardDelete and backDelete
295
+ removeFrom = _selectionEnd - removedText.length;
296
+ removeTo = _selectionEnd;
297
+ } else {
298
+ removeFrom = _selectionEnd;
299
+ removeTo = _selectionEnd + removedText.length;
300
+ }
301
+ this.removeStyleFromTo(removeFrom, removeTo);
302
+ }
303
+ if (insertedText.length) {
304
+ const { copyPasteData } = getEnv();
305
+ if (
306
+ fromPaste &&
307
+ insertedText.join('') === copyPasteData.copiedText &&
308
+ !config.disableStyleCopyPaste
309
+ ) {
310
+ copiedStyle = copyPasteData.copiedTextStyle;
311
+ }
312
+ this.insertNewStyleBlock(insertedText, _selectionStart, copiedStyle);
313
+ }
314
+ updateAndFire();
315
+ }
316
+
317
+ /**
318
+ * Composition start
319
+ */
320
+ onCompositionStart() {
321
+ this.inCompositionMode = true;
322
+ }
323
+
324
+ /**
325
+ * Composition end
326
+ */
327
+ onCompositionEnd() {
328
+ this.inCompositionMode = false;
329
+ }
330
+
331
+ onCompositionUpdate({ target }: CompositionEvent) {
332
+ const { selectionStart, selectionEnd } = target as HTMLTextAreaElement;
333
+ this.compositionStart = selectionStart;
334
+ this.compositionEnd = selectionEnd;
335
+ this.updateTextareaPosition();
336
+ }
337
+
338
+ /**
339
+ * Copies selected text
340
+ */
341
+ copy() {
342
+ if (this.selectionStart === this.selectionEnd) {
343
+ //do not cut-copy if no selection
344
+ return;
345
+ }
346
+ const { copyPasteData } = getEnv();
347
+ copyPasteData.copiedText = this.getSelectedText();
348
+ if (!config.disableStyleCopyPaste) {
349
+ copyPasteData.copiedTextStyle = this.getSelectionStyles(
350
+ this.selectionStart,
351
+ this.selectionEnd,
352
+ true,
353
+ );
354
+ } else {
355
+ copyPasteData.copiedTextStyle = undefined;
356
+ }
357
+ this._copyDone = true;
358
+ }
359
+
360
+ /**
361
+ * Pastes text
362
+ */
363
+ paste() {
364
+ this.fromPaste = true;
365
+ }
366
+
367
+ /**
368
+ * Finds the width in pixels before the cursor on the same line
369
+ * @private
370
+ * @param {Number} lineIndex
371
+ * @param {Number} charIndex
372
+ * @return {Number} widthBeforeCursor width before cursor
373
+ */
374
+ _getWidthBeforeCursor(lineIndex: number, charIndex: number): number {
375
+ let widthBeforeCursor = this._getLineLeftOffset(lineIndex),
376
+ bound;
377
+
378
+ if (charIndex > 0) {
379
+ bound = this.__charBounds[lineIndex][charIndex - 1];
380
+ widthBeforeCursor += bound.left + bound.width;
381
+ }
382
+ return widthBeforeCursor;
383
+ }
384
+
385
+ /**
386
+ * Gets start offset of a selection
387
+ * @param {KeyboardEvent} e Event object
388
+ * @param {Boolean} isRight
389
+ * @return {Number}
390
+ */
391
+ getDownCursorOffset(e: KeyboardEvent, isRight: boolean): number {
392
+ const selectionProp = this._getSelectionForOffset(e, isRight),
393
+ cursorLocation = this.get2DCursorLocation(selectionProp),
394
+ lineIndex = cursorLocation.lineIndex;
395
+ // if on last line, down cursor goes to end of line
396
+ if (
397
+ lineIndex === this._textLines.length - 1 ||
398
+ e.metaKey ||
399
+ e.keyCode === 34
400
+ ) {
401
+ // move to the end of a text
402
+ return this._text.length - selectionProp;
403
+ }
404
+ const charIndex = cursorLocation.charIndex,
405
+ widthBeforeCursor = this._getWidthBeforeCursor(lineIndex, charIndex),
406
+ indexOnOtherLine = this._getIndexOnLine(lineIndex + 1, widthBeforeCursor),
407
+ textAfterCursor = this._textLines[lineIndex].slice(charIndex);
408
+ return (
409
+ textAfterCursor.length +
410
+ indexOnOtherLine +
411
+ 1 +
412
+ this.missingNewlineOffset(lineIndex)
413
+ );
414
+ }
415
+
416
+ /**
417
+ * private
418
+ * Helps finding if the offset should be counted from Start or End
419
+ * @param {KeyboardEvent} e Event object
420
+ * @param {Boolean} isRight
421
+ * @return {Number}
422
+ */
423
+ _getSelectionForOffset(e: KeyboardEvent, isRight: boolean): number {
424
+ if (e.shiftKey && this.selectionStart !== this.selectionEnd && isRight) {
425
+ return this.selectionEnd;
426
+ } else {
427
+ return this.selectionStart;
428
+ }
429
+ }
430
+
431
+ /**
432
+ * @param {KeyboardEvent} e Event object
433
+ * @param {Boolean} isRight
434
+ * @return {Number}
435
+ */
436
+ getUpCursorOffset(e: KeyboardEvent, isRight: boolean): number {
437
+ const selectionProp = this._getSelectionForOffset(e, isRight),
438
+ cursorLocation = this.get2DCursorLocation(selectionProp),
439
+ lineIndex = cursorLocation.lineIndex;
440
+ if (lineIndex === 0 || e.metaKey || e.keyCode === 33) {
441
+ // if on first line, up cursor goes to start of line
442
+ return -selectionProp;
443
+ }
444
+ const charIndex = cursorLocation.charIndex,
445
+ widthBeforeCursor = this._getWidthBeforeCursor(lineIndex, charIndex),
446
+ indexOnOtherLine = this._getIndexOnLine(lineIndex - 1, widthBeforeCursor),
447
+ textBeforeCursor = this._textLines[lineIndex].slice(0, charIndex),
448
+ missingNewlineOffset = this.missingNewlineOffset(lineIndex - 1);
449
+ // return a negative offset
450
+ return (
451
+ -this._textLines[lineIndex - 1].length +
452
+ indexOnOtherLine -
453
+ textBeforeCursor.length +
454
+ (1 - missingNewlineOffset)
455
+ );
456
+ }
457
+
458
+ /**
459
+ * for a given width it founds the matching character.
460
+ * @private
461
+ */
462
+ _getIndexOnLine(lineIndex: number, width: number) {
463
+ const line = this._textLines[lineIndex],
464
+ lineLeftOffset = this._getLineLeftOffset(lineIndex);
465
+ let widthOfCharsOnLine = lineLeftOffset,
466
+ indexOnLine = 0,
467
+ charWidth,
468
+ foundMatch;
469
+
470
+ for (let j = 0, jlen = line.length; j < jlen; j++) {
471
+ charWidth = this.__charBounds[lineIndex][j].width;
472
+ widthOfCharsOnLine += charWidth;
473
+ if (widthOfCharsOnLine > width) {
474
+ foundMatch = true;
475
+ const leftEdge = widthOfCharsOnLine - charWidth,
476
+ rightEdge = widthOfCharsOnLine,
477
+ offsetFromLeftEdge = Math.abs(leftEdge - width),
478
+ offsetFromRightEdge = Math.abs(rightEdge - width);
479
+
480
+ indexOnLine = offsetFromRightEdge < offsetFromLeftEdge ? j : j - 1;
481
+ break;
482
+ }
483
+ }
484
+
485
+ // reached end
486
+ if (!foundMatch) {
487
+ indexOnLine = line.length - 1;
488
+ }
489
+
490
+ return indexOnLine;
491
+ }
492
+
493
+ /**
494
+ * Moves cursor down
495
+ * @param {KeyboardEvent} e Event object
496
+ */
497
+ moveCursorDown(e: KeyboardEvent) {
498
+ if (
499
+ this.selectionStart >= this._text.length &&
500
+ this.selectionEnd >= this._text.length
501
+ ) {
502
+ return;
503
+ }
504
+ this._moveCursorUpOrDown('Down', e);
505
+ }
506
+
507
+ /**
508
+ * Moves cursor up
509
+ * @param {KeyboardEvent} e Event object
510
+ */
511
+ moveCursorUp(e: KeyboardEvent) {
512
+ if (this.selectionStart === 0 && this.selectionEnd === 0) {
513
+ return;
514
+ }
515
+ this._moveCursorUpOrDown('Up', e);
516
+ }
517
+
518
+ /**
519
+ * Moves cursor up or down, fires the events
520
+ * @param {String} direction 'Up' or 'Down'
521
+ * @param {KeyboardEvent} e Event object
522
+ */
523
+ _moveCursorUpOrDown(direction: 'Up' | 'Down', e: KeyboardEvent) {
524
+ const offset = this[`get${direction}CursorOffset`](
525
+ e,
526
+ this._selectionDirection === RIGHT,
527
+ );
528
+ if (e.shiftKey) {
529
+ this.moveCursorWithShift(offset);
530
+ } else {
531
+ this.moveCursorWithoutShift(offset);
532
+ }
533
+ if (offset !== 0) {
534
+ const max = this.text.length;
535
+ this.selectionStart = capValue(0, this.selectionStart, max);
536
+ this.selectionEnd = capValue(0, this.selectionEnd, max);
537
+ // TODO fix: abort and init should be an alternative depending
538
+ // on selectionStart/End being equal or different
539
+ this.abortCursorAnimation();
540
+ this.initDelayedCursor();
541
+ this._fireSelectionChanged();
542
+ this._updateTextarea();
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Moves cursor with shift
548
+ * @param {Number} offset
549
+ */
550
+ moveCursorWithShift(offset: number) {
551
+ const newSelection =
552
+ this._selectionDirection === LEFT
553
+ ? this.selectionStart + offset
554
+ : this.selectionEnd + offset;
555
+ this.setSelectionStartEndWithShift(
556
+ this.selectionStart,
557
+ this.selectionEnd,
558
+ newSelection,
559
+ );
560
+ return offset !== 0;
561
+ }
562
+
563
+ /**
564
+ * Moves cursor up without shift
565
+ * @param {Number} offset
566
+ */
567
+ moveCursorWithoutShift(offset: number) {
568
+ if (offset < 0) {
569
+ this.selectionStart += offset;
570
+ this.selectionEnd = this.selectionStart;
571
+ } else {
572
+ this.selectionEnd += offset;
573
+ this.selectionStart = this.selectionEnd;
574
+ }
575
+ return offset !== 0;
576
+ }
577
+
578
+ /**
579
+ * Moves cursor left
580
+ * @param {KeyboardEvent} e Event object
581
+ */
582
+ moveCursorLeft(e: KeyboardEvent) {
583
+ if (this.selectionStart === 0 && this.selectionEnd === 0) {
584
+ return;
585
+ }
586
+ this._moveCursorLeftOrRight('Left', e);
587
+ }
588
+
589
+ /**
590
+ * @private
591
+ * @return {Boolean} true if a change happened
592
+ *
593
+ * @todo refactor not to use method name composition
594
+ */
595
+ _move(
596
+ e: KeyboardEvent,
597
+ prop: 'selectionStart' | 'selectionEnd',
598
+ direction: 'Left' | 'Right',
599
+ ): boolean {
600
+ let newValue: number | undefined;
601
+ if (e.altKey) {
602
+ newValue = this[`findWordBoundary${direction}`](this[prop]);
603
+ } else if (e.metaKey || e.keyCode === 35 || e.keyCode === 36) {
604
+ newValue = this[`findLineBoundary${direction}`](this[prop]);
605
+ } else {
606
+ this[prop] += direction === 'Left' ? -1 : 1;
607
+ return true;
608
+ }
609
+ if (typeof newValue !== 'undefined' && this[prop] !== newValue) {
610
+ this[prop] = newValue;
611
+ return true;
612
+ }
613
+ return false;
614
+ }
615
+
616
+ /**
617
+ * @private
618
+ */
619
+ _moveLeft(e: KeyboardEvent, prop: 'selectionStart' | 'selectionEnd') {
620
+ return this._move(e, prop, 'Left');
621
+ }
622
+
623
+ /**
624
+ * @private
625
+ */
626
+ _moveRight(e: KeyboardEvent, prop: 'selectionStart' | 'selectionEnd') {
627
+ return this._move(e, prop, 'Right');
628
+ }
629
+
630
+ /**
631
+ * Moves cursor left without keeping selection
632
+ * @param {KeyboardEvent} e
633
+ */
634
+ moveCursorLeftWithoutShift(e: KeyboardEvent) {
635
+ let change = true;
636
+ this._selectionDirection = LEFT;
637
+
638
+ // only move cursor when there is no selection,
639
+ // otherwise we discard it, and leave cursor on same place
640
+ if (
641
+ this.selectionEnd === this.selectionStart &&
642
+ this.selectionStart !== 0
643
+ ) {
644
+ change = this._moveLeft(e, 'selectionStart');
645
+ }
646
+ this.selectionEnd = this.selectionStart;
647
+ return change;
648
+ }
649
+
650
+ /**
651
+ * Moves cursor left while keeping selection
652
+ * @param {KeyboardEvent} e
653
+ */
654
+ moveCursorLeftWithShift(e: KeyboardEvent) {
655
+ if (
656
+ this._selectionDirection === RIGHT &&
657
+ this.selectionStart !== this.selectionEnd
658
+ ) {
659
+ return this._moveLeft(e, 'selectionEnd');
660
+ } else if (this.selectionStart !== 0) {
661
+ this._selectionDirection = LEFT;
662
+ return this._moveLeft(e, 'selectionStart');
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Moves cursor right
668
+ * @param {KeyboardEvent} e Event object
669
+ */
670
+ moveCursorRight(e: KeyboardEvent) {
671
+ if (
672
+ this.selectionStart >= this._text.length &&
673
+ this.selectionEnd >= this._text.length
674
+ ) {
675
+ return;
676
+ }
677
+ this._moveCursorLeftOrRight('Right', e);
678
+ }
679
+
680
+ /**
681
+ * Moves cursor right or Left, fires event
682
+ * @param {String} direction 'Left', 'Right'
683
+ * @param {KeyboardEvent} e Event object
684
+ */
685
+ _moveCursorLeftOrRight(direction: 'Left' | 'Right', e: KeyboardEvent) {
686
+ const actionName = `moveCursor${direction}${
687
+ e.shiftKey ? 'WithShift' : 'WithoutShift'
688
+ }` as const;
689
+ this._currentCursorOpacity = 1;
690
+ if (this[actionName](e)) {
691
+ // TODO fix: abort and init should be an alternative depending
692
+ // on selectionStart/End being equal or different
693
+ this.abortCursorAnimation();
694
+ this.initDelayedCursor();
695
+ this._fireSelectionChanged();
696
+ this._updateTextarea();
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Moves cursor right while keeping selection
702
+ * @param {KeyboardEvent} e
703
+ */
704
+ moveCursorRightWithShift(e: KeyboardEvent) {
705
+ if (
706
+ this._selectionDirection === LEFT &&
707
+ this.selectionStart !== this.selectionEnd
708
+ ) {
709
+ return this._moveRight(e, 'selectionStart');
710
+ } else if (this.selectionEnd !== this._text.length) {
711
+ this._selectionDirection = RIGHT;
712
+ return this._moveRight(e, 'selectionEnd');
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Moves cursor right without keeping selection
718
+ * @param {KeyboardEvent} e Event object
719
+ */
720
+ moveCursorRightWithoutShift(e: KeyboardEvent) {
721
+ let changed = true;
722
+ this._selectionDirection = RIGHT;
723
+
724
+ if (this.selectionStart === this.selectionEnd) {
725
+ changed = this._moveRight(e, 'selectionStart');
726
+ this.selectionEnd = this.selectionStart;
727
+ } else {
728
+ this.selectionStart = this.selectionEnd;
729
+ }
730
+ return changed;
731
+ }
732
+ }