javascriptangulartypescriptangular-directive

How to get the old state in a input by doing Undo (Ctrl + Z) when the value is formatted using a directive?


I'm creating a thousand separator directive where 1234 will be shown as 1,234 visually but behind the scene it will be 1234 only. So after everything is working expected. Problem is happening when I'm pressing Ctrl + Z. I'm giving 1 case below -

When form loads input field is BLANK. Then I type 123 then do Ctrl + Z works fine it resets back to BLANK. Now if I type 1234 my directive transforms it to 1,234 but now if I do Undo it doesn't do anything. Which in this case also I want to reset it to BLANK or whatever the state was previously like normally happens.

When I debugged the code I found that Ctrl + Z does try to make it BLANK but during that time the directive opposes it by again reperforming same operation which creates an image that it doesn't do anything. You can view my Stackblitz solution which I tried.

Directive code:

import { Directive, ElementRef, HostListener, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Directive({
  selector: '[thousandSeperator]',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => ThousandSeperatorDirective),
      multi: true,
    },
  ],
  standalone: true,
})
export class ThousandSeperatorDirective implements ControlValueAccessor {
  private onChange: (_: any) => void = () => {};
  private onTouched: () => void = () => {};
  constructor(private el: ElementRef) {}

  @HostListener('input', ['$event'])
  @HostListener('paste', ['$event'])
  @HostListener('blur', ['$event'])
  onInput(event: Event | ClipboardEvent) {
    const rawValue = (event.target as HTMLInputElement).value;
    const unformattedValue = rawValue.replace(/,/g, '');

    this.onChange(unformattedValue);

    if (rawValue) {
      const formattedValue = this.formatValue(unformattedValue);
      setTimeout(() => (this.el.nativeElement.value = formattedValue), 0);
    }
  }

  writeValue(value: any): void {
    if (value || value == 0) {
      this.el.nativeElement.value = this.formatValue(value);
    } else {
      this.el.nativeElement.value = '';
    }
  }

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

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

  private formatValue(value: string): string {
    const [integer, decimal] = value.toString().split('.'),
      HAS_DECIMAL_POINT = value.toString().includes('.');
    return (
      integer.replace(/,/g, '').replace(/\B(?=(\d{3})+(?!\d))/g, ',') +
      (decimal ? `.${decimal}` : !decimal && HAS_DECIMAL_POINT ? '.' : '')
    );
  }
}

HTML -

<input [formControl]="abc" thousandSeperator />

Solution

  • It seems that the browser overwrites the entire history of the variable when the event occurs and the value is updated via the input value property. I've tried several approaches to work around this behavior, but nothing worked, so I simply modified your directive to track changes and manually handle the ctrl + z and ctrl + y events. Here's the code:

        import { Directive, ElementRef, HostListener, forwardRef } from '@angular/core';
        import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
        
        @Directive({
          selector: '[thousandSeperator]',
          providers: [
            {
              provide: NG_VALUE_ACCESSOR,
              useExisting: forwardRef(() => ThousandSeperatorDirective),
              multi: true,
            },
          ],
          standalone: true,
        })
        export class ThousandSeperatorDirective implements ControlValueAccessor {
          private onChange: (_: any) => void = () => {};
          private onTouched: () => void = () => {};
          history: string[] = [];
          index: number = 0;
        
          constructor(private el: ElementRef) {
            this.updateHistory(''); // pushing the blank input (starting point)
          }
        
          @HostListener('keydown', ['$event'])
          onKeyDown(event: KeyboardEvent) {
            if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
              // overwrite the browser default behavior
              event.preventDefault();
              event.stopPropagation();
        
              const input = this.el.nativeElement as HTMLInputElement;
              const prevVal = this.undo();
        
              // we need to specify null because '' is also considered a falsy value
              if (prevVal != null) input.value = prevVal;
            } else if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
              // overwrite the browser default behavior
              event.preventDefault();
              event.stopPropagation();
        
              const input = this.el.nativeElement as HTMLInputElement;
        
              const nextVal = this.redo();
              if (nextVal) input.value = nextVal;
            }
          }
        
          @HostListener('input', ['$event'])
          @HostListener('paste', ['$event'])
          @HostListener('blur', ['$event'])
          onInput(event: InputEvent) {
            const target = event.target as HTMLInputElement;
            console.log(event);
        
            const rawValue = (event.target as HTMLInputElement).value;
            const unformattedValue = rawValue.replace(/,/g, '');
            this.updateHistory(unformattedValue);
            console.log(this.history);
            this.onChange(unformattedValue);
        
            if (rawValue) {
              const formattedValue = this.formatValue(unformattedValue);
              requestAnimationFrame(() => (target.value = formattedValue));
            }
          }
        
          // update history with index
          updateHistory(val: string) {
            this.history.splice(this.index + 1);
            this.history.push(val);
            this.index = this.history.length - 1;
          }
        
          undo(): string | null {
            if (this.index > 0) {
              this.index--;
              return this.history[this.index];
            }
            return this.history[0] || null;
          }
        
          redo() {
            const nextVal = this.history[this.index + 1];
            if (nextVal) {
              this.index++;
              return nextVal;
            }
            return null;
          }
        
          writeValue(value: any): void {
            if (value || value == 0) {
              this.el.nativeElement.value = this.formatValue(value);
            } else {
              this.el.nativeElement.value = '';
            }
          }
        
          registerOnChange(fn: any): void {
            this.onChange = fn;
          }
        
          registerOnTouched(fn: any): void {
            this.onTouched = fn;
          }
        
          private formatValue(value: string): string {
            const [integer, decimal] = value.toString().split('.'),
              HAS_DECIMAL_POINT = value.toString().includes('.');
            return (
              integer.replace(/,/g, '').replace(/\B(?=(\d{3})+(?!\d))/g, '.') +
              (decimal ? `.${decimal}` : !decimal && HAS_DECIMAL_POINT ? ',' : '')
            );
          }
        }