swimlane/ngx-ui

View on GitHub
projects/swimlane/ngx-ui/src/lib/components/code-editor/code-editor.component.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import {
  Component,
  Input,
  Output,
  ViewChild,
  OnInit,
  Renderer2,
  EventEmitter,
  forwardRef,
  AfterViewInit,
  ViewEncapsulation,
  ChangeDetectionStrategy
} from '@angular/core';
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
import { coerceBooleanProperty } from '@angular/cdk/coercion';

import * as CodeMirror from 'codemirror';

// code extensions
import 'codemirror/mode/yaml/yaml.js';
import 'codemirror/mode/python/python.js';
import 'codemirror/mode/powershell/powershell.js';
import 'codemirror/mode/javascript/javascript.js';
import 'codemirror/mode/htmlmixed/htmlmixed.js';
import 'codemirror/mode/spreadsheet/spreadsheet.js';
import 'codemirror/mode/handlebars/handlebars.js';
import './mustache';

// add-ons
import 'codemirror/addon/lint/lint.js';
import 'codemirror/addon/search/search.js';
import 'codemirror/addon/search/searchcursor.js';
import 'codemirror/addon/search/jump-to-line.js';
import 'codemirror/addon/dialog/dialog.js';
import 'codemirror/addon/fold/foldcode.js';
import 'codemirror/addon/fold/foldgutter.js';
import 'codemirror/addon/fold/indent-fold.js';
import 'codemirror/addon/hint/show-hint.js';
import 'codemirror/addon/mode/overlay.js';

import { HintCompletion } from './hint-completion.interface';

const CODEMIRROR_VALUE_ACCESSOR = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => CodeEditorComponent),
  multi: true
};

@Component({
  exportAs: 'ngxCodemirror',
  selector: 'ngx-codemirror',
  providers: [CODEMIRROR_VALUE_ACCESSOR],
  host: { class: 'ngx-codemirror' },
  templateUrl: './code-editor.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  styleUrls: [
    './codemirror.css',
    './lint.css',
    './dialog.css',
    './foldgutter.css',
    './dracula.css',
    './hint.scss',
    './code-editor.component.scss'
  ]
})
export class CodeEditorComponent implements OnInit, AfterViewInit, ControlValueAccessor {
  @Input() config: any = { lineWrapping: true };
  @Input() theme = 'dracula';
  @Input() readOnly: string | boolean = false;
  @Input() allowDropFileTypes: string[] = [];
  @Input() gutters: Array<string | { className: string; style?: string }> = [];
  @Input() mode?: any;
  @Input() lint?: any;
  @Input() autocompleteTokens?: Array<string | HintCompletion>;

  @Input()
  get autofocus() {
    return this._autofocus;
  }
  set autofocus(autofocus: boolean) {
    this._autofocus = coerceBooleanProperty(autofocus);
  }

  @Input()
  get lineNumbers() {
    return this._lineNumbers;
  }
  set lineNumbers(lineNumbers: boolean) {
    this._lineNumbers = coerceBooleanProperty(lineNumbers);
  }

  @Output() change: EventEmitter<any> = new EventEmitter();
  @Output() blur: EventEmitter<any> = new EventEmitter();

  @ViewChild('host', { static: true }) host: any;
  @ViewChild('content', { static: true }) content: any;

  instance: CodeMirror.EditorFromTextArea;
  _value: string;

  get value(): string {
    return this._value;
  }
  set value(val: string) {
    if (val !== this._value) {
      this._value = val;
      this.onChangeCallback(val);
      this.change.emit(this._value);
    }
  }

  private _autofocus = false;
  private _lineNumbers = false;

  constructor(private readonly renderer: Renderer2) {}

  ngOnInit(): void {
    this.config = {
      theme: this.theme,
      readOnly: this.readOnly,
      mode: this.mode,
      autofocus: this.autofocus,
      lint: this.lint,
      allowDropFileTypes: this.allowDropFileTypes,
      lineNumbers: this.lineNumbers,
      gutters: this.gutters,
      extraKeys: {
        'Ctrl-Space': 'autocomplete'
      },
      ...this.config
    };

    if (this.autocompleteTokens) {
      this.config.hintOptions = this.config.hintOptions || {};
      this.config.hintOptions.hint = this.autocomplete.bind(this);
    }
  }

  ngAfterViewInit(): void {
    if (typeof this.value !== 'string') {
      const elm = this.content.nativeElement;
      const code = elm.innerHTML;

      for (const childNode of elm.childNodes) {
        this.renderer.removeChild(elm, childNode);
      }

      this.host.nativeElement.value = this.cleanCode(code);
    }

    this.instance = CodeMirror.fromTextArea(this.host.nativeElement, this.config);
    this.instance.on('change', this.onChange.bind(this));
    this.instance.on('blur', this.onBlur.bind(this));

    if (this.autocompleteTokens) {
      this.instance.on('keyup', this.onKeyUp.bind(this));
    }
  }

  cleanCode(code: string): string {
    let lines = code.split('\n');

    // Remove empty lines
    lines = lines.filter(function (line) {
      return line.trim().length > 0;
    });

    // don't mess w/ empties
    if (!lines.length) return '';

    // Make it so each line starts at 0 whitespace
    const firstLineWhitespace = lines[0].match(/^\s*/)[0];
    // eslint-disable-next-line
    const startingWhitespaceRegex = new RegExp('^' + firstLineWhitespace);
    lines = lines.map(function (line) {
      return line
        .replace('=""', '') // remove empty values
        .replace(startingWhitespaceRegex, '')
        .replace(/\s+$/, '');
    });

    const codeToParse = lines
      .join('\n')
      .replace(/\{ \{/gi, '{{')
      .replace(/\} \}/gi, '}}')
      // replace with < and > to render HTML in angular 2
      .replace(/&lt;/gi, '<')
      .replace(/&gt;/gi, '>');

    return codeToParse;
  }

  onVisible(): void {
    // hidden on init will cause incorrect sizing
    this.instance.refresh();
  }

  onKeyUp(cm: CodeMirror.EditorFromTextArea, event: KeyboardEvent) {
    if ((!cm.state.completionActive && event.keyCode > 64 && event.keyCode < 91) || event.keyCode === 219) {
      (CodeMirror.commands as any).autocomplete(cm, null, { completeSingle: false });
    }
  }

  onChange() {
    this.updateValue(this.instance.getValue());
  }

  onBlur() {
    this.blur.emit(this.instance.getValue());
  }

  updateValue(value: string): void {
    this.value = value;
    this.onTouchedCallback();
    this.onChangeCallback(value);
    this.change.emit(value);
  }

  writeValue(val: string): void {
    if (val !== this.value && this.instance) {
      this._value = val;
      this.instance.setValue(this._value);
    }
  }

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

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

  private onTouchedCallback: () => void = () => {
    // placeholder
  };

  private onChangeCallback: (_: any) => void = () => {
    // placeholder
  };

  private autocomplete(editor: CodeMirror.EditorFromTextArea) {
    const word = /[\S$]+/;
    const cur = editor.getCursor();
    const curLine = editor.getLine(cur.line);
    const end = cur.ch;
    let start = end;

    while (start && word.test(curLine.charAt(start - 1))) {
      --start;
    }

    const curWord = start !== end && curLine.slice(start, end);
    const list = this.autocompleteTokens.filter((s: string | HintCompletion) => {
      s = typeof s === 'string' ? s : s.text;
      return s ? s.startsWith(curWord) : false;
    });

    return {
      list,
      from: CodeMirror.Pos(cur.line, start),
      to: CodeMirror.Pos(cur.line, end)
    };
  }
}