Blame Raw
Cohee · e3f41666 · · 875 lines (37.9 KB)
1 contributor
1import { power_user } from '../power-user.js';
2import { debounce, escapeRegex } from '../utils.js';
3import { AutoCompleteOption } from './AutoCompleteOption.js';
4import { AutoCompleteFuzzyScore } from './AutoCompleteFuzzyScore.js';
5import { BlankAutoCompleteOption } from './BlankAutoCompleteOption.js';
6import { AutoCompleteNameResult } from './AutoCompleteNameResult.js';
7import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js';
8
9/**@readonly*/
10/**@enum {Number}*/
11export const AUTOCOMPLETE_WIDTH = {
12 'INPUT': 0,
13 'CHAT': 1,
14 'FULL': 2,
15};
16
17/**@readonly*/
18/**@enum {Number}*/
19export const AUTOCOMPLETE_SELECT_KEY = {
20 'TAB': 1, // 2^0
21 'ENTER': 2, // 2^1
22};
23
24/** @readonly */
25/** @enum {Number} */
26export const AUTOCOMPLETE_STATE = {
27 DISABLED: 0,
28 MIN_LENGTH: 1,
29 ALWAYS: 2,
30};
31
32export 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}