1789 lines
		
	
	
		
			54 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1789 lines
		
	
	
		
			54 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 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 <li> 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 "<li>No Match Found!</li>";
 | |
|               }.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 "<li>No Match Found!</li>";
 | |
|               }.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 (
 | |
|         '<span class="tribute-mention">' +
 | |
|         (this.current.collection.trigger +
 | |
|           item.original[this.current.collection.fillAttr]) +
 | |
|         "</span>"
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     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 || "<span>",
 | |
|         post: this.current.collection.searchOpts.post || "</span>",
 | |
|         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;
 |