/**
 * Tagify (v 4.17.7) - tags input component
 * By undefined
 * https://github.com/yairEO/tagify
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 * 
 * THE SOFTWARE IS NOT PERMISSIBLE TO BE SOLD.
 */

(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);
      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 = null != arguments[i] ? arguments[i] : {};
      i % 2 ? ownKeys(Object(source), !0).forEach(function (key) {
        _defineProperty(target, key, source[key]);
      }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) {
        Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
      });
    }
    return target;
  }
  function _defineProperty(obj, key, value) {
    key = _toPropertyKey(key);
    if (key in obj) {
      Object.defineProperty(obj, key, {
        value: value,
        enumerable: true,
        configurable: true,
        writable: true
      });
    } else {
      obj[key] = value;
    }
    return obj;
  }
  function _toPrimitive(input, hint) {
    if (typeof input !== "object" || input === null) return input;
    var prim = input[Symbol.toPrimitive];
    if (prim !== undefined) {
      var res = prim.call(input, hint || "default");
      if (typeof res !== "object") return res;
      throw new TypeError("@@toPrimitive must return a primitive value.");
    }
    return (hint === "string" ? String : Number)(input);
  }
  function _toPropertyKey(arg) {
    var key = _toPrimitive(arg, "string");
    return typeof key === "symbol" ? key : String(key);
  }

  // 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);
  }

  /**
  * Get the caret position relative to the viewport
  * https://stackoverflow.com/q/58985076/104380
  *
  * @returns {object} left, top distance in pixels
  */
  function 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
    };
  }

  /**
   * Injects content (either string or node) at the current the current (or specificed) caret position
   * @param {content} string/node
   * @param {range} Object (optional, a range other than the current window selection)
   */
  function injectAtCaret(content, range) {
    var selection = window.getSelection();
    range = range || selection.getRangeAt(0);
    if (typeof content == 'string') content = document.createTextNode(content);
    if (range) {
      range.deleteContents();
      range.insertNode(content);
    }
    return content;
  }

  /** Setter/Getter
   * Each tag DOM node contains a custom property called "__tagifyTagData" which hosts its data
   * @param {Node}   tagElm
   * @param {Object} data
   */
  function getSetTagData(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;
  }

  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,
    // automatically adds the text which was inputed as a tag when blur event happens
    onChangeAfterBlur: true,
    // By default, the native way of inputs' onChange events is kept, and it only fires when the field is blured.
    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,
    // Only allow tags from the whitelist
    userInput: true,
    // disable manually typing/pasting/editing tags (tags may only be added from the whitelist)
    keepInvalidTags: false,
    // if true, do not remove tags which did not pass validation
    createInvalidTags: true,
    // if false, do not create invalid tags from invalid user input
    mixTagsAllowedAfter: /,|\.|\:|\s/,
    // RegEx - Define conditions in which mix-tags content allows a tag to be added after
    mixTagsInterpolator: ['[[', ']]'],
    // Interpolation for mix mode. Everything between these will become a tag, if is a valid Object
    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',
      dropdownHeader: 'tagify__dropdown__header',
      dropdownFooter: 'tagify__dropdown__footer',
      dropdownItem: 'tagify__dropdown__item',
      dropdownItemActive: 'tagify__dropdown__item--active',
      dropdownItemHidden: 'tagify__dropdown__item--hidden',
      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,
      includeSelectedTags: false,
      // Should the suggestions list Include already-selected tags (after filtering)
      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 once DOM has been loaded
    },

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

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

    // auto-bind "this" to all the dropdown methods
    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("[data-selector='tagify-suggestions-wrapper']");
    },
    getHeaderRef() {
      return this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-header']");
    },
    getFooterRef() {
      return this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-footer']");
    },
    getAllSuggestionsRefs() {
      return [...this.DOM.dropdown.content.querySelectorAll(this.settings.classNames.dropdownItemSelector)];
    },
    /**
     * 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 || this.settings.readonly) 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.querySelector(_s.classNames.dropdownItemSelector));
      }

      // 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;
    },
    /**
     * re-renders the dropdown content element (see "dropdownContent" in templates file)
     * @param {String/Array} HTMLContent - optional
     */
    fill(HTMLContent) {
      HTMLContent = typeof HTMLContent == 'string' ? HTMLContent : this.dropdown.createListHTML(HTMLContent || this.suggestedListItems);
      var dropdownContent = this.settings.templates.dropdownContent.call(this, HTMLContent);
      this.DOM.dropdown.content.innerHTML = minify(dropdownContent);
    },
    /**
     * Re-renders only the header & footer.
     * Used when selecting a suggestion and it is wanted that the suggestions dropdown stays open.
     * Since the list of sugegstions is not being re-rendered completely every time a suggestion is selected (the item is transitioned-out)
     * then the header & footer should be kept in sync with the suggestions data change
     */
    fillHeaderFooter() {
      var suggestions = this.dropdown.filterListItems(this.state.dropdown.query),
        newHeaderElem = this.parseTemplate('dropdownHeader', [suggestions]),
        newFooterElem = this.parseTemplate('dropdownFooter', [suggestions]),
        headerRef = this.dropdown.getHeaderRef(),
        footerRef = this.dropdown.getFooterRef();
      newHeaderElem && headerRef?.parentNode.replaceChild(newHeaderElem, headerRef);
      newFooterElem && footerRef?.parentNode.replaceChild(newFooterElem, footerRef);
    },
    /**
     * fill data into the suggestions list
     * (mainly used to update the list when removing tags while the suggestions dropdown is visible, 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,
        isDefaultAppendTarget = _sd.appendTarget === document.body,
        appendTargetScrollTop = isDefaultAppendTarget ? window.pageYOffset : _sd.appendTarget.scrollTop,
        root = document.fullscreenElement || document.webkitFullscreenElement || document.documentElement,
        viewportHeight = root.clientHeight,
        viewportWidth = Math.max(root.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;

        // when in element-fullscreen mode, do not go above the fullscreened-element
        while (p && p != root) {
          left += p.offsetLeft || 0;
          top += p.offsetTop || 0;
          p = p.parentNode;
        }
        return {
          left,
          top
        };
      }
      function getAccumulatedAncestorsScrollTop() {
        var scrollTop = 0,
          p = _sd.appendTarget.parentNode;
        while (p) {
          scrollTop += p.scrollTop || 0;
          p = p.parentNode;
        }
        return scrollTop;
      }
      if (!this.state.dropdown.visible) return;
      if (positionTo == 'text') {
        rect = getCaretGlobalPosition();
        bottom = rect.bottom;
        top = rect.top;
        left = rect.left;
        width = 'auto';
      } else {
        parentsPositions = getParentsPositions(_sd.appendTarget);
        rect = ddTarget.getBoundingClientRect();
        top = rect.top - parentsPositions.top;
        bottom = rect.bottom - 1 - parentsPositions.top;
        left = rect.left - parentsPositions.left;
        width = rect.width + 'px';
      }

      // if the "append target" isn't the default, correct the `top` variable by ignoring any scrollTop of the target's Ancestors
      if (!isDefaultAppendTarget) {
        let accumulatedAncestorsScrollTop = getAccumulatedAncestorsScrollTop();
        top += accumulatedAncestorsScrollTop;
        bottom += accumulatedAncestorsScrollTop;
      }
      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 + appendTargetScrollTop) + "px" : "top: " + (bottom + appendTargetScrollTop) + "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() {
        let bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 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, null),
            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') {
          document[action]('scroll', _CBR.position, true);
          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) {
          // ignore keys during IME composition
          if (!this.state.hasFocus || this.state.composing) return;

          // 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 = this.dropdown.getAllSuggestionsRefs(),
                  actionUp = e.key == 'ArrowUp' || e.key == 'Up';
                if (selectedElm) {
                  selectedElm = this.dropdown.getNextOrPrevOption(selectedElm, !actionUp);
                }

                // if no element was found OR current item is not a "real" item, loop
                if (!selectedElm || !selectedElm.matches(this.settings.classNames.dropdownItemSelector)) {
                  selectedElm = dropdownItems[actionUp ? dropdownItems.length - 1 : 0];
                }
                this.dropdown.highlightOption(selectedElm, true);
                // selectedElm.scrollIntoView({inline: 'nearest', behavior: 'smooth'})
                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) {
                    this.dropdown.selectOption(selectedElm);
                    // highlight next option
                    selectedElm = this.dropdown.getNextOrPrevOption(selectedElm, !actionUp);
                    this.dropdown.highlightOption(selectedElm);
                    return;
                  } 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, e);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)
          });
        }
      }
    },
    /**
     * Given a suggestion-item, return the data associated with it
     * @param {HTMLElement} tagElm
     * @returns Object
     */
    getSuggestionDataByNode(tagElm) {
      var value = tagElm && tagElm.getAttribute('value');
      return this.suggestedListItems.find(item => item.value == value) || null;
    },
    getNextOrPrevOption(selected) {
      let next = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
      var dropdownItems = this.dropdown.getAllSuggestionsRefs(),
        selectedIdx = dropdownItems.findIndex(item => item === selected);
      return next ? dropdownItems[selectedIdx + 1] : dropdownItems[selectedIdx - 1];
    },
    /**
     * 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.dropdown.getSuggestionDataByNode(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
     * @param {Object} event The original Click event, if available (since keyboard ENTER key also triggers this method)
     */
    selectOption(elm, event) {
      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;
      }
      event = event || {};

      // 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 value = elm.getAttribute('value'),
        isNoMatch = value == 'noMatch',
        tagData = this.suggestedListItems.find(item => (item.value || item) == value);

      // The below event must be triggered, regardless of anything else which might go wrong
      this.trigger('dropdown:select', {
        data: tagData,
        elm,
        event
      });
      if (!value || !tagData && !isNoMatch) {
        closeOnSelect && setTimeout(this.dropdown.hide.bind(this));
        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 || this.input.raw.call(this)], clearOnSelect);
      }

      // todo: consider not doing this on mix-mode
      if (!this.DOM.input.parentNode) return;
      setTimeout(() => {
        this.DOM.input.focus();
        this.toggleFocusClass(true);
      });
      closeOnSelect && setTimeout(this.dropdown.hide.bind(this));

      // hide selected suggestion
      elm.addEventListener('transitionend', () => {
        this.dropdown.fillHeaderFooter();
        setTimeout(() => elm.remove(), 100);
      }, {
        once: true
      });
      elm.classList.add(this.settings.classNames.dropdownItemHidden);
    },
    // adds all the suggested items, including the ones which are not currently rendered,
    // unless specified otherwise (by the "onlyRendered" argument)
    selectAll(onlyRendered) {
      // having suggestedListItems with items messes with "normalizeTags" when wanting
      // to add all tags
      this.suggestedListItems.length = 0;
      this.dropdown.hide();
      this.dropdown.filterListItems('');
      var tagsToAdd = this.dropdown.filterListItems('');
      if (!onlyRendered) tagsToAdd = this.state.dropdown.suggestions;

      // 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(tagsToAdd, 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 || {},
        list = [],
        exactMatchesList = [],
        whitelist = _s.whitelist,
        suggestionsCount = _sd.maxItems >= 0 ? _sd.maxItems : Infinity,
        searchKeys = _sd.searchKeys,
        whitelistItem,
        valueIsInWhitelist,
        searchBy,
        isDuplicate,
        niddle,
        i = 0;
      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;
      if (!value || !searchKeys.length) {
        list = _sd.includeSelectedTags ? whitelist : whitelist.filter(item => !this.isTagDuplicate(isObject(item) ? item.value : item)); // don't include tags which have already been added.

        this.state.dropdown.suggestions = list;
        return list.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 = !_sd.includeSelectedTags && 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);
      }
      this.state.dropdown.suggestions = exactMatchesList.concat(list);

      // 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} sugegstionsList  [Array of Objects]
     * @return {String}
     */
    createListHTML(sugegstionsList) {
      return extend([], sugegstionsList).map((suggestion, idx) => {
        if (typeof suggestion == 'string' || typeof suggestion == 'number') suggestion = {
          value: suggestion
        };
        var mappedValue = this.dropdown.getMappedValue(suggestion);
        mappedValue = typeof mappedValue == 'string' ? escapeHTML(mappedValue) : mappedValue;
        return this.settings.templates.dropdownItem.apply(this, [_objectSpread2(_objectSpread2({}, suggestion), {}, {
          mappedValue
        }), this]);
      }).join("");
    }
  };

  const VERSION = 1; // current version of persisted data. if code change breaks persisted data, verison number should be bumped.
  const STORE_KEY = '%40yaireo/tagify/index.html';
  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' : ''}
                    ${_s.mode === 'select' ? "spellcheck='false'" : ''}
                    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, _ref) {
      let _s = _ref.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 data-selector='tagify-suggestions-wrapper' class="${settings.classNames.dropdownWrapper}"></div>
                </div>`;
    },
    dropdownContent(HTMLContent) {
      var _s = this.settings,
        suggestions = this.state.dropdown.suggestions;
      return `
            ${_s.templates.dropdownHeader.call(this, suggestions)}
            ${HTMLContent}
            ${_s.templates.dropdownFooter.call(this, suggestions)}
        `;
    },
    dropdownItem(item) {
      return `<div ${this.getAttributes(item)}
                    class='${this.settings.classNames.dropdownItem} ${item.class ? item.class : ""}'
                    tabindex="0"
                    role="option">${item.mappedValue || item.value}</div>`;
    },
    /**
     * @param {Array} suggestions An array of all the matched suggested items, including those which were sliced away due to the "dropdown.maxItems" setting
     */
    dropdownHeader(suggestions) {
      return `<header data-selector='tagify-suggestions-header' class="${this.settings.classNames.dropdownHeader}"></header>`;
    },
    dropdownFooter(suggestions) {
      var hasMore = suggestions.length - this.settings.dropdown.maxItems;
      return hasMore > 0 ? `<footer data-selector='tagify-suggestions-footer' class="${this.settings.classNames.dropdownFooter}">
                ${hasMore} more items. Refine your search.
            </footer>` : '';
    },
    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;
            if (data.event) eventData.event = this.cloneEvent(data.event);

            // 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() {
      let bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 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)],
        compositionstart: ['input', _CB.onCompositionStart.bind(this)],
        compositionend: ['input', _CB.onCompositionEnd.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
      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 (!this.listeners || !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.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)
      }, {
        type: 'click',
        target: document,
        cb: _CB.onClickAnywhere.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 _s = this.settings,
          text = e.target ? this.trim(e.target.textContent) : '',
          // a string
          currentDisplayValue = this.value?.[0]?.[_s.tagTextProp],
          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();
          _s.onChangeAfterBlur && 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 for it to be added back
          // again in a few more lines of code (shouldAddTags && addTags)
          if (_s.mode == 'select') {
            if (isRelatedTargetX) {
              this.removeTags();
              text = '';
            }

            // if nothing has changed (same display value), do not add a tag
            if (currentDisplayValue === text) text = '';
          }
          shouldAddTags = 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);
        }
        this.DOM.input.removeAttribute('style');
        this.dropdown.hide();
      },
      onCompositionStart(e) {
        this.state.composing = true;
      },
      onCompositionEnd(e) {
        this.state.composing = false;
      },
      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;

        // ignore keys during IME composition or when user input is not allowed
        if (this.state.composing || !_s.userInput) return;
        if (_s.mode == 'select' && _s.enforceWhitelist && this.value.length && e.key != 'Tab') {
          e.preventDefault();
        }
        var s = this.trim(e.target.textContent);
        this.trigger("keydown", {
          event: 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;
                  decode(this.DOM.input.innerHTML);
                  var 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 instanceof Element) {
                  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[Math.min(lastTagElems.length, 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 instanceof Element) 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( setRangeAtStartEnd(false, this.DOM.input) )
                }
                */

                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();
                    decode(this.DOM.input.innerHTML);
                    !deleteKeyTagDetected && sel.anchorNode.previousSibling;

                  // fixes #384, where the first and only tag will not get removed with backspace
                  /*
                   * [UPDATE DEC 3, 22] SEEMS BELOEW CODE IS NOT NEEDED ANY MORE
                   *
                  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 = getSetTagData(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':
            // manual suggestion boxes are assumed to always be visible
            if (this.state.dropdown.visible && _s.dropdown.position != 'manual') 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.dropdown.visible || this.state.actions.selectOption) return;
              this.addTags(s, true);
            });
        }
      },
      onInput(e) {
        this.postUpdate(); // toggles "tagify--empty" class

        var _s = this.settings;
        if (_s.mode == 'mix') return this.events.callbacks.onMixTagsInput.call(this, e);
        var value = this.input.normalize.call(this),
          showSuggestions = value.length >= _s.dropdown.enabled,
          eventData = {
            value,
            inputElm: this.DOM.input
          },
          validation = this.validateTag({
            value
          });
        if (_s.mode == 'select') {
          this.toggleScopeValidation(validation);
        }
        eventData.isValid = validation;

        // 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(false, this.DOM.input); // fix caret position

        // if delimiters detected, add tags
        if (value.search(_s.delimiters) != -1) {
          if (this.addTags(value)) {
            this.input.set.call(this); // clear the input field's value
          }
        } else if (_s.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 => getSetTagData(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 => getSetTagData(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 writing 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 can close 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 another matched tag, if more than one 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 triggered, 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, for some reason, the Tagified element is no longer in the DOM,
        // call the "destroy" method to kill all references to timeouts/intervals
        if (!this.DOM.originalInput.parentNode) this.destroy();

        // if original input value changed for some reason (for exmaple a form reset)
        if (this.DOM.originalInput.value != this.DOM.originalInput.tagifyValue) this.loadOriginalValues();
      },
      onClickAnywhere(e) {
        if (e.target != this.DOM.scope && !this.DOM.scope.contains(e.target)) {
          this.toggleFocusClass(false);
          this.state.hasFocus = false;
        }
      },
      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: getSetTagData(tagElm),
            event: 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' && _s.dropdown.enabled === 0 && !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 = getSetTagData(tagElm),
          textValue = this.input.normalize.call(this, editableElm),
          dataForChangedProp = {
            [this.settings.tagTextProp]: textValue,
            __tagId: tagData.__tagId
          },
          // "__tagId" is needed so validation will skip current tag when checking for dups
          isValid = this.validateTag(dataForChangedProp),
          // the value could have been invalid in the first-place so make sure to re-validate it (via "addEmptyTag" method)
          hasChanged = this.editTagChangeDetected(extend(tagData, dataForChangedProp));

        // 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 (textValue.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 = textValue;
          this.dropdown.show(textValue);
        }
        this.trigger("edit:input", {
          tag: tagElm,
          index: tagElmIdx,
          data: extend({}, this.value[tagElmIdx], {
            newValue: textValue
          }),
          event: e
        });
      },
      onEditTagPaste(tagElm, e) {
        // Get pasted data via clipboard API
        var clipboardData = e.clipboardData || window.clipboardData,
          pastedText = clipboardData.getData('Text');
        e.preventDefault();
        var newNode = injectAtCaret(pastedText);
        this.setRangeAtStartEnd(false, newNode);
      },
      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),
          tagData = getSetTagData(tagElm),
          textValue = this.input.normalize.call(this, editableElm),
          dataForChangedProp = {
            [_s.tagTextProp]: textValue,
            __tagId: tagData.__tagId
          },
          // "__tagId" is needed so validation will skip current tag when checking for dups
          originalData = tagData.__originalData,
          // pre-edit data
          hasChanged = this.editTagChangeDetected(extend(tagData, dataForChangedProp)),
          isValid = this.validateTag(dataForChangedProp),
          // "__tagId" is needed so validation will skip current tag when checking for dups
          hasMaxTags,
          newTagData;
        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 = extend({}, originalData, {
          [_s.tagTextProp]: this.trim(textValue),
          __isValid: isValid
        });

        // pass through optional transformer defined in settings
        _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(newTagData);
        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 invalid
          delete newTagData.title;
          delete newTagData["aria-invalid"];
          delete newTagData.class;
        }

        // tagElm.classList.toggle(_s.classNames.tagInvalid, true)

        this.onEditTagDone(tagElm, newTagData);
      },
      onEditTagkeydown(e, tagElm) {
        // ignore keys during IME composition
        if (this.state.composing) return;
        this.trigger("edit:keydown", {
          event: e
        });
        switch (e.key) {
          case 'Esc':
          case 'Escape':
            {
              // revert the tag to how it was before editing
              // replace current tag with original one (pre-edited one)
              tagElm.parentNode.replaceChild(tagElm.__tagifyTagData.__originalHTML, tagElm);
              this.state.editing = false;
            }
          case 'Enter':
          case 'Tab':
            e.preventDefault();
            e.target.blur();
        }
      },
      onDoubleClickScope(e) {
        var tagElm = e.target.closest('.' + this.settings.classNames.tag),
          tagData = getSetTagData(tagElm),
          _s = this.settings,
          isEditingTag,
          isReadyOnlyTag;
        if (!tagElm || !_s.userInput || tagData.editable === false) 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: getSetTagData(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 => {
            // 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);
            }

            // if this is a tag
            else if (isNodeTag.call(this, addedNode)) {
              if (addedNode.previousSibling?.nodeType == 3 && !addedNode.previousSibling.textContent) addedNode.previousSibling.remove();
              // 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');
                let nextNode = addedNode.nextSibling,
                  anythingAfterNode = '';
                while (nextNode) {
                  anythingAfterNode += nextNode.textContent;
                  nextNode = nextNode.nextSibling;
                }

                // when hitting ENTER for new line just before an existing tag, but skip below logic when a tag has been addded
                anythingAfterNode.trim() && this.placeCaretAfterNode(addedNode.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 exist
      const mockInstance = new Proxy(this, {
        get() {
          return () => mockInstance;
        }
      });
      return mockInstance;
    }
    if (input.__tagify) {
      console.warn('Tagify: ', 'input element is already Tagified - Same instance is returned.', input);
      return input.__tagify;
    }
    extend(this, EventDispatcher(this));
    this.isFirefox = /firefox|fxios/i.test(navigator.userAgent) && !/seamonkey/i.test(navigator.userAgent);
    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,
      composing: 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();
    input.__tagify = this;
  }
  Tagify.prototype = {
    _dropdown,
    getSetTagData,
    helpers: {
      sameStr,
      removeCollectionProp,
      omit,
      isObject,
      parseHTML,
      escapeHTML,
      extend,
      concatWithoutDups,
      getUID,
      isNodeTag
    },
    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 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;
    },
    generateClassSelectors(classNames) {
      for (let name in classNames) {
        let currentName = name;
        Object.defineProperty(classNames, currentName + "Selector", {
          get() {
            return "." + this[currentName].split(" ")[0];
          }
        });
      }
    },
    applySettings(input, settings) {
      DEFAULTS.templates = this.templates;
      var mixModeDefaults = {
        dropdown: {
          position: "text"
        }
      };
      var mergedDefaults = extend({}, DEFAULTS, settings.mode == 'mix' ? mixModeDefaults : {});
      var _s = this.settings = extend({}, mergedDefaults, 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');
      this.generateClassSelectors(_s.classNames);
      if (_s.dropdown.includeSelectedTags === undefined) _s.dropdown.includeSelectedTags = _s.duplicates;
      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.pattern = _s.pattern || /@/;
        _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 (_s.delimiters) {
        _s._delimiters = _s.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' && !settings.dropdown?.enabled || !_s.userInput) {
        _s.dropdown.enabled = 0;
      }
      _s.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 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: (_ref => {
          let value = _ref.value,
            unit = _ref.unit;
          return 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.originalInput_tabIndex = input.tabIndex;
        DOM.scope = this.parseTemplate('wrapper', [input, this.settings]);
        DOM.input = DOM.scope.querySelector(this.settings.classNames.inputSelector);
        input.parentNode.insertBefore(DOM.scope, input);
        input.tabIndex = -1; // do not allow focus or typing directly, once tagified
      }
    },

    /**
     * revert any changes made by this component
     */
    destroy() {
      this.events.unbindGlobal.call(this);
      this.DOM.scope.parentNode.removeChild(this.DOM.scope);
      this.DOM.originalInput.tabIndex = this.DOM.originalInput_tabIndex;
      delete this.DOM.originalInput.__tagify;
      this.dropdown.hide(true);
      clearTimeout(this.dropdownHide__bindEventsTimeout);
      clearInterval(this.listeners.main.originalInputValueObserverInterval);
    },
    /**
     * 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(value);
          lastChild = this.DOM.input.lastChild;

          // fixes a Chrome bug, when the last node in `mix-mode` is a tag, the caret appears at the far-top-top, outside the field
          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, true).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) if (v != 'path') 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 a 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);
    },
    toggleScopeValidation(validation) {
      var isValid = validation === true || validation === undefined; // initially it is undefined

      if (!this.settings.required && validation && validation === this.TEXTS.empty) isValid = true;
      this.toggleClass(this.settings.classNames.tagInvalid, !isValid);
      this.DOM.scope.title = isValid ? '' : validation;
    },
    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)
      // var inputElm = this.DOM.input

      // if( this.isFirefox && inputElm.childNodes.length && inputElm.lastChild.nodeType == 1 ){
      //     inputElm.appendChild(document.createTextNode("\u200b"))
      //     this.setRangeAtStartEnd(true, inputElm)
      //     return true
      // }
    },

    /** 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) {
      if (!node) return;
      start = typeof start == 'number' ? start : !!start;
      node = node.lastChild || node;
      var sel = document.getSelection();

      // do not force caret placement if the current selection (focus) is on another element (not this tagify instance)
      if (sel.focusNode instanceof Element && !this.DOM.input.contains(sel.focusNode)) {
        return true;
      }
      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)
      }
    },
    placeCaretAfterNode(node) {
      if (!node || !node.parentNode) return;
      var nextSibling = node,
        sel = window.getSelection(),
        range = sel.getRangeAt(0);
      if (sel.rangeCount) {
        range.setStartAfter(nextSibling);
        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;
    },
    // compares all "__originalData" property values with the current "tagData" properties
    // and returns "true" if something changed.
    editTagChangeDetected(tagData) {
      var originalData = tagData.__originalData;
      for (var prop in originalData) if (!this.dataProps.includes(prop) && tagData[prop] != originalData[prop]) return true;
      return false; // not changed
    },

    // returns the node which has the actual tag's content
    getTagTextNode(tagElm) {
      return tagElm.querySelector(this.settings.classNames.tagTextSelector);
    },
    // sets the text of a tag
    setTagTextNode(tagElm, HTML) {
      this.getTagTextNode(tagElm).innerHTML = escapeHTML(HTML);
    },
    /**
     * 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,
        editableElm = this.getTagTextNode(tagElm),
        tagIdx = this.getNodeIndex(tagElm),
        tagData = getSetTagData(tagElm),
        _CB = this.events.callbacks,
        that = this,
        isValid = true,
        delayed_onEditTagBlur = function () {
          setTimeout(() => _CB.onEditTagBlur.call(that, that.getTagTextNode(tagElm)));
        };
      if (!editableElm) {
        console.warn('Cannot find element in Tag template: .', _s.classNames.tagTextSelector);
        return;
      }
      if (tagData instanceof Object && "editable" in tagData && !tagData.editable) return;

      // cache the original data, on the DOM node, before any modification ocurs, for possible revert
      tagData = getSetTagData(tagElm, {
        __originalData: extend({}, tagData),
        __originalHTML: tagElm.cloneNode(true)
      });
      // re-set the tagify custom-prop on the clones element (because cloning removed it)
      getSetTagData(tagData.__originalHTML, tagData.__originalData);
      editableElm.setAttribute('contenteditable', true);
      tagElm.classList.add(_s.classNames.tagEditing);
      editableElm.addEventListener('focus', _CB.onEditTagFocus.bind(this, tagElm));
      editableElm.addEventListener('blur', delayed_onEditTagBlur);
      editableElm.addEventListener('input', _CB.onEditTagInput.bind(this, editableElm));
      editableElm.addEventListener('paste', _CB.onEditTagPaste.bind(this, editableElm));
      editableElm.addEventListener('keydown', e => _CB.onEditTagkeydown.call(this, e, tagElm));
      editableElm.addEventListener('compositionstart', _CB.onCompositionStart.bind(this));
      editableElm.addEventListener('compositionend', _CB.onCompositionEnd.bind(this));
      if (!opts.skipValidation) isValid = this.editTagToggleValidity(tagElm);
      editableElm.originalIsValid = isValid;
      this.trigger("edit:start", {
        tag: tagElm,
        index: tagIdx,
        data: tagData,
        isValid
      });
      editableElm.focus();
      this.setRangeAtStartEnd(false, editableElm); // place the caret at the END of the editable tag text

      return this;
    },
    /**
     * If a tag is invalid, for any reason, set its class to "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 || getSetTagData(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: getSetTagData(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);
      } 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(getSetTagData(node));
      });
      this.update();
    },
    /**
     * 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 && injectedNode) {
        this.appendMixTags(injectedNode);
        return this;
      }
      injectAtCaret(injectedNode, range);
      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() {
        let s = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
        let updateDOM = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 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

        return this.trim(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 || {
            value: ''
          };
          if (typeof data == 'string') data = {
            value: data
          };
          var suggestedText = this.dropdown.getMappedValue(data);
          if (typeof suggestedText === 'number') return;
          var 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(false, this.DOM.input);
            }
            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() {
      for (var _len = arguments.length, classess = new Array(_len), _key = 0; _key < _len; _key++) {
        classess[_key] = arguments[_key];
      }
      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];
    },
    /**
     * 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, tagId) {
      var dupsCount = 0,
        _s = this.settings;

      // duplications are irrelevant for this scenario
      if (_s.mode == 'select') return false;
      for (let item of this.value) {
        let isSameStr = sameStr(this.trim("" + value), item.value, caseSensitive);
        if (isSameStr && tagId != item.__tagId) dupsCount++;
      }
      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 matches 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, _s.dropdown.caseSensitive, tagData.__tagId)) 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);
      this.setContentEditable(!toggle);
    },
    setContentEditable(state) {
      if (!this.settings.userInput) return;
      this.DOM.input.contentEditable = state;
      this.DOM.input.tabIndex = !!state ? 0 : -1;
    },
    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,
        whitelistMatches = [],
        whitelistWithProps = whitelist ? whitelist[0] instanceof Object : false,
        isArray = Array.isArray(tagsItems),
        isCollection = isArray && tagsItems[0].value,
        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);
      }

      // if 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.
      // skip matching collections with whitelist items as they are considered "whole"
      if (whitelistWithProps && !isCollection) {
        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) => getSetTagData(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 = this.state.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(false, this.DOM.input));
      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);
      getSetTagData(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,
        aggregatedinvalidInput = [],
        frag = document.createDocumentFragment();
      skipInvalid = skipInvalid || _s.skipInvalid;
      if (!tagsItems || tagsItems.length == 0) {
        return tagElems;
      }

      // converts Array/String/Object to an Array of Objects
      tagsItems = this.normalizeTags(tagsItems);
      switch (_s.mode) {
        case 'mix':
          return this.addMixTags(tagsItems);
        case 'select':
          {
            clearInput = false;
            this.removeAllTags();
          }
      }
      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 (!_s.createInvalidTags) {
            aggregatedinvalidInput.push(tagData.value);
            return;
          }
        }
        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, _s.createInvalidTags ? '' : aggregatedinvalidInput.join(_s._delimiters));
        this.setRangeAtStartEnd(false, this.DOM.input);
      }
      _s.dropdown.enabled && 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]);
      }
      var frag = document.createDocumentFragment();
      tagsData.forEach(tagData => {
        var tagElm = this.createTagElem(tagData);
        frag.appendChild(tagElm);
      });
      this.appendMixTags(frag);
      return frag;
    },
    appendMixTags(node) {
      var selection = !!this.state.selection;

      // 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(node);
      }
      // 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(node);
        this.updateValueByDOMTags(); // updates internal "this.value"
        this.update(); // updates original input/textarea
      }
    },

    /**
     * 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;
        // a timeout is needed when selecting a tag from the suggestions via mouse.
        // Without it, it seems the caret is placed right after the tag and not after the
        // node which was inserted after the tag (whitespace by default)
        setTimeout(this.placeCaretAfterNode, 0, 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.input;

      //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, this]);

      // 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 be misplaced inside the tag element (especially affects "readonly" tags)
      removeTextChildNodes(tagElm);
      // while( tagElm.lastChild.nodeType == 3 )
      //     tagElm.lastChild.parentNode.removeChild(tagElm.lastChild)

      getSetTagData(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 = getSetTagData(tagElm),
          hasMaxTags = this.hasMaxTags(),
          tagValidation = this.validateTag(tagData),
          isValid = tagValidation === true && !hasMaxTags;
        if (_s.mode == 'select') this.toggleScopeValidation(tagValidation);

        // if the tag has become valid
        if (isValid) {
          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,
        _s = this.settings;
      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 = getSetTagData(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: getSetTagData(tagElm, {
              '__removed': true
            })
          });
        return elms;
      }, []);
      tranDuration = typeof tranDuration == "number" ? tranDuration : this.CSSVars.tagHideTransition;
      if (_s.mode == 'select') {
        tranDuration = 0;
        this.input.set.call(this);
      }

      // if only a single tag is to be removed.
      // skip "select" mode because invalid tags are actually set to `this.value`
      if (tagsToRemove.length == 1 && _s.mode != 'select') {
        if (tagsToRemove[0].node.classList.contains(_s.classNames.tagNotAllowed)) silent = true;
      }
      if (!tagsToRemove.length) return;
      return _s.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 (_s.keepInvalidTags) this.reCheckInvalidTags();

            // below code is unfinished. it should iterate all currently invalid edited tags, which their edits have not
            // changed the value yet, and should re-trigger the check, but since nothing has changed, it does not work...
            // this.getTagElms(_s.classNames.tagEditing).forEach( this.events.callbacks.onEditTagBlur.bind )
          } else if (_s.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(_s.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 (_s.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 = getSetTagData(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.refilter();
      this.dropdown.position();
      if (this.state.dropdown.visible) setTimeout(() => {
        this.DOM.input.focus();
      });
      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 _s = this.settings,
        classNames = _s.classNames,
        hasValue = _s.mode == 'mix' ? _s.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 >= _s.maxTags);
      this.toggleClass(classNames.hasNoTags, !this.value.length);
      this.toggleClass(classNames.empty, !hasValue);

      // specifically the "select mode" might have the "invalid" classname set when the field is changed, so it must be toggled on add/remove/edit
      if (_s.mode == 'select') {
        this.toggleScopeValidation(this.value?.[0]?.__isValid);
      }
    },
    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) {
      const UPDATE_DELAY = 100;
      clearTimeout(this.debouncedUpdateTimeout);
      this.debouncedUpdateTimeout = setTimeout(reallyUpdate.bind(this), UPDATE_DELAY);
      function reallyUpdate() {
        var inputValue = this.getInputValue();
        this.setOriginalInputValue(inputValue);
        this.postUpdate();
        if ((!this.settings.onChangeAfterBlur || !(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,
        _s = this.settings,
        originalInputValueFormat = _s.originalInputValueFormat || JSON.stringify,
        _interpolator = _s.mixTagsInterpolator;
      function iterateChildren(rootNode) {
        rootNode.childNodes.forEach(node => {
          if (node.nodeType == 1) {
            const tagData = getSetTagData(node);
            if (node.tagName == 'BR') {
              result += "\r\n";
            }
            if (tagData && isNodeTag.call(that, node)) {
              if (tagData.__removed) return;else result += _interpolator[0] + originalInputValueFormat(omit(tagData, that.dataProps)) + _interpolator[1];
            } else if (node.getAttribute('style') || ['B', 'I', 'U'].includes(node.tagName)) result += node.textContent;else if (node.tagName == 'DIV' || node.tagName == 'P') {
              result += "\r\n";
              //  if( !node.children.length && node.textContent )
              //  result += node.textContent;
              iterateChildren(node);
            }
          } else result += node.textContent;
        });
      }
      iterateChildren(this.DOM.input);
      return result;
    }
  };

  // legacy support for changed methods names
  Tagify.prototype.removeTag = Tagify.prototype.removeTags;

  return Tagify;

}));
