diff --git a/assets/js/command_bar.js b/assets/js/command_bar.js new file mode 100644 index 0000000..fc37e3f --- /dev/null +++ b/assets/js/command_bar.js @@ -0,0 +1,477 @@ +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); diff --git a/layouts/partials/template.html b/layouts/partials/template.html new file mode 100644 index 0000000..d79af36 --- /dev/null +++ b/layouts/partials/template.html @@ -0,0 +1,60 @@ +