add a vim-like command line under :

This commit is contained in:
liquidex 2024-12-08 12:45:29 +01:00
parent 0ce7f50285
commit 9cac6c3c3e
9 changed files with 332 additions and 69 deletions

View file

@ -0,0 +1,20 @@
%% title = "command line"
% id = "01JEK4XKK26T6W603FTPQHQ7C8"
- press `<kbd>:</kbd>`{=html} to open the command line.
% id = "01JEK4XKK27KXP01EK8K890SPK"
- type in your command, then press `<kbd>Enter</kbd>`{=html} to run it.
- `<kbd>Esc</kbd>`{=html} closes the command line.
- `<kbd>Tab</kbd>`{=html} cycles through suggestions.
- you may also use the mouse to close the command line or pick a suggestion from the list.
% id = "01JEK4XKK2EDTVCNZQRV9XDZXJ"
- unknown commands do not do anything.
known commands usually result in immediate feedback.
% id = "01JEK4XKK2S4W0TPT4JY8AH143"
- the command line is currently not accessible on mobile devices.

View file

@ -1,5 +1,5 @@
%% title = "developer tools" %% title = "developer tools"
styles = ["page/treehouse/dev/tools.css"] styles = ["dev.css"]
scripts = ["treehouse/dev/picture-upload.js"] scripts = ["treehouse/dev/picture-upload.js"]
% id = "01JEHDJSJP282VCTRKYHNFM4N7" % id = "01JEHDJSJP282VCTRKYHNFM4N7"

View file

@ -1,18 +1,14 @@
/* Styles for developer tools.
This stylesheet MUST NOT be used for modifying the appearance of elements globally.
If you notice that it is for whatever reason, please bonk liquidex on the head. */
th-picture-upload { th-picture-upload {
display: block; display: block;
border: 1px solid var(--border-1);
padding: 8px 12px;
margin-right: 8px;
border-radius: 8px;
cursor: default; cursor: default;
&:focus {
border-color: var(--liquidex-brand-blue);
}
& > .nothing-pasted { & > .nothing-pasted {
border: 1px solid var(--border-1);
text-align: center; text-align: center;
opacity: 50%; opacity: 50%;
padding: 16px; padding: 16px;

View file

@ -108,6 +108,11 @@ body {
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
:focus-visible {
outline: 1px solid var(--liquidex-brand-blue);
outline-offset: 2px;
}
/* Set up typography */ /* Set up typography */
@font-face { @font-face {
@ -131,8 +136,11 @@ pre,
code, code,
kbd, kbd,
button, button,
select { select,
input,
dfn {
font-family: "RecVar", sans-serif; font-family: "RecVar", sans-serif;
font-style: normal;
line-height: 1.5; line-height: 1.5;
} }
@ -144,8 +152,9 @@ pre,
code, code,
kbd, kbd,
button, button,
select { select,
font-size: 100%; input {
font-size: inherit;
} }
:root { :root {
@ -499,28 +508,7 @@ h1.page-title {
} }
} }
/* Style the `new` link on the homepage */ /* Style badges */
a[data-cast~="new"] {
flex-shrink: 0;
color: var(--text-color);
opacity: 50%;
&.has-news {
opacity: 100%;
text-decoration: none;
& .new-text {
text-decoration: underline;
}
}
& .badge {
margin-left: var(--8px);
text-decoration: none;
}
}
/* Style new badges */
span.badge { span.badge {
--recursive-wght: 800; --recursive-wght: 800;
--recursive-mono: 1; --recursive-mono: 1;
@ -641,6 +629,20 @@ footer {
} }
} }
/* Style dialogues */
dialog[open] {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: var(--text-color);
background-color: var(--background-color);
border: 1px solid var(--border-1);
border-radius: 12px;
}
/* Style emojis to be readable */ /* Style emojis to be readable */
img[data-cast~="emoji"] { img[data-cast~="emoji"] {
@ -702,35 +704,78 @@ th-emoji-tooltip p {
cursor: help; cursor: help;
} }
/* Funny joke */ /* Command line */
@keyframes hello-there { th-command-line {
0% { --recursive-mono: 1;
opacity: 0%; --recursive-casl: 0;
}
70% {
opacity: 0%;
}
100% {
opacity: 70%;
}
}
.oops-you-seem-to-have-gotten-stuck {
margin-top: 16px;
display: none; display: none;
position: absolute; flex-direction: column;
opacity: 0%;
background-color: var(--background-color-tooltip);
font-size: 87.5%;
&.visible {
display: flex;
position: fixed;
left: 0;
bottom: 0;
width: 100%;
} }
#index\:treehouse & > .input-wrapper {
> details:not([open]) display: flex;
> summary flex-direction: row;
.oops-you-seem-to-have-gotten-stuck {
display: inline; padding: 2px 4px;
animation: 4s hello-there forwards; width: 100%;
&::before {
content: ":";
padding-right: 2px;
opacity: 50%;
}
& > input {
background: none;
color: var(--text-color);
border: none;
flex-grow: 1;
&:focus {
outline: none;
}
}
}
& > ul.suggestions {
list-style: none;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
& > li {
padding: 2px 8px;
cursor: default;
& > dfn {
--recursive-crsv: 0;
--recursive-wght: 700;
margin-right: 2ch;
}
&:hover,
&.tabbed {
background-color: var(--liquidex-brand-blue);
color: white;
}
}
}
} }
/* Literate programming support */ /* Literate programming support */

174
static/js/command-line.js Normal file
View file

@ -0,0 +1,174 @@
export class CommandLine extends HTMLElement {
static commands = new Map();
constructor() {
super();
}
connectedCallback() {
this.suggestions = this.appendChild(document.createElement("ul"));
this.suggestions.classList.add("suggestions");
let inputWrapper = this.appendChild(document.createElement("div"));
inputWrapper.classList.add("input-wrapper");
this.input = inputWrapper.appendChild(document.createElement("input"));
this.input.type = "text";
window.addEventListener("keydown", (event) => {
if (event.key == ":" && !this.visible) {
event.stopPropagation();
event.preventDefault();
this.show();
}
if (event.key == "Escape") {
this.hide();
}
});
window.addEventListener("click", () => {
this.hide();
});
this.addEventListener("click", (event) => {
event.stopPropagation();
});
this.input.addEventListener("keydown", (event) => {
if (event.key == "Enter") {
event.preventDefault();
this.hide();
this.runCommand(this.input.value);
}
if (event.key == "Tab") {
event.preventDefault();
if (event.shiftKey) this.tabToPreviousSuggestion();
else this.tabToNextSuggestion();
}
});
this.input.addEventListener("input", () => {
this.updateSuggestions();
});
}
get visible() {
return this.classList.contains("visible");
}
show() {
this.classList.add("visible");
this.input.focus();
this.input.value = "";
this.updateSuggestions();
}
hide() {
this.classList.remove("visible");
}
tab(current, next) {
current?.classList?.remove("tabbed");
next.classList.add("tabbed");
this.input.value = next.name;
// NOTE: Do NOT update suggestions here.
// This would cause the tabbing to break.
}
tabToNextSuggestion() {
let current = this.suggestions.querySelector(".tabbed");
let next = current?.nextSibling ?? this.suggestions.childNodes[0];
this.tab(current, next);
}
tabToPreviousSuggestion() {
let current = this.suggestions.querySelector(".tabbed");
let previous =
current?.previousSibling ??
this.suggestions.childNodes[this.suggestions.childNodes.length - 1];
this.tab(current, previous);
}
updateSuggestions() {
let search = parseCommand(this.input.value)?.command ?? "";
let suggestions = Array.from(CommandLine.commands.entries()).filter(
([name, def]) => !def.isAlias && fuzzyMatch(search, name),
);
suggestions.sort();
this.suggestions.replaceChildren();
for (let [name, def] of suggestions) {
let suggestion = this.suggestions.appendChild(document.createElement("li"));
let commandName = suggestion.appendChild(document.createElement("dfn"));
commandName.textContent = name;
let commandDescription = suggestion.appendChild(document.createElement("span"));
commandDescription.classList.add("description");
commandDescription.textContent = def.description;
suggestion.name = name;
suggestion.def = def;
suggestion.addEventListener("click", () => {
this.input.value = name;
this.updateSuggestions();
this.input.focus();
});
}
}
runCommand(commandLine) {
let { command, args } = parseCommand(commandLine);
let commandDef = CommandLine.commands.get(command);
if (CommandLine.commands.has(command)) {
commandDef.run(args);
} else {
console.log(`unknown command`);
}
}
static registerCommand({ aliases, description, run }) {
for (let i = 0; i < aliases.length; ++i) {
CommandLine.commands.set(aliases[i], {
isAlias: i != 0,
description,
run,
});
}
}
}
customElements.define("th-command-line", CommandLine);
function parseCommand(commandLine) {
let result = /^([^ ]+) *(.*)$/.exec(commandLine);
if (result == null) return null;
let [_, command, args] = result;
return { command, args };
}
// https://www.forrestthewoods.com/blog/reverse_engineering_sublime_texts_fuzzy_match/
function fuzzyMatch(pattern, string) {
let iPattern = 0;
let iString = 0;
while (iPattern < pattern.length && iString < string.length) {
if (pattern.charAt(iPattern).toLowerCase() == string.charAt(iString).toLowerCase()) {
iPattern += 1;
}
iString += 1;
}
return iPattern == pattern.length;
}
CommandLine.registerCommand({
aliases: ["help", "h"],
description: '"OwO, what is this?"',
run() {
window.location = `${TREEHOUSE_SITE}/treehouse/cmd`;
},
});

View file

@ -1,10 +1,11 @@
import { CommandLine } from "treehouse/command-line.js";
class PictureUpload extends HTMLElement { class PictureUpload extends HTMLElement {
constructor() { constructor() {
super(); super();
} }
connectedCallback() { connectedCallback() {
this.tabIndex = 0;
this.gotoInit(); this.gotoInit();
this.preview = this.querySelector("img[name='preview']"); this.preview = this.querySelector("img[name='preview']");
@ -30,16 +31,17 @@ class PictureUpload extends HTMLElement {
gotoInit() { gotoInit() {
this.setState("init"); this.setState("init");
this.innerHTML = ` this.innerHTML = `
<div class="nothing-pasted"> <div class="nothing-pasted" tabindex="0">
paste or drop an image here to make a picture out of it paste or drop an image here to make a picture out of it
</div> </div>
`; `;
this.querySelector(".nothing-pasted").focus();
} }
async gotoHavePicture(imageType, imageFile) { async gotoHavePicture(imageType, imageFile) {
this.setState("have-picture"); this.setState("have-picture");
this.innerHTML = ` this.innerHTML = `
<div class="have-picture"> <form name="upload" class="have-picture">
<img name="preview" class="pic" alt="preview"> <img name="preview" class="pic" alt="preview">
<p> <p>
<span name="preview-width"></span> × <span name="preview-height"></span> px (<span name="file-size"></span>) <span name="preview-width"></span> × <span name="preview-height"></span> px (<span name="file-size"></span>)
@ -57,19 +59,20 @@ class PictureUpload extends HTMLElement {
</select> </select>
</p> </p>
<button name="upload">upload</button> <button type="submit" name="upload">upload</button>
</div> </div>
`; `;
let uploadForm = this.querySelector("form[name='upload']");
let preview = this.querySelector("img[name='preview']"); let preview = this.querySelector("img[name='preview']");
let previewWidth = this.querySelector("[name='preview-width']"); let previewWidth = this.querySelector("[name='preview-width']");
let previewHeight = this.querySelector("[name='preview-height']"); let previewHeight = this.querySelector("[name='preview-height']");
let fileSize = this.querySelector("[name='file-size']"); let fileSize = this.querySelector("[name='file-size']");
let label = this.querySelector("[name='label']"); let label = this.querySelector("[name='label']");
let compression = this.querySelector("[name='compression']"); let compression = this.querySelector("[name='compression']");
let upload = this.querySelector("button[name='upload']");
fileSize.textContent = formatSizeSI(imageFile.size); fileSize.textContent = formatSizeSI(imageFile.size);
label.focus();
let url = URL.createObjectURL(imageFile); let url = URL.createObjectURL(imageFile);
preview.src = url; preview.src = url;
@ -80,7 +83,9 @@ class PictureUpload extends HTMLElement {
previewHeight.textContent = bitmap.height.toString(); previewHeight.textContent = bitmap.height.toString();
}); });
upload.addEventListener("click", async () => { uploadForm.addEventListener("submit", async (event) => {
event.preventDefault();
let params = new URLSearchParams({ let params = new URLSearchParams({
label: label.value, label: label.value,
format: imageType, format: imageType,
@ -147,3 +152,21 @@ function formatSizeSI(bytes) {
unitDisplay: "narrow", unitDisplay: "narrow",
}).format(bytes); }).format(bytes);
} }
if (TREEHOUSE_DEV) {
CommandLine.registerCommand({
aliases: ["addpic"],
description: "add a picture interactively and copy its ulid",
run() {
let dialog = document.body.appendChild(document.createElement("dialog"));
dialog.addEventListener("keydown", (event) => {
if (event.key == "Escape") dialog.close();
});
dialog.addEventListener("close", () => {
dialog.remove();
});
dialog.appendChild(new PictureUpload());
dialog.show();
},
});
}

View file

@ -28,6 +28,7 @@
{{/if}} {{/if}}
<th-emoji-tooltips></th-emoji-tooltips> <th-emoji-tooltips></th-emoji-tooltips>
<th-command-line></th-command-line>
</body> </body>
</html> </html>

View file

@ -19,28 +19,32 @@ clever to do while browser vendors figure that out, we'll just have to do a cach
{{#if dev}} {{#if dev}}
<script type="module"> <script type="module">
import "treehouse/live-reload.js"; import "treehouse/dev/live-reload.js";
import "treehouse/dev/picture-upload.js";
</script> </script>
<link rel="stylesheet" href="{{ asset 'css/dev.css' }}">
{{/if}} {{/if}}
<script> <script>
const TREEHOUSE_DEV = {{ dev }};
const TREEHOUSE_SITE = `{{ config.site }}`; const TREEHOUSE_SITE = `{{ config.site }}`;
{{!-- Yeah, this should probably be solved in a better way somehow. {{!-- Yeah, this should probably be solved in a better way somehow.
For now this is used to allow literate-programming.js to refer to syntax files with the ?cache attribute, For now this is used to allow literate-programming.js to refer to syntax files with the ?v attribute,
so that they don't need to be redownloaded every single time. --}} so that they don't need to be redownloaded every single time. --}}
const TREEHOUSE_SYNTAX_URLS = { const TREEHOUSE_SYNTAX_URLS = {
javascript: `{{{ asset 'syntax/javascript.json' }}}`, javascript: `{{{ asset 'syntax/javascript.json' }}}`,
haku: `{{{ asset 'syntax/haku.json' }}}`, haku: `{{{ asset 'syntax/haku.json' }}}`,
}; };
</script> </script>
<script type="module"> <script type="module" async>
import "treehouse/spells.js"; import "treehouse/spells.js";
import "treehouse/ulid.js"; import "treehouse/ulid.js";
import "treehouse/usability.js"; import "treehouse/usability.js";
import "treehouse/settings.js"; import "treehouse/settings.js";
import "treehouse/tree.js"; import "treehouse/tree.js";
import "treehouse/emoji.js"; import "treehouse/emoji.js";
import "treehouse/command-line.js";
</script> </script>
<meta property="og:site_name" content="{{ config.user.title }}"> <meta property="og:site_name" content="{{ config.user.title }}">