thelounge/client/js/autocompletion.js
2019-07-19 11:27:40 +01:00

337 lines
7.2 KiB
JavaScript

"use strict";
const $ = require("jquery");
const fuzzy = require("fuzzy");
const Mousetrap = require("mousetrap");
const {Textcomplete, Textarea} = require("textcomplete");
const emojiMap = require("./libs/simplemap.json");
const constants = require("./constants");
const {vueApp} = require("./vue");
let input;
let textcomplete;
let enabled = false;
module.exports = {
enable: enableAutocomplete,
disable() {
if (enabled) {
$("#form").off("submit.tabcomplete");
input.off("input.tabcomplete");
Mousetrap(input.get(0)).unbind("tab", "keydown");
textcomplete.destroy();
enabled = false;
vueApp.isAutoCompleting = false;
}
},
};
const emojiSearchTerms = Object.keys(emojiMap);
const emojiStrategy = {
id: "emoji",
match: /\B:([-+\w:?]{2,}):?$/,
search(term, callback) {
// Trim colon from the matched term,
// as we are unable to get a clean string from match regex
(term = term.replace(/:$/, "")), callback(fuzzyGrep(term, emojiSearchTerms));
},
template([string, original]) {
return `<span class="emoji">${emojiMap[original]}</span> ${string}`;
},
replace([, original]) {
return emojiMap[original];
},
index: 1,
};
const nicksStrategy = {
id: "nicks",
match: /\B(@([a-zA-Z_[\]\\^{}|`@][a-zA-Z0-9_[\]\\^{}|`-]*)?)$/,
search(term, callback) {
term = term.slice(1);
if (term[0] === "@") {
callback(completeNicks(term.slice(1), true).map((val) => ["@" + val[0], "@" + val[1]]));
} else {
callback(completeNicks(term, true));
}
},
template([string]) {
return string;
},
replace([, original], position = 1) {
// If no postfix specified, return autocompleted nick as-is
if (!vueApp.settings.nickPostfix) {
return original;
}
// If there is whitespace in the input already, append space to nick
if (position > 0 && /\s/.test($("#input").val())) {
return original + " ";
}
// If nick is first in the input, append specified postfix
return original + vueApp.settings.nickPostfix;
},
index: 1,
};
const chanStrategy = {
id: "chans",
match: /\B((#|\+|&|![A-Z0-9]{5})([^\x00\x0A\x0D\x20\x2C\x3A]+(:[^\x00\x0A\x0D\x20\x2C\x3A]*)?)?)$/,
search(term, callback, match) {
callback(completeChans(match[0]));
},
template([string]) {
return string;
},
replace([, original]) {
return original;
},
index: 1,
};
const commandStrategy = {
id: "commands",
match: /^\/(\w*)$/,
search(term, callback) {
callback(completeCommands("/" + term));
},
template([string]) {
return string;
},
replace([, original]) {
return original;
},
index: 1,
};
const foregroundColorStrategy = {
id: "foreground-colors",
match: /\x03(\d{0,2}|[A-Za-z ]{0,10})$/,
search(term, callback) {
term = term.toLowerCase();
const matchingColorCodes = constants.colorCodeMap
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
.map((i) => {
if (fuzzy.test(term, i[1])) {
return [
i[0],
fuzzy.match(term, i[1], {
pre: "<b>",
post: "</b>",
}).rendered,
];
}
return i;
});
callback(matchingColorCodes);
},
template(value) {
return `<span class="irc-fg${parseInt(value[0], 10)}">${value[1]}</span>`;
},
replace(value) {
return "\x03" + value[0];
},
index: 1,
};
const backgroundColorStrategy = {
id: "background-colors",
match: /\x03(\d{2}),(\d{0,2}|[A-Za-z ]{0,10})$/,
search(term, callback, match) {
term = term.toLowerCase();
const matchingColorCodes = constants.colorCodeMap
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
.map((pair) => {
if (fuzzy.test(term, pair[1])) {
return [
pair[0],
fuzzy.match(term, pair[1], {
pre: "<b>",
post: "</b>",
}).rendered,
];
}
return pair;
})
.map((pair) => pair.concat(match[1])); // Needed to pass fg color to `template`...
callback(matchingColorCodes);
},
template(value) {
return `<span class="irc-fg${parseInt(value[2], 10)} irc-bg irc-bg${parseInt(
value[0],
10
)}">${value[1]}</span>`;
},
replace(value) {
return "\x03$1," + value[0];
},
index: 2,
};
function enableAutocomplete(inputRef) {
enabled = true;
let tabCount = 0;
let lastMatch = "";
let currentMatches = [];
input = $(inputRef);
input.on("input.tabcomplete", (e) => {
if (e.detail === "autocomplete") {
return;
}
tabCount = 0;
currentMatches = [];
lastMatch = "";
});
Mousetrap(input.get(0)).bind(
"tab",
(e) => {
if (vueApp.isAutoCompleting) {
return;
}
e.preventDefault();
const text = input.val();
if (input.get(0).selectionStart !== text.length) {
return;
}
if (tabCount === 0) {
lastMatch = text.split(/\s/).pop();
if (lastMatch.length === 0) {
return;
}
currentMatches = completeNicks(lastMatch, false);
if (currentMatches.length === 0) {
return;
}
}
const position = input.get(0).selectionStart - lastMatch.length;
const newMatch = nicksStrategy.replace(
[0, currentMatches[tabCount % currentMatches.length]],
position
);
input.val(text.substr(0, position) + newMatch);
// Propagate change to Vue model
input.get(0).dispatchEvent(
new CustomEvent("input", {
detail: "autocomplete",
})
);
lastMatch = newMatch;
tabCount++;
},
"keydown"
);
const editor = new Textarea(input.get(0));
textcomplete = new Textcomplete(editor, {
dropdown: {
className: "textcomplete-menu",
placement: "top",
},
});
textcomplete.register([
emojiStrategy,
nicksStrategy,
chanStrategy,
commandStrategy,
foregroundColorStrategy,
backgroundColorStrategy,
]);
// Activate the first item by default
// https://github.com/yuku-t/textcomplete/issues/93
textcomplete.on("rendered", () => {
if (textcomplete.dropdown.items.length > 0) {
textcomplete.dropdown.items[0].activate();
}
});
textcomplete.on("show", () => {
vueApp.isAutoCompleting = true;
});
textcomplete.on("hidden", () => {
vueApp.isAutoCompleting = false;
});
$("#form").on("submit.tabcomplete", () => {
textcomplete.hide();
});
}
function fuzzyGrep(term, array) {
const results = fuzzy.filter(term, array, {
pre: "<b>",
post: "</b>",
});
return results.map((el) => [el.string, el.original]);
}
function rawNicks() {
if (vueApp.activeChannel.channel.users.length > 0) {
const users = vueApp.activeChannel.channel.users.slice();
return users.sort((a, b) => b.lastMessage - a.lastMessage).map((u) => u.nick);
}
const me = vueApp.activeChannel.network.nick;
const otherUser = vueApp.activeChannel.channel.name;
// If this is a query, add their name to autocomplete
if (me !== otherUser && vueApp.activeChannel.channel.type === "query") {
return [otherUser, me];
}
// Return our own name by default for anything that isn't a channel or query
return [me];
}
function completeNicks(word, isFuzzy) {
const users = rawNicks();
word = word.toLowerCase();
if (isFuzzy) {
return fuzzyGrep(word, users);
}
return $.grep(users, (w) => !w.toLowerCase().indexOf(word));
}
function completeCommands(word) {
const words = constants.commands.slice();
return fuzzyGrep(word, words);
}
function completeChans(word) {
const words = [];
for (const channel of vueApp.activeChannel.network.channels) {
if (channel.type === "channel") {
words.push(channel.name);
}
}
return fuzzyGrep(word, words);
}