yuku-t/textcomplete

View on GitHub
src/textarea.js

Summary

Maintainability
A
50 mins
Test Coverage
// @flow

import update from "undate/lib/update"

import Editor from "./editor"
import { calculateElementOffset, getLineHeightPx } from "./utils"
import SearchResult from "./search_result"

const getCaretCoordinates = require("textarea-caret")

const CALLBACK_METHODS = ["onInput", "onKeydown"]

/**
 * Encapsulate the target textarea element.
 */
export default class Textarea extends Editor {
  el: HTMLTextAreaElement

  /**
   * @param {HTMLTextAreaElement} el - Where the textcomplete works on.
   */
  constructor(el: HTMLTextAreaElement) {
    super()
    this.el = el

    CALLBACK_METHODS.forEach(method => {
      ;(this: any)[method] = (this: any)[method].bind(this)
    })

    this.startListening()
  }

  /**
   * @return {this}
   */
  destroy() {
    super.destroy()
    this.stopListening()
    // Release the element reference early to help garbage collection.
    ;(this: any).el = null
    return this
  }

  /**
   * Implementation for {@link Editor#applySearchResult}
   */
  applySearchResult(searchResult: SearchResult) {
    const before = this.getBeforeCursor()
    if (before != null) {
      const replace = searchResult.replace(before, this.getAfterCursor())
      this.el.focus() // Clicking a dropdown item removes focus from the element.
      if (Array.isArray(replace)) {
        update(this.el, replace[0], replace[1])
        if (this.el)
          this.el.dispatchEvent(new Event("input"))
      }
    }
  }

  /**
   * Implementation for {@link Editor#getCursorOffset}
   */
  getCursorOffset() {
    const elOffset = calculateElementOffset(this.el)
    const elScroll = this.getElScroll()
    const cursorPosition = this.getCursorPosition()
    const lineHeight = getLineHeightPx(this.el)
    const top = elOffset.top - elScroll.top + cursorPosition.top + lineHeight
    const left = elOffset.left - elScroll.left + cursorPosition.left
    const clientTop = this.el.getBoundingClientRect().top;
    if (this.el.dir !== "rtl") {
      return { top, left, lineHeight, clientTop }
    } else {
      const right = document.documentElement
        ? document.documentElement.clientWidth - left
        : 0
      return { top, right, lineHeight, clientTop }
    }
  }

  /**
   * Implementation for {@link Editor#getBeforeCursor}
   */
  getBeforeCursor() {
    return this.el.selectionStart !== this.el.selectionEnd
      ? null
      : this.el.value.substring(0, this.el.selectionEnd)
  }

  /** @private */
  getAfterCursor() {
    return this.el.value.substring(this.el.selectionEnd)
  }

  /** @private */
  getElScroll(): { top: number, left: number } {
    return { top: this.el.scrollTop, left: this.el.scrollLeft }
  }

  /**
   * The input cursor's relative coordinates from the textarea's left
   * top corner.
   *
   * @private
   */
  getCursorPosition(): { top: number, left: number } {
    return getCaretCoordinates(this.el, this.el.selectionEnd)
  }

  /** @private */
  onInput() {
    this.emitChangeEvent()
  }

  /** @private */
  onKeydown(e: KeyboardEvent) {
    const code = this.getCode(e)
    let event
    if (code === "UP" || code === "DOWN") {
      event = this.emitMoveEvent(code)
    } else if (code === "ENTER") {
      event = this.emitEnterEvent()
    } else if (code === "ESC") {
      event = this.emitEscEvent()
    }
    if (event && event.defaultPrevented) {
      e.preventDefault()
    }
  }

  /** @private */
  startListening() {
    this.el.addEventListener("input", this.onInput)
    this.el.addEventListener("keydown", this.onKeydown)
  }

  /** @private */
  stopListening() {
    this.el.removeEventListener("input", this.onInput)
    this.el.removeEventListener("keydown", this.onKeydown)
  }
}