Linux dpw.dpwebtech.com 3.10.0-1160.88.1.el7.x86_64 #1 SMP Tue Mar 7 15:41:52 UTC 2023 x86_64
Apache
: 192.232.243.69 | : 18.226.187.60
54 Domain
7.3.33
dpclient
www.github.com/MadExploits
Terminal
AUTO ROOT
Adminer
Backdoor Destroyer
Linux Exploit
Lock Shell
Lock File
Create User
CREATE RDP
PHP Mailer
BACKCONNECT
UNLOCK SHELL
HASH IDENTIFIER
CPANEL RESET
CREATE WP USER
README
+ Create Folder
+ Create File
/
home /
dpclient /
public_html /
milovo /
assets /
tagify /
[ HOME SHELL ]
Name
Size
Permission
Action
.pkexec
[ DIR ]
drwxr-xr-x
GCONV_PATH=.
[ DIR ]
drwxr-xr-x
.mad-root
0
B
-rw-r--r--
jQuery.tagify.min.js
60
KB
-rw-r--r--
pwnkit
10.99
KB
-rwxr-xr-x
react.tagify.js
6.66
KB
-rw-r--r--
tagify.css
10.61
KB
-rw-r--r--
tagify.esm.js
144.81
KB
-rw-r--r--
tagify.js
151.69
KB
-rw-r--r--
tagify.min.js
58.3
KB
-rw-r--r--
tagify.polyfills.min.js
22.73
KB
-rw-r--r--
tagify.vue
532
B
-rw-r--r--
Delete
Unzip
Zip
${this.title}
Close
Code Editor : tagify.esm.js
/** * Tagify (v 4.17.8) - tags input component * By undefined * https://github.com/yairEO/tagify * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * * THE SOFTWARE IS NOT PERMISSIBLE TO BE SOLD. */ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } function _objectSpread2(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } // console.json = console.json || function(argument){ // for(var arg=0; arg < arguments.length; ++arg) // console.log( JSON.stringify(arguments[arg], null, 4) ) // } // const isEdge = /Edge/.test(navigator.userAgent) const sameStr = (s1, s2, caseSensitive, trim) => { // cast to String s1 = "" + s1; s2 = "" + s2; if (trim) { s1 = s1.trim(); s2 = s2.trim(); } return caseSensitive ? s1 == s2 : s1.toLowerCase() == s2.toLowerCase(); }; // const getUID = () => (new Date().getTime() + Math.floor((Math.random()*10000)+1)).toString(16) const removeCollectionProp = (collection, unwantedProps) => collection && Array.isArray(collection) && collection.map(v => omit(v, unwantedProps)); function omit(obj, props) { var newObj = {}, p; for (p in obj) if (props.indexOf(p) < 0) newObj[p] = obj[p]; return newObj; } function decode(s) { var el = document.createElement('div'); return s.replace(/\&#?[0-9a-z]+;/gi, function (enc) { el.innerHTML = enc; return el.innerText; }); } /** * utility method * https://stackoverflow.com/a/35385518/104380 * @param {String} s [HTML string] * @return {Object} [DOM node] */ function parseHTML(s) { var parser = new DOMParser(), node = parser.parseFromString(s.trim(), "text/html"); return node.body.firstElementChild; } /** * Removed new lines and irrelevant spaces which might affect layout, and are better gone * @param {string} s [HTML string] */ function minify(s) { return s ? s.replace(/\>[\r\n ]+\</g, "><").replace(/(<.*?>)|\s+/g, (m, $1) => $1 ? $1 : ' ') // https://stackoverflow.com/a/44841484/104380 : ""; } function removeTextChildNodes(elm) { var iter = document.createNodeIterator(elm, NodeFilter.SHOW_TEXT, null, false), textnode; // print all text nodes while (textnode = iter.nextNode()) { if (!textnode.textContent.trim()) textnode.parentNode.removeChild(textnode); } } function getfirstTextNode(elm, action) { action = action || 'previous'; while (elm = elm[action + 'Sibling']) if (elm.nodeType == 3) return elm; } /** * utility method * https://stackoverflow.com/a/6234804/104380 */ function escapeHTML(s) { return typeof s == 'string' ? s.replace(/&/g, "&").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); } /** * Get the caret position relative to the viewport * https://stackoverflow.com/q/58985076/104380 * * @returns {object} left, top distance in pixels */ function getCaretGlobalPosition() { const sel = document.getSelection(); if (sel.rangeCount) { const r = sel.getRangeAt(0); const node = r.startContainer; const offset = r.startOffset; let rect, r2; if (offset > 0) { r2 = document.createRange(); r2.setStart(node, offset - 1); r2.setEnd(node, offset); rect = r2.getBoundingClientRect(); return { left: rect.right, top: rect.top, bottom: rect.bottom }; } if (node.getBoundingClientRect) return node.getBoundingClientRect(); } return { left: -9999, top: -9999 }; } /** * Injects content (either string or node) at the current the current (or specificed) caret position * @param {content} string/node * @param {range} Object (optional, a range other than the current window selection) */ function injectAtCaret(content, range) { var selection = window.getSelection(); range = range || selection.getRangeAt(0); if (typeof content == 'string') content = document.createTextNode(content); if (range) { range.deleteContents(); range.insertNode(content); } return content; } /** Setter/Getter * Each tag DOM node contains a custom property called "__tagifyTagData" which hosts its data * @param {Node} tagElm * @param {Object} data */ function getSetTagData(tagElm, data, override) { if (!tagElm) { console.warn("tag element doesn't exist", tagElm, data); return data; } if (data) tagElm.__tagifyTagData = override ? data : extend({}, tagElm.__tagifyTagData || {}, data); return tagElm.__tagifyTagData; } var DEFAULTS = { delimiters: ",", // [RegEx] split tags by any of these delimiters ("null" to cancel) Example: ",| |." pattern: null, // RegEx pattern to validate input by. Ex: /[1-9]/ tagTextProp: 'value', // tag data Object property which will be displayed as the tag's text maxTags: Infinity, // Maximum number of tags callbacks: {}, // Exposed callbacks object to be triggered on certain events addTagOnBlur: true, // automatically adds the text which was inputed as a tag when blur event happens onChangeAfterBlur: true, // By default, the native way of inputs' onChange events is kept, and it only fires when the field is blured. duplicates: false, // "true" - allow duplicate tags whitelist: [], // Array of tags to suggest as the user types (can be used along with "enforceWhitelist" setting) blacklist: [], // A list of non-allowed tags enforceWhitelist: false, // Only allow tags from the whitelist userInput: true, // disable manually typing/pasting/editing tags (tags may only be added from the whitelist) keepInvalidTags: false, // if true, do not remove tags which did not pass validation createInvalidTags: true, // if false, do not create invalid tags from invalid user input mixTagsAllowedAfter: /,|\.|\:|\s/, // RegEx - Define conditions in which mix-tags content allows a tag to be added after mixTagsInterpolator: ['[[', ']]'], // Interpolation for mix mode. Everything between these will become a tag, if is a valid Object backspace: true, // false / true / "edit" skipInvalid: false, // If `true`, do not add invalid, temporary, tags before automatically removing them pasteAsTags: true, // automatically converts pasted text into tags. if "false", allows for further text editing editTags: { clicks: 2, // clicks to enter "edit-mode": 1 for single click. any other value is considered as double-click keepInvalid: true // keeps invalid edits as-is until `esc` is pressed while in focus }, // 1 or 2 clicks to edit a tag. false/null for not allowing editing transformTag: () => {}, // Takes a tag input string as argument and returns a transformed value trim: true, // whether or not the value provided should be trimmed, before being added as a tag a11y: { focusableTags: false }, mixMode: { insertAfterTag: '\u00A0' // String/Node to inject after a tag has been added (see #588) }, autoComplete: { enabled: true, // Tries to suggest the input's value while typing (match from whitelist) by adding the rest of term as grayed-out text rightKey: false // If `true`, when Right key is pressed, use the suggested value to create a tag, else just auto-completes the input. in mixed-mode this is set to "true" }, classNames: { namespace: 'tagify', mixMode: 'tagify--mix', selectMode: 'tagify--select', input: 'tagify__input', focus: 'tagify--focus', tagNoAnimation: 'tagify--noAnim', tagInvalid: 'tagify--invalid', tagNotAllowed: 'tagify--notAllowed', scopeLoading: 'tagify--loading', hasMaxTags: 'tagify--hasMaxTags', hasNoTags: 'tagify--noTags', empty: 'tagify--empty', inputInvalid: 'tagify__input--invalid', dropdown: 'tagify__dropdown', dropdownWrapper: 'tagify__dropdown__wrapper', dropdownHeader: 'tagify__dropdown__header', dropdownFooter: 'tagify__dropdown__footer', dropdownItem: 'tagify__dropdown__item', dropdownItemActive: 'tagify__dropdown__item--active', dropdownItemHidden: 'tagify__dropdown__item--hidden', dropdownInital: 'tagify__dropdown--initial', tag: 'tagify__tag', tagText: 'tagify__tag-text', tagX: 'tagify__tag__removeBtn', tagLoading: 'tagify__tag--loading', tagEditing: 'tagify__tag--editable', tagFlash: 'tagify__tag--flash', tagHide: 'tagify__tag--hide' }, dropdown: { classname: '', enabled: 2, // minimum input characters to be typed for the suggestions dropdown to show maxItems: 10, searchKeys: ["value", "searchBy"], fuzzySearch: true, caseSensitive: false, accentedSearch: true, includeSelectedTags: false, // Should the suggestions list Include already-selected tags (after filtering) highlightFirst: false, // highlights first-matched item in the list closeOnSelect: true, // closes the dropdown after selecting an item, if `enabled:0` (which means always show dropdown) clearOnSelect: true, // after selecting a suggetion, should the typed text input remain or be cleared position: 'all', // 'manual' / 'text' / 'all' appendTarget: null // defaults to document.body once DOM has been loaded }, hooks: { beforeRemoveTag: () => Promise.resolve(), beforePaste: () => Promise.resolve(), suggestionClick: () => Promise.resolve() } }; function initDropdown() { this.dropdown = {}; // auto-bind "this" to all the dropdown methods for (let p in this._dropdown) this.dropdown[p] = typeof this._dropdown[p] === 'function' ? this._dropdown[p].bind(this) : this._dropdown[p]; this.dropdown.refs(); } var _dropdown = { refs() { this.DOM.dropdown = this.parseTemplate('dropdown', [this.settings]); this.DOM.dropdown.content = this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-wrapper']"); }, getHeaderRef() { return this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-header']"); }, getFooterRef() { return this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-footer']"); }, getAllSuggestionsRefs() { return [...this.DOM.dropdown.content.querySelectorAll(this.settings.classNames.dropdownItemSelector)]; }, /** * shows the suggestions select box * @param {String} value [optional, filter the whitelist by this value] */ show(value) { var _s = this.settings, firstListItem, firstListItemValue, allowNewTags = _s.mode == 'mix' && !_s.enforceWhitelist, noWhitelist = !_s.whitelist || !_s.whitelist.length, noMatchListItem, isManual = _s.dropdown.position == 'manual'; // if text still exists in the input, and `show` method has no argument, then the input's text should be used value = value === undefined ? this.state.inputText : value; // ⚠️ Do not render suggestions list if: // 1. there's no whitelist (can happen while async loading) AND new tags arn't allowed // 2. dropdown is disabled // 3. loader is showing (controlled outside of this code) if (noWhitelist && !allowNewTags && !_s.templates.dropdownItemNoMatch || _s.dropdown.enable === false || this.state.isLoading || this.settings.readonly) return; clearTimeout(this.dropdownHide__bindEventsTimeout); // if no value was supplied, show all the "whitelist" items in the dropdown // @type [Array] listItems // TODO: add a Setting to control items' sort order for "listItems" this.suggestedListItems = this.dropdown.filterListItems(value); // trigger at this exact point to let the developer the chance to manually set "this.suggestedListItems" if (value && !this.suggestedListItems.length) { this.trigger('dropdown:noMatch', value); if (_s.templates.dropdownItemNoMatch) noMatchListItem = _s.templates.dropdownItemNoMatch.call(this, { value }); } // if "dropdownItemNoMatch" was no defined, procceed regular flow. // if (!noMatchListItem) { // in mix-mode, if the value isn't included in the whilelist & "enforceWhitelist" setting is "false", // then add a custom suggestion item to the dropdown if (this.suggestedListItems.length) { if (value && allowNewTags && !this.state.editing.scope && !sameStr(this.suggestedListItems[0].value, value)) this.suggestedListItems.unshift({ value }); } else { if (value && allowNewTags && !this.state.editing.scope) { this.suggestedListItems = [{ value }]; } // hide suggestions list if no suggestion matched else { this.input.autocomplete.suggest.call(this); this.dropdown.hide(); return; } } firstListItem = this.suggestedListItems[0]; firstListItemValue = "" + (isObject(firstListItem) ? firstListItem.value : firstListItem); if (_s.autoComplete && firstListItemValue) { // only fill the sugegstion if the value of the first list item STARTS with the input value (regardless of "fuzzysearch" setting) if (firstListItemValue.indexOf(value) == 0) this.input.autocomplete.suggest.call(this, firstListItem); } } this.dropdown.fill(noMatchListItem); if (_s.dropdown.highlightFirst) { this.dropdown.highlightOption(this.DOM.dropdown.content.querySelector(_s.classNames.dropdownItemSelector)); } // bind events, exactly at this stage of the code. "dropdown.show" method is allowed to be // called multiple times, regardless if the dropdown is currently visible, but the events-binding // should only be called if the dropdown wasn't previously visible. if (!this.state.dropdown.visible) // timeout is needed for when pressing arrow down to show the dropdown, // so the key event won't get registered in the dropdown events listeners setTimeout(this.dropdown.events.binding.bind(this)); // set the dropdown visible state to be the same as the searched value. // MUST be set *before* position() is called this.state.dropdown.visible = value || true; this.state.dropdown.query = value; this.setStateSelection(); // try to positioning the dropdown (it might not yet be on the page, doesn't matter, next code handles this) if (!isManual) { // a slight delay is needed if the dropdown "position" setting is "text", and nothing was typed in the input, // so sadly the "getCaretGlobalPosition" method doesn't recognize the caret position without this delay setTimeout(() => { this.dropdown.position(); this.dropdown.render(); }); } // a delay is needed because of the previous delay reason. // this event must be fired after the dropdown was rendered & positioned setTimeout(() => { this.trigger("dropdown:show", this.DOM.dropdown); }); }, /** * Hides the dropdown (if it's not managed manually by the developer) * @param {Boolean} overrideManual */ hide(overrideManual) { var _this$DOM = this.DOM, scope = _this$DOM.scope, dropdown = _this$DOM.dropdown, isManual = this.settings.dropdown.position == 'manual' && !overrideManual; // if there's no dropdown, this means the dropdown events aren't binded if (!dropdown || !document.body.contains(dropdown) || isManual) return; window.removeEventListener('resize', this.dropdown.position); this.dropdown.events.binding.call(this, false); // unbind all events // if the dropdown is open, and the input (scope) is clicked, // the dropdown should be now "close", and the next click (on the scope) // should re-open it, and without a timeout, clicking to close will re-open immediately // clearTimeout(this.dropdownHide__bindEventsTimeout) // this.dropdownHide__bindEventsTimeout = setTimeout(this.events.binding.bind(this), 250) // re-bind main events scope.setAttribute("aria-expanded", false); dropdown.parentNode.removeChild(dropdown); // scenario: clicking the scope to show the dropdown, clicking again to hide -> calls dropdown.hide() and then re-focuses the input // which casues another onFocus event, which checked "this.state.dropdown.visible" and see it as "false" and re-open the dropdown setTimeout(() => { this.state.dropdown.visible = false; }, 100); this.state.dropdown.query = this.state.ddItemData = this.state.ddItemElm = this.state.selection = null; // if the user closed the dropdown (in mix-mode) while a potential tag was detected, flag the current tag // so the dropdown won't be shown on following user input for that "tag" if (this.state.tag && this.state.tag.value.length) { this.state.flaggedTags[this.state.tag.baseOffset] = this.state.tag; } this.trigger("dropdown:hide", dropdown); return this; }, /** * Toggles dropdown show/hide * @param {Boolean} show forces the dropdown to show */ toggle(show) { this.dropdown[this.state.dropdown.visible && !show ? 'hide' : 'show'](); }, render() { // let the element render in the DOM first, to accurately measure it. // this.DOM.dropdown.style.cssText = "left:-9999px; top:-9999px;"; var ddHeight = getNodeHeight(this.DOM.dropdown), _s = this.settings, enabled = typeof _s.dropdown.enabled == 'number' && _s.dropdown.enabled >= 0; if (!enabled) return this; this.DOM.scope.setAttribute("aria-expanded", true); // if the dropdown has yet to be appended to the DOM, // append the dropdown to the body element & handle events if (!document.body.contains(this.DOM.dropdown)) { this.DOM.dropdown.classList.add(_s.classNames.dropdownInital); this.dropdown.position(ddHeight); _s.dropdown.appendTarget.appendChild(this.DOM.dropdown); setTimeout(() => this.DOM.dropdown.classList.remove(_s.classNames.dropdownInital)); } return this; }, /** * re-renders the dropdown content element (see "dropdownContent" in templates file) * @param {String/Array} HTMLContent - optional */ fill(HTMLContent) { HTMLContent = typeof HTMLContent == 'string' ? HTMLContent : this.dropdown.createListHTML(HTMLContent || this.suggestedListItems); var dropdownContent = this.settings.templates.dropdownContent.call(this, HTMLContent); this.DOM.dropdown.content.innerHTML = minify(dropdownContent); }, /** * Re-renders only the header & footer. * Used when selecting a suggestion and it is wanted that the suggestions dropdown stays open. * Since the list of sugegstions is not being re-rendered completely every time a suggestion is selected (the item is transitioned-out) * then the header & footer should be kept in sync with the suggestions data change */ fillHeaderFooter() { var suggestions = this.dropdown.filterListItems(this.state.dropdown.query), newHeaderElem = this.parseTemplate('dropdownHeader', [suggestions]), newFooterElem = this.parseTemplate('dropdownFooter', [suggestions]), headerRef = this.dropdown.getHeaderRef(), footerRef = this.dropdown.getFooterRef(); newHeaderElem && headerRef?.parentNode.replaceChild(newHeaderElem, headerRef); newFooterElem && footerRef?.parentNode.replaceChild(newFooterElem, footerRef); }, /** * fill data into the suggestions list * (mainly used to update the list when removing tags while the suggestions dropdown is visible, so they will be re-added to the list. not efficient) */ refilter(value) { value = value || this.state.dropdown.query || ''; this.suggestedListItems = this.dropdown.filterListItems(value); this.dropdown.fill(); if (!this.suggestedListItems.length) this.dropdown.hide(); this.trigger("dropdown:updated", this.DOM.dropdown); }, position(ddHeight) { var _sd = this.settings.dropdown; if (_sd.position == 'manual') return; var rect, top, bottom, left, width, parentsPositions, ddElm = this.DOM.dropdown, placeAbove = _sd.placeAbove, isDefaultAppendTarget = _sd.appendTarget === document.body, appendTargetScrollTop = isDefaultAppendTarget ? window.pageYOffset : _sd.appendTarget.scrollTop, root = document.fullscreenElement || document.webkitFullscreenElement || document.documentElement, viewportHeight = root.clientHeight, viewportWidth = Math.max(root.clientWidth || 0, window.innerWidth || 0), positionTo = viewportWidth > 480 ? _sd.position : 'all', ddTarget = this.DOM[positionTo == 'input' ? 'input' : 'scope']; ddHeight = ddHeight || ddElm.clientHeight; function getParentsPositions(p) { var left = 0, top = 0; // when in element-fullscreen mode, do not go above the fullscreened-element while (p && p != root) { left += p.offsetLeft || 0; top += p.offsetTop || 0; p = p.parentNode; } return { left, top }; } function getAccumulatedAncestorsScrollTop() { var scrollTop = 0, p = _sd.appendTarget.parentNode; while (p) { scrollTop += p.scrollTop || 0; p = p.parentNode; } return scrollTop; } if (!this.state.dropdown.visible) return; if (positionTo == 'text') { rect = getCaretGlobalPosition(); bottom = rect.bottom; top = rect.top; left = rect.left; width = 'auto'; } else { parentsPositions = getParentsPositions(_sd.appendTarget); rect = ddTarget.getBoundingClientRect(); top = rect.top - parentsPositions.top; bottom = rect.bottom - 1 - parentsPositions.top; left = rect.left - parentsPositions.left; width = rect.width + 'px'; } // if the "append target" isn't the default, correct the `top` variable by ignoring any scrollTop of the target's Ancestors if (!isDefaultAppendTarget) { let accumulatedAncestorsScrollTop = getAccumulatedAncestorsScrollTop(); top += accumulatedAncestorsScrollTop; bottom += accumulatedAncestorsScrollTop; } top = Math.floor(top); bottom = Math.ceil(bottom); placeAbove = placeAbove === undefined ? viewportHeight - rect.bottom < ddHeight : placeAbove; // flip vertically if there is no space for the dropdown below the input ddElm.style.cssText = "left:" + (left + window.pageXOffset) + "px; width:" + width + ";" + (placeAbove ? "top: " + (top + appendTargetScrollTop) + "px" : "top: " + (bottom + appendTargetScrollTop) + "px"); ddElm.setAttribute('placement', placeAbove ? "top" : "bottom"); ddElm.setAttribute('position', positionTo); }, events: { /** * Events should only be binded when the dropdown is rendered and removed when isn't * because there might be multiple Tagify instances on a certain page * @param {Boolean} bindUnbind [optional. true when wanting to unbind all the events] */ binding() { let bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; // references to the ".bind()" methods must be saved so they could be unbinded later var _CB = this.dropdown.events.callbacks, // callback-refs _CBR = this.listeners.dropdown = this.listeners.dropdown || { position: this.dropdown.position.bind(this, null), onKeyDown: _CB.onKeyDown.bind(this), onMouseOver: _CB.onMouseOver.bind(this), onMouseLeave: _CB.onMouseLeave.bind(this), onClick: _CB.onClick.bind(this), onScroll: _CB.onScroll.bind(this) }, action = bindUnbind ? 'addEventListener' : 'removeEventListener'; if (this.settings.dropdown.position != 'manual') { document[action]('scroll', _CBR.position, true); window[action]('resize', _CBR.position); window[action]('keydown', _CBR.onKeyDown); } this.DOM.dropdown[action]('mouseover', _CBR.onMouseOver); this.DOM.dropdown[action]('mouseleave', _CBR.onMouseLeave); this.DOM.dropdown[action]('mousedown', _CBR.onClick); this.DOM.dropdown.content[action]('scroll', _CBR.onScroll); }, callbacks: { onKeyDown(e) { // ignore keys during IME composition if (!this.state.hasFocus || this.state.composing) return; // get the "active" element, and if there was none (yet) active, use first child var selectedElm = this.DOM.dropdown.querySelector(this.settings.classNames.dropdownItemActiveSelector), selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm); switch (e.key) { case 'ArrowDown': case 'ArrowUp': case 'Down': // >IE11 case 'Up': { // >IE11 e.preventDefault(); var dropdownItems = this.dropdown.getAllSuggestionsRefs(), actionUp = e.key == 'ArrowUp' || e.key == 'Up'; if (selectedElm) { selectedElm = this.dropdown.getNextOrPrevOption(selectedElm, !actionUp); } // if no element was found OR current item is not a "real" item, loop if (!selectedElm || !selectedElm.matches(this.settings.classNames.dropdownItemSelector)) { selectedElm = dropdownItems[actionUp ? dropdownItems.length - 1 : 0]; } this.dropdown.highlightOption(selectedElm, true); // selectedElm.scrollIntoView({inline: 'nearest', behavior: 'smooth'}) break; } case 'Escape': case 'Esc': // IE11 this.dropdown.hide(); break; case 'ArrowRight': if (this.state.actions.ArrowLeft) return; case 'Tab': { // in mix-mode, treat arrowRight like Enter key, so a tag will be created if (this.settings.mode != 'mix' && selectedElm && !this.settings.autoComplete.rightKey && !this.state.editing) { e.preventDefault(); // prevents blur so the autocomplete suggestion will not become a tag var value = this.dropdown.getMappedValue(selectedElmData); this.input.autocomplete.set.call(this, value); return false; } return true; } case 'Enter': { e.preventDefault(); this.settings.hooks.suggestionClick(e, { tagify: this, tagData: selectedElmData, suggestionElm: selectedElm }).then(() => { if (selectedElm) { this.dropdown.selectOption(selectedElm); // highlight next option selectedElm = this.dropdown.getNextOrPrevOption(selectedElm, !actionUp); this.dropdown.highlightOption(selectedElm); return; } else this.dropdown.hide(); if (this.settings.mode != 'mix') this.addTags(this.state.inputText.trim(), true); }).catch(err => err); break; } case 'Backspace': { if (this.settings.mode == 'mix' || this.state.editing.scope) return; const value = this.input.raw.call(this); if (value == "" || value.charCodeAt(0) == 8203) { if (this.settings.backspace === true) this.removeTags();else if (this.settings.backspace == 'edit') setTimeout(this.editTag.bind(this), 0); } } } }, onMouseOver(e) { var ddItem = e.target.closest(this.settings.classNames.dropdownItemSelector); // event delegation check ddItem && this.dropdown.highlightOption(ddItem); }, onMouseLeave(e) { // de-highlight any previously highlighted option this.dropdown.highlightOption(); }, onClick(e) { if (e.button != 0 || e.target == this.DOM.dropdown || e.target == this.DOM.dropdown.content) return; // allow only mouse left-clicks var selectedElm = e.target.closest(this.settings.classNames.dropdownItemSelector), selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm); // temporary set the "actions" state to indicate to the main "blur" event it shouldn't run this.state.actions.selectOption = true; setTimeout(() => this.state.actions.selectOption = false, 50); this.settings.hooks.suggestionClick(e, { tagify: this, tagData: selectedElmData, suggestionElm: selectedElm }).then(() => { if (selectedElm) this.dropdown.selectOption(selectedElm, e);else this.dropdown.hide(); }).catch(err => console.warn(err)); }, onScroll(e) { var elm = e.target, pos = elm.scrollTop / (elm.scrollHeight - elm.parentNode.clientHeight) * 100; this.trigger("dropdown:scroll", { percentage: Math.round(pos) }); } } }, /** * Given a suggestion-item, return the data associated with it * @param {HTMLElement} tagElm * @returns Object */ getSuggestionDataByNode(tagElm) { var value = tagElm && tagElm.getAttribute('value'); return this.suggestedListItems.find(item => item.value == value) || null; }, getNextOrPrevOption(selected) { let next = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; var dropdownItems = this.dropdown.getAllSuggestionsRefs(), selectedIdx = dropdownItems.findIndex(item => item === selected); return next ? dropdownItems[selectedIdx + 1] : dropdownItems[selectedIdx - 1]; }, /** * mark the currently active suggestion option * @param {Object} elm option DOM node * @param {Boolean} adjustScroll when navigation with keyboard arrows (up/down), aut-scroll to always show the highlighted element */ highlightOption(elm, adjustScroll) { var className = this.settings.classNames.dropdownItemActive, itemData; // focus casues a bug in Firefox with the placeholder been shown on the input element // if( this.settings.dropdown.position != 'manual' ) // elm.focus(); if (this.state.ddItemElm) { this.state.ddItemElm.classList.remove(className); this.state.ddItemElm.removeAttribute("aria-selected"); } if (!elm) { this.state.ddItemData = null; this.state.ddItemElm = null; this.input.autocomplete.suggest.call(this); return; } itemData = this.dropdown.getSuggestionDataByNode(elm); this.state.ddItemData = itemData; this.state.ddItemElm = elm; // this.DOM.dropdown.querySelectorAll("." + this.settings.classNames.dropdownItemActive).forEach(activeElm => activeElm.classList.remove(className)); elm.classList.add(className); elm.setAttribute("aria-selected", true); if (adjustScroll) elm.parentNode.scrollTop = elm.clientHeight + elm.offsetTop - elm.parentNode.clientHeight; // Try to autocomplete the typed value with the currently highlighted dropdown item if (this.settings.autoComplete) { this.input.autocomplete.suggest.call(this, itemData); this.dropdown.position(); // suggestions might alter the height of the tagify wrapper because of unkown suggested term length that could drop to the next line } }, /** * Create a tag from the currently active suggestion option * @param {Object} elm DOM node to select * @param {Object} event The original Click event, if available (since keyboard ENTER key also triggers this method) */ selectOption(elm, event) { var _this$settings$dropdo = this.settings.dropdown, clearOnSelect = _this$settings$dropdo.clearOnSelect, closeOnSelect = _this$settings$dropdo.closeOnSelect; if (!elm) { this.addTags(this.state.inputText, true); closeOnSelect && this.dropdown.hide(); return; } event = event || {}; // if in edit-mode, do not continue but instead replace the tag's text. // the scenario is that "addTags" was called from a dropdown suggested option selected while editing var value = elm.getAttribute('value'), isNoMatch = value == 'noMatch', tagData = this.suggestedListItems.find(item => (item.value ?? item) == value); // The below event must be triggered, regardless of anything else which might go wrong this.trigger('dropdown:select', { data: tagData, elm, event }); if (!value || !tagData && !isNoMatch) { closeOnSelect && setTimeout(this.dropdown.hide.bind(this)); return; } if (this.state.editing) { // normalizing value, because "tagData" might be a string, and therefore will not be able to extend the object this.onEditTagDone(null, extend({ __isValid: true }, this.normalizeTags([tagData])[0])); } // Tagify instances should re-focus to the input element once an option was selected, to allow continuous typing else { this[this.settings.mode == 'mix' ? "addMixTags" : "addTags"]([tagData || this.input.raw.call(this)], clearOnSelect); } // todo: consider not doing this on mix-mode if (!this.DOM.input.parentNode) return; setTimeout(() => { this.DOM.input.focus(); this.toggleFocusClass(true); }); closeOnSelect && setTimeout(this.dropdown.hide.bind(this)); // hide selected suggestion elm.addEventListener('transitionend', () => { this.dropdown.fillHeaderFooter(); setTimeout(() => elm.remove(), 100); }, { once: true }); elm.classList.add(this.settings.classNames.dropdownItemHidden); }, // adds all the suggested items, including the ones which are not currently rendered, // unless specified otherwise (by the "onlyRendered" argument) selectAll(onlyRendered) { // having suggestedListItems with items messes with "normalizeTags" when wanting // to add all tags this.suggestedListItems.length = 0; this.dropdown.hide(); this.dropdown.filterListItems(''); var tagsToAdd = this.dropdown.filterListItems(''); if (!onlyRendered) tagsToAdd = this.state.dropdown.suggestions; // some whitelist items might have already been added as tags so when addings all of them, // skip adding already-added ones, so best to use "filterListItems" method over "settings.whitelist" this.addTags(tagsToAdd, true); return this; }, /** * returns an HTML string of the suggestions' list items * @param {String} value string to filter the whitelist by * @param {Object} options "exact" - for exact complete match * @return {Array} list of filtered whitelist items according to the settings provided and current value */ filterListItems(value, options) { var _s = this.settings, _sd = _s.dropdown, options = options || {}, list = [], exactMatchesList = [], whitelist = _s.whitelist, suggestionsCount = _sd.maxItems >= 0 ? _sd.maxItems : Infinity, searchKeys = _sd.searchKeys, whitelistItem, valueIsInWhitelist, searchBy, isDuplicate, niddle, i = 0; value = _s.mode == 'select' && this.value.length && this.value[0][_s.tagTextProp] == value ? '' // do not filter if the tag, which is already selecetd in "select" mode, is the same as the typed text : value; if (!value || !searchKeys.length) { list = _sd.includeSelectedTags ? whitelist : whitelist.filter(item => !this.isTagDuplicate(isObject(item) ? item.value : item)); // don't include tags which have already been added. this.state.dropdown.suggestions = list; return list.slice(0, suggestionsCount); // respect "maxItems" dropdown setting } niddle = _sd.caseSensitive ? "" + value : ("" + value).toLowerCase(); // checks if ALL of the words in the search query exists in the current whitelist item, regardless of their order function stringHasAll(s, query) { return query.toLowerCase().split(' ').every(q => s.includes(q.toLowerCase())); } for (; i < whitelist.length; i++) { let startsWithMatch, exactMatch; whitelistItem = whitelist[i] instanceof Object ? whitelist[i] : { value: whitelist[i] }; //normalize value as an Object let itemWithoutSearchKeys = !Object.keys(whitelistItem).some(k => searchKeys.includes(k)), _searchKeys = itemWithoutSearchKeys ? ["value"] : searchKeys; if (_sd.fuzzySearch && !options.exact) { searchBy = _searchKeys.reduce((values, k) => values + " " + (whitelistItem[k] || ""), "").toLowerCase().trim(); if (_sd.accentedSearch) { searchBy = unaccent(searchBy); niddle = unaccent(niddle); } startsWithMatch = searchBy.indexOf(niddle) == 0; exactMatch = searchBy === niddle; valueIsInWhitelist = stringHasAll(searchBy, niddle); } else { startsWithMatch = true; valueIsInWhitelist = _searchKeys.some(k => { var v = '' + (whitelistItem[k] || ''); // if key exists, cast to type String if (_sd.accentedSearch) { v = unaccent(v); niddle = unaccent(niddle); } if (!_sd.caseSensitive) v = v.toLowerCase(); exactMatch = v === niddle; return options.exact ? v === niddle : v.indexOf(niddle) == 0; }); } isDuplicate = !_sd.includeSelectedTags && this.isTagDuplicate(isObject(whitelistItem) ? whitelistItem.value : whitelistItem); // match for the value within each "whitelist" item if (valueIsInWhitelist && !isDuplicate) if (exactMatch && startsWithMatch) exactMatchesList.push(whitelistItem);else if (_sd.sortby == 'startsWith' && startsWithMatch) list.unshift(whitelistItem);else list.push(whitelistItem); } this.state.dropdown.suggestions = exactMatchesList.concat(list); // custom sorting function return typeof _sd.sortby == 'function' ? _sd.sortby(exactMatchesList.concat(list), niddle) : exactMatchesList.concat(list).slice(0, suggestionsCount); }, /** * Returns the final value of a tag data (object) with regards to the "mapValueTo" dropdown setting * @param {Object} tagData * @returns */ getMappedValue(tagData) { var mapValueTo = this.settings.dropdown.mapValueTo, value = mapValueTo ? typeof mapValueTo == 'function' ? mapValueTo(tagData) : tagData[mapValueTo] || tagData.value : tagData.value; return value; }, /** * Creates the dropdown items' HTML * @param {Array} sugegstionsList [Array of Objects] * @return {String} */ createListHTML(sugegstionsList) { return extend([], sugegstionsList).map((suggestion, idx) => { if (typeof suggestion == 'string' || typeof suggestion == 'number') suggestion = { value: suggestion }; var mappedValue = this.dropdown.getMappedValue(suggestion); mappedValue = typeof mappedValue == 'string' ? escapeHTML(mappedValue) : mappedValue; return this.settings.templates.dropdownItem.apply(this, [_objectSpread2(_objectSpread2({}, suggestion), {}, { mappedValue }), this]); }).join(""); } }; const VERSION = 1; // current version of persisted data. if code change breaks persisted data, verison number should be bumped. const STORE_KEY = '@yaireo/tagify/'; const getPersistedData = id => key => { // if "persist" is "false", do not save to localstorage let customKey = '/' + key, persistedData, versionMatch = localStorage.getItem(STORE_KEY + id + '/v', VERSION) == VERSION; if (versionMatch) { try { persistedData = JSON.parse(localStorage[STORE_KEY + id + customKey]); } catch (err) {} } return persistedData; }; const setPersistedData = id => { if (!id) return () => {}; // for storage invalidation localStorage.setItem(STORE_KEY + id + '/v', VERSION); return (data, key) => { let customKey = '/' + key, persistedData = JSON.stringify(data); if (data && key) { localStorage.setItem(STORE_KEY + id + customKey, persistedData); dispatchEvent(new Event('storage')); } }; }; const clearPersistedData = id => key => { const base = STORE_KEY + '/' + id + '/'; // delete specific key in the storage if (key) localStorage.removeItem(base + key); // delete all keys in the storage with a specific tagify id else { for (let k in localStorage) if (k.includes(base)) localStorage.removeItem(k); } }; var TEXTS = { empty: "empty", exceed: "number of tags exceeded", pattern: "pattern mismatch", duplicate: "already exists", notAllowed: "not allowed" }; var templates = { /** * * @param {DOM Object} input Original input DOm element * @param {Object} settings Tagify instance settings Object */ wrapper(input, _s) { return `<tags class="${_s.classNames.namespace} ${_s.mode ? `${_s.classNames[_s.mode + "Mode"]}` : ""} ${input.className}" ${_s.readonly ? 'readonly' : ''} ${_s.disabled ? 'disabled' : ''} ${_s.required ? 'required' : ''} ${_s.mode === 'select' ? "spellcheck='false'" : ''} tabIndex="-1"> <span ${!_s.readonly && _s.userInput ? 'contenteditable' : ''} tabIndex="0" data-placeholder="${_s.placeholder || '​'}" aria-placeholder="${_s.placeholder || ''}" class="${_s.classNames.input}" role="textbox" aria-autocomplete="both" aria-multiline="${_s.mode == 'mix' ? true : false}"></span> ​ </tags>`; }, tag(tagData, _ref) { let _s = _ref.settings; return `<tag title="${tagData.title || tagData.value}" contenteditable='false' spellcheck='false' tabIndex="${_s.a11y.focusableTags ? 0 : -1}" class="${_s.classNames.tag} ${tagData.class || ""}" ${this.getAttributes(tagData)}> <x title='' class="${_s.classNames.tagX}" role='button' aria-label='remove tag'></x> <div> <span class="${_s.classNames.tagText}">${tagData[_s.tagTextProp] || tagData.value}</span> </div> </tag>`; }, dropdown(settings) { var _sd = settings.dropdown, isManual = _sd.position == 'manual', className = `${settings.classNames.dropdown}`; return `<div class="${isManual ? "" : className} ${_sd.classname}" role="listbox" aria-labelledby="dropdown"> <div data-selector='tagify-suggestions-wrapper' class="${settings.classNames.dropdownWrapper}"></div> </div>`; }, dropdownContent(HTMLContent) { var _s = this.settings, suggestions = this.state.dropdown.suggestions; return ` ${_s.templates.dropdownHeader.call(this, suggestions)} ${HTMLContent} ${_s.templates.dropdownFooter.call(this, suggestions)} `; }, dropdownItem(item) { return `<div ${this.getAttributes(item)} class='${this.settings.classNames.dropdownItem} ${item.class ? item.class : ""}' tabindex="0" role="option">${item.mappedValue || item.value}</div>`; }, /** * @param {Array} suggestions An array of all the matched suggested items, including those which were sliced away due to the "dropdown.maxItems" setting */ dropdownHeader(suggestions) { return `<header data-selector='tagify-suggestions-header' class="${this.settings.classNames.dropdownHeader}"></header>`; }, dropdownFooter(suggestions) { var hasMore = suggestions.length - this.settings.dropdown.maxItems; return hasMore > 0 ? `<footer data-selector='tagify-suggestions-footer' class="${this.settings.classNames.dropdownFooter}"> ${hasMore} more items. Refine your search. </footer>` : ''; }, dropdownItemNoMatch: null }; function EventDispatcher(instance) { // Create a DOM EventTarget object var target = document.createTextNode(''); function addRemove(op, events, cb) { if (cb) events.split(/\s+/g).forEach(name => target[op + 'EventListener'].call(target, name, cb)); } // Pass EventTarget interface calls to DOM EventTarget object return { off(events, cb) { addRemove('remove', events, cb); return this; }, on(events, cb) { if (cb && typeof cb == 'function') addRemove('add', events, cb); return this; }, trigger(eventName, data, opts) { var e; opts = opts || { cloneData: true }; if (!eventName) return; if (instance.settings.isJQueryPlugin) { if (eventName == 'remove') eventName = 'removeTag'; // issue #222 jQuery(instance.DOM.originalInput).triggerHandler(eventName, [data]); } else { try { var eventData = typeof data === 'object' ? data : { value: data }; eventData = opts.cloneData ? extend({}, eventData) : eventData; eventData.tagify = this; if (data.event) eventData.event = this.cloneEvent(data.event); // TODO: move the below to the "extend" function if (data instanceof Object) for (var prop in data) if (data[prop] instanceof HTMLElement) eventData[prop] = data[prop]; e = new CustomEvent(eventName, { "detail": eventData }); } catch (err) { console.warn(err); } target.dispatchEvent(e); } } }; } var deleteBackspaceTimeout; function triggerChangeEvent() { if (this.settings.mixMode.integrated) return; var inputElm = this.DOM.originalInput, changed = this.state.lastOriginalValueReported !== inputElm.value, event = new CustomEvent("change", { bubbles: true }); // must use "CustomEvent" and not "Event" to support IE if (!changed) return; // must apply this BEFORE triggering the simulated event this.state.lastOriginalValueReported = inputElm.value; // React hack: https://github.com/facebook/react/issues/11488 event.simulated = true; if (inputElm._valueTracker) inputElm._valueTracker.setValue(Math.random()); inputElm.dispatchEvent(event); // also trigger a Tagify event this.trigger("change", this.state.lastOriginalValueReported); // React, for some reason, clears the input's value after "dispatchEvent" is fired inputElm.value = this.state.lastOriginalValueReported; } var events = { // bind custom events which were passed in the settings customBinding() { this.customEventsList.forEach(name => { this.on(name, this.settings.callbacks[name]); }); }, binding() { let bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; var _CB = this.events.callbacks, _CBR, action = bindUnbind ? 'addEventListener' : 'removeEventListener'; // do not allow the main events to be bound more than once if (this.state.mainEvents && bindUnbind) return; // set the binding state of the main events, so they will not be bound more than once this.state.mainEvents = bindUnbind; // everything inside gets executed only once-per instance if (bindUnbind && !this.listeners.main) { this.events.bindGlobal.call(this); if (this.settings.isJQueryPlugin) jQuery(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this)); } // setup callback references so events could be removed later _CBR = this.listeners.main = this.listeners.main || { focus: ['input', _CB.onFocusBlur.bind(this)], keydown: ['input', _CB.onKeydown.bind(this)], click: ['scope', _CB.onClickScope.bind(this)], dblclick: ['scope', _CB.onDoubleClickScope.bind(this)], paste: ['input', _CB.onPaste.bind(this)], drop: ['input', _CB.onDrop.bind(this)], compositionstart: ['input', _CB.onCompositionStart.bind(this)], compositionend: ['input', _CB.onCompositionEnd.bind(this)] }; for (var eventName in _CBR) { this.DOM[_CBR[eventName][0]][action](eventName, _CBR[eventName][1]); } // listen to original input changes (unfortunetly this is the best way...) // https://stackoverflow.com/a/1949416/104380 clearInterval(this.listeners.main.originalInputValueObserverInterval); this.listeners.main.originalInputValueObserverInterval = setInterval(_CB.observeOriginalInputValue.bind(this), 500); // observers var inputMutationObserver = this.listeners.main.inputMutationObserver || new MutationObserver(_CB.onInputDOMChange.bind(this)); // cleaup just-in-case inputMutationObserver.disconnect(); // observe stuff if (this.settings.mode == 'mix') inputMutationObserver.observe(this.DOM.input, { childList: true }); }, bindGlobal(unbind) { var _CB = this.events.callbacks, action = unbind ? 'removeEventListener' : 'addEventListener', e; if (!this.listeners || !unbind && this.listeners.global) return; // do not re-bind // these events are global event should never be unbinded, unless the instance is destroyed: this.listeners.global = this.listeners.global || [{ type: this.isIE ? 'keydown' : 'input', // IE cannot register "input" events on contenteditable elements, so the "keydown" should be used instead.. target: this.DOM.input, cb: _CB[this.isIE ? 'onInputIE' : 'onInput'].bind(this) }, { type: 'keydown', target: window, cb: _CB.onWindowKeyDown.bind(this) }, { type: 'blur', target: this.DOM.input, cb: _CB.onFocusBlur.bind(this) }, { type: 'click', target: document, cb: _CB.onClickAnywhere.bind(this) }]; for (e of this.listeners.global) e.target[action](e.type, e.cb); }, unbindGlobal() { this.events.bindGlobal.call(this, true); }, /** * DOM events callbacks */ callbacks: { onFocusBlur(e) { var _s = this.settings, text = e.target ? this.trim(e.target.textContent) : '', // a string currentDisplayValue = this.value?.[0]?.[_s.tagTextProp], type = e.type, ddEnabled = _s.dropdown.enabled >= 0, eventData = { relatedTarget: e.relatedTarget }, isTargetSelectOption = this.state.actions.selectOption && (ddEnabled || !_s.dropdown.closeOnSelect), isTargetAddNewBtn = this.state.actions.addNew && ddEnabled, isRelatedTargetX = e.relatedTarget && isNodeTag.call(this, e.relatedTarget) && this.DOM.scope.contains(e.relatedTarget), shouldAddTags; if (type == 'blur') { if (e.relatedTarget === this.DOM.scope) { this.dropdown.hide(); this.DOM.input.focus(); return; } this.postUpdate(); _s.onChangeAfterBlur && this.triggerChangeEvent(); } if (isTargetSelectOption || isTargetAddNewBtn) return; this.state.hasFocus = type == "focus" ? +new Date() : false; this.toggleFocusClass(this.state.hasFocus); if (_s.mode == 'mix') { if (type == "focus") { this.trigger("focus", eventData); } else if (e.type == "blur") { this.trigger("blur", eventData); this.loading(false); this.dropdown.hide(); // reset state which needs reseting this.state.dropdown.visible = undefined; this.setStateSelection(); } return; } if (type == "focus") { this.trigger("focus", eventData); // e.target.classList.remove('placeholder'); if (_s.dropdown.enabled === 0 || !_s.userInput) { // && _s.mode != "select" this.dropdown.show(this.value.length ? '' : undefined); } return; } else if (type == "blur") { this.trigger("blur", eventData); this.loading(false); // when clicking the X button of a selected tag, it is unwanted for it to be added back // again in a few more lines of code (shouldAddTags && addTags) if (_s.mode == 'select') { if (isRelatedTargetX) { this.removeTags(); text = ''; } // if nothing has changed (same display value), do not add a tag if (currentDisplayValue === text) text = ''; } shouldAddTags = text && !this.state.actions.selectOption && _s.addTagOnBlur; // do not add a tag if "selectOption" action was just fired (this means a tag was just added from the dropdown) shouldAddTags && this.addTags(text, true); } this.DOM.input.removeAttribute('style'); this.dropdown.hide(); }, onCompositionStart(e) { this.state.composing = true; }, onCompositionEnd(e) { this.state.composing = false; }, onWindowKeyDown(e) { var focusedElm = document.activeElement, isTag = isNodeTag.call(this, focusedElm), isBelong = isTag && this.DOM.scope.contains(document.activeElement), isReadyOnlyTag = isBelong && focusedElm.hasAttribute('readonly'), nextTag; if (!isBelong || isReadyOnlyTag) return; nextTag = focusedElm.nextElementSibling; switch (e.key) { // remove tag if has focus case 'Backspace': { if (!this.settings.readonly) { this.removeTags(focusedElm); (nextTag ? nextTag : this.DOM.input).focus(); } break; } // edit tag if has focus case 'Enter': { setTimeout(this.editTag.bind(this), 0, focusedElm); break; } } }, onKeydown(e) { var _s = this.settings; // ignore keys during IME composition or when user input is not allowed if (this.state.composing || !_s.userInput) return; if (_s.mode == 'select' && _s.enforceWhitelist && this.value.length && e.key != 'Tab') { e.preventDefault(); } var s = this.trim(e.target.textContent); this.trigger("keydown", { event: e }); /** * ONLY FOR MIX-MODE: */ if (_s.mode == 'mix') { switch (e.key) { case 'Left': case 'ArrowLeft': { // when left arrow was pressed, set a flag so when the dropdown is shown, right-arrow will be ignored // because it seems likely the user wishes to use the arrows to move the caret this.state.actions.ArrowLeft = true; break; } case 'Delete': case 'Backspace': { if (this.state.editing) return; var sel = document.getSelection(), deleteKeyTagDetected = e.key == 'Delete' && sel.anchorOffset == (sel.anchorNode.length || 0), prevAnchorSibling = sel.anchorNode.previousSibling, isCaretAfterTag = sel.anchorNode.nodeType == 1 || !sel.anchorOffset && prevAnchorSibling && prevAnchorSibling.nodeType == 1 && sel.anchorNode.previousSibling; decode(this.DOM.input.innerHTML); var lastTagElems = this.getTagElms(), // isCaretInsideTag = sel.anchorNode.parentNode('.' + _s.classNames.tag), tagBeforeCaret, tagElmToBeDeleted, firstTextNodeBeforeTag; if (_s.backspace == 'edit' && isCaretAfterTag) { tagBeforeCaret = sel.anchorNode.nodeType == 1 ? null : sel.anchorNode.previousElementSibling; setTimeout(this.editTag.bind(this), 0, tagBeforeCaret); // timeout is needed to the last cahacrter in the edited tag won't get deleted e.preventDefault(); // needed so the tag elm won't get deleted return; } if (isChromeAndroidBrowser() && isCaretAfterTag instanceof Element) { firstTextNodeBeforeTag = getfirstTextNode(isCaretAfterTag); if (!isCaretAfterTag.hasAttribute('readonly')) isCaretAfterTag.remove(); // since this is Chrome, can safetly use this "new" DOM API // Android-Chrome wrongly hides the keyboard, and loses focus, // so this hack below is needed to regain focus at the correct place: this.DOM.input.focus(); setTimeout(() => { this.placeCaretAfterNode(firstTextNodeBeforeTag); this.DOM.input.click(); }); return; } if (sel.anchorNode.nodeName == 'BR') return; if ((deleteKeyTagDetected || isCaretAfterTag) && sel.anchorNode.nodeType == 1) { if (sel.anchorOffset == 0) // caret is at the very begining, before a tag tagElmToBeDeleted = deleteKeyTagDetected // delete key pressed ? lastTagElems[0] : null;else tagElmToBeDeleted = lastTagElems[Math.min(lastTagElems.length, sel.anchorOffset) - 1]; // find out if a tag *might* be a candidate for deletion, and if so, which } else if (deleteKeyTagDetected) tagElmToBeDeleted = sel.anchorNode.nextElementSibling;else if (isCaretAfterTag instanceof Element) tagElmToBeDeleted = isCaretAfterTag; // tagElm.hasAttribute('readonly') if (sel.anchorNode.nodeType == 3 && // node at caret location is a Text node !sel.anchorNode.nodeValue && // has some text sel.anchorNode.previousElementSibling) // text node has a Tag node before it e.preventDefault(); // if backspace not allowed, do nothing // TODO: a better way to detect if nodes were deleted is to simply check the "this.value" before & after if ((isCaretAfterTag || deleteKeyTagDetected) && !_s.backspace) { e.preventDefault(); return; } if (sel.type != 'Range' && !sel.anchorOffset && sel.anchorNode == this.DOM.input && e.key != 'Delete') { e.preventDefault(); return; } if (sel.type != 'Range' && tagElmToBeDeleted && tagElmToBeDeleted.hasAttribute('readonly')) { // allows the continuation of deletion by placing the caret on the first previous textNode. // since a few readonly-tags might be one after the other, iteration is needed: this.placeCaretAfterNode(getfirstTextNode(tagElmToBeDeleted)); return; } // update regarding https://github.com/yairEO/tagify/issues/762#issuecomment-786464317: // the bug described is more severe than the fix below, therefore I disable the fix until a solution // is found which work well for both cases. // ------- // nodeType is "1" only when the caret is at the end after last tag (no text after), or before first first (no text before) /* if( this.isFirefox && sel.anchorNode.nodeType == 1 && sel.anchorOffset != 0 ){ this.removeTags() // removes last tag by default if no parameter supplied // place caret inside last textNode, if exist. it's an annoying bug only in FF, // if the last tag is removed, and there is a textNode before it, the caret is not placed at its end this.placeCaretAfterNode( setRangeAtStartEnd(false, this.DOM.input) ) } */ clearTimeout(deleteBackspaceTimeout); // a minimum delay is needed before the node actually gets detached from the document (don't know why), // to know exactly which tag was deleted. This is the easiest way of knowing besides using MutationObserver deleteBackspaceTimeout = setTimeout(() => { var sel = document.getSelection(); decode(this.DOM.input.innerHTML); !deleteKeyTagDetected && sel.anchorNode.previousSibling; // fixes #384, where the first and only tag will not get removed with backspace /* * [UPDATE DEC 3, 22] SEEMS BELOEW CODE IS NOT NEEDED ANY MORE * if( currentValue.length > lastInputValue.length && prevElm ){ if( isNodeTag.call(this, prevElm) && !prevElm.hasAttribute('readonly') ){ this.removeTags(prevElm) this.fixFirefoxLastTagNoCaret() // the above "removeTag" methods removes the tag with a transition. Chrome adds a <br> element for some reason at this stage if( this.DOM.input.children.length == 2 && this.DOM.input.children[1].tagName == "BR" ){ this.DOM.input.innerHTML = "" this.value.length = 0 return true } } else prevElm.remove() } */ // find out which tag(s) were deleted and trigger "remove" event // iterate over the list of tags still in the document and then filter only those from the "this.value" collection this.value = [].map.call(lastTagElems, (node, nodeIdx) => { var tagData = getSetTagData(node); // since readonly cannot be removed (it's technically resurrected if removed somehow) if (node.parentNode || tagData.readonly) return tagData;else this.trigger('remove', { tag: node, index: nodeIdx, data: tagData }); }).filter(n => n); // remove empty items in the mapped array }, 20); // Firefox needs this higher duration for some reason or things get buggy when deleting text from the end break; } // currently commented to allow new lines in mixed-mode // case 'Enter' : // // e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 } return true; } switch (e.key) { case 'Backspace': if (_s.mode == 'select' && _s.enforceWhitelist && this.value.length) this.removeTags();else if (!this.state.dropdown.visible || _s.dropdown.position == 'manual') { if (e.target.textContent == "" || s.charCodeAt(0) == 8203) { // 8203: ZERO WIDTH SPACE unicode if (_s.backspace === true) this.removeTags();else if (_s.backspace == 'edit') setTimeout(this.editTag.bind(this), 0); // timeout reason: when edited tag gets focused and the caret is placed at the end, the last character gets deletec (because of backspace) } } break; case 'Esc': case 'Escape': if (this.state.dropdown.visible) return; e.target.blur(); break; case 'Down': case 'ArrowDown': // if( _s.mode == 'select' ) // issue #333 if (!this.state.dropdown.visible) this.dropdown.show(); break; case 'ArrowRight': { let tagData = this.state.inputSuggestion || this.state.ddItemData; if (tagData && _s.autoComplete.rightKey) { this.addTags([tagData], true); return; } break; } case 'Tab': { let selectMode = _s.mode == 'select'; if (s && !selectMode) e.preventDefault();else return true; } case 'Enter': // manual suggestion boxes are assumed to always be visible if (this.state.dropdown.visible && _s.dropdown.position != 'manual') return; e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 // because the main "keydown" event is bound before the dropdown events, this will fire first and will not *yet* // know if an option was just selected from the dropdown menu. If an option was selected, // the dropdown events should handle adding the tag setTimeout(() => { if (this.state.dropdown.visible || this.state.actions.selectOption) return; this.addTags(s, true); }); } }, onInput(e) { this.postUpdate(); // toggles "tagify--empty" class var _s = this.settings; if (_s.mode == 'mix') return this.events.callbacks.onMixTagsInput.call(this, e); var value = this.input.normalize.call(this), showSuggestions = value.length >= _s.dropdown.enabled, eventData = { value, inputElm: this.DOM.input }, validation = this.validateTag({ value }); if (_s.mode == 'select') { this.toggleScopeValidation(validation); } eventData.isValid = validation; // for IE; since IE doesn't have an "input" event so "keyDown" is used instead to trigger the "onInput" callback, // and so many keys do not change the input, and for those do not continue. if (this.state.inputText == value) return; // save the value on the input's State object this.input.set.call(this, value, false); // update the input with the normalized value and run validations // this.setRangeAtStartEnd(false, this.DOM.input); // fix caret position // if delimiters detected, add tags if (value.search(_s.delimiters) != -1) { if (this.addTags(value)) { this.input.set.call(this); // clear the input field's value } } else if (_s.dropdown.enabled >= 0) { this.dropdown[showSuggestions ? "show" : "hide"](value); } this.trigger('input', eventData); // "input" event must be triggered at this point, before the dropdown is shown }, onMixTagsInput(e) { var rangeText, match, matchedPatternCount, tag, showSuggestions, selection, _s = this.settings, lastTagsCount = this.value.length, matchFlaggedTag, matchDelimiters, tagsElems = this.getTagElms(), fragment = document.createDocumentFragment(), range = window.getSelection().getRangeAt(0), remainingTagsValues = [].map.call(tagsElems, node => getSetTagData(node).value); // Android Chrome "keydown" event argument does not report the correct "key". // this workaround is needed to manually call "onKeydown" method with a synthesized event object if (e.inputType == "deleteContentBackward" && isChromeAndroidBrowser()) { this.events.callbacks.onKeydown.call(this, { target: e.target, key: "Backspace" }); } // re-add "readonly" tags which might have been removed this.value.slice().forEach(item => { if (item.readonly && !remainingTagsValues.includes(item.value)) fragment.appendChild(this.createTagElem(item)); }); if (fragment.childNodes.length) { range.insertNode(fragment); this.setRangeAtStartEnd(false, fragment.lastChild); } // check if tags were "magically" added/removed (browser redo/undo or CTRL-A -> delete) if (tagsElems.length != lastTagsCount) { this.value = [].map.call(this.getTagElms(), node => getSetTagData(node)); this.update({ withoutChangeEvent: true }); return; } if (this.hasMaxTags()) return true; if (window.getSelection) { selection = window.getSelection(); // only detect tags if selection is inside a textNode (not somehow on already-existing tag) if (selection.rangeCount > 0 && selection.anchorNode.nodeType == 3) { range = selection.getRangeAt(0).cloneRange(); range.collapse(true); range.setStart(selection.focusNode, 0); rangeText = range.toString().slice(0, range.endOffset); // slice the range so everything AFTER the caret will be trimmed // split = range.toString().split(_s.mixTagsAllowedAfter) // ["foo", "bar", "@baz"] matchedPatternCount = rangeText.split(_s.pattern).length - 1; match = rangeText.match(_s.pattern); if (match) // tag string, example: "@aaa ccc" tag = rangeText.slice(rangeText.lastIndexOf(match[match.length - 1])); if (tag) { this.state.actions.ArrowLeft = false; // start fresh, assuming the user did not (yet) used any arrow to move the caret this.state.tag = { prefix: tag.match(_s.pattern)[0], value: tag.replace(_s.pattern, '') // get rid of the prefix }; this.state.tag.baseOffset = selection.baseOffset - this.state.tag.value.length; matchDelimiters = this.state.tag.value.match(_s.delimiters); // if a delimeter exists, add the value as tag (exluding the delimiter) if (matchDelimiters) { this.state.tag.value = this.state.tag.value.replace(_s.delimiters, ''); this.state.tag.delimiters = matchDelimiters[0]; this.addTags(this.state.tag.value, _s.dropdown.clearOnSelect); this.dropdown.hide(); return; } showSuggestions = this.state.tag.value.length >= _s.dropdown.enabled; // When writing something that might look like a tag (an email address) but isn't one - it is unwanted // the suggestions dropdown be shown, so the user can close it (in any way), and while continue typing, // dropdown should stay closed until another tag is typed. // if( this.state.tag.value.length && this.state.dropdown.visible === false ) // showSuggestions = false // test for similar flagged tags to the current tag try { matchFlaggedTag = this.state.flaggedTags[this.state.tag.baseOffset]; matchFlaggedTag = matchFlaggedTag.prefix == this.state.tag.prefix && matchFlaggedTag.value[0] == this.state.tag.value[0]; // reset if (this.state.flaggedTags[this.state.tag.baseOffset] && !this.state.tag.value) delete this.state.flaggedTags[this.state.tag.baseOffset]; } catch (err) {} // scenario: (do not show suggestions of another matched tag, if more than one detected) // (2 tags exist) " a@a.com and @" // (second tag is removed by backspace) " a@a.com and " if (matchFlaggedTag || matchedPatternCount < this.state.mixMode.matchedPatternCount) showSuggestions = false; } // no (potential) tag found else { this.state.flaggedTags = {}; } this.state.mixMode.matchedPatternCount = matchedPatternCount; } } // wait until the "this.value" has been updated (see "onKeydown" method for "mix-mode") // the dropdown must be shown only after this event has been triggered, so an implementer could // dynamically change the whitelist. setTimeout(() => { this.update({ withoutChangeEvent: true }); this.trigger("input", extend({}, this.state.tag, { textContent: this.DOM.input.textContent })); if (this.state.tag) this.dropdown[showSuggestions ? "show" : "hide"](this.state.tag.value); }, 10); }, onInputIE(e) { var _this = this; // for the "e.target.textContent" to be changed, the browser requires a small delay setTimeout(function () { _this.events.callbacks.onInput.call(_this, e); }); }, observeOriginalInputValue() { // if, for some reason, the Tagified element is no longer in the DOM, // call the "destroy" method to kill all references to timeouts/intervals if (!this.DOM.originalInput.parentNode) this.destroy(); // if original input value changed for some reason (for exmaple a form reset) if (this.DOM.originalInput.value != this.DOM.originalInput.tagifyValue) this.loadOriginalValues(); }, onClickAnywhere(e) { if (e.target != this.DOM.scope && !this.DOM.scope.contains(e.target)) { this.toggleFocusClass(false); this.state.hasFocus = false; } }, onClickScope(e) { var _s = this.settings, tagElm = e.target.closest('.' + _s.classNames.tag), timeDiffFocus = +new Date() - this.state.hasFocus; if (e.target == this.DOM.scope) { // if( !this.state.hasFocus ) this.DOM.input.focus(); return; } else if (e.target.classList.contains(_s.classNames.tagX)) { this.removeTags(e.target.parentNode); return; } else if (tagElm) { this.trigger("click", { tag: tagElm, index: this.getNodeIndex(tagElm), data: getSetTagData(tagElm), event: e }); if (_s.editTags === 1 || _s.editTags.clicks === 1) this.events.callbacks.onDoubleClickScope.call(this, e); return; } // when clicking on the input itself else if (e.target == this.DOM.input) { if (_s.mode == 'mix') { // firefox won't show caret if last element is a tag (and not a textNode), // so an empty textnode should be added this.fixFirefoxLastTagNoCaret(); } if (timeDiffFocus > 500) { if (this.state.dropdown.visible) this.dropdown.hide();else if (_s.dropdown.enabled === 0 && _s.mode != 'mix') this.dropdown.show(this.value.length ? '' : undefined); return; } } if (_s.mode == 'select' && _s.dropdown.enabled === 0 && !this.state.dropdown.visible) this.dropdown.show(); }, // special proccess is needed for pasted content in order to "clean" it onPaste(e) { e.preventDefault(); var _s = this.settings, selectModeWithoutInput = _s.mode == 'select' && _s.enforceWhitelist; if (selectModeWithoutInput || !_s.userInput) { return false; } var clipboardData, pastedText; if (_s.readonly) return; // Get pasted data via clipboard API clipboardData = e.clipboardData || window.clipboardData; pastedText = clipboardData.getData('Text'); _s.hooks.beforePaste(e, { tagify: this, pastedText, clipboardData }).then(result => { if (result === undefined) result = pastedText; if (result) { this.injectAtCaret(result, window.getSelection().getRangeAt(0)); if (this.settings.mode == 'mix') { this.events.callbacks.onMixTagsInput.call(this, e); } else if (this.settings.pasteAsTags) { this.addTags(this.state.inputText + result, true); } else this.state.inputText = result; } }).catch(err => err); }, onDrop(e) { e.preventDefault(); }, onEditTagInput(editableElm, e) { var tagElm = editableElm.closest('.' + this.settings.classNames.tag), tagElmIdx = this.getNodeIndex(tagElm), tagData = getSetTagData(tagElm), textValue = this.input.normalize.call(this, editableElm), dataForChangedProp = { [this.settings.tagTextProp]: textValue, __tagId: tagData.__tagId }, // "__tagId" is needed so validation will skip current tag when checking for dups isValid = this.validateTag(dataForChangedProp), // the value could have been invalid in the first-place so make sure to re-validate it (via "addEmptyTag" method) hasChanged = this.editTagChangeDetected(extend(tagData, dataForChangedProp)); // if the value is same as before-editing and the tag was valid before as well, ignore the current "isValid" result, which is false-positive if (!hasChanged && editableElm.originalIsValid === true) isValid = true; tagElm.classList.toggle(this.settings.classNames.tagInvalid, isValid !== true); tagData.__isValid = isValid; tagElm.title = isValid === true ? tagData.title || tagData.value : isValid; // change the tag's title to indicate why is the tag invalid (if it's so) // show dropdown if typed text is equal or more than the "enabled" dropdown setting if (textValue.length >= this.settings.dropdown.enabled) { // this check is needed apparently because doing browser "undo" will fire // "onEditTagInput" but "this.state.editing" will be "false" if (this.state.editing) this.state.editing.value = textValue; this.dropdown.show(textValue); } this.trigger("edit:input", { tag: tagElm, index: tagElmIdx, data: extend({}, this.value[tagElmIdx], { newValue: textValue }), event: e }); }, onEditTagPaste(tagElm, e) { // Get pasted data via clipboard API var clipboardData = e.clipboardData || window.clipboardData, pastedText = clipboardData.getData('Text'); e.preventDefault(); var newNode = injectAtCaret(pastedText); this.setRangeAtStartEnd(false, newNode); }, onEditTagFocus(tagElm) { this.state.editing = { scope: tagElm, input: tagElm.querySelector("[contenteditable]") }; }, onEditTagBlur(editableElm) { if (!this.state.hasFocus) this.toggleFocusClass(); // one scenario is when selecting a suggestion from the dropdown, when editing, and by selecting it // the "onEditTagDone" is called directly, already replacing the tag, so the argument "editableElm" // node isn't in the DOM anynmore because it has been replaced. if (!this.DOM.scope.contains(editableElm)) return; var _s = this.settings, tagElm = editableElm.closest('.' + _s.classNames.tag), tagData = getSetTagData(tagElm), textValue = this.input.normalize.call(this, editableElm), dataForChangedProp = { [_s.tagTextProp]: textValue, __tagId: tagData.__tagId }, // "__tagId" is needed so validation will skip current tag when checking for dups originalData = tagData.__originalData, // pre-edit data hasChanged = this.editTagChangeDetected(extend(tagData, dataForChangedProp)), isValid = this.validateTag(dataForChangedProp), // "__tagId" is needed so validation will skip current tag when checking for dups hasMaxTags, newTagData; if (!textValue) { this.onEditTagDone(tagElm); return; } // if nothing changed revert back to how it was before editing if (!hasChanged) { this.onEditTagDone(tagElm, originalData); return; } // need to know this because if "keepInvalidTags" setting is "true" and an invalid tag is edited as a valid one, // but the maximum number of tags have alreay been reached, so it should not allow saving the new valid value. // only if the tag was already valid before editing, ignore this check (see a few lines below) hasMaxTags = this.hasMaxTags(); newTagData = extend({}, originalData, { [_s.tagTextProp]: this.trim(textValue), __isValid: isValid }); // pass through optional transformer defined in settings _s.transformTag.call(this, newTagData, originalData); // MUST re-validate after tag transformation // only validate the "tagTextProp" because is the only thing that metters for validating an edited tag. // -- Scenarios: -- // 1. max 3 tags allowd. there are 4 tags, one has invalid input and is edited to a valid one, and now should be marked as "not allowed" because limit of tags has reached // 2. max 3 tags allowed. there are 3 tags, one is edited, and so max-tags vaildation should be OK isValid = (!hasMaxTags || originalData.__isValid === true) && this.validateTag(newTagData); if (isValid !== true) { this.trigger("invalid", { data: newTagData, tag: tagElm, message: isValid }); // do nothing if invalid, stay in edit-mode until corrected or reverted by presssing esc if (_s.editTags.keepInvalid) return; if (_s.keepInvalidTags) newTagData.__isValid = isValid;else // revert back if not specified to keep newTagData = originalData; } else if (_s.keepInvalidTags) { // cleaup any previous leftovers if the tag was invalid delete newTagData.title; delete newTagData["aria-invalid"]; delete newTagData.class; } // tagElm.classList.toggle(_s.classNames.tagInvalid, true) this.onEditTagDone(tagElm, newTagData); }, onEditTagkeydown(e, tagElm) { // ignore keys during IME composition if (this.state.composing) return; this.trigger("edit:keydown", { event: e }); switch (e.key) { case 'Esc': case 'Escape': { // revert the tag to how it was before editing // replace current tag with original one (pre-edited one) tagElm.parentNode.replaceChild(tagElm.__tagifyTagData.__originalHTML, tagElm); this.state.editing = false; } case 'Enter': case 'Tab': e.preventDefault(); e.target.blur(); } }, onDoubleClickScope(e) { var tagElm = e.target.closest('.' + this.settings.classNames.tag), tagData = getSetTagData(tagElm), _s = this.settings, isEditingTag, isReadyOnlyTag; if (!tagElm || !_s.userInput || tagData.editable === false) return; isEditingTag = tagElm.classList.contains(this.settings.classNames.tagEditing); isReadyOnlyTag = tagElm.hasAttribute('readonly'); if (_s.mode != 'select' && !_s.readonly && !isEditingTag && !isReadyOnlyTag && this.settings.editTags) this.editTag(tagElm); this.toggleFocusClass(true); this.trigger('dblclick', { tag: tagElm, index: this.getNodeIndex(tagElm), data: getSetTagData(tagElm) }); }, /** * * @param {Object} m an object representing the observed DOM changes */ onInputDOMChange(m) { // iterate all DOm mutation m.forEach(record => { // only the ADDED nodes record.addedNodes.forEach(addedNode => { // fix chrome's placing '<div><br></div>' everytime ENTER key is pressed, and replace with just `<br' if (addedNode.outerHTML == '<div><br></div>') { addedNode.replaceWith(document.createElement('br')); } // if the added element is a div containing a tag within it (chrome does this when pressing ENTER before a tag) else if (addedNode.nodeType == 1 && addedNode.querySelector(this.settings.classNames.tagSelector)) { let newlineText = document.createTextNode(''); if (addedNode.childNodes[0].nodeType == 3 && addedNode.previousSibling.nodeName != 'BR') newlineText = document.createTextNode('\n'); // unwrap the useless div // chrome adds a BR at the end which should be removed addedNode.replaceWith(...[newlineText, ...[...addedNode.childNodes].slice(0, -1)]); this.placeCaretAfterNode(newlineText); } // if this is a tag else if (isNodeTag.call(this, addedNode)) { if (addedNode.previousSibling?.nodeType == 3 && !addedNode.previousSibling.textContent) addedNode.previousSibling.remove(); // and it is the first node in a new line if (addedNode.previousSibling && addedNode.previousSibling.nodeName == 'BR') { // allows placing the caret just before the tag, when the tag is the first node in that line addedNode.previousSibling.replaceWith('\n\u200B'); let nextNode = addedNode.nextSibling, anythingAfterNode = ''; while (nextNode) { anythingAfterNode += nextNode.textContent; nextNode = nextNode.nextSibling; } // when hitting ENTER for new line just before an existing tag, but skip below logic when a tag has been addded anythingAfterNode.trim() && this.placeCaretAfterNode(addedNode.previousSibling); } } }); record.removedNodes.forEach(removedNode => { // when trying to delete a tag which is in a new line and there's nothing else there (caret is after the tag) if (removedNode && removedNode.nodeName == 'BR' && isNodeTag.call(this, lastInputChild)) { this.removeTags(lastInputChild); this.fixFirefoxLastTagNoCaret(); } }); }); // get the last child only after the above DOM modifications // check these scenarios: // 1. after a single line, press ENTER once - should add only 1 BR // 2. presss ENTER right before a tag // 3. press enter within a text node before a tag var lastInputChild = this.DOM.input.lastChild; if (lastInputChild && lastInputChild.nodeValue == '') lastInputChild.remove(); // make sure the last element is always a BR if (!lastInputChild || lastInputChild.nodeName != 'BR') { this.DOM.input.appendChild(document.createElement('br')); } } } }; /** * @constructor * @param {Object} input DOM element * @param {Object} settings settings object */ function Tagify(input, settings) { if (!input) { console.warn('Tagify:', 'input element not found', input); // return an empty mock of all methods, so the code using tagify will not break // because it might be calling methods even though the input element does not exist const mockInstance = new Proxy(this, { get() { return () => mockInstance; } }); return mockInstance; } if (input.__tagify) { console.warn('Tagify: ', 'input element is already Tagified - Same instance is returned.', input); return input.__tagify; } extend(this, EventDispatcher(this)); this.isFirefox = /firefox|fxios/i.test(navigator.userAgent) && !/seamonkey/i.test(navigator.userAgent); this.isIE = window.document.documentMode; // https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode#Browser_compatibility settings = settings || {}; this.getPersistedData = getPersistedData(settings.id); this.setPersistedData = setPersistedData(settings.id); this.clearPersistedData = clearPersistedData(settings.id); this.applySettings(input, settings); this.state = { inputText: '', editing: false, composing: false, actions: {}, // UI actions for state-locking mixMode: {}, dropdown: {}, flaggedTags: {} // in mix-mode, when a string is detetced as potential tag, and the user has chocen to close the suggestions dropdown, keep the record of the tasg here }; this.value = []; // tags' data // events' callbacks references will be stores here, so events could be unbinded this.listeners = {}; this.DOM = {}; // Store all relevant DOM elements in an Object this.build(input); initDropdown.call(this); this.getCSSVars(); this.loadOriginalValues(); this.events.customBinding.call(this); this.events.binding.call(this); input.autofocus && this.DOM.input.focus(); input.__tagify = this; } Tagify.prototype = { _dropdown, getSetTagData, helpers: { sameStr, removeCollectionProp, omit, isObject, parseHTML, escapeHTML, extend, concatWithoutDups, getUID, isNodeTag }, customEventsList: ['change', 'add', 'remove', 'invalid', 'input', 'click', 'keydown', 'focus', 'blur', 'edit:input', 'edit:beforeUpdate', 'edit:updated', 'edit:start', 'edit:keydown', 'dropdown:show', 'dropdown:hide', 'dropdown:select', 'dropdown:updated', 'dropdown:noMatch', 'dropdown:scroll'], dataProps: ['__isValid', '__removed', '__originalData', '__originalHTML', '__tagId'], // internal-uasge props trim(text) { return this.settings.trim && text && typeof text == "string" ? text.trim() : text; }, // expose this handy utility function parseHTML, templates, parseTemplate(template, data) { template = this.settings.templates[template] || template; return parseHTML(template.apply(this, data)); }, set whitelist(arr) { const isArray = arr && Array.isArray(arr); this.settings.whitelist = isArray ? arr : []; this.setPersistedData(isArray ? arr : [], 'whitelist'); }, get whitelist() { return this.settings.whitelist; }, generateClassSelectors(classNames) { for (let name in classNames) { let currentName = name; Object.defineProperty(classNames, currentName + "Selector", { get() { return "." + this[currentName].split(" ")[0]; } }); } }, applySettings(input, settings) { DEFAULTS.templates = this.templates; var mixModeDefaults = { dropdown: { position: "text" } }; var mergedDefaults = extend({}, DEFAULTS, settings.mode == 'mix' ? mixModeDefaults : {}); var _s = this.settings = extend({}, mergedDefaults, settings); _s.disabled = input.hasAttribute('disabled'); _s.readonly = _s.readonly || input.hasAttribute('readonly'); _s.placeholder = escapeHTML(input.getAttribute('placeholder') || _s.placeholder || ""); _s.required = input.hasAttribute('required'); this.generateClassSelectors(_s.classNames); if (_s.dropdown.includeSelectedTags === undefined) _s.dropdown.includeSelectedTags = _s.duplicates; if (this.isIE) _s.autoComplete = false; // IE goes crazy if this isn't false ["whitelist", "blacklist"].forEach(name => { var attrVal = input.getAttribute('data-' + name); if (attrVal) { attrVal = attrVal.split(_s.delimiters); if (attrVal instanceof Array) _s[name] = attrVal; } }); // backward-compatibility for old version of "autoComplete" setting: if ("autoComplete" in settings && !isObject(settings.autoComplete)) { _s.autoComplete = DEFAULTS.autoComplete; _s.autoComplete.enabled = settings.autoComplete; } if (_s.mode == 'mix') { _s.pattern = _s.pattern || /@/; _s.autoComplete.rightKey = true; _s.delimiters = settings.delimiters || null; // default dlimiters in mix-mode must be NULL // needed for "filterListItems". This assumes the user might have forgotten to manually // define the same term in "dropdown.searchKeys" as defined in "tagTextProp" setting, so // by automatically adding it, tagify is "helping" out, guessing the intesntions of the developer. if (_s.tagTextProp && !_s.dropdown.searchKeys.includes(_s.tagTextProp)) _s.dropdown.searchKeys.push(_s.tagTextProp); } if (input.pattern) try { _s.pattern = new RegExp(input.pattern); } catch (e) {} // Convert the "delimiters" setting into a REGEX object if (_s.delimiters) { _s._delimiters = _s.delimiters; try { _s.delimiters = new RegExp(this.settings.delimiters, "g"); } catch (e) {} } if (_s.disabled) _s.userInput = false; this.TEXTS = _objectSpread2(_objectSpread2({}, TEXTS), _s.texts || {}); // make sure the dropdown will be shown on "focus" and not only after typing something (in "select" mode) if (_s.mode == 'select' && !settings.dropdown?.enabled || !_s.userInput) { _s.dropdown.enabled = 0; } _s.dropdown.appendTarget = settings.dropdown?.appendTarget || document.body; // get & merge persisted data with current data let persistedWhitelist = this.getPersistedData('whitelist'); if (Array.isArray(persistedWhitelist)) this.whitelist = Array.isArray(_s.whitelist) ? concatWithoutDups(_s.whitelist, persistedWhitelist) : persistedWhitelist; }, /** * Returns a string of HTML element attributes * @param {Object} data [Tag data] */ getAttributes(data) { var attrs = this.getCustomAttributes(data), s = '', k; for (k in attrs) s += " " + k + (data[k] !== undefined ? `="${attrs[k]}"` : ""); return s; }, /** * Returns an object of attributes to be used for the templates */ getCustomAttributes(data) { // only items which are objects have properties which can be used as attributes if (!isObject(data)) return ''; var output = {}, propName; for (propName in data) { if (propName.slice(0, 2) != '__' && propName != 'class' && data.hasOwnProperty(propName) && data[propName] !== undefined) output[propName] = escapeHTML(data[propName]); } return output; }, setStateSelection() { var selection = window.getSelection(); // save last selection place to be able to inject anything from outside to that specific place var sel = { anchorOffset: selection.anchorOffset, anchorNode: selection.anchorNode, range: selection.getRangeAt && selection.rangeCount && selection.getRangeAt(0) }; this.state.selection = sel; return sel; }, /** * Get specific CSS variables which are relevant to this script and parse them as needed. * The result is saved on the instance in "this.CSSVars" */ getCSSVars() { var compStyle = getComputedStyle(this.DOM.scope, null); const getProp = name => compStyle.getPropertyValue('--' + name); function seprateUnitFromValue(a) { if (!a) return {}; a = a.trim().split(' ')[0]; var unit = a.split(/\d+/g).filter(n => n).pop().trim(), value = +a.split(unit).filter(n => n)[0].trim(); return { value, unit }; } this.CSSVars = { tagHideTransition: (_ref => { let value = _ref.value, unit = _ref.unit; return unit == 's' ? value * 1000 : value; })(seprateUnitFromValue(getProp('tag-hide-transition'))) }; }, /** * builds the HTML of this component * @param {Object} input [DOM element which would be "transformed" into "Tags"] */ build(input) { var DOM = this.DOM; if (this.settings.mixMode.integrated) { DOM.originalInput = null; DOM.scope = input; DOM.input = input; } else { DOM.originalInput = input; DOM.originalInput_tabIndex = input.tabIndex; DOM.scope = this.parseTemplate('wrapper', [input, this.settings]); DOM.input = DOM.scope.querySelector(this.settings.classNames.inputSelector); input.parentNode.insertBefore(DOM.scope, input); input.tabIndex = -1; // do not allow focus or typing directly, once tagified } }, /** * revert any changes made by this component */ destroy() { this.events.unbindGlobal.call(this); this.DOM.scope.parentNode.removeChild(this.DOM.scope); this.DOM.originalInput.tabIndex = this.DOM.originalInput_tabIndex; delete this.DOM.originalInput.__tagify; this.dropdown.hide(true); clearTimeout(this.dropdownHide__bindEventsTimeout); clearInterval(this.listeners.main.originalInputValueObserverInterval); }, /** * if the original input has any values, add them as tags */ loadOriginalValues(value) { var lastChild, _s = this.settings; // temporarily block firing the "change" event on the original input until // this method finish removing current value and adding a new one this.state.blockChangeEvent = true; if (value === undefined) { const persistedOriginalValue = this.getPersistedData('value'); // if the field already has a field, trust its the desired // one to be rendered and do not use the persisted one if (persistedOriginalValue && !this.DOM.originalInput.value) value = persistedOriginalValue;else value = _s.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value; } this.removeAllTags(); if (value) { if (_s.mode == 'mix') { this.parseMixTags(value); lastChild = this.DOM.input.lastChild; // fixes a Chrome bug, when the last node in `mix-mode` is a tag, the caret appears at the far-top-top, outside the field if (!lastChild || lastChild.tagName != 'BR') this.DOM.input.insertAdjacentHTML('beforeend', '<br>'); } else { try { if (JSON.parse(value) instanceof Array) value = JSON.parse(value); } catch (err) {} this.addTags(value, true).forEach(tag => tag && tag.classList.add(_s.classNames.tagNoAnimation)); } } else this.postUpdate(); this.state.lastOriginalValueReported = _s.mixMode.integrated ? '' : this.DOM.originalInput.value; }, cloneEvent(e) { var clonedEvent = {}; for (var v in e) if (v != 'path') clonedEvent[v] = e[v]; return clonedEvent; }, /** * Toogle global loading state on/off * Useful when fetching async whitelist while user is typing * @param {Boolean} isLoading */ loading(isLoading) { this.state.isLoading = isLoading; // IE11 doesn't support toggle with second parameter this.DOM.scope.classList[isLoading ? "add" : "remove"](this.settings.classNames.scopeLoading); return this; }, /** * Toogle a tag loading state on/off * @param {Boolean} isLoading */ tagLoading(tagElm, isLoading) { if (tagElm) // IE11 doesn't support toggle with second parameter tagElm.classList[isLoading ? "add" : "remove"](this.settings.classNames.tagLoading); return this; }, /** * Toggles class on the main tagify container ("scope") * @param {String} className * @param {Boolean} force */ toggleClass(className, force) { if (typeof className == 'string') this.DOM.scope.classList.toggle(className, force); }, toggleScopeValidation(validation) { var isValid = validation === true || validation === undefined; // initially it is undefined if (!this.settings.required && validation && validation === this.TEXTS.empty) isValid = true; this.toggleClass(this.settings.classNames.tagInvalid, !isValid); this.DOM.scope.title = isValid ? '' : validation; }, toggleFocusClass(force) { this.toggleClass(this.settings.classNames.focus, !!force); }, triggerChangeEvent, events, fixFirefoxLastTagNoCaret() { return; // seems to be fixed in newer version of FF, so retiring below code (for now) // var inputElm = this.DOM.input // if( this.isFirefox && inputElm.childNodes.length && inputElm.lastChild.nodeType == 1 ){ // inputElm.appendChild(document.createTextNode("\u200b")) // this.setRangeAtStartEnd(true, inputElm) // return true // } }, /** https://stackoverflow.com/a/59156872/104380 * @param {Boolean} start indicating where to place it (start or end of the node) * @param {Object} node DOM node to place the caret at */ setRangeAtStartEnd(start, node) { if (!node) return; start = typeof start == 'number' ? start : !!start; node = node.lastChild || node; var sel = document.getSelection(); // do not force caret placement if the current selection (focus) is on another element (not this tagify instance) if (sel.focusNode instanceof Element && !this.DOM.input.contains(sel.focusNode)) { return true; } try { if (sel.rangeCount >= 1) { ['Start', 'End'].forEach(pos => sel.getRangeAt(0)["set" + pos](node, start ? start : node.length)); } } catch (err) { // console.warn("Tagify: ", err) } }, placeCaretAfterNode(node) { if (!node || !node.parentNode) return; var nextSibling = node, sel = window.getSelection(), range = sel.getRangeAt(0); if (sel.rangeCount) { range.setStartAfter(nextSibling); range.collapse(true); // range.setEndBefore(nextSibling || node); sel.removeAllRanges(); sel.addRange(range); } }, insertAfterTag(tagElm, newNode) { newNode = newNode || this.settings.mixMode.insertAfterTag; if (!tagElm || !tagElm.parentNode || !newNode) return; newNode = typeof newNode == 'string' ? document.createTextNode(newNode) : newNode; tagElm.parentNode.insertBefore(newNode, tagElm.nextSibling); return newNode; }, // compares all "__originalData" property values with the current "tagData" properties // and returns "true" if something changed. editTagChangeDetected(tagData) { var originalData = tagData.__originalData; for (var prop in originalData) if (!this.dataProps.includes(prop) && tagData[prop] != originalData[prop]) return true; return false; // not changed }, // returns the node which has the actual tag's content getTagTextNode(tagElm) { return tagElm.querySelector(this.settings.classNames.tagTextSelector); }, // sets the text of a tag setTagTextNode(tagElm, HTML) { this.getTagTextNode(tagElm).innerHTML = escapeHTML(HTML); }, /** * Enters a tag into "edit" mode * @param {Node} tagElm the tag element to edit. if nothing specified, use last last */ editTag(tagElm, opts) { tagElm = tagElm || this.getLastTag(); opts = opts || {}; this.dropdown.hide(); var _s = this.settings, editableElm = this.getTagTextNode(tagElm), tagIdx = this.getNodeIndex(tagElm), tagData = getSetTagData(tagElm), _CB = this.events.callbacks, that = this, isValid = true, delayed_onEditTagBlur = function () { setTimeout(() => _CB.onEditTagBlur.call(that, that.getTagTextNode(tagElm))); }; if (!editableElm) { console.warn('Cannot find element in Tag template: .', _s.classNames.tagTextSelector); return; } if (tagData instanceof Object && "editable" in tagData && !tagData.editable) return; // cache the original data, on the DOM node, before any modification ocurs, for possible revert tagData = getSetTagData(tagElm, { __originalData: extend({}, tagData), __originalHTML: tagElm.cloneNode(true) }); // re-set the tagify custom-prop on the clones element (because cloning removed it) getSetTagData(tagData.__originalHTML, tagData.__originalData); editableElm.setAttribute('contenteditable', true); tagElm.classList.add(_s.classNames.tagEditing); editableElm.addEventListener('focus', _CB.onEditTagFocus.bind(this, tagElm)); editableElm.addEventListener('blur', delayed_onEditTagBlur); editableElm.addEventListener('input', _CB.onEditTagInput.bind(this, editableElm)); editableElm.addEventListener('paste', _CB.onEditTagPaste.bind(this, editableElm)); editableElm.addEventListener('keydown', e => _CB.onEditTagkeydown.call(this, e, tagElm)); editableElm.addEventListener('compositionstart', _CB.onCompositionStart.bind(this)); editableElm.addEventListener('compositionend', _CB.onCompositionEnd.bind(this)); if (!opts.skipValidation) isValid = this.editTagToggleValidity(tagElm); editableElm.originalIsValid = isValid; this.trigger("edit:start", { tag: tagElm, index: tagIdx, data: tagData, isValid }); editableElm.focus(); this.setRangeAtStartEnd(false, editableElm); // place the caret at the END of the editable tag text return this; }, /** * If a tag is invalid, for any reason, set its class to "not allowed" (see defaults file) * @param {Node} tagElm required * @param {Object} tagData optional * @returns true if valid, a string (reason) if not */ editTagToggleValidity(tagElm, tagData) { var tagData = tagData || getSetTagData(tagElm), isValid; if (!tagData) { console.warn("tag has no data: ", tagElm, tagData); return; } isValid = !("__isValid" in tagData) || tagData.__isValid === true; if (!isValid) { this.removeTagsFromValue(tagElm); } this.update(); //this.validateTag(tagData); tagElm.classList.toggle(this.settings.classNames.tagNotAllowed, !isValid); return tagData.__isValid; }, onEditTagDone(tagElm, tagData) { tagElm = tagElm || this.state.editing.scope; tagData = tagData || {}; var eventData = { tag: tagElm, index: this.getNodeIndex(tagElm), previousData: getSetTagData(tagElm), data: tagData }; this.trigger("edit:beforeUpdate", eventData, { cloneData: false }); this.state.editing = false; delete tagData.__originalData; delete tagData.__originalHTML; if (tagElm && tagData[this.settings.tagTextProp]) { tagElm = this.replaceTag(tagElm, tagData); this.editTagToggleValidity(tagElm, tagData); if (this.settings.a11y.focusableTags) tagElm.focus();else // place caret after edited tag this.placeCaretAfterNode(tagElm); } else if (tagElm) this.removeTags(tagElm); this.trigger("edit:updated", eventData); this.dropdown.hide(); // check if any of the current tags which might have been marked as "duplicate" should be now un-marked if (this.settings.keepInvalidTags) this.reCheckInvalidTags(); }, /** * Replaces an exisitng tag with a new one. Used for updating a tag's data * @param {Object} tagElm [DOM node to replace] * @param {Object} tagData [data to create new tag from] */ replaceTag(tagElm, tagData) { if (!tagData || !tagData.value) tagData = tagElm.__tagifyTagData; // if tag is invalid, make the according changes in the newly created element if (tagData.__isValid && tagData.__isValid != true) extend(tagData, this.getInvalidTagAttrs(tagData, tagData.__isValid)); var newTagElm = this.createTagElem(tagData); // update DOM tagElm.parentNode.replaceChild(newTagElm, tagElm); this.updateValueByDOMTags(); return newTagElm; }, /** * update "value" (Array of Objects) by traversing all valid tags */ updateValueByDOMTags() { this.value.length = 0; [].forEach.call(this.getTagElms(), node => { if (node.classList.contains(this.settings.classNames.tagNotAllowed.split(' ')[0])) return; this.value.push(getSetTagData(node)); }); this.update(); }, /** * injects nodes/text at caret position, which is saved on the "state" when "blur" event gets triggered * @param {Node} injectedNode [the node to inject at the caret position] * @param {Object} selection [optional range Object. must have "anchorNode" & "anchorOffset"] */ injectAtCaret(injectedNode, range) { range = range || this.state.selection?.range; if (!range && injectedNode) { this.appendMixTags(injectedNode); return this; } injectAtCaret(injectedNode, range); this.setRangeAtStartEnd(false, injectedNode); this.updateValueByDOMTags(); // updates internal "this.value" this.update(); // updates original input/textarea return this; }, /** * input bridge for accessing & setting * @type {Object} */ input: { set() { let s = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; let updateDOM = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; var hideDropdown = this.settings.dropdown.closeOnSelect; this.state.inputText = s; if (updateDOM) this.DOM.input.innerHTML = escapeHTML("" + s); if (!s && hideDropdown) this.dropdown.hide.bind(this); this.input.autocomplete.suggest.call(this); this.input.validate.call(this); }, raw() { return this.DOM.input.textContent; }, /** * Marks the tagify's input as "invalid" if the value did not pass "validateTag()" */ validate() { var isValid = !this.state.inputText || this.validateTag({ value: this.state.inputText }) === true; this.DOM.input.classList.toggle(this.settings.classNames.inputInvalid, !isValid); return isValid; }, // remove any child DOM elements that aren't of type TEXT (like <br>) normalize(node) { var clone = node || this.DOM.input, //.cloneNode(true), v = []; // when a text was pasted in FF, the "this.DOM.input" element will have <br> but no newline symbols (\n), and this will // result in tags not being properly created if one wishes to create a separate tag per newline. clone.childNodes.forEach(n => n.nodeType == 3 && v.push(n.nodeValue)); v = v.join("\n"); try { // "delimiters" might be of a non-regex value, where this will fail ("Tags With Properties" example in demo page): v = v.replace(/(?:\r\n|\r|\n)/g, this.settings.delimiters.source.charAt(0)); } catch (err) {} v = v.replace(/\s/g, ' '); // replace NBSPs with spaces characters return this.trim(v); }, /** * suggest the rest of the input's value (via CSS "::after" using "content:attr(...)") * @param {String} s [description] */ autocomplete: { suggest(data) { if (!this.settings.autoComplete.enabled) return; data = data || { value: '' }; if (typeof data == 'string') data = { value: data }; var suggestedText = this.dropdown.getMappedValue(data); if (typeof suggestedText === 'number') return; var suggestionStart = suggestedText.substr(0, this.state.inputText.length).toLowerCase(), suggestionTrimmed = suggestedText.substring(this.state.inputText.length); if (!suggestedText || !this.state.inputText || suggestionStart != this.state.inputText.toLowerCase()) { this.DOM.input.removeAttribute("data-suggest"); delete this.state.inputSuggestion; } else { this.DOM.input.setAttribute("data-suggest", suggestionTrimmed); this.state.inputSuggestion = data; } }, /** * sets the suggested text as the input's value & cleanup the suggestion autocomplete. * @param {String} s [text] */ set(s) { var dataSuggest = this.DOM.input.getAttribute('data-suggest'), suggestion = s || (dataSuggest ? this.state.inputText + dataSuggest : null); if (suggestion) { if (this.settings.mode == 'mix') { this.replaceTextWithNode(document.createTextNode(this.state.tag.prefix + suggestion)); } else { this.input.set.call(this, suggestion); this.setRangeAtStartEnd(false, this.DOM.input); } this.input.autocomplete.suggest.call(this); this.dropdown.hide(); return true; } return false; } } }, /** * returns the index of the the tagData within the "this.value" array collection. * since values should be unique, it is suffice to only search by "value" property * @param {Object} tagData */ getTagIdx(tagData) { return this.value.findIndex(item => item.__tagId == (tagData || {}).__tagId); }, getNodeIndex(node) { var index = 0; if (node) while (node = node.previousElementSibling) index++; return index; }, getTagElms() { for (var _len = arguments.length, classess = new Array(_len), _key = 0; _key < _len; _key++) { classess[_key] = arguments[_key]; } var classname = '.' + [...this.settings.classNames.tag.split(' '), ...classess].join('.'); return [].slice.call(this.DOM.scope.querySelectorAll(classname)); // convert nodeList to Array - https://stackoverflow.com/a/3199627/104380 }, /** * gets the last non-readonly, not-in-the-proccess-of-removal tag */ getLastTag() { var lastTag = this.DOM.scope.querySelectorAll(`${this.settings.classNames.tagSelector}:not(.${this.settings.classNames.tagHide}):not([readonly])`); return lastTag[lastTag.length - 1]; }, /** * Searches if any tag with a certain value already exis * @param {String/Object} value [text value / tag data object] * @param {Boolean} caseSensitive * @return {Number} */ isTagDuplicate(value, caseSensitive, tagId) { var dupsCount = 0, _s = this.settings; // duplications are irrelevant for this scenario if (_s.mode == 'select') return false; for (let item of this.value) { let isSameStr = sameStr(this.trim("" + value), item.value, caseSensitive); if (isSameStr && tagId != item.__tagId) dupsCount++; } return dupsCount; }, getTagIndexByValue(value) { var indices = []; this.getTagElms().forEach((tagElm, i) => { if (sameStr(this.trim(tagElm.textContent), value, this.settings.dropdown.caseSensitive)) indices.push(i); }); return indices; }, getTagElmByValue(value) { var tagIdx = this.getTagIndexByValue(value)[0]; return this.getTagElms()[tagIdx]; }, /** * Temporarily marks a tag element (by value or Node argument) * @param {Object} tagElm [a specific "tag" element to compare to the other tag elements siblings] */ flashTag(tagElm) { if (tagElm) { tagElm.classList.add(this.settings.classNames.tagFlash); setTimeout(() => { tagElm.classList.remove(this.settings.classNames.tagFlash); }, 100); } }, /** * checks if text is in the blacklist */ isTagBlacklisted(v) { v = this.trim(v.toLowerCase()); return this.settings.blacklist.filter(x => ("" + x).toLowerCase() == v).length; }, /** * checks if text is in the whitelist */ isTagWhitelisted(v) { return !!this.getWhitelistItem(v); /* return this.settings.whitelist.some(item => typeof v == 'string' ? sameStr(this.trim(v), (item.value || item)) : sameStr(JSON.stringify(item), JSON.stringify(v)) ) */ }, /** * Returns the first whitelist item matched, by value (if match found) * @param {String} value [text to match by] */ getWhitelistItem(value, prop, whitelist) { var result, prop = prop || 'value', _s = this.settings, whitelist = whitelist || _s.whitelist; whitelist.some(_wi => { var _wiv = typeof _wi == 'string' ? _wi : _wi[prop] || _wi.value, isSameStr = sameStr(_wiv, value, _s.dropdown.caseSensitive, _s.trim); if (isSameStr) { result = typeof _wi == 'string' ? { value: _wi } : _wi; return true; } }); // first iterate the whitelist, try find matches by "value" and if that fails // and a "tagTextProp" is set to be other than "value", try that also if (!result && prop == 'value' && _s.tagTextProp != 'value') { // if found, adds the first which matches result = this.getWhitelistItem(value, _s.tagTextProp, whitelist); } return result; }, /** * validate a tag object BEFORE the actual tag will be created & appeneded * @param {String} s * @param {String} uid [unique ID, to not inclue own tag when cheking for duplicates] * @return {Boolean/String} ["true" if validation has passed, String for a fail] */ validateTag(tagData) { var _s = this.settings, // when validating a tag in edit-mode, need to take "tagTextProp" into consideration prop = "value" in tagData ? "value" : _s.tagTextProp, v = this.trim(tagData[prop] + ""); // check for definitive empty value if (!(tagData[prop] + "").trim()) return this.TEXTS.empty; // check if pattern should be used and if so, use it to test the value if (_s.pattern && _s.pattern instanceof RegExp && !_s.pattern.test(v)) return this.TEXTS.pattern; // check for duplicates if (!_s.duplicates && this.isTagDuplicate(v, _s.dropdown.caseSensitive, tagData.__tagId)) return this.TEXTS.duplicate; if (this.isTagBlacklisted(v) || _s.enforceWhitelist && !this.isTagWhitelisted(v)) return this.TEXTS.notAllowed; if (_s.validate) return _s.validate(tagData); return true; }, getInvalidTagAttrs(tagData, validation) { return { "aria-invalid": true, "class": `${tagData.class || ''} ${this.settings.classNames.tagNotAllowed}`.trim(), "title": validation }; }, hasMaxTags() { return this.value.length >= this.settings.maxTags ? this.TEXTS.exceed : false; }, setReadonly(toggle, attrribute) { var _s = this.settings; document.activeElement.blur(); // exit possible edit-mode _s[attrribute || 'readonly'] = toggle; this.DOM.scope[(toggle ? 'set' : 'remove') + 'Attribute'](attrribute || 'readonly', true); this.setContentEditable(!toggle); }, setContentEditable(state) { if (!this.settings.userInput) return; this.DOM.input.contentEditable = state; this.DOM.input.tabIndex = !!state ? 0 : -1; }, setDisabled(isDisabled) { this.setReadonly(isDisabled, 'disabled'); }, /** * pre-proccess the tagsItems, which can be a complex tagsItems like an Array of Objects or a string comprised of multiple words * so each item should be iterated on and a tag created for. * @return {Array} [Array of Objects] */ normalizeTags(tagsItems) { var _this$settings = this.settings, whitelist = _this$settings.whitelist, delimiters = _this$settings.delimiters, mode = _this$settings.mode, tagTextProp = _this$settings.tagTextProp, whitelistMatches = [], whitelistWithProps = whitelist ? whitelist[0] instanceof Object : false, isArray = Array.isArray(tagsItems), isCollection = isArray && tagsItems[0].value, mapStringToCollection = s => (s + "").split(delimiters).filter(n => n).map(v => ({ [tagTextProp]: this.trim(v), value: this.trim(v) })); if (typeof tagsItems == 'number') tagsItems = tagsItems.toString(); // if the argument is a "simple" String, ex: "aaa, bbb, ccc" if (typeof tagsItems == 'string') { if (!tagsItems.trim()) return []; // go over each tag and add it (if there were multiple ones) tagsItems = mapStringToCollection(tagsItems); } // if is an Array of Strings, convert to an Array of Objects else if (isArray) { // flatten the 2D array tagsItems = [].concat(...tagsItems.map(item => item.value != undefined ? item // mapStringToCollection(item.value).map(newItem => ({...item,...newItem})) : mapStringToCollection(item))); } // search if the tag exists in the whitelist as an Object (has props), // to be able to use its properties. // skip matching collections with whitelist items as they are considered "whole" if (whitelistWithProps && !isCollection) { tagsItems.forEach(item => { var whitelistMatchesValues = whitelistMatches.map(a => a.value); // if suggestions are shown, they are already filtered, so it's easier to use them, // because the whitelist might also include items which have already been added var filteredList = this.dropdown.filterListItems.call(this, item[tagTextProp], { exact: true }); if (!this.settings.duplicates) // also filter out items which have already been matched in previous iterations filteredList = filteredList.filter(filteredItem => !whitelistMatchesValues.includes(filteredItem.value)); // get the best match out of list of possible matches. // if there was a single item in the filtered list, use that one var matchObj = filteredList.length > 1 ? this.getWhitelistItem(item[tagTextProp], tagTextProp, filteredList) : filteredList[0]; if (matchObj && matchObj instanceof Object) { whitelistMatches.push(matchObj); // set the Array (with the found Object) as the new value } else if (mode != 'mix') { if (item.value == undefined) item.value = item[tagTextProp]; whitelistMatches.push(item); } }); if (whitelistMatches.length) tagsItems = whitelistMatches; } return tagsItems; }, /** * Parse the initial value of a textarea (or input) element and generate mixed text w/ tags * https://stackoverflow.com/a/57598892/104380 * @param {String} s */ parseMixTags(s) { var _this$settings2 = this.settings, mixTagsInterpolator = _this$settings2.mixTagsInterpolator, duplicates = _this$settings2.duplicates, transformTag = _this$settings2.transformTag, enforceWhitelist = _this$settings2.enforceWhitelist, maxTags = _this$settings2.maxTags, tagTextProp = _this$settings2.tagTextProp, tagsDataSet = []; s = s.split(mixTagsInterpolator[0]).map((s1, i) => { var s2 = s1.split(mixTagsInterpolator[1]), preInterpolated = s2[0], maxTagsReached = tagsDataSet.length == maxTags, textProp, tagData, tagElm; try { // skip numbers and go straight to the "catch" statement if (preInterpolated == +preInterpolated) throw Error; tagData = JSON.parse(preInterpolated); } catch (err) { tagData = this.normalizeTags(preInterpolated)[0] || { value: preInterpolated }; } transformTag.call(this, tagData); if (!maxTagsReached && s2.length > 1 && (!enforceWhitelist || this.isTagWhitelisted(tagData.value)) && !(!duplicates && this.isTagDuplicate(tagData.value))) { // in case "tagTextProp" setting is set to other than "value" and this tag does not have this prop textProp = tagData[tagTextProp] ? tagTextProp : 'value'; tagData[textProp] = this.trim(tagData[textProp]); tagElm = this.createTagElem(tagData); tagsDataSet.push(tagData); tagElm.classList.add(this.settings.classNames.tagNoAnimation); s2[0] = tagElm.outerHTML; //+ "⁠" // put a zero-space at the end so the caret won't jump back to the start (when the last input's child element is a tag) this.value.push(tagData); } else if (s1) return i ? mixTagsInterpolator[0] + s1 : s1; return s2.join(''); }).join(''); this.DOM.input.innerHTML = s; this.DOM.input.appendChild(document.createTextNode('')); this.DOM.input.normalize(); this.getTagElms().forEach((elm, idx) => getSetTagData(elm, tagsDataSet[idx])); this.update({ withoutChangeEvent: true }); return s; }, /** * For mixed-mode: replaces a text starting with a prefix with a wrapper element (tag or something) * First there *has* to be a "this.state.tag" which is a string that was just typed and is staring with a prefix */ replaceTextWithNode(newWrapperNode, strToReplace) { if (!this.state.tag && !strToReplace) return; strToReplace = strToReplace || this.state.tag.prefix + this.state.tag.value; var idx, nodeToReplace, selection = this.state.selection || window.getSelection(), nodeAtCaret = selection.anchorNode, firstSplitOffset = this.state.tag.delimiters ? this.state.tag.delimiters.length : 0; // STEP 1: ex. replace #ba with the tag "bart" where "|" is where the caret is: // CURRENT STATE: "foo #ba #ba| #ba" // split the text node at the index of the caret nodeAtCaret.splitText(selection.anchorOffset - firstSplitOffset); // node 0: "foo #ba #ba|" // node 1: " #ba" // get index of LAST occurence of "#ba" idx = nodeAtCaret.nodeValue.lastIndexOf(strToReplace); if (idx == -1) return true; nodeToReplace = nodeAtCaret.splitText(idx); // node 0: "foo #ba " // node 1: "#ba" <- nodeToReplace newWrapperNode && nodeAtCaret.parentNode.replaceChild(newWrapperNode, nodeToReplace); // must NOT normalize contenteditable or it will cause unwanted issues: // https://monosnap.com/file/ZDVmRvq5upYkidiFedvrwzSswegWk7 // nodeAtCaret.parentNode.normalize() return true; }, /** * For selecting a single option (not used for multiple tags, but for "mode:select" only) * @param {Object} tagElm Tag DOM node * @param {Object} tagData Tag data */ selectTag(tagElm, tagData) { var _s = this.settings; if (_s.enforceWhitelist && !this.isTagWhitelisted(tagData.value)) return; this.input.set.call(this, tagData[_s.tagTextProp] || tagData.value, true); // place the caret at the end of the input, only if a dropdown option was selected (and not by manually typing another value and clicking "TAB") if (this.state.actions.selectOption) setTimeout(() => this.setRangeAtStartEnd(false, this.DOM.input)); var lastTagElm = this.getLastTag(); if (lastTagElm) this.replaceTag(lastTagElm, tagData);else this.appendTag(tagElm); // if( _s.enforceWhitelist ) // this.setContentEditable(false); this.value[0] = tagData; this.update(); this.trigger('add', { tag: tagElm, data: tagData }); return [tagElm]; }, /** * add an empty "tag" element in an editable state */ addEmptyTag(initialData) { var tagData = extend({ value: "" }, initialData || {}), tagElm = this.createTagElem(tagData); getSetTagData(tagElm, tagData); // add the tag to the component's DOM this.appendTag(tagElm); this.editTag(tagElm, { skipValidation: true }); }, /** * add a "tag" element to the "tags" component * @param {String/Array} tagsItems [A string (single or multiple values with a delimiter), or an Array of Objects or just Array of Strings] * @param {Boolean} clearInput [flag if the input's value should be cleared after adding tags] * @param {Boolean} skipInvalid [do not add, mark & remove invalid tags] * @return {Array} Array of DOM elements (tags) */ addTags(tagsItems, clearInput, skipInvalid) { var tagElems = [], _s = this.settings, aggregatedinvalidInput = [], frag = document.createDocumentFragment(); skipInvalid = skipInvalid || _s.skipInvalid; if (!tagsItems || tagsItems.length == 0) { return tagElems; } // converts Array/String/Object to an Array of Objects tagsItems = this.normalizeTags(tagsItems); switch (_s.mode) { case 'mix': return this.addMixTags(tagsItems); case 'select': { clearInput = false; this.removeAllTags(); } } this.DOM.input.removeAttribute('style'); tagsItems.forEach(tagData => { var tagElm, tagElmParams = {}, originalData = Object.assign({}, tagData, { value: tagData.value + "" }); // shallow-clone tagData so later modifications will not apply to the source tagData = Object.assign({}, originalData); _s.transformTag.call(this, tagData); tagData.__isValid = this.hasMaxTags() || this.validateTag(tagData); if (tagData.__isValid !== true) { if (skipInvalid) return; // originalData is kept because it might be that this tag is invalid because it is a duplicate of another, // and if that other tags is edited/deleted, this one should be re-validated and if is no more a duplicate - restored extend(tagElmParams, this.getInvalidTagAttrs(tagData, tagData.__isValid), { __preInvalidData: originalData }); if (tagData.__isValid == this.TEXTS.duplicate) // mark, for a brief moment, the tag (this this one) which THIS CURRENT tag is a duplcate of this.flashTag(this.getTagElmByValue(tagData.value)); if (!_s.createInvalidTags) { aggregatedinvalidInput.push(tagData.value); return; } } if ('readonly' in tagData) { if (tagData.readonly) tagElmParams["aria-readonly"] = true; // if "readonly" is "false", remove it from the tagData so it won't be added as an attribute in the template else delete tagData.readonly; } // Create tag HTML element tagElm = this.createTagElem(tagData, tagElmParams); tagElems.push(tagElm); // mode-select overrides if (_s.mode == 'select') { return this.selectTag(tagElm, tagData); } // add the tag to the component's DOM // this.appendTag(tagElm) frag.appendChild(tagElm); if (tagData.__isValid && tagData.__isValid === true) { // update state this.value.push(tagData); this.trigger('add', { tag: tagElm, index: this.value.length - 1, data: tagData }); } else { this.trigger("invalid", { data: tagData, index: this.value.length, tag: tagElm, message: tagData.__isValid }); if (!_s.keepInvalidTags) // remove invalid tags (if "keepInvalidTags" is set to "false") setTimeout(() => this.removeTags(tagElm, true), 1000); } this.dropdown.position(); // reposition the dropdown because the just-added tag might cause a new-line }); this.appendTag(frag); this.update(); if (tagsItems.length && clearInput) { this.input.set.call(this, _s.createInvalidTags ? '' : aggregatedinvalidInput.join(_s._delimiters)); this.setRangeAtStartEnd(false, this.DOM.input); } _s.dropdown.enabled && this.dropdown.refilter(); return tagElems; }, /** * Adds a mix-content tag * @param {String/Array} tagData A string (single or multiple values with a delimiter), or an Array of Objects or just Array of Strings */ addMixTags(tagsData) { tagsData = this.normalizeTags(tagsData); if (tagsData[0].prefix || this.state.tag) { return this.prefixedTextToTag(tagsData[0]); } var frag = document.createDocumentFragment(); tagsData.forEach(tagData => { var tagElm = this.createTagElem(tagData); frag.appendChild(tagElm); }); this.appendMixTags(frag); return frag; }, appendMixTags(node) { var selection = !!this.state.selection; // if "selection" exists, assumes intention of inecting the new tag at the last // saved location of the caret inside "this.DOM.input" if (selection) { this.injectAtCaret(node); } // else, create a range and inject the new tag as the last child of "this.DOM.input" else { this.DOM.input.focus(); selection = this.setStateSelection(); selection.range.setStart(this.DOM.input, selection.range.endOffset); selection.range.setEnd(this.DOM.input, selection.range.endOffset); this.DOM.input.appendChild(node); this.updateValueByDOMTags(); // updates internal "this.value" this.update(); // updates original input/textarea } }, /** * Adds a tag which was activly typed by the user * @param {String/Array} tagItem [A string (single or multiple values with a delimiter), or an Array of Objects or just Array of Strings] */ prefixedTextToTag(tagItem) { var _s = this.settings, tagElm, createdFromDelimiters = this.state.tag.delimiters; _s.transformTag.call(this, tagItem); tagItem.prefix = tagItem.prefix || this.state.tag ? this.state.tag.prefix : (_s.pattern.source || _s.pattern)[0]; // TODO: should check if the tag is valid tagElm = this.createTagElem(tagItem); // tries to replace a taged textNode with a tagElm, and if not able, // insert the new tag to the END if "addTags" was called from outside if (!this.replaceTextWithNode(tagElm)) { this.DOM.input.appendChild(tagElm); } setTimeout(() => tagElm.classList.add(this.settings.classNames.tagNoAnimation), 300); this.value.push(tagItem); this.update(); if (!createdFromDelimiters) { var elm = this.insertAfterTag(tagElm) || tagElm; // a timeout is needed when selecting a tag from the suggestions via mouse. // Without it, it seems the caret is placed right after the tag and not after the // node which was inserted after the tag (whitespace by default) setTimeout(this.placeCaretAfterNode, 0, elm); } this.state.tag = null; this.trigger('add', extend({}, { tag: tagElm }, { data: tagItem })); return tagElm; }, /** * appened (validated) tag to the component's DOM scope */ appendTag(tagElm) { var DOM = this.DOM, insertBeforeNode = DOM.input; //if( insertBeforeNode === DOM.input ) DOM.scope.insertBefore(tagElm, insertBeforeNode); //else // DOM.scope.appendChild(tagElm) }, /** * creates a DOM tag element and injects it into the component (this.DOM.scope) * @param {Object} tagData [text value & properties for the created tag] * @param {Object} extraData [properties which are for the HTML template only] * @return {Object} [DOM element] */ createTagElem(tagData, extraData) { tagData.__tagId = getUID(); var tagElm, templateData = extend({}, tagData, _objectSpread2({ value: escapeHTML(tagData.value + "") }, extraData)); // if( this.settings.readonly ) // tagData.readonly = true tagElm = this.parseTemplate('tag', [templateData, this]); // crucial for proper caret placement when deleting content. if textNodes are allowed as children of a tag element, // a browser bug casues the caret to be misplaced inside the tag element (especially affects "readonly" tags) removeTextChildNodes(tagElm); // while( tagElm.lastChild.nodeType == 3 ) // tagElm.lastChild.parentNode.removeChild(tagElm.lastChild) getSetTagData(tagElm, tagData); return tagElm; }, /** * re-check all invalid tags. * called after a tag was edited or removed */ reCheckInvalidTags() { var _s = this.settings; this.getTagElms(_s.classNames.tagNotAllowed).forEach((tagElm, i) => { var tagData = getSetTagData(tagElm), hasMaxTags = this.hasMaxTags(), tagValidation = this.validateTag(tagData), isValid = tagValidation === true && !hasMaxTags; if (_s.mode == 'select') this.toggleScopeValidation(tagValidation); // if the tag has become valid if (isValid) { tagData = tagData.__preInvalidData ? tagData.__preInvalidData : { value: tagData.value }; return this.replaceTag(tagElm, tagData); } // if the tag is still invaild, set its title as such (reson of invalid might have changed) tagElm.title = hasMaxTags || tagValidation; }); }, /** * Removes a tag * @param {Array|Node|String} tagElms [DOM element(s) or a String value. if undefined or null, remove last added tag] * @param {Boolean} silent [A flag, which when turned on, does not remove any value and does not update the original input value but simply removes the tag from tagify] * @param {Number} tranDuration [Transition duration in MS] * TODO: Allow multiple tags to be removed at-once */ removeTags(tagElms, silent, tranDuration) { var tagsToRemove, _s = this.settings; tagElms = tagElms && tagElms instanceof HTMLElement ? [tagElms] : tagElms instanceof Array ? tagElms : tagElms ? [tagElms] : [this.getLastTag()]; // normalize tagElms array values: // 1. removing invalid items // 2, if an item is String try to get the matching Tag HTML node // 3. get the tag data // 4. return a collection of Objects tagsToRemove = tagElms.reduce((elms, tagElm) => { if (tagElm && typeof tagElm == 'string') tagElm = this.getTagElmByValue(tagElm); var tagData = getSetTagData(tagElm); if (tagElm && tagData && !tagData.readonly) // make sure it's a tag and not some other node // because the DOM node might be removed by async animation, the state will be updated while // the node might still be in the DOM, so the "update" method should know which nodes to ignore elms.push({ node: tagElm, idx: this.getTagIdx(tagData), // this.getNodeIndex(tagElm); // this.getTagIndexByValue(tagElm.textContent) data: getSetTagData(tagElm, { '__removed': true }) }); return elms; }, []); tranDuration = typeof tranDuration == "number" ? tranDuration : this.CSSVars.tagHideTransition; if (_s.mode == 'select') { tranDuration = 0; this.input.set.call(this); } // if only a single tag is to be removed. // skip "select" mode because invalid tags are actually set to `this.value` if (tagsToRemove.length == 1 && _s.mode != 'select') { if (tagsToRemove[0].node.classList.contains(_s.classNames.tagNotAllowed)) silent = true; } if (!tagsToRemove.length) return; return _s.hooks.beforeRemoveTag(tagsToRemove, { tagify: this }).then(() => { function removeNode(tag) { if (!tag.node.parentNode) return; tag.node.parentNode.removeChild(tag.node); if (!silent) { // this.removeValueById(tagData.__uid) this.trigger('remove', { tag: tag.node, index: tag.idx, data: tag.data }); this.dropdown.refilter(); this.dropdown.position(); this.DOM.input.normalize(); // best-practice when in mix-mode (safe to do always anyways) // check if any of the current tags which might have been marked as "duplicate" should be un-marked if (_s.keepInvalidTags) this.reCheckInvalidTags(); // below code is unfinished. it should iterate all currently invalid edited tags, which their edits have not // changed the value yet, and should re-trigger the check, but since nothing has changed, it does not work... // this.getTagElms(_s.classNames.tagEditing).forEach( this.events.callbacks.onEditTagBlur.bind ) } else if (_s.keepInvalidTags) this.trigger('remove', { tag: tag.node, index: tag.idx }); } function animation(tag) { tag.node.style.width = parseFloat(window.getComputedStyle(tag.node).width) + 'px'; document.body.clientTop; // force repaint for the width to take affect before the "hide" class below tag.node.classList.add(_s.classNames.tagHide); // manual timeout (hack, since transitionend cannot be used because of hover) setTimeout(removeNode.bind(this), tranDuration, tag); } if (tranDuration && tranDuration > 10 && tagsToRemove.length == 1) animation.call(this, tagsToRemove[0]);else tagsToRemove.forEach(removeNode.bind(this)); // update state regardless of animation if (!silent) { this.removeTagsFromValue(tagsToRemove.map(tag => tag.node)); this.update(); // update the original input with the current value if (_s.mode == 'select') this.setContentEditable(true); } }).catch(reason => {}); }, removeTagsFromDOM() { [].slice.call(this.getTagElms()).forEach(elm => elm.parentNode.removeChild(elm)); }, /** * @param {Array/Node} tags to be removed from the this.value array */ removeTagsFromValue(tags) { tags = Array.isArray(tags) ? tags : [tags]; tags.forEach(tag => { var tagData = getSetTagData(tag), tagIdx = this.getTagIdx(tagData); // delete tagData.__removed if (tagIdx > -1) this.value.splice(tagIdx, 1); }); }, removeAllTags(opts) { opts = opts || {}; this.value = []; if (this.settings.mode == 'mix') this.DOM.input.innerHTML = '';else this.removeTagsFromDOM(); this.dropdown.refilter(); this.dropdown.position(); if (this.state.dropdown.visible) setTimeout(() => { this.DOM.input.focus(); }); if (this.settings.mode == 'select') { this.input.set.call(this); this.setContentEditable(true); } // technically for now only "withoutChangeEvent" exists in the opts. // if more properties will be added later, only pass what's needed to "update" this.update(opts); }, postUpdate() { this.state.blockChangeEvent = false; var _s = this.settings, classNames = _s.classNames, hasValue = _s.mode == 'mix' ? _s.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value.trim() : this.value.length + this.input.raw.call(this).length; this.toggleClass(classNames.hasMaxTags, this.value.length >= _s.maxTags); this.toggleClass(classNames.hasNoTags, !this.value.length); this.toggleClass(classNames.empty, !hasValue); // specifically the "select mode" might have the "invalid" classname set when the field is changed, so it must be toggled on add/remove/edit if (_s.mode == 'select') { this.toggleScopeValidation(this.value?.[0]?.__isValid); } }, setOriginalInputValue(v) { var inputElm = this.DOM.originalInput; if (!this.settings.mixMode.integrated) { inputElm.value = v; inputElm.tagifyValue = inputElm.value; // must set to "inputElm.value" and not again to "inputValue" because for some reason the browser changes the string afterwards a bit. this.setPersistedData(v, 'value'); } }, /** * update the origianl (hidden) input field's value * see - https://stackoverflow.com/q/50957841/104380 */ update(args) { const UPDATE_DELAY = 100; clearTimeout(this.debouncedUpdateTimeout); this.debouncedUpdateTimeout = setTimeout(reallyUpdate.bind(this), UPDATE_DELAY); function reallyUpdate() { var inputValue = this.getInputValue(); this.setOriginalInputValue(inputValue); if ((!this.settings.onChangeAfterBlur || !(args || {}).withoutChangeEvent) && !this.state.blockChangeEvent) this.triggerChangeEvent(); this.postUpdate(); } }, getInputValue() { var value = this.getCleanValue(); return this.settings.mode == 'mix' ? this.getMixedTagsAsString(value) : value.length ? this.settings.originalInputValueFormat ? this.settings.originalInputValueFormat(value) : JSON.stringify(value) : ""; }, /** * removes properties from `this.value` which are only used internally */ getCleanValue(v) { return removeCollectionProp(v || this.value, this.dataProps); }, getMixedTagsAsString() { var result = "", that = this, _s = this.settings, originalInputValueFormat = _s.originalInputValueFormat || JSON.stringify, _interpolator = _s.mixTagsInterpolator; function iterateChildren(rootNode) { rootNode.childNodes.forEach(node => { if (node.nodeType == 1) { const tagData = getSetTagData(node); if (node.tagName == 'BR') { result += "\r\n"; } if (tagData && isNodeTag.call(that, node)) { if (tagData.__removed) return;else result += _interpolator[0] + originalInputValueFormat(omit(tagData, that.dataProps)) + _interpolator[1]; } else if (node.getAttribute('style') || ['B', 'I', 'U'].includes(node.tagName)) result += node.textContent;else if (node.tagName == 'DIV' || node.tagName == 'P') { result += "\r\n"; // if( !node.children.length && node.textContent ) // result += node.textContent; iterateChildren(node); } } else result += node.textContent; }); } iterateChildren(this.DOM.input); return result; } }; // legacy support for changed methods names Tagify.prototype.removeTag = Tagify.prototype.removeTags; export { Tagify as default };
Close