Files
.gitea
apps
admin
api
apps
blog
db
follow
identity
intro
issues
journal
room
sneaker
ssb
app.js
commonmark-hashtag.js
commonmark.min.js
emojis.js
emojis.json
filesaver.min.js
filesaver.min.js.map
index.html
lit-all.min.js
lit-all.min.js.map
script.js
tf-app.js
tf-compose.js
tf-message.js
tf-news.js
tf-profile.js
tf-reactions-modal.js
tf-styles.js
tf-tab-connections.js
tf-tab-news-feed.js
tf-tab-news.js
tf-tab-query.js
tf-tab-search.js
tf-tag.js
tf-user.js
tf-utils.js
tribute.css
tribute.esm.js
storage
test
todo
web
welcome
wiki
admin.json
api.json
apps.json
blog.json
db.json
follow.json
identity.json
intro.json
issues.json
journal.json
room.json
sneaker.json
ssb.json
storage.json
test.json
todo.json
web.json
welcome.json
wiki.json
core
deps
docs
metadata
src
tools
.clang-format
.dockerignore
.git-blame-ignore-revs
.gitignore
.gitmodules
.prettierignore
.prettierrc.yaml
CONTRIBUTING.md
Dockerfile
Doxyfile
GNUmakefile
LICENSE
README.md
default.nix
flake.lock
flake.nix
package-lock.json
package.json
tildefriends/apps/ssb/tribute.esm.js

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1789 lines
54 KiB
JavaScript
Raw Normal View History

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;
}
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) {
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 = '&#x200B;';
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;