neetshin/opelete

View on GitHub
src/opelete/Opelete.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { browser } from 'webextension-polyfill-ts';
import { SUGGESTION_CONTAINER_QUERY } from './constants';
import { Operator, searchOperators } from './operators';

export class Opelete {

  /** Matched suggestions */
  private suggestions: Operator[] = [];

  /** Exclude operator's ids */
  private blacklist: string[] = [];

  /** Index of suggestion which user focused */
  private focusedSuggestionIndex = 0;

  /** Google's input element of search form  */
  private inputNode: HTMLInputElement;

  /** Google's suggesntion element below of search form */
  private suggestionNode: HTMLDivElement;

  /** Container of root node */
  private opeleteNode: HTMLDivElement|null = null;

  /**
   * @param inputNode Node for the input form
   * @param suggestionNode Node for the suggestions container
   */
  constructor (inputNode: HTMLInputElement, suggestionNode: HTMLDivElement) {
    this.inputNode      = inputNode;
    this.suggestionNode = suggestionNode;

    // Overwrite Google's event listener
    this.inputNode.addEventListener('input', this.handleInput, true);
    this.inputNode.addEventListener('keydown', this.handleKeyDown, true);
    document.addEventListener('keydown', this.handleKeyDown, true);

    this.initializeNodes();
  }

  /**
   * Create Opelete container element with preferences in the sync storage
   * and insert before of original suggestion's wrapepr
   * @return Nothing
   */
  private initializeNodes = async (): Promise<void> => {
    if (!this.suggestionNode) {
      return;
    }

    const { hide_descriptions, operator_blacklist } = await browser.storage.sync.get();

    const node = document.createElement('div');
    node.classList.add('opelete');
    node.setAttribute('dir', 'ltr');

    if (hide_descriptions) {
      node.classList.add('opelete--hide-descriptions');
    }

    if (operator_blacklist) {
      this.blacklist = operator_blacklist;
    }

    this.opeleteNode = this.suggestionNode.insertBefore(node, this.suggestionNode.firstChild);
  }

  /**
   * Handle the behaviour when user downs keys
   * @param e Keyboard event object
   * @return nothing
   */
  private handleKeyDown = (e: KeyboardEvent) => {
    if ( this.suggestions.length === 0 ) {
      return;
    }

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        e.stopImmediatePropagation();
        this.focusedSuggestionIndex = Math.min(this.focusedSuggestionIndex + 1, this.suggestions.length - 1);
        this.updateSuggestion();
        break;

      case 'ArrowUp':
        e.preventDefault();
        e.stopImmediatePropagation();
        this.focusedSuggestionIndex = Math.max(this.focusedSuggestionIndex - 1, 0);
        this.updateSuggestion();
        break;

      case 'Enter':
        e.preventDefault();
        e.stopImmediatePropagation();
        const focusedSuggestion = this.suggestions[this.focusedSuggestionIndex];
        this.handleSelect(focusedSuggestion);
        break;

      case 'Escape':
        this.clearSuggestion();
        this.disableForceShowSuggestion();
        break;

      default:
        break;
    }
  }

  /**
   * Event listener of the search form
   * @param e Event of the input
   */
  private handleInput = async (e: Event) => {
    const target = e.target as HTMLInputElement;

    if ( target && target.value === '' || /\s$/.test(target.value) ) {
      this.clearSuggestion();
      this.disableForceShowSuggestion();
      return;
    }

    // Split search form's value by whitespace
    // and search operators by the last word
    // e.g. "JavaScript site" to search by "site"
    const [, operator] = target.value.match(/([^\s\n]+?)$/) as string[];

    if ( !operator ) {
      return;
    }

    this.suggestions = await searchOperators(operator);
    this.updateSuggestion();
    this.enableForceShowSuggestion();
  }

  /**
   * Handle the behaviour when user selected suggestion
   * @param focusedSuggestion Focused suggestion's Operator object
   * @return nothing
   */
  private handleSelect = (focusedSuggestion: Operator) => {
    if (!this.inputNode) {
      return;
    }

    this.inputNode.value = this.inputNode.value.replace(/([^\s\n]+?)$/, focusedSuggestion.operator);

    if ( focusedSuggestion.cursorPosition ) {
      const cursorPosition = this.inputNode.value.length - focusedSuggestion.operator.length + focusedSuggestion.cursorPosition;
      this.inputNode.setSelectionRange(cursorPosition, cursorPosition);
    }

    if ( focusedSuggestion.insertWhiteSpace ) {
      this.inputNode.value += ' ';
    }

    this.clearSuggestion();
    this.disableForceShowSuggestion();
  }

  /**
   * Update suggestion with given operators
   * @return Nothing
   */
  private updateSuggestion = (): void => {
    const suggestion = document.createElement('ul');
    suggestion.classList.add('opelete-list');

    this.suggestions.forEach((operator, i) => {
      if ( this.blacklist.indexOf(operator.id) !== -1 ) {
        return;
      }

      const listItem = document.createElement('li');
      listItem.classList.add('opelete-list-item');

      // If result selected, add --focused class
      if ( i === this.focusedSuggestionIndex ) {
        listItem.classList.add('opelete-list-item--focused');
      }

      const operatorNode = document.createElement('code');
      operatorNode.classList.add('opelete-list-item__operator');
      operatorNode.textContent = operator.operator;
      listItem.appendChild(operatorNode);

      const descriptionNode = document.createElement('p');
      descriptionNode.classList.add('opelete-list-item__description');
      descriptionNode.textContent = operator.description;
      listItem.appendChild(descriptionNode);

      // Add item to list wrapper and add click event listener
      const appendedItem = suggestion.appendChild(listItem);

      appendedItem.addEventListener('click', () => {
        this.handleSelect(operator);
      });
    });

    // Remove inner element
    if ( this.opeleteNode && this.opeleteNode.firstChild ) {
      this.opeleteNode.removeChild(this.opeleteNode.firstChild);
    }

    // Add suggestion
    if ( this.opeleteNode ) {
      this.opeleteNode.appendChild(suggestion);
    }
  }

  /**
   * Clear suggestions
   * @return Nothing
   */
  private clearSuggestion = (): void => {
    this.suggestions = [];
    this.focusedSuggestionIndex = 0;
    this.updateSuggestion();
  }

  /**
   * Force hide search form's suggestion node
   * @return Nothing
   */
  private enableForceShowSuggestion = (): void => {
    if (!this.suggestionNode) {
      return;
    }

    this.suggestionNode.classList.add('opelete-force-show');

    const suggestionContainer = document.querySelector(SUGGESTION_CONTAINER_QUERY);

    if (suggestionContainer) {
      suggestionContainer.classList.add('opelete-force-show');
    }
  }

  /**
   * Force hide search form's suggestion node
   * @return Nothing
   */
  private disableForceShowSuggestion = (): void => {
    if (!this.suggestionNode) {
      return;
    }

    this.suggestionNode.classList.add('opelete-force-show');

    const suggestionContainer = document.querySelector(SUGGESTION_CONTAINER_QUERY);

    if (suggestionContainer) {
      suggestionContainer.classList.remove('opelete-force-show');
    }
  }

}