<!DOCTYPE html>
<html>

<head>
  <title>Masked Input Control</title>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <script src="https://unpkg.com/zone.js@0.6.21/dist/zone.js"></script>
  <script src="https://unpkg.com/reflect-metadata@0.1.9/Reflect.js"></script>
  <script src="https://unpkg.com/systemjs@0.19.41/dist/system.js"></script>
  <script src="https://unpkg.com/typescript@2.1.4/lib/typescript.js"></script>
  <script src="config.js"></script>
  <script>
    System.import('app')
      .catch(console.error.bind(console));
  </script>
</head>

<body>
  <my-app>
    loading...
  </my-app>
</body>

</html>
System.config({
  transpiler: 'typescript',
  typescriptOptions: {
    emitDecoratorMetadata: true,
    experimentalDecorators: true
  },
  map: {
    app: "./src",

    '@angular/core': 'npm:@angular/core@4.0.0/bundles/core.umd.js',
    '@angular/common': 'npm:@angular/common@4.0.0/bundles/common.umd.js',
    '@angular/compiler': 'npm:@angular/compiler@4.0.0/bundles/compiler.umd.js',
    '@angular/platform-browser': 'npm:@angular/platform-browser@4.0.0/bundles/platform-browser.umd.js',
    '@angular/platform-browser-dynamic': 'npm:@angular/platform-browser-dynamic@4.0.0/bundles/platform-browser-dynamic.umd.js',
    '@angular/http': 'npm:@angular/http@4.0.0/bundles/http.umd.js',
    '@angular/router': 'npm:@angular/router@4.0.0/bundles/router.umd.js',
    '@angular/forms': 'npm:@angular/forms@4.0.0/bundles/forms.umd.js',
    '@angular/animations': 'npm:@angular/animations@4.0.0/bundles/animations.umd.js',
    '@angular/core/testing': 'npm:@angular/core@4.0.0/bundles/core-testing.umd.js',
    '@angular/common/testing': 'npm:@angular/common/bundles@4.0.0/common-testing.umd.js',
    '@angular/compiler/testing': 'npm:@angular/compiler@4.0.0/bundles/compiler-testing.umd.js',
    '@angular/platform-browser/testing': 'npm:@angular/platform-browser@4.0.0/bundles/platform-browser-testing.umd.js',
    '@angular/platform-browser-dynamic/testing': 'npm:@angular/platform-browser-dynamic@4.0.0/bundles/platform-browser-dynamic-testing.umd.js',
    '@angular/http/testing': 'npm:@angular/http@4.0.0/bundles/http-testing.umd.js',
    '@angular/router/testing': 'npm:@angular/router@4.0.0/bundles/router-testing.umd.js',

    'rxjs': 'npm:rxjs@5.1.0',
    'lodash': 'npm:lodash@4.17.4/'
  },
  //packages defines our app package
  packages: {
    app: {
      main: './main.ts',
      defaultExtension: 'ts'

    },
    rxjs: {
      defaultExtension: 'js'
    },
    lodash: {
      defaultExtension: 'js'
    }
  },
  paths: {
    'npm:': 'https://unpkg.com/'
  }
});
//main entry point
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app';

platformBrowserDynamic().bootstrapModule(AppModule)
import { Component, NgModule, OnInit } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule, FormBuilder, ReactiveFormsModule, FormGroup, Validators } from '@angular/forms';
import { MaskedInputComponent } from './md-input.component.ts';

@Component({
  selector: 'my-app',
  template: `
<form [formGroup]="cardForm" (ngSubmit)="submitState(localState.value)" autocomplete="off">

  <md-input
    [title]="'Card Number'"
    formControlName="card"
    [mask]="cardMask"></md-input>

  <button class="btn">
    <span>Submit</span>
  </button>
</form>

<span *ngIf="cardForm.valid">Credit card number is valid</span>
<pre>Card control value: {{cardForm.value.card}}</pre>
  `
})
export class App implements OnInit {
  public cardForm: FormGroup;

  public cardMask = [/\d/, /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/];

  constructor(private _formBuilder: FormBuilder) {
  }

  public ngOnInit() {
    this.cardForm = this._formBuilder.group({
      card: ['', Validators.pattern(/^[0-9]{16}$/)]
    });

    this.cardForm.controls.card.setValue('1234567890123456');
  }

  public submitState(value: string) {
    console.log('submitState', value);
  }

}

@NgModule({
  imports: [ BrowserModule, FormsModule, ReactiveFormsModule ],
  declarations: [ App, MaskedInputComponent ],
  bootstrap: [ App ]
})
export class AppModule {}
import {Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild, ViewEncapsulation} from "@angular/core";
import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR} from "@angular/forms";

@Component({
    selector: 'md-input',
    template: `<div class="group">
              <input #mdInputEl class="spacer"
                     [formControl]="mdInput"/>
              <span class="highlight"></span>
              <span class="bar"></span>
              <label>{{title}}</label>
            </div>`,
    encapsulation: ViewEncapsulation.None,
    providers: [
        {
            provide: NG_VALUE_ACCESSOR,
            useExisting: forwardRef(() => MaskedInputComponent),
            multi: true,
        }
    ]
})
export class MaskedInputComponent implements ControlValueAccessor, OnInit {
    @ViewChild('mdInputEl') public mdInputEl: ElementRef;

    @Input() mask: any[];

    @Input() title: string;

    public mdInput = new FormControl();

    private _previousValue: string = '';

    private _previousPlaceholder: string = '';

    private _maxInputValue: number;

    private _currentCursorPosition: number;

    private readonly _placeholderChar: string = '_';

    public ngOnInit(): void {
        this._maxInputValue = this.mask.length;

        this.mdInput.valueChanges
            .subscribe((value: string) => {
                    if (!value || value === this._previousValue) {
                        return;
                    }

                    this._currentCursorPosition = this.mdInputEl.nativeElement.selectionEnd;

                    const placeholder = this._convertMaskToPlaceholder();

                    const values = this._conformValue(value, placeholder);

                    const adjustedCursorPosition = this._getCursorPosition(value, placeholder, values.conformed);

                    this.mdInputEl.nativeElement.value = values.conformed;
                    this.mdInputEl.nativeElement.setSelectionRange(
                        adjustedCursorPosition,
                        adjustedCursorPosition,
                        'none');

                    this._onChange(values.cleaned);

                    this._previousValue = values.conformed;
                    this._previousPlaceholder = placeholder;
                },
                (err) => console.warn(err)
            );
    }

    public writeValue(value: string): void {

        this._currentCursorPosition = this.mdInputEl.nativeElement.selectionEnd;

        
        
            const placeholder = this._convertMaskToPlaceholder();
        let    values = this._conformValue(value, placeholder);
            this.mdInputEl.nativeElement.value = values.conformed;
        
        this.mdInput.setValue(values.conformed);
    }


    public registerOnChange(fn: any): void {
        this._onChange = fn;
    }

    public registerOnTouched(fn: any): void {
        this._onTouched = fn;
    }

    private _onChange: Function = (_: any) => {
    }

    private _onTouched: Function = (_: any) => {
    }

    private _convertMaskToPlaceholder(): string {
        return this.mask.map((char) => {
            return (char instanceof RegExp) ? this._placeholderChar : char;
        }).join('');
    }


    private _conformValue(value: string, placeholder: string): { conformed: string, cleaned: string } {
        const editDistance = value.length - this._previousValue.length;
        const isAddition = editDistance > 0;
        const indexOfFirstChange = this._currentCursorPosition + (isAddition ? -editDistance : 0);
        const indexOfLastChange = indexOfFirstChange + Math.abs(editDistance);

        if (!isAddition) {
            let compensatingPlaceholderChars = '';

            for (let i = indexOfFirstChange; i < indexOfLastChange; i++) {
                if (placeholder[i] === this._placeholderChar) {
                    compensatingPlaceholderChars += this._placeholderChar;
                }
            }

            value =
                (value.slice(0, indexOfFirstChange) +
                compensatingPlaceholderChars +
                value.slice(indexOfFirstChange, value.length)
            );
        }

        const valueArr = value.split('');

        for (let i = value.length - 1; i >= 0; i--) {
            let char = value[i];

            if (char !== this._placeholderChar) {
                const shouldOffset = i >= indexOfFirstChange &&
                    this._previousValue.length === this._maxInputValue;

                if (char === placeholder[(shouldOffset) ? i - editDistance : i]) {
                    valueArr.splice(i, 1);
                }
            }
        }

        let conformedValue = '';
        let cleanedValue = '';

        placeholderLoop: for (let i = 0; i < placeholder.length; i++) {
            const charInPlaceholder = placeholder[i];

            if (charInPlaceholder === this._placeholderChar) {
                if (valueArr.length > 0) {
                    while (valueArr.length > 0) {
                        let valueChar = valueArr.shift();

                        if (valueChar === this._placeholderChar) {
                            conformedValue += this._placeholderChar;

                            continue placeholderLoop;
                        } else if (this.mask[i].test(valueChar)) {
                            conformedValue += valueChar;
                            cleanedValue += valueChar;

                            continue placeholderLoop;
                        }
                    }
                }

                conformedValue += placeholder.substr(i, placeholder.length);
                break;
            } else {
                conformedValue += charInPlaceholder;
            }
        }

        return {conformed: conformedValue, cleaned: cleanedValue};
    }

    private _getCursorPosition(value: string, placeholder: string, conformedValue: string): number {
        if (this._currentCursorPosition === 0) {
            return 0;
        }

        const editLength = value.length - this._previousValue.length;
        const isAddition = editLength > 0;
        const isFirstValue = this._previousValue.length === 0;
        const isPartialMultiCharEdit = editLength > 1 && !isAddition && !isFirstValue;

        if (isPartialMultiCharEdit) {
            return this._currentCursorPosition;
        }

        const possiblyHasRejectedChar = isAddition && (
            this._previousValue === conformedValue ||
            conformedValue === placeholder);

        let startingSearchIndex = 0;
        let trackRightCharacter;
        let targetChar;

        if (possiblyHasRejectedChar) {
            startingSearchIndex = this._currentCursorPosition - editLength;
        } else {
            const normalizedConformedValue = conformedValue.toLowerCase();
            const normalizedValue = value.toLowerCase();

            const leftHalfChars = normalizedValue.substr(0, this._currentCursorPosition).split('');

            const intersection = leftHalfChars.filter((char) => normalizedConformedValue.indexOf(char) !== -1);

            targetChar = intersection[intersection.length - 1];

            const previousLeftMaskChars = this._previousPlaceholder
                .substr(0, intersection.length)
                .split('')
                .filter((char) => char !== this._placeholderChar)
                .length;

            const leftMaskChars = placeholder
                .substr(0, intersection.length)
                .split('')
                .filter((char) => char !== this._placeholderChar)
                .length;

            const maskLengthChanged = leftMaskChars !== previousLeftMaskChars;

            const targetIsMaskMovingLeft = (
                this._previousPlaceholder[intersection.length - 1] !== undefined &&
                placeholder[intersection.length - 2] !== undefined &&
                this._previousPlaceholder[intersection.length - 1] !== this._placeholderChar &&
                this._previousPlaceholder[intersection.length - 1] !== placeholder[intersection.length - 1] &&
                this._previousPlaceholder[intersection.length - 1] === placeholder[intersection.length - 2]
            );

            if (!isAddition &&
                (maskLengthChanged || targetIsMaskMovingLeft) &&
                previousLeftMaskChars > 0 &&
                placeholder.indexOf(targetChar) > -1 &&
                value[this._currentCursorPosition] !== undefined) {
                trackRightCharacter = true;
                targetChar = value[this._currentCursorPosition];
            }

            const countTargetCharInIntersection = intersection.filter((char) => char === targetChar).length;

            const countTargetCharInPlaceholder = placeholder
                .substr(0, placeholder.indexOf(this._placeholderChar))
                .split('')
                .filter((char, index) => (
                    char === targetChar &&
                    value[index] !== char
                )).length;

            const requiredNumberOfMatches =
                (countTargetCharInPlaceholder + countTargetCharInIntersection + (trackRightCharacter ? 1 : 0));

            let numberOfEncounteredMatches = 0;
            for (let i = 0; i < conformedValue.length; i++) {
                const conformedValueChar = normalizedConformedValue[i];

                startingSearchIndex = i + 1;

                if (conformedValueChar === targetChar) {
                    numberOfEncounteredMatches++;
                }

                if (numberOfEncounteredMatches >= requiredNumberOfMatches) {
                    break;
                }
            }
        }

        if (isAddition) {
            let lastPlaceholderChar = startingSearchIndex;

            for (let i = startingSearchIndex; i <= placeholder.length; i++) {
                if (placeholder[i] === this._placeholderChar) {
                    lastPlaceholderChar = i;
                }

                if (placeholder[i] === this._placeholderChar || i === placeholder.length) {
                    return lastPlaceholderChar;
                }
            }
        } else {
            if (trackRightCharacter) {
                for (let i = startingSearchIndex - 1; i >= 0; i--) {
                    if (
                        conformedValue[i] === targetChar ||
                        i === 0
                    ) {
                        return i;
                    }
                }
            } else {
                for (let i = startingSearchIndex; i >= 0; i--) {
                    if (placeholder[i - 1] === this._placeholderChar || i === 0) {
                        return i;
                    }
                }
            }
        }
    }
}