All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

424 lines
16 KiB

/**
* 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;