szurubooru/client/js/controls/auto_complete_control.js
rr- 7e62751e4e client/tags: fix hovering over autocomplete in FF
Hovering over an autocomplete box always selected the last element
rather than the element under the cursor. This is because resultIndex
was bound by reference.

This looks like a bug in FF implementation of "for (let [x, y] of ...)"
-rather than binding "x" and "y" to the scope of the loop, it's
equivalent to "for (var [x, y] of ...)", which causes nasty anomalies
for functions created inside the loop body.
2016-07-05 23:59:10 +02:00

290 lines
9.8 KiB
JavaScript

'use strict';
const views = require('../util/views.js');
const KEY_TAB = 9;
const KEY_RETURN = 13;
const KEY_DELETE = 46;
const KEY_ESCAPE = 27;
const KEY_UP = 38;
const KEY_DOWN = 40;
function _getSelectionStart(input) {
if ('selectionStart' in input) {
return input.selectionStart;
}
if (document.selection) {
input.focus();
const sel = document.selection.createRange();
const selLen = document.selection.createRange().text.length;
sel.moveStart('character', -input.value.length);
return sel.text.length - selLen;
}
return 0;
}
class AutoCompleteControl {
constructor(sourceInputNode, options) {
this._sourceInputNode = sourceInputNode;
this._options = {};
Object.assign(this._options, {
verticalShift: 2,
source: null,
addSpace: false,
maxResults: 15,
getTextToFind: () => {
const value = sourceInputNode.value;
const start = _getSelectionStart(sourceInputNode);
return value.substring(0, start).replace(/.*\s+/, '');
},
confirm: text => {
const start = _getSelectionStart(sourceInputNode);
let prefix = '';
let suffix = sourceInputNode.value.substring(start);
let middle = sourceInputNode.value.substring(0, start);
const index = middle.lastIndexOf(' ');
if (index !== -1) {
prefix = sourceInputNode.value.substring(0, index + 1);
middle = sourceInputNode.value.substring(index + 1);
}
sourceInputNode.value = prefix +
this._results[this._activeResult].value +
' ' +
suffix.trimLeft();
if (!this._options.addSpace) {
sourceInputNode.value = sourceInputNode.value.trim();
}
sourceInputNode.focus();
},
delete: text => {
},
getMatches: null,
}, options);
this._showTimeout = null;
this._results = [];
this._activeResult = -1;
this._install();
}
hide() {
window.clearTimeout(this._showTimeout);
this._suggestionDiv.style.display = 'none';
this._isVisible = false;
}
_show() {
this._suggestionDiv.style.display = 'block';
this._isVisible = true;
}
_showOrHide() {
const textToFind = this._options.getTextToFind();
if (!textToFind || !textToFind.length) {
this.hide();
} else {
this._updateResults(textToFind);
this._refreshList();
}
}
_install() {
if (!this._sourceInputNode) {
throw new Error('Input element was not found');
}
if (this._sourceInputNode.getAttribute('data-autocomplete')) {
throw new Error(
'Autocompletion was already added for this element');
}
this._sourceInputNode.setAttribute('data-autocomplete', true);
this._sourceInputNode.setAttribute('autocomplete', 'off');
this._sourceInputNode.addEventListener(
'keydown', e => this._evtKeyDown(e));
this._sourceInputNode.addEventListener(
'blur', e => this._evtBlur(e));
this._suggestionDiv = views.htmlToDom(
'<div class="autocomplete"><ul></ul></div>');
this._suggestionList = this._suggestionDiv.querySelector('ul');
document.body.appendChild(this._suggestionDiv);
views.monitorNodeRemoval(
this._sourceInputNode, () => { this._uninstall(); });
}
_uninstall() {
window.clearTimeout(this._showTimeout);
document.body.removeChild(this._suggestionDiv);
}
_evtKeyDown(e) {
const key = e.which;
const shift = e.shiftKey;
let func = null;
if (this._isVisible) {
if (key === KEY_ESCAPE) {
func = this.hide;
} else if (key === KEY_TAB && shift) {
func = () => { this._selectPrevious(); };
} else if (key === KEY_TAB && !shift) {
func = () => { this._selectNext(); };
} else if (key === KEY_UP) {
func = () => { this._selectPrevious(); };
} else if (key === KEY_DOWN) {
func = () => { this._selectNext(); };
} else if (key === KEY_RETURN && this._activeResult >= 0) {
func = () => {
this._options.confirm(this._getActiveSuggestion());
this.hide();
};
} else if (key === KEY_DELETE && this._activeResult >= 0) {
func = () => {
this._options.delete(this._getActiveSuggestion());
this.hide();
};
}
}
if (func !== null) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
func();
} else {
window.clearTimeout(this._showTimeout);
this._showTimeout = window.setTimeout(
() => { this._showOrHide(); }, 250);
}
}
_evtBlur(e) {
window.clearTimeout(this._showTimeout);
window.setTimeout(() => { this.hide(); }, 50);
}
_getActiveSuggestion() {
if (this._activeResult === -1) {
return null;
}
return this._results[this._activeResult].value;
}
_selectPrevious() {
this._select(this._activeResult === -1 ?
this._results.length - 1 :
this._activeResult - 1);
}
_selectNext() {
this._select(this._activeResult === -1 ? 0 : this._activeResult + 1);
}
_select(newActiveResult) {
this._activeResult =
newActiveResult.between(0, this._results.length - 1, true) ?
newActiveResult :
-1;
this._refreshActiveResult();
}
_updateResults(textToFind) {
const oldResults = this._results.slice();
this._results =
this._options.getMatches(textToFind)
.slice(0, this._options.maxResults);
const oldResultsHash = JSON.stringify(oldResults);
const newResultsHash = JSON.stringify(this._results);
if (oldResultsHash !== newResultsHash) {
this._activeResult = -1;
}
}
_refreshList() {
if (this._results.length === 0) {
this.hide();
return;
}
while (this._suggestionList.firstChild) {
this._suggestionList.removeChild(this._suggestionList.firstChild);
}
for (let [resultIndex, resultItem] of this._results.entries()) {
let resultIndexWorkaround = resultIndex;
const listItem = document.createElement('li');
const link = document.createElement('a');
link.href = '#';
link.innerHTML = resultItem.caption;
link.setAttribute('data-key', resultItem.value);
link.addEventListener(
'mouseenter',
e => {
e.preventDefault();
this._activeResult = resultIndexWorkaround;
this._refreshActiveResult();
});
link.addEventListener(
'mousedown',
e => {
e.preventDefault();
this._activeResult = resultIndexWorkaround;
this._options.confirm(this._getActiveSuggestion());
this.hide();
});
listItem.appendChild(link);
this._suggestionList.appendChild(listItem);
}
this._refreshActiveResult();
// display the suggestions offscreen to get the height
this._suggestionDiv.style.left = '-9999px';
this._suggestionDiv.style.top = '-9999px';
this._show();
const verticalShift = this._options.verticalShift;
const inputRect = this._sourceInputNode.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect();
const viewPortHeight = bodyRect.bottom - bodyRect.top;
let listRect = this._suggestionDiv.getBoundingClientRect();
// choose where to view the suggestions: if there's more space above
// the input - draw the suggestions above it, otherwise below
const direction =
inputRect.top + inputRect.height / 2 < viewPortHeight / 2 ? 1 : -1;
let x = inputRect.left - bodyRect.left;
let y = direction == 1 ?
inputRect.bottom - bodyRect.top - verticalShift :
inputRect.top - bodyRect.top - listRect.height + verticalShift;
// remove offscreen items until whole suggestion list can fit on the
// screen
while ((y < 0 || y + listRect.height > viewPortHeight) &&
this._suggestionList.childNodes.length) {
this._suggestionList.removeChild(this._suggestionList.lastChild);
const prevHeight = listRect.height;
listRect = this._suggestionDiv.getBoundingClientRect();
const heightDelta = prevHeight - listRect.height;
if (direction == -1) {
y += heightDelta;
}
}
this._suggestionDiv.style.left = x + 'px';
this._suggestionDiv.style.top = y + 'px';
}
_refreshActiveResult() {
let activeItem = this._suggestionList.querySelector('li.active');
if (activeItem) {
activeItem.classList.remove('active');
}
if (this._activeResult >= 0) {
const allItems = this._suggestionList.querySelectorAll('li');
activeItem = allItems[this._activeResult];
activeItem.classList.add('active');
}
}
};
module.exports = AutoCompleteControl;