import "./utils"; import TributeEvents from "./TributeEvents"; import TributeMenuEvents from "./TributeMenuEvents"; import TributeRange from "./TributeRange"; import TributeSearch from "./TributeSearch"; class Tribute { constructor({ values = null, iframe = null, selectClass = "highlight", containerClass = "tribute-container", itemClass = "", trigger = "@", autocompleteMode = false, selectTemplate = null, menuItemTemplate = null, lookup = "key", fillAttr = "value", collection = null, menuContainer = null, noMatchTemplate = null, requireLeadingSpace = true, allowSpaces = false, replaceTextSuffix = null, positionMenu = true, spaceSelectsMatch = false, searchOpts = {}, menuItemLimit = null, menuShowMinLength = 0 }) { this.autocompleteMode = autocompleteMode; this.menuSelected = 0; this.current = {}; this.inputEvent = false; this.isActive = false; this.menuContainer = menuContainer; this.allowSpaces = allowSpaces; this.replaceTextSuffix = replaceTextSuffix; this.positionMenu = positionMenu; this.hasTrailingSpace = false; this.spaceSelectsMatch = spaceSelectsMatch; if (this.autocompleteMode) { trigger = ""; allowSpaces = false; } if (values) { this.collection = [ { // symbol that starts the lookup trigger: trigger, // is it wrapped in an iframe iframe: iframe, // class applied to selected item selectClass: selectClass, // class applied to the Container containerClass: containerClass, // class applied to each item itemClass: itemClass, // function called on select that retuns the content to insert selectTemplate: ( selectTemplate || Tribute.defaultSelectTemplate ).bind(this), // function called that returns content for an item menuItemTemplate: ( menuItemTemplate || Tribute.defaultMenuItemTemplate ).bind(this), // function called when menu is empty, disables hiding of menu. noMatchTemplate: (t => { if (typeof t === "string") { if (t.trim() === "") return null; return t; } if (typeof t === "function") { return t.bind(this); } return ( noMatchTemplate || function() { return "
  • No Match Found!
  • "; }.bind(this) ); })(noMatchTemplate), // column to search against in the object lookup: lookup, // column that contains the content to insert by default fillAttr: fillAttr, // array of objects or a function returning an array of objects values: values, requireLeadingSpace: requireLeadingSpace, searchOpts: searchOpts, menuItemLimit: menuItemLimit, menuShowMinLength: menuShowMinLength } ]; } else if (collection) { if (this.autocompleteMode) console.warn( "Tribute in autocomplete mode does not work for collections" ); this.collection = collection.map(item => { return { trigger: item.trigger || trigger, iframe: item.iframe || iframe, selectClass: item.selectClass || selectClass, containerClass: item.containerClass || containerClass, itemClass: item.itemClass || itemClass, selectTemplate: ( item.selectTemplate || Tribute.defaultSelectTemplate ).bind(this), menuItemTemplate: ( item.menuItemTemplate || Tribute.defaultMenuItemTemplate ).bind(this), // function called when menu is empty, disables hiding of menu. noMatchTemplate: (t => { if (typeof t === "string") { if (t.trim() === "") return null; return t; } if (typeof t === "function") { return t.bind(this); } return ( noMatchTemplate || function() { return "
  • No Match Found!
  • "; }.bind(this) ); })(noMatchTemplate), lookup: item.lookup || lookup, fillAttr: item.fillAttr || fillAttr, values: item.values, requireLeadingSpace: item.requireLeadingSpace, searchOpts: item.searchOpts || searchOpts, menuItemLimit: item.menuItemLimit || menuItemLimit, menuShowMinLength: item.menuShowMinLength || menuShowMinLength }; }); } else { throw new Error("[Tribute] No collection specified."); } new TributeRange(this); new TributeEvents(this); new TributeMenuEvents(this); new TributeSearch(this); } get isActive() { return this._isActive; } set isActive(val) { if (this._isActive != val) { this._isActive = val; if (this.current.element) { let noMatchEvent = new CustomEvent(`tribute-active-${val}`); this.current.element.dispatchEvent(noMatchEvent); } } } static defaultSelectTemplate(item) { if (typeof item === "undefined") return `${this.current.collection.trigger}${this.current.mentionText}`; if (this.range.isContentEditable(this.current.element)) { return ( '' + (this.current.collection.trigger + item.original[this.current.collection.fillAttr]) + "" ); } return ( this.current.collection.trigger + item.original[this.current.collection.fillAttr] ); } static defaultMenuItemTemplate(matchItem) { return matchItem.string; } static inputTypes() { return ["TEXTAREA", "INPUT"]; } triggers() { return this.collection.map(config => { return config.trigger; }); } attach(el) { if (!el) { throw new Error("[Tribute] Must pass in a DOM node or NodeList."); } // Check if it is a jQuery collection if (typeof jQuery !== "undefined" && el instanceof jQuery) { el = el.get(); } // Is el an Array/Array-like object? if ( el.constructor === NodeList || el.constructor === HTMLCollection || el.constructor === Array ) { let length = el.length; for (var i = 0; i < length; ++i) { this._attach(el[i]); } } else { this._attach(el); } } _attach(el) { if (el.hasAttribute("data-tribute")) { console.warn("Tribute was already bound to " + el.nodeName); } this.ensureEditable(el); this.events.bind(el); el.setAttribute("data-tribute", true); } ensureEditable(element) { if (Tribute.inputTypes().indexOf(element.nodeName) === -1) { if (element.contentEditable) { element.contentEditable = true; } else { throw new Error("[Tribute] Cannot bind to " + element.nodeName); } } } createMenu(containerClass) { let wrapper = this.range.getDocument().createElement("div"), ul = this.range.getDocument().createElement("ul"); wrapper.className = containerClass; wrapper.appendChild(ul); if (this.menuContainer) { return this.menuContainer.appendChild(wrapper); } return this.range.getDocument().body.appendChild(wrapper); } showMenuFor(element, scrollTo) { // Only proceed if menu isn't already shown for the current element & mentionText if ( this.isActive && this.current.element === element && this.current.mentionText === this.currentMentionTextSnapshot ) { return; } this.currentMentionTextSnapshot = this.current.mentionText; // create the menu if it doesn't exist. if (!this.menu) { this.menu = this.createMenu(this.current.collection.containerClass); element.tributeMenu = this.menu; this.menuEvents.bind(this.menu); } this.isActive = true; this.menuSelected = 0; if (!this.current.mentionText) { this.current.mentionText = ""; } const processValues = values => { // Tribute may not be active any more by the time the value callback returns if (!this.isActive) { return; } let items = this.search.filter(this.current.mentionText, values, { pre: this.current.collection.searchOpts.pre || "", post: this.current.collection.searchOpts.post || "", skip: this.current.collection.searchOpts.skip, extract: el => { if (typeof this.current.collection.lookup === "string") { return el[this.current.collection.lookup]; } else if (typeof this.current.collection.lookup === "function") { return this.current.collection.lookup(el, this.current.mentionText); } else { throw new Error( "Invalid lookup attribute, lookup must be string or function." ); } } }); if (this.current.collection.menuItemLimit) { items = items.slice(0, this.current.collection.menuItemLimit); } this.current.filteredItems = items; let ul = this.menu.querySelector("ul"); this.range.positionMenuAtCaret(scrollTo); if (!items.length) { let noMatchEvent = new CustomEvent("tribute-no-match", { detail: this.menu }); this.current.element.dispatchEvent(noMatchEvent); if ( (typeof this.current.collection.noMatchTemplate === "function" && !this.current.collection.noMatchTemplate()) || !this.current.collection.noMatchTemplate ) { this.hideMenu(); } else { typeof this.current.collection.noMatchTemplate === "function" ? (ul.innerHTML = this.current.collection.noMatchTemplate()) : (ul.innerHTML = this.current.collection.noMatchTemplate); } return; } ul.innerHTML = ""; let fragment = this.range.getDocument().createDocumentFragment(); items.forEach((item, index) => { let li = this.range.getDocument().createElement("li"); li.setAttribute("data-index", index); li.className = this.current.collection.itemClass; li.addEventListener("mousemove", e => { let [li, index] = this._findLiTarget(e.target); if (e.movementY !== 0) { this.events.setActiveLi(index); } }); if (this.menuSelected === index) { li.classList.add(this.current.collection.selectClass); } li.innerHTML = this.current.collection.menuItemTemplate(item); fragment.appendChild(li); }); ul.appendChild(fragment); }; if (typeof this.current.collection.values === "function") { this.current.collection.values(this.current.mentionText, processValues); } else { processValues(this.current.collection.values); } } _findLiTarget(el) { if (!el) return []; const index = el.getAttribute("data-index"); return !index ? this._findLiTarget(el.parentNode) : [el, index]; } showMenuForCollection(element, collectionIndex) { if (element !== document.activeElement) { this.placeCaretAtEnd(element); } this.current.collection = this.collection[collectionIndex || 0]; this.current.externalTrigger = true; this.current.element = element; if (element.isContentEditable) this.insertTextAtCursor(this.current.collection.trigger); else this.insertAtCaret(element, this.current.collection.trigger); this.showMenuFor(element); } // TODO: make sure this works for inputs/textareas placeCaretAtEnd(el) { el.focus(); if ( typeof window.getSelection != "undefined" && typeof document.createRange != "undefined" ) { var range = document.createRange(); range.selectNodeContents(el); range.collapse(false); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } else if (typeof document.body.createTextRange != "undefined") { var textRange = document.body.createTextRange(); textRange.moveToElementText(el); textRange.collapse(false); textRange.select(); } } // for contenteditable insertTextAtCursor(text) { var sel, range, html; sel = window.getSelection(); range = sel.getRangeAt(0); range.deleteContents(); var textNode = document.createTextNode(text); range.insertNode(textNode); range.selectNodeContents(textNode); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); } // for regular inputs insertAtCaret(textarea, text) { var scrollPos = textarea.scrollTop; var caretPos = textarea.selectionStart; var front = textarea.value.substring(0, caretPos); var back = textarea.value.substring( textarea.selectionEnd, textarea.value.length ); textarea.value = front + text + back; caretPos = caretPos + text.length; textarea.selectionStart = caretPos; textarea.selectionEnd = caretPos; textarea.focus(); textarea.scrollTop = scrollPos; } hideMenu() { if (this.menu) { this.menu.style.cssText = "display: none;"; this.isActive = false; this.menuSelected = 0; this.current = {}; } } selectItemAtIndex(index, originalEvent) { index = parseInt(index); if (typeof index !== "number" || isNaN(index)) return; let item = this.current.filteredItems[index]; let content = this.current.collection.selectTemplate(item); if (content !== null) this.replaceText(content, originalEvent, item); } replaceText(content, originalEvent, item) { this.range.replaceTriggerText(content, true, true, originalEvent, item); } _append(collection, newValues, replace) { if (typeof collection.values === "function") { throw new Error("Unable to append to values, as it is a function."); } else if (!replace) { collection.values = collection.values.concat(newValues); } else { collection.values = newValues; } } append(collectionIndex, newValues, replace) { let index = parseInt(collectionIndex); if (typeof index !== "number") throw new Error("please provide an index for the collection to update."); let collection = this.collection[index]; this._append(collection, newValues, replace); } appendCurrent(newValues, replace) { if (this.isActive) { this._append(this.current.collection, newValues, replace); } else { throw new Error( "No active state. Please use append instead and pass an index." ); } } detach(el) { if (!el) { throw new Error("[Tribute] Must pass in a DOM node or NodeList."); } // Check if it is a jQuery collection if (typeof jQuery !== "undefined" && el instanceof jQuery) { el = el.get(); } // Is el an Array/Array-like object? if ( el.constructor === NodeList || el.constructor === HTMLCollection || el.constructor === Array ) { let length = el.length; for (var i = 0; i < length; ++i) { this._detach(el[i]); } } else { this._detach(el); } } _detach(el) { this.events.unbind(el); if (el.tributeMenu) { this.menuEvents.unbind(el.tributeMenu); } setTimeout(() => { el.removeAttribute("data-tribute"); this.isActive = false; if (el.tributeMenu) { el.tributeMenu.remove(); } }); } } export default Tribute;