if (!Array.prototype.find) { Array.prototype.find = function(predicate) { if (this === null) { throw new TypeError('Array.prototype.find called on null or undefined') } if (typeof predicate !== 'function') { throw new TypeError('predicate must be a function') } var list = Object(this); var length = list.length >>> 0; var thisArg = arguments[1]; var value; for (var i = 0; i < length; i++) { value = list[i]; if (predicate.call(thisArg, value, i, list)) { return value } } return undefined }; } if (window && typeof window.CustomEvent !== "function") { function CustomEvent$1(event, params) { params = params || { bubbles: false, cancelable: false, detail: undefined }; var evt = document.createEvent('CustomEvent'); evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); return evt } if (typeof window.Event !== 'undefined') { CustomEvent$1.prototype = window.Event.prototype; } window.CustomEvent = CustomEvent$1; } class TributeEvents { constructor(tribute) { this.tribute = tribute; this.tribute.events = this; } static keys() { return [ { key: 9, value: "TAB" }, { key: 8, value: "DELETE" }, { key: 13, value: "ENTER" }, { key: 27, value: "ESCAPE" }, { key: 32, value: "SPACE" }, { key: 38, value: "UP" }, { key: 40, value: "DOWN" } ]; } bind(element) { element.boundKeydown = this.keydown.bind(element, this); element.boundKeyup = this.keyup.bind(element, this); element.boundInput = this.input.bind(element, this); element.addEventListener("keydown", element.boundKeydown, false); element.addEventListener("keyup", element.boundKeyup, false); element.addEventListener("input", element.boundInput, false); } unbind(element) { element.removeEventListener("keydown", element.boundKeydown, false); element.removeEventListener("keyup", element.boundKeyup, false); element.removeEventListener("input", element.boundInput, false); delete element.boundKeydown; delete element.boundKeyup; delete element.boundInput; } keydown(instance, event) { if (instance.shouldDeactivate(event)) { instance.tribute.isActive = false; instance.tribute.hideMenu(); } let element = this; instance.commandEvent = false; TributeEvents.keys().forEach(o => { if (o.key === event.keyCode) { instance.commandEvent = true; instance.callbacks()[o.value.toLowerCase()](event, element); } }); } input(instance, event) { instance.inputEvent = true; instance.keyup.call(this, instance, event); } click(instance, event) { let tribute = instance.tribute; if (tribute.menu && tribute.menu.contains(event.target)) { let li = event.target; event.preventDefault(); event.stopPropagation(); while (li.nodeName.toLowerCase() !== "li") { li = li.parentNode; if (!li || li === tribute.menu) { throw new Error("cannot find the
  • container for the click"); } } tribute.selectItemAtIndex(li.getAttribute("data-index"), event); tribute.hideMenu(); // TODO: should fire with externalTrigger and target is outside of menu } else if (tribute.current.element && !tribute.current.externalTrigger) { tribute.current.externalTrigger = false; setTimeout(() => tribute.hideMenu()); } } keyup(instance, event) { if (instance.inputEvent) { instance.inputEvent = false; } instance.updateSelection(this); if (event.keyCode === 27) return; if (!instance.tribute.allowSpaces && instance.tribute.hasTrailingSpace) { instance.tribute.hasTrailingSpace = false; instance.commandEvent = true; instance.callbacks()["space"](event, this); return; } if (!instance.tribute.isActive) { if (instance.tribute.autocompleteMode) { instance.callbacks().triggerChar(event, this, ""); } else { let keyCode = instance.getKeyCode(instance, this, event); if (isNaN(keyCode) || !keyCode) return; let trigger = instance.tribute.triggers().find(trigger => { return trigger.charCodeAt(0) === keyCode; }); if (typeof trigger !== "undefined") { instance.callbacks().triggerChar(event, this, trigger); } } } if ( instance.tribute.current.mentionText.length < instance.tribute.current.collection.menuShowMinLength ) { return; } if ( ((instance.tribute.current.trigger || instance.tribute.autocompleteMode) && instance.commandEvent === false) || (instance.tribute.isActive && event.keyCode === 8) ) { instance.tribute.showMenuFor(this, true); } } shouldDeactivate(event) { if (!this.tribute.isActive) return false; if (this.tribute.current.mentionText.length === 0) { let eventKeyPressed = false; TributeEvents.keys().forEach(o => { if (event.keyCode === o.key) eventKeyPressed = true; }); return !eventKeyPressed; } return false; } getKeyCode(instance, el, event) { let tribute = instance.tribute; let info = tribute.range.getTriggerInfo( false, tribute.hasTrailingSpace, true, tribute.allowSpaces, tribute.autocompleteMode ); if (info) { return info.mentionTriggerChar.charCodeAt(0); } else { return false; } } updateSelection(el) { this.tribute.current.element = el; let info = this.tribute.range.getTriggerInfo( false, this.tribute.hasTrailingSpace, true, this.tribute.allowSpaces, this.tribute.autocompleteMode ); if (info) { this.tribute.current.selectedPath = info.mentionSelectedPath; this.tribute.current.mentionText = info.mentionText; this.tribute.current.selectedOffset = info.mentionSelectedOffset; } } callbacks() { return { triggerChar: (e, el, trigger) => { let tribute = this.tribute; tribute.current.trigger = trigger; let collectionItem = tribute.collection.find(item => { return item.trigger === trigger; }); tribute.current.collection = collectionItem; if ( tribute.current.mentionText.length >= tribute.current.collection.menuShowMinLength && tribute.inputEvent ) { tribute.showMenuFor(el, true); } }, enter: (e, el) => { // choose selection if (this.tribute.isActive && this.tribute.current.filteredItems) { e.preventDefault(); e.stopPropagation(); setTimeout(() => { this.tribute.selectItemAtIndex(this.tribute.menuSelected, e); this.tribute.hideMenu(); }, 0); } }, escape: (e, el) => { if (this.tribute.isActive) { e.preventDefault(); e.stopPropagation(); this.tribute.isActive = false; this.tribute.hideMenu(); } }, tab: (e, el) => { // choose first match this.callbacks().enter(e, el); }, space: (e, el) => { if (this.tribute.isActive) { if (this.tribute.spaceSelectsMatch) { this.callbacks().enter(e, el); } else if (!this.tribute.allowSpaces) { e.stopPropagation(); setTimeout(() => { this.tribute.hideMenu(); this.tribute.isActive = false; }, 0); } } }, up: (e, el) => { // navigate up ul if (this.tribute.isActive && this.tribute.current.filteredItems) { e.preventDefault(); e.stopPropagation(); let count = this.tribute.current.filteredItems.length, selected = this.tribute.menuSelected; if (count > selected && selected > 0) { this.tribute.menuSelected--; this.setActiveLi(); } else if (selected === 0) { this.tribute.menuSelected = count - 1; this.setActiveLi(); this.tribute.menu.scrollTop = this.tribute.menu.scrollHeight; } } }, down: (e, el) => { // navigate down ul if (this.tribute.isActive && this.tribute.current.filteredItems) { e.preventDefault(); e.stopPropagation(); let count = this.tribute.current.filteredItems.length - 1, selected = this.tribute.menuSelected; if (count > selected) { this.tribute.menuSelected++; this.setActiveLi(); } else if (count === selected) { this.tribute.menuSelected = 0; this.setActiveLi(); this.tribute.menu.scrollTop = 0; } } }, delete: (e, el) => { if ( this.tribute.isActive && this.tribute.current.mentionText.length < 1 ) { this.tribute.hideMenu(); } else if (this.tribute.isActive) { this.tribute.showMenuFor(el); } } }; } setActiveLi(index) { let lis = this.tribute.menu.querySelectorAll("li"), length = lis.length >>> 0; if (index) this.tribute.menuSelected = parseInt(index); for (let i = 0; i < length; i++) { let li = lis[i]; if (i === this.tribute.menuSelected) { li.classList.add(this.tribute.current.collection.selectClass); let liClientRect = li.getBoundingClientRect(); let menuClientRect = this.tribute.menu.getBoundingClientRect(); if (liClientRect.bottom > menuClientRect.bottom) { let scrollDistance = liClientRect.bottom - menuClientRect.bottom; this.tribute.menu.scrollTop += scrollDistance; } else if (liClientRect.top < menuClientRect.top) { let scrollDistance = menuClientRect.top - liClientRect.top; this.tribute.menu.scrollTop -= scrollDistance; } } else { li.classList.remove(this.tribute.current.collection.selectClass); } } } getFullHeight(elem, includeMargin) { let height = elem.getBoundingClientRect().height; if (includeMargin) { let style = elem.currentStyle || window.getComputedStyle(elem); return ( height + parseFloat(style.marginTop) + parseFloat(style.marginBottom) ); } return height; } } class TributeMenuEvents { constructor(tribute) { this.tribute = tribute; this.tribute.menuEvents = this; this.menu = this.tribute.menu; } bind(menu) { this.menuClickEvent = this.tribute.events.click.bind(null, this); this.menuContainerScrollEvent = this.debounce( () => { if (this.tribute.isActive) { this.tribute.hideMenu(); } }, 10, false ); this.windowResizeEvent = this.debounce( () => { if (this.tribute.isActive) { this.tribute.hideMenu(); } }, 10, false ); // fixes IE11 issues with mousedown this.tribute.range .getDocument() .addEventListener("MSPointerDown", this.menuClickEvent, false); this.tribute.range .getDocument() .addEventListener("mousedown", this.menuClickEvent, false); window.addEventListener("resize", this.windowResizeEvent); if (this.menuContainer) { this.menuContainer.addEventListener( "scroll", this.menuContainerScrollEvent, false ); } else { window.addEventListener("scroll", this.menuContainerScrollEvent); } } unbind(menu) { this.tribute.range .getDocument() .removeEventListener("mousedown", this.menuClickEvent, false); this.tribute.range .getDocument() .removeEventListener("MSPointerDown", this.menuClickEvent, false); window.removeEventListener("resize", this.windowResizeEvent); if (this.menuContainer) { this.menuContainer.removeEventListener( "scroll", this.menuContainerScrollEvent, false ); } else { window.removeEventListener("scroll", this.menuContainerScrollEvent); } } debounce(func, wait, immediate) { var timeout; return () => { var context = this, args = arguments; var later = () => { timeout = null; if (!immediate) func.apply(context, args); }; var callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); }; } } // Thanks to https://github.com/jeff-collins/ment.io class TributeRange { constructor(tribute) { this.tribute = tribute; this.tribute.range = this; } getDocument() { return document; } positionMenuAtCaret(scrollTo) { let context = this.tribute.current, coordinates; let info = this.getTriggerInfo(false, this.tribute.hasTrailingSpace, true, this.tribute.allowSpaces, this.tribute.autocompleteMode); if (typeof info !== 'undefined') { if(!this.tribute.positionMenu){ this.tribute.menu.style.cssText = `display: block;`; return } if (!this.isContentEditable(context.element)) { coordinates = this.getTextAreaOrInputUnderlinePosition(this.tribute.current.element, info.mentionPosition); } else { coordinates = this.getContentEditableCaretPosition(info.mentionPosition); } this.tribute.menu.style.cssText = `top: ${coordinates.top}px; left: ${coordinates.left}px; right: ${coordinates.right}px; bottom: ${coordinates.bottom}px; max-height: ${coordinates.maxHeight || 500}px; max-width: ${coordinates.maxWidth || 300}px; position: ${coordinates.position || 'absolute'}; display: block;`; if (coordinates.left === 'auto') { this.tribute.menu.style.left = 'auto'; } if (coordinates.top === 'auto') { this.tribute.menu.style.top = 'auto'; } if (scrollTo) this.scrollIntoView(); } else { this.tribute.menu.style.cssText = 'display: none'; } } get menuContainerIsBody() { return this.tribute.menuContainer === document.body || !this.tribute.menuContainer; } selectElement(targetElement, path, offset) { let range; let elem = targetElement; if (path) { for (var i = 0; i < path.length; i++) { elem = elem.childNodes[path[i]]; if (elem === undefined) { return } while (elem.length < offset) { offset -= elem.length; elem = elem.nextSibling; } if (elem.childNodes.length === 0 && !elem.length) { elem = elem.previousSibling; } } } let sel = this.getWindowSelection(); range = this.getDocument().createRange(); range.setStart(elem, offset); range.setEnd(elem, offset); range.collapse(true); try { sel.removeAllRanges(); } catch (error) {} sel.addRange(range); targetElement.focus(); } replaceTriggerText(text, requireLeadingSpace, hasTrailingSpace, originalEvent, item) { let info = this.getTriggerInfo(true, hasTrailingSpace, requireLeadingSpace, this.tribute.allowSpaces, this.tribute.autocompleteMode); if (info !== undefined) { let context = this.tribute.current; let replaceEvent = new CustomEvent('tribute-replaced', { detail: { item: item, instance: context, context: info, event: originalEvent, } }); if (!this.isContentEditable(context.element)) { let myField = this.tribute.current.element; let textSuffix = typeof this.tribute.replaceTextSuffix == 'string' ? this.tribute.replaceTextSuffix : ' '; text += textSuffix; let startPos = info.mentionPosition; let endPos = info.mentionPosition + info.mentionText.length + textSuffix.length; if (!this.tribute.autocompleteMode) { endPos += info.mentionTriggerChar.length - 1; } myField.value = myField.value.substring(0, startPos) + text + myField.value.substring(endPos, myField.value.length); myField.selectionStart = startPos + text.length; myField.selectionEnd = startPos + text.length; } else { // add a space to the end of the pasted text let textSuffix = typeof this.tribute.replaceTextSuffix == 'string' ? this.tribute.replaceTextSuffix : '\xA0'; text += textSuffix; let endPos = info.mentionPosition + info.mentionText.length; if (!this.tribute.autocompleteMode) { endPos += info.mentionTriggerChar.length; } this.pasteHtml(text, info.mentionPosition, endPos); } context.element.dispatchEvent(new CustomEvent('input', { bubbles: true })); context.element.dispatchEvent(replaceEvent); } } pasteHtml(html, startPos, endPos) { let range, sel; sel = this.getWindowSelection(); range = this.getDocument().createRange(); range.setStart(sel.anchorNode, startPos); range.setEnd(sel.anchorNode, endPos); range.deleteContents(); let el = this.getDocument().createElement('div'); el.innerHTML = html; let frag = this.getDocument().createDocumentFragment(), node, lastNode; while ((node = el.firstChild)) { lastNode = frag.appendChild(node); } range.insertNode(frag); // Preserve the selection if (lastNode) { range = range.cloneRange(); range.setStartAfter(lastNode); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } } getWindowSelection() { if (this.tribute.collection[0].iframe?.getSelection) { return this.tribute.collection[0].iframe.getSelection() } return window.getSelection() } getNodePositionInParent(element) { if (element.parentNode === null) { return 0 } for (var i = 0; i < element.parentNode.childNodes.length; i++) { let node = element.parentNode.childNodes[i]; if (node === element) { return i } } } getContentEditableSelectedPath(ctx) { let sel = this.getWindowSelection(); let selected = sel.anchorNode; let path = []; let offset; if (selected != null) { let i; let ce = selected.contentEditable; while (selected !== null && ce !== 'true') { i = this.getNodePositionInParent(selected); path.push(i); selected = selected.parentNode; if (selected !== null) { ce = selected.contentEditable; } } path.reverse(); // getRangeAt may not exist, need alternative offset = sel.getRangeAt(0).startOffset; return { selected: selected, path: path, offset: offset } } } getTextPrecedingCurrentSelection() { let context = this.tribute.current, text = ''; if (!this.isContentEditable(context.element)) { let textComponent = this.tribute.current.element; if (textComponent) { let startPos = textComponent.selectionStart; if (textComponent.value && startPos >= 0) { text = textComponent.value.substring(0, startPos); } } } else { let selectedElem = this.getWindowSelection().anchorNode; if (selectedElem != null) { let workingNodeContent = selectedElem.textContent; let selectStartOffset = this.getWindowSelection().getRangeAt(0).startOffset; if (workingNodeContent && selectStartOffset >= 0) { text = workingNodeContent.substring(0, selectStartOffset); } } } return text } getLastWordInText(text) { text = text.replace(/\u00A0/g, ' '); // https://stackoverflow.com/questions/29850407/how-do-i-replace-unicode-character-u00a0-with-a-space-in-javascript var wordsArray; if (this.tribute.autocompleteSeparator) { wordsArray = text.split(this.tribute.autocompleteSeparator); } else { wordsArray = text.split(/\s+/); } var worldsCount = wordsArray.length - 1; return wordsArray[worldsCount].trim(); } getTriggerInfo(menuAlreadyActive, hasTrailingSpace, requireLeadingSpace, allowSpaces, isAutocomplete) { let ctx = this.tribute.current; let selected, path, offset; if (!this.isContentEditable(ctx.element)) { selected = this.tribute.current.element; } else { let selectionInfo = this.getContentEditableSelectedPath(ctx); if (selectionInfo) { selected = selectionInfo.selected; path = selectionInfo.path; offset = selectionInfo.offset; } } let effectiveRange = this.getTextPrecedingCurrentSelection(); let lastWordOfEffectiveRange = this.getLastWordInText(effectiveRange); if (isAutocomplete) { return { mentionPosition: effectiveRange.length - lastWordOfEffectiveRange.length, mentionText: lastWordOfEffectiveRange, mentionSelectedElement: selected, mentionSelectedPath: path, mentionSelectedOffset: offset } } if (effectiveRange !== undefined && effectiveRange !== null) { let mostRecentTriggerCharPos = -1; let triggerChar; this.tribute.collection.forEach(config => { let c = config.trigger; let idx = config.requireLeadingSpace ? this.lastIndexWithLeadingSpace(effectiveRange, c) : effectiveRange.lastIndexOf(c); if (idx > mostRecentTriggerCharPos) { mostRecentTriggerCharPos = idx; triggerChar = c; requireLeadingSpace = config.requireLeadingSpace; } }); if (mostRecentTriggerCharPos >= 0 && ( mostRecentTriggerCharPos === 0 || !requireLeadingSpace || /[\xA0\s]/g.test( effectiveRange.substring( mostRecentTriggerCharPos - 1, mostRecentTriggerCharPos) ) ) ) { let currentTriggerSnippet = effectiveRange.substring(mostRecentTriggerCharPos + triggerChar.length, effectiveRange.length); triggerChar = effectiveRange.substring(mostRecentTriggerCharPos, mostRecentTriggerCharPos + triggerChar.length); let firstSnippetChar = currentTriggerSnippet.substring(0, 1); let leadingSpace = currentTriggerSnippet.length > 0 && ( firstSnippetChar === ' ' || firstSnippetChar === '\xA0' ); if (hasTrailingSpace) { currentTriggerSnippet = currentTriggerSnippet.trim(); } let regex = allowSpaces ? /[^\S ]/g : /[\xA0\s]/g; this.tribute.hasTrailingSpace = regex.test(currentTriggerSnippet); if (!leadingSpace && (menuAlreadyActive || !(regex.test(currentTriggerSnippet)))) { return { mentionPosition: mostRecentTriggerCharPos, mentionText: currentTriggerSnippet, mentionSelectedElement: selected, mentionSelectedPath: path, mentionSelectedOffset: offset, mentionTriggerChar: triggerChar } } } } } lastIndexWithLeadingSpace (str, trigger) { let reversedStr = str.split('').reverse().join(''); let index = -1; for (let cidx = 0, len = str.length; cidx < len; cidx++) { let firstChar = cidx === str.length - 1; let leadingSpace = /\s/.test(reversedStr[cidx + 1]); let match = true; for (let triggerIdx = trigger.length - 1; triggerIdx >= 0; triggerIdx--) { if (trigger[triggerIdx] !== reversedStr[cidx-triggerIdx]) { match = false; break } } if (match && (firstChar || leadingSpace)) { index = str.length - 1 - cidx; break } } return index } isContentEditable(element) { return element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' } isMenuOffScreen(coordinates, menuDimensions) { let windowWidth = window.innerWidth; let windowHeight = window.innerHeight; let doc = document.documentElement; let windowLeft = (window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0); let windowTop = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); let menuTop = typeof coordinates.top === 'number' ? coordinates.top : windowTop + windowHeight - coordinates.bottom - menuDimensions.height; let menuRight = typeof coordinates.right === 'number' ? coordinates.right : coordinates.left + menuDimensions.width; let menuBottom = typeof coordinates.bottom === 'number' ? coordinates.bottom : coordinates.top + menuDimensions.height; let menuLeft = typeof coordinates.left === 'number' ? coordinates.left : windowLeft + windowWidth - coordinates.right - menuDimensions.width; return { top: menuTop < Math.floor(windowTop), right: menuRight > Math.ceil(windowLeft + windowWidth), bottom: menuBottom > Math.ceil(windowTop + windowHeight), left: menuLeft < Math.floor(windowLeft) } } getMenuDimensions() { // Width of the menu depends of its contents and position // We must check what its width would be without any obstruction // This way, we can achieve good positioning for flipping the menu let dimensions = { width: null, height: null }; this.tribute.menu.style.cssText = `top: 0px; left: 0px; position: fixed; display: block; visibility; hidden; max-height:500px;`; dimensions.width = this.tribute.menu.offsetWidth; dimensions.height = this.tribute.menu.offsetHeight; this.tribute.menu.style.cssText = `display: none;`; return dimensions } getTextAreaOrInputUnderlinePosition(element, position, flipped) { let properties = ['direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing' ]; let div = this.getDocument().createElement('div'); div.id = 'input-textarea-caret-position-mirror-div'; this.getDocument().body.appendChild(div); let style = div.style; let computed = window.getComputedStyle ? getComputedStyle(element) : element.currentStyle; style.whiteSpace = 'pre-wrap'; if (element.nodeName !== 'INPUT') { style.wordWrap = 'break-word'; } style.position = 'absolute'; style.visibility = 'hidden'; // transfer the element's properties to the div properties.forEach(prop => { style[prop] = computed[prop]; }); //NOT SURE WHY THIS IS HERE AND IT DOESNT SEEM HELPFUL // if (isFirefox) { // style.width = `${(parseInt(computed.width) - 2)}px` // if (element.scrollHeight > parseInt(computed.height)) // style.overflowY = 'scroll' // } else { // style.overflow = 'hidden' // } let span0 = document.createElement('span'); span0.textContent = element.value.substring(0, position); div.appendChild(span0); if (element.nodeName === 'INPUT') { div.textContent = div.textContent.replace(/\s/g, ' '); } //Create a span in the div that represents where the cursor //should be let span = this.getDocument().createElement('span'); //we give it no content as this represents the cursor span.textContent = '​'; div.appendChild(span); let span2 = this.getDocument().createElement('span'); span2.textContent = element.value.substring(position); div.appendChild(span2); let rect = element.getBoundingClientRect(); //position the div exactly over the element //so we can get the bounding client rect for the span and //it should represent exactly where the cursor is div.style.position = 'fixed'; div.style.left = rect.left + 'px'; div.style.top = rect.top + 'px'; div.style.width = rect.width + 'px'; div.style.height = rect.height + 'px'; div.scrollTop = element.scrollTop; var spanRect = span.getBoundingClientRect(); this.getDocument().body.removeChild(div); return this.getFixedCoordinatesRelativeToRect(spanRect); } getContentEditableCaretPosition(selectedNodePosition) { let range; let sel = this.getWindowSelection(); range = this.getDocument().createRange(); range.setStart(sel.anchorNode, selectedNodePosition); range.setEnd(sel.anchorNode, selectedNodePosition); range.collapse(false); let rect = range.getBoundingClientRect(); return this.getFixedCoordinatesRelativeToRect(rect); } getFixedCoordinatesRelativeToRect(rect) { let coordinates = { position: 'fixed', left: rect.left, top: rect.top + rect.height }; let menuDimensions = this.getMenuDimensions(); var availableSpaceOnTop = rect.top; var availableSpaceOnBottom = window.innerHeight - (rect.top + rect.height); //check to see where's the right place to put the menu vertically if (availableSpaceOnBottom < menuDimensions.height) { if (availableSpaceOnTop >= menuDimensions.height || availableSpaceOnTop > availableSpaceOnBottom) { coordinates.top = 'auto'; coordinates.bottom = window.innerHeight - rect.top; if (availableSpaceOnBottom < menuDimensions.height) { coordinates.maxHeight = availableSpaceOnTop; } } else { if (availableSpaceOnTop < menuDimensions.height) { coordinates.maxHeight = availableSpaceOnBottom; } } } var availableSpaceOnLeft = rect.left; var availableSpaceOnRight = window.innerWidth - rect.left; //check to see where's the right place to put the menu horizontally if (availableSpaceOnRight < menuDimensions.width) { if (availableSpaceOnLeft >= menuDimensions.width || availableSpaceOnLeft > availableSpaceOnRight) { coordinates.left = 'auto'; coordinates.right = window.innerWidth - rect.left; if (availableSpaceOnRight < menuDimensions.width) { coordinates.maxWidth = availableSpaceOnLeft; } } else { if (availableSpaceOnLeft < menuDimensions.width) { coordinates.maxWidth = availableSpaceOnRight; } } } return coordinates } scrollIntoView(elem) { let reasonableBuffer = 20, clientRect; let maxScrollDisplacement = 100; let e = this.menu; if (typeof e === 'undefined') return; while (clientRect === undefined || clientRect.height === 0) { clientRect = e.getBoundingClientRect(); if (clientRect.height === 0) { e = e.childNodes[0]; if (e === undefined || !e.getBoundingClientRect) { return } } } let elemTop = clientRect.top; let elemBottom = elemTop + clientRect.height; if (elemTop < 0) { window.scrollTo(0, window.pageYOffset + clientRect.top - reasonableBuffer); } else if (elemBottom > window.innerHeight) { let maxY = window.pageYOffset + clientRect.top - reasonableBuffer; if (maxY - window.pageYOffset > maxScrollDisplacement) { maxY = window.pageYOffset + maxScrollDisplacement; } let targetY = window.pageYOffset - (window.innerHeight - elemBottom); if (targetY > maxY) { targetY = maxY; } window.scrollTo(0, targetY); } } } // Thanks to https://github.com/mattyork/fuzzy class TributeSearch { constructor(tribute) { this.tribute = tribute; this.tribute.search = this; } simpleFilter(pattern, array) { return array.filter(string => { return this.test(pattern, string) }) } test(pattern, string) { return this.match(pattern, string) !== null } match(pattern, string, opts) { opts = opts || {}; let len = string.length, pre = opts.pre || '', post = opts.post || '', compareString = opts.caseSensitive && string || string.toLowerCase(); if (opts.skip) { return {rendered: string, score: 0} } pattern = opts.caseSensitive && pattern || pattern.toLowerCase(); let patternCache = this.traverse(compareString, pattern, 0, 0, []); if (!patternCache) { return null } return { rendered: this.render(string, patternCache.cache, pre, post), score: patternCache.score } } traverse(string, pattern, stringIndex, patternIndex, patternCache) { if (this.tribute.autocompleteSeparator) { // if the pattern search at end pattern = pattern.split(this.tribute.autocompleteSeparator).splice(-1)[0]; } if (pattern.length === patternIndex) { // calculate score and copy the cache containing the indices where it's found return { score: this.calculateScore(patternCache), cache: patternCache.slice() } } // if string at end or remaining pattern > remaining string if (string.length === stringIndex || pattern.length - patternIndex > string.length - stringIndex) { return undefined } let c = pattern[patternIndex]; let index = string.indexOf(c, stringIndex); let best, temp; while (index > -1) { patternCache.push(index); temp = this.traverse(string, pattern, index + 1, patternIndex + 1, patternCache); patternCache.pop(); // if downstream traversal failed, return best answer so far if (!temp) { return best } if (!best || best.score < temp.score) { best = temp; } index = string.indexOf(c, index + 1); } return best } calculateScore(patternCache) { let score = 0; let temp = 1; patternCache.forEach((index, i) => { if (i > 0) { if (patternCache[i - 1] + 1 === index) { temp += temp + 1; } else { temp = 1; } } score += temp; }); return score } render(string, indices, pre, post) { var rendered = string.substring(0, indices[0]); indices.forEach((index, i) => { rendered += pre + string[index] + post + string.substring(index + 1, (indices[i + 1]) ? indices[i + 1] : string.length); }); return rendered } filter(pattern, arr, opts) { opts = opts || {}; return arr .reduce((prev, element, idx, arr) => { let str = element; if (opts.extract) { str = opts.extract(element); if (!str) { // take care of undefineds / nulls / etc. str = ''; } } let rendered = this.match(pattern, str, opts); if (rendered != null) { prev[prev.length] = { string: rendered.rendered, score: rendered.score, index: idx, original: element }; } return prev }, []) .sort((a, b) => { let compare = b.score - a.score; if (compare) return compare return a.index - b.index }) } } class Tribute { constructor({ values = null, loadingItemTemplate = null, iframe = null, selectClass = "highlight", containerClass = "tribute-container", itemClass = "", trigger = "@", autocompleteMode = false, autocompleteSeparator = null, 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.autocompleteSeparator = autocompleteSeparator; 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, // useful for when values is an async function loadingItemTemplate: loadingItemTemplate, 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, loadingItemTemplate: item.loadingItemTemplate, 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"); 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); this.range.positionMenuAtCaret(scrollTo); } 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); this.range.positionMenuAtCaret(scrollTo); }; if (typeof this.current.collection.values === "function") { if (this.current.collection.loadingItemTemplate) { this.menu.querySelector("ul").innerHTML = this.current.collection.loadingItemTemplate; this.range.positionMenuAtCaret(scrollTo); } 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; 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(); } }); } } /** * Tribute.js * Native ES6 JavaScript @mention Plugin **/ export default Tribute;