From e480f06235149fc4cfe388270970c7b58045104b Mon Sep 17 00:00:00 2001 From: phantamanta44 Date: Mon, 19 Jul 2021 14:50:51 -0500 Subject: [PATCH] Implement autocompletion for advanced atlas --- items_2.css | 79 +++++++++++++++++ items_2.html | 37 ++++---- items_2.js | 240 +++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 items_2.css diff --git a/items_2.css b/items_2.css new file mode 100644 index 0000000..db52799 --- /dev/null +++ b/items_2.css @@ -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; +} diff --git a/items_2.html b/items_2.html index 6915b2c..a751846 100644 --- a/items_2.html +++ b/items_2.html @@ -7,11 +7,12 @@ + WynnAtlas - +
@@ -32,29 +33,25 @@ -
-
-
- -
- -
-
+
+
+
+ + +
+
-
- -
- -
-
+
+ + +
+
-
+
diff --git a/items_2.js b/items_2.js index 9f4b939..656a731 100644 --- a/items_2.js +++ b/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());