478 lines
13 KiB
JavaScript
478 lines
13 KiB
JavaScript
|
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);
|