Rewrite tab-complete logic to allow peeking ahead
This primarily means pre-calculating the list of things we'll be looping over and then returning matches from this list. Make the regex match be more generic rather than sorta-kinda-user-id-like-ish.
This commit is contained in:
parent
41d4c1d14e
commit
ab0a277d94
1 changed files with 119 additions and 91 deletions
|
@ -18,20 +18,22 @@ const DELAY_TIME_MS = 500;
|
||||||
const KEY_TAB = 9;
|
const KEY_TAB = 9;
|
||||||
const KEY_SHIFT = 16;
|
const KEY_SHIFT = 16;
|
||||||
|
|
||||||
|
// word boundary -> 1 or more non-whitespace chars (group) -> end of line
|
||||||
|
const MATCH_REGEX = /\b(\S+)$/;
|
||||||
|
|
||||||
class TabComplete {
|
class TabComplete {
|
||||||
|
|
||||||
constructor(opts) {
|
constructor(opts) {
|
||||||
opts.startingWordSuffix = opts.startingWordSuffix || "";
|
opts.startingWordSuffix = opts.startingWordSuffix || "";
|
||||||
opts.wordSuffix = opts.wordSuffix || "";
|
opts.wordSuffix = opts.wordSuffix || "";
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
|
|
||||||
this.tabStruct = {
|
|
||||||
original: null,
|
|
||||||
index: 0
|
|
||||||
};
|
|
||||||
this.completing = false;
|
this.completing = false;
|
||||||
this.list = [];
|
this.list = []; // full set of tab-completable things
|
||||||
this.textArea = opts.textArea;
|
this.matchedList = []; // subset of completable things to loop over
|
||||||
|
this.currentIndex = 0; // index in matchedList currently
|
||||||
|
this.originalText = null; // original input text when tab was first hit
|
||||||
|
this.textArea = opts.textArea; // DOMElement
|
||||||
|
this.isFirstWord = false; // true if you tab-complete on the first word
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -41,99 +43,48 @@ class TabComplete {
|
||||||
this.list = completeList;
|
this.list = completeList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DOMElement}
|
||||||
|
*/
|
||||||
setTextArea(textArea) {
|
setTextArea(textArea) {
|
||||||
this.textArea = textArea;
|
this.textArea = textArea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {Boolean}
|
||||||
|
*/
|
||||||
isTabCompleting() {
|
isTabCompleting() {
|
||||||
return this.completing;
|
return this.completing;
|
||||||
}
|
}
|
||||||
|
|
||||||
next() {
|
|
||||||
this.tabStruct.index++;
|
|
||||||
this.setCompletionOption();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Number} numAheadToPeek
|
* @param {Number} numAheadToPeek Return *up to* this many elements.
|
||||||
* @return {TabComplete.Entry[]}
|
* @return {TabComplete.Entry[]}
|
||||||
*/
|
*/
|
||||||
peek(numAheadToPeek) {
|
peek(numAheadToPeek) {
|
||||||
var current = this.list[this.tabStruct.index];
|
if (this.matchedList.length === 0) {
|
||||||
return [current];
|
return [];
|
||||||
}
|
|
||||||
|
|
||||||
prev() {
|
|
||||||
this.tabStruct.index --;
|
|
||||||
if (this.tabStruct.index < 0) {
|
|
||||||
// wrap to the last search match, and fix up to a real index
|
|
||||||
// value after we've matched.
|
|
||||||
this.tabStruct.index = Number.MAX_VALUE;
|
|
||||||
}
|
}
|
||||||
this.setCompletionOption();
|
|
||||||
}
|
|
||||||
|
|
||||||
setCompletionOption() {
|
var peekList = [
|
||||||
var searchIndex = 0;
|
this.matchedList[this.currentIndex]
|
||||||
var targetIndex = this.tabStruct.index;
|
];
|
||||||
var text = this.tabStruct.original;
|
// return the current match item and then one with an index higher, and
|
||||||
|
// so on until we've reached the requested limit OR we've looped back
|
||||||
var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text);
|
// around to our starting index.
|
||||||
// console.log("Searched in '%s' - got %s", text, search);
|
for (var i = 1; i < numAheadToPeek; i++) {
|
||||||
if (targetIndex === 0) { // 0 is always the original text
|
var nextIndex = this.currentIndex + i;
|
||||||
this.textArea.value = text;
|
if (nextIndex >= this.matchedList.length) {
|
||||||
}
|
// wrap around and take account of how far we've wrapped
|
||||||
else if (search && search[1]) {
|
nextIndex -= this.matchedList.length;
|
||||||
// console.log("search found: " + search+" from "+text);
|
|
||||||
var expansion;
|
|
||||||
|
|
||||||
// FIXME: could do better than linear search here
|
|
||||||
for (var i=0; i < this.list.length; i++) {
|
|
||||||
if (searchIndex < targetIndex) {
|
|
||||||
if (this.list[i].text.toLowerCase().indexOf(search[1].toLowerCase()) === 0) {
|
|
||||||
expansion = this.list[i].text;
|
|
||||||
searchIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// check for looping back to start
|
||||||
if (searchIndex === targetIndex || targetIndex === Number.MAX_VALUE) {
|
if (nextIndex === this.currentIndex) {
|
||||||
if (search[0].length === text.length) {
|
break; // no more items to return without looping
|
||||||
expansion += this.opts.startingWordSuffix;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
expansion += this.opts.wordSuffix;
|
|
||||||
}
|
|
||||||
this.textArea.value = text.replace(
|
|
||||||
/@?([a-zA-Z0-9_\-:\.]+)$/, expansion
|
|
||||||
);
|
|
||||||
// cancel blink
|
|
||||||
this.textArea.style["background-color"] = "";
|
|
||||||
if (targetIndex === Number.MAX_VALUE) {
|
|
||||||
// wrap the index around to the last index found
|
|
||||||
this.tabStruct.index = searchIndex;
|
|
||||||
targetIndex = searchIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// console.log("wrapped!");
|
|
||||||
this.textArea.style["background-color"] = "#faa";
|
|
||||||
setTimeout(() => { // yay for lexical 'this'!
|
|
||||||
this.textArea.style["background-color"] = "";
|
|
||||||
}, 150);
|
|
||||||
this.textArea.value = text;
|
|
||||||
this.tabStruct.index = 0;
|
|
||||||
}
|
}
|
||||||
|
peekList.push(this.matchedList[nextIndex]);
|
||||||
}
|
}
|
||||||
else {
|
return peekList;
|
||||||
this.tabStruct.index = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyStateChange() {
|
|
||||||
if (this.opts.onStateChange) {
|
|
||||||
this.opts.onStateChange(this.completing);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -146,8 +97,8 @@ class TabComplete {
|
||||||
if (ev.keyCode !== KEY_SHIFT && this.completing) {
|
if (ev.keyCode !== KEY_SHIFT && this.completing) {
|
||||||
// they're resuming typing; reset tab complete state vars.
|
// they're resuming typing; reset tab complete state vars.
|
||||||
this.completing = false;
|
this.completing = false;
|
||||||
this.tabStruct.index = 0;
|
this.currentIndex = 0;
|
||||||
this.notifyStateChange();
|
this._notifyStateChange();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
@ -161,23 +112,100 @@ class TabComplete {
|
||||||
// init struct if necessary
|
// init struct if necessary
|
||||||
if (!this.completing) {
|
if (!this.completing) {
|
||||||
this.completing = true;
|
this.completing = true;
|
||||||
this.tabStruct.index = 0;
|
this.currentIndex = 0;
|
||||||
// cache starting text
|
this._calculateCompletions();
|
||||||
this.tabStruct.original = this.textArea.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ev.shiftKey) {
|
if (ev.shiftKey) {
|
||||||
this.prev();
|
this.nextMatchedEntry(-1);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.next();
|
this.nextMatchedEntry(1);
|
||||||
}
|
}
|
||||||
// prevent the default TAB operation (typically focus shifting)
|
// prevent the default TAB operation (typically focus shifting)
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.notifyStateChange();
|
this._notifyStateChange();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the textarea to the next value in the matched list.
|
||||||
|
* @param {Number} offset Offset to apply *before* setting the next value.
|
||||||
|
*/
|
||||||
|
nextMatchedEntry(offset) {
|
||||||
|
if (this.matchedList.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var looped = false;
|
||||||
|
// work out the new index, wrapping if necessary.
|
||||||
|
this.currentIndex += offset;
|
||||||
|
if (this.currentIndex >= this.matchedList.length) {
|
||||||
|
this.currentIndex = 0;
|
||||||
|
looped = true;
|
||||||
|
}
|
||||||
|
else if (this.currentIndex < 0) {
|
||||||
|
this.currentIndex = this.matchedList.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var suffix = "";
|
||||||
|
|
||||||
|
if (this.currentIndex !== 0) { // don't suffix the original text!
|
||||||
|
suffix = this.isFirstWord ? this.opts.startingWordSuffix : this.opts.wordSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
// set textarea to this new value
|
||||||
|
this.textArea.value = this._replaceWith(
|
||||||
|
this.matchedList[this.currentIndex].text + suffix
|
||||||
|
);
|
||||||
|
|
||||||
|
// visual display to the user that we looped - TODO: This should be configurable
|
||||||
|
if (looped) {
|
||||||
|
this.textArea.style["background-color"] = "#faa";
|
||||||
|
setTimeout(() => { // yay for lexical 'this'!
|
||||||
|
this.textArea.style["background-color"] = "";
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.textArea.style["background-color"] = ""; // cancel blinks TODO: required?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_replaceWith(newVal) {
|
||||||
|
return this.originalText.replace(MATCH_REGEX, newVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
_calculateCompletions() {
|
||||||
|
this.originalText = this.textArea.value; // cache starting text
|
||||||
|
|
||||||
|
// grab the partial word from the text which we'll be tab-completing
|
||||||
|
var res = MATCH_REGEX.exec(this.originalText);
|
||||||
|
if (!res) {
|
||||||
|
this.matchedList = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var [ ,group] = res; // ES6 destructuring; ignore first element
|
||||||
|
this.isFirstWord = group.length === this.originalText.length;
|
||||||
|
|
||||||
|
this.matchedList = [
|
||||||
|
new TabComplete.Entry(group) // first entry is always the original partial
|
||||||
|
];
|
||||||
|
|
||||||
|
// find matching entries in the set of entries given to us
|
||||||
|
this.list.forEach((entry) => {
|
||||||
|
if (entry.text.toLowerCase().indexOf(group.toLowerCase()) === 0) {
|
||||||
|
this.matchedList.push(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log("_calculateCompletions => %s", JSON.stringify(this.matchedList));
|
||||||
|
}
|
||||||
|
|
||||||
|
_notifyStateChange() {
|
||||||
|
if (this.opts.onStateChange) {
|
||||||
|
this.opts.onStateChange(this.completing);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
TabComplete.Entry = function(text, image) {
|
TabComplete.Entry = function(text, image) {
|
||||||
|
|
Loading…
Reference in a new issue