/**
 * Tagify (v 4.9.8) - tags input component
 * By Yair Even-Or
 * Don't sell this code. (c)
 * https://github.com/yairEO/tagify
 */

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Tagify = factory());
}(this, (function () { 'use strict';

  function ownKeys(object, enumerableOnly) {
    var keys = Object.keys(object);

    if (Object.getOwnPropertySymbols) {
      var symbols = Object.getOwnPropertySymbols(object);

      if (enumerableOnly) {
        symbols = symbols.filter(function (sym) {
          return Object.getOwnPropertyDescriptor(object, sym).enumerable;
        });
      }

      keys.push.apply(keys, symbols);
    }

    return keys;
  }

  function _objectSpread2(target) {
    for (var i = 1; i < arguments.length; i++) {
      var source = arguments[i] != null ? arguments[i] : {};

      if (i % 2) {
        ownKeys(Object(source), true).forEach(function (key) {
          _defineProperty(target, key, source[key]);
        });
      } else if (Object.getOwnPropertyDescriptors) {
        Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
      } else {
        ownKeys(Object(source)).forEach(function (key) {
          Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
        });
      }
    }

    return target;
  }

  function _defineProperty(obj, key, value) {
    if (key in obj) {
      Object.defineProperty(obj, key, {
        value: value,
        enumerable: true,
        configurable: true,
        writable: true
      });
    } else {
      obj[key] = value;
    }

    return obj;
  }

  // console.json = console.json || function(argument){
  //     for(var arg=0; arg < arguments.length; ++arg)
  //         console.log(  JSON.stringify(arguments[arg], null, 4)  )
  // }
  // const isEdge = /Edge/.test(navigator.userAgent)
  const sameStr = (s1, s2, caseSensitive, trim) => {
    // cast to String
    s1 = "" + s1;
    s2 = "" + s2;

    if (trim) {
      s1 = s1.trim();
      s2 = s2.trim();
    }

    return caseSensitive ? s1 == s2 : s1.toLowerCase() == s2.toLowerCase();
  }; // const getUID = () => (new Date().getTime() + Math.floor((Math.random()*10000)+1)).toString(16)

  const removeCollectionProp = (collection, unwantedProps) => collection && Array.isArray(collection) && collection.map(v => omit(v, unwantedProps));
  function omit(obj, props) {
    var newObj = {},
        p;

    for (p in obj) if (props.indexOf(p) < 0) newObj[p] = obj[p];

    return newObj;
  }
  function decode(s) {
    var el = document.createElement('div');
    return s.replace(/\&#?[0-9a-z]+;/gi, function (enc) {
      el.innerHTML = enc;
      return el.innerText;
    });
  }
  /**
   * utility method
   * https://stackoverflow.com/a/35385518/104380
   * @param  {String} s [HTML string]
   * @return {Object}   [DOM node]
   */

  function parseHTML(s) {
    var parser = new DOMParser(),
        node = parser.parseFromString(s.trim(), "text/html");
    return node.body.firstElementChild;
  }
  /**
   * Removed new lines and irrelevant spaces which might affect layout, and are better gone
   * @param {string} s [HTML string]
   */

  function minify(s) {
    return s ? s.replace(/\>[\r\n ]+\</g, "><").replace(/(<.*?>)|\s+/g, (m, $1) => $1 ? $1 : ' ') // https://stackoverflow.com/a/44841484/104380
    : "";
  }
  function removeTextChildNodes(elm) {
    var iter = document.createNodeIterator(elm, NodeFilter.SHOW_TEXT, null, false),
        textnode; // print all text nodes

    while (textnode = iter.nextNode()) {
      if (!textnode.textContent.trim()) textnode.parentNode.removeChild(textnode);
    }
  }
  function getfirstTextNode(elm, action) {
    action = action || 'previous';

    while (elm = elm[action + 'Sibling']) if (elm.nodeType == 3) return elm;
  }
  /**
   * utility method
   * https://stackoverflow.com/a/6234804/104380
   */

  function escapeHTML(s) {
    return typeof s == 'string' ? s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/`|'/g, "&#039;") : s;
  }
  /**
   * Checks if an argument is a javascript Object
   */

  function isObject(obj) {
    var type = Object.prototype.toString.call(obj).split(' ')[1].slice(0, -1);
    return obj === Object(obj) && type != 'Array' && type != 'Function' && type != 'RegExp' && type != 'HTMLUnknownElement';
  }
  /**
   * merge objects into a single new one
   * TEST: extend({}, {a:{foo:1}, b:[]}, {a:{bar:2}, b:[1], c:()=>{}})
   */

  function extend(o, o1, o2) {
    if (!(o instanceof Object)) o = {};
    copy(o, o1);
    if (o2) copy(o, o2);

    function copy(a, b) {
      // copy o2 to o
      for (var key in b) if (b.hasOwnProperty(key)) {
        if (isObject(b[key])) {
          if (!isObject(a[key])) a[key] = Object.assign({}, b[key]);else copy(a[key], b[key]);
          continue;
        }

        if (Array.isArray(b[key])) {
          a[key] = Object.assign([], b[key]);
          continue;
        }

        a[key] = b[key];
      }
    }

    return o;
  }
  /**
   * concatenates N arrays without dups.
   * If an array's item is an Object, compare by `value`
   */

  function concatWithoutDups() {
    const newArr = [],
          existingObj = {};

    for (let arr of arguments) {
      for (let item of arr) {
        // if current item is an object which has yet to be added to the new array
        if (isObject(item)) {
          if (!existingObj[item.value]) {
            newArr.push(item);
            existingObj[item.value] = 1;
          }
        } // if current item is not an object and is not in the new array
        else if (!newArr.includes(item)) newArr.push(item);
      }
    }

    return newArr;
  }
  /**
   *  Extracted from: https://stackoverflow.com/a/37511463/104380
   * @param {String} s
   */

  function unaccent(s) {
    // if not supported, do not continue.
    // developers should use a polyfill:
    // https://github.com/walling/unorm
    if (!String.prototype.normalize) return s;
    if (typeof s === 'string') return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
  }
  /**
   * Meassures an element's height, which might yet have been added DOM
   * https://stackoverflow.com/q/5944038/104380
   * @param {DOM} node
   */

  function getNodeHeight(node) {
    var height,
        clone = node.cloneNode(true);
    clone.style.cssText = "position:fixed; top:-9999px; opacity:0";
    document.body.appendChild(clone);
    height = clone.clientHeight;
    clone.parentNode.removeChild(clone);
    return height;
  }
  var isChromeAndroidBrowser = () => /(?=.*chrome)(?=.*android)/i.test(navigator.userAgent);
  function getUID() {
    return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
  }
  function isNodeTag(node) {
    return node && node.classList && node.classList.contains(this.settings.classNames.tag);
  }

  var DEFAULTS = {
    delimiters: ",",
    // [RegEx] split tags by any of these delimiters ("null" to cancel) Example: ",| |."
    pattern: null,
    // RegEx pattern to validate input by. Ex: /[1-9]/
    tagTextProp: 'value',
    // tag data Object property which will be displayed as the tag's text
    maxTags: Infinity,
    // Maximum number of tags
    callbacks: {},
    // Exposed callbacks object to be triggered on certain events
    addTagOnBlur: true,
    // Flag - automatically adds the text which was inputed as a tag when blur event happens
    duplicates: false,
    // "true" - allow duplicate tags
    whitelist: [],
    // Array of tags to suggest as the user types (can be used along with "enforceWhitelist" setting)
    blacklist: [],
    // A list of non-allowed tags
    enforceWhitelist: false,
    // Flag - Only allow tags from the whitelist
    userInput: true,
    // Flag - disable manually typing/pasting/editing tags (tags may only be added from the whitelist)
    keepInvalidTags: false,
    // Flag - if true, do not remove tags which did not pass validation
    mixTagsAllowedAfter: /,|\.|\:|\s/,
    // RegEx - Define conditions in which mix-tags content allows a tag to be added after
    mixTagsInterpolator: ['[[', ']]'],
    // Interpolation for mix mode. Everything between this will becmoe a tag
    backspace: true,
    // false / true / "edit"
    skipInvalid: false,
    // If `true`, do not add invalid, temporary, tags before automatically removing them
    pasteAsTags: true,
    // automatically converts pasted text into tags. if "false", allows for further text editing
    editTags: {
      clicks: 2,
      // clicks to enter "edit-mode": 1 for single click. any other value is considered as double-click
      keepInvalid: true // keeps invalid edits as-is until `esc` is pressed while in focus

    },
    // 1 or 2 clicks to edit a tag. false/null for not allowing editing
    transformTag: () => {},
    // Takes a tag input string as argument and returns a transformed value
    trim: true,
    // whether or not the value provided should be trimmed, before being added as a tag
    a11y: {
      focusableTags: false
    },
    mixMode: {
      insertAfterTag: '\u00A0' // String/Node to inject after a tag has been added (see #588)

    },
    autoComplete: {
      enabled: true,
      // Tries to suggest the input's value while typing (match from whitelist) by adding the rest of term as grayed-out text
      rightKey: false // If `true`, when Right key is pressed, use the suggested value to create a tag, else just auto-completes the input. in mixed-mode this is set to "true"

    },
    classNames: {
      namespace: 'tagify',
      mixMode: 'tagify--mix',
      selectMode: 'tagify--select',
      input: 'tagify__input',
      focus: 'tagify--focus',
      tagNoAnimation: 'tagify--noAnim',
      tagInvalid: 'tagify--invalid',
      tagNotAllowed: 'tagify--notAllowed',
      scopeLoading: 'tagify--loading',
      hasMaxTags: 'tagify--hasMaxTags',
      hasNoTags: 'tagify--noTags',
      empty: 'tagify--empty',
      inputInvalid: 'tagify__input--invalid',
      dropdown: 'tagify__dropdown',
      dropdownWrapper: 'tagify__dropdown__wrapper',
      dropdownItem: 'tagify__dropdown__item',
      dropdownItemActive: 'tagify__dropdown__item--active',
      dropdownInital: 'tagify__dropdown--initial',
      tag: 'tagify__tag',
      tagText: 'tagify__tag-text',
      tagX: 'tagify__tag__removeBtn',
      tagLoading: 'tagify__tag--loading',
      tagEditing: 'tagify__tag--editable',
      tagFlash: 'tagify__tag--flash',
      tagHide: 'tagify__tag--hide'
    },
    dropdown: {
      classname: '',
      enabled: 2,
      // minimum input characters to be typed for the suggestions dropdown to show
      maxItems: 10,
      searchKeys: ["value", "searchBy"],
      fuzzySearch: true,
      caseSensitive: false,
      accentedSearch: true,
      highlightFirst: false,
      // highlights first-matched item in the list
      closeOnSelect: true,
      // closes the dropdown after selecting an item, if `enabled:0` (which means always show dropdown)
      clearOnSelect: true,
      // after selecting a suggetion, should the typed text input remain or be cleared
      position: 'all',
      // 'manual' / 'text' / 'all'
      appendTarget: null // defaults to document.body one DOM has been loaded

    },
    hooks: {
      beforeRemoveTag: () => Promise.resolve(),
      beforePaste: () => Promise.resolve(),
      suggestionClick: () => Promise.resolve()
    }
  };

  function initDropdown() {
    this.dropdown = {};

    for (let p in this._dropdown) this.dropdown[p] = typeof this._dropdown[p] === 'function' ? this._dropdown[p].bind(this) : this._dropdown[p];

    this.dropdown.refs();
  }
  var _dropdown = {
    refs() {
      this.DOM.dropdown = this.parseTemplate('dropdown', [this.settings]);
      this.DOM.dropdown.content = this.DOM.dropdown.querySelector(this.settings.classNames.dropdownWrapperSelector);
    },

    /**
     * shows the suggestions select box
     * @param {String} value [optional, filter the whitelist by this value]
     */
    show(value) {
      var _s = this.settings,
          firstListItem,
          firstListItemValue,
          allowNewTags = _s.mode == 'mix' && !_s.enforceWhitelist,
          noWhitelist = !_s.whitelist || !_s.whitelist.length,
          noMatchListItem,
          isManual = _s.dropdown.position == 'manual'; // if text still exists in the input, and `show` method has no argument, then the input's text should be used

      value = value === undefined ? this.state.inputText : value; // ⚠️ Do not render suggestions list  if:
      // 1. there's no whitelist (can happen while async loading) AND new tags arn't allowed
      // 2. dropdown is disabled
      // 3. loader is showing (controlled outside of this code)

      if (noWhitelist && !allowNewTags && !_s.templates.dropdownItemNoMatch || _s.dropdown.enable === false || this.state.isLoading) return;
      clearTimeout(this.dropdownHide__bindEventsTimeout); // if no value was supplied, show all the "whitelist" items in the dropdown
      // @type [Array] listItems
      // TODO: add a Setting to control items' sort order for "listItems"

      this.suggestedListItems = this.dropdown.filterListItems(value); // trigger at this exact point to let the developer the chance to manually set "this.suggestedListItems"

      if (value && !this.suggestedListItems.length) {
        this.trigger('dropdown:noMatch', value);
        if (_s.templates.dropdownItemNoMatch) noMatchListItem = _s.templates.dropdownItemNoMatch.call(this, {
          value
        });
      } // if "dropdownItemNoMatch" was no defined, procceed regular flow.
      //


      if (!noMatchListItem) {
        // in mix-mode, if the value isn't included in the whilelist & "enforceWhitelist" setting is "false",
        // then add a custom suggestion item to the dropdown
        if (this.suggestedListItems.length) {
          if (value && allowNewTags && !this.state.editing.scope && !sameStr(this.suggestedListItems[0].value, value)) this.suggestedListItems.unshift({
            value
          });
        } else {
          if (value && allowNewTags && !this.state.editing.scope) {
            this.suggestedListItems = [{
              value
            }];
          } // hide suggestions list if no suggestion matched
          else {
            this.input.autocomplete.suggest.call(this);
            this.dropdown.hide();
            return;
          }
        }

        firstListItem = this.suggestedListItems[0];
        firstListItemValue = "" + (isObject(firstListItem) ? firstListItem.value : firstListItem);

        if (_s.autoComplete && firstListItemValue) {
          // only fill the sugegstion if the value of the first list item STARTS with the input value (regardless of "fuzzysearch" setting)
          if (firstListItemValue.indexOf(value) == 0) this.input.autocomplete.suggest.call(this, firstListItem);
        }
      }

      this.dropdown.fill(noMatchListItem);
      if (_s.dropdown.highlightFirst) this.dropdown.highlightOption(this.DOM.dropdown.content.children[0]); // bind events, exactly at this stage of the code. "dropdown.show" method is allowed to be
      // called multiple times, regardless if the dropdown is currently visible, but the events-binding
      // should only be called if the dropdown wasn't previously visible.

      if (!this.state.dropdown.visible) // timeout is needed for when pressing arrow down to show the dropdown,
        // so the key event won't get registered in the dropdown events listeners
        setTimeout(this.dropdown.events.binding.bind(this)); // set the dropdown visible state to be the same as the searched value.
      // MUST be set *before* position() is called

      this.state.dropdown.visible = value || true;
      this.state.dropdown.query = value;
      this.setStateSelection(); // try to positioning the dropdown (it might not yet be on the page, doesn't matter, next code handles this)

      if (!isManual) {
        // a slight delay is needed if the dropdown "position" setting is "text", and nothing was typed in the input,
        // so sadly the "getCaretGlobalPosition" method doesn't recognize the caret position without this delay
        setTimeout(() => {
          this.dropdown.position();
          this.dropdown.render();
        });
      } // a delay is needed because of the previous delay reason.
      // this event must be fired after the dropdown was rendered & positioned


      setTimeout(() => {
        this.trigger("dropdown:show", this.DOM.dropdown);
      });
    },

    /**
     * Hides the dropdown (if it's not managed manually by the developer)
     * @param {Boolean} overrideManual
     */
    hide(overrideManual) {
      var _this$DOM = this.DOM,
          scope = _this$DOM.scope,
          dropdown = _this$DOM.dropdown,
          isManual = this.settings.dropdown.position == 'manual' && !overrideManual; // if there's no dropdown, this means the dropdown events aren't binded

      if (!dropdown || !document.body.contains(dropdown) || isManual) return;
      window.removeEventListener('resize', this.dropdown.position);
      this.dropdown.events.binding.call(this, false); // unbind all events
      // if the dropdown is open, and the input (scope) is clicked,
      // the dropdown should be now "close", and the next click (on the scope)
      // should re-open it, and without a timeout, clicking to close will re-open immediately
      //  clearTimeout(this.dropdownHide__bindEventsTimeout)
      //  this.dropdownHide__bindEventsTimeout = setTimeout(this.events.binding.bind(this), 250)  // re-bind main events

      scope.setAttribute("aria-expanded", false);
      dropdown.parentNode.removeChild(dropdown); // scenario: clicking the scope to show the dropdown, clicking again to hide -> calls dropdown.hide() and then re-focuses the input
      // which casues another onFocus event, which checked "this.state.dropdown.visible" and see it as "false" and re-open the dropdown

      setTimeout(() => {
        this.state.dropdown.visible = false;
      }, 100);
      this.state.dropdown.query = this.state.ddItemData = this.state.ddItemElm = this.state.selection = null; // if the user closed the dropdown (in mix-mode) while a potential tag was detected, flag the current tag
      // so the dropdown won't be shown on following user input for that "tag"

      if (this.state.tag && this.state.tag.value.length) {
        this.state.flaggedTags[this.state.tag.baseOffset] = this.state.tag;
      }

      this.trigger("dropdown:hide", dropdown);
      return this;
    },

    /**
     * Toggles dropdown show/hide
     * @param {Boolean} show forces the dropdown to show
     */
    toggle(show) {
      this.dropdown[this.state.dropdown.visible && !show ? 'hide' : 'show']();
    },

    render() {
      // let the element render in the DOM first, to accurately measure it.
      // this.DOM.dropdown.style.cssText = "left:-9999px; top:-9999px;";
      var ddHeight = getNodeHeight(this.DOM.dropdown),
          _s = this.settings,
          enabled = typeof _s.dropdown.enabled == 'number' && _s.dropdown.enabled >= 0;
      if (!enabled) return this;
      this.DOM.scope.setAttribute("aria-expanded", true); // if the dropdown has yet to be appended to the DOM,
      // append the dropdown to the body element & handle events

      if (!document.body.contains(this.DOM.dropdown)) {
        this.DOM.dropdown.classList.add(_s.classNames.dropdownInital);
        this.dropdown.position(ddHeight);

        _s.dropdown.appendTarget.appendChild(this.DOM.dropdown);

        setTimeout(() => this.DOM.dropdown.classList.remove(_s.classNames.dropdownInital));
      }

      return this;
    },

    /**
     *
     * @param {String/Array} HTMLContent - optional
     */
    fill(HTMLContent) {
      HTMLContent = typeof HTMLContent == 'string' ? HTMLContent : this.dropdown.createListHTML(HTMLContent || this.suggestedListItems);
      this.DOM.dropdown.content.innerHTML = minify(HTMLContent);
    },

    /**
     * fill data into the suggestions list
     * (mainly used to update the list when removing tags, so they will be re-added to the list. not efficient)
     */
    refilter(value) {
      value = value || this.state.dropdown.query || '';
      this.suggestedListItems = this.dropdown.filterListItems(value);
      this.dropdown.fill();
      if (!this.suggestedListItems.length) this.dropdown.hide();
      this.trigger("dropdown:updated", this.DOM.dropdown);
    },

    position(ddHeight) {
      var _sd = this.settings.dropdown;
      if (_sd.position == 'manual') return;
      var rect,
          top,
          bottom,
          left,
          width,
          parentsPositions,
          ddElm = this.DOM.dropdown,
          placeAbove = _sd.placeAbove,
          viewportHeight = document.documentElement.clientHeight,
          viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0),
          positionTo = viewportWidth > 480 ? _sd.position : 'all',
          ddTarget = this.DOM[positionTo == 'input' ? 'input' : 'scope'];
      ddHeight = ddHeight || ddElm.clientHeight;

      function getParentsPositions(p) {
        var left = 0,
            top = 0;

        while (p) {
          left += p.offsetLeft || 0;
          top += p.offsetTop || 0;
          p = p.parentNode;
        }

        return {
          left,
          top
        };
      }

      if (!this.state.dropdown.visible) return;

      if (positionTo == 'text') {
        rect = this.getCaretGlobalPosition();
        bottom = rect.bottom;
        top = rect.top;
        left = rect.left;
        width = 'auto';
      } else {
        parentsPositions = getParentsPositions(this.settings.dropdown.appendTarget);
        rect = ddTarget.getBoundingClientRect();
        top = rect.top - parentsPositions.top;
        bottom = rect.bottom - 1 - parentsPositions.top;
        left = rect.left - parentsPositions.left;
        width = rect.width + 'px';
      }

      top = Math.floor(top);
      bottom = Math.ceil(bottom);
      placeAbove = placeAbove === undefined ? viewportHeight - rect.bottom < ddHeight : placeAbove; // flip vertically if there is no space for the dropdown below the input

      ddElm.style.cssText = "left:" + (left + window.pageXOffset) + "px; width:" + width + ";" + (placeAbove ? "top: " + (top + window.pageYOffset) + "px" : "top: " + (bottom + window.pageYOffset) + "px");
      ddElm.setAttribute('placement', placeAbove ? "top" : "bottom");
      ddElm.setAttribute('position', positionTo);
    },

    events: {
      /**
       * Events should only be binded when the dropdown is rendered and removed when isn't
       * because there might be multiple Tagify instances on a certain page
       * @param  {Boolean} bindUnbind [optional. true when wanting to unbind all the events]
       */
      binding(bindUnbind = true) {
        // references to the ".bind()" methods must be saved so they could be unbinded later
        var _CB = this.dropdown.events.callbacks,
            // callback-refs
        _CBR = this.listeners.dropdown = this.listeners.dropdown || {
          position: this.dropdown.position.bind(this),
          onKeyDown: _CB.onKeyDown.bind(this),
          onMouseOver: _CB.onMouseOver.bind(this),
          onMouseLeave: _CB.onMouseLeave.bind(this),
          onClick: _CB.onClick.bind(this),
          onScroll: _CB.onScroll.bind(this)
        },
            action = bindUnbind ? 'addEventListener' : 'removeEventListener';

        if (this.settings.dropdown.position != 'manual') {
          window[action]('resize', _CBR.position);
          window[action]('keydown', _CBR.onKeyDown);
        }

        this.DOM.dropdown[action]('mouseover', _CBR.onMouseOver);
        this.DOM.dropdown[action]('mouseleave', _CBR.onMouseLeave);
        this.DOM.dropdown[action]('mousedown', _CBR.onClick);
        this.DOM.dropdown.content[action]('scroll', _CBR.onScroll);
      },

      callbacks: {
        onKeyDown(e) {
          // get the "active" element, and if there was none (yet) active, use first child
          var selectedElm = this.DOM.dropdown.querySelector(this.settings.classNames.dropdownItemActiveSelector),
              selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm);

          switch (e.key) {
            case 'ArrowDown':
            case 'ArrowUp':
            case 'Down': // >IE11

            case 'Up':
              {
                // >IE11
                e.preventDefault();
                var dropdownItems;
                if (selectedElm) selectedElm = selectedElm[(e.key == 'ArrowUp' || e.key == 'Up' ? "previous" : "next") + "ElementSibling"]; // if no element was found, loop

                if (!selectedElm) {
                  dropdownItems = this.DOM.dropdown.content.children;
                  selectedElm = dropdownItems[e.key == 'ArrowUp' || e.key == 'Up' ? dropdownItems.length - 1 : 0];
                }

                selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm);
                this.dropdown.highlightOption(selectedElm, true);
                break;
              }

            case 'Escape':
            case 'Esc':
              // IE11
              this.dropdown.hide();
              break;

            case 'ArrowRight':
              if (this.state.actions.ArrowLeft) return;

            case 'Tab':
              {
                // in mix-mode, treat arrowRight like Enter key, so a tag will be created
                if (this.settings.mode != 'mix' && selectedElm && !this.settings.autoComplete.rightKey && !this.state.editing) {
                  e.preventDefault(); // prevents blur so the autocomplete suggestion will not become a tag

                  var value = this.dropdown.getMappedValue(selectedElmData);
                  this.input.autocomplete.set.call(this, value);
                  return false;
                }

                return true;
              }

            case 'Enter':
              {
                e.preventDefault();
                this.settings.hooks.suggestionClick(e, {
                  tagify: this,
                  tagData: selectedElmData,
                  suggestionElm: selectedElm
                }).then(() => {
                  if (selectedElm) return this.dropdown.selectOption(selectedElm);else this.dropdown.hide();
                  if (this.settings.mode != 'mix') this.addTags(this.state.inputText.trim(), true);
                }).catch(err => err);
                break;
              }

            case 'Backspace':
              {
                if (this.settings.mode == 'mix' || this.state.editing.scope) return;
                const value = this.input.raw.call(this);

                if (value == "" || value.charCodeAt(0) == 8203) {
                  if (this.settings.backspace === true) this.removeTags();else if (this.settings.backspace == 'edit') setTimeout(this.editTag.bind(this), 0);
                }
              }
          }
        },

        onMouseOver(e) {
          var ddItem = e.target.closest(this.settings.classNames.dropdownItemSelector); // event delegation check

          ddItem && this.dropdown.highlightOption(ddItem);
        },

        onMouseLeave(e) {
          // de-highlight any previously highlighted option
          this.dropdown.highlightOption();
        },

        onClick(e) {
          if (e.button != 0 || e.target == this.DOM.dropdown || e.target == this.DOM.dropdown.content) return; // allow only mouse left-clicks

          var selectedElm = e.target.closest(this.settings.classNames.dropdownItemSelector),
              selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm); // temporary set the "actions" state to indicate to the main "blur" event it shouldn't run

          this.state.actions.selectOption = true;
          setTimeout(() => this.state.actions.selectOption = false, 50);
          this.settings.hooks.suggestionClick(e, {
            tagify: this,
            tagData: selectedElmData,
            suggestionElm: selectedElm
          }).then(() => {
            if (selectedElm) this.dropdown.selectOption(selectedElm);else this.dropdown.hide();
          }).catch(err => console.warn(err));
        },

        onScroll(e) {
          var elm = e.target,
              pos = elm.scrollTop / (elm.scrollHeight - elm.parentNode.clientHeight) * 100;
          this.trigger("dropdown:scroll", {
            percentage: Math.round(pos)
          });
        }

      }
    },

    getSuggestionDataByNode(tagElm) {
      var idx = tagElm ? +tagElm.getAttribute('tagifySuggestionIdx') : -1;
      return this.suggestedListItems[idx] || null;
    },

    /**
     * mark the currently active suggestion option
     * @param {Object}  elm            option DOM node
     * @param {Boolean} adjustScroll   when navigation with keyboard arrows (up/down), aut-scroll to always show the highlighted element
     */
    highlightOption(elm, adjustScroll) {
      var className = this.settings.classNames.dropdownItemActive,
          itemData; // focus casues a bug in Firefox with the placeholder been shown on the input element
      // if( this.settings.dropdown.position != 'manual' )
      //     elm.focus();

      if (this.state.ddItemElm) {
        this.state.ddItemElm.classList.remove(className);
        this.state.ddItemElm.removeAttribute("aria-selected");
      }

      if (!elm) {
        this.state.ddItemData = null;
        this.state.ddItemElm = null;
        this.input.autocomplete.suggest.call(this);
        return;
      }

      itemData = this.suggestedListItems[this.getNodeIndex(elm)];
      this.state.ddItemData = itemData;
      this.state.ddItemElm = elm; // this.DOM.dropdown.querySelectorAll("." + this.settings.classNames.dropdownItemActive).forEach(activeElm => activeElm.classList.remove(className));

      elm.classList.add(className);
      elm.setAttribute("aria-selected", true);
      if (adjustScroll) elm.parentNode.scrollTop = elm.clientHeight + elm.offsetTop - elm.parentNode.clientHeight; // Try to autocomplete the typed value with the currently highlighted dropdown item

      if (this.settings.autoComplete) {
        this.input.autocomplete.suggest.call(this, itemData);
        this.dropdown.position(); // suggestions might alter the height of the tagify wrapper because of unkown suggested term length that could drop to the next line
      }
    },

    /**
     * Create a tag from the currently active suggestion option
     * @param {Object} elm  DOM node to select
     */
    selectOption(elm) {
      var _this$settings$dropdo = this.settings.dropdown,
          clearOnSelect = _this$settings$dropdo.clearOnSelect,
          closeOnSelect = _this$settings$dropdo.closeOnSelect;

      if (!elm) {
        this.addTags(this.state.inputText, true);
        closeOnSelect && this.dropdown.hide();
        return;
      } // if in edit-mode, do not continue but instead replace the tag's text.
      // the scenario is that "addTags" was called from a dropdown suggested option selected while editing


      var tagifySuggestionIdx = elm.getAttribute('tagifySuggestionIdx'),
          tagData = this.suggestedListItems[+tagifySuggestionIdx];
      this.trigger("dropdown:select", {
        data: tagData,
        elm
      }); // The above event must be triggered, regardless of anything else which might go wrong

      if (!tagifySuggestionIdx || !tagData) {
        this.dropdown.hide();
        return;
      }

      if (this.state.editing) {
        // normalizing value, because "tagData" might be a string, and therefore will not be able to extend the object
        this.onEditTagDone(null, extend({
          __isValid: true
        }, this.normalizeTags([tagData])[0]));
      } // Tagify instances should re-focus to the input element once an option was selected, to allow continuous typing
      else {
        this[this.settings.mode == 'mix' ? "addMixTags" : "addTags"]([tagData], clearOnSelect);
      } // todo: consider not doing this on mix-mode


      if (!this.DOM.input.parentNode) return;
      setTimeout(() => {
        this.DOM.input.focus();
        this.toggleFocusClass(true);
      });

      if (closeOnSelect) {
        setTimeout(this.dropdown.hide.bind(this));
      } else this.dropdown.refilter();
    },

    selectAll() {
      // having suggestedListItems with items messes with "normalizeTags" when wanting
      // to add all tags
      this.suggestedListItems.length = 0;
      this.dropdown.hide(); // some whitelist items might have already been added as tags so when addings all of them,
      // skip adding already-added ones, so best to use "filterListItems" method over "settings.whitelist"

      this.addTags(this.dropdown.filterListItems(''), true);
      return this;
    },

    /**
     * returns an HTML string of the suggestions' list items
     * @param {String} value string to filter the whitelist by
     * @param {Object} options "exact" - for exact complete match
     * @return {Array} list of filtered whitelist items according to the settings provided and current value
     */
    filterListItems(value, options) {
      var _s = this.settings,
          _sd = _s.dropdown,
          options = options || {},
          value = _s.mode == 'select' && this.value.length && this.value[0][_s.tagTextProp] == value ? '' // do not filter if the tag, which is already selecetd in "select" mode, is the same as the typed text
      : value,
          list = [],
          exactMatchesList = [],
          whitelist = _s.whitelist,
          suggestionsCount = _sd.maxItems || Infinity,
          searchKeys = _sd.searchKeys,
          whitelistItem,
          valueIsInWhitelist,
          searchBy,
          isDuplicate,
          niddle,
          i = 0;

      if (!value || !searchKeys.length) {
        return (_s.duplicates ? whitelist : whitelist.filter(item => !this.isTagDuplicate(isObject(item) ? item.value : item)) // don't include tags which have already been added.
        ).slice(0, suggestionsCount); // respect "maxItems" dropdown setting
      }

      niddle = _sd.caseSensitive ? "" + value : ("" + value).toLowerCase(); // checks if ALL of the words in the search query exists in the current whitelist item, regardless of their order

      function stringHasAll(s, query) {
        return query.toLowerCase().split(' ').every(q => s.includes(q.toLowerCase()));
      }

      for (; i < whitelist.length; i++) {
        let startsWithMatch, exactMatch;
        whitelistItem = whitelist[i] instanceof Object ? whitelist[i] : {
          value: whitelist[i]
        }; //normalize value as an Object

        let itemWithoutSearchKeys = !Object.keys(whitelistItem).some(k => searchKeys.includes(k)),
            _searchKeys = itemWithoutSearchKeys ? ["value"] : searchKeys;

        if (_sd.fuzzySearch && !options.exact) {
          searchBy = _searchKeys.reduce((values, k) => values + " " + (whitelistItem[k] || ""), "").toLowerCase().trim();

          if (_sd.accentedSearch) {
            searchBy = unaccent(searchBy);
            niddle = unaccent(niddle);
          }

          startsWithMatch = searchBy.indexOf(niddle) == 0;
          exactMatch = searchBy === niddle;
          valueIsInWhitelist = stringHasAll(searchBy, niddle);
        } else {
          startsWithMatch = true;
          valueIsInWhitelist = _searchKeys.some(k => {
            var v = '' + (whitelistItem[k] || ''); // if key exists, cast to type String

            if (_sd.accentedSearch) {
              v = unaccent(v);
              niddle = unaccent(niddle);
            }

            if (!_sd.caseSensitive) v = v.toLowerCase();
            exactMatch = v === niddle;
            return options.exact ? v === niddle : v.indexOf(niddle) == 0;
          });
        }

        isDuplicate = !_s.duplicates && this.isTagDuplicate(isObject(whitelistItem) ? whitelistItem.value : whitelistItem); // match for the value within each "whitelist" item

        if (valueIsInWhitelist && !isDuplicate) if (exactMatch && startsWithMatch) exactMatchesList.push(whitelistItem);else if (_sd.sortby == 'startsWith' && startsWithMatch) list.unshift(whitelistItem);else list.push(whitelistItem);
      } // custom sorting function


      return typeof _sd.sortby == 'function' ? _sd.sortby(exactMatchesList.concat(list), niddle) : exactMatchesList.concat(list).slice(0, suggestionsCount);
    },

    /**
     * Returns the final value of a tag data (object) with regards to the "mapValueTo" dropdown setting
     * @param {Object} tagData
     * @returns
     */
    getMappedValue(tagData) {
      var mapValueTo = this.settings.dropdown.mapValueTo,
          value = mapValueTo ? typeof mapValueTo == 'function' ? mapValueTo(tagData) : tagData[mapValueTo] || tagData.value : tagData.value;
      return value;
    },

    /**
     * Creates the dropdown items' HTML
     * @param  {Array} list  [Array of Objects]
     * @return {String}
     */
    createListHTML(optionsArr) {
      return extend([], optionsArr).map((suggestion, idx) => {
        if (typeof suggestion == 'string' || typeof suggestion == 'number') suggestion = {
          value: suggestion
        };
        var value = this.dropdown.getMappedValue(suggestion);
        suggestion.value = value && typeof value == 'string' ? escapeHTML(value) : value;
        var tagHTMLString = this.settings.templates.dropdownItem.apply(this, [suggestion, this]); // make sure the sugestion index is present as attribute, to match the data when one is selected

        tagHTMLString = tagHTMLString.replace(/\s*tagifySuggestionIdx=(["'])(.*?)\1/gmi, '') // remove the "tagifySuggestionIdx" attribute if for some reason was there
        .replace('>', ` tagifySuggestionIdx="${idx}">`); // add "tagifySuggestionIdx"

        return tagHTMLString;
      }).join("");
    }

  };

  const VERSION = 1; // current version of persisted data. if code change breaks persisted data, verison number should be bumped.

  const STORE_KEY = '@yaireo/tagify/';
  const getPersistedData = id => key => {
    // if "persist" is "false", do not save to localstorage
    let customKey = '/' + key,
        persistedData,
        versionMatch = localStorage.getItem(STORE_KEY + id + '/v', VERSION) == VERSION;

    if (versionMatch) {
      try {
        persistedData = JSON.parse(localStorage[STORE_KEY + id + customKey]);
      } catch (err) {}
    }

    return persistedData;
  };
  const setPersistedData = id => {
    if (!id) return () => {}; // for storage invalidation

    localStorage.setItem(STORE_KEY + id + '/v', VERSION);
    return (data, key) => {
      let customKey = '/' + key,
          persistedData = JSON.stringify(data);

      if (data && key) {
        localStorage.setItem(STORE_KEY + id + customKey, persistedData);
        dispatchEvent(new Event('storage'));
      }
    };
  };
  const clearPersistedData = id => key => {
    const base = STORE_KEY + '/' + id + '/'; // delete specific key in the storage

    if (key) localStorage.removeItem(base + key); // delete all keys in the storage with a specific tagify id
    else {
      for (let k in localStorage) if (k.includes(base)) localStorage.removeItem(k);
    }
  };

  var TEXTS = {
    empty: "empty",
    exceed: "number of tags exceeded",
    pattern: "pattern mismatch",
    duplicate: "already exists",
    notAllowed: "not allowed"
  };

  var templates = {
    /**
     *
     * @param {DOM Object} input     Original input DOm element
     * @param {Object}     settings  Tagify instance settings Object
     */
    wrapper(input, _s) {
      return `<tags class="${_s.classNames.namespace} ${_s.mode ? `${_s.classNames[_s.mode + "Mode"]}` : ""} ${input.className}"
                    ${_s.readonly ? 'readonly' : ''}
                    ${_s.disabled ? 'disabled' : ''}
                    ${_s.required ? 'required' : ''}
                    tabIndex="-1">
            <span ${!_s.readonly && _s.userInput ? 'contenteditable' : ''} tabIndex="0" data-placeholder="${_s.placeholder || '&#8203;'}" aria-placeholder="${_s.placeholder || ''}"
                class="${_s.classNames.input}"
                role="textbox"
                aria-autocomplete="both"
                aria-multiline="${_s.mode == 'mix' ? true : false}"></span>
                &#8203;
        </tags>`;
    },

    tag(tagData, tagify) {
      var _s = this.settings;
      return `<tag title="${tagData.title || tagData.value}"
                    contenteditable='false'
                    spellcheck='false'
                    tabIndex="${_s.a11y.focusableTags ? 0 : -1}"
                    class="${_s.classNames.tag} ${tagData.class || ""}"
                    ${this.getAttributes(tagData)}>
            <x title='' class="${_s.classNames.tagX}" role='button' aria-label='remove tag'></x>
            <div>
                <span class="${_s.classNames.tagText}">${tagData[_s.tagTextProp] || tagData.value}</span>
            </div>
        </tag>`;
    },

    dropdown(settings) {
      var _sd = settings.dropdown,
          isManual = _sd.position == 'manual',
          className = `${settings.classNames.dropdown}`;
      return `<div class="${isManual ? "" : className} ${_sd.classname}" role="listbox" aria-labelledby="dropdown">
                    <div class="${settings.classNames.dropdownWrapper}"></div>
                </div>`;
    },

    dropdownItem(item, tagify) {
      return `<div ${this.getAttributes(item)}
                    class='${this.settings.classNames.dropdownItem} ${item.class ? item.class : ""}'
                    tabindex="0"
                    role="option">${item.value}</div>`;
    },

    dropdownItemNoMatch: null
  };

  function EventDispatcher(instance) {
    // Create a DOM EventTarget object
    var target = document.createTextNode('');

    function addRemove(op, events, cb) {
      if (cb) events.split(/\s+/g).forEach(name => target[op + 'EventListener'].call(target, name, cb));
    } // Pass EventTarget interface calls to DOM EventTarget object


    return {
      off(events, cb) {
        addRemove('remove', events, cb);
        return this;
      },

      on(events, cb) {
        if (cb && typeof cb == 'function') addRemove('add', events, cb);
        return this;
      },

      trigger(eventName, data, opts) {
        var e;
        opts = opts || {
          cloneData: true
        };
        if (!eventName) return;

        if (instance.settings.isJQueryPlugin) {
          if (eventName == 'remove') eventName = 'removeTag'; // issue #222

          jQuery(instance.DOM.originalInput).triggerHandler(eventName, [data]);
        } else {
          try {
            var eventData = typeof data === 'object' ? data : {
              value: data
            };
            eventData = opts.cloneData ? extend({}, eventData) : eventData;
            eventData.tagify = this; // TODO: move the below to the "extend" function

            if (data instanceof Object) for (var prop in data) if (data[prop] instanceof HTMLElement) eventData[prop] = data[prop];
            e = new CustomEvent(eventName, {
              "detail": eventData
            });
          } catch (err) {
            console.warn(err);
          }

          target.dispatchEvent(e);
        }
      }

    };
  }

  var deleteBackspaceTimeout;
  function triggerChangeEvent() {
    if (this.settings.mixMode.integrated) return;
    var inputElm = this.DOM.originalInput,
        changed = this.state.lastOriginalValueReported !== inputElm.value,
        event = new CustomEvent("change", {
      bubbles: true
    }); // must use "CustomEvent" and not "Event" to support IE

    if (!changed) return; // must apply this BEFORE triggering the simulated event

    this.state.lastOriginalValueReported = inputElm.value; // React hack: https://github.com/facebook/react/issues/11488

    event.simulated = true;
    if (inputElm._valueTracker) inputElm._valueTracker.setValue(Math.random());
    inputElm.dispatchEvent(event); // also trigger a Tagify event

    this.trigger("change", this.state.lastOriginalValueReported); // React, for some reason, clears the input's value after "dispatchEvent" is fired

    inputElm.value = this.state.lastOriginalValueReported;
  }
  var events = {
    // bind custom events which were passed in the settings
    customBinding() {
      this.customEventsList.forEach(name => {
        this.on(name, this.settings.callbacks[name]);
      });
    },

    binding(bindUnbind = true) {
      var _CB = this.events.callbacks,
          _CBR,
          action = bindUnbind ? 'addEventListener' : 'removeEventListener'; // do not allow the main events to be bound more than once


      if (this.state.mainEvents && bindUnbind) return; // set the binding state of the main events, so they will not be bound more than once

      this.state.mainEvents = bindUnbind; // everything inside gets executed only once-per instance

      if (bindUnbind && !this.listeners.main) {
        this.events.bindGlobal.call(this);
        if (this.settings.isJQueryPlugin) jQuery(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this));
      } // setup callback references so events could be removed later


      _CBR = this.listeners.main = this.listeners.main || {
        focus: ['input', _CB.onFocusBlur.bind(this)],
        keydown: ['input', _CB.onKeydown.bind(this)],
        click: ['scope', _CB.onClickScope.bind(this)],
        dblclick: ['scope', _CB.onDoubleClickScope.bind(this)],
        paste: ['input', _CB.onPaste.bind(this)],
        drop: ['input', _CB.onDrop.bind(this)]
      };

      for (var eventName in _CBR) {
        this.DOM[_CBR[eventName][0]][action](eventName, _CBR[eventName][1]);
      } // listen to original input changes (unfortunetly this is the best way...)
      // https://stackoverflow.com/a/1949416/104380


      clearInterval(this.listeners.main.originalInputValueObserverInterval);
      this.listeners.main.originalInputValueObserverInterval = setInterval(_CB.observeOriginalInputValue.bind(this), 500); // observers

      var inputMutationObserver = this.listeners.main.inputMutationObserver || new MutationObserver(_CB.onInputDOMChange.bind(this)); // cleaup just-in-case

      if (inputMutationObserver) inputMutationObserver.disconnect(); // observe stuff

      if (this.settings.mode == 'mix') inputMutationObserver.observe(this.DOM.input, {
        childList: true
      });
    },

    bindGlobal(unbind) {
      var _CB = this.events.callbacks,
          action = unbind ? 'removeEventListener' : 'addEventListener',
          e;
      if (!unbind && this.listeners.global) return; // do not re-bind
      // these events are global event should never be unbinded, unless the instance is destroyed:

      this.listeners.global = this.listeners && this.listeners.global || [{
        type: this.isIE ? 'keydown' : 'input',
        // IE cannot register "input" events on contenteditable elements, so the "keydown" should be used instead..
        target: this.DOM.input,
        cb: _CB[this.isIE ? 'onInputIE' : 'onInput'].bind(this)
      }, {
        type: 'keydown',
        target: window,
        cb: _CB.onWindowKeyDown.bind(this)
      }, {
        type: 'blur',
        target: this.DOM.input,
        cb: _CB.onFocusBlur.bind(this)
      }];

      for (e of this.listeners.global) e.target[action](e.type, e.cb);
    },

    unbindGlobal() {
      this.events.bindGlobal.call(this, true);
    },

    /**
     * DOM events callbacks
     */
    callbacks: {
      onFocusBlur(e) {
        var text = e.target ? this.trim(e.target.textContent) : '',
            // a string
        _s = this.settings,
            type = e.type,
            ddEnabled = _s.dropdown.enabled >= 0,
            eventData = {
          relatedTarget: e.relatedTarget
        },
            isTargetSelectOption = this.state.actions.selectOption && (ddEnabled || !_s.dropdown.closeOnSelect),
            isTargetAddNewBtn = this.state.actions.addNew && ddEnabled,
            isRelatedTargetX = e.relatedTarget && isNodeTag.call(this, e.relatedTarget) && this.DOM.scope.contains(e.relatedTarget),
            shouldAddTags;

        if (type == 'blur') {
          if (e.relatedTarget === this.DOM.scope) {
            this.dropdown.hide();
            this.DOM.input.focus();
            return;
          }

          this.postUpdate();
          this.triggerChangeEvent();
        }

        if (isTargetSelectOption || isTargetAddNewBtn) return;
        this.state.hasFocus = type == "focus" ? +new Date() : false;
        this.toggleFocusClass(this.state.hasFocus);

        if (_s.mode == 'mix') {
          if (type == "focus") {
            this.trigger("focus", eventData);
          } else if (e.type == "blur") {
            this.trigger("blur", eventData);
            this.loading(false);
            this.dropdown.hide(); // reset state which needs reseting

            this.state.dropdown.visible = undefined;
            this.setStateSelection();
          }

          return;
        }

        if (type == "focus") {
          this.trigger("focus", eventData); //  e.target.classList.remove('placeholder');

          if (_s.dropdown.enabled === 0 || !_s.userInput) {
            // && _s.mode != "select"
            this.dropdown.show(this.value.length ? '' : undefined);
          }

          return;
        } else if (type == "blur") {
          this.trigger("blur", eventData);
          this.loading(false); // when clicking the X button of a selected tag, it is unwanted it will be added back
          // again in a few more lines of code (shouldAddTags && addTags)

          if (this.settings.mode == 'select' && isRelatedTargetX) text = '';
          shouldAddTags = this.settings.mode == 'select' && text ? !this.value.length || this.value[0].value != text : text && !this.state.actions.selectOption && _s.addTagOnBlur; // do not add a tag if "selectOption" action was just fired (this means a tag was just added from the dropdown)

          shouldAddTags && this.addTags(text, true);
          if (this.settings.mode == 'select' && !text) this.removeTags();
        }

        this.DOM.input.removeAttribute('style');
        this.dropdown.hide();
      },

      onWindowKeyDown(e) {
        var focusedElm = document.activeElement,
            isTag = isNodeTag.call(this, focusedElm),
            isBelong = isTag && this.DOM.scope.contains(document.activeElement),
            nextTag;
        if (!isBelong) return;
        nextTag = focusedElm.nextElementSibling;

        switch (e.key) {
          // remove tag if has focus
          case 'Backspace':
            {
              if (!this.settings.readonly) {
                this.removeTags(focusedElm);
                (nextTag ? nextTag : this.DOM.input).focus();
              }

              break;
            }
          // edit tag if has focus

          case 'Enter':
            {
              setTimeout(this.editTag.bind(this), 0, focusedElm);
              break;
            }
        }
      },

      onKeydown(e) {
        var _s = this.settings;

        if (_s.mode == 'select' && _s.enforceWhitelist && this.value.length && e.key != 'Tab') {
          e.preventDefault();
        }

        var s = this.trim(e.target.textContent);
        this.trigger("keydown", {
          originalEvent: this.cloneEvent(e)
        });
        /**
         * ONLY FOR MIX-MODE:
         */

        if (_s.mode == 'mix') {
          switch (e.key) {
            case 'Left':
            case 'ArrowLeft':
              {
                // when left arrow was pressed, set a flag so when the dropdown is shown, right-arrow will be ignored
                // because it seems likely the user wishes to use the arrows to move the caret
                this.state.actions.ArrowLeft = true;
                break;
              }

            case 'Delete':
            case 'Backspace':
              {
                if (this.state.editing) return;
                var sel = document.getSelection(),
                    deleteKeyTagDetected = e.key == 'Delete' && sel.anchorOffset == (sel.anchorNode.length || 0),
                    prevAnchorSibling = sel.anchorNode.previousSibling,
                    isCaretAfterTag = sel.anchorNode.nodeType == 1 || !sel.anchorOffset && prevAnchorSibling && prevAnchorSibling.nodeType == 1 && sel.anchorNode.previousSibling,
                    lastInputValue = decode(this.DOM.input.innerHTML),
                    lastTagElems = this.getTagElms(),
                    //  isCaretInsideTag = sel.anchorNode.parentNode('.' + _s.classNames.tag),
                tagBeforeCaret,
                    tagElmToBeDeleted,
                    firstTextNodeBeforeTag;

                if (_s.backspace == 'edit' && isCaretAfterTag) {
                  tagBeforeCaret = sel.anchorNode.nodeType == 1 ? null : sel.anchorNode.previousElementSibling;
                  setTimeout(this.editTag.bind(this), 0, tagBeforeCaret); // timeout is needed to the last cahacrter in the edited tag won't get deleted

                  e.preventDefault(); // needed so the tag elm won't get deleted

                  return;
                }

                if (isChromeAndroidBrowser() && isCaretAfterTag) {
                  firstTextNodeBeforeTag = getfirstTextNode(isCaretAfterTag);
                  if (!isCaretAfterTag.hasAttribute('readonly')) isCaretAfterTag.remove(); // since this is Chrome, can safetly use this "new" DOM API
                  // Android-Chrome wrongly hides the keyboard, and loses focus,
                  // so this hack below is needed to regain focus at the correct place:

                  this.DOM.input.focus();
                  setTimeout(() => {
                    this.placeCaretAfterNode(firstTextNodeBeforeTag);
                    this.DOM.input.click();
                  });
                  return;
                }

                if (sel.anchorNode.nodeName == 'BR') return;
                if ((deleteKeyTagDetected || isCaretAfterTag) && sel.anchorNode.nodeType == 1) {
                  if (sel.anchorOffset == 0) // caret is at the very begining, before a tag
                    tagElmToBeDeleted = deleteKeyTagDetected // delete key pressed
                    ? lastTagElems[0] : null;else tagElmToBeDeleted = lastTagElems[sel.anchorOffset - 1]; // find out if a tag *might* be a candidate for deletion, and if so, which

                } else if (deleteKeyTagDetected) tagElmToBeDeleted = sel.anchorNode.nextElementSibling;else if (isCaretAfterTag) tagElmToBeDeleted = isCaretAfterTag; // tagElm.hasAttribute('readonly')

                if (sel.anchorNode.nodeType == 3 && // node at caret location is a Text node
                !sel.anchorNode.nodeValue && // has some text
                sel.anchorNode.previousElementSibling) // text node has a Tag node before it
                  e.preventDefault(); // if backspace not allowed, do nothing
                // TODO: a better way to detect if nodes were deleted is to simply check the "this.value" before & after

                if ((isCaretAfterTag || deleteKeyTagDetected) && !_s.backspace) {
                  e.preventDefault();
                  return;
                }

                if (sel.type != 'Range' && !sel.anchorOffset && sel.anchorNode == this.DOM.input && e.key != 'Delete') {
                  e.preventDefault();
                  return;
                }

                if (sel.type != 'Range' && tagElmToBeDeleted && tagElmToBeDeleted.hasAttribute('readonly')) {
                  // allows the continuation of deletion by placing the caret on the first previous textNode.
                  // since a few readonly-tags might be one after the other, iteration is needed:
                  this.placeCaretAfterNode(getfirstTextNode(tagElmToBeDeleted));
                  return;
                } // update regarding https://github.com/yairEO/tagify/issues/762#issuecomment-786464317:
                // the bug described is more severe than the fix below, therefore I disable the fix until a solution
                // is found which work well for both cases.
                // -------
                // nodeType is "1" only when the caret is at the end after last tag (no text after), or before first first (no text before)

                /*
                if( this.isFirefox && sel.anchorNode.nodeType == 1 && sel.anchorOffset != 0 ){
                    this.removeTags() // removes last tag by default if no parameter supplied
                    // place caret inside last textNode, if exist. it's an annoying bug only in FF,
                    // if the last tag is removed, and there is a textNode before it, the caret is not placed at its end
                    this.placeCaretAfterNode( this.setRangeAtStartEnd() )
                }
                */


                clearTimeout(deleteBackspaceTimeout); // a minimum delay is needed before the node actually gets detached from the document (don't know why),
                // to know exactly which tag was deleted. This is the easiest way of knowing besides using MutationObserver

                deleteBackspaceTimeout = setTimeout(() => {
                  var sel = document.getSelection(),
                      currentValue = decode(this.DOM.input.innerHTML),
                      prevElm = !deleteKeyTagDetected && sel.anchorNode.previousSibling; // fixes #384, where the first and only tag will not get removed with backspace

                  if (currentValue.length >= lastInputValue.length && prevElm) {
                    if (isNodeTag.call(this, prevElm) && !prevElm.hasAttribute('readonly')) {
                      this.removeTags(prevElm);
                      this.fixFirefoxLastTagNoCaret(); // the above "removeTag" methods removes the tag with a transition. Chrome adds a <br> element for some reason at this stage

                      if (this.DOM.input.children.length == 2 && this.DOM.input.children[1].tagName == "BR") {
                        this.DOM.input.innerHTML = "";
                        this.value.length = 0;
                        return true;
                      }
                    } else prevElm.remove();
                  } // find out which tag(s) were deleted and trigger "remove" event
                  // iterate over the list of tags still in the document and then filter only those from the "this.value" collection


                  this.value = [].map.call(lastTagElems, (node, nodeIdx) => {
                    var tagData = this.tagData(node); // since readonly cannot be removed (it's technically resurrected if removed somehow)

                    if (node.parentNode || tagData.readonly) return tagData;else this.trigger('remove', {
                      tag: node,
                      index: nodeIdx,
                      data: tagData
                    });
                  }).filter(n => n); // remove empty items in the mapped array
                }, 20); // Firefox needs this higher duration for some reason or things get buggy when deleting text from the end

                break;
              }
            // currently commented to allow new lines in mixed-mode
            // case 'Enter' :
            //     // e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380
          }

          return true;
        }

        switch (e.key) {
          case 'Backspace':
            if (_s.mode == 'select' && _s.enforceWhitelist && this.value.length) this.removeTags();else if (!this.state.dropdown.visible || _s.dropdown.position == 'manual') {
              if (e.target.textContent == "" || s.charCodeAt(0) == 8203) {
                // 8203: ZERO WIDTH SPACE unicode
                if (_s.backspace === true) this.removeTags();else if (_s.backspace == 'edit') setTimeout(this.editTag.bind(this), 0); // timeout reason: when edited tag gets focused and the caret is placed at the end, the last character gets deletec (because of backspace)
              }
            }
            break;

          case 'Esc':
          case 'Escape':
            if (this.state.dropdown.visible) return;
            e.target.blur();
            break;

          case 'Down':
          case 'ArrowDown':
            // if( _s.mode == 'select' ) // issue #333
            if (!this.state.dropdown.visible) this.dropdown.show();
            break;

          case 'ArrowRight':
            {
              let tagData = this.state.inputSuggestion || this.state.ddItemData;

              if (tagData && _s.autoComplete.rightKey) {
                this.addTags([tagData], true);
                return;
              }

              break;
            }

          case 'Tab':
            {
              let selectMode = _s.mode == 'select';
              if (s && !selectMode) e.preventDefault();else return true;
            }

          case 'Enter':
            if (this.state.dropdown.visible || e.keyCode == 229) return;
            e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380
            // because the main "keydown" event is bound before the dropdown events, this will fire first and will not *yet*
            // know if an option was just selected from the dropdown menu. If an option was selected,
            // the dropdown events should handle adding the tag

            setTimeout(() => {
              if (this.state.actions.selectOption) return;
              this.addTags(s, true);
            });
        }
      },

      onInput(e) {
        this.postUpdate(); // toggles "tagify--empty" class

        if (this.settings.mode == 'mix') return this.events.callbacks.onMixTagsInput.call(this, e);
        var value = this.input.normalize.call(this),
            showSuggestions = value.length >= this.settings.dropdown.enabled,
            eventData = {
          value,
          inputElm: this.DOM.input
        };
        eventData.isValid = this.validateTag({
          value
        }); // for IE; since IE doesn't have an "input" event so "keyDown" is used instead to trigger the "onInput" callback,
        // and so many keys do not change the input, and for those do not continue.

        if (this.state.inputText == value) return; // save the value on the input's State object

        this.input.set.call(this, value, false); // update the input with the normalized value and run validations
        // this.setRangeAtStartEnd(); // fix caret position

        if (value.search(this.settings.delimiters) != -1) {
          if (this.addTags(value)) {
            this.input.set.call(this); // clear the input field's value
          }
        } else if (this.settings.dropdown.enabled >= 0) {
          this.dropdown[showSuggestions ? "show" : "hide"](value);
        }

        this.trigger('input', eventData); // "input" event must be triggered at this point, before the dropdown is shown
      },

      onMixTagsInput(e) {
        var rangeText,
            match,
            matchedPatternCount,
            tag,
            showSuggestions,
            selection,
            _s = this.settings,
            lastTagsCount = this.value.length,
            matchFlaggedTag,
            matchDelimiters,
            tagsElems = this.getTagElms(),
            fragment = document.createDocumentFragment(),
            range = window.getSelection().getRangeAt(0),
            remainingTagsValues = [].map.call(tagsElems, node => this.tagData(node).value); // Android Chrome "keydown" event argument does not report the correct "key".
        // this workaround is needed to manually call "onKeydown" method with a synthesized event object

        if (e.inputType == "deleteContentBackward" && isChromeAndroidBrowser()) {
          this.events.callbacks.onKeydown.call(this, {
            target: e.target,
            key: "Backspace"
          });
        } // re-add "readonly" tags which might have been removed


        this.value.slice().forEach(item => {
          if (item.readonly && !remainingTagsValues.includes(item.value)) fragment.appendChild(this.createTagElem(item));
        });

        if (fragment.childNodes.length) {
          range.insertNode(fragment);
          this.setRangeAtStartEnd(false, fragment.lastChild);
        } // check if tags were "magically" added/removed (browser redo/undo or CTRL-A -> delete)


        if (tagsElems.length != lastTagsCount) {
          this.value = [].map.call(this.getTagElms(), node => this.tagData(node));
          this.update({
            withoutChangeEvent: true
          });
          return;
        }

        if (this.hasMaxTags()) return true;

        if (window.getSelection) {
          selection = window.getSelection(); // only detect tags if selection is inside a textNode (not somehow on already-existing tag)

          if (selection.rangeCount > 0 && selection.anchorNode.nodeType == 3) {
            range = selection.getRangeAt(0).cloneRange();
            range.collapse(true);
            range.setStart(selection.focusNode, 0);
            rangeText = range.toString().slice(0, range.endOffset); // slice the range so everything AFTER the caret will be trimmed
            // split = range.toString().split(_s.mixTagsAllowedAfter)  // ["foo", "bar", "@baz"]

            matchedPatternCount = rangeText.split(_s.pattern).length - 1;
            match = rangeText.match(_s.pattern);
            if (match) // tag string, example: "@aaa ccc"
              tag = rangeText.slice(rangeText.lastIndexOf(match[match.length - 1]));

            if (tag) {
              this.state.actions.ArrowLeft = false; // start fresh, assuming the user did not (yet) used any arrow to move the caret

              this.state.tag = {
                prefix: tag.match(_s.pattern)[0],
                value: tag.replace(_s.pattern, '') // get rid of the prefix

              };
              this.state.tag.baseOffset = selection.baseOffset - this.state.tag.value.length;
              matchDelimiters = this.state.tag.value.match(_s.delimiters); // if a delimeter exists, add the value as tag (exluding the delimiter)

              if (matchDelimiters) {
                this.state.tag.value = this.state.tag.value.replace(_s.delimiters, '');
                this.state.tag.delimiters = matchDelimiters[0];
                this.addTags(this.state.tag.value, _s.dropdown.clearOnSelect);
                this.dropdown.hide();
                return;
              }

              showSuggestions = this.state.tag.value.length >= _s.dropdown.enabled; // When writeing something that might look like a tag (an email address) but isn't one - it is unwanted
              // the suggestions dropdown be shown, so the user closes it (in any way), and while continue typing,
              // dropdown should stay closed until another tag is typed.
              // if( this.state.tag.value.length && this.state.dropdown.visible === false )
              //     showSuggestions = false
              // test for similar flagged tags to the current tag

              try {
                matchFlaggedTag = this.state.flaggedTags[this.state.tag.baseOffset];
                matchFlaggedTag = matchFlaggedTag.prefix == this.state.tag.prefix && matchFlaggedTag.value[0] == this.state.tag.value[0]; // reset

                if (this.state.flaggedTags[this.state.tag.baseOffset] && !this.state.tag.value) delete this.state.flaggedTags[this.state.tag.baseOffset];
              } catch (err) {} // scenario: (do not show suggestions of previous matched tag, if more than 1 detected)
              // (2 tags exist)                          " a@a.com and @"
              // (second tag is removed by backspace)    " a@a.com and "


              if (matchFlaggedTag || matchedPatternCount < this.state.mixMode.matchedPatternCount) showSuggestions = false;
            } // no (potential) tag found
            else {
              this.state.flaggedTags = {};
            }

            this.state.mixMode.matchedPatternCount = matchedPatternCount;
          }
        } // wait until the "this.value" has been updated (see "onKeydown" method for "mix-mode")
        // the dropdown must be shown only after this event has been driggered, so an implementer could
        // dynamically change the whitelist.


        setTimeout(() => {
          this.update({
            withoutChangeEvent: true
          });
          this.trigger("input", extend({}, this.state.tag, {
            textContent: this.DOM.input.textContent
          }));
          if (this.state.tag) this.dropdown[showSuggestions ? "show" : "hide"](this.state.tag.value);
        }, 10);
      },

      onInputIE(e) {
        var _this = this; // for the "e.target.textContent" to be changed, the browser requires a small delay


        setTimeout(function () {
          _this.events.callbacks.onInput.call(_this, e);
        });
      },

      observeOriginalInputValue() {
        // if original input value changed for some reason (for exmaple a form reset)
        if (this.DOM.originalInput.value != this.DOM.originalInput.tagifyValue) this.loadOriginalValues();
      },

      onClickScope(e) {
        var _s = this.settings,
            tagElm = e.target.closest('.' + _s.classNames.tag),
            timeDiffFocus = +new Date() - this.state.hasFocus;

        if (e.target == this.DOM.scope) {
          if (!this.state.hasFocus) this.DOM.input.focus();
          return;
        } else if (e.target.classList.contains(_s.classNames.tagX)) {
          this.removeTags(e.target.parentNode);
          return;
        } else if (tagElm) {
          this.trigger("click", {
            tag: tagElm,
            index: this.getNodeIndex(tagElm),
            data: this.tagData(tagElm),
            originalEvent: this.cloneEvent(e)
          });
          if (_s.editTags === 1 || _s.editTags.clicks === 1) this.events.callbacks.onDoubleClickScope.call(this, e);
          return;
        } // when clicking on the input itself
        else if (e.target == this.DOM.input) {
          if (_s.mode == 'mix') {
            // firefox won't show caret if last element is a tag (and not a textNode),
            // so an empty textnode should be added
            this.fixFirefoxLastTagNoCaret();
          }

          if (timeDiffFocus > 500) {
            if (this.state.dropdown.visible) this.dropdown.hide();else if (_s.dropdown.enabled === 0 && _s.mode != 'mix') this.dropdown.show(this.value.length ? '' : undefined);
            return;
          }
        }

        if (_s.mode == 'select') !this.state.dropdown.visible && this.dropdown.show();
      },

      // special proccess is needed for pasted content in order to "clean" it
      onPaste(e) {
        e.preventDefault();
        var _s = this.settings,
            selectModeWithoutInput = _s.mode == 'select' && _s.enforceWhitelist;

        if (selectModeWithoutInput || !_s.userInput) {
          return false;
        }

        var clipboardData, pastedText;
        if (_s.readonly) return; // Get pasted data via clipboard API

        clipboardData = e.clipboardData || window.clipboardData;
        pastedText = clipboardData.getData('Text');

        _s.hooks.beforePaste(e, {
          tagify: this,
          pastedText,
          clipboardData
        }).then(result => {
          if (result === undefined) result = pastedText;

          if (result) {
            this.injectAtCaret(result, window.getSelection().getRangeAt(0));

            if (this.settings.mode == 'mix') {
              this.events.callbacks.onMixTagsInput.call(this, e);
            } else if (this.settings.pasteAsTags) {
              this.addTags(this.state.inputText + result, true);
            } else this.state.inputText = result;
          }
        }).catch(err => err);
      },

      onDrop(e) {
        e.preventDefault();
      },

      onEditTagInput(editableElm, e) {
        var tagElm = editableElm.closest('.' + this.settings.classNames.tag),
            tagElmIdx = this.getNodeIndex(tagElm),
            tagData = this.tagData(tagElm),
            value = this.input.normalize.call(this, editableElm),
            hasChanged = tagElm.innerHTML != tagElm.__tagifyTagData.__originalHTML,
            isValid = this.validateTag({
          [this.settings.tagTextProp]: value
        }); // the value could have been invalid in the first-place so make sure to re-validate it (via "addEmptyTag" method)
        // if the value is same as before-editing and the tag was valid before as well, ignore the  current "isValid" result, which is false-positive

        if (!hasChanged && editableElm.originalIsValid === true) isValid = true;
        tagElm.classList.toggle(this.settings.classNames.tagInvalid, isValid !== true);
        tagData.__isValid = isValid;
        tagElm.title = isValid === true ? tagData.title || tagData.value : isValid; // change the tag's title to indicate why is the tag invalid (if it's so)
        // show dropdown if typed text is equal or more than the "enabled" dropdown setting

        if (value.length >= this.settings.dropdown.enabled) {
          // this check is needed apparently because doing browser "undo" will fire
          //  "onEditTagInput" but "this.state.editing" will be "false"
          if (this.state.editing) this.state.editing.value = value;
          this.dropdown.show(value);
        }

        this.trigger("edit:input", {
          tag: tagElm,
          index: tagElmIdx,
          data: extend({}, this.value[tagElmIdx], {
            newValue: value
          }),
          originalEvent: this.cloneEvent(e)
        });
      },

      onEditTagFocus(tagElm) {
        this.state.editing = {
          scope: tagElm,
          input: tagElm.querySelector("[contenteditable]")
        };
      },

      onEditTagBlur(editableElm) {
        if (!this.state.hasFocus) this.toggleFocusClass(); // one scenario is when selecting a suggestion from the dropdown, when editing, and by selecting it
        // the "onEditTagDone" is called directly, already replacing the tag, so the argument "editableElm"
        // node isn't in the DOM anynmore because it has been replaced.

        if (!this.DOM.scope.contains(editableElm)) return;

        var _s = this.settings,
            tagElm = editableElm.closest('.' + _s.classNames.tag),
            textValue = this.input.normalize.call(this, editableElm),
            originalData = this.tagData(tagElm).__originalData,
            // pre-edit data
        hasChanged = tagElm.innerHTML != tagElm.__tagifyTagData.__originalHTML,
            isValid = this.validateTag({
          [_s.tagTextProp]: textValue
        }),
            hasMaxTags,
            newTagData; //  this.DOM.input.focus()


        if (!textValue) {
          this.onEditTagDone(tagElm);
          return;
        } // if nothing changed revert back to how it was before editing


        if (!hasChanged) {
          this.onEditTagDone(tagElm, originalData);
          return;
        } // need to know this because if "keepInvalidTags" setting is "true" and an invalid tag is edited as a valid one,
        // but the maximum number of tags have alreay been reached, so it should not allow saving the new valid value.
        // only if the tag was already valid before editing, ignore this check (see a few lines below)


        hasMaxTags = this.hasMaxTags();
        newTagData = this.getWhitelistItem(textValue) || extend({}, originalData, {
          [_s.tagTextProp]: textValue,
          value: textValue,
          __isValid: isValid
        });

        _s.transformTag.call(this, newTagData, originalData); // MUST re-validate after tag transformation
        // only validate the "tagTextProp" because is the only thing that metters for validating an edited tag.
        // -- Scenarios: --
        // 1. max 3 tags allowd. there are 4 tags, one has invalid input and is edited to a valid one, and now should be marked as "not allowed" because limit of tags has reached
        // 2. max 3 tags allowed. there are 3 tags, one is edited, and so max-tags vaildation should be OK


        isValid = (!hasMaxTags || originalData.__isValid === true) && this.validateTag({
          [_s.tagTextProp]: newTagData[_s.tagTextProp]
        });

        if (isValid !== true) {
          this.trigger("invalid", {
            data: newTagData,
            tag: tagElm,
            message: isValid
          }); // do nothing if invalid, stay in edit-mode until corrected or reverted by presssing esc

          if (_s.editTags.keepInvalid) return;
          if (_s.keepInvalidTags) newTagData.__isValid = isValid;else // revert back if not specified to keep
            newTagData = originalData;
        } else if (_s.keepInvalidTags) {
          // cleaup any previous leftovers if the tag was
          delete newTagData.title;
          delete newTagData["aria-invalid"];
          delete newTagData.class;
        } // tagElm.classList.toggle(_s.classNames.tagInvalid, true)


        this.onEditTagDone(tagElm, newTagData);
      },

      onEditTagkeydown(e, tagElm) {
        this.trigger("edit:keydown", {
          originalEvent: this.cloneEvent(e)
        });

        switch (e.key) {
          case 'Esc':
          case 'Escape':
            tagElm.innerHTML = tagElm.__tagifyTagData.__originalHTML;

          case 'Enter':
          case 'Tab':
            e.preventDefault();
            e.target.blur();
        }
      },

      onDoubleClickScope(e) {
        var tagElm = e.target.closest('.' + this.settings.classNames.tag),
            _s = this.settings,
            isEditingTag,
            isReadyOnlyTag;
        if (!tagElm || !_s.userInput) return;
        isEditingTag = tagElm.classList.contains(this.settings.classNames.tagEditing);
        isReadyOnlyTag = tagElm.hasAttribute('readonly');
        if (_s.mode != 'select' && !_s.readonly && !isEditingTag && !isReadyOnlyTag && this.settings.editTags) this.editTag(tagElm);
        this.toggleFocusClass(true);
        this.trigger('dblclick', {
          tag: tagElm,
          index: this.getNodeIndex(tagElm),
          data: this.tagData(tagElm)
        });
      },

      /**
       *
       * @param {Object} m an object representing the observed DOM changes
       */
      onInputDOMChange(m) {
        // iterate all DOm mutation
        m.forEach(record => {
          // only the ADDED nodes
          record.addedNodes.forEach(addedNode => {
            if (addedNode) {
              // fix chrome's placing '<div><br></div>' everytime ENTER key is pressed, and replace with just `<br'
              if (addedNode.outerHTML == '<div><br></div>') {
                addedNode.replaceWith(document.createElement('br'));
              } // if the added element is a div containing a tag within it (chrome does this when pressing ENTER before a tag)
              else if (addedNode.nodeType == 1 && addedNode.querySelector(this.settings.classNames.tagSelector)) {
                let newlineText = document.createTextNode('');
                if (addedNode.childNodes[0].nodeType == 3 && addedNode.previousSibling.nodeName != 'BR') newlineText = document.createTextNode('\n'); // unwrap the useless div
                // chrome adds a BR at the end which should be removed

                addedNode.replaceWith(...[newlineText, ...[...addedNode.childNodes].slice(0, -1)]);
                this.placeCaretAfterNode(newlineText.previousSibling);
              } // if this is a tag
              else if (isNodeTag.call(this, addedNode)) {
                // and it is the first node in a new line
                if (addedNode.previousSibling && addedNode.previousSibling.nodeName == 'BR') {
                  // allows placing the caret just before the tag, when the tag is the first node in that line
                  addedNode.previousSibling.replaceWith('\n\u200B'); // when hitting ENTER for new line just before a tag

                  this.placeCaretAfterNode(addedNode.previousSibling.previousSibling);
                }
              }
            }
          });
          record.removedNodes.forEach(removedNode => {
            // when trying to delete a tag which is in a new line and there's nothing else there (caret is after the tag)
            if (removedNode && removedNode.nodeName == 'BR' && isNodeTag.call(this, lastInputChild)) {
              this.removeTags(lastInputChild);
              this.fixFirefoxLastTagNoCaret();
            }
          });
        }); // get the last child only after the above DOM modifications
        // check these scenarios:
        // 1. after a single line, press ENTER once - should add only 1 BR
        // 2. presss ENTER right before a tag
        // 3. press enter within a text node before a tag

        var lastInputChild = this.DOM.input.lastChild;
        if (lastInputChild && lastInputChild.nodeValue == '') lastInputChild.remove(); // make sure the last element is always a BR

        if (!lastInputChild || lastInputChild.nodeName != 'BR') {
          this.DOM.input.appendChild(document.createElement('br'));
        }
      }

    }
  };

  /**
   * @constructor
   * @param {Object} input    DOM element
   * @param {Object} settings settings object
   */

  function Tagify(input, settings) {
    if (!input) {
      console.warn('Tagify:', 'input element not found', input); // return an empty mock of all methods, so the code using tagify will not break
      // because it might be calling methods even though the input element does not exists

      const mockInstance = new Proxy(this, {
        get() {
          return () => mockInstance;
        }

      });
      return mockInstance;
    }

    if (input.previousElementSibling && input.previousElementSibling.classList.contains('tagify')) {
      console.warn('Tagify: ', 'input element is already Tagified', input);
      return this;
    }

    extend(this, EventDispatcher(this));
    this.isFirefox = typeof InstallTrigger !== 'undefined';
    this.isIE = window.document.documentMode; // https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode#Browser_compatibility

    settings = settings || {};
    this.getPersistedData = getPersistedData(settings.id);
    this.setPersistedData = setPersistedData(settings.id);
    this.clearPersistedData = clearPersistedData(settings.id);
    this.applySettings(input, settings);
    this.state = {
      inputText: '',
      editing: false,
      actions: {},
      // UI actions for state-locking
      mixMode: {},
      dropdown: {},
      flaggedTags: {} // in mix-mode, when a string is detetced as potential tag, and the user has chocen to close the suggestions dropdown, keep the record of the tasg here

    };
    this.value = []; // tags' data
    // events' callbacks references will be stores here, so events could be unbinded

    this.listeners = {};
    this.DOM = {}; // Store all relevant DOM elements in an Object

    this.build(input);
    initDropdown.call(this);
    this.getCSSVars();
    this.loadOriginalValues();
    this.events.customBinding.call(this);
    this.events.binding.call(this);
    input.autofocus && this.DOM.input.focus();
  }

  Tagify.prototype = {
    _dropdown,
    customEventsList: ['change', 'add', 'remove', 'invalid', 'input', 'click', 'keydown', 'focus', 'blur', 'edit:input', 'edit:beforeUpdate', 'edit:updated', 'edit:start', 'edit:keydown', 'dropdown:show', 'dropdown:hide', 'dropdown:select', 'dropdown:updated', 'dropdown:noMatch', 'dropdown:scroll'],
    dataProps: ['__isValid', '__removed', '__originalData', '__originalHTML', '__tagId'],

    // internal-uasge props
    trim(text) {
      return this.settings.trim && text && typeof text == "string" ? text.trim() : text;
    },

    // expose this handy utility function
    parseHTML,
    templates,

    parseTemplate(template, data) {
      template = this.settings.templates[template] || template;
      return this.parseHTML(template.apply(this, data));
    },

    set whitelist(arr) {
      const isArray = arr && Array.isArray(arr);
      this.settings.whitelist = isArray ? arr : [];
      this.setPersistedData(isArray ? arr : [], 'whitelist');
    },

    get whitelist() {
      return this.settings.whitelist;
    },

    applySettings(input, settings) {
      DEFAULTS.templates = this.templates;

      var _s = this.settings = extend({}, DEFAULTS, settings);

      _s.disabled = input.hasAttribute('disabled');
      _s.readonly = _s.readonly || input.hasAttribute('readonly');
      _s.placeholder = escapeHTML(input.getAttribute('placeholder') || _s.placeholder || "");
      _s.required = input.hasAttribute('required');

      for (let name in _s.classNames) Object.defineProperty(_s.classNames, name + "Selector", {
        get() {
          return "." + this[name].split(" ")[0];
        }

      });

      if (this.isIE) _s.autoComplete = false; // IE goes crazy if this isn't false

      ["whitelist", "blacklist"].forEach(name => {
        var attrVal = input.getAttribute('data-' + name);

        if (attrVal) {
          attrVal = attrVal.split(_s.delimiters);
          if (attrVal instanceof Array) _s[name] = attrVal;
        }
      }); // backward-compatibility for old version of "autoComplete" setting:

      if ("autoComplete" in settings && !isObject(settings.autoComplete)) {
        _s.autoComplete = DEFAULTS.autoComplete;
        _s.autoComplete.enabled = settings.autoComplete;
      }

      if (_s.mode == 'mix') {
        _s.autoComplete.rightKey = true;
        _s.delimiters = settings.delimiters || null; // default dlimiters in mix-mode must be NULL
        // needed for "filterListItems". This assumes the user might have forgotten to manually
        // define the same term in "dropdown.searchKeys" as defined in "tagTextProp" setting, so
        // by automatically adding it, tagify is "helping" out, guessing the intesntions of the developer.

        if (_s.tagTextProp && !_s.dropdown.searchKeys.includes(_s.tagTextProp)) _s.dropdown.searchKeys.push(_s.tagTextProp);
      }

      if (input.pattern) try {
        _s.pattern = new RegExp(input.pattern);
      } catch (e) {} // Convert the "delimiters" setting into a REGEX object

      if (this.settings.delimiters) {
        try {
          _s.delimiters = new RegExp(this.settings.delimiters, "g");
        } catch (e) {}
      }

      if (_s.disabled) _s.userInput = false;
      this.TEXTS = _objectSpread2(_objectSpread2({}, TEXTS), _s.texts || {}); // make sure the dropdown will be shown on "focus" and not only after typing something (in "select" mode)

      if (_s.mode == 'select' || !_s.userInput) _s.dropdown.enabled = 0;
      _s.dropdown.appendTarget = settings.dropdown && settings.dropdown.appendTarget ? settings.dropdown.appendTarget : document.body; // get & merge persisted data with current data

      let persistedWhitelist = this.getPersistedData('whitelist');
      if (Array.isArray(persistedWhitelist)) this.whitelist = Array.isArray(_s.whitelist) ? concatWithoutDups(_s.whitelist, persistedWhitelist) : persistedWhitelist;
    },

    /**
     * Returns a string of HTML element attributes
     * @param {Object} data [Tag data]
     */
    getAttributes(data) {
      var attrs = this.getCustomAttributes(data),
          s = '',
          k;

      for (k in attrs) s += " " + k + (data[k] !== undefined ? `="${attrs[k]}"` : "");

      return s;
    },

    /**
     * Returns an object of attributes to be used for the templates
     */
    getCustomAttributes(data) {
      // only items which are objects have properties which can be used as attributes
      if (!isObject(data)) return '';
      var output = {},
          propName;

      for (propName in data) {
        if (propName.slice(0, 2) != '__' && propName != 'class' && data.hasOwnProperty(propName) && data[propName] !== undefined) output[propName] = escapeHTML(data[propName]);
      }

      return output;
    },

    setStateSelection() {
      var selection = window.getSelection(); // save last selection place to be able to inject anything from outside to that specific place

      var sel = {
        anchorOffset: selection.anchorOffset,
        anchorNode: selection.anchorNode,
        range: selection.getRangeAt && selection.rangeCount && selection.getRangeAt(0)
      };
      this.state.selection = sel;
      return sel;
    },

    /**
     * Get the caret position relative to the viewport
     * https://stackoverflow.com/q/58985076/104380
     *
     * @returns {object} left, top distance in pixels
     */
    getCaretGlobalPosition() {
      const sel = document.getSelection();

      if (sel.rangeCount) {
        const r = sel.getRangeAt(0);
        const node = r.startContainer;
        const offset = r.startOffset;
        let rect, r2;

        if (offset > 0) {
          r2 = document.createRange();
          r2.setStart(node, offset - 1);
          r2.setEnd(node, offset);
          rect = r2.getBoundingClientRect();
          return {
            left: rect.right,
            top: rect.top,
            bottom: rect.bottom
          };
        }

        if (node.getBoundingClientRect) return node.getBoundingClientRect();
      }

      return {
        left: -9999,
        top: -9999
      };
    },

    /**
     * Get specific CSS variables which are relevant to this script and parse them as needed.
     * The result is saved on the instance in "this.CSSVars"
     */
    getCSSVars() {
      var compStyle = getComputedStyle(this.DOM.scope, null);

      const getProp = name => compStyle.getPropertyValue('--' + name);

      function seprateUnitFromValue(a) {
        if (!a) return {};
        a = a.trim().split(' ')[0];
        var unit = a.split(/\d+/g).filter(n => n).pop().trim(),
            value = +a.split(unit).filter(n => n)[0].trim();
        return {
          value,
          unit
        };
      }

      this.CSSVars = {
        tagHideTransition: (({
          value,
          unit
        }) => unit == 's' ? value * 1000 : value)(seprateUnitFromValue(getProp('tag-hide-transition')))
      };
    },

    /**
     * builds the HTML of this component
     * @param  {Object} input [DOM element which would be "transformed" into "Tags"]
     */
    build(input) {
      var DOM = this.DOM;

      if (this.settings.mixMode.integrated) {
        DOM.originalInput = null;
        DOM.scope = input;
        DOM.input = input;
      } else {
        DOM.originalInput = input;
        DOM.scope = this.parseTemplate('wrapper', [input, this.settings]);
        DOM.input = DOM.scope.querySelector(this.settings.classNames.inputSelector);
        input.parentNode.insertBefore(DOM.scope, input);
      }
    },

    /**
     * revert any changes made by this component
     */
    destroy() {
      this.events.unbindGlobal.call(this);
      this.DOM.scope.parentNode.removeChild(this.DOM.scope);
      this.dropdown.hide(true);
      clearTimeout(this.dropdownHide__bindEventsTimeout);
    },

    /**
     * if the original input has any values, add them as tags
     */
    loadOriginalValues(value) {
      var lastChild,
          _s = this.settings; // temporarily block firign the "change" event on the original input unil
      // this method finish removing current value and adding the new one

      this.state.blockChangeEvent = true;

      if (value === undefined) {
        const persistedOriginalValue = this.getPersistedData('value'); // if the field already has a field, trust its the desired
        // one to be rendered and do not use the persisted one

        if (persistedOriginalValue && !this.DOM.originalInput.value) value = persistedOriginalValue;else value = _s.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value;
      }

      this.removeAllTags();

      if (value) {
        if (_s.mode == 'mix') {
          this.parseMixTags(this.trim(value));
          lastChild = this.DOM.input.lastChild;
          if (!lastChild || lastChild.tagName != 'BR') this.DOM.input.insertAdjacentHTML('beforeend', '<br>');
        } else {
          try {
            if (JSON.parse(value) instanceof Array) value = JSON.parse(value);
          } catch (err) {}

          this.addTags(value).forEach(tag => tag && tag.classList.add(_s.classNames.tagNoAnimation));
        }
      } else this.postUpdate();

      this.state.lastOriginalValueReported = _s.mixMode.integrated ? '' : this.DOM.originalInput.value;
      this.state.blockChangeEvent = false;
    },

    cloneEvent(e) {
      var clonedEvent = {};

      for (var v in e) clonedEvent[v] = e[v];

      return clonedEvent;
    },

    /**
     * Toogle global loading state on/off
     * Useful when fetching async whitelist while user is typing
     * @param {Boolean} isLoading
     */
    loading(isLoading) {
      this.state.isLoading = isLoading; // IE11 doesn't support toggle with second parameter

      this.DOM.scope.classList[isLoading ? "add" : "remove"](this.settings.classNames.scopeLoading);
      return this;
    },

    /**
     * Toogle specieif tag loading state on/off
     * @param {Boolean} isLoading
     */
    tagLoading(tagElm, isLoading) {
      if (tagElm) // IE11 doesn't support toggle with second parameter
        tagElm.classList[isLoading ? "add" : "remove"](this.settings.classNames.tagLoading);
      return this;
    },

    /**
     * Toggles class on the main tagify container ("scope")
     * @param {String} className
     * @param {Boolean} force
     */
    toggleClass(className, force) {
      if (typeof className == 'string') this.DOM.scope.classList.toggle(className, force);
    },

    toggleFocusClass(force) {
      this.toggleClass(this.settings.classNames.focus, !!force);
    },

    triggerChangeEvent,
    events,

    fixFirefoxLastTagNoCaret() {
      return; // seems to be fixed in newer version of FF, so retiring below code (for now)
    },

    placeCaretAfterNode(node) {
      if (!node || !node.parentNode) return;
      var nextSibling = node.nextSibling,
          sel = window.getSelection(),
          range = sel.getRangeAt(0);

      if (sel.rangeCount) {
        range.setStartAfter(nextSibling || node);
        range.collapse(true); // range.setEndBefore(nextSibling || node);

        sel.removeAllRanges();
        sel.addRange(range);
      }
    },

    insertAfterTag(tagElm, newNode) {
      newNode = newNode || this.settings.mixMode.insertAfterTag;
      if (!tagElm || !tagElm.parentNode || !newNode) return;
      newNode = typeof newNode == 'string' ? document.createTextNode(newNode) : newNode;
      tagElm.parentNode.insertBefore(newNode, tagElm.nextSibling);
      return newNode;
    },

    /**
     * Enters a tag into "edit" mode
     * @param {Node} tagElm the tag element to edit. if nothing specified, use last last
     */
    editTag(tagElm, opts) {
      tagElm = tagElm || this.getLastTag();
      opts = opts || {};
      this.dropdown.hide();
      var _s = this.settings;

      function getEditableElm() {
        return tagElm.querySelector(_s.classNames.tagTextSelector);
      }

      var editableElm = getEditableElm(),
          tagIdx = this.getNodeIndex(tagElm),
          tagData = this.tagData(tagElm),
          _CB = this.events.callbacks,
          that = this,
          isValid = true,
          delayed_onEditTagBlur = function () {
        setTimeout(() => _CB.onEditTagBlur.call(that, getEditableElm()));
      };

      if (!editableElm) {
        console.warn('Cannot find element in Tag template: .', _s.classNames.tagTextSelector);
        return;
      }

      if (tagData instanceof Object && "editable" in tagData && !tagData.editable) return;
      editableElm.setAttribute('contenteditable', true);
      tagElm.classList.add(_s.classNames.tagEditing); // cache the original data, on the DOM node, before any modification ocurs, for possible revert

      this.tagData(tagElm, {
        __originalData: extend({}, tagData),
        __originalHTML: tagElm.innerHTML
      });
      editableElm.addEventListener('focus', _CB.onEditTagFocus.bind(this, tagElm));
      editableElm.addEventListener('blur', delayed_onEditTagBlur);
      editableElm.addEventListener('input', _CB.onEditTagInput.bind(this, editableElm));
      editableElm.addEventListener('keydown', e => _CB.onEditTagkeydown.call(this, e, tagElm));
      editableElm.focus();
      this.setRangeAtStartEnd(false, editableElm);
      if (!opts.skipValidation) isValid = this.editTagToggleValidity(tagElm);
      editableElm.originalIsValid = isValid;
      this.trigger("edit:start", {
        tag: tagElm,
        index: tagIdx,
        data: tagData,
        isValid
      });
      return this;
    },

    /**
     * If a tag is invalid, for any reason, set its class to as "not allowed" (see defaults file)
     * @param {Node} tagElm required
     * @param {Object} tagData optional
     * @returns true if valid, a string (reason) if not
     */
    editTagToggleValidity(tagElm, tagData) {
      var tagData = tagData || this.tagData(tagElm),
          isValid;

      if (!tagData) {
        console.warn("tag has no data: ", tagElm, tagData);
        return;
      }

      isValid = !("__isValid" in tagData) || tagData.__isValid === true;

      if (!isValid) {
        this.removeTagsFromValue(tagElm);
      }

      this.update(); //this.validateTag(tagData);

      tagElm.classList.toggle(this.settings.classNames.tagNotAllowed, !isValid);
      return tagData.__isValid;
    },

    onEditTagDone(tagElm, tagData) {
      tagElm = tagElm || this.state.editing.scope;
      tagData = tagData || {};
      var eventData = {
        tag: tagElm,
        index: this.getNodeIndex(tagElm),
        previousData: this.tagData(tagElm),
        data: tagData
      };
      this.trigger("edit:beforeUpdate", eventData, {
        cloneData: false
      });
      this.state.editing = false;
      delete tagData.__originalData;
      delete tagData.__originalHTML;

      if (tagElm && tagData[this.settings.tagTextProp]) {
        tagElm = this.replaceTag(tagElm, tagData);
        this.editTagToggleValidity(tagElm, tagData);
        if (this.settings.a11y.focusableTags) tagElm.focus();else // place caret after edited tag
          this.placeCaretAfterNode(tagElm.previousSibling);
      } else if (tagElm) this.removeTags(tagElm);

      this.trigger("edit:updated", eventData);
      this.dropdown.hide(); // check if any of the current tags which might have been marked as "duplicate" should be now un-marked

      if (this.settings.keepInvalidTags) this.reCheckInvalidTags();
    },

    /**
     * Replaces an exisitng tag with a new one. Used for updating a tag's data
     * @param {Object} tagElm  [DOM node to replace]
     * @param {Object} tagData [data to create new tag from]
     */
    replaceTag(tagElm, tagData) {
      if (!tagData || !tagData.value) tagData = tagElm.__tagifyTagData; // if tag is invalid, make the according changes in the newly created element

      if (tagData.__isValid && tagData.__isValid != true) extend(tagData, this.getInvalidTagAttrs(tagData, tagData.__isValid));
      var newTagElm = this.createTagElem(tagData); // update DOM

      tagElm.parentNode.replaceChild(newTagElm, tagElm);
      this.updateValueByDOMTags();
      return newTagElm;
    },

    /**
     * update "value" (Array of Objects) by traversing all valid tags
     */
    updateValueByDOMTags() {
      this.value.length = 0;
      [].forEach.call(this.getTagElms(), node => {
        if (node.classList.contains(this.settings.classNames.tagNotAllowed.split(' ')[0])) return;
        this.value.push(this.tagData(node));
      });
      this.update();
    },

    /** https://stackoverflow.com/a/59156872/104380
     * @param {Boolean} start indicating where to place it (start or end of the node)
     * @param {Object}  node  DOM node to place the caret at
     */
    setRangeAtStartEnd(start, node) {
      start = typeof start == 'number' ? start : !!start;
      node = node || this.DOM.input;
      node = node.lastChild || node;
      var sel = document.getSelection();

      try {
        if (sel.rangeCount >= 1) {
          ['Start', 'End'].forEach(pos => sel.getRangeAt(0)["set" + pos](node, start ? start : node.length));
        }
      } catch (err) {// console.warn("Tagify: ", err)
      }
    },

    /**
     * injects nodes/text at caret position, which is saved on the "state" when "blur" event gets triggered
     * @param {Node} injectedNode [the node to inject at the caret position]
     * @param {Object} selection [optional range Object. must have "anchorNode" & "anchorOffset"]
     */
    injectAtCaret(injectedNode, range) {
      range = range || this.state.selection.range;
      if (!range) return;
      if (typeof injectedNode == 'string') injectedNode = document.createTextNode(injectedNode);
      range.deleteContents();
      range.insertNode(injectedNode);
      this.setRangeAtStartEnd(false, injectedNode);
      this.updateValueByDOMTags(); // updates internal "this.value"

      this.update(); // updates original input/textarea

      return this;
    },

    /**
     * input bridge for accessing & setting
     * @type {Object}
     */
    input: {
      set(s = '', updateDOM = true) {
        var hideDropdown = this.settings.dropdown.closeOnSelect;
        this.state.inputText = s;
        if (updateDOM) this.DOM.input.innerHTML = escapeHTML("" + s);
        if (!s && hideDropdown) this.dropdown.hide.bind(this);
        this.input.autocomplete.suggest.call(this);
        this.input.validate.call(this);
      },

      raw() {
        return this.DOM.input.textContent;
      },

      /**
       * Marks the tagify's input as "invalid" if the value did not pass "validateTag()"
       */
      validate() {
        var isValid = !this.state.inputText || this.validateTag({
          value: this.state.inputText
        }) === true;
        this.DOM.input.classList.toggle(this.settings.classNames.inputInvalid, !isValid);
        return isValid;
      },

      // remove any child DOM elements that aren't of type TEXT (like <br>)
      normalize(node) {
        var clone = node || this.DOM.input,
            //.cloneNode(true),
        v = []; // when a text was pasted in FF, the "this.DOM.input" element will have <br> but no newline symbols (\n), and this will
        // result in tags not being properly created if one wishes to create a separate tag per newline.

        clone.childNodes.forEach(n => n.nodeType == 3 && v.push(n.nodeValue));
        v = v.join("\n");

        try {
          // "delimiters" might be of a non-regex value, where this will fail ("Tags With Properties" example in demo page):
          v = v.replace(/(?:\r\n|\r|\n)/g, this.settings.delimiters.source.charAt(0));
        } catch (err) {}

        v = v.replace(/\s/g, ' '); // replace NBSPs with spaces characters

        if (this.settings.trim) v = v.replace(/^\s+/, ''); // trimLeft

        return v;
      },

      /**
       * suggest the rest of the input's value (via CSS "::after" using "content:attr(...)")
       * @param  {String} s [description]
       */
      autocomplete: {
        suggest(data) {
          if (!this.settings.autoComplete.enabled) return;
          data = data || {};
          if (typeof data == 'string') data = {
            value: data
          };
          var suggestedText = data.value ? '' + data.value : '',
              suggestionStart = suggestedText.substr(0, this.state.inputText.length).toLowerCase(),
              suggestionTrimmed = suggestedText.substring(this.state.inputText.length);

          if (!suggestedText || !this.state.inputText || suggestionStart != this.state.inputText.toLowerCase()) {
            this.DOM.input.removeAttribute("data-suggest");
            delete this.state.inputSuggestion;
          } else {
            this.DOM.input.setAttribute("data-suggest", suggestionTrimmed);
            this.state.inputSuggestion = data;
          }
        },

        /**
         * sets the suggested text as the input's value & cleanup the suggestion autocomplete.
         * @param {String} s [text]
         */
        set(s) {
          var dataSuggest = this.DOM.input.getAttribute('data-suggest'),
              suggestion = s || (dataSuggest ? this.state.inputText + dataSuggest : null);

          if (suggestion) {
            if (this.settings.mode == 'mix') {
              this.replaceTextWithNode(document.createTextNode(this.state.tag.prefix + suggestion));
            } else {
              this.input.set.call(this, suggestion);
              this.setRangeAtStartEnd();
            }

            this.input.autocomplete.suggest.call(this);
            this.dropdown.hide();
            return true;
          }

          return false;
        }

      }
    },

    /**
     * returns the index of the the tagData within the "this.value" array collection.
     * since values should be unique, it is suffice to only search by "value" property
     * @param {Object} tagData
     */
    getTagIdx(tagData) {
      return this.value.findIndex(item => item.__tagId == (tagData || {}).__tagId);
    },

    getNodeIndex(node) {
      var index = 0;
      if (node) while (node = node.previousElementSibling) index++;
      return index;
    },

    getTagElms(...classess) {
      var classname = '.' + [...this.settings.classNames.tag.split(' '), ...classess].join('.');
      return [].slice.call(this.DOM.scope.querySelectorAll(classname)); // convert nodeList to Array - https://stackoverflow.com/a/3199627/104380
    },

    /**
     * gets the last non-readonly, not-in-the-proccess-of-removal tag
     */
    getLastTag() {
      var lastTag = this.DOM.scope.querySelectorAll(`${this.settings.classNames.tagSelector}:not(.${this.settings.classNames.tagHide}):not([readonly])`);
      return lastTag[lastTag.length - 1];
    },

    /** Setter/Getter
     * Each tag DOM node contains a custom property called "__tagifyTagData" which hosts its data
     * @param {Node}   tagElm
     * @param {Object} data
     */
    tagData(tagElm, data, override) {
      if (!tagElm) {
        console.warn("tag element doesn't exist", tagElm, data);
        return data;
      }

      if (data) tagElm.__tagifyTagData = override ? data : extend({}, tagElm.__tagifyTagData || {}, data);
      return tagElm.__tagifyTagData;
    },

    /**
     * Searches if any tag with a certain value already exis
     * @param  {String/Object} value [text value / tag data object]
     * @param  {Boolean} caseSensitive
     * @return {Number}
     */
    isTagDuplicate(value, caseSensitive) {
      var dupsCount,
          _s = this.settings; // duplications are irrelevant for this scenario

      if (_s.mode == 'select') return false;
      dupsCount = this.value.reduce((acc, item) => sameStr(this.trim("" + value), item.value, caseSensitive || _s.dropdown.caseSensitive) ? acc + 1 : acc, 0);
      return dupsCount;
    },

    getTagIndexByValue(value) {
      var indices = [];
      this.getTagElms().forEach((tagElm, i) => {
        if (sameStr(this.trim(tagElm.textContent), value, this.settings.dropdown.caseSensitive)) indices.push(i);
      });
      return indices;
    },

    getTagElmByValue(value) {
      var tagIdx = this.getTagIndexByValue(value)[0];
      return this.getTagElms()[tagIdx];
    },

    /**
     * Temporarily marks a tag element (by value or Node argument)
     * @param  {Object} tagElm [a specific "tag" element to compare to the other tag elements siblings]
     */
    flashTag(tagElm) {
      if (tagElm) {
        tagElm.classList.add(this.settings.classNames.tagFlash);
        setTimeout(() => {
          tagElm.classList.remove(this.settings.classNames.tagFlash);
        }, 100);
      }
    },

    /**
     * checks if text is in the blacklist
     */
    isTagBlacklisted(v) {
      v = this.trim(v.toLowerCase());
      return this.settings.blacklist.filter(x => ("" + x).toLowerCase() == v).length;
    },

    /**
     * checks if text is in the whitelist
     */
    isTagWhitelisted(v) {
      return !!this.getWhitelistItem(v);
      /*
      return this.settings.whitelist.some(item =>
          typeof v == 'string'
              ? sameStr(this.trim(v), (item.value || item))
              : sameStr(JSON.stringify(item), JSON.stringify(v))
      )
      */
    },

    /**
     * Returns the first whitelist item matched, by value (if match found)
     * @param {String} value [text to match by]
     */
    getWhitelistItem(value, prop, whitelist) {
      var result,
          prop = prop || 'value',
          _s = this.settings,
          whitelist = whitelist || _s.whitelist;
      whitelist.some(_wi => {
        var _wiv = typeof _wi == 'string' ? _wi : _wi[prop] || _wi.value,
            isSameStr = sameStr(_wiv, value, _s.dropdown.caseSensitive, _s.trim);

        if (isSameStr) {
          result = typeof _wi == 'string' ? {
            value: _wi
          } : _wi;
          return true;
        }
      }); // first iterate the whitelist, try find maches by "value" and if that fails
      // and a "tagTextProp" is set to be other than "value", try that also

      if (!result && prop == 'value' && _s.tagTextProp != 'value') {
        // if found, adds the first which matches
        result = this.getWhitelistItem(value, _s.tagTextProp, whitelist);
      }

      return result;
    },

    /**
     * validate a tag object BEFORE the actual tag will be created & appeneded
     * @param  {String} s
     * @param  {String} uid      [unique ID, to not inclue own tag when cheking for duplicates]
     * @return {Boolean/String}  ["true" if validation has passed, String for a fail]
     */
    validateTag(tagData) {
      var _s = this.settings,
          // when validating a tag in edit-mode, need to take "tagTextProp" into consideration
      prop = "value" in tagData ? "value" : _s.tagTextProp,
          v = this.trim(tagData[prop] + ""); // check for definitive empty value

      if (!(tagData[prop] + "").trim()) return this.TEXTS.empty; // check if pattern should be used and if so, use it to test the value

      if (_s.pattern && _s.pattern instanceof RegExp && !_s.pattern.test(v)) return this.TEXTS.pattern; // check for duplicates

      if (!_s.duplicates && this.isTagDuplicate(v, this.state.editing)) return this.TEXTS.duplicate;
      if (this.isTagBlacklisted(v) || _s.enforceWhitelist && !this.isTagWhitelisted(v)) return this.TEXTS.notAllowed;
      if (_s.validate) return _s.validate(tagData);
      return true;
    },

    getInvalidTagAttrs(tagData, validation) {
      return {
        "aria-invalid": true,
        "class": `${tagData.class || ''} ${this.settings.classNames.tagNotAllowed}`.trim(),
        "title": validation
      };
    },

    hasMaxTags() {
      return this.value.length >= this.settings.maxTags ? this.TEXTS.exceed : false;
    },

    setReadonly(toggle, attrribute) {
      var _s = this.settings;
      document.activeElement.blur(); // exit possible edit-mode

      _s[attrribute || 'readonly'] = toggle;
      this.DOM.scope[(toggle ? 'set' : 'remove') + 'Attribute'](attrribute || 'readonly', true);

      if (_s.mode == 'mix') {
        this.setContentEditable(!toggle);
      }
    },

    setContentEditable(state) {
      if (!this.settings.readonly && this.settings.userInput) this.DOM.input.contentEditable = state;
    },

    setDisabled(isDisabled) {
      this.setReadonly(isDisabled, 'disabled');
    },

    /**
     * pre-proccess the tagsItems, which can be a complex tagsItems like an Array of Objects or a string comprised of multiple words
     * so each item should be iterated on and a tag created for.
     * @return {Array} [Array of Objects]
     */
    normalizeTags(tagsItems) {
      var _this$settings = this.settings,
          whitelist = _this$settings.whitelist,
          delimiters = _this$settings.delimiters,
          mode = _this$settings.mode,
          tagTextProp = _this$settings.tagTextProp;
          _this$settings.enforceWhitelist;
          var whitelistMatches = [],
          whitelistWithProps = whitelist ? whitelist[0] instanceof Object : false,
          isArray = tagsItems instanceof Array,
          mapStringToCollection = s => (s + "").split(delimiters).filter(n => n).map(v => ({
        [tagTextProp]: this.trim(v),
        value: this.trim(v)
      }));

      if (typeof tagsItems == 'number') tagsItems = tagsItems.toString(); // if the argument is a "simple" String, ex: "aaa, bbb, ccc"

      if (typeof tagsItems == 'string') {
        if (!tagsItems.trim()) return []; // go over each tag and add it (if there were multiple ones)

        tagsItems = mapStringToCollection(tagsItems);
      } // is is an Array of Strings, convert to an Array of Objects
      else if (isArray) {
        // flatten the 2D array
        tagsItems = [].concat(...tagsItems.map(item => item.value ? item // mapStringToCollection(item.value).map(newItem => ({...item,...newItem}))
        : mapStringToCollection(item)));
      } // search if the tag exists in the whitelist as an Object (has props),
      // to be able to use its properties


      if (whitelistWithProps) {
        tagsItems.forEach(item => {
          var whitelistMatchesValues = whitelistMatches.map(a => a.value); // if suggestions are shown, they are already filtered, so it's easier to use them,
          // because the whitelist might also include items which have already been added

          var filteredList = this.dropdown.filterListItems.call(this, item[tagTextProp], {
            exact: true
          });
          if (!this.settings.duplicates) // also filter out items which have already been matched in previous iterations
            filteredList = filteredList.filter(filteredItem => !whitelistMatchesValues.includes(filteredItem.value)); // get the best match out of list of possible matches.
          // if there was a single item in the filtered list, use that one

          var matchObj = filteredList.length > 1 ? this.getWhitelistItem(item[tagTextProp], tagTextProp, filteredList) : filteredList[0];

          if (matchObj && matchObj instanceof Object) {
            whitelistMatches.push(matchObj); // set the Array (with the found Object) as the new value
          } else if (mode != 'mix') {
            if (item.value == undefined) item.value = item[tagTextProp];
            whitelistMatches.push(item);
          }
        });
        if (whitelistMatches.length) tagsItems = whitelistMatches;
      }

      return tagsItems;
    },

    /**
     * Parse the initial value of a textarea (or input) element and generate mixed text w/ tags
     * https://stackoverflow.com/a/57598892/104380
     * @param {String} s
     */
    parseMixTags(s) {
      var _this$settings2 = this.settings,
          mixTagsInterpolator = _this$settings2.mixTagsInterpolator,
          duplicates = _this$settings2.duplicates,
          transformTag = _this$settings2.transformTag,
          enforceWhitelist = _this$settings2.enforceWhitelist,
          maxTags = _this$settings2.maxTags,
          tagTextProp = _this$settings2.tagTextProp,
          tagsDataSet = [];
      s = s.split(mixTagsInterpolator[0]).map((s1, i) => {
        var s2 = s1.split(mixTagsInterpolator[1]),
            preInterpolated = s2[0],
            maxTagsReached = tagsDataSet.length == maxTags,
            textProp,
            tagData,
            tagElm;

        try {
          // skip numbers and go straight to the "catch" statement
          if (preInterpolated == +preInterpolated) throw Error;
          tagData = JSON.parse(preInterpolated);
        } catch (err) {
          tagData = this.normalizeTags(preInterpolated)[0] || {
            value: preInterpolated
          };
        }

        transformTag.call(this, tagData);

        if (!maxTagsReached && s2.length > 1 && (!enforceWhitelist || this.isTagWhitelisted(tagData.value)) && !(!duplicates && this.isTagDuplicate(tagData.value))) {
          // in case "tagTextProp" setting is set to other than "value" and this tag does not have this prop
          textProp = tagData[tagTextProp] ? tagTextProp : 'value';
          tagData[textProp] = this.trim(tagData[textProp]);
          tagElm = this.createTagElem(tagData);
          tagsDataSet.push(tagData);
          tagElm.classList.add(this.settings.classNames.tagNoAnimation);
          s2[0] = tagElm.outerHTML; //+ "&#8288;"  // put a zero-space at the end so the caret won't jump back to the start (when the last input's child element is a tag)

          this.value.push(tagData);
        } else if (s1) return i ? mixTagsInterpolator[0] + s1 : s1;

        return s2.join('');
      }).join('');
      this.DOM.input.innerHTML = s;
      this.DOM.input.appendChild(document.createTextNode(''));
      this.DOM.input.normalize();
      this.getTagElms().forEach((elm, idx) => this.tagData(elm, tagsDataSet[idx]));
      this.update({
        withoutChangeEvent: true
      });
      return s;
    },

    /**
     * For mixed-mode: replaces a text starting with a prefix with a wrapper element (tag or something)
     * First there *has* to be a "this.state.tag" which is a string that was just typed and is staring with a prefix
     */
    replaceTextWithNode(newWrapperNode, strToReplace) {
      if (!this.state.tag && !strToReplace) return;
      strToReplace = strToReplace || this.state.tag.prefix + this.state.tag.value;
      var idx,
          nodeToReplace,
          selection = window.getSelection(),
          nodeAtCaret = selection.anchorNode,
          firstSplitOffset = this.state.tag.delimiters ? this.state.tag.delimiters.length : 0; // STEP 1: ex. replace #ba with the tag "bart" where "|" is where the caret is:
      // CURRENT STATE: "foo #ba #ba| #ba"
      // split the text node at the index of the caret

      nodeAtCaret.splitText(selection.anchorOffset - firstSplitOffset); // node 0: "foo #ba #ba|"
      // node 1: " #ba"
      // get index of LAST occurence of "#ba"

      idx = nodeAtCaret.nodeValue.lastIndexOf(strToReplace);
      if (idx == -1) return true;
      nodeToReplace = nodeAtCaret.splitText(idx); // node 0: "foo #ba "
      // node 1: "#ba"    <- nodeToReplace

      newWrapperNode && nodeAtCaret.parentNode.replaceChild(newWrapperNode, nodeToReplace); // must NOT normalize contenteditable or it will cause unwanted issues:
      // https://monosnap.com/file/ZDVmRvq5upYkidiFedvrwzSswegWk7
      // nodeAtCaret.parentNode.normalize()

      return true;
    },

    /**
     * For selecting a single option (not used for multiple tags, but for "mode:select" only)
     * @param {Object} tagElm   Tag DOM node
     * @param {Object} tagData  Tag data
     */
    selectTag(tagElm, tagData) {
      var _s = this.settings;
      if (_s.enforceWhitelist && !this.isTagWhitelisted(tagData.value)) return;
      this.input.set.call(this, tagData[_s.tagTextProp] || tagData.value, true); // place the caret at the end of the input, only if a dropdown option was selected (and not by manually typing another value and clicking "TAB")

      if (this.state.actions.selectOption) setTimeout(this.setRangeAtStartEnd.bind(this));
      var lastTagElm = this.getLastTag();
      if (lastTagElm) this.replaceTag(lastTagElm, tagData);else this.appendTag(tagElm);
      if (_s.enforceWhitelist) this.setContentEditable(false);
      this.value[0] = tagData;
      this.update();
      this.trigger('add', {
        tag: tagElm,
        data: tagData
      });
      return [tagElm];
    },

    /**
     * add an empty "tag" element in an editable state
     */
    addEmptyTag(initialData) {
      var tagData = extend({
        value: ""
      }, initialData || {}),
          tagElm = this.createTagElem(tagData);
      this.tagData(tagElm, tagData); // add the tag to the component's DOM

      this.appendTag(tagElm);
      this.editTag(tagElm, {
        skipValidation: true
      });
    },

    /**
     * add a "tag" element to the "tags" component
     * @param {String/Array} tagsItems   [A string (single or multiple values with a delimiter), or an Array of Objects or just Array of Strings]
     * @param {Boolean}      clearInput  [flag if the input's value should be cleared after adding tags]
     * @param {Boolean}      skipInvalid [do not add, mark & remove invalid tags]
     * @return {Array} Array of DOM elements (tags)
     */
    addTags(tagsItems, clearInput, skipInvalid) {
      var tagElems = [],
          _s = this.settings,
          frag = document.createDocumentFragment();
      skipInvalid = skipInvalid || _s.skipInvalid;

      if (!tagsItems || tagsItems.length == 0) {
        if (_s.mode == 'select') this.removeAllTags();
        return tagElems;
      } // converts Array/String/Object to an Array of Objects


      tagsItems = this.normalizeTags(tagsItems);

      if (_s.mode == 'mix') {
        return this.addMixTags(tagsItems);
      }

      if (_s.mode == 'select') clearInput = false;
      this.DOM.input.removeAttribute('style');
      tagsItems.forEach(tagData => {
        var tagElm,
            tagElmParams = {},
            originalData = Object.assign({}, tagData, {
          value: tagData.value + ""
        }); // shallow-clone tagData so later modifications will not apply to the source

        tagData = Object.assign({}, originalData);

        _s.transformTag.call(this, tagData);

        tagData.__isValid = this.hasMaxTags() || this.validateTag(tagData);

        if (tagData.__isValid !== true) {
          if (skipInvalid) return; // originalData is kept because it might be that this tag is invalid because it is a duplicate of another,
          // and if that other tags is edited/deleted, this one should be re-validated and if is no more a duplicate - restored

          extend(tagElmParams, this.getInvalidTagAttrs(tagData, tagData.__isValid), {
            __preInvalidData: originalData
          });
          if (tagData.__isValid == this.TEXTS.duplicate) // mark, for a brief moment, the tag (this this one) which THIS CURRENT tag is a duplcate of
            this.flashTag(this.getTagElmByValue(tagData.value));
        } /////////////////////////////////////////////////////


        if ('readonly' in tagData) {
          if (tagData.readonly) tagElmParams["aria-readonly"] = true; // if "readonly" is "false", remove it from the tagData so it won't be added as an attribute in the template
          else delete tagData.readonly;
        } // Create tag HTML element


        tagElm = this.createTagElem(tagData, tagElmParams);
        tagElems.push(tagElm); // mode-select overrides

        if (_s.mode == 'select') {
          return this.selectTag(tagElm, tagData);
        } // add the tag to the component's DOM
        // this.appendTag(tagElm)


        frag.appendChild(tagElm);

        if (tagData.__isValid && tagData.__isValid === true) {
          // update state
          this.value.push(tagData);
          this.trigger('add', {
            tag: tagElm,
            index: this.value.length - 1,
            data: tagData
          });
        } else {
          this.trigger("invalid", {
            data: tagData,
            index: this.value.length,
            tag: tagElm,
            message: tagData.__isValid
          });
          if (!_s.keepInvalidTags) // remove invalid tags (if "keepInvalidTags" is set to "false")
            setTimeout(() => this.removeTags(tagElm, true), 1000);
        }

        this.dropdown.position(); // reposition the dropdown because the just-added tag might cause a new-line
      });
      this.appendTag(frag);
      this.update();

      if (tagsItems.length && clearInput) {
        this.input.set.call(this);
      }

      this.dropdown.refilter();
      return tagElems;
    },

    /**
     * Adds a mix-content tag
     * @param {String/Array} tagData    A string (single or multiple values with a delimiter), or an Array of Objects or just Array of Strings
     */
    addMixTags(tagsData) {
      tagsData = this.normalizeTags(tagsData);

      if (tagsData[0].prefix || this.state.tag) {
        return this.prefixedTextToTag(tagsData[0]);
      }

      if (typeof tagsData == 'string') tagsData = [{
        value: tagsData
      }];
      var selection = !!this.state.selection,
          // must be cast, not to use the reference which is changing
      frag = document.createDocumentFragment();
      tagsData.forEach(tagData => {
        var tagElm = this.createTagElem(tagData);
        frag.appendChild(tagElm);
        this.insertAfterTag(tagElm);
      }); // if "selection" exists, assumes intention of inecting the new tag at the last
      // saved location of the caret inside "this.DOM.input"

      if (selection) {
        this.injectAtCaret(frag);
      } // else, create a range and inject the new tag as the last child of "this.DOM.input"
      else {
        this.DOM.input.focus();
        selection = this.setStateSelection();
        selection.range.setStart(this.DOM.input, selection.range.endOffset);
        selection.range.setEnd(this.DOM.input, selection.range.endOffset);
        this.DOM.input.appendChild(frag);
        this.updateValueByDOMTags(); // updates internal "this.value"

        this.update(); // updates original input/textarea
      }

      return frag;
    },

    /**
     * Adds a tag which was activly typed by the user
     * @param {String/Array} tagItem   [A string (single or multiple values with a delimiter), or an Array of Objects or just Array of Strings]
     */
    prefixedTextToTag(tagItem) {
      var _s = this.settings,
          tagElm,
          createdFromDelimiters = this.state.tag.delimiters;

      _s.transformTag.call(this, tagItem);

      tagItem.prefix = tagItem.prefix || this.state.tag ? this.state.tag.prefix : (_s.pattern.source || _s.pattern)[0]; // TODO: should check if the tag is valid

      tagElm = this.createTagElem(tagItem); // tries to replace a taged textNode with a tagElm, and if not able,
      // insert the new tag to the END if "addTags" was called from outside

      if (!this.replaceTextWithNode(tagElm)) {
        this.DOM.input.appendChild(tagElm);
      }

      setTimeout(() => tagElm.classList.add(this.settings.classNames.tagNoAnimation), 300);
      this.value.push(tagItem);
      this.update();

      if (!createdFromDelimiters) {
        var elm = this.insertAfterTag(tagElm) || tagElm;
        this.placeCaretAfterNode(elm);
      }

      this.state.tag = null;
      this.trigger('add', extend({}, {
        tag: tagElm
      }, {
        data: tagItem
      }));
      return tagElm;
    },

    /**
     * appened (validated) tag to the component's DOM scope
     */
    appendTag(tagElm) {
      var DOM = this.DOM,
          insertBeforeNode = DOM.scope.lastElementChild;
      if (insertBeforeNode === DOM.input) DOM.scope.insertBefore(tagElm, insertBeforeNode);else DOM.scope.appendChild(tagElm);
    },

    /**
     * creates a DOM tag element and injects it into the component (this.DOM.scope)
     * @param  {Object}  tagData [text value & properties for the created tag]
     * @param  {Object}  extraData [properties which are for the HTML template only]
     * @return {Object} [DOM element]
     */
    createTagElem(tagData, extraData) {
      tagData.__tagId = getUID();
      var tagElm,
          templateData = extend({}, tagData, _objectSpread2({
        value: escapeHTML(tagData.value + "")
      }, extraData)); // if( this.settings.readonly )
      //     tagData.readonly = true

      tagElm = this.parseTemplate('tag', [templateData]); // crucial for proper caret placement when deleting content. if textNodes are allowed as children of
      // a tag element, a browser bug casues the caret to misplaced inside the tag element (especcially affects "readonly" tags)

      removeTextChildNodes(tagElm); // while( tagElm.lastChild.nodeType == 3 )
      //     tagElm.lastChild.parentNode.removeChild(tagElm.lastChild)

      this.tagData(tagElm, tagData);
      return tagElm;
    },

    /**
     * re-check all invalid tags.
     * called after a tag was edited or removed
     */
    reCheckInvalidTags() {
      var _s = this.settings;
      this.getTagElms(_s.classNames.tagNotAllowed).forEach((tagElm, i) => {
        var tagData = this.tagData(tagElm),
            hasMaxTags = this.hasMaxTags(),
            tagValidation = this.validateTag(tagData); // if the tag has become valid

        if (tagValidation === true && !hasMaxTags) {
          tagData = tagData.__preInvalidData ? tagData.__preInvalidData : {
            value: tagData.value
          };
          return this.replaceTag(tagElm, tagData);
        } // if the tag is still invaild, set its title as such (reson of invalid might have changed)


        tagElm.title = hasMaxTags || tagValidation;
      });
    },

    /**
     * Removes a tag
     * @param  {Array|Node|String}  tagElms         [DOM element(s) or a String value. if undefined or null, remove last added tag]
     * @param  {Boolean}            silent          [A flag, which when turned on, does not remove any value and does not update the original input value but simply removes the tag from tagify]
     * @param  {Number}             tranDuration    [Transition duration in MS]
     * TODO: Allow multiple tags to be removed at-once
     */
    removeTags(tagElms, silent, tranDuration) {
      var tagsToRemove;
      tagElms = tagElms && tagElms instanceof HTMLElement ? [tagElms] : tagElms instanceof Array ? tagElms : tagElms ? [tagElms] : [this.getLastTag()]; // normalize tagElms array values:
      // 1. removing invalid items
      // 2, if an item is String try to get the matching Tag HTML node
      // 3. get the tag data
      // 4. return a collection of Objects

      tagsToRemove = tagElms.reduce((elms, tagElm) => {
        if (tagElm && typeof tagElm == 'string') tagElm = this.getTagElmByValue(tagElm);
        var tagData = this.tagData(tagElm);
        if (tagElm && tagData && !tagData.readonly) // make sure it's a tag and not some other node
          // because the DOM node might be removed by async animation, the state will be updated while
          // the node might still be in the DOM, so the "update" method should know which nodes to ignore
          elms.push({
            node: tagElm,
            idx: this.getTagIdx(tagData),
            // this.getNodeIndex(tagElm); // this.getTagIndexByValue(tagElm.textContent)
            data: this.tagData(tagElm, {
              '__removed': true
            })
          });
        return elms;
      }, []);
      tranDuration = typeof tranDuration == "number" ? tranDuration : this.CSSVars.tagHideTransition;

      if (this.settings.mode == 'select') {
        tranDuration = 0;
        this.input.set.call(this);
      } // if only a single tag is to be removed


      if (tagsToRemove.length == 1) {
        if (tagsToRemove[0].node.classList.contains(this.settings.classNames.tagNotAllowed)) silent = true;
      }

      if (!tagsToRemove.length) return;
      return this.settings.hooks.beforeRemoveTag(tagsToRemove, {
        tagify: this
      }).then(() => {
        function removeNode(tag) {
          if (!tag.node.parentNode) return;
          tag.node.parentNode.removeChild(tag.node);

          if (!silent) {
            // this.removeValueById(tagData.__uid)
            this.trigger('remove', {
              tag: tag.node,
              index: tag.idx,
              data: tag.data
            });
            this.dropdown.refilter();
            this.dropdown.position();
            this.DOM.input.normalize(); // best-practice when in mix-mode (safe to do always anyways)
            // check if any of the current tags which might have been marked as "duplicate" should be un-marked

            if (this.settings.keepInvalidTags) this.reCheckInvalidTags();
          } else if (this.settings.keepInvalidTags) this.trigger('remove', {
            tag: tag.node,
            index: tag.idx
          });
        }

        function animation(tag) {
          tag.node.style.width = parseFloat(window.getComputedStyle(tag.node).width) + 'px';
          document.body.clientTop; // force repaint for the width to take affect before the "hide" class below

          tag.node.classList.add(this.settings.classNames.tagHide); // manual timeout (hack, since transitionend cannot be used because of hover)

          setTimeout(removeNode.bind(this), tranDuration, tag);
        }

        if (tranDuration && tranDuration > 10 && tagsToRemove.length == 1) animation.call(this, tagsToRemove[0]);else tagsToRemove.forEach(removeNode.bind(this)); // update state regardless of animation

        if (!silent) {
          this.removeTagsFromValue(tagsToRemove.map(tag => tag.node));
          this.update(); // update the original input with the current value

          if (this.settings.mode == 'select') this.setContentEditable(true);
        }
      }).catch(reason => {});
    },

    removeTagsFromDOM() {
      [].slice.call(this.getTagElms()).forEach(elm => elm.parentNode.removeChild(elm));
    },

    /**
     * @param {Array/Node} tags to be removed from the this.value array
     */
    removeTagsFromValue(tags) {
      tags = Array.isArray(tags) ? tags : [tags];
      tags.forEach(tag => {
        var tagData = this.tagData(tag),
            tagIdx = this.getTagIdx(tagData); //  delete tagData.__removed

        if (tagIdx > -1) this.value.splice(tagIdx, 1);
      });
    },

    removeAllTags(opts) {
      opts = opts || {};
      this.value = [];
      if (this.settings.mode == 'mix') this.DOM.input.innerHTML = '';else this.removeTagsFromDOM();
      this.dropdown.position();

      if (this.settings.mode == 'select') {
        this.input.set.call(this);
        this.setContentEditable(true);
      } // technically for now only "withoutChangeEvent" exists in the opts.
      // if more properties will be added later, only pass what's needed to "update"


      this.update(opts);
    },

    postUpdate() {
      var classNames = this.settings.classNames,
          hasValue = this.settings.mode == 'mix' ? this.settings.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value.trim() : this.value.length + this.input.raw.call(this).length;
      this.toggleClass(classNames.hasMaxTags, this.value.length >= this.settings.maxTags);
      this.toggleClass(classNames.hasNoTags, !this.value.length);
      this.toggleClass(classNames.empty, !hasValue);
    },

    setOriginalInputValue(v) {
      var inputElm = this.DOM.originalInput;

      if (!this.settings.mixMode.integrated) {
        inputElm.value = v;
        inputElm.tagifyValue = inputElm.value; // must set to "inputElm.value" and not again to "inputValue" because for some reason the browser changes the string afterwards a bit.

        this.setPersistedData(v, 'value');
      }
    },

    /**
     * update the origianl (hidden) input field's value
     * see - https://stackoverflow.com/q/50957841/104380
     */
    update(args) {
      var inputValue = this.getInputValue();
      this.setOriginalInputValue(inputValue);
      this.postUpdate();
      if (!(args || {}).withoutChangeEvent && !this.state.blockChangeEvent) this.triggerChangeEvent();
    },

    getInputValue() {
      var value = this.getCleanValue();
      return this.settings.mode == 'mix' ? this.getMixedTagsAsString(value) : value.length ? this.settings.originalInputValueFormat ? this.settings.originalInputValueFormat(value) : JSON.stringify(value) : "";
    },

    /**
     * removes properties from `this.value` which are only used internally
     */
    getCleanValue(v) {
      return removeCollectionProp(v || this.value, this.dataProps);
    },

    getMixedTagsAsString() {
      var result = "",
          that = this,
          _interpolator = this.settings.mixTagsInterpolator;

      function iterateChildren(rootNode) {
        rootNode.childNodes.forEach(node => {
          if (node.nodeType == 1) {
            const tagData = that.tagData(node);

            if (node.tagName == 'BR') {
              result += "\r\n";
            }

            if (node.tagName == 'DIV' || node.tagName == 'P') {
              result += "\r\n"; //  if( !node.children.length && node.textContent )
              //  result += node.textContent;

              iterateChildren(node);
            } else if (isNodeTag.call(that, node) && tagData) {
              if (tagData.__removed) return;else result += _interpolator[0] + JSON.stringify(omit(tagData, that.dataProps)) + _interpolator[1];
            }
          } else result += node.textContent;
        });
      }

      iterateChildren(this.DOM.input);
      return result;
    }

  }; // legacy support for changed methods names

  Tagify.prototype.removeTag = Tagify.prototype.removeTags;

  return Tagify;

})));
