Import existing code

Move the <template> to a template.html partial.
Bring the JS to the assets directory.
This commit is contained in:
Eryn Wells 2024-07-28 09:35:30 -07:00
parent 42b4de6b4c
commit 7c88822645
2 changed files with 537 additions and 0 deletions

477
assets/js/command_bar.js Normal file
View file

@ -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);

View file

@ -0,0 +1,60 @@
<template id="command-bar-template">
<div class="command-bar">
<div class="command-bar__container">
<div class="command-bar__status">
<span class="command-bar__buffer">1:commandbar</span>
<span class="command-bar__ruler"></span>
<span class="command-bar__scrollposition"></span>
</div>
<input class="command-bar__input" name="command-bar__command">
</div>
</div>
<style>
.command-bar {
--secondary: #aaa;
position: fixed;
bottom: -8vh;
width: calc(100% - 18px);
transition: bottom 250ms ease-in-out;
font-family: monospace;
line-height: 1.2;
&[presented] {
bottom: 0vh;
}
.command-bar__container {
background-color: white;
border: 1px solid var(--secondary);
}
.command-bar__status {
background-color: var(--secondary);
color: white;
display: grid;
grid-template-columns: 3fr 1fr 1fr;
margin: 0;
padding: 0;
width: 100%;
.command-bar__scrollposition {
justify-self: flex-end;
}
}
.command-bar__input {
border: none;
display: block;
font-family: monospace;
font-size: 18px;
margin: 0;
outline: none;
outline-offset: 0;
padding: 0;
width: 100%;
}
}
</style>
</template>