Implement autocompletion for advanced atlas

This commit is contained in:
phantamanta44 2021-07-19 14:50:51 -05:00
parent ab2e02754d
commit e480f06235
3 changed files with 320 additions and 36 deletions

79
items_2.css Normal file
View 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;
}

View file

@ -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 ?= &quot;blue&quot; & str >= 15 & dex >= 10">
placeholder="name ?= &quot;blue&quot; & 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>

View file

@ -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());