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 href="https://fonts.googleapis.com/css2?family=Nunito&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="items_2.css">
|
||||||
<link rel="icon" href="./media/icons/new/searcher.png">
|
<link rel="icon" href="./media/icons/new/searcher.png">
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<title>WynnAtlas</title>
|
<title>WynnAtlas</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="all" style="overflow-y: scroll">
|
<body class="all">
|
||||||
<header class="header nomarginp">
|
<header class="header nomarginp">
|
||||||
<div class="headerleft" id = "headerleft">
|
<div class="headerleft" id = "headerleft">
|
||||||
</div>
|
</div>
|
||||||
|
@ -32,29 +33,25 @@
|
||||||
<div class="center" id="help">
|
<div class="center" id="help">
|
||||||
<a href="items_2_help.html" class="link" target="_blank">Search Guide</a>
|
<a href="items_2_help.html" class="link" target="_blank">Search Guide</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="center" id="main" style="padding: 2%">
|
<div class="center" id="main">
|
||||||
<div id="search-container" style="margin-bottom: 1.5%">
|
<div id="search-container">
|
||||||
<div class="left" id="search-filter" style="display: inline-block; vertical-align: top">
|
<div class="search-field-container left" id="search-filter">
|
||||||
<label for="search-filter-field">Filter By:</label>
|
<label class="search-field-label" for="search-filter-field">Filter By:</label>
|
||||||
<br>
|
<input class="search-field" id="search-filter-field" type="text" autofocus="true"
|
||||||
<input id="search-filter-field" type="text"
|
placeholder="name ?= "blue" & str >= 15 & dex >= 10">
|
||||||
placeholder="name ?= "blue" & str >= 15 & dex >= 10"
|
<div class="search-field-compl" id="search-filter-compl"></div>
|
||||||
style="width: 25vw; padding: 8px">
|
<div class="search-field-error" id="search-filter-error"></div>
|
||||||
<br>
|
|
||||||
<div id="search-filter-error" style="color: #ff0000"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="left" id="search-sort" style="display: inline-block; vertical-align: top">
|
<div class="search-field-container left" id="search-sort">
|
||||||
<label for="search-sort-field">Sort By:</label>
|
<label class="search-field-label" for="search-sort-field">Sort By:</label>
|
||||||
<br>
|
<input class="search-field" id="search-sort-field" type="text"
|
||||||
<input id="search-sort-field" type="text" placeholder="str + dex; meleerawdmg + spellrawdmg"
|
placeholder="str + dex; meleerawdmg + spellrawdmg">
|
||||||
style="width: 25vw; padding: 8px">
|
<div class="search-field-compl" id="search-sort-compl"></div>
|
||||||
<br>
|
<div class="search-field-error" id="search-sort-error"></div>
|
||||||
<div id="search-sort-error" style="color: #ff0000"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="item-list-container">
|
<div id="item-list-container">
|
||||||
<div class="left" id="item-list"
|
<div class="left" id="item-list"></div>
|
||||||
style="display: flex; flex-flow: row wrap; justify-content: space-evenly"></div>
|
|
||||||
<div class="center" id="item-list-footer"></div>
|
<div class="center" id="item-list-footer"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
238
items_2.js
238
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
|
// represents a field containing a query expression string
|
||||||
class ExprField {
|
class ExprField {
|
||||||
constructor(fieldId, errorTextId, compiler) {
|
constructor(key, compiler) {
|
||||||
this.field = document.getElementById(fieldId);
|
this.field = document.getElementById(`search-${key}-field`);
|
||||||
this.errorText = document.getElementById(errorTextId);
|
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.compiler = compiler;
|
||||||
this.output = null;
|
this.output = null;
|
||||||
this.text = 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() {
|
get value() {
|
||||||
return this.field.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() {
|
compile() {
|
||||||
if (this.value === this.text) return false;
|
if (this.value === this.text) return false;
|
||||||
this.text = this.value;
|
this.text = this.value;
|
||||||
|
@ -66,12 +270,8 @@ function init_items2() {
|
||||||
const itemEntries = [];
|
const itemEntries = [];
|
||||||
for (let i = 0; i < ITEM_LIST_SIZE; i++) {
|
for (let i = 0; i < ITEM_LIST_SIZE; i++) {
|
||||||
const itemElem = document.createElement('div');
|
const itemElem = document.createElement('div');
|
||||||
itemElem.classList.add('box');
|
itemElem.classList.add('item-entry', 'box');
|
||||||
itemElem.setAttribute('id', `item-entry-${i}`);
|
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);
|
itemList.append(itemElem);
|
||||||
itemEntries.push(itemElem);
|
itemEntries.push(itemElem);
|
||||||
}
|
}
|
||||||
|
@ -80,11 +280,11 @@ function init_items2() {
|
||||||
const exprParser = new ExprParser(itemQueryProps, itemQueryFuncs);
|
const exprParser = new ExprParser(itemQueryProps, itemQueryFuncs);
|
||||||
|
|
||||||
// the two search query input boxes
|
// 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);
|
const expr = exprParser.parse(exprStr);
|
||||||
return expr !== null ? expr : new BoolLitTerm(true);
|
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);
|
const subExprs = exprStr.split(';').map(e => exprParser.parse(e)).filter(f => f != null);
|
||||||
return {
|
return {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
@ -109,7 +309,7 @@ function init_items2() {
|
||||||
|
|
||||||
// hide old search results
|
// hide old search results
|
||||||
itemListFooter.innerText = '';
|
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
|
// index and sort search results
|
||||||
const searchResults = [];
|
const searchResults = [];
|
||||||
|
@ -129,7 +329,14 @@ function init_items2() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
searchSortField.errorText.innerText = e.message;
|
searchSortField.errorText.innerText = e.message;
|
||||||
return;
|
return;
|
||||||
|
@ -139,14 +346,13 @@ function init_items2() {
|
||||||
const searchMax = Math.min(searchResults.length, ITEM_LIST_SIZE);
|
const searchMax = Math.min(searchResults.length, ITEM_LIST_SIZE);
|
||||||
for (let i = 0; i < searchMax; i++) {
|
for (let i = 0; i < searchMax; i++) {
|
||||||
const result = searchResults[i];
|
const result = searchResults[i];
|
||||||
itemEntries[i].style.display = 'inline-block';
|
itemEntries[i].classList.add('visible');
|
||||||
displayExpandedItem(result.itemExp, `item-entry-${i}`);
|
displayExpandedItem(result.itemExp, `item-entry-${i}`);
|
||||||
if (result.sortKeys.length > 0) {
|
if (result.sortKeys.length > 0) {
|
||||||
const sortKeyListContainer = document.createElement('div');
|
const sortKeyListContainer = document.createElement('div');
|
||||||
sortKeyListContainer.classList.add('itemleft');
|
sortKeyListContainer.classList.add('itemleft');
|
||||||
const sortKeyList = document.createElement('ul');
|
const sortKeyList = document.createElement('ul');
|
||||||
sortKeyList.classList.add('itemp', 'T0');
|
sortKeyList.classList.add('item-entry-sort-key', 'itemp', 'T0');
|
||||||
sortKeyList.style.marginLeft = '1.75em';
|
|
||||||
sortKeyListContainer.append(sortKeyList);
|
sortKeyListContainer.append(sortKeyList);
|
||||||
for (let j = 0; j < result.sortKeys.length; j++) {
|
for (let j = 0; j < result.sortKeys.length; j++) {
|
||||||
const sortKeyElem = document.createElement('li');
|
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
|
// updates the search state from the input boxes after a brief delay, to prevent excessive DOM updates
|
||||||
let updateSearchTask = null;
|
let updateSearchTask = null;
|
||||||
|
|
||||||
function scheduleSearchUpdate() {
|
function scheduleSearchUpdate() {
|
||||||
if (updateSearchTask !== null) {
|
if (updateSearchTask !== null) {
|
||||||
clearTimeout(updateSearchTask);
|
clearTimeout(updateSearchTask);
|
||||||
|
@ -172,6 +379,7 @@ function init_items2() {
|
||||||
updateSearch();
|
updateSearch();
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
searchFilterField.field.addEventListener('input', e => scheduleSearchUpdate());
|
searchFilterField.field.addEventListener('input', e => scheduleSearchUpdate());
|
||||||
searchSortField.field.addEventListener('input', e => scheduleSearchUpdate());
|
searchSortField.field.addEventListener('input', e => scheduleSearchUpdate());
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue