ngx-touch-keyboard 2.0.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,723 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ ElementRef,
5
+ EventEmitter,
6
+ HostListener,
7
+ OnInit,
8
+ Output,
9
+ ViewEncapsulation,
10
+ } from '@angular/core';
11
+
12
+ import { KeyboardDisplay, KeyboardLayout } from './layouts/layouts.type';
13
+ import { getDefaultDisplay, getDefaultLayout } from './layouts';
14
+
15
+ @Component({
16
+ selector: 'ngx-touch-keyboard',
17
+ templateUrl: './ngx-touch-keyboard.component.html',
18
+ styleUrls: ['./ngx-touch-keyboard.component.scss'],
19
+ encapsulation: ViewEncapsulation.None,
20
+ changeDetection: ChangeDetectionStrategy.OnPush,
21
+ })
22
+ export class NgxTouchKeyboardComponent implements OnInit {
23
+ layoutMode = 'text';
24
+ layoutName = 'default';
25
+ layout!: KeyboardLayout;
26
+ display!: KeyboardDisplay;
27
+ debug = false;
28
+
29
+ @Output() closePanel = new EventEmitter<void>();
30
+
31
+ private _activeButtonClass = 'active';
32
+ private _holdInteractionTimeout!: number;
33
+ private _holdTimeout!: number;
34
+ private _isMouseHold!: boolean;
35
+ private _caretPosition: number | null = null;
36
+ private _caretPositionEnd: number | null = null;
37
+ private _activeInputElement!: HTMLInputElement | HTMLTextAreaElement | null;
38
+
39
+ /**
40
+ * Constructor
41
+ */
42
+ constructor(private _elementRef: ElementRef<HTMLInputElement>) {}
43
+
44
+ // -----------------------------------------------------------------------------------------------------
45
+ // @ Decorated methods
46
+ // -----------------------------------------------------------------------------------------------------
47
+
48
+ /**
49
+ * On keyup
50
+ */
51
+ @HostListener('window:keyup', ['$event'])
52
+ handleKeyUp(event: KeyboardEvent): void {
53
+ if (event.isTrusted) {
54
+ this._caretEventHandler(event);
55
+ this._handleHighlightKeyUp(event);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * On keydown
61
+ */
62
+ @HostListener('window:keydown', ['$event'])
63
+ handleKeyDown(event: KeyboardEvent): void {
64
+ if (event.isTrusted) {
65
+ this._handleHighlightKeyDown(event);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * On pointerup (mouseup or touchend)
71
+ */
72
+ @HostListener('window:pointerup', ['$event'])
73
+ handleMouseUp(event: MouseEvent): void {
74
+ this._caretEventHandler(event);
75
+ }
76
+
77
+ /**
78
+ * On select
79
+ */
80
+ @HostListener('window:select', ['$event'])
81
+ handleSelect(event: Event): void {
82
+ this._caretEventHandler(event);
83
+ }
84
+
85
+ /**
86
+ * On selectionchange
87
+ */
88
+ @HostListener('window:selectionchange', ['$event'])
89
+ handleSelectionChange(event: Event): void {
90
+ this._caretEventHandler(event);
91
+ }
92
+
93
+ // -----------------------------------------------------------------------------------------------------
94
+ // @ Lifecycle hooks
95
+ // -----------------------------------------------------------------------------------------------------
96
+
97
+ /**
98
+ * On init
99
+ */
100
+ ngOnInit(): void {
101
+ this.layout = getDefaultLayout();
102
+ this.display = getDefaultDisplay();
103
+ }
104
+
105
+ // -----------------------------------------------------------------------------------------------------
106
+ // @ Public methods
107
+ // -----------------------------------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Set active input
111
+ *
112
+ * @param input Input native element
113
+ */
114
+ setActiveInput(input: HTMLInputElement | HTMLTextAreaElement): void {
115
+ this._activeInputElement = input;
116
+
117
+ /**
118
+ * Tracking keyboard layout
119
+ */
120
+ const inputMode = this._activeInputElement?.inputMode;
121
+ if (
122
+ inputMode &&
123
+ ['text', 'search', 'email', 'url', 'numeric', 'decimal', 'tel'].some(
124
+ (i) => i === inputMode
125
+ )
126
+ ) {
127
+ this.layoutMode = inputMode;
128
+ this.layoutName = 'default';
129
+ } else {
130
+ this.layoutMode = 'text';
131
+ this.layoutName = 'default';
132
+ }
133
+
134
+ if (this.debug) {
135
+ console.log('Layout:', `${this.layoutMode}_${this.layoutName}`);
136
+ }
137
+
138
+ /**
139
+ * we must ensure caretPosition doesn't persist once reactivated.
140
+ */
141
+ this._setCaretPosition(
142
+ this._activeInputElement.selectionStart,
143
+ this._activeInputElement.selectionEnd
144
+ );
145
+
146
+ if (this.debug) {
147
+ console.log(
148
+ 'Caret start at:',
149
+ this._caretPosition,
150
+ this._caretPositionEnd
151
+ );
152
+ }
153
+
154
+ // And set focus to input
155
+ this._focusActiveInput();
156
+ }
157
+
158
+ /**
159
+ * Check whether the button is a standard button
160
+ */
161
+ isStandardButton = (button: string) =>
162
+ button && !(button[0] === '{' && button[button.length - 1] === '}');
163
+
164
+ /**
165
+ * Retrieve button type
166
+ *
167
+ * @param button The button's layout name
168
+ * @return The button type
169
+ */
170
+ getButtonType(button: string): string {
171
+ return button.includes('{') && button.includes('}')
172
+ ? 'function-key'
173
+ : 'standard-key';
174
+ }
175
+
176
+ /**
177
+ * Adds default classes to a given button
178
+ *
179
+ * @param button The button's layout name
180
+ * @return The classes to be added to the button
181
+ */
182
+ getButtonClass(button: string): string {
183
+ const buttonTypeClass = this.getButtonType(button);
184
+ const buttonWithoutBraces = button.replace('{', '').replace('}', '');
185
+ let buttonNormalized = '';
186
+
187
+ if (buttonTypeClass !== 'standard-key')
188
+ buttonNormalized = `${buttonWithoutBraces}-key`;
189
+
190
+ return `${buttonTypeClass} ${buttonNormalized}`;
191
+ }
192
+
193
+ /**
194
+ * Returns the display (label) name for a given button
195
+ *
196
+ * @param button The button's layout name
197
+ * @return The display name to be show to the button
198
+ */
199
+ getButtonDisplayName(button: string): string {
200
+ return this.display[button] || button;
201
+ }
202
+
203
+ /**
204
+ * Handles clicks made to keyboard buttons
205
+ *
206
+ * @param button The button layout name.
207
+ * @param event The button event.
208
+ */
209
+ handleButtonClicked(button: string, e?: Event): void {
210
+ if (this.debug) {
211
+ console.log('Key pressed:', button);
212
+ }
213
+
214
+ if (button === '{shift}') {
215
+ this.layoutName = this.layoutName === 'default' ? 'shift' : 'default';
216
+ return;
217
+ } else if (button === '{abc}') {
218
+ this.layoutName = 'default';
219
+ return;
220
+ } else if (button === '{numbers}') {
221
+ this.layoutName = 'numbers';
222
+ return;
223
+ } else if (button === '{extends}') {
224
+ this.layoutName = 'extends';
225
+ return;
226
+ } else if (button === '{ent}') {
227
+ this.closePanel.emit();
228
+ return;
229
+ }
230
+
231
+ const commonParams: [number, number, boolean] = [
232
+ this._caretPosition || 0,
233
+ this._caretPositionEnd || 0,
234
+ true,
235
+ ];
236
+ let output = this._activeInputElement?.value || '';
237
+
238
+ if (button === '{backspace}' && output.length > 0) {
239
+ output = this._removeAt(output, ...commonParams);
240
+ } else if (button === '{space}') {
241
+ output = this._addStringAt(output, ' ', ...commonParams);
242
+ } else if (button === '{tab}') {
243
+ output = this._addStringAt(output, '\t', ...commonParams);
244
+ } else if (button === '{enter}') {
245
+ output = this._addStringAt(output, '\n', ...commonParams);
246
+ } else if (button === '{' || button === '}') {
247
+ output = this._addStringAt(output, button, ...commonParams);
248
+ } else if (!button.includes('{') && !button.includes('}')) {
249
+ output = this._addStringAt(output, button, ...commonParams);
250
+ }
251
+
252
+ if (this._activeInputElement) {
253
+ this._activeInputElement.value = output;
254
+
255
+ if (this.debug) {
256
+ console.log(
257
+ 'Caret at:',
258
+ this._caretPosition,
259
+ this._caretPositionEnd,
260
+ 'Button',
261
+ e
262
+ );
263
+ }
264
+ }
265
+
266
+ this._dispatchEvents(button);
267
+ }
268
+
269
+ /**
270
+ * Handles button mousedown
271
+ *
272
+ * @param button The button layout name.
273
+ * @param event The button event.
274
+ */
275
+ handleButtonMouseDown(button: string, e?: Event): void {
276
+ if (e) {
277
+ /**
278
+ * Handle event options
279
+ */
280
+ e.preventDefault();
281
+ e.stopPropagation();
282
+ }
283
+
284
+ /**
285
+ * Add active class
286
+ */
287
+ this._setActiveButton(button);
288
+
289
+ if (this._holdInteractionTimeout)
290
+ clearTimeout(this._holdInteractionTimeout);
291
+ if (this._holdTimeout) clearTimeout(this._holdTimeout);
292
+ this._isMouseHold = true;
293
+
294
+ /**
295
+ * Time to wait until a key hold is detected
296
+ */
297
+ this._holdTimeout = window.setTimeout(() => {
298
+ if (
299
+ this._isMouseHold &&
300
+ ((!button.includes('{') && !button.includes('}')) ||
301
+ button === '{backspace}' ||
302
+ button === '{space}')
303
+ ) {
304
+ if (this.debug) {
305
+ console.log('Button held:', button);
306
+ }
307
+ this.handleButtonHold(button);
308
+ }
309
+ clearTimeout(this._holdTimeout);
310
+ }, 500);
311
+ }
312
+
313
+ /**
314
+ * Handles button mouseup
315
+ *
316
+ * @param button The button layout name.
317
+ * @param event The button event.
318
+ */
319
+ handleButtonMouseUp(button: string, e?: Event): void {
320
+ if (e) {
321
+ /**
322
+ * Handle event options
323
+ */
324
+ e.preventDefault();
325
+ e.stopPropagation();
326
+ }
327
+
328
+ /**
329
+ * Remove active class
330
+ */
331
+ this._removeActiveButton();
332
+
333
+ this._isMouseHold = false;
334
+ if (this._holdInteractionTimeout)
335
+ clearTimeout(this._holdInteractionTimeout);
336
+ }
337
+
338
+ /**
339
+ * Handles button hold
340
+ */
341
+ handleButtonHold(button: string): void {
342
+ if (this._holdInteractionTimeout)
343
+ clearTimeout(this._holdInteractionTimeout);
344
+
345
+ /**
346
+ * Timeout dictating the speed of key hold iterations
347
+ */
348
+ this._holdInteractionTimeout = window.setTimeout(() => {
349
+ if (this._isMouseHold) {
350
+ this.handleButtonClicked(button);
351
+ this.handleButtonHold(button);
352
+ } else {
353
+ clearTimeout(this._holdInteractionTimeout);
354
+ }
355
+ }, 100);
356
+ }
357
+
358
+ // -----------------------------------------------------------------------------------------------------
359
+ // @ Private methods
360
+ // -----------------------------------------------------------------------------------------------------
361
+
362
+ /**
363
+ * Changes the internal caret position
364
+ *
365
+ * @private
366
+ * @param position The caret's start position
367
+ * @param positionEnd The caret's end position
368
+ */
369
+ private _setCaretPosition(
370
+ position: number | null,
371
+ endPosition = position
372
+ ): void {
373
+ this._caretPosition = position;
374
+ this._caretPositionEnd = endPosition;
375
+ }
376
+
377
+ /**
378
+ * Moves the cursor position by a given amount
379
+ *
380
+ * @private
381
+ * @param length Represents by how many characters the input should be moved
382
+ * @param minus Whether the cursor should be moved to the left or not.
383
+ */
384
+ private _updateCaretPos(length: number, minus = false) {
385
+ const newCaretPos = this._updateCaretPosAction(length, minus);
386
+ this._setCaretPosition(newCaretPos);
387
+ }
388
+
389
+ /**
390
+ * Action method of updateCaretPos
391
+ *
392
+ * @private
393
+ * @param length Represents by how many characters the input should be moved
394
+ * @param minus Whether the cursor should be moved to the left or not.
395
+ */
396
+ private _updateCaretPosAction(length: number, minus = false) {
397
+ let caretPosition = this._caretPosition;
398
+
399
+ if (caretPosition != null) {
400
+ if (minus) {
401
+ if (caretPosition > 0) caretPosition = caretPosition - length;
402
+ } else {
403
+ caretPosition = caretPosition + length;
404
+ }
405
+ }
406
+
407
+ return caretPosition;
408
+ }
409
+
410
+ /**
411
+ * Removes an amount of characters before a given position
412
+ *
413
+ * @private
414
+ * @param source The source input
415
+ * @param position The (cursor) position from where the characters should be removed
416
+ * @param moveCaret Whether to update input cursor
417
+ */
418
+ private _removeAt(
419
+ source: string,
420
+ position = source.length,
421
+ positionEnd = source.length,
422
+ moveCaret = false
423
+ ) {
424
+ if (position === 0 && positionEnd === 0) {
425
+ return source;
426
+ }
427
+
428
+ let output;
429
+
430
+ if (position === positionEnd) {
431
+ let prevTwoChars;
432
+ let emojiMatched;
433
+ const emojiMatchedReg = /([\uD800-\uDBFF][\uDC00-\uDFFF])/g;
434
+
435
+ /**
436
+ * Emojis are made out of two characters, so we must take a custom approach to trim them.
437
+ * For more info: https://mathiasbynens.be/notes/javascript-unicode
438
+ */
439
+ if (position && position >= 0) {
440
+ prevTwoChars = source.substring(position - 2, position);
441
+ emojiMatched = prevTwoChars.match(emojiMatchedReg);
442
+
443
+ if (emojiMatched) {
444
+ output = source.substr(0, position - 2) + source.substr(position);
445
+ if (moveCaret) this._updateCaretPos(2, true);
446
+ } else {
447
+ output = source.substr(0, position - 1) + source.substr(position);
448
+ if (moveCaret) this._updateCaretPos(1, true);
449
+ }
450
+ } else {
451
+ prevTwoChars = source.slice(-2);
452
+ emojiMatched = prevTwoChars.match(emojiMatchedReg);
453
+
454
+ if (emojiMatched) {
455
+ output = source.slice(0, -2);
456
+ if (moveCaret) this._updateCaretPos(2, true);
457
+ } else {
458
+ output = source.slice(0, -1);
459
+ if (moveCaret) this._updateCaretPos(1, true);
460
+ }
461
+ }
462
+ } else {
463
+ output = source.slice(0, position) + source.slice(positionEnd);
464
+ if (moveCaret) {
465
+ this._setCaretPosition(position);
466
+ }
467
+ }
468
+
469
+ return output;
470
+ }
471
+
472
+ /**
473
+ * Adds a string to the input at a given position
474
+ *
475
+ * @private
476
+ * @param source The source input
477
+ * @param str The string to add
478
+ * @param position The (cursor) position where the string should be added
479
+ * @param moveCaret Whether to update virtual-keyboard cursor
480
+ */
481
+ private _addStringAt(
482
+ source: string,
483
+ str: string,
484
+ position = source.length,
485
+ positionEnd = source.length,
486
+ moveCaret = false
487
+ ) {
488
+ let output;
489
+
490
+ if (!position && position !== 0) {
491
+ output = source + str;
492
+ } else {
493
+ output = [source.slice(0, position), str, source.slice(positionEnd)].join(
494
+ ''
495
+ );
496
+ if (moveCaret) this._updateCaretPos(str.length, false);
497
+ }
498
+
499
+ return output;
500
+ }
501
+
502
+ /**
503
+ * Method to dispatch necessary keyboard events to current input element.
504
+ * @see https://w3c.github.io/uievents/tools/key-event-viewer.html
505
+ *
506
+ * @param button
507
+ */
508
+ private _dispatchEvents(button: string) {
509
+ let key, code;
510
+ if (button.includes('{') && button.includes('}')) {
511
+ // Capitalize name
512
+ key = button.slice(1, button.length - 1).toLowerCase();
513
+ key = key.charAt(0).toUpperCase() + key.slice(1);
514
+ code = key;
515
+ } else {
516
+ key = button;
517
+ code = Number.isInteger(Number(button))
518
+ ? `Digit${button}`
519
+ : `Key${button.toUpperCase()}`;
520
+ }
521
+
522
+ const eventInit: KeyboardEventInit = {
523
+ bubbles: true,
524
+ cancelable: true,
525
+ shiftKey: this.layoutName == 'shift',
526
+ key: key,
527
+ code: code,
528
+ location: 0,
529
+ };
530
+
531
+ // Simulate all needed events on base element
532
+ this._activeInputElement?.dispatchEvent(
533
+ new KeyboardEvent('keydown', eventInit)
534
+ );
535
+ this._activeInputElement?.dispatchEvent(
536
+ new KeyboardEvent('keypress', eventInit)
537
+ );
538
+ this._activeInputElement?.dispatchEvent(
539
+ new Event('input', { bubbles: true })
540
+ );
541
+ this._activeInputElement?.dispatchEvent(
542
+ new KeyboardEvent('keyup', eventInit)
543
+ );
544
+
545
+ // And set focus to input
546
+ this._focusActiveInput();
547
+ }
548
+
549
+ /**
550
+ * Called when an event that warrants a cursor position update is triggered
551
+ *
552
+ * @private
553
+ * @param event
554
+ */
555
+ private _caretEventHandler(event: any) {
556
+ let targetTagName = '';
557
+ if (event.target.tagName) {
558
+ targetTagName = event.target.tagName.toLowerCase();
559
+ }
560
+
561
+ const isTextInput =
562
+ targetTagName === 'textarea' ||
563
+ (targetTagName === 'input' &&
564
+ ['text', 'search', 'email', 'password', 'url', 'tel'].includes(
565
+ event.target.type
566
+ ));
567
+
568
+ const isKeyboard =
569
+ event.target === this._elementRef.nativeElement ||
570
+ (event.target && this._elementRef.nativeElement.contains(event.target));
571
+
572
+ if (isTextInput && this._activeInputElement == event.target) {
573
+ /**
574
+ * Tracks current cursor position
575
+ * As keys are pressed, text will be added/removed at that position within the input.
576
+ */
577
+ this._setCaretPosition(
578
+ event.target.selectionStart,
579
+ event.target.selectionEnd
580
+ );
581
+
582
+ if (this.debug) {
583
+ console.log(
584
+ 'Caret at:',
585
+ this._caretPosition,
586
+ this._caretPositionEnd,
587
+ event && event.target.tagName.toLowerCase(),
588
+ event
589
+ );
590
+ }
591
+ } else if (
592
+ event.type === 'pointerup' &&
593
+ this._activeInputElement === document.activeElement
594
+ ) {
595
+ if (this._isMouseHold) {
596
+ this.handleButtonMouseUp('');
597
+ }
598
+ return;
599
+ } else if (!isKeyboard && event?.type !== 'selectionchange') {
600
+ /**
601
+ * we must ensure caretPosition doesn't persist once reactivated.
602
+ */
603
+ this._setCaretPosition(null);
604
+
605
+ if (this.debug) {
606
+ console.log(
607
+ `Caret position reset due to "${event?.type}" event`,
608
+ event
609
+ );
610
+ }
611
+
612
+ /**
613
+ * Close panel
614
+ */
615
+ this.closePanel.emit();
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Focus to input
621
+ *
622
+ * @private
623
+ */
624
+ private _focusActiveInput(): void {
625
+ this._activeInputElement?.focus();
626
+ this._activeInputElement?.setSelectionRange(
627
+ this._caretPosition,
628
+ this._caretPositionEnd
629
+ );
630
+ }
631
+
632
+ /**
633
+ * Handel highlight on key down
634
+ *
635
+ * @private
636
+ * @param event The KeyboardEvent
637
+ */
638
+ private _handleHighlightKeyDown(event: KeyboardEvent): void {
639
+ const buttonPressed = this._getKeyboardLayoutKey(event);
640
+ /**
641
+ * Add active class
642
+ */
643
+ this._setActiveButton(buttonPressed);
644
+ }
645
+
646
+ /**
647
+ * Handel highlight on key up
648
+ *
649
+ * @private
650
+ * @param event The KeyboardEvent
651
+ */
652
+ private _handleHighlightKeyUp(event: KeyboardEvent): void {
653
+ const buttonPressed = this._getKeyboardLayoutKey(event);
654
+ /**
655
+ * Remove active class
656
+ */
657
+ this._removeActiveButton(buttonPressed);
658
+ }
659
+
660
+ /**
661
+ * Transforms a KeyboardEvent's "key.code" string into a virtual-keyboard layout format
662
+ *
663
+ * @private
664
+ * @param event The KeyboardEvent
665
+ */
666
+ private _getKeyboardLayoutKey(event: KeyboardEvent) {
667
+ let output = '';
668
+ const keyId = event.code || event.key;
669
+
670
+ if (
671
+ keyId?.includes('Space') ||
672
+ keyId?.includes('Numpad') ||
673
+ keyId?.includes('Backspace') ||
674
+ keyId?.includes('CapsLock') ||
675
+ keyId?.includes('Meta')
676
+ ) {
677
+ output = `{${event.code}}` || '';
678
+ } else if (
679
+ keyId?.includes('Control') ||
680
+ keyId?.includes('Shift') ||
681
+ keyId?.includes('Alt')
682
+ ) {
683
+ output = `{${event.key}}` || '';
684
+ } else {
685
+ output = event.key || '';
686
+ }
687
+
688
+ return output.length > 1 ? output?.toLowerCase() : output;
689
+ }
690
+
691
+ /**
692
+ * Set active class in button
693
+ *
694
+ * @param buttonName
695
+ */
696
+ private _setActiveButton(buttonName: string): void {
697
+ const node = this._elementRef.nativeElement
698
+ .getElementsByTagName('button')
699
+ .namedItem(buttonName);
700
+ if (node && node.classList) {
701
+ node.classList.add(this._activeButtonClass);
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Remove active button
707
+ *
708
+ * @param buttonName
709
+ */
710
+ private _removeActiveButton(buttonName?: string): void {
711
+ const nodes = this._elementRef.nativeElement.getElementsByTagName('button');
712
+ if (buttonName) {
713
+ const node = nodes.namedItem(buttonName);
714
+ if (node && node.classList) {
715
+ node.classList.remove(this._activeButtonClass);
716
+ }
717
+ } else {
718
+ for (let i = 0; i < nodes.length; i++) {
719
+ nodes[i].classList.remove(this._activeButtonClass);
720
+ }
721
+ }
722
+ }
723
+ }