export class CommandLine extends HTMLElement { static commands = new Map(); constructor() { super(); } connectedCallback() { this.suggestions = this.appendChild(document.createElement("ul")); this.suggestions.classList.add("suggestions"); let inputWrapper = this.appendChild(document.createElement("div")); inputWrapper.classList.add("input-wrapper"); this.input = inputWrapper.appendChild(document.createElement("input")); this.input.type = "text"; window.addEventListener("keydown", (event) => { if (event.key == ":" && !this.visible) { event.stopPropagation(); event.preventDefault(); this.show(); } if (event.key == "Escape") { this.hide(); } }); window.addEventListener("click", () => { this.hide(); }); this.addEventListener("click", (event) => { event.stopPropagation(); }); this.input.addEventListener("keydown", (event) => { if (event.key == "Enter") { event.preventDefault(); this.hide(); this.runCommand(this.input.value); } if (event.key == "Tab") { event.preventDefault(); if (event.shiftKey) this.tabToPreviousSuggestion(); else this.tabToNextSuggestion(); } }); this.input.addEventListener("input", () => { this.updateSuggestions(); }); } get visible() { return this.classList.contains("visible"); } show() { this.classList.add("visible"); this.input.focus(); this.input.value = ""; this.updateSuggestions(); } hide() { this.classList.remove("visible"); } tab(current, next) { current?.classList?.remove("tabbed"); next.classList.add("tabbed"); this.input.value = next.name; // NOTE: Do NOT update suggestions here. // This would cause the tabbing to break. } tabToNextSuggestion() { let current = this.suggestions.querySelector(".tabbed"); let next = current?.nextSibling ?? this.suggestions.childNodes[0]; this.tab(current, next); } tabToPreviousSuggestion() { let current = this.suggestions.querySelector(".tabbed"); let previous = current?.previousSibling ?? this.suggestions.childNodes[this.suggestions.childNodes.length - 1]; this.tab(current, previous); } updateSuggestions() { let search = parseCommand(this.input.value)?.command ?? ""; let suggestions = Array.from(CommandLine.commands.entries()).filter( ([name, def]) => !def.isAlias && fuzzyMatch(search, name), ); suggestions.sort(); this.suggestions.replaceChildren(); for (let [name, def] of suggestions) { let suggestion = this.suggestions.appendChild(document.createElement("li")); let commandName = suggestion.appendChild(document.createElement("dfn")); commandName.textContent = name; let commandDescription = suggestion.appendChild(document.createElement("span")); commandDescription.classList.add("description"); commandDescription.textContent = def.description; suggestion.name = name; suggestion.def = def; suggestion.addEventListener("click", () => { this.input.value = name; this.updateSuggestions(); this.input.focus(); }); } } runCommand(commandLine) { let { command, args } = parseCommand(commandLine); let commandDef = CommandLine.commands.get(command); if (CommandLine.commands.has(command)) { commandDef.run(args); } else { console.log(`unknown command`); } } static registerCommand({ aliases, description, run }) { for (let i = 0; i < aliases.length; ++i) { CommandLine.commands.set(aliases[i], { isAlias: i != 0, description, run, }); } } } customElements.define("th-command-line", CommandLine); function parseCommand(commandLine) { let result = /^([^ ]+) *(.*)$/.exec(commandLine); if (result == null) return null; let [_, command, args] = result; return { command, args }; } // https://www.forrestthewoods.com/blog/reverse_engineering_sublime_texts_fuzzy_match/ function fuzzyMatch(pattern, string) { let iPattern = 0; let iString = 0; while (iPattern < pattern.length && iString < string.length) { if (pattern.charAt(iPattern).toLowerCase() == string.charAt(iString).toLowerCase()) { iPattern += 1; } iString += 1; } return iPattern == pattern.length; } CommandLine.registerCommand({ aliases: ["help", "h"], description: '"OwO, what is this?"', run() { window.location = `${TREEHOUSE_SITE}/treehouse/cmd`; }, });