| 1 | import { power_user } from '../power-user.js'; |
| 2 | import { debounce, escapeRegex } from '../utils.js'; |
| 3 | import { AutoCompleteOption } from './AutoCompleteOption.js'; |
| 4 | import { AutoCompleteFuzzyScore } from './AutoCompleteFuzzyScore.js'; |
| 5 | import { BlankAutoCompleteOption } from './BlankAutoCompleteOption.js'; |
| 6 | import { AutoCompleteNameResult } from './AutoCompleteNameResult.js'; |
| 7 | import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js'; |
| 8 | |
| 9 | /**@readonly*/ |
| 10 | /**@enum {Number}*/ |
| 11 | export const AUTOCOMPLETE_WIDTH = { |
| 12 | 'INPUT': 0, |
| 13 | 'CHAT': 1, |
| 14 | 'FULL': 2, |
| 15 | }; |
| 16 | |
| 17 | /**@readonly*/ |
| 18 | /**@enum {Number}*/ |
| 19 | export const AUTOCOMPLETE_SELECT_KEY = { |
| 20 | 'TAB': 1, // 2^0 |
| 21 | 'ENTER': 2, // 2^1 |
| 22 | }; |
| 23 | |
| 24 | /** @readonly */ |
| 25 | /** @enum {Number} */ |
| 26 | export const AUTOCOMPLETE_STATE = { |
| 27 | DISABLED: 0, |
| 28 | MIN_LENGTH: 1, |
| 29 | ALWAYS: 2, |
| 30 | }; |
| 31 | |
| 32 | export class AutoComplete { |
| 33 | /**@type {HTMLTextAreaElement|HTMLInputElement}*/ textarea; |
| 34 | /**@type {boolean}*/ isFloating = false; |
| 35 | /**@type {()=>boolean}*/ checkIfActivate; |
| 36 | /**@type {(text:string, index:number) => Promise<AutoCompleteNameResult>}*/ getNameAt; |
| 37 | |
| 38 | /**@type {boolean}*/ isActive = false; |
| 39 | /**@type {boolean}*/ isReplaceable = false; |
| 40 | /**@type {boolean}*/ isShowingDetails = false; |
| 41 | /**@type {boolean}*/ wasForced = false; |
| 42 | /**@type {boolean}*/ isForceHidden = false; |
| 43 | /**@type {boolean}*/ canBeAutoHidden = false; |
| 44 | |
| 45 | /**@type {string}*/ text; |
| 46 | /**@type {AutoCompleteNameResult}*/ parserResult; |
| 47 | /**@type {AutoCompleteSecondaryNameResult}*/ secondaryParserResult; |
| 48 | get effectiveParserResult() { return this.secondaryParserResult ?? this.parserResult; } |
| 49 | /**@type {string}*/ name; |
| 50 | |
| 51 | /**@type {boolean}*/ startQuote; |
| 52 | /**@type {boolean}*/ endQuote; |
| 53 | /**@type {number}*/ selectionStart; |
| 54 | |
| 55 | /**@type {RegExp}*/ fuzzyRegex; |
| 56 | |
| 57 | /**@type {AutoCompleteOption[]}*/ result = []; |
| 58 | /**@type {AutoCompleteOption}*/ selectedItem = null; |
| 59 | |
| 60 | /**@type {HTMLElement}*/ clone; |
| 61 | /**@type {HTMLElement}*/ domWrap; |
| 62 | /**@type {HTMLElement}*/ dom; |
| 63 | /**@type {HTMLElement}*/ detailsWrap; |
| 64 | /**@type {HTMLElement}*/ detailsDom; |
| 65 | |
| 66 | /**@type {function}*/ renderDebounced; |
| 67 | /**@type {function}*/ renderDetailsDebounced; |
| 68 | /**@type {function}*/ updatePositionDebounced; |
| 69 | /**@type {function}*/ updateDetailsPositionDebounced; |
| 70 | /**@type {function}*/ updateFloatingPositionDebounced; |
| 71 | |
| 72 | /**@type {(item:AutoCompleteOption)=>any}*/ onSelect; |
| 73 | |
| 74 | get matchType() { |
| 75 | return power_user.stscript.matching ?? 'fuzzy'; |
| 76 | } |
| 77 | |
| 78 | get autoHide() { |
| 79 | return power_user.stscript.autocomplete.autoHide ?? false; |
| 80 | } |
| 81 | |
| 82 | |
| 83 | /** |
| 84 | * @param {HTMLTextAreaElement|HTMLInputElement} textarea The textarea to receive autocomplete. |
| 85 | * @param {() => boolean} checkIfActivate Function should return true only if under the current conditions, autocomplete should display (e.g., for slash commands: autoComplete.text[0] == '/') |
| 86 | * @param {(text: string, index: number) => Promise<AutoCompleteNameResult>} getNameAt Function should return (unfiltered, matching against input is done in AutoComplete) information about name options at index in text. |
| 87 | * @param {boolean} isFloating Whether autocomplete should float at the keyboard cursor. |
| 88 | */ |
| 89 | constructor(textarea, checkIfActivate, getNameAt, isFloating = false) { |
| 90 | this.textarea = textarea; |
| 91 | this.checkIfActivate = checkIfActivate; |
| 92 | this.getNameAt = getNameAt; |
| 93 | this.isFloating = isFloating; |
| 94 | |
| 95 | this.domWrap = document.createElement('div'); { |
| 96 | this.domWrap.classList.add('autoComplete-wrap'); |
| 97 | if (isFloating) this.domWrap.classList.add('isFloating'); |
| 98 | } |
| 99 | this.dom = document.createElement('ul'); { |
| 100 | this.dom.classList.add('autoComplete'); |
| 101 | this.domWrap.append(this.dom); |
| 102 | } |
| 103 | this.detailsWrap = document.createElement('div'); { |
| 104 | this.detailsWrap.classList.add('autoComplete-detailsWrap'); |
| 105 | if (isFloating) this.detailsWrap.classList.add('isFloating'); |
| 106 | } |
| 107 | this.detailsDom = document.createElement('div'); { |
| 108 | this.detailsDom.classList.add('autoComplete-details'); |
| 109 | this.detailsWrap.append(this.detailsDom); |
| 110 | } |
| 111 | |
| 112 | this.renderDebounced = debounce(this.render.bind(this), 10); |
| 113 | this.renderDetailsDebounced = debounce(this.renderDetails.bind(this), 10); |
| 114 | this.updatePositionDebounced = debounce(this.updatePosition.bind(this), 10); |
| 115 | this.updateDetailsPositionDebounced = debounce(this.updateDetailsPosition.bind(this), 10); |
| 116 | this.updateFloatingPositionDebounced = debounce(this.updateFloatingPosition.bind(this), 10); |
| 117 | |
| 118 | textarea.addEventListener('input', () => { |
| 119 | this.selectionStart = this.textarea.selectionStart; |
| 120 | if (this.text != this.textarea.value) this.show(true, this.wasForced); |
| 121 | }); |
| 122 | textarea.addEventListener('keydown', (evt) => this.handleKeyDown(evt)); |
| 123 | textarea.addEventListener('click', () => { |
| 124 | this.selectionStart = this.textarea.selectionStart; |
| 125 | if (this.isActive) this.show(); |
| 126 | }); |
| 127 | textarea.addEventListener('blur', () => this.hide()); |
| 128 | if (isFloating) { |
| 129 | textarea.addEventListener('scroll', () => this.updateFloatingPositionDebounced()); |
| 130 | } |
| 131 | window.addEventListener('resize', () => this.updatePositionDebounced()); |
| 132 | } |
| 133 | |
| 134 | /** |
| 135 | * |
| 136 | * @param {AutoCompleteOption} option |
| 137 | */ |
| 138 | makeItem(option) { |
| 139 | const li = option.renderItem(); |
| 140 | // gotta listen to pointerdown (happens before textarea-blur) |
| 141 | li.addEventListener('pointerdown', (evt) => { |
| 142 | evt.preventDefault(); |
| 143 | this.selectedItem = this.result.find(it => it.name == li.getAttribute('data-name')); |
| 144 | this.select(); |
| 145 | }); |
| 146 | return li; |
| 147 | } |
| 148 | |
| 149 | |
| 150 | /** |
| 151 | * |
| 152 | * @param {AutoCompleteOption} item |
| 153 | */ |
| 154 | updateName(item) { |
| 155 | const chars = Array.from(item.dom.querySelector('.name').children); |
| 156 | if (item.forceFullNameMatch) { |
| 157 | chars.forEach(c => c.classList.toggle('matched', true)); |
| 158 | return; |
| 159 | } |
| 160 | switch (this.matchType) { |
| 161 | case 'strict': { |
| 162 | chars.forEach((it, idx) => { |
| 163 | if (idx + item.nameOffset < item.name.length) { |
| 164 | it.classList.add('matched'); |
| 165 | } else { |
| 166 | it.classList.remove('matched'); |
| 167 | } |
| 168 | }); |
| 169 | break; |
| 170 | } |
| 171 | case 'includes': { |
| 172 | const start = item.name.toLowerCase().search(this.name); |
| 173 | chars.forEach((it, idx) => { |
| 174 | if (idx + item.nameOffset < start) { |
| 175 | it.classList.remove('matched'); |
| 176 | } else if (idx + item.nameOffset < start + item.name.length) { |
| 177 | it.classList.add('matched'); |
| 178 | } else { |
| 179 | it.classList.remove('matched'); |
| 180 | } |
| 181 | }); |
| 182 | break; |
| 183 | } |
| 184 | case 'fuzzy': { |
| 185 | item.name.replace(this.fuzzyRegex, (_, ...parts) => { |
| 186 | parts.splice(-2, 2); |
| 187 | if (parts.length == 2) { |
| 188 | chars.forEach(c => c.classList.remove('matched')); |
| 189 | } else { |
| 190 | let cIdx = item.nameOffset; |
| 191 | parts.forEach((it, idx) => { |
| 192 | if (it === null || it.length == 0) return ''; |
| 193 | if (idx % 2 == 1) { |
| 194 | chars.slice(cIdx, cIdx + it.length).forEach(c => c.classList.add('matched')); |
| 195 | } else { |
| 196 | chars.slice(cIdx, cIdx + it.length).forEach(c => c.classList.remove('matched')); |
| 197 | } |
| 198 | cIdx += it.length; |
| 199 | }); |
| 200 | } |
| 201 | return ''; |
| 202 | }); |
| 203 | } |
| 204 | } |
| 205 | return item; |
| 206 | } |
| 207 | |
| 208 | /** |
| 209 | * Calculate score for the fuzzy match. |
| 210 | * @param {AutoCompleteOption} option |
| 211 | * @returns The option. |
| 212 | */ |
| 213 | fuzzyScore(option) { |
| 214 | // might have been matched by the options matchProvider function instead |
| 215 | if (!this.fuzzyRegex.test(option.name)) { |
| 216 | option.score = new AutoCompleteFuzzyScore(Number.MAX_SAFE_INTEGER, -1); |
| 217 | return option; |
| 218 | } |
| 219 | const parts = this.fuzzyRegex.exec(option.name).slice(1, -1); |
| 220 | let start = null; |
| 221 | let consecutive = []; |
| 222 | let current = ''; |
| 223 | let offset = 0; |
| 224 | parts.forEach((part, idx) => { |
| 225 | if (idx % 2 == 0) { |
| 226 | if (part.length > 0) { |
| 227 | if (current.length > 0) { |
| 228 | consecutive.push(current); |
| 229 | } |
| 230 | current = ''; |
| 231 | } |
| 232 | } else { |
| 233 | if (start === null) { |
| 234 | start = offset; |
| 235 | } |
| 236 | current += part; |
| 237 | } |
| 238 | offset += part.length; |
| 239 | }); |
| 240 | if (current.length > 0) { |
| 241 | consecutive.push(current); |
| 242 | } |
| 243 | consecutive.sort((a, b) => b.length - a.length); |
| 244 | option.score = new AutoCompleteFuzzyScore(start, consecutive[0]?.length ?? 0); |
| 245 | return option; |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * Compare two auto complete options by their fuzzy score. |
| 250 | * @param {AutoCompleteOption} a |
| 251 | * @param {AutoCompleteOption} b |
| 252 | */ |
| 253 | fuzzyScoreCompare(a, b) { |
| 254 | if (a.score.start < b.score.start) return -1; |
| 255 | if (a.score.start > b.score.start) return 1; |
| 256 | if (a.score.longestConsecutive > b.score.longestConsecutive) return -1; |
| 257 | if (a.score.longestConsecutive < b.score.longestConsecutive) return 1; |
| 258 | return a.name.localeCompare(b.name); |
| 259 | } |
| 260 | |
| 261 | basicAutoHideCheck() { |
| 262 | // auto hide only if at least one char has been typed after the name + space |
| 263 | return this.textarea.selectionStart > this.parserResult.start |
| 264 | + this.parserResult.name.length |
| 265 | + (this.startQuote ? 1 : 0) |
| 266 | + (this.endQuote ? 1 : 0) |
| 267 | + 1; |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Show the autocomplete. |
| 272 | * @param {boolean} isInput Whether triggered by input. |
| 273 | * @param {boolean} isForced Whether force-showing (ctrl+space). |
| 274 | * @param {boolean} isSelect Whether an autocomplete option was just selected. |
| 275 | */ |
| 276 | async show(isInput = false, isForced = false, isSelect = false) { |
| 277 | //TODO check if isInput and isForced are both required |
| 278 | this.text = this.textarea.value; |
| 279 | this.isReplaceable = false; |
| 280 | this.isShowForced = isForced; // Store forced state for checkIfActivate to access |
| 281 | |
| 282 | if (document.activeElement != this.textarea) { |
| 283 | // only show with textarea in focus |
| 284 | return this.hide(); |
| 285 | } |
| 286 | if (!this.checkIfActivate()) { |
| 287 | // only show if provider wants to |
| 288 | return this.hide(); |
| 289 | } |
| 290 | |
| 291 | // disable force-hide if trigger was forced |
| 292 | if (isForced) this.isForceHidden = false; |
| 293 | |
| 294 | // request provider to get name result (potentially "incomplete", i.e. not an actual existing name) for |
| 295 | // cursor position |
| 296 | this.parserResult = await this.getNameAt(this.text, this.textarea.selectionStart); |
| 297 | this.secondaryParserResult = null; |
| 298 | |
| 299 | if (!this.parserResult) { |
| 300 | // don't show if no name result found, e.g., cursor's area is not a command |
| 301 | return this.hide(); |
| 302 | } |
| 303 | |
| 304 | // need to know if name can be inside quotes, and then check if quotes are already there |
| 305 | if (this.parserResult.canBeQuoted) { |
| 306 | this.startQuote = this.text[this.parserResult.start] == '"'; |
| 307 | this.endQuote = this.startQuote && this.text[this.parserResult.start + this.parserResult.name.length + 1] == '"'; |
| 308 | } else { |
| 309 | this.startQuote = false; |
| 310 | this.endQuote = false; |
| 311 | } |
| 312 | |
| 313 | // use lowercase name for matching |
| 314 | this.name = this.parserResult.name.toLowerCase() ?? ''; |
| 315 | |
| 316 | const isCursorInNamePart = this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0); |
| 317 | if (isForced || isInput || isSelect) { |
| 318 | // if forced (ctrl+space) or user input or just selected an option... |
| 319 | if (isCursorInNamePart) { |
| 320 | // ...and cursor is somewhere in the name part (including right behind the final char) |
| 321 | // -> show autocomplete for the (partial if cursor in the middle) name |
| 322 | this.name = this.name.slice(0, this.textarea.selectionStart - (this.parserResult.start) - (this.startQuote ? 1 : 0)); |
| 323 | this.parserResult.name = this.name; |
| 324 | this.isReplaceable = true; |
| 325 | this.isForceHidden = false; |
| 326 | this.canBeAutoHidden = false; |
| 327 | } else { |
| 328 | this.isReplaceable = false; |
| 329 | this.canBeAutoHidden = this.basicAutoHideCheck(); |
| 330 | } |
| 331 | } else { |
| 332 | // if not forced and no user input -> just show details |
| 333 | this.isReplaceable = false; |
| 334 | this.canBeAutoHidden = this.basicAutoHideCheck(); |
| 335 | } |
| 336 | |
| 337 | if (isForced || isInput || isSelect) { |
| 338 | // is forced or user input or just selected autocomplete option... |
| 339 | if (!isCursorInNamePart) { |
| 340 | // ...and cursor is not somwehere in the main name part -> check for secondary options (e.g., named arguments) |
| 341 | const result = this.parserResult.getSecondaryNameAt(this.text, this.textarea.selectionStart, isSelect); |
| 342 | if (result && (isForced || result.isRequired)) { |
| 343 | this.secondaryParserResult = result; |
| 344 | this.name = this.secondaryParserResult.name; |
| 345 | this.isReplaceable = isForced || this.secondaryParserResult.isRequired; |
| 346 | this.isForceHidden = false; |
| 347 | this.canBeAutoHidden = false; |
| 348 | } else { |
| 349 | this.isReplaceable = false; |
| 350 | this.canBeAutoHidden = this.basicAutoHideCheck(); |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | if (this.matchType == 'fuzzy') { |
| 356 | // only build the fuzzy regex if match type is set to fuzzy |
| 357 | this.fuzzyRegex = new RegExp(`^(.*?)${this.name.split('').map(char => `(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i'); |
| 358 | } |
| 359 | |
| 360 | //TODO maybe move the matchers somewhere else; a single match function? matchType is available as property |
| 361 | const matchers = { |
| 362 | 'strict': (name) => name.toLowerCase().startsWith(this.name), |
| 363 | 'includes': (name) => name.toLowerCase().includes(this.name), |
| 364 | 'fuzzy': (name) => this.fuzzyRegex.test(name), |
| 365 | }; |
| 366 | |
| 367 | this.result = this.effectiveParserResult.optionList |
| 368 | // filter the list of options by the partial name according to the matching type |
| 369 | .filter(it => this.isReplaceable || it.name == '' ? (it.matchProvider ? it.matchProvider(this.name) : matchers[this.matchType](it.name)) : it.name.toLowerCase() == this.name) |
| 370 | // remove aliases |
| 371 | .filter((it, idx, list) => list.findIndex(opt => opt.value == it.value) == idx); |
| 372 | |
| 373 | if (this.result.length == 0 && this.effectiveParserResult != this.parserResult && isForced) { |
| 374 | // no matching secondary results and forced trigger -> show current command details |
| 375 | this.secondaryParserResult = null; |
| 376 | this.result = [this.effectiveParserResult.optionList.find(it => it.name == this.effectiveParserResult.name)]; |
| 377 | this.name = this.effectiveParserResult.name; |
| 378 | this.fuzzyRegex = /(.*)(.*)(.*)/; |
| 379 | } |
| 380 | |
| 381 | this.result = this.result |
| 382 | // update remaining options |
| 383 | .map(option => { |
| 384 | // build element |
| 385 | option.dom = this.makeItem(option); |
| 386 | // update replacer and add quotes if necessary |
| 387 | const optionName = option.valueProvider ? option.valueProvider(this.name) : option.name; |
| 388 | if (this.effectiveParserResult.canBeQuoted) { |
| 389 | option.replacer = optionName.includes(' ') || this.startQuote || this.endQuote ? `"${optionName.replace(/"/g, '\\"')}"` : `${optionName}`; |
| 390 | } else { |
| 391 | option.replacer = optionName; |
| 392 | } |
| 393 | // calculate fuzzy score if matching is fuzzy |
| 394 | if (this.matchType == 'fuzzy') this.fuzzyScore(option); |
| 395 | // update the name to highlight the matched chars |
| 396 | this.updateName(option); |
| 397 | return option; |
| 398 | }) |
| 399 | // sort by priority first, then by fuzzy score or alphabetical |
| 400 | .toSorted((a, b) => { |
| 401 | // First compare by sortPriority (lower = higher priority) |
| 402 | const priorityA = a.sortPriority ?? 100; |
| 403 | const priorityB = b.sortPriority ?? 100; |
| 404 | if (priorityA !== priorityB) { |
| 405 | return priorityA - priorityB; |
| 406 | } |
| 407 | // Then by fuzzy score or alphabetical |
| 408 | if (this.matchType == 'fuzzy') { |
| 409 | return this.fuzzyScoreCompare(a, b); |
| 410 | } |
| 411 | return a.name.localeCompare(b.name); |
| 412 | }); |
| 413 | |
| 414 | |
| 415 | if (this.isForceHidden) { |
| 416 | // hidden with escape |
| 417 | return this.hide(); |
| 418 | } |
| 419 | if (this.autoHide && this.canBeAutoHidden && !isForced && this.effectiveParserResult == this.parserResult && this.result.length == 1) { |
| 420 | // auto hide user setting enabled and somewhere after name part and would usually show command details |
| 421 | return this.hide(); |
| 422 | } |
| 423 | if (this.result.length == 0) { |
| 424 | if (!isInput) { |
| 425 | // no result and no input? hide autocomplete |
| 426 | return this.hide(); |
| 427 | } |
| 428 | if (this.effectiveParserResult instanceof AutoCompleteSecondaryNameResult && !this.effectiveParserResult.forceMatch) { |
| 429 | // no result and matching is no forced? hide autocomplete |
| 430 | return this.hide(); |
| 431 | } |
| 432 | // otherwise add "no match" notice |
| 433 | const option = new BlankAutoCompleteOption( |
| 434 | this.name.length ? |
| 435 | this.effectiveParserResult.makeNoMatchText() |
| 436 | : this.effectiveParserResult.makeNoOptionsText() |
| 437 | , |
| 438 | ); |
| 439 | this.result.push(option); |
| 440 | } else if (this.result.length == 1 && this.effectiveParserResult && this.effectiveParserResult != this.secondaryParserResult && this.result[0].name == this.effectiveParserResult.name) { |
| 441 | // only one result that is exactly the current value? just show hint, no autocomplete |
| 442 | this.isReplaceable = false; |
| 443 | this.isShowingDetails = false; |
| 444 | } else if (!this.isReplaceable && this.result.length > 1) { |
| 445 | return this.hide(); |
| 446 | } |
| 447 | this.selectedItem = this.selectDefaultItem(this.result); |
| 448 | this.isActive = true; |
| 449 | this.wasForced = isForced; |
| 450 | this.renderDebounced(); |
| 451 | } |
| 452 | |
| 453 | /** |
| 454 | * Hide autocomplete. |
| 455 | */ |
| 456 | hide() { |
| 457 | this.domWrap?.remove(); |
| 458 | this.detailsWrap?.remove(); |
| 459 | this.isActive = false; |
| 460 | this.isShowingDetails = false; |
| 461 | this.wasForced = false; |
| 462 | } |
| 463 | |
| 464 | |
| 465 | /** |
| 466 | * Create updated DOM. |
| 467 | */ |
| 468 | render() { |
| 469 | if (!this.isActive) return this.domWrap.remove(); |
| 470 | if (this.isReplaceable) { |
| 471 | this.dom.innerHTML = ''; |
| 472 | const frag = document.createDocumentFragment(); |
| 473 | for (const item of this.result) { |
| 474 | if (item == this.selectedItem) { |
| 475 | item.dom.classList.add('selected'); |
| 476 | } else { |
| 477 | item.dom.classList.remove('selected'); |
| 478 | } |
| 479 | if (!item.isSelectable) { |
| 480 | item.dom.classList.add('not-selectable'); |
| 481 | } |
| 482 | frag.append(item.dom); |
| 483 | } |
| 484 | this.dom.append(frag); |
| 485 | this.updatePosition(); |
| 486 | this.getLayer().append(this.domWrap); |
| 487 | } else { |
| 488 | this.domWrap.remove(); |
| 489 | } |
| 490 | this.renderDetailsDebounced(); |
| 491 | } |
| 492 | |
| 493 | /** |
| 494 | * Create updated DOM for details. |
| 495 | */ |
| 496 | renderDetails() { |
| 497 | if (!this.isActive) return this.detailsWrap.remove(); |
| 498 | if (!this.isShowingDetails && this.isReplaceable) return this.detailsWrap.remove(); |
| 499 | this.detailsDom.innerHTML = ''; |
| 500 | this.detailsDom.append(this.selectedItem?.renderDetails() ?? 'NO ITEM'); |
| 501 | this.getLayer().append(this.detailsWrap); |
| 502 | this.updateDetailsPositionDebounced(); |
| 503 | } |
| 504 | |
| 505 | /** |
| 506 | * @returns {HTMLElement} closest ancestor dialog or body |
| 507 | */ |
| 508 | getLayer() { |
| 509 | return this.textarea.closest('dialog, body'); |
| 510 | } |
| 511 | |
| 512 | |
| 513 | /** |
| 514 | * Update position of DOM. |
| 515 | */ |
| 516 | updatePosition() { |
| 517 | if (this.isFloating) { |
| 518 | this.updateFloatingPosition(); |
| 519 | } else { |
| 520 | const rect = {}; |
| 521 | rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect(); |
| 522 | rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect(); |
| 523 | rect[AUTOCOMPLETE_WIDTH.FULL] = this.getLayer().getBoundingClientRect(); |
| 524 | this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`); |
| 525 | this.dom.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`); |
| 526 | this.domWrap.style.bottom = `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`; |
| 527 | if (this.isShowingDetails) { |
| 528 | this.domWrap.style.setProperty('--leftOffset', '1vw'); |
| 529 | this.domWrap.style.setProperty('--leftOffset', `max(1vw, ${rect[power_user.stscript.autocomplete.width.left].left}px)`); |
| 530 | this.domWrap.style.setProperty('--rightOffset', `calc(100vw - min(${rect[power_user.stscript.autocomplete.width.right].right}px, ${this.isShowingDetails ? 74 : 0}vw)`); |
| 531 | } else { |
| 532 | this.domWrap.style.setProperty('--leftOffset', `max(1vw, ${rect[power_user.stscript.autocomplete.width.left].left}px)`); |
| 533 | this.domWrap.style.setProperty('--rightOffset', `calc(100vw - min(99vw, ${rect[power_user.stscript.autocomplete.width.right].right}px)`); |
| 534 | } |
| 535 | } |
| 536 | this.updateDetailsPosition(); |
| 537 | } |
| 538 | |
| 539 | /** |
| 540 | * Update position of details DOM. |
| 541 | */ |
| 542 | updateDetailsPosition() { |
| 543 | if (this.isShowingDetails || !this.isReplaceable) { |
| 544 | if (this.isFloating) { |
| 545 | this.updateFloatingDetailsPosition(); |
| 546 | } else { |
| 547 | const rect = {}; |
| 548 | rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect(); |
| 549 | rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect(); |
| 550 | rect[AUTOCOMPLETE_WIDTH.FULL] = this.getLayer().getBoundingClientRect(); |
| 551 | if (this.isReplaceable) { |
| 552 | this.detailsWrap.classList.remove('full'); |
| 553 | const selRect = this.selectedItem.dom.children[0].getBoundingClientRect(); |
| 554 | this.detailsWrap.style.setProperty('--targetOffset', `${selRect.top}`); |
| 555 | this.detailsWrap.style.setProperty('--rightOffset', '1vw'); |
| 556 | this.detailsWrap.style.setProperty('--bottomOffset', `calc(100vh - ${rect[AUTOCOMPLETE_WIDTH.INPUT].top}px)`); |
| 557 | this.detailsWrap.style.setProperty('--leftOffset', `calc(100vw - ${this.domWrap.style.getPropertyValue('--rightOffset')}`); |
| 558 | } else { |
| 559 | this.detailsWrap.classList.add('full'); |
| 560 | this.detailsWrap.style.setProperty('--targetOffset', `${rect[AUTOCOMPLETE_WIDTH.INPUT].top}`); |
| 561 | this.detailsWrap.style.setProperty('--bottomOffset', `calc(100vh - ${rect[AUTOCOMPLETE_WIDTH.INPUT].top}px)`); |
| 562 | this.detailsWrap.style.setProperty('--leftOffset', `${rect[power_user.stscript.autocomplete.width.left].left}px`); |
| 563 | this.detailsWrap.style.setProperty('--rightOffset', `calc(100vw - ${rect[power_user.stscript.autocomplete.width.right].right}px)`); |
| 564 | } |
| 565 | } |
| 566 | } |
| 567 | } |
| 568 | |
| 569 | |
| 570 | /** |
| 571 | * Update position of floating autocomplete. |
| 572 | */ |
| 573 | updateFloatingPosition() { |
| 574 | const location = this.getCursorPosition(); |
| 575 | const rect = this.textarea.getBoundingClientRect(); |
| 576 | const layerRect = this.getLayer().getBoundingClientRect(); |
| 577 | // cursor is out of view -> hide |
| 578 | if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) { |
| 579 | return this.hide(); |
| 580 | } |
| 581 | const left = Math.max(rect.left, location.left) - layerRect.left; |
| 582 | this.domWrap.style.setProperty('--targetOffset', `${left}`); |
| 583 | if (location.top <= window.innerHeight / 2) { |
| 584 | // if cursor is in lower half of window, show list above line |
| 585 | this.domWrap.style.top = `${location.bottom - layerRect.top}px`; |
| 586 | this.domWrap.style.bottom = 'auto'; |
| 587 | this.domWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; |
| 588 | } else { |
| 589 | // if cursor is in upper half of window, show list below line |
| 590 | this.domWrap.style.top = 'auto'; |
| 591 | this.domWrap.style.bottom = `calc(${layerRect.height}px - ${location.top - layerRect.top}px)`; |
| 592 | this.domWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; |
| 593 | } |
| 594 | } |
| 595 | |
| 596 | updateFloatingDetailsPosition(location = null) { |
| 597 | if (!location) location = this.getCursorPosition(); |
| 598 | const rect = this.textarea.getBoundingClientRect(); |
| 599 | const layerRect = this.getLayer().getBoundingClientRect(); |
| 600 | if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) { |
| 601 | return this.hide(); |
| 602 | } |
| 603 | let left = Math.max(rect.left, location.left) - layerRect.left; |
| 604 | |
| 605 | // Check if the autocomplete list is constrained by the right edge of the viewport. |
| 606 | // If so, adjust the details panel position to align with the actual list position. |
| 607 | // Only do this when the list is actually visible (isReplaceable). |
| 608 | if (this.isReplaceable) { |
| 609 | const listRect = this.dom.getBoundingClientRect(); |
| 610 | const listActualLeft = listRect.left - layerRect.left; |
| 611 | const isConstrainedRight = listActualLeft < left - 5; // 5px tolerance |
| 612 | |
| 613 | if (isConstrainedRight) { |
| 614 | // Use the actual list position instead of cursor position |
| 615 | left = listActualLeft; |
| 616 | } |
| 617 | } |
| 618 | |
| 619 | this.detailsWrap.style.setProperty('--targetOffset', `${left}`); |
| 620 | if (this.isReplaceable) { |
| 621 | this.detailsWrap.classList.remove('full'); |
| 622 | if (left < window.innerWidth / 4) { |
| 623 | // if cursor is in left part of screen, show details on right of list |
| 624 | this.detailsWrap.classList.add('right'); |
| 625 | this.detailsWrap.classList.remove('left'); |
| 626 | } else { |
| 627 | // if cursor is in right part of screen, show details on left of list |
| 628 | this.detailsWrap.classList.remove('right'); |
| 629 | this.detailsWrap.classList.add('left'); |
| 630 | } |
| 631 | } else { |
| 632 | this.detailsWrap.classList.remove('left'); |
| 633 | this.detailsWrap.classList.remove('right'); |
| 634 | this.detailsWrap.classList.add('full'); |
| 635 | } |
| 636 | if (location.top <= window.innerHeight / 2) { |
| 637 | // if cursor is in lower half of window, show list above line |
| 638 | this.detailsWrap.style.top = `${location.bottom - layerRect.top}px`; |
| 639 | this.detailsWrap.style.bottom = 'auto'; |
| 640 | this.detailsWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; |
| 641 | } else { |
| 642 | // if cursor is in upper half of window, show list below line |
| 643 | this.detailsWrap.style.top = 'auto'; |
| 644 | this.detailsWrap.style.bottom = `calc(${layerRect.height}px - ${location.top - layerRect.top}px)`; |
| 645 | this.detailsWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`; |
| 646 | } |
| 647 | } |
| 648 | |
| 649 | /** |
| 650 | * Calculate (keyboard) cursor coordinates within textarea. |
| 651 | * @returns {{left:number, top:number, bottom:number}} |
| 652 | */ |
| 653 | getCursorPosition() { |
| 654 | const inputRect = this.textarea.getBoundingClientRect(); |
| 655 | const style = window.getComputedStyle(this.textarea); |
| 656 | if (!this.clone) { |
| 657 | this.clone = document.createElement('div'); |
| 658 | for (const key of style) { |
| 659 | this.clone.style[key] = style[key]; |
| 660 | } |
| 661 | this.clone.style.position = 'fixed'; |
| 662 | this.clone.style.visibility = 'hidden'; |
| 663 | document.body.append(this.clone); |
| 664 | const mo = new MutationObserver(muts => { |
| 665 | if (muts.find(it => Array.from(it.removedNodes).includes(this.textarea))) { |
| 666 | this.clone.remove(); |
| 667 | } |
| 668 | }); |
| 669 | mo.observe(this.textarea.parentElement, { childList: true }); |
| 670 | } |
| 671 | this.clone.style.height = `${inputRect.height}px`; |
| 672 | this.clone.style.left = `${inputRect.left}px`; |
| 673 | this.clone.style.top = `${inputRect.top}px`; |
| 674 | this.clone.style.whiteSpace = style.whiteSpace; |
| 675 | this.clone.style.tabSize = style.tabSize; |
| 676 | const text = this.textarea.value; |
| 677 | const before = text.slice(0, this.textarea.selectionStart); |
| 678 | this.clone.textContent = before; |
| 679 | const locator = document.createElement('span'); |
| 680 | locator.textContent = text[this.textarea.selectionStart]; |
| 681 | this.clone.append(locator); |
| 682 | this.clone.append(text.slice(this.textarea.selectionStart + 1)); |
| 683 | this.clone.scrollTop = this.textarea.scrollTop; |
| 684 | this.clone.scrollLeft = this.textarea.scrollLeft; |
| 685 | const locatorRect = locator.getBoundingClientRect(); |
| 686 | const location = { |
| 687 | left: locatorRect.left, |
| 688 | top: locatorRect.top, |
| 689 | bottom: locatorRect.bottom, |
| 690 | }; |
| 691 | return location; |
| 692 | } |
| 693 | |
| 694 | |
| 695 | /** |
| 696 | * Toggle details view alongside autocomplete list. |
| 697 | */ |
| 698 | toggleDetails() { |
| 699 | this.isShowingDetails = !this.isShowingDetails; |
| 700 | this.renderDetailsDebounced(); |
| 701 | this.updatePosition(); |
| 702 | } |
| 703 | |
| 704 | |
| 705 | /** |
| 706 | * Select an item for autocomplete and put text into textarea. |
| 707 | */ |
| 708 | async select() { |
| 709 | if (this.isReplaceable && this.selectedItem.value !== null) { |
| 710 | // Apply per-option replacement offset (e.g., for closing tags that need to replace leading whitespace) |
| 711 | const effectiveStart = this.effectiveParserResult.start + (this.selectedItem.replacementStartOffset ?? 0); |
| 712 | this.textarea.value = `${this.text.slice(0, effectiveStart)}${this.selectedItem.replacer}${this.text.slice(this.effectiveParserResult.start + this.effectiveParserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`; |
| 713 | this.textarea.selectionStart = effectiveStart + this.selectedItem.replacer.length; |
| 714 | this.textarea.selectionEnd = this.textarea.selectionStart; |
| 715 | this.show(false, false, true); |
| 716 | } else { |
| 717 | const selectionStart = this.textarea.selectionStart; |
| 718 | const selectionEnd = this.textarea.selectionDirection; |
| 719 | this.textarea.selectionStart = selectionStart; |
| 720 | this.textarea.selectionDirection = selectionEnd; |
| 721 | } |
| 722 | this.wasForced = false; |
| 723 | this.textarea.dispatchEvent(new Event('input', { bubbles: true })); |
| 724 | this.onSelect?.(this.selectedItem); |
| 725 | } |
| 726 | |
| 727 | |
| 728 | /** |
| 729 | * Select the default item for the autocomplete list. |
| 730 | * Selects the first selectable item if any is present, or falls back to the last item. |
| 731 | * (To preserve context of where we are with multiple non-selectable options, if they are present for info) |
| 732 | * @param {AutoCompleteOption[]} result The list of autocomplete options. |
| 733 | * @returns {AutoCompleteOption} The item to select. |
| 734 | */ |
| 735 | selectDefaultItem(result) { |
| 736 | if (result.length === 0) return null; |
| 737 | |
| 738 | // Find first selectable item |
| 739 | const firstSelectable = result.find(it => it.isSelectable); |
| 740 | if (firstSelectable) return firstSelectable; |
| 741 | |
| 742 | // Fall back to last item |
| 743 | return result[result.length - 1]; |
| 744 | } |
| 745 | |
| 746 | /** |
| 747 | * Mark the item at newIdx in the autocomplete list as selected. |
| 748 | * @param {number} newIdx |
| 749 | */ |
| 750 | selectItemAtIndex(newIdx) { |
| 751 | this.selectedItem.dom.classList.remove('selected'); |
| 752 | this.selectedItem = this.result[newIdx]; |
| 753 | this.selectedItem.dom.classList.add('selected'); |
| 754 | const rect = this.selectedItem.dom.children[0].getBoundingClientRect(); |
| 755 | const rectParent = this.dom.getBoundingClientRect(); |
| 756 | if (rect.top < rectParent.top || rect.bottom > rectParent.bottom) { |
| 757 | this.dom.scrollTop += rect.top < rectParent.top ? rect.top - rectParent.top : rect.bottom - rectParent.bottom; |
| 758 | } |
| 759 | this.renderDetailsDebounced(); |
| 760 | } |
| 761 | |
| 762 | /** |
| 763 | * Handle keyboard events. |
| 764 | * @param {KeyboardEvent} evt The event. |
| 765 | */ |
| 766 | async handleKeyDown(evt) { |
| 767 | // autocomplete is shown and cursor at end of current command name (or inside name and typed or forced) |
| 768 | if (this.isActive && this.isReplaceable) { |
| 769 | // actions in the list |
| 770 | switch (evt.key) { |
| 771 | case 'ArrowUp': { |
| 772 | // select previous item |
| 773 | if (evt.ctrlKey || evt.altKey || evt.shiftKey) return; |
| 774 | evt.preventDefault(); |
| 775 | evt.stopPropagation(); |
| 776 | const idx = this.result.indexOf(this.selectedItem); |
| 777 | let newIdx; |
| 778 | if (idx == 0) newIdx = this.result.length - 1; |
| 779 | else newIdx = idx - 1; |
| 780 | this.selectItemAtIndex(newIdx); |
| 781 | return; |
| 782 | } |
| 783 | case 'ArrowDown': { |
| 784 | // select next item |
| 785 | if (evt.ctrlKey || evt.altKey || evt.shiftKey) return; |
| 786 | evt.preventDefault(); |
| 787 | evt.stopPropagation(); |
| 788 | const idx = this.result.indexOf(this.selectedItem); |
| 789 | const newIdx = (idx + 1) % this.result.length; |
| 790 | this.selectItemAtIndex(newIdx); |
| 791 | return; |
| 792 | } |
| 793 | case 'Enter': { |
| 794 | // pick the selected item to autocomplete |
| 795 | if ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.ENTER) != AUTOCOMPLETE_SELECT_KEY.ENTER) break; |
| 796 | if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break; |
| 797 | if (this.selectedItem.name == this.name) break; |
| 798 | if (!this.selectedItem.isSelectable) break; |
| 799 | evt.preventDefault(); |
| 800 | evt.stopImmediatePropagation(); |
| 801 | this.select(); |
| 802 | return; |
| 803 | } |
| 804 | case 'Tab': { |
| 805 | // pick the selected item to autocomplete |
| 806 | if ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.TAB) != AUTOCOMPLETE_SELECT_KEY.TAB) break; |
| 807 | if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break; |
| 808 | evt.preventDefault(); |
| 809 | evt.stopImmediatePropagation(); |
| 810 | if (!this.selectedItem.isSelectable) break; |
| 811 | this.select(); |
| 812 | return; |
| 813 | } |
| 814 | } |
| 815 | } |
| 816 | // details are shown, cursor can be anywhere |
| 817 | if (this.isActive) { |
| 818 | switch (evt.key) { |
| 819 | case 'Escape': { |
| 820 | // close autocomplete |
| 821 | if (evt.ctrlKey || evt.altKey || evt.shiftKey) return; |
| 822 | evt.preventDefault(); |
| 823 | evt.stopPropagation(); |
| 824 | this.isForceHidden = true; |
| 825 | this.wasForced = false; |
| 826 | this.hide(); |
| 827 | return; |
| 828 | } |
| 829 | case 'Enter': { |
| 830 | // hide autocomplete on enter (send, execute, ...) |
| 831 | if (!evt.shiftKey) { |
| 832 | this.hide(); |
| 833 | return; |
| 834 | } |
| 835 | break; |
| 836 | } |
| 837 | } |
| 838 | } |
| 839 | // autocomplete shown or not, cursor anywhere |
| 840 | switch (evt.key) { |
| 841 | // The first is a non-breaking space, the second is a regular space. |
| 842 | case ' ': |
| 843 | case ' ': { |
| 844 | if (evt.ctrlKey || evt.altKey) { |
| 845 | if (this.isActive && this.isReplaceable) { |
| 846 | // ctrl-space to toggle details for selected item |
| 847 | this.toggleDetails(); |
| 848 | } else { |
| 849 | // ctrl-space to force show autocomplete |
| 850 | this.show(false, true); |
| 851 | } |
| 852 | evt.preventDefault(); |
| 853 | evt.stopPropagation(); |
| 854 | return; |
| 855 | } |
| 856 | break; |
| 857 | } |
| 858 | } |
| 859 | if (['Control', 'Shift', 'Alt'].includes(evt.key)) { |
| 860 | // ignore keydown on modifier keys |
| 861 | return; |
| 862 | } |
| 863 | // await keyup to see if cursor position or text has changed |
| 864 | const oldText = this.textarea.value; |
| 865 | await new Promise(resolve => { |
| 866 | window.addEventListener('keyup', resolve, { once: true }); |
| 867 | }); |
| 868 | if (this.selectionStart != this.textarea.selectionStart) { |
| 869 | this.selectionStart = this.textarea.selectionStart; |
| 870 | this.show(this.isReplaceable || oldText != this.textarea.value); |
| 871 | } else if (this.isActive) { |
| 872 | this.text != this.textarea.value && this.show(this.isReplaceable); |
| 873 | } |
| 874 | } |
| 875 | } |