From 704ed737a966338fa75aa098d120538bca4f9cd4 Mon Sep 17 00:00:00 2001 From: Cory McWilliams Date: Sat, 15 Oct 2022 18:22:13 +0000 Subject: [PATCH] Auto-complete @mentions. git-svn-id: https://www.unprompted.com/svn/projects/tildefriends/trunk@4007 ed5197a5-7fde-0310-b194-c3ffbd925b24 --- apps/cory/ssblit.json | 2 +- apps/cory/ssblit/index.html | 6 + apps/cory/ssblit/tf-compose.js | 17 +- apps/cory/ssblit/tribute.css | 32 + apps/cory/ssblit/tribute.esm.js | 1797 +++++++++++++++++++++++++++++++ 5 files changed, 1852 insertions(+), 2 deletions(-) create mode 100644 apps/cory/ssblit/tribute.css create mode 100644 apps/cory/ssblit/tribute.esm.js diff --git a/apps/cory/ssblit.json b/apps/cory/ssblit.json index 6c8aaa17..99d521f1 100644 --- a/apps/cory/ssblit.json +++ b/apps/cory/ssblit.json @@ -1 +1 @@ -{"type":"tildefriends-app","files":{"app.js":"&XCpiJOtpMzQz5Zo+Hu9f3ppQON9PxFdV4XnS2Ae+Ye8=.sha256","lit-all.min.js":"&N4A12AsifdQgwdpII0SFtG513BfoLpmPjdJ9VTDftpg=.sha256","index.html":"&WH8A5tF25xlfPDGei2TCQc2/HJFJf5DuRN1GRSYQhhk=.sha256","script.js":"&G8puK9Q4MngHy3D4ppcKyT49WKbHD2OCeUcAw2ghTDE=.sha256","lit-all.min.js.map":"&oFY9wO4MnujgfGNGv4VggHc5V5JwX4C8csqKZ6KJYbE=.sha256","tf-id-picker.js":"&9BDffV4HY9FqhL7XI4it+UQJB4cYwbDNsY3S1cxy2vw=.sha256","tf-app.js":"&qqpOZHnkJQevhLGPUEJ7br2S/LNH+nQtC91vz3CrYrE=.sha256","tf-message.js":"&NC1Fysqht3f3sP5sHKmxuWjJx1HyJa3nx77BlOX9eZY=.sha256","tf-user.js":"&bXTedgBudTQLXEBPY9R8OLfQ/ZLpo8YRU9Oq/wuGG3Y=.sha256","tf-utils.js":"&N2yKZwFnb2GbPeipgQtu6xFvezENNOgud9G7EhCQ/K0=.sha256","commonmark.min.js":"&bfBaMLU19d1p/vPBF9hlARqDX002KXG/UOfxOahZhe4=.sha256","tf-compose.js":"&+qbmbM5zr068m9wyvP9RLz6doVujflQ3oY+rC/ul70U=.sha256","emojis.json":"&h3P4pez+AI4aYdsN0dJ3pbUEFR0276t9AM20caj/W/s=.sha256","emojis.js":"&pqYLDE/13PyEt2ceeFqvnwZ8NqWfPfpDBt4vP8SeHbs=.sha256","tf-styles.js":"&Zw90HptAvGwX/vBnEhRVfNrYjMSssFnnKpp8bzwXQH0=.sha256","tf-profile.js":"&vRKjsnYvOiHCQahzEfznCvP5YDwUPtltlpWf+pxwZ1Y=.sha256","commonmark-linkify.js":"&X+hNNkmSRvKY86khyAun+cXksquXbMakZdINbGbx30g=.sha256","tf-tab-search.js":"&ESt2vMG19sH5j6ungKua/ZuvIGslyuWyb3juXdOCecg=.sha256","tf-tab-news.js":"&6ialbh/M2eBCDH6wFyoOHyDqG9QHbyIrzvRK+CgeyRc=.sha256","tf-tab-connections.js":"&jSnF/5NmgqxRze1XQAEGOW5mPzOV1/8aCyrDRZu34IQ=.sha256","tf-news.js":"&K3azL6eFcgbM9xpLHU3L5oSOApi1fXEvJZrhPkZgDpQ=.sha256"}} \ No newline at end of file +{"type":"tildefriends-app","files":{"app.js":"&XCpiJOtpMzQz5Zo+Hu9f3ppQON9PxFdV4XnS2Ae+Ye8=.sha256","lit-all.min.js":"&N4A12AsifdQgwdpII0SFtG513BfoLpmPjdJ9VTDftpg=.sha256","index.html":"&Vpp3ezlQiD5guf1P6yZhpcNMnO0u+uQoil3hNkwiIp4=.sha256","script.js":"&G8puK9Q4MngHy3D4ppcKyT49WKbHD2OCeUcAw2ghTDE=.sha256","lit-all.min.js.map":"&oFY9wO4MnujgfGNGv4VggHc5V5JwX4C8csqKZ6KJYbE=.sha256","tf-id-picker.js":"&9BDffV4HY9FqhL7XI4it+UQJB4cYwbDNsY3S1cxy2vw=.sha256","tf-app.js":"&qqpOZHnkJQevhLGPUEJ7br2S/LNH+nQtC91vz3CrYrE=.sha256","tf-message.js":"&NC1Fysqht3f3sP5sHKmxuWjJx1HyJa3nx77BlOX9eZY=.sha256","tf-user.js":"&bXTedgBudTQLXEBPY9R8OLfQ/ZLpo8YRU9Oq/wuGG3Y=.sha256","tf-utils.js":"&N2yKZwFnb2GbPeipgQtu6xFvezENNOgud9G7EhCQ/K0=.sha256","commonmark.min.js":"&bfBaMLU19d1p/vPBF9hlARqDX002KXG/UOfxOahZhe4=.sha256","tf-compose.js":"&HqoEyjhK0ShnF/bjTezHri2JoX1wxu+/p5UxJFx9Lgs=.sha256","emojis.json":"&h3P4pez+AI4aYdsN0dJ3pbUEFR0276t9AM20caj/W/s=.sha256","emojis.js":"&pqYLDE/13PyEt2ceeFqvnwZ8NqWfPfpDBt4vP8SeHbs=.sha256","tf-styles.js":"&Zw90HptAvGwX/vBnEhRVfNrYjMSssFnnKpp8bzwXQH0=.sha256","tf-profile.js":"&vRKjsnYvOiHCQahzEfznCvP5YDwUPtltlpWf+pxwZ1Y=.sha256","commonmark-linkify.js":"&X+hNNkmSRvKY86khyAun+cXksquXbMakZdINbGbx30g=.sha256","tf-tab-search.js":"&ESt2vMG19sH5j6ungKua/ZuvIGslyuWyb3juXdOCecg=.sha256","tf-tab-news.js":"&6ialbh/M2eBCDH6wFyoOHyDqG9QHbyIrzvRK+CgeyRc=.sha256","tf-tab-connections.js":"&jSnF/5NmgqxRze1XQAEGOW5mPzOV1/8aCyrDRZu34IQ=.sha256","tf-news.js":"&K3azL6eFcgbM9xpLHU3L5oSOApi1fXEvJZrhPkZgDpQ=.sha256","tribute.css":"&9FogMzZHKXCfGb7mlh7z+/wiNZzBsOB/tKoh6MfYJno=.sha256","tribute.esm.js":"&P1wKqCfYULpR/ahSB98JP8xaxfikuZwwtT6I/SAo7/Y=.sha256"}} \ No newline at end of file diff --git a/apps/cory/ssblit/index.html b/apps/cory/ssblit/index.html index 837846f7..0f872525 100644 --- a/apps/cory/ssblit/index.html +++ b/apps/cory/ssblit/index.html @@ -3,6 +3,12 @@ Tilde Friends + +

Tilde Friends

diff --git a/apps/cory/ssblit/tf-compose.js b/apps/cory/ssblit/tf-compose.js index e6ada6a2..94c69760 100644 --- a/apps/cory/ssblit/tf-compose.js +++ b/apps/cory/ssblit/tf-compose.js @@ -1,6 +1,8 @@ import {LitElement, html} from './lit-all.min.js'; import * as tfutils from './tf-utils.js'; import * as tfrpc from '/static/tfrpc.js'; +import {styles} from './tf-styles.js'; +import Tribute from './tribute.esm.js'; class TfComposeElement extends LitElement { static get properties() { @@ -12,6 +14,8 @@ class TfComposeElement extends LitElement { } } + static styles = styles; + constructor() { super(); this.users = {}; @@ -95,8 +99,18 @@ class TfComposeElement extends LitElement { input.click(); } + firstUpdated() { + let tribute = new Tribute({ + values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})), + selectTemplate: function(item) { + return `[@${item.original.key}](${item.original.value})`; + }, + }); + tribute.attach(this.renderRoot.getElementById('edit')); + } + render() { - return html` + let result = html`
@@ -105,6 +119,7 @@ class TfComposeElement extends LitElement { `; + return result; } } diff --git a/apps/cory/ssblit/tribute.css b/apps/cory/ssblit/tribute.css new file mode 100644 index 00000000..3268c09a --- /dev/null +++ b/apps/cory/ssblit/tribute.css @@ -0,0 +1,32 @@ +.tribute-container { + position: absolute; + top: 0; + left: 0; + height: auto; + overflow: auto; + display: block; + z-index: 999999; +} +.tribute-container ul { + margin: 0; + margin-top: 2px; + padding: 0; + list-style: none; + background: #efefef; +} +.tribute-container li { + padding: 5px 5px; + cursor: pointer; +} +.tribute-container li.highlight { + background: #ddd; +} +.tribute-container li span { + font-weight: bold; +} +.tribute-container li.no-match { + cursor: default; +} +.tribute-container .menu-highlighted { + font-weight: bold; +} \ No newline at end of file diff --git a/apps/cory/ssblit/tribute.esm.js b/apps/cory/ssblit/tribute.esm.js new file mode 100644 index 00000000..98ee826f --- /dev/null +++ b/apps/cory/ssblit/tribute.esm.js @@ -0,0 +1,1797 @@ +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() { + let iframe; + if (this.tribute.current.collection) { + iframe = this.tribute.current.collection.iframe; + } + + if (!iframe) { + return document + } + + return iframe.contentWindow.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.iframe) { + return this.tribute.collection.iframe.contentWindow.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;