zammad/zammad

View on GitHub
app/assets/javascripts/app/lib/base/jquery.textmodule.js

Summary

Maintainability
F
4 days
Test Coverage
(function ($, window, undefined) {

/*
# mode: textonly/richtext / disable b/i/u/enter + strip on paste
# pasteOnlyText: true
# maxlength: 123
# multiline: true / disable enter + strip on paste
# placeholder: 'some placeholder'
#
*/

  var pluginName = 'textmodule',
  defaults = {
    debug: false
  }

  function Plugin(element, options) {
    this.element    = element
    this.$element   = $(element)

    this.options    = $.extend({}, defaults, options)

    this._defaults   = defaults
    this._name       = pluginName
    this._fixedWidth = true

    this.collection = []
    this.active     = false
    this.buffer     = ''
    this.oldElementText = ''

    // check if ce exists
    if ( $.data(element, 'plugin_ce') ) {
      this.ce = $.data(element, 'plugin_ce')
    }

    this.init()
  }

  Plugin.prototype.init = function () {
    this.renderBase()
    this.bindEvents()
  }

  Plugin.prototype.bindEvents = function () {
    this.$element.on('keydown', this.onKeydown.bind(this))
    this.$element.on('keyup', this.onKeyup.bind(this))
    // using onInput event to trigger onKeyPress behavior
    // since keyPress doesn't work on Mobile Chrome / Android
    this.$element.on('input', this.onKeypress.bind(this))
    this.$element.on('focus', this.onFocus.bind(this))
  }

  Plugin.prototype.onFocus = function (e) {
    this.close()
  }

  Plugin.prototype.onKeydown = function (e) {
    // Saves the old element text for some special situations.
    this.oldElementText = this.$element.text()

    // navigate through item
    if (this.isActive()) {
      // esc
      if (e.keyCode === 27) {
        e.preventDefault()
        e.stopPropagation()
        this.close()
        return
      }

      // enter
      if (e.keyCode === 13) {
        e.preventDefault()
        e.stopPropagation()
        var elem = this.$widget.find('.dropdown-menu li.is-active')[0]

        // as fallback use hovered element
        if (!elem) {
          elem = this.$widget.find('.dropdown-menu li:hover')[0]
        }

        // as fallback first element
        if (!elem) {
          elem = this.$widget.find('.dropdown-menu li:first-child')[0]
        }
        this.take(elem)
        return
      }

      // arrow keys left/right
      if (e.keyCode === 37 || e.keyCode === 39) {
        e.preventDefault()
        e.stopPropagation()
        return
      }

      // up or down
      if (e.keyCode === 38 || e.keyCode === 40) {
        e.preventDefault()
        e.stopPropagation()
        var active = this.$widget.find('.dropdown-menu li.is-active')
        active.removeClass('is-active')

        if (e.keyCode == 38 && active.prev().length) {
          active = active.prev()
        }
        else if (e.keyCode == 40 && active.next().length) {
          active = active.next()
        }

        active.addClass('is-active')

        var menu = this.$widget.find('.dropdown-menu')

        if (!active.get(0)) {
          return
        }
        if (active.position().top < 0) {
          // scroll up
          menu.scrollTop( menu.scrollTop() + active.position().top )
        }
        else if ( active.position().top + active.height() > menu.height() ) {
          // scroll down
          var invisibleHeight = active.position().top + active.height() - menu.height()
          menu.scrollTop( menu.scrollTop() + invisibleHeight )
        }
      }
    }

    // esc
    if (e.keyCode === 27) {
      this.close()
    }

    // reduce buffer, in case close it
    // backspace
    if (e.keyCode === 8 && !( e.ctrlKey || e.metaKey ) && this.buffer) {

      var trigger = this.findTrigger(this.buffer)
      // backspace + buffer === :: -> close textmodule
      if (trigger && trigger.trigger === this.buffer) {
        this.close(true)
        e.preventDefault()
        return
      }

      // reduce buffer and show new result
      var length   = this.buffer.length
      this.buffer = this.buffer.substr(0, length-1)
      this.log('BS backspace', this.buffer)
      this.result(trigger)
    }
  }

  Plugin.prototype.onKeyup = function (e) {

    // in normal use we make sure that mentions
    // which has no text anymore get deleted
    if (e.keyCode == 8 && !this.buffer) {
      this.removeInvalidMentions()
    }
  }

  Plugin.prototype.onKeypress = function (e) {
    this.log('BUFF', this.buffer, e.keyCode, String.fromCharCode(e.which))
    // gets the character and keycode from event
    // this event does not have keyCode and which value set
    // so we take char and set those values to not break the flow
    // if originalEvent.data is null that means a non char key is pressed like delete, space
    if(e.originalEvent && e.originalEvent.data) {
      var char = e.originalEvent.data
      var keyCode = char.charCodeAt(0)
      e.keyCode = e.which = keyCode
    }

    // ignore invalid key codes if search is opened (issue #3637)
    if (this.isActive() && e.keyCode === undefined) {

      // Check if the trigger still exists in the new text, after a special key was pressed, otherwise
      //  close the collection.
      var indexOfBuffer = this.oldElementText.indexOf(this.buffer)
      var trigger = this.findTrigger(this.buffer)

      if (this.buffer && indexOfBuffer !== -1 && trigger) {
        foundCurrentBuffer = this.$element.text().substr(indexOfBuffer, this.buffer.length)

        if ( this.$element.text().substr(indexOfBuffer, trigger.trigger.length) !== trigger.trigger ) {
          this.close(true)
        }

        // Check on how many characters the trigger needs to be reduced, in the case it's not the same.
        else if ( foundCurrentBuffer !== this.buffer ) {
          var existingLength = 0
          for (var i = 0; i < this.buffer.length; i++) {
            if (this.buffer.charAt(i) !== foundCurrentBuffer.charAt(i)) {
              existingLength = i
              break
            }
          }

          this.buffer = this.buffer.substr(0, existingLength)
          this.result(trigger)
        }
      }
      return
    }

    // skip on shift + arrow_keys
    if (_.contains([16, 37, 38, 39, 40], e.keyCode)) return

    // enter
    if (e.keyCode === 13) {
      this.buffer = ''
      return
    }

    var newChar = String.fromCharCode(e.which)
    // observe other keys
    if (this.hasAvailableTriggers(this.buffer)) {
      if(this.hasAvailableTriggers(this.buffer + newChar)) {
        this.buffer = this.buffer + newChar
      } else if (!this.findTrigger(this.buffer)) {
        this.buffer = ''
      }
    }

    // oberserve first :
    if (!this.buffer && this.hasAvailableTriggers(newChar)) {
      this.buffer = this.buffer + newChar
    }

    var trigger = this.findTrigger(this.buffer)
    if (trigger) {
      this.log('BUFF HINT', this.buffer, this.buffer.length, e.which, String.fromCharCode(e.which))

      if (!this.isActive()) {
        this.open()
      }

      this.result(trigger)
    }
  }

  // remove invalid mentions
  Plugin.prototype.removeInvalidMentions = function() {
    this.$element.find('a[data-mention-user-id]').each(function() {
      if ($(this).text() != '') return true

      $(this).remove()
    })
  }

  // check if at least one trigger is available with the given prefix
  Plugin.prototype.hasAvailableTriggers = function(prefix) {
    var result = _.find(this.helpers, function(helper) {
      var trigger = helper.trigger
      return trigger.substr(0, prefix.length) == prefix.substr(0, trigger.length)
    })

    return result != undefined
  }

  // find a matching trigger
  Plugin.prototype.findTrigger = function(string) {
    return _.find(this.helpers, function(helper) {
      return helper.trigger == string.substr(0, helper.trigger.length)
    })
  }

  // create base template
  Plugin.prototype.renderBase = function() {
    this.$element.after('<div class="shortcut dropdown dropdown--actions"><ul class="dropdown-menu text-modules-box"></ul></div>')
    this.$widget = this.$element.next()
    this.$widget.on('mousedown', 'li', $.proxy(this.onEntryClick, this))
    this.$widget.on('mouseenter', 'li', $.proxy(this.onMouseEnter, this))
  }

  // set height of widget
  Plugin.prototype.movePosition = function() {
    if (!this._position) return
    var height         = this.$element.outerHeight() + 2
    var widgetHeight   = this.$widget.find('ul').height() //+ 60 // + height
    var rtl            = document.dir == 'rtl'
    var top            = -( widgetHeight + height ) + this._position.top
    var start          = this._position.left - 6
    var availableWidth = this.$element.innerWidth()
    var width          = this.$widget.find('.dropdown-menu').width()

    if(rtl){
      start = availableWidth - start
    }

    // position the element further left if it would break out of the textarea width
    if (start + width > availableWidth) {
      start = availableWidth - width
    }

    var css = {
      top: top
    }

    css[rtl ? 'right' : 'left'] = start

    this.$widget.css(css)
  }

  // set position of widget
  Plugin.prototype.updatePosition = function() {
    this.$widget.find('.dropdown-menu').scrollTop(300)
    if (!this.$element.is(':visible')) return

    // get cursor position
    var marker = '<span id="js-cursor-position"></span>'
    var range = this.getFirstRange();
    var clone = range.cloneRange()
    clone.pasteHtml(marker)
    this._position = $('#js-cursor-position').position()
    $('#js-cursor-position').remove()
    if (!this._position) return

    // set position of widget
    this.movePosition()
  }

  // open widget
  Plugin.prototype.open = function() {
    this.active = true
    this.updatePosition()
    this.renderBase()
    this.$widget.addClass('open')
    $(window).on('click.textmodule', $.proxy(this.close, this))
  }

  // close widget
  Plugin.prototype.close = function(cutInput) {
    this.$widget.removeClass('open')
    if ( cutInput && this.active ) {
      this.cutInput(true)
    }
    this.buffer = ''
    this.active = false
    this.$widget.remove()
    $(window).off('click.textmodule')
  }

  // check if widget is active/open
  Plugin.prototype.isActive = function() {
    return this.active
  }

  // paste some content
  Plugin.prototype.paste = function(string) {
    var isIE11 = !!window.MSInputMethodContext && !!document.documentMode;

    // IE <= 10
    if (document.selection && document.selection.createRange) {
      var range = document.selection.createRange()
      if (range.pasteHTML) {
        range.pasteHTML(string)
      }
    }
    // IE == 11
    else if (isIE11 && document.getSelection) {
      var range = document.getSelection().getRangeAt(0)
      var nnode = document.createElement('div')
          range.surroundContents(nnode)
          nnode.innerHTML = string
    }
    else if (document.queryCommandSupported && document.queryCommandSupported('insertHTML')) {
      if(!!window.chrome) { // Is Chrome-like browser? It will eat up the single trailing space!
        range = document.getSelection().getRangeAt(0)

        var walker         = document.createTreeWalker(document.body, NodeFilter.SHOW_ALL);
        walker.currentNode = range.startContainer;
        var previousNode   = walker.previousNode()

        if(previousNode && !range.startContainer.previousSibling && ['<p></p>', '<div></div>'].includes(previousNode.outerHTML)) {
          document.execCommand('insertHTML', false, "<br><br>")
        }

        if(range && range.endContainer.textContent && range.endContainer.textContent.match(/(?<=\S) $/)) {
          document.execCommand('insertHTML', false, '&nbsp;')
        }
      }

      document.execCommand('insertHTML', false, string)
    }
    else {
      var sel = rangy.getSelection();
      if (!sel.rangeCount) return

      var range = sel.getRangeAt(0);
      range.collapse(false);
      $('<div>').append(string).contents().each(function() {
        range.insertNode($(this).get(0));
        range.collapseAfter($(this).get(0));
      })
      sel.setSingleRange(range);
    }
  }

  // cut some content
  Plugin.prototype.cut = function(string) {
    var range = this.getFirstRange();
    if (!range) return
    /*
    var sel = window.getSelection()
    if ( !sel || sel.rangeCount < 1) {
      return
    }
    var range = sel.getRangeAt(0)
    */
    var clone = range.cloneRange()

    // improve error handling
    start = range.startOffset - string.length
    if (start < 0) {
      start = 0
    }

    //this.log('CUT FOR', string, "-"+clone.toString()+"-", start, range.startOffset)
    clone.setStart(range.startContainer, start)
    clone.setEnd(range.startContainer, range.startOffset)
    clone.deleteContents()
  }

  Plugin.prototype.onMouseEnter = function(event) {
    this.$widget.find('.is-active').removeClass('is-active')
    $(event.currentTarget).addClass('is-active')
  }

  Plugin.prototype.onEntryClick = function(event) {
    event.preventDefault()
    this.take(event.currentTarget)
  }

  // select text module and insert into text
  Plugin.prototype.take = function(elem) {
    if (!elem) {
      this.close(true)
      return
    }

    var trigger = this.findTrigger(this.buffer)

    if (trigger) {
      var _this     = this;

      var form_id = this.$element.closest('form').find('[name=form_id]').val()

      trigger.renderValue(this, elem, function(text, attachments) {
        _this.cutInput()
        _this.paste(text)
        _this.close(true)

        App.Event.trigger('ui::ticket::addArticleAttachent', {
          attachments: attachments,
          form_id: form_id
        })
      })
    }
  }

  Plugin.prototype.getFirstRange = function() {
    var sel = rangy.getSelection();
    return sel.rangeCount ? sel.getRangeAt(0) : null;
  }

  // cut out search string from text
  Plugin.prototype.cutInput = function() {
    if (!this.buffer) return
    if (!this.$element.text()) return
    this.cut(this.buffer)
    this.buffer = ''
  }

  // render result
  Plugin.prototype.result = function(trigger) {
    if (!trigger) return

    var term      = this.buffer.substr(trigger.trigger.length, this.buffer.length)
    trigger.renderResults(this, term)
  }

  Plugin.prototype.emptyResultsContainer = function() {
    this.$widget.find('ul').empty()
  }

  Plugin.prototype.appendResults = function(collection) {
    this.$widget.find('ul').append(collection).scrollTop(9999)
    this.afterResultRendering()
  }

  Plugin.prototype.afterResultRendering = function() {
    // keep the width of the dropdown the same even when longer items got filtered out
    if(this._fixedWidth){
      var elem = this.$widget.find('ul')

      var currentMinWidth = parseInt(elem.css('min-width'))
      var realWidth       = elem.width()

      if(!currentMinWidth || realWidth > currentMinWidth) {
        elem.css('min-width', realWidth + 'px')
      }
    }

    this.movePosition()
  }

  // log method
  Plugin.prototype.log = function() {
    if (App && App.Log) {
      App.Log.debug(this._name, arguments)
    }
    if (this.options.debug) {
      console.log(this._name, arguments)
    }
  }

  $.fn[pluginName] = function (options) {
    return this.each(function () {
      if (!$.data(this, 'plugin_' + pluginName)) {
        $.data(this, 'plugin_' + pluginName, new Plugin(this, options))
      }
    });
  }

  function Collection() {}

  Collection.renderValue = function(textmodule, elem, callback) {
    var id   = $(elem).data('id')
    var item = _.find(textmodule.collection, function(elem) { return elem.id == id })

    if (!item) return

    callback(item.content, [])
  }

  function matchItemWithRegExp(item, regex) {
    return (item.name && item.name.match(regex)) || (item.keywords && item.keywords.match(regex))
  }

  function compareItem(a, b) {
    if (a.name < b.name) return 1;
    if (a.name > b.name) return -1;
    return 0;
  }

  Collection.renderResults = function(textmodule, term) {
    var reg     = new RegExp(term, 'i')
    var regFull = new RegExp('\\b' + term + '\\b', 'i')
    var result  = textmodule.collection.filter(function(item) {
      return matchItemWithRegExp(item, reg) && !matchItemWithRegExp(item, regFull)
    })
    var resultFull = textmodule.collection.filter(function(item) {
      return matchItemWithRegExp(item, regFull)
    })
    result.sort(compareItem)
    if (resultFull.length) {
      resultFull.sort(compareItem)
      result = result.concat(resultFull)
    }

    textmodule.emptyResultsContainer()

    var elements = result.map(function(elem, index, array){
      var element = $('<li>')
        .attr('data-id', elem.id)
        .text(elem.name)
        .addClass('u-clickable u-textTruncate')

      if (index == array.length-1) {
        element.addClass('is-active')
      }

      if (elem.keywords) {
        element.append($('<kbd>').text(elem.keywords))
      }

      return element
    })

    textmodule.appendResults(elements)
  }

  Collection.trigger = '::'

  function KbAnswer() {}

  KbAnswer.renderValue = function(textmodule, elem, callback) {
    textmodule.emptyResultsContainer()

    var element = $('<li>').text(App.i18n.translateInline('Please wait…'))
    textmodule.appendResults(element)

    var form_id = textmodule.$element.closest('form').find('[name=form_id]').val()

    App.Ajax.request({
      id:   'textmoduleKbAnswer',
      type: 'GET',
      url:  $(elem).data('url'),
      success: function(data, status, xhr) {
        App.Collection.loadAssets(data.assets)

        var translation = App.KnowledgeBaseAnswerTranslation.find($(elem).data('id'))

        var body = translation.content().bodyWithPublicURLs()

        App.Ajax.request({
          id:   'textmoduleKbAnswerAttachments',
          type: 'POST',
          data: JSON.stringify({
            form_id: form_id
          }),
          url:  translation.parent().generateURL('/attachments/clone_to_form'),
          success: function(data, status, xhr) {
            App.Utils.htmlImage2DataUrlAsync(body, function(output){
              callback(output, data.attachments)
            })
          },
          error: function(xhr) {
            callback('')
          }
        })
      },
      error: function(xhr) {
        callback('')
      }
    })
  }

  KbAnswer.renderResults = function(textmodule, term) {
    textmodule.emptyResultsContainer()

    // skip in admin interface (ticket is needed)
    if (!textmodule.searchCondition) {
      return
    }
    if(!term) {
      var element = $('<li>').text(App.i18n.translateInline('Start typing to search in Knowledge Base…'))
      textmodule.appendResults(element)

      return
    }

    var element = $('<li>').text(App.i18n.translateInline('Loading…'))
    textmodule.appendResults(element)

    App.Delay.set(function() {
      App.Ajax.request({
        id:   'textmoduleKbAnswer',
        type: 'POST',
        url:  App.Config.get('api_path') + '/knowledge_bases/search',
        data: JSON.stringify({
          'query':             term,
          'flavor':            'agent',
          'index':             'KnowledgeBase::Answer::Translation',
          'url_type':          'agent',
          'highlight_enabled': false,
          'include_locale': true,
        }),
        processData: true,
        success: function(data, status, xhr) {
          textmodule.emptyResultsContainer()

          var items = data
            .result
            .map(function(elem) {
              if(result = _.find(data.details, function(detailElem) { return detailElem.type == elem.type && detailElem.id == elem.id })) {
                return {
                  'category': result.subtitle,
                  'name':     result.title,
                  'value':    elem.id,
                  'url':      result.url
                }
              }
            })
            .filter(function(elem){ return elem != undefined })
            .map(function(elem, index, array) {
              var element = $('<li>')
                .attr('data-id',  elem.value)
                .attr('data-url', elem.url)
                .addClass('u-clickable u-textTruncate with-category')

              element.append($('<small>').text(elem.category))
              element.append('<br>')
              element.append($('<span>').text(elem.name))

              if (index == array.length-1) {
                element.addClass('is-active')
              }

              return element
            })

          if(items.length == 0) {
            items.push($('<li>').text(App.i18n.translateInline('No results found')))
          }

          textmodule.appendResults(items)
        }
      })
    }, 200, 'textmoduleKbAnswerDelay', 'textmodule')
  }

  KbAnswer.trigger = '??'

  function Mention() {}

  Mention.renderValue = function(textmodule, elem, callback) {
    textmodule.emptyResultsContainer()

    var element = $('<li>').text(App.i18n.translateInline('Please wait…'))
    textmodule.appendResults(element)

    var form_id = textmodule.$element.closest('form').find('[name=form_id]').val()

    var user_id = $(elem).data('id')
    var user    = App.User.find(user_id)
    if (!user) {
      return callback('')
    }

    fqdn      = App.Config.get('fqdn')
    http_type = App.Config.get('http_type')

    $replace = $('<a></a>', {
      href: http_type + '://' + fqdn + '/' + user.uiUrl(),
      'data-mention-user-id': user_id,
      text: user.firstname + ' ' + user.lastname
    })

    callback($replace[0].outerHTML)
  }

  Mention.renderResults = function(textmodule, term) {
    textmodule.emptyResultsContainer()

    // skip in admin interface (ticket is needed)
    if (!textmodule.searchCondition) {
      return
    }
    if(!term) {
      var element = $('<li>').text(App.i18n.translateInline('Start typing to search for users…'))
      textmodule.appendResults(element)

      return
    }

    var element = $('<li>').text(App.i18n.translateInline('Loading…'))
    textmodule.appendResults(element)

    App.Delay.set(function() {
      items = []

      if (textmodule.searchCondition.group_id) {
        App.Mention.searchUser(term, textmodule.searchCondition.group_id, function(data) {
          textmodule.emptyResultsContainer()

          activeSet = false
          $.each(data.user_ids, function(index, user_id) {
            user = App.User.find(user_id)
            if (!user) return true
            if (!user.active) return true

            item = $('<li>', {
              'data-id': user_id,
              text: user.firstname + ' ' + user.lastname + ' <' + user.email + '>'
            })
            if (!activeSet) {
              activeSet = true
              item.addClass('is-active')
            }

            items.push(item)
          })

          if(items.length == 0) {
            items.push($('<li>').text(App.i18n.translateInline('No results found')))
          }

          textmodule.appendResults(items)
        })
      }
      else {
        textmodule.emptyResultsContainer()
        items.push($('<li>').text(App.i18n.translateInline('Before you mention a user, please select a group.')))
        textmodule.appendResults(items)
      }
    }, 200, 'textmoduleMentionDelay', 'textmodule')
  }

  Mention.trigger = '@@'

  Plugin.prototype.helpers = [Collection, KbAnswer, Mention]

}(jQuery, window));