/** * 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 = "
"; const suggestionArea = ""; // 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 = ``; } 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( `

${game.i18n.localize("_chatcommands.noEntries")}

`)); } 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;