From: Sweidan Omar Date: Tue, 22 Feb 2022 15:13:36 +0000 (+0000) Subject: git-tfs-id: [http://tfs.userrendszerhaz.hu:8080/tfs/DefaultCollection]$/MediaCube... X-Git-Url: http://git.useribm.hu/?a=commitdiff_plain;h=97a098b893ee4698d3c78fa56a4848e6dc8892e4;p=mediacube.git git-tfs-id: [tfs.userrendszerhaz.hu:8080/tfs/DefaultCollection]$/MediaCube;C32990 --- diff --git a/server/user.mediacube.gui/js/tagify.js b/server/user.mediacube.gui/js/tagify.js index 04ab4780..9382b9e4 100644 --- a/server/user.mediacube.gui/js/tagify.js +++ b/server/user.mediacube.gui/js/tagify.js @@ -1,83 +1,248 @@ /** - * Tagify (v 3.2.6)- tags input component + * Tagify (v 4.9.8) - tags input component * By Yair Even-Or * Don't sell this code. (c) * https://github.com/yairEO/tagify */ -;(function(root, factory) { - if (typeof define === 'function' && define.amd) { - define([], factory); - } else if (typeof exports === 'object') { - module.exports = factory(); - } else { - root.Tagify = factory(); + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Tagify = factory()); +}(this, (function () { 'use strict'; + + function ownKeys(object, enumerableOnly) { + var keys = Object.keys(object); + + if (Object.getOwnPropertySymbols) { + var symbols = Object.getOwnPropertySymbols(object); + + if (enumerableOnly) { + symbols = symbols.filter(function (sym) { + return Object.getOwnPropertyDescriptor(object, sym).enumerable; + }); + } + + keys.push.apply(keys, symbols); + } + + return keys; } -}(this, function() { -"use strict"; -function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } + function _objectSpread2(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i] != null ? arguments[i] : {}; + + if (i % 2) { + ownKeys(Object(source), true).forEach(function (key) { + _defineProperty(target, key, source[key]); + }); + } else if (Object.getOwnPropertyDescriptors) { + Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); + } else { + ownKeys(Object(source)).forEach(function (key) { + Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); + }); + } + } + + return target; + } -function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } + function _defineProperty(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } -function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } + return obj; + } -function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } } + // 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(); + } -function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } + return caseSensitive ? s1 == s2 : s1.toLowerCase() == s2.toLowerCase(); + }; // const getUID = () => (new Date().getTime() + Math.floor((Math.random()*10000)+1)).toString(16) -function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(source, true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(source).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } + const removeCollectionProp = (collection, unwantedProps) => collection && Array.isArray(collection) && collection.map(v => omit(v, unwantedProps)); + function omit(obj, props) { + var newObj = {}, + p; -function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } + for (p in obj) if (props.indexOf(p) < 0) newObj[p] = obj[p]; -/** - * @constructor - * @param {Object} input DOM element - * @param {Object} settings settings object - */ -function Tagify(input, settings) { - // protection - if (!input) { - console.warn('Tagify: ', 'invalid input element ', input); - return this; + 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] + */ - this.applySettings(input, settings || {}); - this.state = { - editing: {}, - actions: {}, - // UI actions for state-locking - dropdown: {} - }; - 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.extend(this, new this.EventDispatcher(this)); - this.build(input); - this.getCSSVars(); - this.loadOriginalValues(); - this.events.customBinding.call(this); - this.events.binding.call(this); - input.autofocus && this.DOM.input.focus(); -} - -Tagify.prototype = { - isIE: window.document.documentMode, - // https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode#Browser_compatibility - TEXTS: { - empty: "empty", - exceed: "number of tags exceeded", - pattern: "pattern mismatch", - duplicate: "already exists", - notAllowed: "not allowed" - }, - DEFAULTS: { + function minify(s) { + return s ? s.replace(/\>[\r\n ]+\<").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, "&").replace(//g, ">").replace(/"/g, """).replace(/`|'/g, "'") : s; + } + /** + * Checks if an argument is a javascript Object + */ + + function isObject(obj) { + var type = Object.prototype.toString.call(obj).split(' ')[1].slice(0, -1); + return obj === Object(obj) && type != 'Array' && type != 'Function' && type != 'RegExp' && type != 'HTMLUnknownElement'; + } + /** + * merge objects into a single new one + * TEST: extend({}, {a:{foo:1}, b:[]}, {a:{bar:2}, b:[1], c:()=>{}}) + */ + + function extend(o, o1, o2) { + if (!(o instanceof Object)) o = {}; + copy(o, o1); + if (o2) copy(o, o2); + + function copy(a, b) { + // copy o2 to o + for (var key in b) if (b.hasOwnProperty(key)) { + if (isObject(b[key])) { + if (!isObject(a[key])) a[key] = Object.assign({}, b[key]);else copy(a[key], b[key]); + continue; + } + + if (Array.isArray(b[key])) { + a[key] = Object.assign([], b[key]); + continue; + } + + a[key] = b[key]; + } + } + + return o; + } + /** + * concatenates N arrays without dups. + * If an array's item is an Object, compare by `value` + */ + + function concatWithoutDups() { + const newArr = [], + existingObj = {}; + + for (let arr of arguments) { + for (let item of arr) { + // if current item is an object which has yet to be added to the new array + if (isObject(item)) { + if (!existingObj[item.value]) { + newArr.push(item); + existingObj[item.value] = 1; + } + } // if current item is not an object and is not in the new array + else if (!newArr.includes(item)) newArr.push(item); + } + } + + return newArr; + } + /** + * Extracted from: https://stackoverflow.com/a/37511463/104380 + * @param {String} s + */ + + function unaccent(s) { + // if not supported, do not continue. + // developers should use a polyfill: + // https://github.com/walling/unorm + if (!String.prototype.normalize) return s; + if (typeof s === 'string') return s.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + } + /** + * Meassures an element's height, which might yet have been added DOM + * https://stackoverflow.com/q/5944038/104380 + * @param {DOM} node + */ + + function getNodeHeight(node) { + var height, + clone = node.cloneNode(true); + clone.style.cssText = "position:fixed; top:-9999px; opacity:0"; + document.body.appendChild(clone); + height = clone.clientHeight; + clone.parentNode.removeChild(clone); + return height; + } + var isChromeAndroidBrowser = () => /(?=.*chrome)(?=.*android)/i.test(navigator.userAgent); + function getUID() { + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)); + } + function isNodeTag(node) { + return node && node.classList && node.classList.contains(this.settings.classNames.tag); + } + + var DEFAULTS = { delimiters: ",", // [RegEx] split tags by any of these delimiters ("null" to cancel) Example: ",| |." pattern: null, // RegEx pattern to validate input by. Ex: /[1-9]/ + tagTextProp: 'value', + // tag data Object property which will be displayed as the tag's text maxTags: Infinity, // Maximum number of tags callbacks: {}, @@ -85,377 +250,910 @@ Tagify.prototype = { addTagOnBlur: true, // Flag - automatically adds the text which was inputed as a tag when blur event happens duplicates: false, - // Flag - allow tuplicate tags + // "true" - allow duplicate tags whitelist: [], // Array of tags to suggest as the user types (can be used along with "enforceWhitelist" setting) blacklist: [], // A list of non-allowed tags enforceWhitelist: false, - // Flag - Only allow tags allowed in whitelist + // Flag - Only allow tags from the whitelist + userInput: true, + // Flag - disable manually typing/pasting/editing tags (tags may only be added from the whitelist) keepInvalidTags: false, // Flag - if true, do not remove tags which did not pass validation mixTagsAllowedAfter: /,|\.|\:|\s/, - // RegEx - Define conditions in which mix-tags content is allowing a tag to be added after + // RegEx - Define conditions in which mix-tags content allows a tag to be added after mixTagsInterpolator: ['[[', ']]'], // Interpolation for mix mode. Everything between this will becmoe a tag backspace: true, // false / true / "edit" skipInvalid: false, // If `true`, do not add invalid, temporary, tags before automatically removing them - editTags: 2, + 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: function transformTag() {}, + transformTag: () => {}, // Takes a tag input string as argument and returns a transformed value + trim: true, + // whether or not the value provided should be trimmed, before being added as a tag + a11y: { + focusableTags: false + }, + mixMode: { + insertAfterTag: '\u00A0' // String/Node to inject after a tag has been added (see #588) + + }, autoComplete: { enabled: true, // Tries to suggest the input's value while typing (match from whitelist) by adding the rest of term as grayed-out text rightKey: false // If `true`, when Right key is pressed, use the suggested value to create a tag, else just auto-completes the input. in mixed-mode this is set to "true" }, + classNames: { + namespace: 'tagify', + mixMode: 'tagify--mix', + selectMode: 'tagify--select', + input: 'tagify__input', + focus: 'tagify--focus', + tagNoAnimation: 'tagify--noAnim', + tagInvalid: 'tagify--invalid', + tagNotAllowed: 'tagify--notAllowed', + scopeLoading: 'tagify--loading', + hasMaxTags: 'tagify--hasMaxTags', + hasNoTags: 'tagify--noTags', + empty: 'tagify--empty', + inputInvalid: 'tagify__input--invalid', + dropdown: 'tagify__dropdown', + dropdownWrapper: 'tagify__dropdown__wrapper', + dropdownItem: 'tagify__dropdown__item', + dropdownItemActive: 'tagify__dropdown__item--active', + dropdownInital: 'tagify__dropdown--initial', + tag: 'tagify__tag', + tagText: 'tagify__tag-text', + tagX: 'tagify__tag__removeBtn', + tagLoading: 'tagify__tag--loading', + tagEditing: 'tagify__tag--editable', + tagFlash: 'tagify__tag--flash', + tagHide: 'tagify__tag--hide' + }, dropdown: { classname: '', enabled: 2, - // minimum input characters needs to be typed for the dropdown to show + // minimum input characters to be typed for the suggestions dropdown to show maxItems: 10, - searchKeys: [], + searchKeys: ["value", "searchBy"], fuzzySearch: true, + caseSensitive: false, + accentedSearch: true, highlightFirst: false, // highlights first-matched item in the list closeOnSelect: true, // closes the dropdown after selecting an item, if `enabled:0` (which means always show dropdown) - position: 'all' // 'manual' / 'text' / 'all' + clearOnSelect: true, + // after selecting a suggetion, should the typed text input remain or be cleared + position: 'all', + // 'manual' / 'text' / 'all' + appendTarget: null // defaults to document.body one DOM has been loaded - } - }, - // Using ARIA & role attributes - // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html - templates: { - wrapper: function wrapper(input, settings) { - return ""); - }, - tag: function tag(value, tagData) { - return "\n \n
\n ").concat(value, "\n
\n
"); }, - dropdownItem: function dropdownItem(item) { - var mapValueTo = this.settings.dropdown.mapValueTo, - value = (mapValueTo ? typeof mapValueTo == 'function' ? mapValueTo(item) : item[mapValueTo] : item.value) || item.value, - sanitizedValue = (value || item).replace(/`|'/g, "'"); - return "
").concat(sanitizedValue, "
"); + hooks: { + beforeRemoveTag: () => Promise.resolve(), + beforePaste: () => Promise.resolve(), + suggestionClick: () => Promise.resolve() } - }, - customEventsList: ['add', 'remove', 'invalid', 'input', 'click', 'keydown', 'focus', 'blur', 'edit:input', 'edit:updated', 'edit:start', 'edit:keydown', 'dropdown:show', 'dropdown:hide', 'dropdown:select'], - applySettings: function applySettings(input, settings) { - var _this2 = this; - - this.DEFAULTS.templates = this.templates; - this.settings = this.extend({}, this.DEFAULTS, settings); - this.settings.readonly = input.hasAttribute('readonly'); // if "readonly" do not include an "input" element inside the Tags component - - this.settings.placeholder = input.getAttribute('placeholder') || this.settings.placeholder || ""; - if (this.isIE) this.settings.autoComplete = false; // IE goes crazy if this isn't false - - ["whitelist", "blacklist"].forEach(function (name) { - var attrVal = input.getAttribute('data-' + name); - - if (attrVal) { - attrVal = attrVal.split(_this2.settings.delimiters); - if (attrVal instanceof Array) _this2.settings[name] = attrVal; - } - }); // backward-compatibility for old version of "autoComplete" setting: + }; - if ("autoComplete" in settings && !this.isObject(settings.autoComplete)) { - this.settings.autoComplete = this.DEFAULTS.autoComplete; - this.settings.autoComplete.enabled = settings.autoComplete; - } + function initDropdown() { + this.dropdown = {}; - if (input.pattern) try { - this.settings.pattern = new RegExp(input.pattern); - } catch (e) {} // Convert the "delimiters" setting into a REGEX object + for (let p in this._dropdown) this.dropdown[p] = typeof this._dropdown[p] === 'function' ? this._dropdown[p].bind(this) : this._dropdown[p]; - if (this.settings.delimiters) { - try { - this.settings.delimiters = new RegExp(this.settings.delimiters, "g"); - } catch (e) {} - } // make sure the dropdown will be shown on "focus" and not only after typing something (in "select" mode) + this.dropdown.refs(); + } + var _dropdown = { + refs() { + this.DOM.dropdown = this.parseTemplate('dropdown', [this.settings]); + this.DOM.dropdown.content = this.DOM.dropdown.querySelector(this.settings.classNames.dropdownWrapperSelector); + }, + /** + * shows the suggestions select box + * @param {String} value [optional, filter the whitelist by this value] + */ + show(value) { + var _s = this.settings, + firstListItem, + firstListItemValue, + allowNewTags = _s.mode == 'mix' && !_s.enforceWhitelist, + noWhitelist = !_s.whitelist || !_s.whitelist.length, + noMatchListItem, + isManual = _s.dropdown.position == 'manual'; // if text still exists in the input, and `show` method has no argument, then the input's text should be used + + value = value === undefined ? this.state.inputText : value; // ⚠️ Do not render suggestions list if: + // 1. there's no whitelist (can happen while async loading) AND new tags arn't allowed + // 2. dropdown is disabled + // 3. loader is showing (controlled outside of this code) + + if (noWhitelist && !allowNewTags && !_s.templates.dropdownItemNoMatch || _s.dropdown.enable === false || this.state.isLoading) return; + clearTimeout(this.dropdownHide__bindEventsTimeout); // if no value was supplied, show all the "whitelist" items in the dropdown + // @type [Array] listItems + // TODO: add a Setting to control items' sort order for "listItems" - if (this.settings.mode == 'select') this.settings.dropdown.enabled = 0; - if (this.settings.mode == 'mix') this.settings.autoComplete.rightKey = true; - }, + this.suggestedListItems = this.dropdown.filterListItems(value); // trigger at this exact point to let the developer the chance to manually set "this.suggestedListItems" - /** - * Creates a string of HTML element attributes - * @param {Object} data [Tag data] - */ - getAttributes: function getAttributes(data) { - // only items which are objects have properties which can be used as attributes - if (Object.prototype.toString.call(data) != "[object Object]") return ''; - var keys = Object.keys(data), - s = "", - propName, - i; - - for (i = keys.length; i--;) { - propName = keys[i]; - if (propName != 'class' && data.hasOwnProperty(propName) && data[propName]) s += " " + propName + (data[propName] ? "=\"".concat(data[propName], "\"") : ""); - } + 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. + // - return s; - }, - /** - * utility method - * https://stackoverflow.com/a/35385518/104380 - * @param {String} s [HTML string] - * @return {Object} [DOM node] - */ - parseHTML: function parseHTML(s) { - var parser = new DOMParser(), - node = parser.parseFromString(s.trim(), "text/html"); - return node.body.firstElementChild; - }, + 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; + } + } - /** - * utility method - * https://stackoverflow.com/a/25396011/104380 - */ - escapeHTML: function escapeHTML(s) { - var text = document.createTextNode(s), - p = document.createElement('p'); - p.appendChild(text); - return p.innerHTML; - }, + firstListItem = this.suggestedListItems[0]; + firstListItemValue = "" + (isObject(firstListItem) ? firstListItem.value : firstListItem); - /** - * Get the caret position relative to the viewport - * https://stackoverflow.com/q/58985076/104380 - * - * @returns {object} left, top distance in pixels - */ - getCaretGlobalPosition: function getCaretGlobalPosition() { - var sel = document.getSelection(); - - if (sel.rangeCount) { - var r = sel.getRangeAt(0); - var node = r.startContainer; - var offset = r.startOffset; - var 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 (_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); + } } - } - - return { - left: -9999, - top: -9999 - }; - }, - - /** - * Get specific CSS variables which are relevant to this script and parse them as needed. - * The result is saved on the instance in "this.CSSVars" - */ - getCSSVars: function getCSSVars() { - var compStyle = getComputedStyle(this.DOM.scope, null); - - var getProp = function getProp(name) { - return compStyle.getPropertyValue('--' + name); - }; - function seprateUnitFromValue(a) { - if (!a) return {}; - a = a.trim().split(' ')[0]; - var unit = a.split(/\d+/g).filter(function (n) { - return n; - }).pop().trim(), - value = +a.split(unit).filter(function (n) { - return n; - })[0].trim(); - return { - value: value, - unit: unit - }; - } + this.dropdown.fill(noMatchListItem); + if (_s.dropdown.highlightFirst) this.dropdown.highlightOption(this.DOM.dropdown.content.children[0]); // bind events, exactly at this stage of the code. "dropdown.show" method is allowed to be + // called multiple times, regardless if the dropdown is currently visible, but the events-binding + // should only be called if the dropdown wasn't previously visible. - this.CSSVars = { - tagHideTransition: function (_ref) { - var value = _ref.value, - unit = _ref.unit; - return unit == 's' ? value * 1000 : value; - }(seprateUnitFromValue(getProp('tag-hide-transition'))) - }; - }, + 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 - /** - * builds the HTML of this component - * @param {Object} input [DOM element which would be "transformed" into "Tags"] - */ - build: function build(input) { - var DOM = this.DOM, - template = this.settings.templates.wrapper(input, this.settings); - DOM.originalInput = input; - DOM.scope = this.parseHTML(template); - DOM.input = DOM.scope.querySelector('[contenteditable]'); - input.parentNode.insertBefore(DOM.scope, input); - - if (this.settings.dropdown.enabled >= 0) { - this.dropdown.init.call(this); - } - }, + 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 - /** - * revert any changes made by this component - */ - destroy: function destroy() { - this.DOM.scope.parentNode.removeChild(this.DOM.scope); - this.dropdown.hide.call(this, true); - }, - /** - * if the original input had any values, add them as tags - */ - loadOriginalValues: function loadOriginalValues(value) { - value = value || this.DOM.originalInput.value; // if the original input already had any value (tags) + setTimeout(() => { + this.trigger("dropdown:show", this.DOM.dropdown); + }); + }, - if (!value) return; - this.removeAllTags(); - if (this.settings.mode == 'mix') this.parseMixTags(value.trim());else { - try { - if (typeof JSON.parse(value) !== 'string') value = JSON.parse(value); - } catch (err) {} + /** + * 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 - this.addTags(value).forEach(function (tag) { - return tag && tag.classList.add('tagify--noAnim'); - }); - } - }, + 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 - /** - * Checks if an argument is a javascript Object - */ - isObject: 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'; - }, + 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 - /** - * merge objects into a single new one - * TEST: extend({}, {a:{foo:1}, b:[]}, {a:{bar:2}, b:[1], c:()=>{}}) - */ - extend: function extend(o, o1, o2) { - var that = this; - if (!(o instanceof Object)) o = {}; - copy(o, o1); - if (o2) copy(o, o2); + 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" - function copy(a, b) { - // copy o2 to o - for (var key in b) { - if (b.hasOwnProperty(key)) { - if (that.isObject(b[key])) { - if (!that.isObject(a[key])) a[key] = Object.assign({}, b[key]);else copy(a[key], b[key]); - } else a[key] = b[key]; - } + if (this.state.tag && this.state.tag.value.length) { + this.state.flaggedTags[this.state.tag.baseOffset] = this.state.tag; } - } - return o; - }, - cloneEvent: function cloneEvent(e) { - var clonedEvent = {}; + this.trigger("dropdown:hide", dropdown); + return this; + }, - for (var v in e) { - clonedEvent[v] = e[v]; - } + /** + * Toggles dropdown show/hide + * @param {Boolean} show forces the dropdown to show + */ + toggle(show) { + this.dropdown[this.state.dropdown.visible && !show ? 'hide' : 'show'](); + }, - return clonedEvent; - }, + 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 - /** - * A constructor for exposing events to the outside - */ - EventDispatcher: function EventDispatcher(instance) { - // Create a DOM EventTarget object - var target = document.createTextNode(''); + if (!document.body.contains(this.DOM.dropdown)) { + this.DOM.dropdown.classList.add(_s.classNames.dropdownInital); + this.dropdown.position(ddHeight); - function addRemove(op, events, cb) { - if (cb) events.split(/\s+/g).forEach(function (name) { - return target[op + 'EventListener'].call(target, name, cb); - }); - } // Pass EventTarget interface calls to DOM EventTarget object + _s.dropdown.appendTarget.appendChild(this.DOM.dropdown); + setTimeout(() => this.DOM.dropdown.classList.remove(_s.classNames.dropdownInital)); + } - this.off = function (events, cb) { - addRemove('remove', events, cb); return this; - }; + }, - this.on = function (events, cb) { - if (cb && typeof cb == 'function') addRemove('add', events, cb); - return this; - }; + /** + * + * @param {String/Array} HTMLContent - optional + */ + fill(HTMLContent) { + HTMLContent = typeof HTMLContent == 'string' ? HTMLContent : this.dropdown.createListHTML(HTMLContent || this.suggestedListItems); + this.DOM.dropdown.content.innerHTML = minify(HTMLContent); + }, - this.trigger = function (eventName, data) { - var e; - if (!eventName) return; + /** + * fill data into the suggestions list + * (mainly used to update the list when removing tags, so they will be re-added to the list. not efficient) + */ + refilter(value) { + value = value || this.state.dropdown.query || ''; + this.suggestedListItems = this.dropdown.filterListItems(value); + this.dropdown.fill(); + if (!this.suggestedListItems.length) this.dropdown.hide(); + this.trigger("dropdown:updated", this.DOM.dropdown); + }, - if (instance.settings.isJQueryPlugin) { - if (eventName == 'remove') eventName = 'removeTag'; // issue #222 + position(ddHeight) { + var _sd = this.settings.dropdown; + if (_sd.position == 'manual') return; + var rect, + top, + bottom, + left, + width, + parentsPositions, + ddElm = this.DOM.dropdown, + placeAbove = _sd.placeAbove, + viewportHeight = document.documentElement.clientHeight, + viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0), + positionTo = viewportWidth > 480 ? _sd.position : 'all', + ddTarget = this.DOM[positionTo == 'input' ? 'input' : 'scope']; + ddHeight = ddHeight || ddElm.clientHeight; + + function getParentsPositions(p) { + var left = 0, + top = 0; + + while (p) { + left += p.offsetLeft || 0; + top += p.offsetTop || 0; + p = p.parentNode; + } + + return { + left, + top + }; + } + + if (!this.state.dropdown.visible) return; - jQuery(instance.DOM.originalInput).triggerHandler(eventName, [data]); + if (positionTo == 'text') { + rect = this.getCaretGlobalPosition(); + bottom = rect.bottom; + top = rect.top; + left = rect.left; + width = 'auto'; } else { - try { - e = new CustomEvent(eventName, { - "detail": this.extend({}, data, { - tagify: this - }) + parentsPositions = getParentsPositions(this.settings.dropdown.appendTarget); + rect = ddTarget.getBoundingClientRect(); + top = rect.top - parentsPositions.top; + bottom = rect.bottom - 1 - parentsPositions.top; + left = rect.left - parentsPositions.left; + width = rect.width + 'px'; + } + + top = Math.floor(top); + bottom = Math.ceil(bottom); + placeAbove = placeAbove === undefined ? viewportHeight - rect.bottom < ddHeight : placeAbove; // flip vertically if there is no space for the dropdown below the input + + ddElm.style.cssText = "left:" + (left + window.pageXOffset) + "px; width:" + width + ";" + (placeAbove ? "top: " + (top + window.pageYOffset) + "px" : "top: " + (bottom + window.pageYOffset) + "px"); + ddElm.setAttribute('placement', placeAbove ? "top" : "bottom"); + ddElm.setAttribute('position', positionTo); + }, + + events: { + /** + * Events should only be binded when the dropdown is rendered and removed when isn't + * because there might be multiple Tagify instances on a certain page + * @param {Boolean} bindUnbind [optional. true when wanting to unbind all the events] + */ + binding(bindUnbind = true) { + // references to the ".bind()" methods must be saved so they could be unbinded later + var _CB = this.dropdown.events.callbacks, + // callback-refs + _CBR = this.listeners.dropdown = this.listeners.dropdown || { + position: this.dropdown.position.bind(this), + onKeyDown: _CB.onKeyDown.bind(this), + onMouseOver: _CB.onMouseOver.bind(this), + onMouseLeave: _CB.onMouseLeave.bind(this), + onClick: _CB.onClick.bind(this), + onScroll: _CB.onScroll.bind(this) + }, + action = bindUnbind ? 'addEventListener' : 'removeEventListener'; + + if (this.settings.dropdown.position != 'manual') { + window[action]('resize', _CBR.position); + window[action]('keydown', _CBR.onKeyDown); + } + + this.DOM.dropdown[action]('mouseover', _CBR.onMouseOver); + this.DOM.dropdown[action]('mouseleave', _CBR.onMouseLeave); + this.DOM.dropdown[action]('mousedown', _CBR.onClick); + this.DOM.dropdown.content[action]('scroll', _CBR.onScroll); + }, + + callbacks: { + onKeyDown(e) { + // get the "active" element, and if there was none (yet) active, use first child + var selectedElm = this.DOM.dropdown.querySelector(this.settings.classNames.dropdownItemActiveSelector), + selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm); + + switch (e.key) { + case 'ArrowDown': + case 'ArrowUp': + case 'Down': // >IE11 + + case 'Up': + { + // >IE11 + e.preventDefault(); + var dropdownItems; + if (selectedElm) selectedElm = selectedElm[(e.key == 'ArrowUp' || e.key == 'Up' ? "previous" : "next") + "ElementSibling"]; // if no element was found, loop + + if (!selectedElm) { + dropdownItems = this.DOM.dropdown.content.children; + selectedElm = dropdownItems[e.key == 'ArrowUp' || e.key == 'Up' ? dropdownItems.length - 1 : 0]; + } + + selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm); + this.dropdown.highlightOption(selectedElm, true); + break; + } + + case 'Escape': + case 'Esc': + // IE11 + this.dropdown.hide(); + break; + + case 'ArrowRight': + if (this.state.actions.ArrowLeft) return; + + case 'Tab': + { + // in mix-mode, treat arrowRight like Enter key, so a tag will be created + if (this.settings.mode != 'mix' && selectedElm && !this.settings.autoComplete.rightKey && !this.state.editing) { + e.preventDefault(); // prevents blur so the autocomplete suggestion will not become a tag + + var value = this.dropdown.getMappedValue(selectedElmData); + this.input.autocomplete.set.call(this, value); + return false; + } + + return true; + } + + case 'Enter': + { + e.preventDefault(); + this.settings.hooks.suggestionClick(e, { + tagify: this, + tagData: selectedElmData, + suggestionElm: selectedElm + }).then(() => { + if (selectedElm) return this.dropdown.selectOption(selectedElm);else this.dropdown.hide(); + if (this.settings.mode != 'mix') this.addTags(this.state.inputText.trim(), true); + }).catch(err => err); + break; + } + + case 'Backspace': + { + if (this.settings.mode == 'mix' || this.state.editing.scope) return; + const value = this.input.raw.call(this); + + if (value == "" || value.charCodeAt(0) == 8203) { + if (this.settings.backspace === true) this.removeTags();else if (this.settings.backspace == 'edit') setTimeout(this.editTag.bind(this), 0); + } + } + } + }, + + onMouseOver(e) { + var ddItem = e.target.closest(this.settings.classNames.dropdownItemSelector); // event delegation check + + ddItem && this.dropdown.highlightOption(ddItem); + }, + + onMouseLeave(e) { + // de-highlight any previously highlighted option + this.dropdown.highlightOption(); + }, + + onClick(e) { + if (e.button != 0 || e.target == this.DOM.dropdown || e.target == this.DOM.dropdown.content) return; // allow only mouse left-clicks + + var selectedElm = e.target.closest(this.settings.classNames.dropdownItemSelector), + selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm); // temporary set the "actions" state to indicate to the main "blur" event it shouldn't run + + this.state.actions.selectOption = true; + setTimeout(() => this.state.actions.selectOption = false, 50); + this.settings.hooks.suggestionClick(e, { + tagify: this, + tagData: selectedElmData, + suggestionElm: selectedElm + }).then(() => { + if (selectedElm) this.dropdown.selectOption(selectedElm);else this.dropdown.hide(); + }).catch(err => console.warn(err)); + }, + + onScroll(e) { + var elm = e.target, + pos = elm.scrollTop / (elm.scrollHeight - elm.parentNode.clientHeight) * 100; + this.trigger("dropdown:scroll", { + percentage: Math.round(pos) + }); + } + + } + }, + + getSuggestionDataByNode(tagElm) { + var idx = tagElm ? +tagElm.getAttribute('tagifySuggestionIdx') : -1; + return this.suggestedListItems[idx] || null; + }, + + /** + * mark the currently active suggestion option + * @param {Object} elm option DOM node + * @param {Boolean} adjustScroll when navigation with keyboard arrows (up/down), aut-scroll to always show the highlighted element + */ + highlightOption(elm, adjustScroll) { + var className = this.settings.classNames.dropdownItemActive, + itemData; // focus casues a bug in Firefox with the placeholder been shown on the input element + // if( this.settings.dropdown.position != 'manual' ) + // elm.focus(); + + if (this.state.ddItemElm) { + this.state.ddItemElm.classList.remove(className); + this.state.ddItemElm.removeAttribute("aria-selected"); + } + + if (!elm) { + this.state.ddItemData = null; + this.state.ddItemElm = null; + this.input.autocomplete.suggest.call(this); + return; + } + + itemData = this.suggestedListItems[this.getNodeIndex(elm)]; + this.state.ddItemData = itemData; + this.state.ddItemElm = elm; // this.DOM.dropdown.querySelectorAll("." + this.settings.classNames.dropdownItemActive).forEach(activeElm => activeElm.classList.remove(className)); + + elm.classList.add(className); + elm.setAttribute("aria-selected", true); + if (adjustScroll) elm.parentNode.scrollTop = elm.clientHeight + elm.offsetTop - elm.parentNode.clientHeight; // Try to autocomplete the typed value with the currently highlighted dropdown item + + if (this.settings.autoComplete) { + this.input.autocomplete.suggest.call(this, itemData); + this.dropdown.position(); // suggestions might alter the height of the tagify wrapper because of unkown suggested term length that could drop to the next line + } + }, + + /** + * Create a tag from the currently active suggestion option + * @param {Object} elm DOM node to select + */ + selectOption(elm) { + var _this$settings$dropdo = this.settings.dropdown, + clearOnSelect = _this$settings$dropdo.clearOnSelect, + closeOnSelect = _this$settings$dropdo.closeOnSelect; + + if (!elm) { + this.addTags(this.state.inputText, true); + closeOnSelect && this.dropdown.hide(); + return; + } // if in edit-mode, do not continue but instead replace the tag's text. + // the scenario is that "addTags" was called from a dropdown suggested option selected while editing + + + var tagifySuggestionIdx = elm.getAttribute('tagifySuggestionIdx'), + tagData = this.suggestedListItems[+tagifySuggestionIdx]; + this.trigger("dropdown:select", { + data: tagData, + elm + }); // The above event must be triggered, regardless of anything else which might go wrong + + if (!tagifySuggestionIdx || !tagData) { + this.dropdown.hide(); + return; + } + + if (this.state.editing) { + // normalizing value, because "tagData" might be a string, and therefore will not be able to extend the object + this.onEditTagDone(null, extend({ + __isValid: true + }, this.normalizeTags([tagData])[0])); + } // Tagify instances should re-focus to the input element once an option was selected, to allow continuous typing + else { + this[this.settings.mode == 'mix' ? "addMixTags" : "addTags"]([tagData], clearOnSelect); + } // todo: consider not doing this on mix-mode + + + if (!this.DOM.input.parentNode) return; + setTimeout(() => { + this.DOM.input.focus(); + this.toggleFocusClass(true); + }); + + if (closeOnSelect) { + setTimeout(this.dropdown.hide.bind(this)); + } else this.dropdown.refilter(); + }, + + selectAll() { + // having suggestedListItems with items messes with "normalizeTags" when wanting + // to add all tags + this.suggestedListItems.length = 0; + this.dropdown.hide(); // some whitelist items might have already been added as tags so when addings all of them, + // skip adding already-added ones, so best to use "filterListItems" method over "settings.whitelist" + + this.addTags(this.dropdown.filterListItems(''), true); + return this; + }, + + /** + * returns an HTML string of the suggestions' list items + * @param {String} value string to filter the whitelist by + * @param {Object} options "exact" - for exact complete match + * @return {Array} list of filtered whitelist items according to the settings provided and current value + */ + filterListItems(value, options) { + var _s = this.settings, + _sd = _s.dropdown, + options = options || {}, + value = _s.mode == 'select' && this.value.length && this.value[0][_s.tagTextProp] == value ? '' // do not filter if the tag, which is already selecetd in "select" mode, is the same as the typed text + : value, + list = [], + exactMatchesList = [], + whitelist = _s.whitelist, + suggestionsCount = _sd.maxItems || Infinity, + searchKeys = _sd.searchKeys, + whitelistItem, + valueIsInWhitelist, + searchBy, + isDuplicate, + niddle, + i = 0; + + if (!value || !searchKeys.length) { + return (_s.duplicates ? whitelist : whitelist.filter(item => !this.isTagDuplicate(isObject(item) ? item.value : item)) // don't include tags which have already been added. + ).slice(0, suggestionsCount); // respect "maxItems" dropdown setting + } + + niddle = _sd.caseSensitive ? "" + value : ("" + value).toLowerCase(); // checks if ALL of the words in the search query exists in the current whitelist item, regardless of their order + + function stringHasAll(s, query) { + return query.toLowerCase().split(' ').every(q => s.includes(q.toLowerCase())); + } + + for (; i < whitelist.length; i++) { + let startsWithMatch, exactMatch; + whitelistItem = whitelist[i] instanceof Object ? whitelist[i] : { + value: whitelist[i] + }; //normalize value as an Object + + let itemWithoutSearchKeys = !Object.keys(whitelistItem).some(k => searchKeys.includes(k)), + _searchKeys = itemWithoutSearchKeys ? ["value"] : searchKeys; + + if (_sd.fuzzySearch && !options.exact) { + searchBy = _searchKeys.reduce((values, k) => values + " " + (whitelistItem[k] || ""), "").toLowerCase().trim(); + + if (_sd.accentedSearch) { + searchBy = unaccent(searchBy); + niddle = unaccent(niddle); + } + + startsWithMatch = searchBy.indexOf(niddle) == 0; + exactMatch = searchBy === niddle; + valueIsInWhitelist = stringHasAll(searchBy, niddle); + } else { + startsWithMatch = true; + valueIsInWhitelist = _searchKeys.some(k => { + var v = '' + (whitelistItem[k] || ''); // if key exists, cast to type String + + if (_sd.accentedSearch) { + v = unaccent(v); + niddle = unaccent(niddle); + } + + if (!_sd.caseSensitive) v = v.toLowerCase(); + exactMatch = v === niddle; + return options.exact ? v === niddle : v.indexOf(niddle) == 0; }); - } catch (err) { - console.warn(err); } - target.dispatchEvent(e); + isDuplicate = !_s.duplicates && this.isTagDuplicate(isObject(whitelistItem) ? whitelistItem.value : whitelistItem); // match for the value within each "whitelist" item + + if (valueIsInWhitelist && !isDuplicate) if (exactMatch && startsWithMatch) exactMatchesList.push(whitelistItem);else if (_sd.sortby == 'startsWith' && startsWithMatch) list.unshift(whitelistItem);else list.push(whitelistItem); + } // custom sorting function + + + return typeof _sd.sortby == 'function' ? _sd.sortby(exactMatchesList.concat(list), niddle) : exactMatchesList.concat(list).slice(0, suggestionsCount); + }, + + /** + * Returns the final value of a tag data (object) with regards to the "mapValueTo" dropdown setting + * @param {Object} tagData + * @returns + */ + getMappedValue(tagData) { + var mapValueTo = this.settings.dropdown.mapValueTo, + value = mapValueTo ? typeof mapValueTo == 'function' ? mapValueTo(tagData) : tagData[mapValueTo] || tagData.value : tagData.value; + return value; + }, + + /** + * Creates the dropdown items' HTML + * @param {Array} list [Array of Objects] + * @return {String} + */ + createListHTML(optionsArr) { + return extend([], optionsArr).map((suggestion, idx) => { + if (typeof suggestion == 'string' || typeof suggestion == 'number') suggestion = { + value: suggestion + }; + var value = this.dropdown.getMappedValue(suggestion); + suggestion.value = value && typeof value == 'string' ? escapeHTML(value) : value; + var tagHTMLString = this.settings.templates.dropdownItem.apply(this, [suggestion, this]); // make sure the sugestion index is present as attribute, to match the data when one is selected + + tagHTMLString = tagHTMLString.replace(/\s*tagifySuggestionIdx=(["'])(.*?)\1/gmi, '') // remove the "tagifySuggestionIdx" attribute if for some reason was there + .replace('>', ` tagifySuggestionIdx="${idx}">`); // add "tagifySuggestionIdx" + + return tagHTMLString; + }).join(""); + } + + }; + + const VERSION = 1; // current version of persisted data. if code change breaks persisted data, verison number should be bumped. + + const STORE_KEY = '@yaireo/tagify/'; + const getPersistedData = id => key => { + // if "persist" is "false", do not save to localstorage + let customKey = '/' + key, + persistedData, + versionMatch = localStorage.getItem(STORE_KEY + id + '/v', VERSION) == VERSION; + + if (versionMatch) { + try { + persistedData = JSON.parse(localStorage[STORE_KEY + id + customKey]); + } catch (err) {} + } + + return persistedData; + }; + const setPersistedData = id => { + if (!id) return () => {}; // for storage invalidation + + localStorage.setItem(STORE_KEY + id + '/v', VERSION); + return (data, key) => { + let customKey = '/' + key, + persistedData = JSON.stringify(data); + + if (data && key) { + localStorage.setItem(STORE_KEY + id + customKey, persistedData); + dispatchEvent(new Event('storage')); } }; - }, + }; + const clearPersistedData = id => key => { + const base = STORE_KEY + '/' + id + '/'; // delete specific key in the storage - /** - * Toogle loading state on/off - * @param {Boolean} isLoading - */ - loading: function loading(isLoading) { - // IE11 doesn't support toggle with second parameter - this.DOM.scope.classList[isLoading ? "add" : "remove"]('tagify--loading'); - return this; - }, - toggleFocusClass: function toggleFocusClass(force) { - this.DOM.scope.classList.toggle('tagify--focus', !!force); - }, + 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); + } + }; - /** - * DOM events listeners binding - */ - events: { - // bind custom events which were passed in the settings - customBinding: function customBinding() { - var _this3 = this; + 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 ` + + ​ + `; + }, + + tag(tagData, tagify) { + var _s = this.settings; + return ` + +
+ ${tagData[_s.tagTextProp] || tagData.value} +
+
`; + }, + + dropdown(settings) { + var _sd = settings.dropdown, + isManual = _sd.position == 'manual', + className = `${settings.classNames.dropdown}`; + return `
+
+
`; + }, + + dropdownItem(item, tagify) { + return `
${item.value}
`; + }, + + dropdownItemNoMatch: null + }; + + function EventDispatcher(instance) { + // Create a DOM EventTarget object + var target = document.createTextNode(''); + + function addRemove(op, events, cb) { + if (cb) events.split(/\s+/g).forEach(name => target[op + 'EventListener'].call(target, name, cb)); + } // Pass EventTarget interface calls to DOM EventTarget object + + + return { + off(events, cb) { + addRemove('remove', events, cb); + return this; + }, + + on(events, cb) { + if (cb && typeof cb == 'function') addRemove('add', events, cb); + return this; + }, + + trigger(eventName, data, opts) { + var e; + opts = opts || { + cloneData: true + }; + if (!eventName) return; + + if (instance.settings.isJQueryPlugin) { + if (eventName == 'remove') eventName = 'removeTag'; // issue #222 + + jQuery(instance.DOM.originalInput).triggerHandler(eventName, [data]); + } else { + try { + var eventData = typeof data === 'object' ? data : { + value: data + }; + eventData = opts.cloneData ? extend({}, eventData) : eventData; + eventData.tagify = this; // TODO: move the below to the "extend" function + + if (data instanceof Object) for (var prop in data) if (data[prop] instanceof HTMLElement) eventData[prop] = data[prop]; + e = new CustomEvent(eventName, { + "detail": eventData + }); + } catch (err) { + console.warn(err); + } + + target.dispatchEvent(e); + } + } + + }; + } + + var deleteBackspaceTimeout; + function triggerChangeEvent() { + if (this.settings.mixMode.integrated) return; + var inputElm = this.DOM.originalInput, + changed = this.state.lastOriginalValueReported !== inputElm.value, + event = new CustomEvent("change", { + bubbles: true + }); // must use "CustomEvent" and not "Event" to support IE + + if (!changed) return; // must apply this BEFORE triggering the simulated event + + this.state.lastOriginalValueReported = inputElm.value; // React hack: https://github.com/facebook/react/issues/11488 + + event.simulated = true; + if (inputElm._valueTracker) inputElm._valueTracker.setValue(Math.random()); + inputElm.dispatchEvent(event); // also trigger a Tagify event - this.customEventsList.forEach(function (name) { - _this3.on(name, _this3.settings.callbacks[name]); + 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: function binding() { - var bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; + binding(bindUnbind = true) { var _CB = this.events.callbacks, _CBR, action = bindUnbind ? 'addEventListener' : 'removeEventListener'; // do not allow the main events to be bound more than once @@ -463,97 +1161,193 @@ Tagify.prototype = { 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; + this.state.mainEvents = bindUnbind; // everything inside gets executed only once-per instance if (bindUnbind && !this.listeners.main) { - // this event should never be unbinded: - // IE cannot register "input" events on contenteditable elements, so the "keydown" should be used instead.. - this.DOM.input.addEventListener(this.isIE ? "keydown" : "input", _CB[this.isIE ? "onInputIE" : "onInput"].bind(this)); + 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)], - blur: ['input', _CB.onFocusBlur.bind(this)], keydown: ['input', _CB.onKeydown.bind(this)], click: ['scope', _CB.onClickScope.bind(this)], - dblclick: ['scope', _CB.onDoubleClickScope.bind(this)] + dblclick: ['scope', _CB.onDoubleClickScope.bind(this)], + paste: ['input', _CB.onPaste.bind(this)], + drop: ['input', _CB.onDrop.bind(this)] }; for (var eventName in _CBR) { - // make sure the focus/blur event is always regesitered (and never more than once) - if (eventName == 'blur' && !bindUnbind) return; - this.DOM[_CBR[eventName][0]][action](eventName, _CBR[eventName][1]); - } + } // listen to original input changes (unfortunetly this is the best way...) + // https://stackoverflow.com/a/1949416/104380 + + + clearInterval(this.listeners.main.originalInputValueObserverInterval); + this.listeners.main.originalInputValueObserverInterval = setInterval(_CB.observeOriginalInputValue.bind(this), 500); // observers + + var inputMutationObserver = this.listeners.main.inputMutationObserver || new MutationObserver(_CB.onInputDOMChange.bind(this)); // cleaup just-in-case + + if (inputMutationObserver) inputMutationObserver.disconnect(); // observe stuff + + if (this.settings.mode == 'mix') inputMutationObserver.observe(this.DOM.input, { + childList: true + }); + }, + + bindGlobal(unbind) { + var _CB = this.events.callbacks, + action = unbind ? 'removeEventListener' : 'addEventListener', + e; + if (!unbind && this.listeners.global) return; // do not re-bind + // these events are global event should never be unbinded, unless the instance is destroyed: + + this.listeners.global = this.listeners && this.listeners.global || [{ + type: this.isIE ? 'keydown' : 'input', + // IE cannot register "input" events on contenteditable elements, so the "keydown" should be used instead.. + target: this.DOM.input, + cb: _CB[this.isIE ? 'onInputIE' : 'onInput'].bind(this) + }, { + type: 'keydown', + target: window, + cb: _CB.onWindowKeyDown.bind(this) + }, { + type: 'blur', + target: this.DOM.input, + cb: _CB.onFocusBlur.bind(this) + }]; + + for (e of this.listeners.global) e.target[action](e.type, e.cb); + }, + + unbindGlobal() { + this.events.bindGlobal.call(this, true); }, /** * DOM events callbacks */ callbacks: { - onFocusBlur: function onFocusBlur(e) { - var text = e.target ? e.target.textContent.trim() : '', + onFocusBlur(e) { + var text = e.target ? this.trim(e.target.textContent) : '', // a string _s = this.settings, - type = e.type; // goes into this scenario only on input "blur" and a tag was clicked - - if (e.relatedTarget && e.relatedTarget.classList.contains('tagify__tag') && this.DOM.scope.contains(e.relatedTarget)) return; + 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; + } - if (type == 'blur' && e.relatedTarget === this.DOM.scope) { - this.dropdown.hide.call(this); - this.DOM.input.focus(); - return; + this.postUpdate(); + this.triggerChangeEvent(); } - if (this.state.actions.selectOption && (_s.dropdown.enabled || !_s.dropdown.closeOnSelect)) return; + if (isTargetSelectOption || isTargetAddNewBtn) return; this.state.hasFocus = type == "focus" ? +new Date() : false; this.toggleFocusClass(this.state.hasFocus); - this.setRangeAtStartEnd(false); if (_s.mode == 'mix') { - if (e.type == "blur") this.dropdown.hide.call(this); + 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", { - relatedTarget: e.relatedTarget - }); // e.target.classList.remove('placeholder'); + if (type == "focus") { + this.trigger("focus", eventData); // e.target.classList.remove('placeholder'); + + if (_s.dropdown.enabled === 0 || !_s.userInput) { + // && _s.mode != "select" + this.dropdown.show(this.value.length ? '' : undefined); + } + + return; + } else if (type == "blur") { + this.trigger("blur", eventData); + this.loading(false); // when clicking the X button of a selected tag, it is unwanted it will be added back + // again in a few more lines of code (shouldAddTags && addTags) + + if (this.settings.mode == 'select' && isRelatedTargetX) text = ''; + shouldAddTags = this.settings.mode == 'select' && text ? !this.value.length || this.value[0].value != text : text && !this.state.actions.selectOption && _s.addTagOnBlur; // do not add a tag if "selectOption" action was just fired (this means a tag was just added from the dropdown) + + shouldAddTags && this.addTags(text, true); + if (this.settings.mode == 'select' && !text) this.removeTags(); + } + + this.DOM.input.removeAttribute('style'); + this.dropdown.hide(); + }, + + onWindowKeyDown(e) { + var focusedElm = document.activeElement, + isTag = isNodeTag.call(this, focusedElm), + isBelong = isTag && this.DOM.scope.contains(document.activeElement), + nextTag; + if (!isBelong) return; + nextTag = focusedElm.nextElementSibling; - if (_s.dropdown.enabled === 0 && _s.mode != "select") { - this.dropdown.show.call(this); - } + switch (e.key) { + // remove tag if has focus + case 'Backspace': + { + if (!this.settings.readonly) { + this.removeTags(focusedElm); + (nextTag ? nextTag : this.DOM.input).focus(); + } - return; - } else if (type == "blur") { - this.trigger("blur", { - relatedTarget: e.relatedTarget - }); - this.loading(false); // do not add a tag if "selectOption" action was just fired (this means a tag was just added from the dropdown) + break; + } + // edit tag if has focus - text && !this.state.actions.selectOption && _s.addTagOnBlur && this.addTags(text, true); + case 'Enter': + { + setTimeout(this.editTag.bind(this), 0, focusedElm); + break; + } } - - this.DOM.input.removeAttribute('style'); - this.dropdown.hide.call(this); }, - onKeydown: function onKeydown(e) { - var _this4 = this; - var s = e.target.textContent.trim(), - tags; + onKeydown(e) { + var _s = this.settings; + + if (_s.mode == 'select' && _s.enforceWhitelist && this.value.length && e.key != 'Tab') { + e.preventDefault(); + } + + var s = this.trim(e.target.textContent); this.trigger("keydown", { originalEvent: this.cloneEvent(e) }); + /** + * ONLY FOR MIX-MODE: + */ - if (this.settings.mode == 'mix') { + if (_s.mode == 'mix') { switch (e.key) { case 'Left': case 'ArrowLeft': { - // when left arrow was pressed, raise a flag so when the dropdown is shown, right-arrow will be ignored + // 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; @@ -562,28 +1356,125 @@ Tagify.prototype = { case 'Delete': case 'Backspace': { - var selection = document.getSelection(), - isFF = !!navigator.userAgent.match(/firefox/i); - if (isFF && selection && selection.anchorOffset == 0) this.removeTag(selection.anchorNode.previousSibling); - var values = []; // find out which tag(s) were deleted and update "this.value" accordingly + if (this.state.editing) return; + var sel = document.getSelection(), + deleteKeyTagDetected = e.key == 'Delete' && sel.anchorOffset == (sel.anchorNode.length || 0), + prevAnchorSibling = sel.anchorNode.previousSibling, + isCaretAfterTag = sel.anchorNode.nodeType == 1 || !sel.anchorOffset && prevAnchorSibling && prevAnchorSibling.nodeType == 1 && sel.anchorNode.previousSibling, + lastInputValue = decode(this.DOM.input.innerHTML), + lastTagElems = this.getTagElms(), + // isCaretInsideTag = sel.anchorNode.parentNode('.' + _s.classNames.tag), + tagBeforeCaret, + tagElmToBeDeleted, + firstTextNodeBeforeTag; + + if (_s.backspace == 'edit' && isCaretAfterTag) { + tagBeforeCaret = sel.anchorNode.nodeType == 1 ? null : sel.anchorNode.previousElementSibling; + setTimeout(this.editTag.bind(this), 0, tagBeforeCaret); // timeout is needed to the last cahacrter in the edited tag won't get deleted + + e.preventDefault(); // needed so the tag elm won't get deleted + + return; + } + + if (isChromeAndroidBrowser() && isCaretAfterTag) { + firstTextNodeBeforeTag = getfirstTextNode(isCaretAfterTag); + if (!isCaretAfterTag.hasAttribute('readonly')) isCaretAfterTag.remove(); // since this is Chrome, can safetly use this "new" DOM API + // Android-Chrome wrongly hides the keyboard, and loses focus, + // so this hack below is needed to regain focus at the correct place: + + this.DOM.input.focus(); + setTimeout(() => { + this.placeCaretAfterNode(firstTextNodeBeforeTag); + this.DOM.input.click(); + }); + return; + } + + if (sel.anchorNode.nodeName == 'BR') return; + if ((deleteKeyTagDetected || isCaretAfterTag) && sel.anchorNode.nodeType == 1) { + if (sel.anchorOffset == 0) // caret is at the very begining, before a tag + tagElmToBeDeleted = deleteKeyTagDetected // delete key pressed + ? lastTagElems[0] : null;else tagElmToBeDeleted = lastTagElems[sel.anchorOffset - 1]; // find out if a tag *might* be a candidate for deletion, and if so, which + + } else if (deleteKeyTagDetected) tagElmToBeDeleted = sel.anchorNode.nextElementSibling;else if (isCaretAfterTag) tagElmToBeDeleted = isCaretAfterTag; // tagElm.hasAttribute('readonly') + + if (sel.anchorNode.nodeType == 3 && // node at caret location is a Text node + !sel.anchorNode.nodeValue && // has some text + sel.anchorNode.previousElementSibling) // text node has a Tag node before it + e.preventDefault(); // if backspace not allowed, do nothing + // TODO: a better way to detect if nodes were deleted is to simply check the "this.value" before & after + + if ((isCaretAfterTag || deleteKeyTagDetected) && !_s.backspace) { + e.preventDefault(); + return; + } + + if (sel.type != 'Range' && !sel.anchorOffset && sel.anchorNode == this.DOM.input && e.key != 'Delete') { + e.preventDefault(); + return; + } + + if (sel.type != 'Range' && tagElmToBeDeleted && tagElmToBeDeleted.hasAttribute('readonly')) { + // allows the continuation of deletion by placing the caret on the first previous textNode. + // since a few readonly-tags might be one after the other, iteration is needed: + this.placeCaretAfterNode(getfirstTextNode(tagElmToBeDeleted)); + return; + } // update regarding https://github.com/yairEO/tagify/issues/762#issuecomment-786464317: + // the bug described is more severe than the fix below, therefore I disable the fix until a solution + // is found which work well for both cases. + // ------- + // nodeType is "1" only when the caret is at the end after last tag (no text after), or before first first (no text before) + + /* + if( this.isFirefox && sel.anchorNode.nodeType == 1 && sel.anchorOffset != 0 ){ + this.removeTags() // removes last tag by default if no parameter supplied + // place caret inside last textNode, if exist. it's an annoying bug only in FF, + // if the last tag is removed, and there is a textNode before it, the caret is not placed at its end + this.placeCaretAfterNode( this.setRangeAtStartEnd() ) + } + */ + - tags = this.DOM.input.children; // a minimum delay is needed before the node actually gets ditached from the document (don't know why), + 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 - setTimeout(function () { + deleteBackspaceTimeout = setTimeout(() => { + var sel = document.getSelection(), + currentValue = decode(this.DOM.input.innerHTML), + prevElm = !deleteKeyTagDetected && sel.anchorNode.previousSibling; // fixes #384, where the first and only tag will not get removed with backspace + + if (currentValue.length >= lastInputValue.length && prevElm) { + if (isNodeTag.call(this, prevElm) && !prevElm.hasAttribute('readonly')) { + this.removeTags(prevElm); + this.fixFirefoxLastTagNoCaret(); // the above "removeTag" methods removes the tag with a transition. Chrome adds a
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 - [].forEach.call(tags, function (tagElm) { - return values.push(tagElm.getAttribute('value')); - }); - _this4.value = _this4.value.filter(function (d) { - return values.indexOf(d.value) != -1; - }); - }); + + + this.value = [].map.call(lastTagElems, (node, nodeIdx) => { + var tagData = this.tagData(node); // since readonly cannot be removed (it's technically resurrected if removed somehow) + + if (node.parentNode || tagData.readonly) return tagData;else this.trigger('remove', { + tag: node, + index: nodeIdx, + data: tagData + }); + }).filter(n => n); // remove empty items in the mapped array + }, 20); // Firefox needs this higher duration for some reason or things get buggy when deleting text from the end + break; } // currently commented to allow new lines in mixed-mode // case 'Enter' : - // e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 + // // e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 } return true; @@ -591,11 +1482,12 @@ Tagify.prototype = { switch (e.key) { case 'Backspace': - if (s == "" || s.charCodeAt(0) == 8203) { - // 8203: ZERO WIDTH SPACE unicode - if (this.settings.backspace === true) this.removeTag();else if (this.settings.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) + 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': @@ -606,15 +1498,15 @@ Tagify.prototype = { case 'Down': case 'ArrowDown': - // if( this.settings.mode == 'select' ) // issue #333 - if (!this.state.dropdown.visible) this.dropdown.show.call(this); + // if( _s.mode == 'select' ) // issue #333 + if (!this.state.dropdown.visible) this.dropdown.show(); break; case 'ArrowRight': { - var tagData = this.state.inputSuggestion || this.state.ddItemData; + let tagData = this.state.inputSuggestion || this.state.ddItemData; - if (tagData && this.settings.autoComplete.rightKey) { + if (tagData && _s.autoComplete.rightKey) { this.addTags([tagData], true); return; } @@ -624,41 +1516,40 @@ Tagify.prototype = { case 'Tab': { - if (!s) return true; + let selectMode = _s.mode == 'select'; + if (s && !selectMode) e.preventDefault();else return true; } case 'Enter': + if (this.state.dropdown.visible || e.keyCode == 229) return; e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 // because the main "keydown" event is bound before the dropdown events, this will fire first and will not *yet* // know if an option was just selected from the dropdown menu. If an option was selected, // the dropdown events should handle adding the tag - setTimeout(function () { - if (_this4.state.actions.selectOption) return; - - _this4.addTags(s, true); + setTimeout(() => { + if (this.state.actions.selectOption) return; + this.addTags(s, true); }); } }, - onInput: function onInput(e) { - var value = this.settings.mode == 'mix' ? this.DOM.input.textContent : this.input.normalize.call(this), + + onInput(e) { + this.postUpdate(); // toggles "tagify--empty" class + + if (this.settings.mode == 'mix') return this.events.callbacks.onMixTagsInput.call(this, e); + var value = this.input.normalize.call(this), showSuggestions = value.length >= this.settings.dropdown.enabled, - data = { - value: value, + eventData = { + value, inputElm: this.DOM.input }; - if (this.settings.mode == 'mix') return this.events.callbacks.onMixTagsInput.call(this, e); - - if (!value) { - this.input.set.call(this, ''); - return; - } + eventData.isValid = this.validateTag({ + value + }); // for IE; since IE doesn't have an "input" event so "keyDown" is used instead to trigger the "onInput" callback, + // and so many keys do not change the input, and for those do not continue. - if (this.input.value == value) return; // for IE; since IE doesn't have an "input" event so "keyDown" is used instead - - data.isValid = this.validateTag(value); - this.trigger('input', data); // "input" event must be triggered at this point, before the dropdown is shown - // save the value on the input's State object + if (this.state.inputText == value) return; // save the value on the input's State object this.input.set.call(this, value, false); // update the input with the normalized value and run validations // this.setRangeAtStartEnd(); // fix caret position @@ -668,56 +1559,133 @@ Tagify.prototype = { this.input.set.call(this); // clear the input field's value } } else if (this.settings.dropdown.enabled >= 0) { - this.dropdown[showSuggestions ? "show" : "hide"].call(this, value); + this.dropdown[showSuggestions ? "show" : "hide"](value); } + + this.trigger('input', eventData); // "input" event must be triggered at this point, before the dropdown is shown }, - onMixTagsInput: function onMixTagsInput(e) { - var _this5 = this; - var sel, - range, - split, + onMixTagsInput(e) { + var rangeText, + match, + matchedPatternCount, tag, showSuggestions, - _s = this.settings; + selection, + _s = this.settings, + lastTagsCount = this.value.length, + matchFlaggedTag, + matchDelimiters, + tagsElems = this.getTagElms(), + fragment = document.createDocumentFragment(), + range = window.getSelection().getRangeAt(0), + remainingTagsValues = [].map.call(tagsElems, node => this.tagData(node).value); // Android Chrome "keydown" event argument does not report the correct "key". + // this workaround is needed to manually call "onKeydown" method with a synthesized event object + + if (e.inputType == "deleteContentBackward" && isChromeAndroidBrowser()) { + this.events.callbacks.onKeydown.call(this, { + target: e.target, + key: "Backspace" + }); + } // re-add "readonly" tags which might have been removed + + + this.value.slice().forEach(item => { + if (item.readonly && !remainingTagsValues.includes(item.value)) fragment.appendChild(this.createTagElem(item)); + }); + + if (fragment.childNodes.length) { + range.insertNode(fragment); + this.setRangeAtStartEnd(false, fragment.lastChild); + } // check if tags were "magically" added/removed (browser redo/undo or CTRL-A -> delete) + + + if (tagsElems.length != lastTagsCount) { + this.value = [].map.call(this.getTagElms(), node => this.tagData(node)); + this.update({ + withoutChangeEvent: true + }); + return; + } + if (this.hasMaxTags()) return true; if (window.getSelection) { - sel = window.getSelection(); + selection = window.getSelection(); // only detect tags if selection is inside a textNode (not somehow on already-existing tag) - if (sel.rangeCount > 0) { - range = sel.getRangeAt(0).cloneRange(); + if (selection.rangeCount > 0 && selection.anchorNode.nodeType == 3) { + range = selection.getRangeAt(0).cloneRange(); range.collapse(true); - range.setStart(window.getSelection().focusNode, 0); - split = range.toString().split(_s.mixTagsAllowedAfter); // ["foo", "bar", "@a"] + 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"] - tag = split[split.length - 1].match(_s.pattern); + 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[0], - value: tag.input.split(tag[0])[1] + prefix: tag.match(_s.pattern)[0], + value: tag.replace(_s.pattern, '') // get rid of the prefix + }; - showSuggestions = this.state.tag.value.length >= _s.dropdown.enabled; + this.state.tag.baseOffset = selection.baseOffset - this.state.tag.value.length; + matchDelimiters = this.state.tag.value.match(_s.delimiters); // if a delimeter exists, add the value as tag (exluding the delimiter) + + if (matchDelimiters) { + this.state.tag.value = this.state.tag.value.replace(_s.delimiters, ''); + this.state.tag.delimiters = matchDelimiters[0]; + this.addTags(this.state.tag.value, _s.dropdown.clearOnSelect); + this.dropdown.hide(); + return; + } + + showSuggestions = this.state.tag.value.length >= _s.dropdown.enabled; // When writeing something that might look like a tag (an email address) but isn't one - it is unwanted + // the suggestions dropdown be shown, so the user closes it (in any way), and while continue typing, + // dropdown should stay closed until another tag is typed. + // if( this.state.tag.value.length && this.state.dropdown.visible === false ) + // showSuggestions = false + // test for similar flagged tags to the current tag + + try { + matchFlaggedTag = this.state.flaggedTags[this.state.tag.baseOffset]; + matchFlaggedTag = matchFlaggedTag.prefix == this.state.tag.prefix && matchFlaggedTag.value[0] == this.state.tag.value[0]; // reset + + if (this.state.flaggedTags[this.state.tag.baseOffset] && !this.state.tag.value) delete this.state.flaggedTags[this.state.tag.baseOffset]; + } catch (err) {} // scenario: (do not show suggestions of previous matched tag, if more than 1 detected) + // (2 tags exist) " a@a.com and @" + // (second tag is removed by backspace) " a@a.com and " + + + if (matchFlaggedTag || matchedPatternCount < this.state.mixMode.matchedPatternCount) showSuggestions = false; + } // no (potential) tag found + else { + this.state.flaggedTags = {}; } - } - } - this.update(); // wait until the "this.value" has been updated (see "onKeydown" method for "mix-mode") + this.state.mixMode.matchedPatternCount = matchedPatternCount; + } + } // wait until the "this.value" has been updated (see "onKeydown" method for "mix-mode") // the dropdown must be shown only after this event has been driggered, so an implementer could // dynamically change the whitelist. - setTimeout(function () { - _this5.trigger("input", _this5.extend({}, _this5.state.tag, { - textContent: _this5.DOM.input.textContent - })); - if (_this5.state.tag) _this5.dropdown[showSuggestions ? "show" : "hide"].call(_this5, _this5.state.tag.value); + 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: function onInputIE(e) { + + onInputIE(e) { var _this = this; // for the "e.target.textContent" to be changed, the browser requires a small delay @@ -725,93 +1693,203 @@ Tagify.prototype = { _this.events.callbacks.onInput.call(_this, e); }); }, - onClickScope: function onClickScope(e) { - var tagElm = e.target.closest('.tagify__tag'), - _s = this.settings, - timeDiffFocus = +new Date() - this.state.hasFocus, - tagElmIdx; + + observeOriginalInputValue() { + // if original input value changed for some reason (for exmaple a form reset) + if (this.DOM.originalInput.value != this.DOM.originalInput.tagifyValue) this.loadOriginalValues(); + }, + + onClickScope(e) { + var _s = this.settings, + tagElm = e.target.closest('.' + _s.classNames.tag), + timeDiffFocus = +new Date() - this.state.hasFocus; if (e.target == this.DOM.scope) { - // if( !this.state.hasFocus ) - // this.dropdown.hide.call(this) - this.DOM.input.focus(); + if (!this.state.hasFocus) this.DOM.input.focus(); return; - } else if (e.target.classList.contains("tagify__tag__removeBtn")) { - this.removeTag(e.target.parentNode); + } else if (e.target.classList.contains(_s.classNames.tagX)) { + this.removeTags(e.target.parentNode); return; } else if (tagElm) { - tagElmIdx = this.getNodeIndex(tagElm); this.trigger("click", { tag: tagElm, - index: tagElmIdx, - data: this.value[tagElmIdx], + index: this.getNodeIndex(tagElm), + data: this.tagData(tagElm), originalEvent: this.cloneEvent(e) }); - if (this.settings.editTags == 1) this.events.callbacks.onDoubleClickScope.call(this, 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 && timeDiffFocus > 500) { - if (this.state.dropdown.visible) this.dropdown.hide.call(this);else if (_s.dropdown.enabled === 0 && _s.mode != 'mix') this.dropdown.show.call(this); + else if (e.target == this.DOM.input) { + if (_s.mode == 'mix') { + // firefox won't show caret if last element is a tag (and not a textNode), + // so an empty textnode should be added + this.fixFirefoxLastTagNoCaret(); + } + + if (timeDiffFocus > 500) { + if (this.state.dropdown.visible) this.dropdown.hide();else if (_s.dropdown.enabled === 0 && _s.mode != 'mix') this.dropdown.show(this.value.length ? '' : undefined); return; } + } + + if (_s.mode == 'select') !this.state.dropdown.visible && this.dropdown.show(); + }, + + // special proccess is needed for pasted content in order to "clean" it + onPaste(e) { + e.preventDefault(); + var _s = this.settings, + selectModeWithoutInput = _s.mode == 'select' && _s.enforceWhitelist; + + if (selectModeWithoutInput || !_s.userInput) { + return false; + } + + var clipboardData, pastedText; + if (_s.readonly) return; // Get pasted data via clipboard API + + clipboardData = e.clipboardData || window.clipboardData; + pastedText = clipboardData.getData('Text'); + + _s.hooks.beforePaste(e, { + tagify: this, + pastedText, + clipboardData + }).then(result => { + if (result === undefined) result = pastedText; + + if (result) { + this.injectAtCaret(result, window.getSelection().getRangeAt(0)); + + if (this.settings.mode == 'mix') { + this.events.callbacks.onMixTagsInput.call(this, e); + } else if (this.settings.pasteAsTags) { + this.addTags(this.state.inputText + result, true); + } else this.state.inputText = result; + } + }).catch(err => err); + }, - if (_s.mode == 'select') !this.state.dropdown.visible && this.dropdown.show.call(this); + onDrop(e) { + e.preventDefault(); }, - onEditTagInput: function onEditTagInput(editableElm, e) { - var tagElm = editableElm.closest('tag'), + + onEditTagInput(editableElm, e) { + var tagElm = editableElm.closest('.' + this.settings.classNames.tag), tagElmIdx = this.getNodeIndex(tagElm), + tagData = this.tagData(tagElm), value = this.input.normalize.call(this, editableElm), - isValid = value.toLowerCase() == editableElm.originalValue.toLowerCase() || this.validateTag(value); - tagElm.classList.toggle('tagify--invalid', isValid !== true); - tagElm.isValid = isValid; // show dropdown if typed text is equal or more than the "enabled" dropdown setting + hasChanged = tagElm.innerHTML != tagElm.__tagifyTagData.__originalHTML, + isValid = this.validateTag({ + [this.settings.tagTextProp]: value + }); // the value could have been invalid in the first-place so make sure to re-validate it (via "addEmptyTag" method) + // if the value is same as before-editing and the tag was valid before as well, ignore the current "isValid" result, which is false-positive + + if (!hasChanged && editableElm.originalIsValid === true) isValid = true; + tagElm.classList.toggle(this.settings.classNames.tagInvalid, isValid !== true); + tagData.__isValid = isValid; + tagElm.title = isValid === true ? tagData.title || tagData.value : isValid; // change the tag's title to indicate why is the tag invalid (if it's so) + // show dropdown if typed text is equal or more than the "enabled" dropdown setting if (value.length >= this.settings.dropdown.enabled) { - this.state.editing.value = value; - this.dropdown.show.call(this, value); + // this check is needed apparently because doing browser "undo" will fire + // "onEditTagInput" but "this.state.editing" will be "false" + if (this.state.editing) this.state.editing.value = value; + this.dropdown.show(value); } this.trigger("edit:input", { tag: tagElm, index: tagElmIdx, - data: this.extend({}, this.value[tagElmIdx], { + data: extend({}, this.value[tagElmIdx], { newValue: value }), originalEvent: this.cloneEvent(e) }); }, - onEditTagBlur: function onEditTagBlur(editableElm) { - if (!this.state.hasFocus) this.toggleFocusClass(); + + 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 tagElm = editableElm.closest('.tagify__tag'), - tagElmIdx = this.getNodeIndex(tagElm), - currentValue = this.input.normalize.call(this, editableElm), - value = currentValue || editableElm.originalValue, - hasChanged = value != editableElm.originalValue, - isValid = tagElm.isValid, - tagData = _objectSpread({}, this.value[tagElmIdx], { - value: value - }); // this.DOM.input.focus() + var _s = this.settings, + tagElm = editableElm.closest('.' + _s.classNames.tag), + textValue = this.input.normalize.call(this, editableElm), + originalData = this.tagData(tagElm).__originalData, + // pre-edit data + hasChanged = tagElm.innerHTML != tagElm.__tagifyTagData.__originalHTML, + isValid = this.validateTag({ + [_s.tagTextProp]: textValue + }), + hasMaxTags, + newTagData; // this.DOM.input.focus() - if (!currentValue) { - this.removeTag(tagElm); + if (!textValue) { + this.onEditTagDone(tagElm); return; - } + } // if nothing changed revert back to how it was before editing - if (hasChanged) { - this.settings.transformTag.call(this, tagData); // re-validate after tag transformation - isValid = this.validateTag(tagData.value); - } else { - this.onEditTagDone(tagElm); + if (!hasChanged) { + this.onEditTagDone(tagElm, originalData); return; - } + } // need to know this because if "keepInvalidTags" setting is "true" and an invalid tag is edited as a valid one, + // but the maximum number of tags have alreay been reached, so it should not allow saving the new valid value. + // only if the tag was already valid before editing, ignore this check (see a few lines below) + + + hasMaxTags = this.hasMaxTags(); + newTagData = this.getWhitelistItem(textValue) || extend({}, originalData, { + [_s.tagTextProp]: textValue, + value: textValue, + __isValid: isValid + }); + + _s.transformTag.call(this, newTagData, originalData); // MUST re-validate after tag transformation + // only validate the "tagTextProp" because is the only thing that metters for validating an edited tag. + // -- Scenarios: -- + // 1. max 3 tags allowd. there are 4 tags, one has invalid input and is edited to a valid one, and now should be marked as "not allowed" because limit of tags has reached + // 2. max 3 tags allowed. there are 3 tags, one is edited, and so max-tags vaildation should be OK + + + isValid = (!hasMaxTags || originalData.__isValid === true) && this.validateTag({ + [_s.tagTextProp]: newTagData[_s.tagTextProp] + }); - if (isValid !== undefined && isValid !== true) return; - this.onEditTagDone(tagElm, tagData); + if (isValid !== true) { + this.trigger("invalid", { + data: newTagData, + tag: tagElm, + message: isValid + }); // do nothing if invalid, stay in edit-mode until corrected or reverted by presssing esc + + if (_s.editTags.keepInvalid) return; + if (_s.keepInvalidTags) newTagData.__isValid = isValid;else // revert back if not specified to keep + newTagData = originalData; + } else if (_s.keepInvalidTags) { + // cleaup any previous leftovers if the tag was + delete newTagData.title; + delete newTagData["aria-invalid"]; + delete newTagData.class; + } // tagElm.classList.toggle(_s.classNames.tagInvalid, true) + + + this.onEditTagDone(tagElm, newTagData); }, - onEditTagkeydown: function onEditTagkeydown(e) { + + onEditTagkeydown(e, tagElm) { this.trigger("edit:keydown", { originalEvent: this.cloneEvent(e) }); @@ -819,7 +1897,7 @@ Tagify.prototype = { switch (e.key) { case 'Esc': case 'Escape': - e.target.textContent = e.target.originalValue; + tagElm.innerHTML = tagElm.__tagifyTagData.__originalHTML; case 'Enter': case 'Tab': @@ -827,1135 +1905,1569 @@ Tagify.prototype = { e.target.blur(); } }, - onDoubleClickScope: function onDoubleClickScope(e) { - var tagElm = e.target.closest('tag'), + + onDoubleClickScope(e) { + var tagElm = e.target.closest('.' + this.settings.classNames.tag), _s = this.settings, isEditingTag, isReadyOnlyTag; - if (!tagElm) return; - isEditingTag = tagElm.classList.contains('tagify__tag--editable'), isReadyOnlyTag = tagElm.hasAttribute('readonly'); + if (!tagElm || !_s.userInput) return; + isEditingTag = tagElm.classList.contains(this.settings.classNames.tagEditing); + isReadyOnlyTag = tagElm.hasAttribute('readonly'); if (_s.mode != 'select' && !_s.readonly && !isEditingTag && !isReadyOnlyTag && this.settings.editTags) this.editTag(tagElm); this.toggleFocusClass(true); + this.trigger('dblclick', { + tag: tagElm, + index: this.getNodeIndex(tagElm), + data: this.tagData(tagElm) + }); + }, + + /** + * + * @param {Object} m an object representing the observed DOM changes + */ + onInputDOMChange(m) { + // iterate all DOm mutation + m.forEach(record => { + // only the ADDED nodes + record.addedNodes.forEach(addedNode => { + if (addedNode) { + // fix chrome's placing '

' everytime ENTER key is pressed, and replace with just `
') { + addedNode.replaceWith(document.createElement('br')); + } // if the added element is a div containing a tag within it (chrome does this when pressing ENTER before a tag) + else if (addedNode.nodeType == 1 && addedNode.querySelector(this.settings.classNames.tagSelector)) { + let newlineText = document.createTextNode(''); + if (addedNode.childNodes[0].nodeType == 3 && addedNode.previousSibling.nodeName != 'BR') newlineText = document.createTextNode('\n'); // unwrap the useless div + // chrome adds a BR at the end which should be removed + + addedNode.replaceWith(...[newlineText, ...[...addedNode.childNodes].slice(0, -1)]); + this.placeCaretAfterNode(newlineText.previousSibling); + } // if this is a tag + else if (isNodeTag.call(this, addedNode)) { + // and it is the first node in a new line + if (addedNode.previousSibling && addedNode.previousSibling.nodeName == 'BR') { + // allows placing the caret just before the tag, when the tag is the first node in that line + addedNode.previousSibling.replaceWith('\n\u200B'); // when hitting ENTER for new line just before a tag + + this.placeCaretAfterNode(addedNode.previousSibling.previousSibling); + } + } + } + }); + record.removedNodes.forEach(removedNode => { + // when trying to delete a tag which is in a new line and there's nothing else there (caret is after the tag) + if (removedNode && removedNode.nodeName == 'BR' && isNodeTag.call(this, lastInputChild)) { + this.removeTags(lastInputChild); + this.fixFirefoxLastTagNoCaret(); + } + }); + }); // get the last child only after the above DOM modifications + // check these scenarios: + // 1. after a single line, press ENTER once - should add only 1 BR + // 2. presss ENTER right before a tag + // 3. press enter within a text node before a tag + + var lastInputChild = this.DOM.input.lastChild; + if (lastInputChild && lastInputChild.nodeValue == '') lastInputChild.remove(); // make sure the last element is always a BR + + if (!lastInputChild || lastInputChild.nodeName != 'BR') { + this.DOM.input.appendChild(document.createElement('br')); + } } + + } + }; + + /** + * @constructor + * @param {Object} input DOM element + * @param {Object} settings settings object + */ + + function Tagify(input, settings) { + if (!input) { + console.warn('Tagify:', 'input element not found', input); // return an empty mock of all methods, so the code using tagify will not break + // because it might be calling methods even though the input element does not exists + + const mockInstance = new Proxy(this, { + get() { + return () => mockInstance; + } + + }); + return mockInstance; } - }, - /** - * @param {Node} tagElm the tag element to edit. if nothing specified, use last last - */ - editTag: function editTag() { - var _this6 = this; - - var tagElm = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.getLastTag(); - - var editableElm = tagElm.querySelector('.tagify__tag-text'), - tagIdx = this.getNodeIndex(tagElm), - tagData = this.value[tagIdx], - _CB = this.events.callbacks, - that = this, - delayed_onEditTagBlur = function delayed_onEditTagBlur() { - setTimeout(_CB.onEditTagBlur.bind(that), 0, editableElm); - }; + if (input.previousElementSibling && input.previousElementSibling.classList.contains('tagify')) { + console.warn('Tagify: ', 'input element is already Tagified', input); + return this; + } + + extend(this, EventDispatcher(this)); + this.isFirefox = typeof InstallTrigger !== 'undefined'; + this.isIE = window.document.documentMode; // https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode#Browser_compatibility + + settings = settings || {}; + this.getPersistedData = getPersistedData(settings.id); + this.setPersistedData = setPersistedData(settings.id); + this.clearPersistedData = clearPersistedData(settings.id); + this.applySettings(input, settings); + this.state = { + inputText: '', + editing: false, + actions: {}, + // UI actions for state-locking + mixMode: {}, + dropdown: {}, + flaggedTags: {} // in mix-mode, when a string is detetced as potential tag, and the user has chocen to close the suggestions dropdown, keep the record of the tasg here + + }; + this.value = []; // tags' data + // events' callbacks references will be stores here, so events could be unbinded + + this.listeners = {}; + this.DOM = {}; // Store all relevant DOM elements in an Object + + this.build(input); + initDropdown.call(this); + this.getCSSVars(); + this.loadOriginalValues(); + this.events.customBinding.call(this); + this.events.binding.call(this); + input.autofocus && this.DOM.input.focus(); + } + + Tagify.prototype = { + _dropdown, + customEventsList: ['change', 'add', 'remove', 'invalid', 'input', 'click', 'keydown', 'focus', 'blur', 'edit:input', 'edit:beforeUpdate', 'edit:updated', 'edit:start', 'edit:keydown', 'dropdown:show', 'dropdown:hide', 'dropdown:select', 'dropdown:updated', 'dropdown:noMatch', 'dropdown:scroll'], + dataProps: ['__isValid', '__removed', '__originalData', '__originalHTML', '__tagId'], + + // internal-uasge props + trim(text) { + return this.settings.trim && text && typeof text == "string" ? text.trim() : text; + }, + + // expose this handy utility function + parseHTML, + templates, + + parseTemplate(template, data) { + template = this.settings.templates[template] || template; + return this.parseHTML(template.apply(this, data)); + }, + + set whitelist(arr) { + const isArray = arr && Array.isArray(arr); + this.settings.whitelist = isArray ? arr : []; + this.setPersistedData(isArray ? arr : [], 'whitelist'); + }, + + get whitelist() { + return this.settings.whitelist; + }, + + applySettings(input, settings) { + DEFAULTS.templates = this.templates; + + var _s = this.settings = extend({}, DEFAULTS, settings); + + _s.disabled = input.hasAttribute('disabled'); + _s.readonly = _s.readonly || input.hasAttribute('readonly'); + _s.placeholder = escapeHTML(input.getAttribute('placeholder') || _s.placeholder || ""); + _s.required = input.hasAttribute('required'); + + for (let name in _s.classNames) Object.defineProperty(_s.classNames, name + "Selector", { + get() { + return "." + this[name].split(" ")[0]; + } + + }); + + if (this.isIE) _s.autoComplete = false; // IE goes crazy if this isn't false + + ["whitelist", "blacklist"].forEach(name => { + var attrVal = input.getAttribute('data-' + name); + + if (attrVal) { + attrVal = attrVal.split(_s.delimiters); + if (attrVal instanceof Array) _s[name] = attrVal; + } + }); // backward-compatibility for old version of "autoComplete" setting: + + if ("autoComplete" in settings && !isObject(settings.autoComplete)) { + _s.autoComplete = DEFAULTS.autoComplete; + _s.autoComplete.enabled = settings.autoComplete; + } + + if (_s.mode == 'mix') { + _s.autoComplete.rightKey = true; + _s.delimiters = settings.delimiters || null; // default dlimiters in mix-mode must be NULL + // needed for "filterListItems". This assumes the user might have forgotten to manually + // define the same term in "dropdown.searchKeys" as defined in "tagTextProp" setting, so + // by automatically adding it, tagify is "helping" out, guessing the intesntions of the developer. + + if (_s.tagTextProp && !_s.dropdown.searchKeys.includes(_s.tagTextProp)) _s.dropdown.searchKeys.push(_s.tagTextProp); + } + + if (input.pattern) try { + _s.pattern = new RegExp(input.pattern); + } catch (e) {} // Convert the "delimiters" setting into a REGEX object + + if (this.settings.delimiters) { + try { + _s.delimiters = new RegExp(this.settings.delimiters, "g"); + } catch (e) {} + } + + if (_s.disabled) _s.userInput = false; + this.TEXTS = _objectSpread2(_objectSpread2({}, TEXTS), _s.texts || {}); // make sure the dropdown will be shown on "focus" and not only after typing something (in "select" mode) + + if (_s.mode == 'select' || !_s.userInput) _s.dropdown.enabled = 0; + _s.dropdown.appendTarget = settings.dropdown && settings.dropdown.appendTarget ? settings.dropdown.appendTarget : document.body; // get & merge persisted data with current data + + let persistedWhitelist = this.getPersistedData('whitelist'); + if (Array.isArray(persistedWhitelist)) this.whitelist = Array.isArray(_s.whitelist) ? concatWithoutDups(_s.whitelist, persistedWhitelist) : persistedWhitelist; + }, + + /** + * Returns a string of HTML element attributes + * @param {Object} data [Tag data] + */ + getAttributes(data) { + var attrs = this.getCustomAttributes(data), + s = '', + k; + + for (k in attrs) s += " " + k + (data[k] !== undefined ? `="${attrs[k]}"` : ""); + + return s; + }, + + /** + * Returns an object of attributes to be used for the templates + */ + getCustomAttributes(data) { + // only items which are objects have properties which can be used as attributes + if (!isObject(data)) return ''; + var output = {}, + propName; + + for (propName in data) { + if (propName.slice(0, 2) != '__' && propName != 'class' && data.hasOwnProperty(propName) && data[propName] !== undefined) output[propName] = escapeHTML(data[propName]); + } + + return output; + }, + + setStateSelection() { + var selection = window.getSelection(); // save last selection place to be able to inject anything from outside to that specific place + + var sel = { + anchorOffset: selection.anchorOffset, + anchorNode: selection.anchorNode, + range: selection.getRangeAt && selection.rangeCount && selection.getRangeAt(0) + }; + this.state.selection = sel; + return sel; + }, + + /** + * Get the caret position relative to the viewport + * https://stackoverflow.com/q/58985076/104380 + * + * @returns {object} left, top distance in pixels + */ + getCaretGlobalPosition() { + const sel = document.getSelection(); + + if (sel.rangeCount) { + const r = sel.getRangeAt(0); + const node = r.startContainer; + const offset = r.startOffset; + let rect, r2; + + if (offset > 0) { + r2 = document.createRange(); + r2.setStart(node, offset - 1); + r2.setEnd(node, offset); + rect = r2.getBoundingClientRect(); + return { + left: rect.right, + top: rect.top, + bottom: rect.bottom + }; + } + + if (node.getBoundingClientRect) return node.getBoundingClientRect(); + } + + return { + left: -9999, + top: -9999 + }; + }, + + /** + * Get specific CSS variables which are relevant to this script and parse them as needed. + * The result is saved on the instance in "this.CSSVars" + */ + getCSSVars() { + var compStyle = getComputedStyle(this.DOM.scope, null); + + const getProp = name => compStyle.getPropertyValue('--' + name); + + function seprateUnitFromValue(a) { + if (!a) return {}; + a = a.trim().split(' ')[0]; + var unit = a.split(/\d+/g).filter(n => n).pop().trim(), + value = +a.split(unit).filter(n => n)[0].trim(); + return { + value, + unit + }; + } + + this.CSSVars = { + tagHideTransition: (({ + value, + unit + }) => unit == 's' ? value * 1000 : value)(seprateUnitFromValue(getProp('tag-hide-transition'))) + }; + }, + + /** + * builds the HTML of this component + * @param {Object} input [DOM element which would be "transformed" into "Tags"] + */ + build(input) { + var DOM = this.DOM; + + if (this.settings.mixMode.integrated) { + DOM.originalInput = null; + DOM.scope = input; + DOM.input = input; + } else { + DOM.originalInput = input; + DOM.scope = this.parseTemplate('wrapper', [input, this.settings]); + DOM.input = DOM.scope.querySelector(this.settings.classNames.inputSelector); + input.parentNode.insertBefore(DOM.scope, input); + } + }, + + /** + * revert any changes made by this component + */ + destroy() { + this.events.unbindGlobal.call(this); + this.DOM.scope.parentNode.removeChild(this.DOM.scope); + this.dropdown.hide(true); + clearTimeout(this.dropdownHide__bindEventsTimeout); + }, + + /** + * if the original input has any values, add them as tags + */ + loadOriginalValues(value) { + var lastChild, + _s = this.settings; // temporarily block firign the "change" event on the original input unil + // this method finish removing current value and adding the new one + + this.state.blockChangeEvent = true; + + if (value === undefined) { + const persistedOriginalValue = this.getPersistedData('value'); // if the field already has a field, trust its the desired + // one to be rendered and do not use the persisted one + + if (persistedOriginalValue && !this.DOM.originalInput.value) value = persistedOriginalValue;else value = _s.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value; + } + + this.removeAllTags(); + + if (value) { + if (_s.mode == 'mix') { + this.parseMixTags(this.trim(value)); + lastChild = this.DOM.input.lastChild; + if (!lastChild || lastChild.tagName != 'BR') this.DOM.input.insertAdjacentHTML('beforeend', '
'); + } else { + try { + if (JSON.parse(value) instanceof Array) value = JSON.parse(value); + } catch (err) {} + + this.addTags(value).forEach(tag => tag && tag.classList.add(_s.classNames.tagNoAnimation)); + } + } else this.postUpdate(); + + this.state.lastOriginalValueReported = _s.mixMode.integrated ? '' : this.DOM.originalInput.value; + this.state.blockChangeEvent = false; + }, + + cloneEvent(e) { + var clonedEvent = {}; - if (!editableElm) { - console.warn('Cannot find element in Tag template: ', '.tagify__tag-text'); - return; - } + for (var v in e) clonedEvent[v] = e[v]; - if ("editable" in tagData && !tagData.editable) return; - tagElm.classList.add('tagify__tag--editable'); - editableElm.originalValue = editableElm.textContent; - editableElm.setAttribute('contenteditable', true); - editableElm.addEventListener('blur', delayed_onEditTagBlur); - editableElm.addEventListener('input', _CB.onEditTagInput.bind(this, editableElm)); - editableElm.addEventListener('keydown', function (e) { - return _CB.onEditTagkeydown.call(_this6, e); - }); - editableElm.focus(); - this.setRangeAtStartEnd(false, editableElm); - this.state.editing = { - scope: tagElm, - input: tagElm.querySelector("[contenteditable]") - }; - this.trigger("edit:start", { - tag: tagElm, - index: tagIdx, - data: tagData - }); - return this; - }, - onEditTagDone: function onEditTagDone(tagElm, tagData) { - var eventData = { - tag: tagElm, - index: this.getNodeIndex(tagElm), - data: tagData - }; - this.trigger("edit:beforeUpdate", eventData); - this.replaceTag(tagElm, tagData); - this.trigger("edit:updated", eventData); - }, + return clonedEvent; + }, - /** - * Exit a tag's edit-mode. - * if "tagData" exists, replace the tag element with new data and update Tagify value - */ - replaceTag: function replaceTag(tagElm, tagData) { - var _this7 = this; + /** + * 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 - var editableElm = tagElm.querySelector('.tagify__tag-text'), - clone = editableElm.cloneNode(true), - tagElmIdx = this.getNodeIndex(tagElm); - if (this.state.editing.locked) return; // when editing a tag and selecting a dropdown suggested item, the state should be "locked" - // so "onEditTagBlur" won't run and change the tag also *after* it was just changed. + this.DOM.scope.classList[isLoading ? "add" : "remove"](this.settings.classNames.scopeLoading); + return this; + }, - this.state.editing = { - locked: true - }; - setTimeout(function () { - return delete _this7.state.editing.locked; - }, 500); // update DOM nodes + /** + * Toogle specieif tag loading state on/off + * @param {Boolean} isLoading + */ + tagLoading(tagElm, isLoading) { + if (tagElm) // IE11 doesn't support toggle with second parameter + tagElm.classList[isLoading ? "add" : "remove"](this.settings.classNames.tagLoading); + return this; + }, - clone.removeAttribute('contenteditable'); - tagElm.classList.remove('tagify__tag--editable'); // guarantee to remove all events which were added by the "editTag" method + /** + * 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); + }, - editableElm.parentNode.replaceChild(clone, editableElm); // continue only if there was a reason for it + toggleFocusClass(force) { + this.toggleClass(this.settings.classNames.focus, !!force); + }, - if (tagData) { - clone.innerHTML = tagData.value; - clone.title = tagData.value; // update data + triggerChangeEvent, + events, - this.value[tagElmIdx] = tagData; - this.update(); - } - }, + fixFirefoxLastTagNoCaret() { + return; // seems to be fixed in newer version of FF, so retiring below code (for now) + }, - /** 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: function setRangeAtStartEnd(start, node) { - node = node || this.DOM.input; - node = node.lastChild || node; - var sel = document.getSelection(); - - if (sel.rangeCount) { - ['Start', 'End'].forEach(function (pos) { - return sel.getRangeAt(0)["set" + pos](node, start ? 0 : node.length); - }); - } - }, + placeCaretAfterNode(node) { + if (!node || !node.parentNode) return; + var nextSibling = node.nextSibling, + sel = window.getSelection(), + range = sel.getRangeAt(0); - /** - * input bridge for accessing & setting - * @type {Object} - */ - input: { - value: '', - set: function set() { - var s = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; - var updateDOM = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; - var hideDropdown = this.settings.dropdown.closeOnSelect; - this.input.value = s; - if (updateDOM) this.DOM.input.innerHTML = s; - if (!s && hideDropdown) setTimeout(this.dropdown.hide.bind(this), 20); // setTimeout duration must be HIGER than the dropdown's item "onClick" method's "focus()" event, because the "hide" method re-binds the main events and it will catch the "blur" event and will cause + if (sel.rangeCount) { + range.setStartAfter(nextSibling || node); + range.collapse(true); // range.setEndBefore(nextSibling || node); - this.input.autocomplete.suggest.call(this); - this.input.validate.call(this); + 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; }, /** - * Marks the tagify's input as "invalid" if the value did not pass "validateTag()" + * Enters a tag into "edit" mode + * @param {Node} tagElm the tag element to edit. if nothing specified, use last last */ - validate: function validate() { - var isValid = !this.input.value || this.validateTag(this.input.value); - if (this.settings.mode == 'select') this.DOM.scope.classList.toggle('tagify--invalid', isValid !== true);else this.DOM.input.classList.toggle('tagify__input--invalid', isValid !== true); - }, - // remove any child DOM elements that aren't of type TEXT (like
) - normalize: function 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
but no newline symbols (\n), and this will - // result in tags no being properly created if one wishes to create a separate tag per newline. - - clone.childNodes.forEach(function (n) { - return n.nodeType == 3 && v.push(n.nodeValue); - }); - v = v.join("\n"); + editTag(tagElm, opts) { + tagElm = tagElm || this.getLastTag(); + opts = opts || {}; + this.dropdown.hide(); + var _s = this.settings; + + function getEditableElm() { + return tagElm.querySelector(_s.classNames.tagTextSelector); + } - 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) {} + var editableElm = getEditableElm(), + tagIdx = this.getNodeIndex(tagElm), + tagData = this.tagData(tagElm), + _CB = this.events.callbacks, + that = this, + isValid = true, + delayed_onEditTagBlur = function () { + setTimeout(() => _CB.onEditTagBlur.call(that, getEditableElm())); + }; - v = v.replace(/\s/g, ' ') // replace NBSPs with spaces characters - .replace(/^\s+/, ""); // trimLeft + if (!editableElm) { + console.warn('Cannot find element in Tag template: .', _s.classNames.tagTextSelector); + return; + } - return v; + if (tagData instanceof Object && "editable" in tagData && !tagData.editable) return; + editableElm.setAttribute('contenteditable', true); + tagElm.classList.add(_s.classNames.tagEditing); // cache the original data, on the DOM node, before any modification ocurs, for possible revert + + this.tagData(tagElm, { + __originalData: extend({}, tagData), + __originalHTML: tagElm.innerHTML + }); + editableElm.addEventListener('focus', _CB.onEditTagFocus.bind(this, tagElm)); + editableElm.addEventListener('blur', delayed_onEditTagBlur); + editableElm.addEventListener('input', _CB.onEditTagInput.bind(this, editableElm)); + editableElm.addEventListener('keydown', e => _CB.onEditTagkeydown.call(this, e, tagElm)); + editableElm.focus(); + this.setRangeAtStartEnd(false, editableElm); + if (!opts.skipValidation) isValid = this.editTagToggleValidity(tagElm); + editableElm.originalIsValid = isValid; + this.trigger("edit:start", { + tag: tagElm, + index: tagIdx, + data: tagData, + isValid + }); + return this; }, /** - * suggest the rest of the input's value (via CSS "::after" using "content:attr(...)") - * @param {String} s [description] + * If a tag is invalid, for any reason, set its class to as "not allowed" (see defaults file) + * @param {Node} tagElm required + * @param {Object} tagData optional + * @returns true if valid, a string (reason) if not */ - autocomplete: { - suggest: function suggest(data) { - if (!this.settings.autoComplete.enabled) return; - data = data || {}; - if (typeof data == 'string') data = { - value: data - }; - var suggestedText = data.value || '', - suggestionStart = suggestedText.substr(0, this.input.value.length).toLowerCase(), - suggestionTrimmed = suggestedText.substring(this.input.value.length); + editTagToggleValidity(tagElm, tagData) { + var tagData = tagData || this.tagData(tagElm), + isValid; - if (!suggestedText || !this.input.value || suggestionStart != this.input.value.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: function set(s) { - var dataSuggest = this.DOM.input.getAttribute('data-suggest'), - suggestion = s || (dataSuggest ? this.input.value + 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(); - } + if (!tagData) { + console.warn("tag has no data: ", tagElm, tagData); + return; + } - this.input.autocomplete.suggest.call(this); - this.dropdown.hide.call(this); - return true; - } + isValid = !("__isValid" in tagData) || tagData.__isValid === true; - return false; + if (!isValid) { + this.removeTagsFromValue(tagElm); } - } - }, - getNodeIndex: function getNodeIndex(node) { - var index = 0; - if (node) while (node = node.previousElementSibling) { - index++; - } - return index; - }, - getTagElms: function getTagElms() { - return this.DOM.scope.querySelectorAll('.tagify__tag'); - }, - getLastTag: function getLastTag() { - var lastTag = this.DOM.scope.querySelectorAll('tag:not(.tagify--hide):not([readonly])'); - return lastTag[lastTag.length - 1]; - }, - /** - * Searches if any tag with a certain value already exis - * @param {String/Object} v [text value / tag data object] - * @return {Boolean} - */ - isTagDuplicate: function isTagDuplicate(v) { - var _this8 = this; + this.update(); //this.validateTag(tagData); - // duplications are irrelevant for this scenario - if (this.settings.mode == 'select') return false; - return this.value.some(function (item) { - return _this8.isObject(v) ? JSON.stringify(item).toLowerCase() === JSON.stringify(v).toLowerCase() : v.trim().toLowerCase() === item.value.toLowerCase(); - }); - }, - getTagIndexByValue: function getTagIndexByValue(value) { - var result = []; - this.getTagElms().forEach(function (tagElm, i) { - if (tagElm.textContent.trim().toLowerCase() == value.toLowerCase()) result.push(i); - }); - return result; - }, - getTagElmByValue: function getTagElmByValue(value) { - var tagIdx = this.getTagIndexByValue(value)[0]; - return this.getTagElms()[tagIdx]; - }, + tagElm.classList.toggle(this.settings.classNames.tagNotAllowed, !isValid); + return tagData.__isValid; + }, - /** - * Mark a tag element by its value - * @param {String|Number} value [text value to search for] - * @param {Object} tagElm [a specific "tag" element to compare to the other tag elements siblings] - * @return {boolean} [found / not found] - */ - markTagByValue: function markTagByValue(value, tagElm) { - tagElm = tagElm || this.getTagElmByValue(value); // check AGAIN if "tagElm" is defined + onEditTagDone(tagElm, tagData) { + tagElm = tagElm || this.state.editing.scope; + tagData = tagData || {}; + var eventData = { + tag: tagElm, + index: this.getNodeIndex(tagElm), + previousData: this.tagData(tagElm), + data: tagData + }; + this.trigger("edit:beforeUpdate", eventData, { + cloneData: false + }); + this.state.editing = false; + delete tagData.__originalData; + delete tagData.__originalHTML; - if (tagElm) { - tagElm.classList.add('tagify--mark'); // setTimeout(() => { tagElm.classList.remove('tagify--mark') }, 100); + if (tagElm && tagData[this.settings.tagTextProp]) { + tagElm = this.replaceTag(tagElm, tagData); + this.editTagToggleValidity(tagElm, tagData); + if (this.settings.a11y.focusableTags) tagElm.focus();else // place caret after edited tag + this.placeCaretAfterNode(tagElm.previousSibling); + } else if (tagElm) this.removeTags(tagElm); - return 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 - return false; - }, + if (this.settings.keepInvalidTags) this.reCheckInvalidTags(); + }, - /** - * make sure the tag, or words in it, is not in the blacklist - */ - isTagBlacklisted: function isTagBlacklisted(v) { - v = v.toLowerCase().trim(); - return this.settings.blacklist.filter(function (x) { - return v == x.toLowerCase(); - }).length; - }, + /** + * 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 - /** - * make sure the tag, or words in it, is not in the blacklist - */ - isTagWhitelisted: function isTagWhitelisted(v) { - return this.settings.whitelist.some(function (item) { - return typeof v == 'string' ? v.trim().toLowerCase() === (item.value || item).toLowerCase() : JSON.stringify(item).toLowerCase() === JSON.stringify(v).toLowerCase(); - }); - }, + if (tagData.__isValid && tagData.__isValid != true) extend(tagData, this.getInvalidTagAttrs(tagData, tagData.__isValid)); + var newTagElm = this.createTagElem(tagData); // update DOM - /** - * validate a tag object BEFORE the actual tag will be created & appeneded - * @param {String} s - * @return {Boolean/String} ["true" if validation has passed, String for a fail] - */ - validateTag: function validateTag(s) { - var value = s.trim(), - _s = this.settings, - result = true; // check for empty value - - if (!value) result = this.TEXTS.empty; // check if pattern should be used and if so, use it to test the value - else if (_s.pattern && !_s.pattern.test(value)) result = this.TEXTS.pattern; // if duplicates are not allowed and there is a duplicate - else if (!_s.duplicates && this.isTagDuplicate(value)) result = this.TEXTS.duplicate;else if (this.isTagBlacklisted(value) || _s.enforceWhitelist && !this.isTagWhitelisted(value)) result = this.TEXTS.notAllowed; - return result; - }, - hasMaxTags: function hasMaxTags() { - if (this.value.length >= this.settings.maxTags) return this.TEXTS.exceed; - return false; - }, + tagElm.parentNode.replaceChild(newTagElm, tagElm); + this.updateValueByDOMTags(); + return newTagElm; + }, - /** - * 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: function normalizeTags(tagsItems) { - var _this$settings = this.settings, - whitelist = _this$settings.whitelist, - delimiters = _this$settings.delimiters, - mode = _this$settings.mode, - whitelistWithProps = whitelist ? whitelist[0] instanceof Object : false, - isArray = tagsItems instanceof Array, - isCollection = isArray && tagsItems[0] instanceof Object && "value" in tagsItems[0], - temp = [], - mapStringToCollection = function mapStringToCollection(s) { - return s.split(delimiters).filter(function (n) { - return n; - }).map(function (v) { - return { - value: v.trim() - }; + /** + * update "value" (Array of Objects) by traversing all valid tags + */ + updateValueByDOMTags() { + this.value.length = 0; + [].forEach.call(this.getTagElms(), node => { + if (node.classList.contains(this.settings.classNames.tagNotAllowed.split(' ')[0])) return; + this.value.push(this.tagData(node)); }); - }; // no need to continue if "tagsItems" is an Array of Objects + this.update(); + }, + + /** https://stackoverflow.com/a/59156872/104380 + * @param {Boolean} start indicating where to place it (start or end of the node) + * @param {Object} node DOM node to place the caret at + */ + setRangeAtStartEnd(start, node) { + start = typeof start == 'number' ? start : !!start; + node = node || this.DOM.input; + node = node.lastChild || node; + var sel = document.getSelection(); + try { + if (sel.rangeCount >= 1) { + ['Start', 'End'].forEach(pos => sel.getRangeAt(0)["set" + pos](node, start ? start : node.length)); + } + } catch (err) {// console.warn("Tagify: ", err) + } + }, - if (isCollection) { - var _ref2; + /** + * injects nodes/text at caret position, which is saved on the "state" when "blur" event gets triggered + * @param {Node} injectedNode [the node to inject at the caret position] + * @param {Object} selection [optional range Object. must have "anchorNode" & "anchorOffset"] + */ + injectAtCaret(injectedNode, range) { + range = range || this.state.selection.range; + if (!range) return; + if (typeof injectedNode == 'string') injectedNode = document.createTextNode(injectedNode); + range.deleteContents(); + range.insertNode(injectedNode); + this.setRangeAtStartEnd(false, injectedNode); + this.updateValueByDOMTags(); // updates internal "this.value" - // iterate the collection items and check for values that can be splitted into multiple tags - tagsItems = (_ref2 = []).concat.apply(_ref2, _toConsumableArray(tagsItems.map(function (item) { - return mapStringToCollection(item.value).map(function (newItem) { - return _objectSpread({}, item, {}, newItem); - }); - }))); - return tagsItems; - } + this.update(); // updates original input/textarea - if (typeof tagsItems == 'number') tagsItems = tagsItems.toString(); // if the value is a "simple" String, ex: "aaa, bbb, ccc" + return this; + }, - if (typeof tagsItems == 'string') { - if (!tagsItems.trim()) return []; // go over each tag and add it (if there were multiple ones) + /** + * input bridge for accessing & setting + * @type {Object} + */ + input: { + set(s = '', updateDOM = true) { + var hideDropdown = this.settings.dropdown.closeOnSelect; + this.state.inputText = s; + if (updateDOM) this.DOM.input.innerHTML = escapeHTML("" + s); + if (!s && hideDropdown) this.dropdown.hide.bind(this); + this.input.autocomplete.suggest.call(this); + this.input.validate.call(this); + }, - tagsItems = mapStringToCollection(tagsItems); - } else if (isArray) { - var _ref3; + raw() { + return this.DOM.input.textContent; + }, - tagsItems = (_ref3 = []).concat.apply(_ref3, _toConsumableArray(tagsItems.map(function (item) { - return mapStringToCollection(item); - }))); - } // search if the tag exists in the whitelist as an Object (has props), - // to be able to use its properties + /** + * 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
) + 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
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. - if (whitelistWithProps) { - tagsItems.forEach(function (item) { - // the "value" prop should preferably be unique - var matchObj = whitelist.filter(function (WL_item) { - return WL_item.value.toLowerCase() == item.value.toLowerCase(); - }); + clone.childNodes.forEach(n => n.nodeType == 3 && v.push(n.nodeValue)); + v = v.join("\n"); - if (matchObj[0]) { - temp.push(matchObj[0]); // set the Array (with the found Object) as the new value - } else if (mode != 'mix') temp.push(item); - }); - tagsItems = temp; - } + 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) {} - return tagsItems; - }, + v = v.replace(/\s/g, ' '); // replace NBSPs with spaces characters - /** - * Used to parse the initial value of a textarea (or input) element and gemerate mixed text w/ tags - * https://stackoverflow.com/a/57598892/104380 - * @param {String} s - */ - parseMixTags: function parseMixTags(s) { - var _this9 = this; - - var _this$settings2 = this.settings, - mixTagsInterpolator = _this$settings2.mixTagsInterpolator, - duplicates = _this$settings2.duplicates, - transformTag = _this$settings2.transformTag, - enforceWhitelist = _this$settings2.enforceWhitelist; - s = s.split(mixTagsInterpolator[0]).map(function (s1, i) { - var s2 = s1.split(mixTagsInterpolator[1]), - preInterpolated = s2[0], - tagData, - tagElm; + if (this.settings.trim) v = v.replace(/^\s+/, ''); // trimLeft - try { - tagData = JSON.parse(preInterpolated); - } catch (err) { - tagData = _this9.normalizeTags(preInterpolated)[0]; //{value:preInterpolated} - } + return v; + }, - if (s2.length > 1 && (!enforceWhitelist || _this9.isTagWhitelisted(tagData.value)) && !(!duplicates && _this9.isTagDuplicate(tagData))) { - transformTag.call(_this9, tagData); - tagElm = _this9.createTagElem(tagData); - s2[0] = tagElm.outerHTML; //+ "⁠" // 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) + /** + * suggest the rest of the input's value (via CSS "::after" using "content:attr(...)") + * @param {String} s [description] + */ + autocomplete: { + suggest(data) { + if (!this.settings.autoComplete.enabled) return; + data = data || {}; + if (typeof data == 'string') data = { + value: data + }; + var suggestedText = data.value ? '' + data.value : '', + suggestionStart = suggestedText.substr(0, this.state.inputText.length).toLowerCase(), + suggestionTrimmed = suggestedText.substring(this.state.inputText.length); + + if (!suggestedText || !this.state.inputText || suggestionStart != this.state.inputText.toLowerCase()) { + this.DOM.input.removeAttribute("data-suggest"); + delete this.state.inputSuggestion; + } else { + this.DOM.input.setAttribute("data-suggest", suggestionTrimmed); + this.state.inputSuggestion = data; + } + }, - _this9.value.push(tagData); - } else if (s1) return i ? mixTagsInterpolator[0] + s1 : s1; + /** + * 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(); + } - return s2.join(''); - }).join(''); - this.DOM.input.innerHTML = s; - this.DOM.input.appendChild(document.createTextNode('')); - this.update(); - return s; - }, + this.input.autocomplete.suggest.call(this); + this.dropdown.hide(); + return true; + } - /** - * 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: function replaceTextWithNode(wrapperElm, tagString) { - if (!this.state.tag && !tagString) return; - tagString = tagString || this.state.tag.prefix + this.state.tag.value; - var idx, - replacedNode, - selection = window.getSelection(), - nodeAtCaret = selection.anchorNode; // ex. replace #ba with the tag "bart" where "|" is where the caret is: - // start with: "#ba #ba| #ba" - // split the text node at the index of the caret - - nodeAtCaret.splitText(selection.anchorOffset); // "#ba #ba" - // get index of last occurence of "#ba" - - idx = nodeAtCaret.nodeValue.lastIndexOf(tagString); - replacedNode = nodeAtCaret.splitText(idx); // clean up the tag's string and put tag element instead - - replacedNode.nodeValue = replacedNode.nodeValue.replace(tagString, ''); - nodeAtCaret.parentNode.insertBefore(wrapperElm, replacedNode); - this.DOM.input.normalize(); - return replacedNode; - }, + return false; + } - /** - * For selecting a single option (not used for multiple tags) - * @param {Object} tagElm Tag DOM node - * @param {Object} tagData Tag data - */ - selectTag: function selectTag(tagElm, tagData) { - this.input.set.call(this, tagData.value, true); - setTimeout(this.setRangeAtStartEnd.bind(this)); - if (this.getLastTag()) this.replaceTag(this.getLastTag(), tagData);else this.appendTag(tagElm); - this.value[0] = tagData; - this.trigger('add', { - tag: tagElm, - data: tagData - }); - this.update(); - return [tagElm]; - }, + } + }, - /** - * add an empty "tag" element in an editable state - */ - addEmptyTag: function addEmptyTag() { - var tagData = { - value: "" + /** + * 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); }, - tagElm = this.createTagElem(tagData); // add the tag to the component's DOM - this.appendTag(tagElm); - this.value.push(tagData); - this.update(); - this.editTag(tagElm); - }, + getNodeIndex(node) { + var index = 0; + if (node) while (node = node.previousElementSibling) index++; + return index; + }, - /** - * 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: function addTags(tagsItems, clearInput) { - var _this10 = this; + getTagElms(...classess) { + var classname = '.' + [...this.settings.classNames.tag.split(' '), ...classess].join('.'); + return [].slice.call(this.DOM.scope.querySelectorAll(classname)); // convert nodeList to Array - https://stackoverflow.com/a/3199627/104380 + }, - var skipInvalid = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : this.settings.skipInvalid; - var tagElems = [], - tagElm, - _s = this.settings; + /** + * 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]; + }, - if (!tagsItems || tagsItems.length == 0) { - // is mode is "select" clean all tags - if (_s.mode == 'select') this.removeAllTags(); - return tagElems; - } // converts Array/String/Object to an Array of Objects + /** Setter/Getter + * Each tag DOM node contains a custom property called "__tagifyTagData" which hosts its data + * @param {Node} tagElm + * @param {Object} data + */ + tagData(tagElm, data, override) { + if (!tagElm) { + console.warn("tag element doesn't exist", tagElm, data); + return data; + } + if (data) tagElm.__tagifyTagData = override ? data : extend({}, tagElm.__tagifyTagData || {}, data); + return tagElm.__tagifyTagData; + }, - tagsItems = this.normalizeTags(tagsItems); // if in edit-mode, do not continue but instead replace the tag's text + /** + * Searches if any tag with a certain value already exis + * @param {String/Object} value [text value / tag data object] + * @param {Boolean} caseSensitive + * @return {Number} + */ + isTagDuplicate(value, caseSensitive) { + var dupsCount, + _s = this.settings; // duplications are irrelevant for this scenario - if (this.state.editing.scope) { - return this.onEditTagDone(this.state.editing.scope, tagsItems[0]); - } + if (_s.mode == 'select') return false; + dupsCount = this.value.reduce((acc, item) => sameStr(this.trim("" + value), item.value, caseSensitive || _s.dropdown.caseSensitive) ? acc + 1 : acc, 0); + return dupsCount; + }, - if (_s.mode == 'mix') { - _s.transformTag.call(this, tagsItems[0]); + 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; + }, - tagElm = this.createTagElem(tagsItems[0]); // insert the new tag to the END if "addTags" was called from outside + getTagElmByValue(value) { + var tagIdx = this.getTagIndexByValue(value)[0]; + return this.getTagElms()[tagIdx]; + }, - if (!this.replaceTextWithNode(tagElm)) { - this.DOM.input.appendChild(tagElm); - } // fixes a firefox bug where if the last child of the input is a tag and not a text, the input cannot get focus (by Tab key) + /** + * 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; + }, - this.DOM.input.appendChild(document.createTextNode('')); - tagsItems[0].prefix = tagsItems[0].prefix || this.state.tag ? this.state.tag.prefix : (_s.pattern.source || _s.pattern)[0]; - this.value.push(tagsItems[0]); - this.update(); - this.state.tag = null; - this.trigger('add', this.extend({}, { - tag: tagElm - }, { - data: tagsItems[0] - })); // fixes a firefox bug where if the last child of the input is a tag and not a text, the input cannot get focus (by Tab key) + /** + * 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)) + ) + */ + }, - this.DOM.input.appendChild(document.createTextNode('')); - return tagElm; - } + /** + * Returns the first whitelist item matched, by value (if match found) + * @param {String} value [text to match by] + */ + getWhitelistItem(value, prop, whitelist) { + var result, + prop = prop || 'value', + _s = this.settings, + whitelist = whitelist || _s.whitelist; + whitelist.some(_wi => { + var _wiv = typeof _wi == 'string' ? _wi : _wi[prop] || _wi.value, + isSameStr = sameStr(_wiv, value, _s.dropdown.caseSensitive, _s.trim); + + if (isSameStr) { + result = typeof _wi == 'string' ? { + value: _wi + } : _wi; + return true; + } + }); // first iterate the whitelist, try find maches by "value" and if that fails + // and a "tagTextProp" is set to be other than "value", try that also - if (_s.mode == 'select') clearInput = false; - this.DOM.input.removeAttribute('style'); - tagsItems.forEach(function (tagData) { - var tagValidation, - tagElm, - tagElmParams = {}; // shallow-clone tagData so later modifications will not apply to the source + if (!result && prop == 'value' && _s.tagTextProp != 'value') { + // if found, adds the first which matches + result = this.getWhitelistItem(value, _s.tagTextProp, whitelist); + } - tagData = Object.assign({}, tagData); + return result; + }, - _s.transformTag.call(_this10, tagData); ///////////////// ( validation )////////////////////// + /** + * 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 - tagValidation = _this10.hasMaxTags() || _this10.validateTag(tagData.value); + if (_s.pattern && _s.pattern instanceof RegExp && !_s.pattern.test(v)) return this.TEXTS.pattern; // check for duplicates - if (tagValidation !== true) { - if (skipInvalid) return; - tagElmParams["aria-invalid"] = true; - tagElmParams["class"] = (tagData["class"] || '') + ' tagify--notAllowed'; - tagElmParams.title = tagValidation; + if (!_s.duplicates && this.isTagDuplicate(v, this.state.editing)) return this.TEXTS.duplicate; + if (this.isTagBlacklisted(v) || _s.enforceWhitelist && !this.isTagWhitelisted(v)) return this.TEXTS.notAllowed; + if (_s.validate) return _s.validate(tagData); + return true; + }, - _this10.markTagByValue(tagData.value); - } ///////////////////////////////////////////////////// - // add accessibility attributes + 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; + }, - tagElmParams.role = "tag"; - if (tagData.readonly) tagElmParams["aria-readonly"] = true; // Create tag HTML element + setReadonly(toggle, attrribute) { + var _s = this.settings; + document.activeElement.blur(); // exit possible edit-mode - tagElm = _this10.createTagElem(_this10.extend({}, tagData, tagElmParams)); - tagElems.push(tagElm); // mode-select overrides + _s[attrribute || 'readonly'] = toggle; + this.DOM.scope[(toggle ? 'set' : 'remove') + 'Attribute'](attrribute || 'readonly', true); - if (_s.mode == 'select') { - return _this10.selectTag(tagElm, tagData); - } // add the tag to the component's DOM + if (_s.mode == 'mix') { + this.setContentEditable(!toggle); + } + }, + setContentEditable(state) { + if (!this.settings.readonly && this.settings.userInput) this.DOM.input.contentEditable = state; + }, - _this10.appendTag(tagElm); + setDisabled(isDisabled) { + this.setReadonly(isDisabled, 'disabled'); + }, - if (tagValidation === true) { - // update state - _this10.value.push(tagData); + /** + * pre-proccess the tagsItems, which can be a complex tagsItems like an Array of Objects or a string comprised of multiple words + * so each item should be iterated on and a tag created for. + * @return {Array} [Array of Objects] + */ + normalizeTags(tagsItems) { + var _this$settings = this.settings, + whitelist = _this$settings.whitelist, + delimiters = _this$settings.delimiters, + mode = _this$settings.mode, + tagTextProp = _this$settings.tagTextProp; + _this$settings.enforceWhitelist; + var whitelistMatches = [], + whitelistWithProps = whitelist ? whitelist[0] instanceof Object : false, + isArray = tagsItems instanceof Array, + mapStringToCollection = s => (s + "").split(delimiters).filter(n => n).map(v => ({ + [tagTextProp]: this.trim(v), + value: this.trim(v) + })); + + if (typeof tagsItems == 'number') tagsItems = tagsItems.toString(); // if the argument is a "simple" String, ex: "aaa, bbb, ccc" + + if (typeof tagsItems == 'string') { + if (!tagsItems.trim()) return []; // go over each tag and add it (if there were multiple ones) + + tagsItems = mapStringToCollection(tagsItems); + } // is is an Array of Strings, convert to an Array of Objects + else if (isArray) { + // flatten the 2D array + tagsItems = [].concat(...tagsItems.map(item => item.value ? item // mapStringToCollection(item.value).map(newItem => ({...item,...newItem})) + : mapStringToCollection(item))); + } // search if the tag exists in the whitelist as an Object (has props), + // to be able to use its properties + + + if (whitelistWithProps) { + tagsItems.forEach(item => { + var whitelistMatchesValues = whitelistMatches.map(a => a.value); // if suggestions are shown, they are already filtered, so it's easier to use them, + // because the whitelist might also include items which have already been added + + var filteredList = this.dropdown.filterListItems.call(this, item[tagTextProp], { + exact: true + }); + if (!this.settings.duplicates) // also filter out items which have already been matched in previous iterations + filteredList = filteredList.filter(filteredItem => !whitelistMatchesValues.includes(filteredItem.value)); // get the best match out of list of possible matches. + // if there was a single item in the filtered list, use that one - _this10.update(); + var matchObj = filteredList.length > 1 ? this.getWhitelistItem(item[tagTextProp], tagTextProp, filteredList) : filteredList[0]; - _this10.trigger('add', { - tag: tagElm, - index: _this10.value.length - 1, - data: tagData - }); - } else { - _this10.trigger("invalid", { - data: tagData, - index: _this10.value.length, - tag: tagElm, - message: tagValidation + 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 (!_s.keepInvalidTags) // remove invalid tags (if "keepInvalidTags" is set to "false") - setTimeout(function () { - return _this10.removeTag(tagElm, true); - }, 1000); + if (whitelistMatches.length) tagsItems = whitelistMatches; } - _this10.dropdown.position.call(_this10); // reposition the dropdown because the just-added tag might cause a new-line + 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; - if (tagsItems.length && clearInput) { - this.input.set.call(this); - } + 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 + }; + } - this.dropdown.refilter.call(this); - return tagElems; - }, + transformTag.call(this, tagData); - /** - * appened (validated) tag to the component's DOM scope - */ - appendTag: function appendTag(tagElm) { - var insertBeforeNode = this.DOM.scope.lastElementChild; - if (insertBeforeNode === this.DOM.input) this.DOM.scope.insertBefore(tagElm, insertBeforeNode);else this.DOM.scope.appendChild(tagElm); - }, + 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; //+ "⁠" // 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) - /** - * Removed new lines and irrelevant spaces which might affect layout, and are better gone - * @param {string} s [HTML string] - */ - minify: function minify(s) { - return s ? s.replace(/\>[\r\n ]+\<").replace(/(<.*?>)|\s+/g, function (m, $1) { - return $1 ? $1 : ' '; - }) // https://stackoverflow.com/a/44841484/104380 - : ""; - }, + this.value.push(tagData); + } else if (s1) return i ? mixTagsInterpolator[0] + s1 : s1; - /** - * creates a DOM tag element and injects it into the component (this.DOM.scope) - * @param {Object} tagData [text value & properties for the created tag] - * @return {Object} [DOM element] - */ - createTagElem: function createTagElem(tagData) { - var tagElm, - v = this.escapeHTML(tagData.value), - template = this.settings.templates.tag.call(this, v, tagData); - if (this.settings.readonly) tagData.readonly = true; - template = this.minify(template); - tagElm = this.parseHTML(template); - return tagElm; - }, + return s2.join(''); + }).join(''); + this.DOM.input.innerHTML = s; + this.DOM.input.appendChild(document.createTextNode('')); + this.DOM.input.normalize(); + this.getTagElms().forEach((elm, idx) => this.tagData(elm, tagsDataSet[idx])); + this.update({ + withoutChangeEvent: true + }); + return s; + }, - /** - * Removes a tag - * @param {Object|String} tagElm [DOM element or a String value. if undefined or null, remove last added tag] - * @param {Boolean} silent [A flag, which when turned on, does not removes any value and does not update the original input value but simply removes the tag from tagify] - * @param {Number} tranDuration [Transition duration in MS] - */ - removeTag: function removeTag(tagElm, silent, tranDuration) { - tagElm = tagElm || this.getLastTag(); - tranDuration = tranDuration || this.CSSVars.tagHideTransition; - if (typeof tagElm == 'string') tagElm = this.getTagElmByValue(tagElm); - if (!(tagElm instanceof HTMLElement)) return; - var tagData, - that = this, - tagIdx = this.getNodeIndex(tagElm); // this.getTagIndexByValue(tagElm.textContent) - - if (this.settings.mode == 'select') { - tranDuration = 0; - this.input.set.call(this); - } + /** + * For mixed-mode: replaces a text starting with a prefix with a wrapper element (tag or something) + * First there *has* to be a "this.state.tag" which is a string that was just typed and is staring with a prefix + */ + replaceTextWithNode(newWrapperNode, strToReplace) { + if (!this.state.tag && !strToReplace) return; + strToReplace = strToReplace || this.state.tag.prefix + this.state.tag.value; + var idx, + nodeToReplace, + selection = window.getSelection(), + nodeAtCaret = selection.anchorNode, + firstSplitOffset = this.state.tag.delimiters ? this.state.tag.delimiters.length : 0; // STEP 1: ex. replace #ba with the tag "bart" where "|" is where the caret is: + // CURRENT STATE: "foo #ba #ba| #ba" + // split the text node at the index of the caret + + nodeAtCaret.splitText(selection.anchorOffset - firstSplitOffset); // node 0: "foo #ba #ba|" + // node 1: " #ba" + // get index of LAST occurence of "#ba" + + idx = nodeAtCaret.nodeValue.lastIndexOf(strToReplace); + if (idx == -1) return true; + nodeToReplace = nodeAtCaret.splitText(idx); // node 0: "foo #ba " + // node 1: "#ba" <- nodeToReplace + + newWrapperNode && nodeAtCaret.parentNode.replaceChild(newWrapperNode, nodeToReplace); // must NOT normalize contenteditable or it will cause unwanted issues: + // https://monosnap.com/file/ZDVmRvq5upYkidiFedvrwzSswegWk7 + // nodeAtCaret.parentNode.normalize() + + return true; + }, + + /** + * For selecting a single option (not used for multiple tags, but for "mode:select" only) + * @param {Object} tagElm Tag DOM node + * @param {Object} tagData Tag data + */ + selectTag(tagElm, tagData) { + var _s = this.settings; + if (_s.enforceWhitelist && !this.isTagWhitelisted(tagData.value)) return; + this.input.set.call(this, tagData[_s.tagTextProp] || tagData.value, true); // place the caret at the end of the input, only if a dropdown option was selected (and not by manually typing another value and clicking "TAB") + + if (this.state.actions.selectOption) setTimeout(this.setRangeAtStartEnd.bind(this)); + var lastTagElm = this.getLastTag(); + if (lastTagElm) this.replaceTag(lastTagElm, tagData);else this.appendTag(tagElm); + if (_s.enforceWhitelist) this.setContentEditable(false); + this.value[0] = tagData; + this.update(); + this.trigger('add', { + tag: tagElm, + data: tagData + }); + return [tagElm]; + }, + + /** + * add an empty "tag" element in an editable state + */ + addEmptyTag(initialData) { + var tagData = extend({ + value: "" + }, initialData || {}), + tagElm = this.createTagElem(tagData); + this.tagData(tagElm, tagData); // add the tag to the component's DOM + + this.appendTag(tagElm); + this.editTag(tagElm, { + skipValidation: true + }); + }, + + /** + * add a "tag" element to the "tags" component + * @param {String/Array} tagsItems [A string (single or multiple values with a delimiter), or an Array of Objects or just Array of Strings] + * @param {Boolean} clearInput [flag if the input's value should be cleared after adding tags] + * @param {Boolean} skipInvalid [do not add, mark & remove invalid tags] + * @return {Array} Array of DOM elements (tags) + */ + addTags(tagsItems, clearInput, skipInvalid) { + var tagElems = [], + _s = this.settings, + frag = document.createDocumentFragment(); + skipInvalid = skipInvalid || _s.skipInvalid; + + if (!tagsItems || tagsItems.length == 0) { + if (_s.mode == 'select') this.removeAllTags(); + return tagElems; + } // converts Array/String/Object to an Array of Objects - if (tagElm.classList.contains('tagify--notAllowed')) silent = true; - function removeNode() { - if (!tagElm.parentNode) return; - tagElm.parentNode.removeChild(tagElm); + tagsItems = this.normalizeTags(tagsItems); - if (!silent) { - tagData = that.value.splice(tagIdx, 1)[0]; // remove the tag from the data object + if (_s.mode == 'mix') { + return this.addMixTags(tagsItems); + } - that.update(); // update the original input with the current value + if (_s.mode == 'select') clearInput = false; + this.DOM.input.removeAttribute('style'); + tagsItems.forEach(tagData => { + var tagElm, + tagElmParams = {}, + originalData = Object.assign({}, tagData, { + value: tagData.value + "" + }); // shallow-clone tagData so later modifications will not apply to the source - that.trigger('remove', { - tag: tagElm, - index: tagIdx, - data: tagData - }); - that.dropdown.refilter.call(that); - that.dropdown.position.call(that); - } else if (that.settings.keepInvalidTags) that.trigger('remove', { - tag: tagElm, - index: tagIdx - }); - } + tagData = Object.assign({}, originalData); - function animation() { - tagElm.style.width = parseFloat(window.getComputedStyle(tagElm).width) + 'px'; - document.body.clientTop; // force repaint for the width to take affect before the "hide" class below + _s.transformTag.call(this, tagData); - tagElm.classList.add('tagify--hide'); // manual timeout (hack, since transitionend cannot be used because of hover) + tagData.__isValid = this.hasMaxTags() || this.validateTag(tagData); - setTimeout(removeNode, tranDuration); - } + 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 - if (tranDuration && tranDuration > 10) animation();else removeNode(); - }, - removeAllTags: function removeAllTags() { - this.value = []; - this.update(); - Array.prototype.slice.call(this.getTagElms()).forEach(function (elm) { - return elm.parentNode.removeChild(elm); - }); - this.dropdown.position.call(this); - if (this.settings.mode == 'select') this.input.set.call(this); - }, - preUpdate: function preUpdate() { - this.DOM.scope.classList.toggle('tagify--hasMaxTags', this.value.length >= this.settings.maxTags); - this.DOM.scope.classList.toggle('tagify--noTags', !this.value.length); - }, + 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)); + } ///////////////////////////////////////////////////// - /** - * update the origianl (hidden) input field's value - * see - https://stackoverflow.com/q/50957841/104380 - */ - update: function update() { - this.preUpdate(); - this.DOM.originalInput.value = this.settings.mode == 'mix' ? this.getMixedTagsAsString() : this.value.length ? JSON.stringify(this.value) : ""; - }, - getMixedTagsAsString: function getMixedTagsAsString() { - var _this11 = this; - - var result = "", - i = 0, - _interpolator = this.settings.mixTagsInterpolator; - this.DOM.input.childNodes.forEach(function (node) { - if (node.nodeType == 1 && node.classList.contains("tagify__tag")) result += _interpolator[0] + JSON.stringify(_this11.value[i++]) + _interpolator[1];else result += node.textContent; - }); - return result; - }, - /** - * Meassures an element's height, which might yet have been added DOM - * https://stackoverflow.com/q/5944038/104380 - * @param {DOM} node - */ - getNodeHeight: 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; - }, + 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 - /** - * Dropdown controller - * @type {Object} - */ - dropdown: { - init: function init() { - this.DOM.dropdown = this.dropdown.build.call(this); - this.DOM.dropdown.content = this.DOM.dropdown.querySelector('.tagify__dropdown__wrapper'); - }, - build: function build() { - var _this$settings$dropdo = this.settings.dropdown, - position = _this$settings$dropdo.position, - classname = _this$settings$dropdo.classname, - _className = "".concat(position == 'manual' ? "" : "tagify__dropdown tagify__dropdown--".concat(position), " ").concat(classname).trim(), - elm = this.parseHTML("
\n
\n
")); - return elm; - }, - show: function show(value) { - var _this12 = this; + tagElm = this.createTagElem(tagData, tagElmParams); + tagElems.push(tagElm); // mode-select overrides - var listHTML, - _s = this.settings, - firstListItem, - firstListItemValue, - ddHeight, - isManual = _s.dropdown.position == 'manual'; - if (!_s.whitelist || !_s.whitelist.length || _s.dropdown.enable === false) return; // 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" + if (_s.mode == 'select') { + return this.selectTag(tagElm, tagData); + } // add the tag to the component's DOM + // this.appendTag(tagElm) - this.suggestedListItems = this.dropdown.filterListItems.call(this, value); // hide suggestions list if no suggestions were matched - if (this.suggestedListItems.length) { - firstListItem = this.suggestedListItems[0]; - firstListItemValue = firstListItem.value || firstListItem; + frag.appendChild(tagElm); - if (_s.autoComplete) { - // 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); + 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); } - } else { - this.input.autocomplete.suggest.call(this); - this.dropdown.hide.call(this); - return; - } - listHTML = this.dropdown.createListHTML.call(this, this.suggestedListItems); - this.DOM.dropdown.content.innerHTML = this.minify(listHTML); // if "enforceWhitelist" is "true", highlight the first suggested item - - if (_s.enforceWhitelist && !isManual || _s.dropdown.highlightFirst) this.dropdown.highlightOption.call(this, this.DOM.dropdown.content.children[0]); - this.DOM.scope.setAttribute("aria-expanded", true); - this.trigger("dropdown:show", this.DOM.dropdown); // set the dropdown visible state to be the same as the searched value. - // MUST be set *before* position() is called + this.dropdown.position(); // reposition the dropdown because the just-added tag might cause a new-line + }); + this.appendTag(frag); + this.update(); - this.state.dropdown.visible = value || true; - this.dropdown.position.call(this); // if the dropdown has yet to be appended to the document, - // append the dropdown to the body element & handle events + if (tagsItems.length && clearInput) { + this.input.set.call(this); + } - if (!document.body.contains(this.DOM.dropdown)) { - if (!isManual) { - this.events.binding.call(this, false); // unbind the main events - // let the element render in the DOM first to accurately measure it - // this.DOM.dropdown.style.cssText = "left:-9999px; top:-9999px;"; - - ddHeight = this.getNodeHeight(this.DOM.dropdown); - this.DOM.dropdown.classList.add('tagify__dropdown--initial'); - this.dropdown.position.call(this, ddHeight); - document.body.appendChild(this.DOM.dropdown); - setTimeout(function () { - return _this12.DOM.dropdown.classList.remove('tagify__dropdown--initial'); - }); - } // 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 + 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); - setTimeout(this.dropdown.events.binding.bind(this)); + if (tagsData[0].prefix || this.state.tag) { + return this.prefixedTextToTag(tagsData[0]); } - }, - hide: function hide(force) { - var _this$DOM = this.DOM, - scope = _this$DOM.scope, - dropdown = _this$DOM.dropdown, - isManual = this.settings.dropdown.position == 'manual' && !force; - if (!dropdown || !document.body.contains(dropdown) || isManual) return; - window.removeEventListener('resize', this.dropdown.position); - this.dropdown.events.binding.call(this, false); // unbind all events - // must delay because if the dropdown is open, and the input (scope) is clicked, - // the dropdown should be now closed, and the next click should re-open it, - // and without this timeout, clicking to close will re-open immediately - setTimeout(this.events.binding.bind(this), 250); // re-bind main events + if (typeof tagsData == 'string') tagsData = [{ + value: tagsData + }]; + var selection = !!this.state.selection, + // must be cast, not to use the reference which is changing + frag = document.createDocumentFragment(); + tagsData.forEach(tagData => { + var tagElm = this.createTagElem(tagData); + frag.appendChild(tagElm); + this.insertAfterTag(tagElm); + }); // if "selection" exists, assumes intention of inecting the new tag at the last + // saved location of the caret inside "this.DOM.input" + + if (selection) { + this.injectAtCaret(frag); + } // else, create a range and inject the new tag as the last child of "this.DOM.input" + else { + this.DOM.input.focus(); + selection = this.setStateSelection(); + selection.range.setStart(this.DOM.input, selection.range.endOffset); + selection.range.setEnd(this.DOM.input, selection.range.endOffset); + this.DOM.input.appendChild(frag); + this.updateValueByDOMTags(); // updates internal "this.value" + + this.update(); // updates original input/textarea + } - scope.setAttribute("aria-expanded", false); - dropdown.parentNode.removeChild(dropdown); - this.state.dropdown.visible = false; - this.state.ddItemData = null; - this.state.ddItemElm = null; - this.trigger("dropdown:hide", dropdown); + return frag; }, /** - * fill data into the suggestions list (mainly used to update the list when removing tags, so they will be re-added to the list. not efficient) + * 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] */ - refilter: function refilter() { - this.suggestedListItems = this.dropdown.filterListItems.call(this, ''); - var listHTML = this.dropdown.createListHTML.call(this, this.suggestedListItems); - this.DOM.dropdown.content.innerHTML = this.minify(listHTML); - }, - position: function position(ddHeight) { - var isBelowViewport, - rect, - top, - bottom, - left, - width, - ddElm = this.DOM.dropdown; - if (!this.state.dropdown.visible) return; + prefixedTextToTag(tagItem) { + var _s = this.settings, + tagElm, + createdFromDelimiters = this.state.tag.delimiters; - if (this.settings.dropdown.position == 'text') { - rect = this.getCaretGlobalPosition(); - bottom = rect.bottom; - top = rect.top; - left = rect.left; - width = 'auto'; - } else { - rect = this.DOM.scope.getBoundingClientRect(); - top = rect.top; - bottom = rect.bottom - 1; - left = rect.left; - width = rect.width + "px"; - } + _s.transformTag.call(this, tagItem); - top = Math.floor(top); - bottom = Math.ceil(bottom); - isBelowViewport = document.documentElement.clientHeight - bottom < (ddHeight || ddElm.clientHeight); // flip vertically if there is no space for the dropdown below the input + 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 - ddElm.style.cssText = "left:" + (left + window.pageXOffset) + "px; width:" + width + ";" + (isBelowViewport ? "bottom:" + (document.documentElement.clientHeight - top - window.pageYOffset - 2) + "px;" : "top: " + (bottom + window.pageYOffset) + "px"); - ddElm.setAttribute('placement', isBelowViewport ? "top" : "bottom"); - }, - events: { - /** - * Events should only be binded when the dropdown is rendered and removed when isn't - * @param {Boolean} bindUnbind [optional. true when wanting to unbind all the events] - */ - binding: function binding() { - var bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; + 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 - // references to the ".bind()" methods must be saved so they could be unbinded later - var _CB = this.dropdown.events.callbacks, - _CBR = this.listeners.dropdown = this.listeners.dropdown || { - position: this.dropdown.position.bind(this), - onKeyDown: _CB.onKeyDown.bind(this), - onMouseOver: _CB.onMouseOver.bind(this), - onMouseLeave: _CB.onMouseLeave.bind(this), - onClick: _CB.onClick.bind(this) - }, - action = bindUnbind ? 'addEventListener' : 'removeEventListener'; + if (!this.replaceTextWithNode(tagElm)) { + this.DOM.input.appendChild(tagElm); + } - if (this.settings.dropdown.position != 'manual') { - window[action]('resize', _CBR.position); - window[action]('keydown', _CBR.onKeyDown); - } // window[action]('mousedown', _CBR.onClick); + setTimeout(() => tagElm.classList.add(this.settings.classNames.tagNoAnimation), 300); + this.value.push(tagItem); + this.update(); + if (!createdFromDelimiters) { + var elm = this.insertAfterTag(tagElm) || tagElm; + this.placeCaretAfterNode(elm); + } - this.DOM.dropdown[action]('mouseover', _CBR.onMouseOver); - this.DOM.dropdown[action]('mouseleave', _CBR.onMouseLeave); - this.DOM.dropdown[action]('mousedown', _CBR.onClick); // add back the main "click" event because it is needed for removing/clicking already-existing tags, even if dropdown is shown + this.state.tag = null; + this.trigger('add', extend({}, { + tag: tagElm + }, { + data: tagItem + })); + return tagElm; + }, - this.DOM[this.listeners.main.click[0]][action]('click', this.listeners.main.click[1]); - }, - callbacks: { - onKeyDown: function onKeyDown(e) { - // get the "active" element, and if there was none (yet) active, use first child - var activeListElm = this.DOM.dropdown.querySelector("[class$='--active']"), - selectedElm = activeListElm; + /** + * appened (validated) tag to the component's DOM scope + */ + appendTag(tagElm) { + var DOM = this.DOM, + insertBeforeNode = DOM.scope.lastElementChild; + if (insertBeforeNode === DOM.input) DOM.scope.insertBefore(tagElm, insertBeforeNode);else DOM.scope.appendChild(tagElm); + }, - switch (e.key) { - case 'ArrowDown': - case 'ArrowUp': - case 'Down': // >IE11 + /** + * 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 - case 'Up': - { - // >IE11 - e.preventDefault(); - var dropdownItems; - if (selectedElm) selectedElm = selectedElm[(e.key == 'ArrowUp' || e.key == 'Up' ? "previous" : "next") + "ElementSibling"]; // if no element was found, loop + tagElm = this.parseTemplate('tag', [templateData]); // crucial for proper caret placement when deleting content. if textNodes are allowed as children of + // a tag element, a browser bug casues the caret to misplaced inside the tag element (especcially affects "readonly" tags) - if (!selectedElm) { - dropdownItems = this.DOM.dropdown.content.children; - selectedElm = dropdownItems[e.key == 'ArrowUp' || e.key == 'Up' ? dropdownItems.length - 1 : 0]; - } + removeTextChildNodes(tagElm); // while( tagElm.lastChild.nodeType == 3 ) + // tagElm.lastChild.parentNode.removeChild(tagElm.lastChild) - this.dropdown.highlightOption.call(this, selectedElm, true); - break; - } + this.tagData(tagElm, tagData); + return tagElm; + }, - case 'Escape': - case 'Esc': - // IE11 - this.dropdown.hide.call(this); - break; + /** + * re-check all invalid tags. + * called after a tag was edited or removed + */ + reCheckInvalidTags() { + var _s = this.settings; + this.getTagElms(_s.classNames.tagNotAllowed).forEach((tagElm, i) => { + var tagData = this.tagData(tagElm), + hasMaxTags = this.hasMaxTags(), + tagValidation = this.validateTag(tagData); // if the tag has become valid + + if (tagValidation === true && !hasMaxTags) { + tagData = tagData.__preInvalidData ? tagData.__preInvalidData : { + value: tagData.value + }; + return this.replaceTag(tagElm, tagData); + } // if the tag is still invaild, set its title as such (reson of invalid might have changed) + + + tagElm.title = hasMaxTags || tagValidation; + }); + }, - case 'ArrowRight': - if (this.state.actions.ArrowLeft) return; + /** + * Removes a tag + * @param {Array|Node|String} tagElms [DOM element(s) or a String value. if undefined or null, remove last added tag] + * @param {Boolean} silent [A flag, which when turned on, does not remove any value and does not update the original input value but simply removes the tag from tagify] + * @param {Number} tranDuration [Transition duration in MS] + * TODO: Allow multiple tags to be removed at-once + */ + removeTags(tagElms, silent, tranDuration) { + var tagsToRemove; + tagElms = tagElms && tagElms instanceof HTMLElement ? [tagElms] : tagElms instanceof Array ? tagElms : tagElms ? [tagElms] : [this.getLastTag()]; // normalize tagElms array values: + // 1. removing invalid items + // 2, if an item is String try to get the matching Tag HTML node + // 3. get the tag data + // 4. return a collection of Objects + + tagsToRemove = tagElms.reduce((elms, tagElm) => { + if (tagElm && typeof tagElm == 'string') tagElm = this.getTagElmByValue(tagElm); + var tagData = this.tagData(tagElm); + if (tagElm && tagData && !tagData.readonly) // make sure it's a tag and not some other node + // because the DOM node might be removed by async animation, the state will be updated while + // the node might still be in the DOM, so the "update" method should know which nodes to ignore + elms.push({ + node: tagElm, + idx: this.getTagIdx(tagData), + // this.getNodeIndex(tagElm); // this.getTagIndexByValue(tagElm.textContent) + data: this.tagData(tagElm, { + '__removed': true + }) + }); + return elms; + }, []); + tranDuration = typeof tranDuration == "number" ? tranDuration : this.CSSVars.tagHideTransition; - case 'Tab': - { - e.preventDefault(); // in mix-mode, treat arrowRight like Enter key, so a tag will be created + if (this.settings.mode == 'select') { + tranDuration = 0; + this.input.set.call(this); + } // if only a single tag is to be removed - if (this.settings.mode != 'mix' && !this.settings.autoComplete.rightKey) { - try { - var value = selectedElm ? selectedElm.textContent : this.suggestedListItems[0].value; - this.input.autocomplete.set.call(this, value); - } catch (err) {} - return false; - } - } + if (tagsToRemove.length == 1) { + if (tagsToRemove[0].node.classList.contains(this.settings.classNames.tagNotAllowed)) silent = true; + } - case 'Enter': - { - e.preventDefault(); - this.dropdown.selectOption.call(this, activeListElm); - break; - } + if (!tagsToRemove.length) return; + return this.settings.hooks.beforeRemoveTag(tagsToRemove, { + tagify: this + }).then(() => { + function removeNode(tag) { + if (!tag.node.parentNode) return; + tag.node.parentNode.removeChild(tag.node); + + if (!silent) { + // this.removeValueById(tagData.__uid) + this.trigger('remove', { + tag: tag.node, + index: tag.idx, + data: tag.data + }); + this.dropdown.refilter(); + this.dropdown.position(); + this.DOM.input.normalize(); // best-practice when in mix-mode (safe to do always anyways) + // check if any of the current tags which might have been marked as "duplicate" should be un-marked + + if (this.settings.keepInvalidTags) this.reCheckInvalidTags(); + } else if (this.settings.keepInvalidTags) this.trigger('remove', { + tag: tag.node, + index: tag.idx + }); + } - case 'Backspace': - { - if (this.settings.mode == 'mix' || this.state.editing.scope) return; + 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 - var _value = this.input.value.trim(); + tag.node.classList.add(this.settings.classNames.tagHide); // manual timeout (hack, since transitionend cannot be used because of hover) - if (_value == "" || _value.charCodeAt(0) == 8203) { - if (this.settings.backspace === true) this.removeTag();else if (this.settings.backspace == 'edit') setTimeout(this.editTag.bind(this), 0); - } - } - } - }, - onMouseOver: function onMouseOver(e) { - var ddItem = e.target.closest('.tagify__dropdown__item'); // event delegation check + setTimeout(removeNode.bind(this), tranDuration, tag); + } - ddItem && this.dropdown.highlightOption.call(this, ddItem); - }, - onMouseLeave: function onMouseLeave(e) { - // de-highlight any previously highlighted option - this.dropdown.highlightOption.call(this); - }, - onClick: function onClick(e) { - if (e.button != 0 || e.target == this.DOM.dropdown) return; // allow only mouse left-clicks + if (tranDuration && tranDuration > 10 && tagsToRemove.length == 1) animation.call(this, tagsToRemove[0]);else tagsToRemove.forEach(removeNode.bind(this)); // update state regardless of animation - var listItemElm = e.target.closest(".tagify__dropdown__item"); - this.dropdown.selectOption.call(this, listItemElm); + if (!silent) { + this.removeTagsFromValue(tagsToRemove.map(tag => tag.node)); + this.update(); // update the original input with the current value + + if (this.settings.mode == 'select') this.setContentEditable(true); } - } + }).catch(reason => {}); + }, + + removeTagsFromDOM() { + [].slice.call(this.getTagElms()).forEach(elm => elm.parentNode.removeChild(elm)); }, /** - * 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 + * @param {Array/Node} tags to be removed from the this.value array */ - highlightOption: function highlightOption(elm, adjustScroll) { - var className = "tagify__dropdown__item--active", - itemData; // focus casues a bug in Firefox with the placeholder been shown on the input element - // if( this.settings.dropdown.position != 'manual' ) - // elm.focus(); + removeTagsFromValue(tags) { + tags = Array.isArray(tags) ? tags : [tags]; + tags.forEach(tag => { + var tagData = this.tagData(tag), + tagIdx = this.getTagIdx(tagData); // delete tagData.__removed - if (this.state.ddItemElm) { - this.state.ddItemElm.classList.remove(className); - this.state.ddItemElm.removeAttribute("aria-selected"); - } + if (tagIdx > -1) this.value.splice(tagIdx, 1); + }); + }, - if (!elm) { - this.state.ddItemData = null; - this.state.ddItemElm = null; - this.input.autocomplete.suggest.call(this); - return; - } + removeAllTags(opts) { + opts = opts || {}; + this.value = []; + if (this.settings.mode == 'mix') this.DOM.input.innerHTML = '';else this.removeTagsFromDOM(); + this.dropdown.position(); - itemData = this.suggestedListItems[this.getNodeIndex(elm)]; - this.state.ddItemData = itemData; - this.state.ddItemElm = elm; // this.DOM.dropdown.querySelectorAll("[class$='--active']").forEach(activeElm => activeElm.classList.remove(className)); + 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" - 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); - if (this.settings.dropdown.position != 'manual') this.dropdown.position.call(this); // suggestions might alter the height of the tagify wrapper because of unkown suggested term length that could drop to the next line - } + this.update(opts); }, - /** - * Create a tag from the currently active suggestion option - * @param {Object} elm DOM node to select - */ - selectOption: function selectOption(elm) { - var _this13 = this; + postUpdate() { + var classNames = this.settings.classNames, + hasValue = this.settings.mode == 'mix' ? this.settings.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value.trim() : this.value.length + this.input.raw.call(this).length; + this.toggleClass(classNames.hasMaxTags, this.value.length >= this.settings.maxTags); + this.toggleClass(classNames.hasNoTags, !this.value.length); + this.toggleClass(classNames.empty, !hasValue); + }, - if (!elm) return; // temporary set the "actions" state to indicate to the main "blur" event it shouldn't run + setOriginalInputValue(v) { + var inputElm = this.DOM.originalInput; - this.state.actions.selectOption = true; - setTimeout(function () { - return _this13.state.actions.selectOption = false; - }, 50); - var hideDropdown = this.settings.dropdown.closeOnSelect, - value = this.suggestedListItems[this.getNodeIndex(elm)] || this.input.value; - this.trigger("dropdown:select", value); - this.addTags([value], true); // Tagify instances should re-focus to the input element once an option was selected, to allow continuous typing + 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. - setTimeout(function () { - _this13.DOM.input.focus(); + this.setPersistedData(v, 'value'); + } + }, - _this13.toggleFocusClass(true); - }); + /** + * update the origianl (hidden) input field's value + * see - https://stackoverflow.com/q/50957841/104380 + */ + update(args) { + var inputValue = this.getInputValue(); + this.setOriginalInputValue(inputValue); + this.postUpdate(); + if (!(args || {}).withoutChangeEvent && !this.state.blockChangeEvent) this.triggerChangeEvent(); + }, - if (hideDropdown) { - this.dropdown.hide.call(this); // setTimeout(() => this.events.callbacks.onFocusBlur.call(this, {type:"blur"}), 60) - } + 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) : ""; }, /** - * returns an HTML string of the suggestions' list items - * @param {string} value string to filter the whitelist by - * @return {Array} list of filtered whitelist items according to the settings provided and current value + * removes properties from `this.value` which are only used internally */ - filterListItems: function filterListItems(value) { - var _this14 = this; + getCleanValue(v) { + return removeCollectionProp(v || this.value, this.dataProps); + }, - var _s = this.settings, - list = [], - whitelist = _s.whitelist, - suggestionsCount = _s.dropdown.maxItems || Infinity, - searchKeys = _s.dropdown.searchKeys.concat(["searchBy", "value"]), - whitelistItem, - valueIsInWhitelist, - whitelistItemValueIndex, - searchBy, - isDuplicate, - i = 0; + getMixedTagsAsString() { + var result = "", + that = this, + _interpolator = this.settings.mixTagsInterpolator; - if (!value) { - return (_s.duplicates ? whitelist : whitelist.filter(function (item) { - return !_this14.isTagDuplicate(item.value || item); - }) // don't include tags which have already been added. - ).slice(0, suggestionsCount); // respect "maxItems" dropdown setting - } + function iterateChildren(rootNode) { + rootNode.childNodes.forEach(node => { + if (node.nodeType == 1) { + const tagData = that.tagData(node); - for (; i < whitelist.length; i++) { - whitelistItem = whitelist[i] instanceof Object ? whitelist[i] : { - value: whitelist[i] - }; //normalize value as an Object + if (node.tagName == 'BR') { + result += "\r\n"; + } - searchBy = searchKeys.reduce(function (values, k) { - return values + " " + (whitelistItem[k] || ""); - }, "").toLowerCase(); - whitelistItemValueIndex = searchBy.indexOf(value.toLowerCase()); - valueIsInWhitelist = _s.dropdown.fuzzySearch ? whitelistItemValueIndex >= 0 : whitelistItemValueIndex == 0; - isDuplicate = !_s.duplicates && this.isTagDuplicate(whitelistItem.value); // match for the value within each "whitelist" item + if (node.tagName == 'DIV' || node.tagName == 'P') { + result += "\r\n"; // if( !node.children.length && node.textContent ) + // result += node.textContent; - if (valueIsInWhitelist && !isDuplicate && suggestionsCount--) list.push(whitelistItem); - if (suggestionsCount == 0) break; + iterateChildren(node); + } else if (isNodeTag.call(that, node) && tagData) { + if (tagData.__removed) return;else result += _interpolator[0] + JSON.stringify(omit(tagData, that.dataProps)) + _interpolator[1]; + } + } else result += node.textContent; + }); } - return list; - }, - - /** - * Creates the dropdown items' HTML - * @param {Array} list [Array of Objects] - * @return {String} - */ - createListHTML: function createListHTML(optionsArr) { - var template = this.settings.templates.dropdownItem.bind(this); - return this.minify(optionsArr.map(template).join("")); + iterateChildren(this.DOM.input); + return result; } - } -}; -return Tagify; -})); + + }; // legacy support for changed methods names + + Tagify.prototype.removeTag = Tagify.prototype.removeTags; + + return Tagify; + +}))); diff --git a/server/user.mediacube.gui/js/tagify.min.js b/server/user.mediacube.gui/js/tagify.min.js index 24c19a9b..680ddaed 100644 --- a/server/user.mediacube.gui/js/tagify.min.js +++ b/server/user.mediacube.gui/js/tagify.min.js @@ -1,7 +1,8 @@ /** - * Tagify (v 3.2.6)- tags input component + * Tagify (v 4.9.8) - tags input component * By Yair Even-Or * Don't sell this code. (c) * https://github.com/yairEO/tagify */ -!function(t,e){"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?module.exports=e():t.Tagify=e()}(this,function(){"use strict";function u(t){return function(t){if(Array.isArray(t)){for(var e=0,i=new Array(t.length);e