import { power_user } from '../power-user.js'; import { debounce, escapeRegex } from '../utils.js'; import { AutoCompleteOption } from './AutoCompleteOption.js'; import { AutoCompleteFuzzyScore } from './AutoCompleteFuzzyScore.js'; import { BlankAutoCompleteOption } from './BlankAutoCompleteOption.js'; // eslint-disable-next-line no-unused-vars import { AutoCompleteNameResult } from './AutoCompleteNameResult.js'; import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js'; import { Popup, getTopmostModalLayer } from '../popup.js'; /**@readonly*/ /**@enum {Number}*/ export const AUTOCOMPLETE_WIDTH = { 'INPUT': 0, 'CHAT': 1, 'FULL': 2, }; export class AutoComplete { /**@type {HTMLTextAreaElement|HTMLInputElement}*/ textarea; /**@type {boolean}*/ isFloating = false; /**@type {()=>boolean}*/ checkIfActivate; /**@type {(text:string, index:number) => Promise}*/ getNameAt; /**@type {boolean}*/ isActive = false; /**@type {boolean}*/ isReplaceable = false; /**@type {boolean}*/ isShowingDetails = false; /**@type {boolean}*/ wasForced = false; /**@type {boolean}*/ isForceHidden = false; /**@type {boolean}*/ canBeAutoHidden = false; /**@type {string}*/ text; /**@type {AutoCompleteNameResult}*/ parserResult; /**@type {AutoCompleteSecondaryNameResult}*/ secondaryParserResult; get effectiveParserResult() { return this.secondaryParserResult ?? this.parserResult; } /**@type {string}*/ name; /**@type {boolean}*/ startQuote; /**@type {boolean}*/ endQuote; /**@type {number}*/ selectionStart; /**@type {RegExp}*/ fuzzyRegex; /**@type {AutoCompleteOption[]}*/ result = []; /**@type {AutoCompleteOption}*/ selectedItem = null; /**@type {HTMLElement}*/ clone; /**@type {HTMLElement}*/ domWrap; /**@type {HTMLElement}*/ dom; /**@type {HTMLElement}*/ detailsWrap; /**@type {HTMLElement}*/ detailsDom; /**@type {function}*/ renderDebounced; /**@type {function}*/ renderDetailsDebounced; /**@type {function}*/ updatePositionDebounced; /**@type {function}*/ updateDetailsPositionDebounced; /**@type {function}*/ updateFloatingPositionDebounced; /**@type {(item:AutoCompleteOption)=>any}*/ onSelect; get matchType() { return power_user.stscript.matching ?? 'fuzzy'; } get autoHide() { return power_user.stscript.autocomplete.autoHide ?? false; } /** * @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete. * @param {() => boolean} checkIfActivate Function should return true only if under the current conditions, autocomplete should display (e.g., for slash commands: autoComplete.text[0] == '/') * @param {(text: string, index: number) => Promise} getNameAt Function should return (unfiltered, matching against input is done in AutoComplete) information about name options at index in text. * @param {boolean} isFloating Whether autocomplete should float at the keyboard cursor. */ constructor(textarea, checkIfActivate, getNameAt, isFloating = false) { this.textarea = textarea; this.checkIfActivate = checkIfActivate; this.getNameAt = getNameAt; this.isFloating = isFloating; this.domWrap = document.createElement('div'); { this.domWrap.classList.add('autoComplete-wrap'); if (isFloating) this.domWrap.classList.add('isFloating'); } this.dom = document.createElement('ul'); { this.dom.classList.add('autoComplete'); this.domWrap.append(this.dom); } this.detailsWrap = document.createElement('div'); { this.detailsWrap.classList.add('autoComplete-detailsWrap'); if (isFloating) this.detailsWrap.classList.add('isFloating'); } this.detailsDom = document.createElement('div'); { this.detailsDom.classList.add('autoComplete-details'); this.detailsWrap.append(this.detailsDom); } this.renderDebounced = debounce(this.render.bind(this), 10); this.renderDetailsDebounced = debounce(this.renderDetails.bind(this), 10); this.updatePositionDebounced = debounce(this.updatePosition.bind(this), 10); this.updateDetailsPositionDebounced = debounce(this.updateDetailsPosition.bind(this), 10); this.updateFloatingPositionDebounced = debounce(this.updateFloatingPosition.bind(this), 10); textarea.addEventListener('input', ()=>{ this.selectionStart = this.textarea.selectionStart; if (this.text != this.textarea.value) this.show(true, this.wasForced); }); textarea.addEventListener('keydown', (evt)=>this.handleKeyDown(evt)); textarea.addEventListener('click', ()=>{ this.selectionStart = this.textarea.selectionStart; if (this.isActive) this.show(); }); textarea.addEventListener('blur', ()=>this.hide()); if (isFloating) { textarea.addEventListener('scroll', ()=>this.updateFloatingPositionDebounced()); } window.addEventListener('resize', ()=>this.updatePositionDebounced()); } /** * * @param {AutoCompleteOption} option */ makeItem(option) { const li = option.renderItem(); // gotta listen to pointerdown (happens before textarea-blur) li.addEventListener('pointerdown', (evt)=>{ evt.preventDefault(); this.selectedItem = this.result.find(it=>it.name == li.getAttribute('data-name')); this.select(); }); return li; } /** * * @param {AutoCompleteOption} item */ updateName(item) { const chars = Array.from(item.dom.querySelector('.name').children); switch (this.matchType) { case 'strict': { chars.forEach((it, idx)=>{ if (idx + item.nameOffset < item.name.length) { it.classList.add('matched'); } else { it.classList.remove('matched'); } }); break; } case 'includes': { const start = item.name.toLowerCase().search(this.name); chars.forEach((it, idx)=>{ if (idx + item.nameOffset < start) { it.classList.remove('matched'); } else if (idx + item.nameOffset < start + item.name.length) { it.classList.add('matched'); } else { it.classList.remove('matched'); } }); break; } case 'fuzzy': { item.name.replace(this.fuzzyRegex, (_, ...parts)=>{ parts.splice(-2, 2); if (parts.length == 2) { chars.forEach(c=>c.classList.remove('matched')); } else { let cIdx = item.nameOffset; parts.forEach((it, idx)=>{ if (it === null || it.length == 0) return ''; if (idx % 2 == 1) { chars.slice(cIdx, cIdx + it.length).forEach(c=>c.classList.add('matched')); } else { chars.slice(cIdx, cIdx + it.length).forEach(c=>c.classList.remove('matched')); } cIdx += it.length; }); } return ''; }); } } return item; } /** * Calculate score for the fuzzy match. * @param {AutoCompleteOption} option * @returns The option. */ fuzzyScore(option) { // might have been matched by the options matchProvider function instead if (!this.fuzzyRegex.test(option.name)) { option.score = new AutoCompleteFuzzyScore(Number.MAX_SAFE_INTEGER, -1); return option; } const parts = this.fuzzyRegex.exec(option.name).slice(1, -1); let start = null; let consecutive = []; let current = ''; let offset = 0; parts.forEach((part, idx) => { if (idx % 2 == 0) { if (part.length > 0) { if (current.length > 0) { consecutive.push(current); } current = ''; } } else { if (start === null) { start = offset; } current += part; } offset += part.length; }); if (current.length > 0) { consecutive.push(current); } consecutive.sort((a,b)=>b.length - a.length); option.score = new AutoCompleteFuzzyScore(start, consecutive[0]?.length ?? 0); return option; } /** * Compare two auto complete options by their fuzzy score. * @param {AutoCompleteOption} a * @param {AutoCompleteOption} b */ fuzzyScoreCompare(a, b) { if (a.score.start < b.score.start) return -1; if (a.score.start > b.score.start) return 1; if (a.score.longestConsecutive > b.score.longestConsecutive) return -1; if (a.score.longestConsecutive < b.score.longestConsecutive) return 1; return a.name.localeCompare(b.name); } basicAutoHideCheck() { // auto hide only if at least one char has been typed after the name + space return this.textarea.selectionStart > this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0) + 1 ; } /** * Show the autocomplete. * @param {boolean} isInput Whether triggered by input. * @param {boolean} isForced Whether force-showing (ctrl+space). * @param {boolean} isSelect Whether an autocomplete option was just selected. */ async show(isInput = false, isForced = false, isSelect = false) { //TODO check if isInput and isForced are both required this.text = this.textarea.value; this.isReplaceable = false; if (document.activeElement != this.textarea) { // only show with textarea in focus return this.hide(); } if (!this.checkIfActivate()) { // only show if provider wants to return this.hide(); } // disable force-hide if trigger was forced if (isForced) this.isForceHidden = false; // request provider to get name result (potentially "incomplete", i.e. not an actual existing name) for // cursor position this.parserResult = await this.getNameAt(this.text, this.textarea.selectionStart); this.secondaryParserResult = null; if (!this.parserResult) { // don't show if no name result found, e.g., cursor's area is not a command return this.hide(); } // need to know if name can be inside quotes, and then check if quotes are already there if (this.parserResult.canBeQuoted) { this.startQuote = this.text[this.parserResult.start] == '"'; this.endQuote = this.startQuote && this.text[this.parserResult.start + this.parserResult.name.length + 1] == '"'; } else { this.startQuote = false; this.endQuote = false; } // use lowercase name for matching this.name = this.parserResult.name.toLowerCase() ?? ''; const isCursorInNamePart = this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0); if (isForced || isInput) { // if forced (ctrl+space) or user input... if (isCursorInNamePart) { // ...and cursor is somewhere in the name part (including right behind the final char) // -> show autocomplete for the (partial if cursor in the middle) name this.name = this.name.slice(0, this.textarea.selectionStart - (this.parserResult.start) - (this.startQuote ? 1 : 0)); this.parserResult.name = this.name; this.isReplaceable = true; this.isForceHidden = false; this.canBeAutoHidden = false; } else { this.isReplaceable = false; this.canBeAutoHidden = this.basicAutoHideCheck(); } } else { // if not forced and no user input -> just show details this.isReplaceable = false; this.canBeAutoHidden = this.basicAutoHideCheck(); } if (isForced || isInput || isSelect) { // is forced or user input or just selected autocomplete option... if (!isCursorInNamePart) { // ...and cursor is not somwehere in the main name part -> check for secondary options (e.g., named arguments) const result = this.parserResult.getSecondaryNameAt(this.text, this.textarea.selectionStart, isSelect); if (result && (isForced || result.isRequired)) { this.secondaryParserResult = result; this.name = this.secondaryParserResult.name; this.isReplaceable = isForced || this.secondaryParserResult.isRequired; this.isForceHidden = false; this.canBeAutoHidden = false; } else { this.isReplaceable = false; this.canBeAutoHidden = this.basicAutoHideCheck(); } } } if (this.matchType == 'fuzzy') { // only build the fuzzy regex if match type is set to fuzzy this.fuzzyRegex = new RegExp(`^(.*?)${this.name.split('').map(char=>`(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i'); } //TODO maybe move the matchers somewhere else; a single match function? matchType is available as property const matchers = { 'strict': (name) => name.toLowerCase().startsWith(this.name), 'includes': (name) => name.toLowerCase().includes(this.name), 'fuzzy': (name) => this.fuzzyRegex.test(name), }; this.result = this.effectiveParserResult.optionList // filter the list of options by the partial name according to the matching type .filter(it => this.isReplaceable || it.name == '' ? (it.matchProvider ? it.matchProvider(this.name) : matchers[this.matchType](it.name)) : it.name.toLowerCase() == this.name) // remove aliases .filter((it,idx,list) => list.findIndex(opt=>opt.value == it.value) == idx); if (this.result.length == 0 && this.effectiveParserResult != this.parserResult && isForced) { // no matching secondary results and forced trigger -> show current command details this.secondaryParserResult = null; this.result = [this.effectiveParserResult.optionList.find(it=>it.name == this.effectiveParserResult.name)]; this.name = this.effectiveParserResult.name; this.fuzzyRegex = /(.*)(.*)(.*)/; } this.result = this.result // update remaining options .map(option => { // build element option.dom = this.makeItem(option); // update replacer and add quotes if necessary const optionName = option.valueProvider ? option.valueProvider(this.name) : option.name; if (this.effectiveParserResult.canBeQuoted) { option.replacer = optionName.includes(' ') || this.startQuote || this.endQuote ? `"${optionName.replace(/"/g, '\\"')}"` : `${optionName}`; } else { option.replacer = optionName; } // calculate fuzzy score if matching is fuzzy if (this.matchType == 'fuzzy') this.fuzzyScore(option); // update the name to highlight the matched chars this.updateName(option); return option; }) // sort by fuzzy score or alphabetical .toSorted(this.matchType == 'fuzzy' ? this.fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name)) ; if (this.isForceHidden) { // hidden with escape return this.hide(); } if (this.autoHide && this.canBeAutoHidden && !isForced && this.effectiveParserResult == this.parserResult && this.result.length == 1) { // auto hide user setting enabled and somewhere after name part and would usually show command details return this.hide(); } if (this.result.length == 0) { if (!isInput) { // no result and no input? hide autocomplete return this.hide(); } if (this.effectiveParserResult instanceof AutoCompleteSecondaryNameResult && !this.effectiveParserResult.forceMatch) { // no result and matching is no forced? hide autocomplete return this.hide(); } // otherwise add "no match" notice const option = new BlankAutoCompleteOption( this.name.length ? this.effectiveParserResult.makeNoMatchText() : this.effectiveParserResult.makeNoOptionsText() , ); this.result.push(option); } else if (this.result.length == 1 && this.effectiveParserResult && this.effectiveParserResult != this.secondaryParserResult && this.result[0].name == this.effectiveParserResult.name) { // only one result that is exactly the current value? just show hint, no autocomplete this.isReplaceable = false; this.isShowingDetails = false; } else if (!this.isReplaceable && this.result.length > 1) { return this.hide(); } this.selectedItem = this.result[0]; this.isActive = true; this.wasForced = isForced; this.renderDebounced(); } /** * Hide autocomplete. */ hide() { this.domWrap?.remove(); this.detailsWrap?.remove(); this.isActive = false; this.isShowingDetails = false; this.wasForced = false; } /** * Create updated DOM. */ render() { if (!this.isActive) return this.domWrap.remove(); if (this.isReplaceable) { this.dom.innerHTML = ''; const frag = document.createDocumentFragment(); for (const item of this.result) { if (item == this.selectedItem) { item.dom.classList.add('selected'); } else { item.dom.classList.remove('selected'); } frag.append(item.dom); } this.dom.append(frag); this.updatePosition(); getTopmostModalLayer().append(this.domWrap); } else { this.domWrap.remove(); } this.renderDetailsDebounced(); } /** * Create updated DOM for details. */ renderDetails() { if (!this.isActive) return this.detailsWrap.remove(); if (!this.isShowingDetails && this.isReplaceable) return this.detailsWrap.remove(); this.detailsDom.innerHTML = ''; this.detailsDom.append(this.selectedItem?.renderDetails() ?? 'NO ITEM'); getTopmostModalLayer().append(this.detailsWrap); this.updateDetailsPositionDebounced(); } /** * Update position of DOM. */ updatePosition() { if (this.isFloating) { this.updateFloatingPosition(); } else { const rect = {}; rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect(); rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect(); rect[AUTOCOMPLETE_WIDTH.FULL] = getTopmostModalLayer().getBoundingClientRect(); this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`); this.dom.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`); this.domWrap.style.bottom = `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`; if (this.isShowingDetails) { this.domWrap.style.setProperty('--leftOffset', '1vw'); this.domWrap.style.setProperty('--leftOffset', `max(1vw, ${rect[power_user.stscript.autocomplete.width.left].left}px)`); this.domWrap.style.setProperty('--rightOffset', `calc(100vw - min(${rect[power_user.stscript.autocomplete.width.right].right}px, ${this.isShowingDetails ? 74 : 0}vw)`); } else { this.domWrap.style.setProperty('--leftOffset', `max(1vw, ${rect[power_user.stscript.autocomplete.width.left].left}px)`); this.domWrap.style.setProperty('--rightOffset', `calc(100vw - min(99vw, ${rect[power_user.stscript.autocomplete.width.right].right}px)`); } } this.updateDetailsPosition(); } /** * Update position of details DOM. */ updateDetailsPosition() { if (this.isShowingDetails || !this.isReplaceable) { if (this.isFloating) { this.updateFloatingDetailsPosition(); } else { const rect = {}; rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect(); rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect(); rect[AUTOCOMPLETE_WIDTH.FULL] = getTopmostModalLayer().getBoundingClientRect(); if (this.isReplaceable) { this.detailsWrap.classList.remove('full'); const selRect = this.selectedItem.dom.children[0].getBoundingClientRect(); this.detailsWrap.style.setProperty('--targetOffset', `${selRect.top}`); this.detailsWrap.style.setProperty('--rightOffset', '1vw'); this.detailsWrap.style.setProperty('--bottomOffset', `calc(100vh - ${rect[AUTOCOMPLETE_WIDTH.INPUT].top}px)`); this.detailsWrap.style.setProperty('--leftOffset', `calc(100vw - ${this.domWrap.style.getPropertyValue('--rightOffset')}`); } else { this.detailsWrap.classList.add('full'); this.detailsWrap.style.setProperty('--targetOffset', `${rect[AUTOCOMPLETE_WIDTH.INPUT].top}`); this.detailsWrap.style.setProperty('--bottomOffset', `calc(100vh - ${rect[AUTOCOMPLETE_WIDTH.INPUT].top}px)`); this.detailsWrap.style.setProperty('--leftOffset', `${rect[power_user.stscript.autocomplete.width.left].left}px`); this.detailsWrap.style.setProperty('--rightOffset', `calc(100vw - ${rect[power_user.stscript.autocomplete.width.right].right}px)`); } } } } /** * Update position of floating autocomplete. */ updateFloatingPosition() { const location = this.getCursorPosition(); const rect = this.textarea.getBoundingClientRect(); const layerRect = this.textarea.closest('dialog, body').getBoundingClientRect(); // cursor is out of view -> hide if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) { return this.hide(); } const left = Math.max(rect.left, location.left) - layerRect.left; this.domWrap.style.setProperty('--targetOffset', `${left}`); if (location.top <= window.innerHeight / 2) { // if cursor is in lower half of window, show list above line this.domWrap.style.top = `${location.bottom - layerRect.top}px`; this.domWrap.style.bottom = 'auto'; this.domWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; } else { // if cursor is in upper half of window, show list below line this.domWrap.style.top = 'auto'; this.domWrap.style.bottom = `calc(${layerRect.height}px - ${location.top - layerRect.top}px)`; this.domWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; } } updateFloatingDetailsPosition(location = null) { if (!location) location = this.getCursorPosition(); const rect = this.textarea.getBoundingClientRect(); const layerRect = this.textarea.closest('dialog, body').getBoundingClientRect(); if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) { return this.hide(); } const left = Math.max(rect.left, location.left) - layerRect.left; this.detailsWrap.style.setProperty('--targetOffset', `${left}`); if (this.isReplaceable) { this.detailsWrap.classList.remove('full'); if (left < window.innerWidth / 4) { // if cursor is in left part of screen, show details on right of list this.detailsWrap.classList.add('right'); this.detailsWrap.classList.remove('left'); } else { // if cursor is in right part of screen, show details on left of list this.detailsWrap.classList.remove('right'); this.detailsWrap.classList.add('left'); } } else { this.detailsWrap.classList.remove('left'); this.detailsWrap.classList.remove('right'); this.detailsWrap.classList.add('full'); } if (location.top <= window.innerHeight / 2) { // if cursor is in lower half of window, show list above line this.detailsWrap.style.top = `${location.bottom - layerRect.top}px`; this.detailsWrap.style.bottom = 'auto'; this.detailsWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; } else { // if cursor is in upper half of window, show list below line this.detailsWrap.style.top = 'auto'; this.detailsWrap.style.bottom = `calc(${layerRect.height}px - ${location.top - layerRect.top}px)`; this.detailsWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; } } /** * Calculate (keyboard) cursor coordinates within textarea. * @returns {{left:number, top:number, bottom:number}} */ getCursorPosition() { const inputRect = this.textarea.getBoundingClientRect(); const style = window.getComputedStyle(this.textarea); if (!this.clone) { this.clone = document.createElement('div'); for (const key of style) { this.clone.style[key] = style[key]; } this.clone.style.position = 'fixed'; this.clone.style.visibility = 'hidden'; document.body.append(this.clone); const mo = new MutationObserver(muts=>{ if (muts.find(it=>Array.from(it.removedNodes).includes(this.textarea))) { this.clone.remove(); } }); mo.observe(this.textarea.parentElement, { childList:true }); } this.clone.style.height = `${inputRect.height}px`; this.clone.style.left = `${inputRect.left}px`; this.clone.style.top = `${inputRect.top}px`; this.clone.style.whiteSpace = style.whiteSpace; this.clone.style.tabSize = style.tabSize; const text = this.textarea.value; const before = text.slice(0, this.textarea.selectionStart); this.clone.textContent = before; const locator = document.createElement('span'); locator.textContent = text[this.textarea.selectionStart]; this.clone.append(locator); this.clone.append(text.slice(this.textarea.selectionStart + 1)); this.clone.scrollTop = this.textarea.scrollTop; this.clone.scrollLeft = this.textarea.scrollLeft; const locatorRect = locator.getBoundingClientRect(); const location = { left: locatorRect.left, top: locatorRect.top, bottom: locatorRect.bottom, }; return location; } /** * Toggle details view alongside autocomplete list. */ toggleDetails() { this.isShowingDetails = !this.isShowingDetails; this.renderDetailsDebounced(); this.updatePosition(); } /** * Select an item for autocomplete and put text into textarea. */ async select() { if (this.isReplaceable && this.selectedItem.value !== null) { this.textarea.value = `${this.text.slice(0, this.effectiveParserResult.start)}${this.selectedItem.replacer}${this.text.slice(this.effectiveParserResult.start + this.effectiveParserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`; this.textarea.selectionStart = this.effectiveParserResult.start + this.selectedItem.replacer.length; this.textarea.selectionEnd = this.textarea.selectionStart; this.show(false, false, true); } else { const selectionStart = this.textarea.selectionStart; const selectionEnd = this.textarea.selectionDirection; this.textarea.selectionStart = selectionStart; this.textarea.selectionDirection = selectionEnd; } this.wasForced = false; this.textarea.dispatchEvent(new Event('input', { bubbles:true })); this.onSelect?.(this.selectedItem); } /** * Mark the item at newIdx in the autocomplete list as selected. * @param {number} newIdx */ selectItemAtIndex(newIdx) { this.selectedItem.dom.classList.remove('selected'); this.selectedItem = this.result[newIdx]; this.selectedItem.dom.classList.add('selected'); const rect = this.selectedItem.dom.children[0].getBoundingClientRect(); const rectParent = this.dom.getBoundingClientRect(); if (rect.top < rectParent.top || rect.bottom > rectParent.bottom ) { this.dom.scrollTop += rect.top < rectParent.top ? rect.top - rectParent.top : rect.bottom - rectParent.bottom; } this.renderDetailsDebounced(); } /** * Handle keyboard events. * @param {KeyboardEvent} evt The event. */ async handleKeyDown(evt) { // autocomplete is shown and cursor at end of current command name (or inside name and typed or forced) if (this.isActive && this.isReplaceable) { // actions in the list switch (evt.key) { case 'ArrowUp': { // select previous item if (evt.ctrlKey || evt.altKey || evt.shiftKey) return; evt.preventDefault(); evt.stopPropagation(); const idx = this.result.indexOf(this.selectedItem); let newIdx; if (idx == 0) newIdx = this.result.length - 1; else newIdx = idx - 1; this.selectItemAtIndex(newIdx); return; } case 'ArrowDown': { // select next item if (evt.ctrlKey || evt.altKey || evt.shiftKey) return; evt.preventDefault(); evt.stopPropagation(); const idx = this.result.indexOf(this.selectedItem); const newIdx = (idx + 1) % this.result.length; this.selectItemAtIndex(newIdx); return; } case 'Enter': { // pick the selected item to autocomplete if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break; if (this.selectedItem.name == this.name) break; if (!this.selectedItem.isSelectable) break; evt.preventDefault(); evt.stopImmediatePropagation(); this.select(); return; } case 'Tab': { // pick the selected item to autocomplete if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break; if (!this.selectedItem.isSelectable) break; evt.preventDefault(); evt.stopImmediatePropagation(); this.select(); return; } } } // details are shown, cursor can be anywhere if (this.isActive) { switch (evt.key) { case 'Escape': { // close autocomplete if (evt.ctrlKey || evt.altKey || evt.shiftKey) return; evt.preventDefault(); evt.stopPropagation(); this.isForceHidden = true; this.wasForced = false; this.hide(); return; } case 'Enter': { // hide autocomplete on enter (send, execute, ...) if (!evt.shiftKey) { this.hide(); return; } break; } } } // autocomplete shown or not, cursor anywhere switch (evt.key) { // The first is a non-breaking space, the second is a regular space. case ' ': case ' ': { if (evt.ctrlKey || evt.altKey) { if (this.isActive && this.isReplaceable) { // ctrl-space to toggle details for selected item this.toggleDetails(); } else { // ctrl-space to force show autocomplete this.show(false, true); } evt.preventDefault(); evt.stopPropagation(); return; } break; } } if (['Control', 'Shift', 'Alt'].includes(evt.key)) { // ignore keydown on modifier keys return; } // await keyup to see if cursor position or text has changed const oldText = this.textarea.value; await new Promise(resolve=>{ window.addEventListener('keyup', resolve, { once:true }); }); if (this.selectionStart != this.textarea.selectionStart) { this.selectionStart = this.textarea.selectionStart; this.show(this.isReplaceable || oldText != this.textarea.value); } else if (this.isActive) { this.text != this.textarea.value && this.show(this.isReplaceable); } } }