Eduardo
Eduardo

Reputation: 67

How I create a code editor with custom rules in Angular 13

my name is Edu and I'm creating a web IDE for RISCV Assembly, I never worked with code editor in angular, I need to create some sintax rules, anyone know some component or library that facilitate for me? I tried CodeMirror but have a lot of errors

Upvotes: -2

Views: 2741

Answers (2)

Saurabh Jha
Saurabh Jha

Reputation: 11

We can create a textarea as code editor with autocomplete and syntax highlighting feature. The textarea component needs an array input. This array input should hold the list of items on which you want autocomplete and color coding. every item can be highlighted with different color.

The input array should look like:

const item1: { 
   name: 'Avg',
   className: 'abc',// any custom class and declare the font color etc,
       subItem: {
        name: 'Avg Details',
        details: {
          description: 'It calculates the average'
        }

}
inputarray.push(item1)

So, when we type A, the autocomplete list would show this item and also when we choose it from autocomplete list or type it in text area, this text will take the css settings which we have defined in css class abc.

//  HTML code
<div class="row m-0 position-relative">
  <div #backdrop class="backdrop">
    <div #highlights class="highlights" [innerHTML]="highlightedSyntaxHtml" contenteditable="true"></div>
  </div>
  <textarea
    #textElem
    id="advancedConditionText"
    spellcheck="false"
    (scroll)="handleScroll()"
    [(ngModel)]="textAreaInput"
    rows="5"
    cols="30"
    (keyup)="onKeyUp($event)"
  ></textarea>

  <div
    #autoCompleteListContainer
    id="autoCompleteListContainer"
    class="row position-absolute"
    *ngIf="showSuggestionList"
  >
    <ul id="autoCompleteList" class="col-5 ms-2 suggestionsList" [ngStyle]="autoCompleteStyle">
      <ng-container *ngFor="let autoCompleteSet of this.filteredAutoCompleteListBasedOnType | keyvalue">
        <li *ngIf="autoCompleteSet.value?.length && autoCompleteSet.key !== 'noType'">
          <h4 class="my-1 text-decoration-underline">{{ autoCompleteSet.key }}</h4>
        </li>
        <li
          id="suggestionList"
          class="cursor-pointer"
          *ngFor="let suggestion of autoCompleteSet.value"
          (click)="selectSuggestion(suggestion)"
        >
          <em  *ngIf="suggestion.iconClass" [ngClass]="suggestion.iconClass" class="float-start pe-3 png-icon-micro iconPos" aria-hidden="true"></em>
          {{ suggestion.name }}
          <em
*ngIf="suggestion.subItem?.name"
class="float-end pe-3 png-icon-micro png-alert-info-solid iconPos"
(click)="showSuggestionPanel($event, suggestion)"
aria-hidden="true"
          ></em>
        </li>
      </ng-container>
    </ul>
  </div>

  <div
    #autoCompleteDetails
    id="autoCompleteDetails"
    class="position-absolute card suggestionDetails"
    *ngIf="showSuggestionList && showSuggestionDetails"
  >
    <div class="card-header ps-0">
      <span class="float-start">{{ selectedSuggestion.subItem?.name }}</span>
      <button
        id="closeSuggestionPanel"
        type="button"
        (click)="hideSuggestionPanel()"
        class="float-end ps-1 pt-1 p-dialog-header-icon p-link"
      >
        <span class="closeIcon p-dialog-header-close-icon cursor-pointer pi pi-times"> </span>
      </button>
    </div>
    <div class="card-body ps-0">
      <p>{{ selectedSuggestion.subItem?.details?.description }}</p>
      <h4 class="mb-0">Syntax</h4>
      <p>{{ selectedSuggestion.subItem?.details?.syntax }}</p>
      <h4 class="mb-0">Examples</h4>
      <p *ngFor="let example of selectedSuggestion.subItem?.details?.examples">
        {{ example }}
      </p>
    </div>
  </div>
</div>
// CSS code      
@mixin border-axis {
  box-shadow: 0 2px 12px #0000001a;
  border-radius: 6px;
  z-index: 1000;
}
/**
Make sure textara and backdrop have same height, width and font size
Also the Z-index of textarea should be greater than that of backdrop
*/
textarea {
  z-index: 2 ;
  color: transparent;
  background-color: transparent;
  caret-color: #4e4e4e;
  border: none;
  outline: none;  
}
        
.backdrop {
  position: absolute ;
  z-index: 1;
  background-color: #f2f2f2 ;
  overflow: auto ;
  pointer-events: none;
  transition: transform 1s;
}
.backdrop,

textarea {
  height: 150px;
  width: 550px;
}
        
.defaultFontColor {
  color: #ffa500; // Orange
}
        
       
#autoCompleteListContainer .suggestionsList {
  @include border-axis;
  background: #ffffff;
  list-style-type: none;
  min-height: 50px;
  max-height: 200px;
  padding: 0.75rem 0 0.75rem 10px;
  overflow: auto;
}
        
.card.suggestionDetails {
  @include border-axis;
  width: 50%;
}
//  Ts file code 

import {
  Component,
  ElementRef,
  Input,
  Renderer2,
  EventEmitter,
  HostListener,
  Output,
  ViewChild,
  forwardRef,
  ChangeDetectionStrategy,
  OnInit
} from '@angular/core';
import { CaretPosition, TextAreaSuggestionItem } from './textarea-autocomplete-entity';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-textarea-autocomplete',
  templateUrl: './textarea-autocomplete.component.html',
  styleUrls: ['./textarea-autocomplete.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => TextareaAutocompleteComponent),
      multi: true
    }
  ],
  changeDetection: ChangeDetectionStrategy.OnPush
})

export class TextareaAutocompleteComponent implements ControlValueAccessor, OnInit {
  textAreaInput: string = '';
  selectedSuggestion: TextAreaSuggestionItem;
  showSuggestionList: boolean;
  showSuggestionDetails: boolean;
  caretPosition: CaretPosition;
  autoCompleteListPanelWidth: number;
  autoCompleteListBasedOnType: { [autoCompleteCategory: string]: any } | null;
  filteredAutoCompleteListBasedOnType: { [autoCompleteCategory: string]: any } | null;
  $event: any;
  value: string;
  disabled: boolean;

  @Input() suggestionList: TextAreaSuggestionItem[];

  @Output() onInsertionPositionChange: EventEmitter<number> = new EventEmitter<number>();
  @Output() onShow: EventEmitter<boolean> = new EventEmitter<boolean>();
  @Output() onHide: EventEmitter<boolean> = new EventEmitter<boolean>();

  @ViewChild('textElem') textAreaElem: ElementRef<HTMLTextAreaElement>;
  @ViewChild('autoCompleteListContainer') autoCompleteListContainerElem: ElementRef<HTMLDivElement>;
  @ViewChild('backdrop') backdrop: ElementRef<HTMLDivElement>;

  onChange = (_: any) => {};
  onTouch = (_: any) => {};

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

  registerOnTouched(angularProvidedFunction: any) {
    this.onTouch = angularProvidedFunction;
  }

  writeValue(angularProvidedValue: string) {
    if (angularProvidedValue || angularProvidedValue === null) {
      this.textAreaInput = angularProvidedValue;
      this.value = angularProvidedValue;
    }
  }

  setDisabledState(angularProvidedDisabledVal: any) {
    this.disabled = angularProvidedDisabledVal;
  }

  constructor(private el: ElementRef, private renderer: Renderer2) {}

  ngOnInit() {
    this.setAutoCompleteListBasedOnType(this.suggestionList);
  }
  /**
   * It closes the autocomplete list on clicking naywhere outside of the autocomplete list
   **/
  @HostListener('document:click', ['$event'])
  onClick($event) {
    if (!$event.target.parentElement?.parentElement?.classList?.contains('suggestionDetails')) {
      this.hideAutocompleteListOnclick();
    }
    if ($event.target.contains(this.textAreaElem.nativeElement)) {
      this.onInsertionPositionChange.emit($event.target.selectionStart);
    }
  }
  get highlightedSyntaxHtml() {
    return this.highlightSyntax(this.textAreaInput);
  }

  /**
   * It shows the autocomplete list and also highlights the syntax
   */
  onKeyUp($event) {
    this.hideAutocompleteListOnclick();
    if ($event.key === 'Enter') {
      this.textAreaInput = this.textAreaInput.replace(/\\n/g, '<br />');
      this.writeValue(this.textAreaInput);
      this.onChange(this.textAreaInput);
      this.onInsertionPositionChange.emit($event.target.selectionStart);
      return;
    }
    this.caretPosition = this.getCaretPosition($event.target);
    this.$event = $event;
    this.addSuggestionToAutoCompleteList($event);
    this.writeValue(this.textAreaInput);
    this.onChange(this.textAreaInput);
    this.onInsertionPositionChange.emit($event.target.selectionStart);
  }

  handleScroll() {
    var scrollTop = this.textAreaElem.nativeElement.scrollTop;
    this.backdrop.nativeElement.scrollTop = scrollTop;

    var scrollLeft = this.textAreaElem.nativeElement.scrollLeft;
    this.backdrop.nativeElement.scrollLeft = scrollLeft;
  }
  /**
   * It selects the suggestion from the autocomplete list and closes th autocomplete list
   */
  selectSuggestion(suggestion: TextAreaSuggestionItem) {
    const cursorPosition = this.$event.target.selectionStart + suggestion.name.length - 1;
    this.hideAutocompleteListOnclick();
    this.insertTextAtAnIndexFromAutocomplete(this.$event, suggestion);
    this.writeValue(this.textAreaInput);
    this.onChange(this.textAreaInput);
    this.onInsertionPositionChange.emit(cursorPosition);
  }
  /**
   * It shows the autocomplete item details panel based on available space
   */
  showSuggestionPanel($event, suggestion: TextAreaSuggestionItem): void {
    $event.stopPropagation();
    this.selectedSuggestion = suggestion;
    this.showSuggestionDetails = suggestion.subItem ? true : false;
    setTimeout(() => {
      let left = this.caretPosition.left + this.autoCompleteListPanelWidth;
      const autoCompleteDetailsElem = this.el.nativeElement.querySelector('#autoCompleteDetails');
      const autoCompletDetailsePanelwidth = autoCompleteDetailsElem?.offsetWidth;
      const horizontalSpaceToTheRightAvailable = this.checkForHorizontalSpaceAvailableToTheRight(
        autoCompletDetailsePanelwidth,
        left
      );

      if (!horizontalSpaceToTheRightAvailable) {
        left =
          this.caretPosition.left - autoCompletDetailsePanelwidth > 0
? this.caretPosition.left - autoCompletDetailsePanelwidth
: 0;
        // The 20 px added is to give space between the auto complete list and auto complete details when details panel is open to the left of list
        this.renderer.setStyle(
          this.autoCompleteListContainerElem.nativeElement,
          'left',
          left + autoCompletDetailsePanelwidth + 10 + 'px'
        );
      } else {
        // The 10 px added is to give space between the auto complete list and auto complete details when details panel is open to the right of list
        left = left + 10;
      }
      this.renderer.setStyle(autoCompleteDetailsElem, 'top', this.caretPosition.top + 'px');
      this.renderer.setStyle(autoCompleteDetailsElem, 'left', left + 'px');
    }, 0);
  }
  /**
   * It closes the autocomplete item details panel
   */
  hideSuggestionPanel(): void {
    this.showSuggestionDetails = false;
    this.selectedSuggestion = null;
    this.renderer.setStyle(this.autoCompleteListContainerElem.nativeElement, 'left', this.caretPosition.left + 'px');
  }
  /**
   * It closes the autocomplete list panel
   */
  hideAutocompleteListOnclick(): void {
    this.filteredAutoCompleteListBasedOnType = null;
    this.showSuggestionList = false;
    this.showSuggestionDetails = false;
    this.onHide.emit(true);
  }

  private setAutoCompleteListBasedOnType(autoCompleteList: TextAreaSuggestionItem[]) {
    this.autoCompleteListBasedOnType = {};
    for (let suggestion of autoCompleteList) {
      if (!suggestion.hideAutoComplete) {
        suggestion.suggestionType = suggestion.suggestionType ? suggestion.suggestionType : 'noType';
        if (!this.autoCompleteListBasedOnType[suggestion.suggestionType]) {
          this.autoCompleteListBasedOnType[suggestion.suggestionType] = [suggestion];
        } else {
          this.autoCompleteListBasedOnType[suggestion.suggestionType].push(suggestion);
        }
      }
    }
  }

  /**
   * It opens up the autocomplete list panel based on available space
   */
  private addSuggestionToAutoCompleteList($event): void {
    if (this.textAreaInput) {
      this.filteredAutoCompleteListBasedOnType = {};
      const searchInput = this.getSearchInput($event);
      let matchesFound = false;
      if (searchInput && searchInput.length) {
        for (const key in this.autoCompleteListBasedOnType) {
          this.filteredAutoCompleteListBasedOnType[key] = this.autoCompleteListBasedOnType[key]?.filter((suggestion) =>
suggestion.name.toLowerCase().startsWith(searchInput.toLowerCase())
          );
          if (!matchesFound) {
matchesFound = this.filteredAutoCompleteListBasedOnType[key]?.length > 0;
          }
        }
      }
      this.showSuggestionList = matchesFound;
      setTimeout(() => {
        if (this.showSuggestionList) {
          const autoCompleteListElem = this.el.nativeElement.querySelector('#autoCompleteList');
          this.autoCompleteListPanelWidth = autoCompleteListElem?.offsetWidth;

          const horizontalSpaceToTheRightAvailable = this.checkForHorizontalSpaceAvailableToTheRight(
this.autoCompleteListPanelWidth,
this.caretPosition.left
          );
          if (!horizontalSpaceToTheRightAvailable) {
this.caretPosition.left = this.caretPosition.left - this.autoCompleteListPanelWidth;
          }
          this.caretPosition.top = this.caretPosition.top + 30;
          this.renderer.setStyle(
this.autoCompleteListContainerElem.nativeElement,
'top',
this.caretPosition.top + 'px'
          );
          this.renderer.setStyle(
this.autoCompleteListContainerElem.nativeElement,
'left',
this.caretPosition.left + 'px'
          );
          this.onShow.emit(true);
        }
      }, 0);
    }
  }
  /**
   * Based on latest non ASCII chacrters encountered, it fetches the search input
   */
  private getSearchInput($event): string {
    let searchInput = '';
    let typedCharacterIndex = $event.target.selectionStart - 1;
    let inputSearchStringcCmpletion = false;
    searchInput = this.textAreaInput.charAt(typedCharacterIndex);
    /**
     * It there is any ascii character to the right of typed charcter.
     * do not display auto complet elist
     */
    if (!this.isInputStringSearchComplete(this.textAreaInput.charAt($event.target.selectionStart))) {
      return;
    }
    /**
     * Loop through the text in reverse order till we reach a non-ascii character to the left
     * from the position where cursor is placed
     * */
    while (!inputSearchStringcCmpletion) {
      typedCharacterIndex = typedCharacterIndex - 1;
      if (this.isInputStringSearchComplete(this.textAreaInput.charAt(typedCharacterIndex))) {
        inputSearchStringcCmpletion = true;
      } else {
        searchInput = this.textAreaInput.charAt(typedCharacterIndex) + searchInput;
      }
    }
    return searchInput;
  }
  /**
   * It inserts the selected suggestion from the autocomplete listat the place where cursor is placed
   */
  private insertTextAtAnIndexFromAutocomplete($event, suggestion: TextAreaSuggestionItem): void {
    const selectedSuggestionName = suggestion.name;

    let typedCharacterIndex = $event.target.selectionStart - 1;
    let startIndex = -1,
      endIndex = typedCharacterIndex + 1;
    let inputSearchStringcCmpletion = false;
    /**
     * Loop through the text to find the exact position of search input and
     * replace it with the selected suggestion from the autocomplete list
     */

    while (!inputSearchStringcCmpletion) {
      if (this.isInputStringSearchComplete(this.textAreaInput.charAt(typedCharacterIndex))) {
        inputSearchStringcCmpletion = true;
        startIndex = typedCharacterIndex;
      } else {
        typedCharacterIndex = typedCharacterIndex - 1;
      }
    }
    const initialPartSubstring = this.textAreaInput.substring(0, startIndex + 1);
    const endPartSubstring = this.textAreaInput.substring(endIndex, this.textAreaInput.length);
    if (initialPartSubstring && endPartSubstring) {
      this.textAreaInput = initialPartSubstring + selectedSuggestionName + endPartSubstring;
    } else if (initialPartSubstring) {
      this.textAreaInput = initialPartSubstring + selectedSuggestionName;
    } else if (endPartSubstring) {
      this.textAreaInput = selectedSuggestionName + endPartSubstring;
    } else {
      this.textAreaInput = selectedSuggestionName;
    }
  }
  /**
   *  It highlights the syntax
   */
  private highlightSyntax(text: string) {
    let newHTML = '';
    let val = '';
    let initialIndex = 0;
    /**
     * Loop through the text and select out words to apply respective styles
     */
    for (let i = 0; i < text.length; i++) {
      const char = text.charAt(initialIndex);
      if (!this.isAlphaNumericCharacter(char) && char !== '_') {
        if (val) {
          newHTML += this.getTextAreaHtmlElement(val);
        }
        newHTML += this.getTextAreaNonASCIICharacterHtmlElement(char);
        initialIndex += 1;
        val = '';
      } else {
        initialIndex += 1;
        val += text.charAt(i);
      }
    }
    if (val) {
      newHTML += this.getTextAreaHtmlElement(val);
    }
    return newHTML;
  }
  /**
   * It returns the html for text area words
   */
  private getTextAreaHtmlElement(val: string) {
    const suggestion = this.suggestionList.find((item) => item.name?.toLowerCase() === val.trim()?.toLowerCase());
    const className = suggestion ? suggestion.className : 'defaultFontColor';
    const spanElem = `<span class=${className}>` + val + `</span>`;
    return spanElem;
  }

  /**
   * It returns the html for non ASCII characters
   */
  private getTextAreaNonASCIICharacterHtmlElement(char: string) {
    switch (char) {
      case '\n':
        return '<br>';
      case ' ':
        return '&nbsp;';
      default:
        return this.getTextAreaHtmlElement(char);
    }
  }
  /**
   * It checks whther even space is available for autocomplete list so that its position can bre adjusted
   */
  private checkForHorizontalSpaceAvailableToTheRight(width: number, leftPosition: number): boolean {
    return this.textAreaElem.nativeElement.offsetWidth - leftPosition > width;
  }

  /**
   * It signifies that search input calculation is complete and can be fed to generate the autocomplete lis
   */
  private isInputStringSearchComplete(char: string): boolean {
    return !char || (!this.isAlphaNumericCharacter(char) && char !== '_');
  }
  /**
   * It checks whether the character encountered in the etxt are is ASCII or nor
   */
  private isAlphaNumericCharacter(char: string): boolean {
    return /^[a-zA-Z0-9]+$/.test(char);
  }
  /**
   * It calculates the current cursor position inside the text area
   */
  private getCaretPosition(textArea): CaretPosition {
    if (!this.textAreaInput) {
      return {
        left: 0,
        top: 0
      };
    }
    const start = textArea.selectionStart;
    const end = textArea.selectionEnd;
    const copy = this.createCopy(textArea);
    const range = document.createRange();
    range.setStart(copy.firstChild, start);
    range.setEnd(copy.firstChild, end);
    const selection = document.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
    const rect = range.getBoundingClientRect();
    document.body.removeChild(copy);
    textArea.selectionStart = start;
    textArea.selectionEnd = end;
    textArea.focus();
    return {
      left: rect.left - textArea.scrollLeft,
      top: rect.top - textArea.scrollTop
    };
  }
  /**
   * It copies the text area element to calculate the cursor position
   */
  private createCopy(textArea) {
    const copy = document.createElement('div');
    copy.textContent = textArea.value;
    const style = getComputedStyle(textArea);
    [
      'fontFamily',
      'fontSize',
      'fontWeight',
      'wordWrap',
      'whiteSpace',
      'borderLeftWidth',
      'borderTopWidth',
      'borderRightWidth',
      'borderBottomWidth'
    ].forEach((key) => {
      copy.style[key] = style[key];
    });
    copy.style.overflow = 'auto';
    copy.style.width = textArea.offsetWidth + 'px';
    copy.style.height = textArea.offsetHeight + 'px';
    copy.style.position = 'absolute';
    copy.style.left = textArea.offsetLeft + 'px';
    copy.style.top = textArea.offsetTop + 'px';
    document.body.appendChild(copy);
    return copy;
  }
}
        ```
        
      //  How to consume the custom text-area component with built in autocomplete and syntax highlighting :
        let inputArray = []
           const item1: { 
   name: 'Avg',
   className: 'abc',// any custom class and declare the font color etc,
       subItem: {
        name: 'Avg Details',
        details: {
          description: 'It calculates the average'
        }

}
inputarray.push(item1)
          <div class="row">
<app-textarea-autocomplete
  [suggestionList]="inputarray"
  formControlName="advancedConditonTextArea"
></app-textarea-autocomplete>

Upvotes: 0

Nick Felker
Nick Felker

Reputation: 11978

The Monaco library is the VSCode code editing textarea itself. You can use that in conjunction with a language server or other specific features. For something a little more off-the-shelf, you can try the Theia IDE which is basically a fully web-based version of VSCode with a lot of extensibility.

Upvotes: 2

Related Questions