2022-10-15 14:22:13 -04:00
|
|
|
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() {
|
2024-06-06 20:05:24 -04:00
|
|
|
return document;
|
2022-10-15 14:22:13 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
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() {
|
2024-06-06 20:05:24 -04:00
|
|
|
if (this.tribute.collection[0].iframe?.getSelection) {
|
2024-06-06 19:52:37 -04:00
|
|
|
return this.tribute.collection[0].iframe.getSelection()
|
2022-10-15 14:22:13 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
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;
|