/**
|
|
* A set of keys to ignore when processing events.
|
|
* @private
|
|
*/
|
|
const ignoredKeys = new Set(["Control", "ContextMenu", "AltGraph", "Alt", "Shift", "Meta"]);
|
|
|
|
/**
|
|
* Extension for the chat text input to enable autocomplete functionalities.
|
|
*/
|
|
class AutocompleteMenu {
|
|
/**
|
|
* Indicates whether the menu is currently displayed.
|
|
* @type {boolean}
|
|
* @private
|
|
*/
|
|
visible = false;
|
|
|
|
/**
|
|
* Parent element of the menu.
|
|
* @type {HTMLElement}
|
|
* @private
|
|
*/
|
|
container;
|
|
|
|
/**
|
|
* Chat text input rendered by FoundryVTT.
|
|
* @type {HTMLTextAreaElement}
|
|
* @private
|
|
*/
|
|
chatInput;
|
|
|
|
/**
|
|
* Text area to display suggestions.
|
|
* @type {HTMLTextAreaElement}
|
|
* @private
|
|
*/
|
|
suggestionArea;
|
|
|
|
/**
|
|
* Maximum amount of entries that can be displayed in the menu.
|
|
* @type {number}
|
|
*/
|
|
maxEntries;
|
|
|
|
/**
|
|
* Indicates whether the command footer should be displayed.
|
|
* @type {boolean}
|
|
*/
|
|
showFooter;
|
|
|
|
/**
|
|
* Stores the command that is currently being autocompleted. May be null to indicate that we're completing the
|
|
* command itself.
|
|
* @type {ChatCommand?}
|
|
*/
|
|
currentCommand;
|
|
|
|
/**
|
|
* Creates a new menu and registers listeners to enable autocompletion of commands.
|
|
* @param {HTMLTextAreaElement} chatInput The text area to attach the listeners to.
|
|
*/
|
|
constructor(chatInput) {
|
|
// Create UI markup.
|
|
const menuContainer = "<div id='autocomplete-menu'></div>";
|
|
const suggestionArea = "<textarea id='autocomplete-suggestion' autocomplete='off' disabled></textarea>";
|
|
|
|
// Insert UI into DOM.
|
|
chatInput.parentElement.insertAdjacentHTML("beforebegin", menuContainer);
|
|
chatInput.insertAdjacentHTML("afterend", suggestionArea);
|
|
|
|
// Store important elements locally.
|
|
this.container = chatInput.parentElement.previousElementSibling;
|
|
this.chatInput = chatInput;
|
|
this.suggestionArea = chatInput.nextElementSibling;
|
|
|
|
// Activate listeners.
|
|
chatInput.addEventListener("input", event => this.open(event.currentTarget.value));
|
|
chatInput.addEventListener("keydown", event => this.focus(event), true); // Use capture to run first.
|
|
this.container.addEventListener("keydown", event => this.navigate(event));
|
|
this.container.addEventListener("click", event => {
|
|
if (event.target.tagName === "A" || !event.target.closest("li")?.matches(".command")) return;
|
|
this.select(event.target.dataset.command);
|
|
});
|
|
|
|
this.maxEntries = game.settings.get("_chatcommands", "maxEntries");
|
|
this.showFooter = game.settings.get("_chatcommands", "displayFooter");
|
|
}
|
|
|
|
/**
|
|
* Initialize the hook to attach autocomplete menus to chat message inputs.
|
|
* @package
|
|
*/
|
|
static initialize() {
|
|
Hooks.on('renderChatLog', (app, html) => {
|
|
if (!game.settings.get("_chatcommands", "autocomplete")) return;
|
|
app.autocompleteMenu = new AutocompleteMenu(html[0].querySelector("#chat-message"));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Prevents all propagation for the given event so that no other handlers are called.
|
|
* @param {Event} event The event to stop.
|
|
* @package
|
|
*/
|
|
static stopEvent(event) {
|
|
event.stopPropagation();
|
|
event.stopImmediatePropagation();
|
|
event.preventDefault();
|
|
}
|
|
|
|
/**
|
|
* Checks if the input of the target text area contains a command that can be autocompleted. If so, the menu is
|
|
* populated and displayed above the input.
|
|
* @param {string} text The text to complete.
|
|
*/
|
|
open(text) {
|
|
if (!game.chatCommands.isCommand(text)) return this.close(); // Input is not a command.
|
|
|
|
let entries;
|
|
const commandInfo = game.chatCommands.parseCommand(text);
|
|
if (!commandInfo || (commandInfo.alias.length > 1 && text.length === commandInfo.alias.length)) {
|
|
// Could not find command or delimiter, autocomplete the command itself.
|
|
this.currentCommand = null;
|
|
if (text.includes(' ')) return this.close();
|
|
entries = this._createCommandList(text);
|
|
} else {
|
|
// Found a command, autocomplete it with its parameters.
|
|
this.currentCommand = commandInfo.command;
|
|
if (!this.currentCommand.canInvoke()) return this.close();
|
|
entries = this._createCommandEntries(commandInfo.alias, commandInfo.parameters);
|
|
}
|
|
|
|
if (!entries.length) return this.close(); // No entries available.
|
|
if (!this.visible) {
|
|
// Display the menu if it was not created yet.
|
|
this.visible = true;
|
|
$(window).on("click.outside-autocomplete-menu", event => {
|
|
if (!event.target.closest("#autocomplete-menu")) this.close();
|
|
});
|
|
|
|
const maxHeight = this.container.parentElement.querySelector("#chat-log")?.offsetHeight ?? 900;
|
|
this.container.innerHTML = `<nav id="context-menu" class="expand-up" style="max-height: ${maxHeight}px">
|
|
<ol class="context-items"></ol></nav>`;
|
|
}
|
|
|
|
this.display(entries);
|
|
}
|
|
|
|
/**
|
|
* Replaces the current menu entries with the given array of elements. Elements exceeding the @see maxEntries limit
|
|
* are omitted and an overflow entry is displayed instead.
|
|
* @param {HTMLElement[]} entries The menu entries to display.
|
|
* @param {boolean=} append Indicates whether the entries will be appended to the menu (instead of replacing
|
|
* existing entries). Defaults to false.
|
|
* @param {boolean=} done Indicates whether the menu is complete. When false, a loading indicator will be
|
|
* displayed. Defaults to true.
|
|
*/
|
|
display(entries, append = false, done = true) {
|
|
if (!this.visible) return;
|
|
|
|
const list = this.container.querySelector("ol.context-items");
|
|
if (!list) return;
|
|
|
|
// Add or remove loading indicator.
|
|
let previousLength = append ? list.children.length : 0;
|
|
if (!previousLength && !done) entries.push(game.chatCommands.createLoadingElement());
|
|
if (previousLength && list.lastElementChild.matches("li.loading")) {
|
|
previousLength--;
|
|
if (done) list.lastElementChild.remove();
|
|
}
|
|
if (!entries.length) return;
|
|
|
|
// Clip the list to the maximum amount of entries.
|
|
const newLength = previousLength + entries.length;
|
|
if (newLength > this.maxEntries) {
|
|
const overflowEntry = game.chatCommands.createOverflowElement();
|
|
if (previousLength > this.maxEntries) {
|
|
// The list already had more entries before appending.
|
|
list.firstElementChild.replaceWith(overflowEntry);
|
|
return;
|
|
} else {
|
|
// The list now has more entries.
|
|
entries.length = Math.max(0, this.maxEntries - previousLength);
|
|
entries.unshift(overflowEntry);
|
|
}
|
|
}
|
|
|
|
// Insert the new entries into the DOM.
|
|
if (append) list.prepend(...entries);
|
|
else list.replaceChildren(...entries);
|
|
|
|
// Suggest the only command or clear the suggestion.
|
|
const commands = list.querySelectorAll("li.command");
|
|
if (commands.length === 1 && !this.suggestionArea.value) this.suggest(commands[0].dataset.command);
|
|
}
|
|
|
|
/**
|
|
* Handles focus changes between the text area and the menu.
|
|
* @param {Event} event The input event that triggered the action.
|
|
*/
|
|
focus(event) {
|
|
// Allow applying a suggestion even when the menu is not visible.
|
|
if (event.key === "Tab") {
|
|
let suggestedValue = this.suggestionArea.value;
|
|
if (suggestedValue) {
|
|
// Apply suggested command.
|
|
if (suggestedValue.startsWith(this.chatInput.value + "\n")) {
|
|
// Trim the suggestion if it was placed on a new line.
|
|
suggestedValue = suggestedValue.substring(this.chatInput.value.length + 1);
|
|
}
|
|
this.select(suggestedValue);
|
|
return this.constructor.stopEvent(event);
|
|
}
|
|
|
|
if (this.visible) {
|
|
// Nothing was applied, close the menu.
|
|
this.close();
|
|
return this.constructor.stopEvent(event);
|
|
}
|
|
|
|
return; // Let other listeners handle the event.
|
|
}
|
|
|
|
if (ignoredKeys.has(event.key)) return;
|
|
this.resetSuggestion();
|
|
if (!this.visible) return;
|
|
switch (event.key) {
|
|
case "ArrowUp":
|
|
// Select the last focusable entry.
|
|
this._focusPreviousEntry(this.container.querySelector("ol.context-items").lastElementChild);
|
|
return this.constructor.stopEvent(event);
|
|
case "ArrowDown":
|
|
// Select the first focusable entry.
|
|
this._focusNextEntry(this.container.querySelector("ol.context-items").firstElementChild);
|
|
return this.constructor.stopEvent(event);
|
|
case "Escape":
|
|
this.constructor.stopEvent(event);
|
|
case "Enter":
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the focus from the menu to the text area.
|
|
*/
|
|
blur() {
|
|
this.chatInput.focus();
|
|
this.resetSuggestion();
|
|
}
|
|
|
|
/**
|
|
* Handles focus changes within the menu.
|
|
* @param {Event} event The input event that triggered the action.
|
|
*/
|
|
navigate(event) {
|
|
let entry = event.target;
|
|
if (!this.visible || !entry.matches("li.context-item")) return;
|
|
switch (event.key) {
|
|
case "ArrowUp":
|
|
// Select the previous focusable entry.
|
|
this._focusPreviousEntry(entry.previousElementSibling);
|
|
return this.constructor.stopEvent(event);
|
|
case "ArrowDown":
|
|
// Select the next focusable entry.
|
|
this._focusNextEntry(entry.nextElementSibling);
|
|
return this.constructor.stopEvent(event);
|
|
case "Tab":
|
|
case "Enter":
|
|
// Apply the currently selected command.
|
|
this.select(entry.dataset.command);
|
|
return this.constructor.stopEvent(event);
|
|
case "Escape":
|
|
// Abort the autocomplete.
|
|
this.blur();
|
|
this.close();
|
|
return this.constructor.stopEvent(event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Closes the menu and resets its state.
|
|
*/
|
|
close() {
|
|
if (!this.visible) return;
|
|
this.visible = false;
|
|
$(window).off("click.outside-autocomplete-menu");
|
|
this.container.replaceChildren();
|
|
this.currentCommand?.abortAutocomplete();
|
|
}
|
|
|
|
/**
|
|
* Displays the given command in the suggestion area.
|
|
* @param {string} command The string containing the command to suggest.
|
|
*/
|
|
suggest(command) {
|
|
if (!command) return this.resetSuggestion();
|
|
const currentValue = this.chatInput.value;
|
|
if (!currentValue || command.startsWith(currentValue)) {
|
|
// Direct match, display behind the current text.
|
|
this.suggestionArea.value = command;
|
|
} else {
|
|
// Approximate match, display below the current text.
|
|
this.suggestionArea.value = currentValue + "\n" + command;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies the given command to the chat message input and closes the menu.
|
|
* @param {string} command The command to select.
|
|
*/
|
|
select(command) {
|
|
if (!command) return;
|
|
this.resetSuggestion();
|
|
this.chatInput.value = command;
|
|
this.chatInput.focus();
|
|
|
|
if (this.currentCommand && (this.currentCommand.closeOnComplete ?? true)) this.close();
|
|
else this.open(command);
|
|
}
|
|
|
|
/**
|
|
* Clears the suggested command.
|
|
*/
|
|
resetSuggestion() {
|
|
this.suggestionArea.value = "";
|
|
}
|
|
|
|
/**
|
|
* Searches the registered commands using the given text and creates a list element for each match.
|
|
* @param {string} text The text to search within command names.
|
|
* @returns {HTMLCollection} The collection of command entries to display.
|
|
* @private
|
|
*/
|
|
_createCommandList(text) {
|
|
text = text.toLowerCase(); // Case insensitive matching.
|
|
if (!game.chatCommands.isCommand(text)) return [];
|
|
const firstChar = text[0];
|
|
text = text.substring(1);
|
|
|
|
const matchedCommands = new Set();
|
|
const matches = [];
|
|
for (let [name, command] of game.chatCommands.commands.entries()) {
|
|
if (name[0] !== firstChar) continue;
|
|
const startIndex = name.toLowerCase().indexOf(text);
|
|
if (startIndex === -1 || matchedCommands.has(command.name) || !command.canInvoke()) continue;
|
|
|
|
// Created highlighted command name entry.
|
|
const content = command.getDescription(name, this.showFooter);
|
|
|
|
matchedCommands.add(command.name);
|
|
matches.push(game.chatCommands.createCommandElement(name + " ", content));
|
|
}
|
|
|
|
if (!matches.length && this.visible) {
|
|
matches.push(game.chatCommands.createInfoElement(
|
|
`<p class="notes">${game.i18n.localize("_chatcommands.noEntries")}</p>`));
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
/**
|
|
* Creates HTML list elements using the output of the given command's autocomplete handler.
|
|
* @param {string} command The command to complete.
|
|
* @param {string} parameters The current parameters of the command.
|
|
* @returns {HTMLCollection} The collection of entries to display.
|
|
* @private
|
|
*/
|
|
_createCommandEntries(command, parameters) {
|
|
if (!this.currentCommand) return [];
|
|
|
|
const result = this.currentCommand.autocomplete(this, command, parameters, this.maxEntries);
|
|
if (result) return result;
|
|
if (this.visible) {
|
|
return [game.chatCommands.createInfoElement(this.currentCommand.getDescription(command, this.showFooter))];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Attempts to focus the given entry or any entry before it. If no focusable entry is found, the menu is unfocused.
|
|
* @param {HTMLElement} entry The first entry to attempt focusing.
|
|
* @private
|
|
*/
|
|
_focusPreviousEntry(entry) {
|
|
while (entry) {
|
|
if (this._tryFocusEntry(entry)) return;
|
|
entry = entry.previousElementSibling;
|
|
}
|
|
|
|
this.blur();
|
|
}
|
|
|
|
/**
|
|
* Attempts to focus the given entry or any entry after it. If no focusable entry is found, the menu is unfocused.
|
|
* @param {HTMLElement} entry The first entry to attempt focusing.
|
|
* @private
|
|
*/
|
|
_focusNextEntry(entry) {
|
|
while (entry) {
|
|
if (this._tryFocusEntry(entry)) return;
|
|
entry = entry.nextElementSibling;
|
|
}
|
|
|
|
this.blur();
|
|
}
|
|
|
|
/**
|
|
* Attempts to focus the given entry and returns whether it can be focused.
|
|
* @param {HTMLElement} entry The entry to focus.
|
|
* @returns {boolean} True if the entry was focused, false otherwise.
|
|
* @private
|
|
*/
|
|
_tryFocusEntry(entry) {
|
|
if (entry.tabIndex > -1) {
|
|
entry.focus();
|
|
this.suggest(entry.dataset.command);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export default AutocompleteMenu;
|