mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
184 lines
6.0 KiB
JavaScript
184 lines
6.0 KiB
JavaScript
import { ChatCommands } from './ChatCommands.js';
|
|
|
|
/**
|
|
* Autocomplete popup for slash commands and @mentions.
|
|
*/
|
|
export class ChatAutocomplete {
|
|
#items = [];
|
|
#selectedIndex = -1;
|
|
#trigger = null;
|
|
|
|
/**
|
|
* @param {HTMLInputElement} inputEl
|
|
* @param {HTMLElement} containerEl
|
|
*/
|
|
constructor(inputEl, containerEl) {
|
|
this.inputEl = inputEl;
|
|
|
|
this.el = document.createElement('div');
|
|
this.el.className = 'autocomplete-popup';
|
|
this.el.style.display = 'none';
|
|
containerEl.appendChild(this.el);
|
|
|
|
inputEl.addEventListener('input', () => this.#onInput());
|
|
inputEl.addEventListener('keydown', (e) => this.#onKeydown(e));
|
|
inputEl.addEventListener('blur', () => setTimeout(() => this.hide(), 150));
|
|
}
|
|
|
|
#getMembersMap = () => new Map();
|
|
|
|
/**
|
|
* @param {() => Map<string, {displayName: string|null}>} fn
|
|
*/
|
|
setMembersProvider(fn) {
|
|
this.#getMembersMap = fn;
|
|
}
|
|
|
|
get isVisible() {
|
|
return this.el.style.display !== 'none';
|
|
}
|
|
|
|
#onInput() {
|
|
const val = this.inputEl.value;
|
|
const cursor = this.inputEl.selectionStart ?? val.length;
|
|
|
|
if (val.startsWith('/') && !val.slice(1).includes(' ')) {
|
|
const query = val.slice(1).toLowerCase();
|
|
const matches = ChatCommands.COMMANDS.filter(c =>
|
|
c.name.startsWith(query) || c.aliases.some(a => a.startsWith(query))
|
|
);
|
|
if (matches.length) {
|
|
this.#trigger = { type: 'command', start: 0 };
|
|
this.#show(matches.map(c => ({
|
|
label: `/${c.name}`,
|
|
hint: c.args || '',
|
|
desc: c.desc,
|
|
completion: `/${c.name} `,
|
|
})));
|
|
return;
|
|
}
|
|
}
|
|
|
|
const textBeforeCursor = val.slice(0, cursor);
|
|
const mentionMatch = textBeforeCursor.match(/(^|\s)@(\S*)$/);
|
|
if (mentionMatch) {
|
|
const query = mentionMatch[2].toLowerCase();
|
|
const atIndex = textBeforeCursor.lastIndexOf('@');
|
|
const members = [...this.#getMembersMap().entries()]
|
|
.filter(([uid, info]) => {
|
|
const name = (info.displayName || '').toLowerCase();
|
|
const localpart = uid.match(/^@([^:]+):/)?.[1]?.toLowerCase() ?? '';
|
|
return name.startsWith(query) || localpart.startsWith(query);
|
|
})
|
|
.slice(0, 10);
|
|
if (members.length) {
|
|
this.#trigger = { type: 'mention', start: atIndex };
|
|
this.#show(members.map(([uid, info]) => ({
|
|
label: info.displayName || uid.match(/^@([^:]+):/)?.[1] || uid,
|
|
hint: uid,
|
|
desc: '',
|
|
completion: uid,
|
|
})));
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.hide();
|
|
}
|
|
|
|
#show(items) {
|
|
this.#items = items;
|
|
this.#selectedIndex = items.length ? 0 : -1;
|
|
this.#render();
|
|
this.el.style.display = 'block';
|
|
}
|
|
|
|
#render() {
|
|
this.el.innerHTML = '';
|
|
this.#items.forEach((item, i) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'ac-item' + (i === this.#selectedIndex ? ' selected' : '');
|
|
|
|
row.appendChild(Object.assign(document.createElement('span'), {
|
|
className: 'ac-label',
|
|
textContent: item.label,
|
|
}));
|
|
if (item.hint) {
|
|
row.appendChild(Object.assign(document.createElement('span'), {
|
|
className: 'ac-hint',
|
|
textContent: item.hint,
|
|
}));
|
|
}
|
|
if (item.desc) {
|
|
row.appendChild(Object.assign(document.createElement('span'), {
|
|
className: 'ac-desc',
|
|
textContent: item.desc,
|
|
}));
|
|
}
|
|
|
|
row.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
this.#apply(i);
|
|
});
|
|
this.el.appendChild(row);
|
|
});
|
|
}
|
|
|
|
#onKeydown(e) {
|
|
if (!this.isVisible) return;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
this.#selectedIndex = (this.#selectedIndex + 1) % this.#items.length;
|
|
this.#render();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
this.#selectedIndex = (this.#selectedIndex - 1 + this.#items.length) % this.#items.length;
|
|
this.#render();
|
|
} else if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (this.#selectedIndex >= 0) this.#apply(this.#selectedIndex);
|
|
} else if (e.key === 'Enter') {
|
|
if (this.#selectedIndex >= 0) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
this.#apply(this.#selectedIndex);
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
this.hide();
|
|
}
|
|
}
|
|
|
|
#apply(index) {
|
|
const item = this.#items[index];
|
|
if (!item || !this.#trigger) return;
|
|
|
|
const val = this.inputEl.value;
|
|
const cursor = this.inputEl.selectionStart ?? val.length;
|
|
|
|
if (this.#trigger.type === 'command') {
|
|
this.inputEl.value = item.completion;
|
|
this.inputEl.selectionStart = this.inputEl.selectionEnd = item.completion.length;
|
|
} else {
|
|
const before = val.slice(0, this.#trigger.start);
|
|
const after = val.slice(cursor);
|
|
const inserted = item.completion + ' ';
|
|
this.inputEl.value = before + inserted + after;
|
|
const pos = before.length + inserted.length;
|
|
this.inputEl.selectionStart = this.inputEl.selectionEnd = pos;
|
|
}
|
|
|
|
this.hide();
|
|
this.inputEl.focus();
|
|
this.inputEl.dispatchEvent(new Event('input'));
|
|
}
|
|
|
|
hide() {
|
|
this.#items = [];
|
|
this.#selectedIndex = -1;
|
|
this.#trigger = null;
|
|
this.el.style.display = 'none';
|
|
}
|
|
}
|