class CommandBar extends HTMLElement { static observedAttributes = [ "presented" ]; #shadowRoot; rootElement; inputElement; rulerElement; scrollPositionElement; #leader = ":"; #commandExecutor = new CommandExecutor(); #indexOfLastShownCommandFromHistory = null; #shouldPresentAfterConnection = false; constructor() { super(); this.#shadowRoot = this.attachShadow({ mode: "closed" }); } get #elementHasPresentedAttribute() { return this.rootElement.hasAttribute("presented"); } // MARK: Presentation show(options) { if (options === undefined || options === null) { options = { leader: null, }; } const inputElement = this.inputElement; if (inputElement) { inputElement.value = options.leader ? options.leader.toString() : ""; inputElement.focus(); } this.rootElement?.setAttribute("presented", ""); } hide() { if (!this.#elementHasPresentedAttribute) { return; } const inputElement = this.inputElement; if (inputElement) { inputElement.value = ""; inputElement.blur(); } this.rootElement?.removeAttribute("presented"); } // MARK: Command Handling executeCommand(commandString) { this.#indexOfLastShownCommandFromHistory = null; try { const command = Command.parse(commandString); this.#commandExecutor.executeCommand(command); } catch (e) { console.error("Error executing command:", e); } } addCommandHandler(name, handler) { this.#commandExecutor.addHandler(name, handler); } updateRuler(x, y) { this.rulerElement.innerText = `${x || 0},${y || 0}`; } /** * Compute the percentage that the document is scrolled relative to the full length * of the document. This algorithm is largely cribbed from neovim's sources. * * @param scrollingElement The element on which to track scrolling */ updateScrollPosition(scrollingElement) { if (!this.scrollPositionElement) { return; } const scrollY = scrollingElement.scrollTop; const scrollHeight = scrollingElement.clientHeight; const totalHeight = scrollingElement.scrollHeight; const bottomY = scrollY + scrollHeight; const pixelLinesBelow = totalHeight - bottomY; const scrollPercentage = Math.round(scrollY * 100 / (scrollY + pixelLinesBelow)); let newScrollPositionText; if (pixelLinesBelow <= 0) { newScrollPositionText = "Bot"; } else if (scrollY <= 0) { newScrollPositionText = "Top"; } else { newScrollPositionText = `${scrollPercentage}%`; } const currentScrollPositionText = this.scrollPositionElement.innerText; const scrollPositionIsEmpty = !currentScrollPositionText || currentScrollPositionText.length === 0; if (scrollPositionIsEmpty || newScrollPositionText != currentScrollPositionText) { this.scrollPositionElement.innerText = newScrollPositionText; } } // MARK: Custom Element connectedCallback() { let template = document.getElementById("command-bar-template"); console.assert(template, "Couldn't find CommandBar component template in the document"); const shadowRoot = this.#shadowRoot; shadowRoot.appendChild(template.content.cloneNode(true)); this.rootElement = shadowRoot.querySelector(".command-bar"); this.rulerElement = shadowRoot.querySelector(".command-bar__ruler"); this.scrollPositionElement = shadowRoot.querySelector(".command-bar__scrollposition"); const inputElement = shadowRoot.querySelector(".command-bar__input"); this.inputElement = inputElement; this.updateRuler(); this.updateScrollPosition(document.scrollingElement); inputElement.addEventListener("input", this.handleInputEvent.bind(this)); inputElement.addEventListener("keydown", this.handleInputKeydownEvent.bind(this)); document.addEventListener("keypress", event => this.handleDocumentKeypressEvent(event)); document.addEventListener("mousemove", event => this.updateRuler(event.x, event.y)); document.addEventListener("scroll", () => this.updateScrollPosition(document.scrollingElement)); if (this.#shouldPresentAfterConnection) { this.show({ leader: this.#leader }); this.#shouldPresentAfterConnection = false; } } attributeChangedCallback(name, _oldValue, newValue) { if (name === "presented") { const rootElement = this.rootElement; if (rootElement) { rootElement.setAttribute("presented", newValue !== undefined && newValue !== null); } else { // The CommandBar custom element hasn't been connected to a document yet. this.#shouldPresentAfterConnection = true; } } } // MARK: Event Listeners handleInputEvent(event) { const target = event.target; if (!target.value.length) { this.hide(); } } handleInputKeydownEvent(event) { const target = event.target; switch (event.key) { case "Escape": { event.preventDefault(); target.value = ""; this.hide(); break; } case "Enter": { event.preventDefault(); // Trim the leader let command = target.value.substring(1); if (command.length) { this.executeCommand(command); } target.value = this.#leader; break; } case "ArrowUp": { event.preventDefault(); let historyIndex = this.#indexOfLastShownCommandFromHistory; if (historyIndex === null) { historyIndex = this.#commandExecutor.historyLength - 1; } else { historyIndex--; } if (historyIndex >= 0) { const commandFromHistory = this.#commandExecutor.commandAtHistoryIndex(historyIndex); target.value = this.#leader + commandFromHistory.originalString; this.#indexOfLastShownCommandFromHistory = historyIndex; } break; } case "ArrowDown": { event.preventDefault(); const historyLength = this.#commandExecutor.historyLength; let historyIndex = this.#indexOfLastShownCommandFromHistory; if (historyIndex === null) { historyIndex = historyLength - 1; } historyIndex++; if (historyIndex < historyLength) { const commandFromHistory = this.#commandExecutor.commandAtHistoryIndex(historyIndex); target.value = this.#leader + commandFromHistory.originalString; this.#indexOfLastShownCommandFromHistory = historyIndex; } else { target.value = this.#leader; this.#indexOfLastShownCommandFromHistory = null; } break; } } } handleDocumentKeypressEvent(event) { if (event.repeat) { return; } if (event.key == "Escape") { this.hide(); } const currentTarget = event.currentTarget; if (currentTarget === this.inputElement) { return; } switch (event.key) { case this.#leader: this.show(); break; case "Escape": this.hide(); break; default: break; } } } class CommandExecutor { #history = []; handlers = new Map(); addHandler(handler) { this.handlers.set(handler.name, handler); } executeCommand(command) { this.appendCommandToHistory(command); const handler = this.#handlerForCommand(command); if (!handler) { throw new Error(`No handler for '${command.verb.valueOf()}' command`); } let result; try { result = handler.handler(command); } catch (e) { result = null; } if (result !== undefined && !result) { console.error(`Command failed: ${command}`); } } #handlerForCommand(command) { const verb = command.verb.valueOf(); return this.handlers.get(verb); } // MARK: History get historyLength() { return this.#history.length; } appendCommandToHistory(command) { this.#history.push(command); } commandAtHistoryIndex(index) { return this.#history[index]; } } class ParseCommandError extends SyntaxError { } class Parameter { name; value; constructor(name, value) { if (!name instanceof NameTerm) { throw new TypeError("Parameter name must be a NameTerm"); } this.name = name; this.value = value; } toString() { return this.value ? `${this.name}=${this.value}` : `${this.name}`; } valueOf() { if (this.value !== undefined) { return this.value.valueOf(); } else { return this.name; } } } class Term { stringValue; constructor(stringValue) { this.stringValue = stringValue; } toString() { return `Term[${stringValue}]`; } valueOf() { return this.stringValue; } } class NumberTerm extends Term { value; constructor(stringValue) { super(stringValue); const parsedValue = parseInt(stringValue); if (isNaN(parsedValue)) { throw new TypeError("Number value is not a valid number"); } this.value = parsedValue; } valueOf() { return this.value; } toString() { return `Number[${this.value}]`; } } class NameTerm extends Term { static regex = /[-\w]+/; constructor(stringValue) { if (!NameTerm.regex.test(stringValue)) { throw new TypeError("Name must be string with non-zero length"); } super(stringValue); } toString() { return `Name[${this.stringValue}]`; } } class Command { static stringRegex = /'[^'"]*'|"[^'"]*"/; static whitespaceRegex = /\s+/; static parse(commandString) { const terms = commandString.split(Command.whitespaceRegex).filter(s => !!s); if (terms.length < 1) { return null; } let verb; try { verb = new NameTerm(terms[0]); } catch { throw new ParseCommandError(`Invalid verb: ${verb}`); } const parameters = terms.slice(1).map(t => { const [nameString, valueString, ...rest] = t.split("="); if (rest.length > 0) { throw new ParseCommandError(`Invalid parameter: ${t}`); } let name; try { name = new NameTerm(nameString); } catch { throw new ParseCommandError(`Invalid parameter name: ${t}`); } let value; if (valueString) { for (const termClass of [NameTerm, NumberTerm]) { try { value = new termClass(valueString); break; } catch { continue; } } if (!value) { throw new ParseCommandError(`Invalid parameter value: ${t}`); } } return new Parameter(name, value); }); return new Command(commandString, verb, parameters); } originalString; verb; parameters = [] constructor(originalString, verb, parameters) { if (!verb instanceof String) { throw new TypeError("Command verb must be a string"); } this.originalString = originalString; this.verb = verb; this.parameters = parameters; } parameter(nameOrIndex) { if (typeof nameOrIndex === "string" || nameOrIndex instanceof String) { return this.parameters.find(p => p.name == nameOrIndex); } else if (nameOrIndex instanceof Number) { return this.parameters[nameOrIndex].valueOf(); } return null; } toString() { return `Command: {verb: "${this.verb.toString()}", parameters: [${this.parameters.join(", ")}] }`; } } window.customElements.define("command-bar", CommandBar);