Implement autocompletion for advanced atlas
This commit is contained in:
parent
ab2e02754d
commit
e480f06235
3 changed files with 320 additions and 36 deletions
79
items_2.css
Normal file
79
items_2.css
Normal file
|
@ -0,0 +1,79 @@
|
|||
.all {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
#main {
|
||||
padding: 2%;
|
||||
}
|
||||
|
||||
#search-container {
|
||||
margin-bottom: 1.5%;
|
||||
}
|
||||
|
||||
.search-field-container {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.search-field-label {
|
||||
display: block;
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
width: 25vw;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-field-compl {
|
||||
max-height: 50vh;
|
||||
position: fixed;
|
||||
display: none;
|
||||
background-color: #212121;
|
||||
border: solid 1px #666;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.search-field-compl.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-field-compl-entry {
|
||||
padding: 2px 6px;
|
||||
font-size: 12pt;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-field-compl-entry.focused {
|
||||
background-color: #424242;
|
||||
}
|
||||
|
||||
.search-field-error {
|
||||
display: block;
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
#item-list {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.item-entry {
|
||||
display: none;
|
||||
width: 20vw;
|
||||
margin: 1vw;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.item-entry.visible {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.item-entry-sort-key {
|
||||
margin-left: 1.75em;
|
||||
}
|
37
items_2.html
37
items_2.html
|
@ -7,11 +7,12 @@
|
|||
<link href="https://fonts.googleapis.com/css2?family=Nunito&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="items_2.css">
|
||||
<link rel="icon" href="./media/icons/new/searcher.png">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<title>WynnAtlas</title>
|
||||
</head>
|
||||
<body class="all" style="overflow-y: scroll">
|
||||
<body class="all">
|
||||
<header class="header nomarginp">
|
||||
<div class="headerleft" id = "headerleft">
|
||||
</div>
|
||||
|
@ -32,29 +33,25 @@
|
|||
<div class="center" id="help">
|
||||
<a href="items_2_help.html" class="link" target="_blank">Search Guide</a>
|
||||
</div>
|
||||
<div class="center" id="main" style="padding: 2%">
|
||||
<div id="search-container" style="margin-bottom: 1.5%">
|
||||
<div class="left" id="search-filter" style="display: inline-block; vertical-align: top">
|
||||
<label for="search-filter-field">Filter By:</label>
|
||||
<br>
|
||||
<input id="search-filter-field" type="text"
|
||||
placeholder="name ?= "blue" & str >= 15 & dex >= 10"
|
||||
style="width: 25vw; padding: 8px">
|
||||
<br>
|
||||
<div id="search-filter-error" style="color: #ff0000"></div>
|
||||
<div class="center" id="main">
|
||||
<div id="search-container">
|
||||
<div class="search-field-container left" id="search-filter">
|
||||
<label class="search-field-label" for="search-filter-field">Filter By:</label>
|
||||
<input class="search-field" id="search-filter-field" type="text" autofocus="true"
|
||||
placeholder="name ?= "blue" & str >= 15 & dex >= 10">
|
||||
<div class="search-field-compl" id="search-filter-compl"></div>
|
||||
<div class="search-field-error" id="search-filter-error"></div>
|
||||
</div>
|
||||
<div class="left" id="search-sort" style="display: inline-block; vertical-align: top">
|
||||
<label for="search-sort-field">Sort By:</label>
|
||||
<br>
|
||||
<input id="search-sort-field" type="text" placeholder="str + dex; meleerawdmg + spellrawdmg"
|
||||
style="width: 25vw; padding: 8px">
|
||||
<br>
|
||||
<div id="search-sort-error" style="color: #ff0000"></div>
|
||||
<div class="search-field-container left" id="search-sort">
|
||||
<label class="search-field-label" for="search-sort-field">Sort By:</label>
|
||||
<input class="search-field" id="search-sort-field" type="text"
|
||||
placeholder="str + dex; meleerawdmg + spellrawdmg">
|
||||
<div class="search-field-compl" id="search-sort-compl"></div>
|
||||
<div class="search-field-error" id="search-sort-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="item-list-container">
|
||||
<div class="left" id="item-list"
|
||||
style="display: flex; flex-flow: row wrap; justify-content: space-evenly"></div>
|
||||
<div class="left" id="item-list"></div>
|
||||
<div class="center" id="item-list-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
240
items_2.js
240
items_2.js
|
@ -1,17 +1,221 @@
|
|||
function isIdentifierChar(character) {
|
||||
return /[\w\d%]/i.test(character);
|
||||
}
|
||||
|
||||
function isIdentifierFirstChar(character) {
|
||||
return /\w/i.test(character);
|
||||
}
|
||||
|
||||
class AutocompleteContext {
|
||||
constructor(field) {
|
||||
this.field = field;
|
||||
this.text = field.value;
|
||||
this.cursorPos = this.startIndex = this.endIndex = field.selectionEnd;
|
||||
while (this.startIndex > 0 && isIdentifierChar(this.text.charAt(this.startIndex - 1))) {
|
||||
--this.startIndex;
|
||||
}
|
||||
if (!isIdentifierFirstChar(this.text.charAt(this.startIndex))) {
|
||||
this.startIndex = this.cursorPos;
|
||||
return;
|
||||
}
|
||||
while (this.endIndex < this.text.length && isIdentifierChar(this.text.charAt(this.endIndex))) {
|
||||
++this.endIndex;
|
||||
}
|
||||
}
|
||||
|
||||
get valid() {
|
||||
return this.endIndex > this.startIndex;
|
||||
}
|
||||
|
||||
get complText() {
|
||||
return this.text.substring(this.startIndex, this.cursorPos);
|
||||
}
|
||||
|
||||
insert(completion, supplant) {
|
||||
this.field.setRangeText(completion, this.startIndex, supplant ? this.endIndex : this.cursorPos, 'end');
|
||||
this.field.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
|
||||
this.startIndex = this.endIndex = -1;
|
||||
setTimeout(() => this.field.focus(), 5); // no idea why i need a delay here
|
||||
}
|
||||
}
|
||||
|
||||
class AutocompleteController {
|
||||
constructor(ctx, completions, exprField) {
|
||||
this.ctx = ctx;
|
||||
this.completions = completions;
|
||||
this.exprField = exprField;
|
||||
this.currentFocus = null;
|
||||
|
||||
for (const completion of completions) {
|
||||
const complElem = document.createElement('div');
|
||||
complElem.classList.add('search-field-compl-entry')
|
||||
complElem.setAttribute('data-compl', completion);
|
||||
complElem.innerText = completion;
|
||||
complElem.addEventListener('mousemove', e => this.focus(complElem));
|
||||
complElem.addEventListener('mousedown', e => this.complete(completion, true));
|
||||
exprField.completions.append(complElem);
|
||||
}
|
||||
}
|
||||
|
||||
get valid() {
|
||||
return this.ctx.valid && this.completions.length > 0;
|
||||
}
|
||||
|
||||
focus(complElem) {
|
||||
if (this.currentFocus !== null) {
|
||||
this.currentFocus.classList.remove('focused');
|
||||
}
|
||||
this.currentFocus = complElem;
|
||||
complElem.classList.add('focused');
|
||||
complElem.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
focusNext() {
|
||||
if (this.currentFocus === null || !this.currentFocus.nextSibling) {
|
||||
this.focus(this.exprField.completions.firstChild);
|
||||
} else {
|
||||
this.focus(this.currentFocus.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
focusPrev() {
|
||||
if (this.currentFocus === null || !this.currentFocus.previousSibling) {
|
||||
this.focus(this.exprField.completions.lastChild);
|
||||
} else {
|
||||
this.focus(this.currentFocus.previousSibling);
|
||||
}
|
||||
}
|
||||
|
||||
complete(completion, supplant) {
|
||||
if (completion === null) {
|
||||
completion = this.currentFocus.getAttribute('data-compl');
|
||||
if (completion === null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.ctx.insert(completion, supplant);
|
||||
this.exprField.clearAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
const getQueryIdentifiers = (function() {
|
||||
let identCache = null;
|
||||
return function() {
|
||||
if (identCache === null) {
|
||||
const idents = new Set();
|
||||
for (const ident of Object.keys(itemQueryProps)) {
|
||||
idents.add(ident);
|
||||
}
|
||||
for (const ident of Object.keys(itemQueryFuncs)) {
|
||||
idents.add(ident);
|
||||
}
|
||||
identCache = [...idents].sort(); // might use a trie optimally, but the set is probably small enough...
|
||||
}
|
||||
return identCache;
|
||||
};
|
||||
})();
|
||||
|
||||
// represents a field containing a query expression string
|
||||
class ExprField {
|
||||
constructor(fieldId, errorTextId, compiler) {
|
||||
this.field = document.getElementById(fieldId);
|
||||
this.errorText = document.getElementById(errorTextId);
|
||||
constructor(key, compiler) {
|
||||
this.field = document.getElementById(`search-${key}-field`);
|
||||
this.completions = document.getElementById(`search-${key}-compl`);
|
||||
this.errorText = document.getElementById(`search-${key}-error`);
|
||||
this.prevComplText = null;
|
||||
this.prevComplPos = null;
|
||||
this.complCtrl = null;
|
||||
this.compiler = compiler;
|
||||
this.output = null;
|
||||
this.text = null;
|
||||
|
||||
this.field.addEventListener('focus', e => this.scheduleAutocomplete());
|
||||
this.field.addEventListener('change', e => this.scheduleAutocomplete());
|
||||
this.field.addEventListener('keydown', e => {
|
||||
if (this.complCtrl !== null && this.complCtrl.valid) {
|
||||
switch (e.key) {
|
||||
case 'Up':
|
||||
case 'ArrowUp':
|
||||
this.complCtrl.focusPrev();
|
||||
break;
|
||||
case 'Down':
|
||||
case 'ArrowDown':
|
||||
this.complCtrl.focusNext();
|
||||
break;
|
||||
case 'Tab':
|
||||
this.complCtrl.complete(null, true);
|
||||
break;
|
||||
case 'Enter':
|
||||
this.complCtrl.complete(null, false);
|
||||
break;
|
||||
case 'Escape':
|
||||
this.clearAutocomplete();
|
||||
break;
|
||||
default:
|
||||
this.scheduleAutocomplete();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
} else {
|
||||
switch (e.key) {
|
||||
case 'Spacebar':
|
||||
case ' ':
|
||||
if (e.ctrlKey) {
|
||||
this.autocomplete();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.scheduleAutocomplete()
|
||||
});
|
||||
this.field.addEventListener('mousedown', e => this.scheduleAutocomplete());
|
||||
this.field.addEventListener('blur', e => this.clearAutocomplete());
|
||||
}
|
||||
|
||||
|
||||
get value() {
|
||||
return this.field.value;
|
||||
}
|
||||
|
||||
scheduleAutocomplete() {
|
||||
setTimeout(() => {
|
||||
if (this.field.value !== this.prevComplText || this.field.selectionEnd !== this.prevComplPos) {
|
||||
this.prevComplText = this.field.value;
|
||||
this.prevComplPos = this.field.selectionEnd;
|
||||
this.autocomplete();
|
||||
}
|
||||
}, 1);
|
||||
}
|
||||
|
||||
autocomplete() {
|
||||
while (this.completions.lastChild) {
|
||||
this.completions.removeChild(this.completions.lastChild);
|
||||
}
|
||||
|
||||
const complCtx = new AutocompleteContext(this.field);
|
||||
if (!complCtx.valid) {
|
||||
this.clearAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const complText = complCtx.complText;
|
||||
const completions = getQueryIdentifiers().filter(ident => ident.startsWith(complText));
|
||||
if (completions.length === 0) {
|
||||
this.clearAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
this.complCtrl = new AutocompleteController(complCtx, completions, this);
|
||||
this.complCtrl.focusNext();
|
||||
this.completions.classList.add('visible');
|
||||
}
|
||||
|
||||
clearAutocomplete() {
|
||||
this.completions.classList.remove('visible');
|
||||
this.prevComplText = this.field.value;
|
||||
this.prevComplPos = this.field.selectionEnd;
|
||||
this.complCtrl = null;
|
||||
}
|
||||
|
||||
compile() {
|
||||
if (this.value === this.text) return false;
|
||||
this.text = this.value;
|
||||
|
@ -66,12 +270,8 @@ function init_items2() {
|
|||
const itemEntries = [];
|
||||
for (let i = 0; i < ITEM_LIST_SIZE; i++) {
|
||||
const itemElem = document.createElement('div');
|
||||
itemElem.classList.add('box');
|
||||
itemElem.classList.add('item-entry', 'box');
|
||||
itemElem.setAttribute('id', `item-entry-${i}`);
|
||||
itemElem.style.display = 'none';
|
||||
itemElem.style.width = '20vw';
|
||||
itemElem.style.margin = '1vw';
|
||||
itemElem.style.verticalAlign = 'top';
|
||||
itemList.append(itemElem);
|
||||
itemEntries.push(itemElem);
|
||||
}
|
||||
|
@ -80,11 +280,11 @@ function init_items2() {
|
|||
const exprParser = new ExprParser(itemQueryProps, itemQueryFuncs);
|
||||
|
||||
// the two search query input boxes
|
||||
const searchFilterField = new ExprField('search-filter-field', 'search-filter-error', function(exprStr) {
|
||||
const searchFilterField = new ExprField('filter', function(exprStr) {
|
||||
const expr = exprParser.parse(exprStr);
|
||||
return expr !== null ? expr : new BoolLitTerm(true);
|
||||
});
|
||||
const searchSortField = new ExprField('search-sort-field', 'search-sort-error', function(exprStr) {
|
||||
const searchSortField = new ExprField('sort', function(exprStr) {
|
||||
const subExprs = exprStr.split(';').map(e => exprParser.parse(e)).filter(f => f != null);
|
||||
return {
|
||||
type: 'array',
|
||||
|
@ -109,7 +309,7 @@ function init_items2() {
|
|||
|
||||
// hide old search results
|
||||
itemListFooter.innerText = '';
|
||||
for (const itemEntry of itemEntries) itemEntry.style.display = 'none';
|
||||
for (const itemEntry of itemEntries) itemEntry.classList.remove('visible');
|
||||
|
||||
// index and sort search results
|
||||
const searchResults = [];
|
||||
|
@ -129,7 +329,14 @@ function init_items2() {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
searchResults.sort((a, b) => compareLexico(a.item, a.sortKeys, b.item, b.sortKeys));
|
||||
searchResults.sort((a, b) => {
|
||||
try {
|
||||
return compareLexico(a.item, a.sortKeys, b.item, b.sortKeys);
|
||||
} catch (e) {
|
||||
console.log(a.item, b.item);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
searchSortField.errorText.innerText = e.message;
|
||||
return;
|
||||
|
@ -139,14 +346,13 @@ function init_items2() {
|
|||
const searchMax = Math.min(searchResults.length, ITEM_LIST_SIZE);
|
||||
for (let i = 0; i < searchMax; i++) {
|
||||
const result = searchResults[i];
|
||||
itemEntries[i].style.display = 'inline-block';
|
||||
itemEntries[i].classList.add('visible');
|
||||
displayExpandedItem(result.itemExp, `item-entry-${i}`);
|
||||
if (result.sortKeys.length > 0) {
|
||||
const sortKeyListContainer = document.createElement('div');
|
||||
sortKeyListContainer.classList.add('itemleft');
|
||||
const sortKeyList = document.createElement('ul');
|
||||
sortKeyList.classList.add('itemp', 'T0');
|
||||
sortKeyList.style.marginLeft = '1.75em';
|
||||
sortKeyList.classList.add('item-entry-sort-key', 'itemp', 'T0');
|
||||
sortKeyListContainer.append(sortKeyList);
|
||||
for (let j = 0; j < result.sortKeys.length; j++) {
|
||||
const sortKeyElem = document.createElement('li');
|
||||
|
@ -163,6 +369,7 @@ function init_items2() {
|
|||
|
||||
// updates the search state from the input boxes after a brief delay, to prevent excessive DOM updates
|
||||
let updateSearchTask = null;
|
||||
|
||||
function scheduleSearchUpdate() {
|
||||
if (updateSearchTask !== null) {
|
||||
clearTimeout(updateSearchTask);
|
||||
|
@ -172,6 +379,7 @@ function init_items2() {
|
|||
updateSearch();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
searchFilterField.field.addEventListener('input', e => scheduleSearchUpdate());
|
||||
searchSortField.field.addEventListener('input', e => scheduleSearchUpdate());
|
||||
|
||||
|
|
Loading…
Reference in a new issue