import { Command } from "./command.js" import { CommandExecutor } from "./commandExecutor.js"; class CommandBar extends HTMLElement { static observedAttributes = [ "presented" ]; #shadowRoot; rootElement; inputElement; rulerElement; scrollPositionElement; #leader = ":"; #commandExecutor; #indexOfLastShownCommandFromHistory = null; #shouldPresentAfterConnection = false; constructor() { super(); this.#commandExecutor = new CommandExecutor(this); this.#shadowRoot = this.attachShadow({ mode: "open" }); } 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; } } } window.customElements.define("command-bar", CommandBar);