| | 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'; |
| | import { AutoCompleteNameResult } from './AutoCompleteNameResult.js'; |
| | import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js'; |
| |
|
| | |
| | |
| | export const AUTOCOMPLETE_WIDTH = { |
| | 'INPUT': 0, |
| | 'CHAT': 1, |
| | 'FULL': 2, |
| | }; |
| |
|
| | |
| | |
| | export const AUTOCOMPLETE_SELECT_KEY = { |
| | 'TAB': 1, |
| | 'ENTER': 2, |
| | }; |
| |
|
| | |
| | |
| | export const AUTOCOMPLETE_STATE = { |
| | DISABLED: 0, |
| | MIN_LENGTH: 1, |
| | ALWAYS: 2, |
| | }; |
| |
|
| | export class AutoComplete { |
| | textarea; |
| | isFloating = false; |
| | checkIfActivate; |
| | getNameAt; |
| |
|
| | isActive = false; |
| | isReplaceable = false; |
| | isShowingDetails = false; |
| | wasForced = false; |
| | isForceHidden = false; |
| | canBeAutoHidden = false; |
| |
|
| | text; |
| | parserResult; |
| | secondaryParserResult; |
| | get effectiveParserResult() { return this.secondaryParserResult ?? this.parserResult; } |
| | name; |
| |
|
| | startQuote; |
| | endQuote; |
| | selectionStart; |
| |
|
| | fuzzyRegex; |
| |
|
| | result = []; |
| | selectedItem = null; |
| |
|
| | clone; |
| | domWrap; |
| | dom; |
| | detailsWrap; |
| | detailsDom; |
| |
|
| | renderDebounced; |
| | renderDetailsDebounced; |
| | updatePositionDebounced; |
| | updateDetailsPositionDebounced; |
| | updateFloatingPositionDebounced; |
| |
|
| | onSelect; |
| |
|
| | get matchType() { |
| | return power_user.stscript.matching ?? 'fuzzy'; |
| | } |
| |
|
| | get autoHide() { |
| | return power_user.stscript.autocomplete.autoHide ?? false; |
| | } |
| |
|
| |
|
| |
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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()); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | makeItem(option) { |
| | const li = option.renderItem(); |
| | |
| | li.addEventListener('pointerdown', (evt) => { |
| | evt.preventDefault(); |
| | this.selectedItem = this.result.find(it => it.name == li.getAttribute('data-name')); |
| | this.select(); |
| | }); |
| | return li; |
| | } |
| |
|
| |
|
| | |
| | |
| | |
| | |
| | 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; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | fuzzyScore(option) { |
| | |
| | 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; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | 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() { |
| | |
| | return this.textarea.selectionStart > this.parserResult.start |
| | + this.parserResult.name.length |
| | + (this.startQuote ? 1 : 0) |
| | + (this.endQuote ? 1 : 0) |
| | + 1; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | async show(isInput = false, isForced = false, isSelect = false) { |
| | |
| | this.text = this.textarea.value; |
| | this.isReplaceable = false; |
| |
|
| | if (document.activeElement != this.textarea) { |
| | |
| | return this.hide(); |
| | } |
| | if (!this.checkIfActivate()) { |
| | |
| | return this.hide(); |
| | } |
| |
|
| | |
| | if (isForced) this.isForceHidden = false; |
| |
|
| | |
| | |
| | this.parserResult = await this.getNameAt(this.text, this.textarea.selectionStart); |
| | this.secondaryParserResult = null; |
| |
|
| | if (!this.parserResult) { |
| | |
| | return this.hide(); |
| | } |
| |
|
| | |
| | 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; |
| | } |
| |
|
| | |
| | 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 (isCursorInNamePart) { |
| | |
| | |
| | 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 { |
| | |
| | this.isReplaceable = false; |
| | this.canBeAutoHidden = this.basicAutoHideCheck(); |
| | } |
| |
|
| | if (isForced || isInput || isSelect) { |
| | |
| | if (!isCursorInNamePart) { |
| | |
| | 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') { |
| | |
| | this.fuzzyRegex = new RegExp(`^(.*?)${this.name.split('').map(char => `(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i'); |
| | } |
| |
|
| | |
| | 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(it => this.isReplaceable || it.name == '' ? (it.matchProvider ? it.matchProvider(this.name) : matchers[this.matchType](it.name)) : it.name.toLowerCase() == this.name) |
| | |
| | .filter((it, idx, list) => list.findIndex(opt => opt.value == it.value) == idx); |
| |
|
| | if (this.result.length == 0 && this.effectiveParserResult != this.parserResult && isForced) { |
| | |
| | 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 |
| | |
| | .map(option => { |
| | |
| | option.dom = this.makeItem(option); |
| | |
| | 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'); |
| | } |
| | if (!item.isSelectable) { |
| | item.dom.classList.add('not-selectable'); |
| | } |
| | frag.append(item.dom); |
| | } |
| | this.dom.append(frag); |
| | this.updatePosition(); |
| | this.getLayer().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'); |
| | this.getLayer().append(this.detailsWrap); |
| | this.updateDetailsPositionDebounced(); |
| | } |
| | |
| | /** |
| | * @returns {HTMLElement} closest ancestor dialog or body |
| | */ |
| | getLayer() { |
| | return this.textarea.closest('dialog, body'); |
| | } |
| | |
| | |
| | |
| | /** |
| | * 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] = this.getLayer().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] = this.getLayer().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.getLayer().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.getLayer().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 ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.ENTER) != AUTOCOMPLETE_SELECT_KEY.ENTER) break; |
| | 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 ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.TAB) != AUTOCOMPLETE_SELECT_KEY.TAB) break; |
| | if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break; |
| | evt.preventDefault(); |
| | evt.stopImmediatePropagation(); |
| | if (!this.selectedItem.isSelectable) break; |
| | 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); |
| | } |
| | } |
| | } |
| | |