1131 lines
29 KiB
JavaScript
1131 lines
29 KiB
JavaScript
////
|
|
// version: 30ae804
|
|
|
|
// src/js/dom.ts
|
|
var content2 = $("content");
|
|
var cmdLine = $("command-line");
|
|
var cmdInput = $("command-textbox");
|
|
var scrollback = $("scrollback");
|
|
function $(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
var $$ = (tag, innerHTML = "") => {
|
|
let name = tag;
|
|
let classList = [];
|
|
if (tag.startsWith("."))
|
|
tag = "div" + tag;
|
|
if (tag.includes("."))
|
|
[name, ...classList] = tag.split(".").map((x) => x.trim());
|
|
const el = document.createElement(name);
|
|
if (innerHTML)
|
|
el.innerHTML = innerHTML;
|
|
if (classList.length)
|
|
el.classList.add(...classList);
|
|
return el;
|
|
};
|
|
|
|
// src/js/resize.ts
|
|
var content3 = document.getElementById("content");
|
|
function initResize() {
|
|
window.addEventListener("resize", resize);
|
|
resize();
|
|
}
|
|
function resize() {
|
|
if (document.body.dataset.mode === "tall") {
|
|
resizeTall();
|
|
} else {
|
|
resizeCinema();
|
|
}
|
|
}
|
|
function resizeTall() {
|
|
const scale = Math.min(1, window.innerWidth / 960);
|
|
content3.style.transformOrigin = "top center";
|
|
content3.style.transform = `scaleX(${scale})`;
|
|
}
|
|
function resizeCinema() {
|
|
const scale = Math.min(window.innerWidth / 960, window.innerHeight / 540);
|
|
content3.style.transformOrigin = "center center";
|
|
content3.style.transform = `scale(${scale})`;
|
|
}
|
|
|
|
// src/shared/utils.ts
|
|
function randomId() {
|
|
return Math.random().toString(36).slice(7);
|
|
}
|
|
|
|
// src/js/scrollback.ts
|
|
function initScrollback() {
|
|
window.addEventListener("click", handleInputClick);
|
|
}
|
|
function autoScroll() {}
|
|
function insert(node) {
|
|
scrollback.append(node);
|
|
}
|
|
function addInput(id, input) {
|
|
const parent = $$("li.input");
|
|
const status = $$("span.status.yellow", "•");
|
|
const content4 = $$("span.content", input);
|
|
parent.append(status, content4);
|
|
parent.dataset.id = id;
|
|
insert(parent);
|
|
scrollback.scrollTop = scrollback.scrollHeight - scrollback.clientHeight;
|
|
}
|
|
function setStatus(id, status) {
|
|
const statusEl = document.querySelector(`[data-id="${id}"].input .status`);
|
|
if (!statusEl)
|
|
return;
|
|
const colors = {
|
|
waiting: "yellow",
|
|
streaming: "purple",
|
|
ok: "green",
|
|
error: "red"
|
|
};
|
|
statusEl.classList.remove(...Object.values(colors));
|
|
statusEl.classList.add(colors[status]);
|
|
}
|
|
function addOutput(id, output2) {
|
|
const item = $$("li");
|
|
item.classList.add("output");
|
|
item.dataset.id = id || randomId();
|
|
const [format, content4] = processOutput(output2);
|
|
if (format === "html")
|
|
item.innerHTML = content4;
|
|
else
|
|
item.textContent = content4;
|
|
const input = document.querySelector(`[data-id="${id}"].input`);
|
|
if (input instanceof HTMLLIElement) {
|
|
input.parentNode.insertBefore(item, input.nextSibling);
|
|
} else {
|
|
insert(item);
|
|
}
|
|
autoScroll();
|
|
}
|
|
function addErrorMessage(message) {
|
|
addOutput("", { html: `<span class="red">${message}</span>` });
|
|
}
|
|
function appendOutput(id, output2) {
|
|
const item = document.querySelector(`[data-id="${id}"].output`);
|
|
if (!item) {
|
|
console.error(`output id ${id} not found`);
|
|
return;
|
|
}
|
|
const [format, content4] = processOutput(output2);
|
|
if (format === "html")
|
|
item.innerHTML += content4;
|
|
else
|
|
item.textContent += content4;
|
|
autoScroll();
|
|
}
|
|
function replaceOutput(id, output2) {
|
|
const item = document.querySelector(`[data-id="${id}"].output`);
|
|
if (!item) {
|
|
console.error(`output id ${id} not found`);
|
|
return;
|
|
}
|
|
const [format, content4] = processOutput(output2);
|
|
if (format === "html")
|
|
item.innerHTML = content4;
|
|
else
|
|
item.textContent = content4;
|
|
autoScroll();
|
|
}
|
|
function processOutput(output) {
|
|
let content = "";
|
|
let html = false;
|
|
if (typeof output === "string") {
|
|
content = output;
|
|
} else if (Array.isArray(output)) {
|
|
content = output.join(" ");
|
|
} else if (typeof output === "object" && "html" in output) {
|
|
html = true;
|
|
content = output.html;
|
|
if (output.script)
|
|
eval(output.script);
|
|
} else if (typeof output === "object" && "text" in output) {
|
|
content = output.text;
|
|
if (output.script)
|
|
eval(output.script);
|
|
} else if (typeof output === "object" && "script" in output) {
|
|
eval(output.script);
|
|
} else {
|
|
content = JSON.stringify(output);
|
|
}
|
|
return [html ? "html" : "text", content];
|
|
}
|
|
function handleInputClick(e) {
|
|
const target = e.target;
|
|
if (!(target instanceof HTMLElement))
|
|
return;
|
|
if (target.matches(".input .content")) {
|
|
cmdInput.value = target.textContent;
|
|
}
|
|
}
|
|
function handleOutput(msg) {
|
|
const result = msg.data;
|
|
setStatus(msg.id, result.status);
|
|
addOutput(msg.id, result.output);
|
|
}
|
|
|
|
// src/js/session.ts
|
|
var sessionId = randomId();
|
|
|
|
// src/js/stream.ts
|
|
function handleStreamStart(msg) {
|
|
const id = msg.id;
|
|
const status = document.querySelector(`[data-id="${id}"].input .status`);
|
|
if (!status)
|
|
return;
|
|
addOutput(id, msg.data);
|
|
status.classList.remove("yellow");
|
|
status.classList.add("purple");
|
|
}
|
|
function handleStreamAppend(msg) {
|
|
appendOutput(msg.id, msg.data);
|
|
}
|
|
function handleStreamReplace(msg) {
|
|
replaceOutput(msg.id, msg.data);
|
|
}
|
|
function handleStreamEnd(_msg) {}
|
|
|
|
// src/shared/game.ts
|
|
class GameContext {
|
|
ctx;
|
|
constructor(ctx) {
|
|
this.ctx = ctx;
|
|
}
|
|
width = 960;
|
|
height = 540;
|
|
clear(color) {
|
|
if (color)
|
|
this.rectfill(0, 0, this.width, this.height, color);
|
|
else
|
|
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
|
|
}
|
|
text(msg, x, y, color = "black", size = 16, font = "C64ProMono") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.fillStyle = color;
|
|
c.font = `${size}px ${font}`;
|
|
c.textBaseline = "top";
|
|
c.fillText(msg, x, y);
|
|
c.restore();
|
|
}
|
|
centerText(msg, color = "black", size = 16, font = "C64ProMono") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.fillStyle = color;
|
|
c.font = `${size}px ${font}`;
|
|
c.textBaseline = "middle";
|
|
c.textAlign = "center";
|
|
c.fillText(msg, this.width / 2, this.height / 2);
|
|
c.restore();
|
|
}
|
|
centerTextX(msg, y, color = "black", size = 16, font = "C64ProMono") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.fillStyle = color;
|
|
c.font = `${size}px ${font}`;
|
|
c.textBaseline = "middle";
|
|
c.textAlign = "center";
|
|
c.fillText(msg, this.width / 2, y);
|
|
c.restore();
|
|
}
|
|
centerTextY(msg, x, color = "black", size = 16, font = "C64ProMono") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.fillStyle = color;
|
|
c.font = `${size}px ${font}`;
|
|
c.textBaseline = "middle";
|
|
c.textAlign = "center";
|
|
c.fillText(msg, x, this.height / 2);
|
|
c.restore();
|
|
}
|
|
circ(x, y, r, color = "black") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.beginPath();
|
|
c.strokeStyle = color;
|
|
c.arc(x, y, r, 0, Math.PI * 2);
|
|
c.stroke();
|
|
c.restore();
|
|
}
|
|
circfill(x, y, r, color = "black") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.beginPath();
|
|
c.fillStyle = color;
|
|
c.arc(x, y, r, 0, Math.PI * 2);
|
|
c.fill();
|
|
c.restore();
|
|
}
|
|
line(x0, y0, x1, y1, color = "black") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.beginPath();
|
|
c.strokeStyle = color;
|
|
c.moveTo(x0, y0);
|
|
c.lineTo(x1, y1);
|
|
c.stroke();
|
|
c.restore();
|
|
}
|
|
oval(x0, y0, x1, y1, color = "black") {
|
|
const c = this.ctx;
|
|
const w = x1 - x0;
|
|
const h = y1 - y0;
|
|
c.save();
|
|
c.beginPath();
|
|
c.strokeStyle = color;
|
|
c.ellipse(x0 + w / 2, y0 + h / 2, Math.abs(w) / 2, Math.abs(h) / 2, 0, 0, Math.PI * 2);
|
|
c.stroke();
|
|
c.restore();
|
|
}
|
|
ovalfill(x0, y0, x1, y1, color = "black") {
|
|
const c = this.ctx;
|
|
const w = x1 - x0;
|
|
const h = y1 - y0;
|
|
c.save();
|
|
c.beginPath();
|
|
c.fillStyle = color;
|
|
c.ellipse(x0 + w / 2, y0 + h / 2, Math.abs(w) / 2, Math.abs(h) / 2, 0, 0, Math.PI * 2);
|
|
c.fill();
|
|
c.restore();
|
|
}
|
|
rect(x0, y0, x1, y1, color = "black") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.beginPath();
|
|
c.strokeStyle = color;
|
|
c.rect(x0, y0, x1 - x0, y1 - y0);
|
|
c.stroke();
|
|
c.restore();
|
|
}
|
|
rectfill(x0, y0, x1, y1, color = "black") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
this.ctx.fillStyle = color;
|
|
this.ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
|
|
c.restore();
|
|
}
|
|
rrect(x, y, w, h, r, color = "black") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.beginPath();
|
|
c.strokeStyle = color;
|
|
this.roundRectPath(x, y, w, h, r);
|
|
c.stroke();
|
|
c.restore();
|
|
}
|
|
rrectfill(x, y, w, h, r, color = "black") {
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.beginPath();
|
|
c.fillStyle = color;
|
|
this.roundRectPath(x, y, w, h, r);
|
|
c.fill();
|
|
c.restore();
|
|
}
|
|
trianglefill(x0, y0, x1, y1, x2, y2, color = "black") {
|
|
return this.polygonfill([
|
|
[x0, y0],
|
|
[x1, y1],
|
|
[x2, y2]
|
|
], color);
|
|
}
|
|
polygonfill(points, color = "black") {
|
|
if (points.length < 3)
|
|
return;
|
|
const c = this.ctx;
|
|
c.save();
|
|
c.beginPath();
|
|
c.fillStyle = color;
|
|
c.moveTo(points[0][0], points[0][1]);
|
|
for (let i = 1;i < points.length; i++) {
|
|
c.lineTo(points[i][0], points[i][1]);
|
|
}
|
|
c.closePath();
|
|
c.fill();
|
|
c.restore();
|
|
}
|
|
roundRectPath(x, y, w, h, r) {
|
|
const c = this.ctx;
|
|
c.moveTo(x + r, y);
|
|
c.lineTo(x + w - r, y);
|
|
c.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
c.lineTo(x + w, y + h - r);
|
|
c.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
c.lineTo(x + r, y + h);
|
|
c.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
c.lineTo(x, y + r);
|
|
c.quadraticCurveTo(x, y, x + r, y);
|
|
}
|
|
}
|
|
|
|
// src/js/focus.ts
|
|
function initFocus() {
|
|
window.addEventListener("click", focusHandler);
|
|
focusInput();
|
|
}
|
|
function focusInput() {
|
|
cmdInput.focus();
|
|
}
|
|
function focusHandler(e) {
|
|
const target = e.target;
|
|
if (!(target instanceof HTMLElement)) {
|
|
focusInput();
|
|
return;
|
|
}
|
|
if (["INPUT", "TEXTAREA", "CANVAS", "A"].includes(target.tagName))
|
|
return false;
|
|
const selection = window.getSelection() || "";
|
|
if (selection.toString() === "")
|
|
focusInput();
|
|
e.preventDefault();
|
|
}
|
|
|
|
// src/js/game.ts
|
|
var FPS = 30;
|
|
var HEIGHT = 540;
|
|
var WIDTH = 980;
|
|
var oldMode = "cinema";
|
|
var running = false;
|
|
var canvas;
|
|
var pressedStack = new Set;
|
|
var pressed = {
|
|
key: "",
|
|
shift: false,
|
|
ctrl: false,
|
|
meta: false,
|
|
pressed: pressedStack,
|
|
prevPressed: new Set,
|
|
justPressed: new Set,
|
|
justReleased: new Set
|
|
};
|
|
async function handleGameStart(msg) {
|
|
const msgId = msg.id;
|
|
const name = msg.data;
|
|
let game;
|
|
try {
|
|
game = await import(`/source/${name}`);
|
|
} catch (err) {
|
|
setStatus(msgId, "error");
|
|
addOutput(msgId, `Error: ${err.message ? err.message : err}`);
|
|
return;
|
|
}
|
|
if (document.body.dataset.mode === "tall") {
|
|
browserCommands.mode?.();
|
|
oldMode = "tall";
|
|
}
|
|
canvas = createCanvas();
|
|
canvas.focus();
|
|
setStatus(msgId, "ok");
|
|
window.addEventListener("keydown", handleKeydown);
|
|
window.addEventListener("keyup", handleKeyup);
|
|
window.addEventListener("resize", resizeCanvas);
|
|
resizeCanvas();
|
|
gameLoop(new GameContext(canvas.getContext("2d")), game);
|
|
}
|
|
function createCanvas() {
|
|
const canvas2 = $$("canvas.game.active");
|
|
canvas2.id = randomId();
|
|
canvas2.height = HEIGHT;
|
|
canvas2.width = WIDTH;
|
|
canvas2.tabIndex = 0;
|
|
const main = document.querySelector("main");
|
|
main?.classList.add("game");
|
|
main?.parentNode?.insertBefore(canvas2, main);
|
|
return canvas2;
|
|
}
|
|
function handleKeydown(e) {
|
|
e.preventDefault();
|
|
if (e.key === "Escape" || e.ctrlKey && e.key === "c") {
|
|
endGame();
|
|
} else {
|
|
pressedStack.add(e.key);
|
|
pressed.key = e.key;
|
|
pressed.ctrl = e.ctrlKey;
|
|
pressed.shift = e.shiftKey;
|
|
pressed.meta = e.metaKey;
|
|
}
|
|
}
|
|
function handleKeyup(e) {
|
|
pressedStack.delete(e.key);
|
|
if (pressedStack.size === 0) {
|
|
pressed.key = "";
|
|
pressed.ctrl = false;
|
|
pressed.shift = false;
|
|
pressed.meta = false;
|
|
}
|
|
}
|
|
function updateInputState() {
|
|
pressed.justPressed = new Set([...pressed.pressed].filter((k) => !pressed.prevPressed.has(k)));
|
|
pressed.justReleased = new Set([...pressed.prevPressed].filter((k) => !pressed.pressed.has(k)));
|
|
pressed.prevPressed = new Set(pressed.pressed);
|
|
}
|
|
function resizeCanvas() {
|
|
const scale = Math.min(window.innerWidth / 960, window.innerHeight / 540);
|
|
canvas.width = 960 * scale;
|
|
canvas.height = 540 * scale;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
}
|
|
function gameLoop(ctx, game) {
|
|
running = true;
|
|
let last = 0;
|
|
if (game.init)
|
|
game.init();
|
|
function loop(ts) {
|
|
if (!running)
|
|
return;
|
|
const delta = ts - last;
|
|
if (delta >= 1000 / FPS) {
|
|
updateInputState();
|
|
if (game.update)
|
|
game.update(delta, pressed);
|
|
if (game.draw)
|
|
game.draw(ctx);
|
|
last = ts;
|
|
}
|
|
requestAnimationFrame(loop);
|
|
}
|
|
requestAnimationFrame(loop);
|
|
}
|
|
function endGame() {
|
|
running = false;
|
|
window.removeEventListener("keydown", handleKeydown);
|
|
window.removeEventListener("keyup", handleKeyup);
|
|
window.removeEventListener("resize", resizeCanvas);
|
|
if (oldMode === "tall")
|
|
browserCommands.mode?.();
|
|
const main = document.querySelector("main");
|
|
main?.classList.remove("game");
|
|
canvas.classList.remove("active");
|
|
canvas.style.height = HEIGHT / 2 + "px";
|
|
canvas.style.width = WIDTH / 2 + "px";
|
|
const output2 = $$("li.output");
|
|
output2.append(canvas);
|
|
insert(output2);
|
|
focusInput();
|
|
}
|
|
|
|
// src/js/dispatch.ts
|
|
async function dispatchMessage(msg) {
|
|
switch (msg.type) {
|
|
case "output":
|
|
handleOutput(msg);
|
|
break;
|
|
case "commands":
|
|
cacheCommands(msg.data);
|
|
break;
|
|
case "error":
|
|
console.error(msg.data);
|
|
break;
|
|
case "stream:start":
|
|
handleStreamStart(msg);
|
|
break;
|
|
case "stream:end":
|
|
handleStreamEnd(msg);
|
|
break;
|
|
case "stream:append":
|
|
handleStreamAppend(msg);
|
|
break;
|
|
case "stream:replace":
|
|
handleStreamReplace(msg);
|
|
break;
|
|
case "game:start":
|
|
await handleGameStart(msg);
|
|
break;
|
|
case "ui:mode":
|
|
browserCommands.mode?.(msg.data);
|
|
break;
|
|
default:
|
|
console.error("unknown message type", msg);
|
|
}
|
|
}
|
|
|
|
// src/js/websocket.ts
|
|
var MAX_RETRIES = 5;
|
|
var retries = 0;
|
|
var connected = false;
|
|
var msgQueue = [];
|
|
var ws = null;
|
|
function startConnection() {
|
|
const url = new URL(`/ws?session=${sessionId}`, location.href);
|
|
url.protocol = url.protocol.replace("http", "ws");
|
|
ws = new WebSocket(url);
|
|
ws.onmessage = receive;
|
|
ws.onclose = retryConnection;
|
|
ws.onerror = () => ws?.close();
|
|
ws.onopen = () => {
|
|
connected = true;
|
|
msgQueue.forEach((msg) => send(msg));
|
|
msgQueue.length = 0;
|
|
};
|
|
}
|
|
function send(msg) {
|
|
if (!connected) {
|
|
msgQueue.push(msg);
|
|
startConnection();
|
|
return;
|
|
}
|
|
if (!msg.session)
|
|
msg.session = sessionId;
|
|
ws?.readyState === 1 && ws.send(JSON.stringify(msg));
|
|
console.log("-> send", msg);
|
|
}
|
|
async function receive(e) {
|
|
const data = JSON.parse(e.data);
|
|
console.log("<- receive", data);
|
|
await dispatchMessage(data);
|
|
}
|
|
function retryConnection() {
|
|
connected = false;
|
|
if (retries >= MAX_RETRIES) {
|
|
addErrorMessage(`Failed to reconnect ${retries} times. Server is down.`);
|
|
if (ws)
|
|
ws.onclose = () => {};
|
|
return;
|
|
}
|
|
retries++;
|
|
addErrorMessage(`Connection lost. Retrying...`);
|
|
setTimeout(startConnection, 2000);
|
|
}
|
|
|
|
// src/js/commands.ts
|
|
var commands = [];
|
|
var browserCommands = {
|
|
"browser-session": () => sessionId,
|
|
clear: () => scrollback.innerHTML = "",
|
|
commands: () => {
|
|
return { html: "<div>" + commands.map((cmd) => `<a href="#help ${cmd}">${cmd}</a>`).join("") + "</div>" };
|
|
},
|
|
fullscreen: () => document.body.requestFullscreen(),
|
|
mode: (mode) => {
|
|
if (!mode) {
|
|
mode = document.body.dataset.mode === "tall" ? "cinema" : "tall";
|
|
send({ type: "ui:mode", data: mode });
|
|
}
|
|
content2.style.display = "";
|
|
document.body.dataset.mode = mode;
|
|
resize();
|
|
autoScroll();
|
|
focusInput();
|
|
},
|
|
reload: () => window.location.reload()
|
|
};
|
|
function cacheCommands(cmds) {
|
|
commands.length = 0;
|
|
commands.push(...cmds);
|
|
commands.push(...Object.keys(browserCommands));
|
|
commands.sort();
|
|
console.log("CMDS", commands);
|
|
}
|
|
|
|
// src/js/completion.ts
|
|
function initCompletion() {
|
|
cmdInput.addEventListener("keydown", handleCompletion);
|
|
}
|
|
function handleCompletion(e) {
|
|
if (e.key !== "Tab")
|
|
return;
|
|
e.preventDefault();
|
|
const input = cmdInput.value;
|
|
for (const command of commands) {
|
|
if (command.startsWith(input)) {
|
|
cmdInput.value = command;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// src/js/cursor.ts
|
|
var cursor = "Û";
|
|
var cmdCursor;
|
|
var enabled = true;
|
|
function initCursor() {
|
|
cmdCursor = $("command-cursor");
|
|
cmdInput.addEventListener("keydown", showCursor);
|
|
document.addEventListener("focus", cursorEnablerHandler, true);
|
|
showCursor();
|
|
}
|
|
function showCursor(e = {}) {
|
|
if (!enabled) {
|
|
cmdCursor.value = "";
|
|
return;
|
|
}
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
cmdCursor.value = cursor;
|
|
return;
|
|
}
|
|
requestAnimationFrame(() => cmdCursor.value = buildBlankCursorLine() + cursor);
|
|
}
|
|
function cursorEnablerHandler(e) {
|
|
if (!e.target)
|
|
return;
|
|
const target = e.target;
|
|
enabled = target.id === "command-textbox";
|
|
showCursor();
|
|
}
|
|
function buildBlankCursorLine() {
|
|
let line = "";
|
|
for (const char of cmdInput.value.slice(0, cmdInput.selectionEnd)) {
|
|
line += char === `
|
|
` ? char : " ";
|
|
}
|
|
return line;
|
|
}
|
|
|
|
// src/js/drop.ts
|
|
function initDrop() {
|
|
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
|
|
document.body.addEventListener(eventName, preventDefaults, false);
|
|
});
|
|
document.body.addEventListener("drop", handleDrop);
|
|
}
|
|
function preventDefaults(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
function handleDrop(e) {
|
|
const fileInput = document.querySelector("input[type=file]");
|
|
const files = e.dataTransfer?.files ?? [];
|
|
if (files.length > 0) {
|
|
const dt = new DataTransfer;
|
|
Array.from(files).forEach((f) => dt.items.add(f));
|
|
fileInput.files = dt.files;
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
// src/js/editor.ts
|
|
var INDENT_SIZE = 2;
|
|
function initEditor() {
|
|
document.addEventListener("input", handleAdjustHeight);
|
|
focusTextareaOnCreation();
|
|
}
|
|
function handleAdjustHeight(e) {
|
|
const target = e.target;
|
|
if (target?.matches(".editor"))
|
|
adjustHeight(target);
|
|
}
|
|
function adjustHeight(editor) {
|
|
editor.style.height = "auto";
|
|
editor.style.height = editor.scrollHeight + "px";
|
|
}
|
|
function focusTextareaOnCreation() {
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const m of mutations)
|
|
for (const node of Array.from(m.addedNodes))
|
|
if (node instanceof HTMLElement && node.childNodes[0] instanceof HTMLElement && node.childNodes[0].matches("textarea")) {
|
|
const editor = node.childNodes[0];
|
|
editor.focus();
|
|
editor.addEventListener("keydown", keydownHandler);
|
|
return;
|
|
}
|
|
});
|
|
observer.observe(scrollback, { childList: true });
|
|
}
|
|
function keydownHandler(e) {
|
|
const editor = e.target;
|
|
if (e.key === "Tab") {
|
|
e.preventDefault();
|
|
if (e.shiftKey)
|
|
removeTab(editor);
|
|
else
|
|
insertTab(editor);
|
|
} else if (e.ctrlKey && e.key === "c") {
|
|
focusInput();
|
|
} else if (e.ctrlKey && e.key === "s" || e.ctrlKey && e.key === "Enter") {
|
|
e.preventDefault();
|
|
send({
|
|
id: editor.dataset.path,
|
|
type: "save-file",
|
|
data: editor.value
|
|
});
|
|
} else if (e.key === "{") {
|
|
if (editor.selectionStart !== editor.selectionEnd) {
|
|
insertAroundSelection(editor, "{", "}");
|
|
e.preventDefault();
|
|
} else {
|
|
setTimeout(() => insertAfterCaret(editor, "}"), 0);
|
|
}
|
|
} else if (e.key === "}" && isNextChar(editor, "}")) {
|
|
moveOneRight(editor);
|
|
e.preventDefault();
|
|
} else if (e.key === "[") {
|
|
if (editor.selectionStart !== editor.selectionEnd) {
|
|
insertAroundSelection(editor, "[", "]");
|
|
e.preventDefault();
|
|
} else {
|
|
setTimeout(() => insertAfterCaret(editor, "]"), 0);
|
|
}
|
|
} else if (e.key === "]" && isNextChar(editor, "]")) {
|
|
moveOneRight(editor);
|
|
e.preventDefault();
|
|
} else if (e.key === "(") {
|
|
if (editor.selectionStart !== editor.selectionEnd) {
|
|
insertAroundSelection(editor, "(", ")");
|
|
e.preventDefault();
|
|
} else {
|
|
setTimeout(() => insertAfterCaret(editor, ")"), 0);
|
|
}
|
|
} else if (e.key === ")" && isNextChar(editor, ")")) {
|
|
moveOneRight(editor);
|
|
e.preventDefault();
|
|
} else if (e.key === '"') {
|
|
if (isNextChar(editor, '"')) {
|
|
moveOneRight(editor);
|
|
e.preventDefault();
|
|
} else if (editor.selectionStart !== editor.selectionEnd) {
|
|
insertAroundSelection(editor, '"', '"');
|
|
e.preventDefault();
|
|
} else {
|
|
setTimeout(() => insertAfterCaret(editor, '"'), 0);
|
|
}
|
|
} else if (e.key === "Enter") {
|
|
indentNewlineForBraces(e, editor);
|
|
}
|
|
}
|
|
function moveOneRight(editor) {
|
|
const pos = editor.selectionStart;
|
|
editor.selectionStart = editor.selectionEnd = pos + 1;
|
|
}
|
|
function insertTab(editor) {
|
|
const start = editor.selectionStart;
|
|
const end = editor.selectionEnd;
|
|
const lineStart = editor.value.lastIndexOf(`
|
|
`, start - 1) + 1;
|
|
editor.value = editor.value.slice(0, lineStart) + " " + editor.value.slice(lineStart);
|
|
editor.selectionStart = start + INDENT_SIZE;
|
|
editor.selectionEnd = end + INDENT_SIZE;
|
|
}
|
|
function removeTab(editor) {
|
|
const start = editor.selectionStart;
|
|
const end = editor.selectionEnd;
|
|
const lineStart = editor.value.lastIndexOf(`
|
|
`, start - 1) + 1;
|
|
if (editor.value.slice(lineStart, lineStart + 2) === " ") {
|
|
editor.value = editor.value.slice(0, lineStart) + editor.value.slice(lineStart + 2);
|
|
editor.selectionStart = start - INDENT_SIZE;
|
|
editor.selectionEnd = end - INDENT_SIZE;
|
|
}
|
|
}
|
|
function insertAfterCaret(editor, char) {
|
|
const pos = editor.selectionStart;
|
|
editor.value = editor.value.slice(0, pos) + char + editor.value.slice(pos);
|
|
editor.selectionStart = editor.selectionEnd = pos;
|
|
}
|
|
function isNextChar(editor, char) {
|
|
return editor.value[editor.selectionStart] === char;
|
|
}
|
|
function indentNewlineForBraces(e, editor) {
|
|
e.preventDefault();
|
|
if (isBetween(editor, "{", "}") || isBetween(editor, "[", "]")) {
|
|
setTimeout(() => insertMoreIndentedNewline(editor), 0);
|
|
} else {
|
|
setTimeout(() => insertIndentedNewline(editor), 0);
|
|
}
|
|
}
|
|
function isBetween(editor, start, end) {
|
|
const pos = editor.selectionStart;
|
|
const val = editor.value;
|
|
if (pos <= 0 || pos >= val.length)
|
|
return false;
|
|
return val[pos - 1] === start && val[pos] === end;
|
|
}
|
|
function insertIndentedNewline(editor) {
|
|
const pos = editor.selectionStart;
|
|
const before = editor.value.slice(0, pos);
|
|
const prevLineStart = before.lastIndexOf(`
|
|
`, pos - 1) + 1;
|
|
const prevLine = before.slice(prevLineStart);
|
|
let leading = 0;
|
|
while (prevLine[leading] === " ")
|
|
leading++;
|
|
const indent = " ".repeat(leading);
|
|
const insert2 = `
|
|
` + indent;
|
|
editor.value = editor.value.slice(0, pos) + insert2 + editor.value.slice(pos);
|
|
const newPos = pos + insert2.length;
|
|
editor.selectionStart = editor.selectionEnd = newPos;
|
|
adjustHeight(editor);
|
|
}
|
|
function insertAroundSelection(editor, before, after) {
|
|
const start = editor.selectionStart;
|
|
const end = editor.selectionEnd;
|
|
editor.value = editor.value.slice(0, start) + before + editor.value.slice(start, end) + after + editor.value.slice(end);
|
|
}
|
|
function insertMoreIndentedNewline(editor) {
|
|
const pos = editor.selectionStart;
|
|
const before = editor.value.slice(0, pos);
|
|
const prevLineStart = before.lastIndexOf(`
|
|
`, pos - 1) + 1;
|
|
const prevLine = before.slice(prevLineStart);
|
|
let leading = 0;
|
|
while (prevLine[leading] === " ")
|
|
leading++;
|
|
const oldIndent = " ".repeat(leading);
|
|
const newIndent = " ".repeat(leading + INDENT_SIZE);
|
|
const insert2 = `
|
|
` + newIndent + `
|
|
` + oldIndent;
|
|
editor.value = editor.value.slice(0, pos) + insert2 + editor.value.slice(pos);
|
|
const newPos = pos + insert2.length;
|
|
editor.selectionStart = editor.selectionEnd = newPos - 1 - oldIndent.length;
|
|
adjustHeight(editor);
|
|
}
|
|
|
|
// src/js/form.ts
|
|
function initForm() {
|
|
document.addEventListener("submit", submitHandler);
|
|
}
|
|
var submitHandler = async (e) => {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
if (!(form instanceof HTMLFormElement))
|
|
return;
|
|
const li = form.closest(".output");
|
|
if (!(li instanceof HTMLLIElement))
|
|
return;
|
|
const id = li.dataset.id;
|
|
if (!id)
|
|
return;
|
|
let output2;
|
|
let error = false;
|
|
try {
|
|
const fd = new FormData(form);
|
|
const data = await fetch("/cmd" + new URL(form.action).pathname, {
|
|
method: "POST",
|
|
headers: { "X-Session": sessionId },
|
|
body: fd
|
|
}).then((r) => r.json());
|
|
output2 = data.output;
|
|
error = data.status === "error";
|
|
} catch (e2) {
|
|
output2 = e2.message || e2.toString();
|
|
error = true;
|
|
}
|
|
if (error)
|
|
setStatus(id, "error");
|
|
replaceOutput(id, output2);
|
|
focusInput();
|
|
};
|
|
|
|
// src/js/gamepad.ts
|
|
var BUTTONS_TO_KEYS = {
|
|
0: "z",
|
|
1: "x",
|
|
2: "c",
|
|
3: "v",
|
|
12: "ArrowUp",
|
|
13: "ArrowDown",
|
|
14: "ArrowLeft",
|
|
15: "ArrowRight",
|
|
9: "Enter",
|
|
8: "Escape"
|
|
};
|
|
var activePads = new Set;
|
|
var prevState = {};
|
|
function initGamepad() {
|
|
window.addEventListener("gamepadconnected", (e) => {
|
|
console.log("Gamepad connected:", e.gamepad);
|
|
activePads.add(e.gamepad.index);
|
|
requestAnimationFrame(handleGamepad);
|
|
});
|
|
window.addEventListener("gamepaddisconnected", (e) => {
|
|
console.log("Gamepad disconnected:", e.gamepad);
|
|
activePads.delete(e.gamepad.index);
|
|
});
|
|
}
|
|
function handleGamepad() {
|
|
const pads = navigator.getGamepads();
|
|
for (const gp of pads) {
|
|
if (!gp)
|
|
continue;
|
|
if (!prevState[gp.index])
|
|
prevState[gp.index] = gp.buttons.map(() => false);
|
|
gp.buttons.forEach((btn, i) => {
|
|
const was = prevState[gp.index][i];
|
|
const is = btn.pressed;
|
|
if (was !== is) {
|
|
const key = BUTTONS_TO_KEYS[i];
|
|
if (key) {
|
|
const type = is ? "keydown" : "keyup";
|
|
window.dispatchEvent(new KeyboardEvent(type, { code: key, key }));
|
|
}
|
|
prevState[gp.index][i] = is;
|
|
}
|
|
});
|
|
}
|
|
requestAnimationFrame(handleGamepad);
|
|
}
|
|
|
|
// src/js/history.ts
|
|
var history = ["one", "two", "three"];
|
|
var idx = -1;
|
|
var savedInput = "";
|
|
function initHistory() {
|
|
cmdInput.addEventListener("keydown", navigateHistory);
|
|
}
|
|
function addToHistory(input) {
|
|
if (history.length === 0 || history[0] === input)
|
|
return;
|
|
history.unshift(input);
|
|
resetHistory();
|
|
}
|
|
function resetHistory() {
|
|
idx = -1;
|
|
savedInput = "";
|
|
}
|
|
function navigateHistory(e) {
|
|
if (cmdLine.dataset.extended)
|
|
return;
|
|
if (e.key === "ArrowUp" || e.ctrlKey && e.key === "p") {
|
|
e.preventDefault();
|
|
if (idx >= history.length - 1)
|
|
return;
|
|
if (idx === -1)
|
|
savedInput = cmdInput.value;
|
|
cmdInput.value = history[++idx] || "";
|
|
if (idx >= history.length)
|
|
idx = history.length - 1;
|
|
} else if (e.key === "ArrowDown" || e.ctrlKey && e.key === "n") {
|
|
e.preventDefault();
|
|
if (idx <= 0) {
|
|
cmdInput.value = savedInput;
|
|
idx = -1;
|
|
return;
|
|
}
|
|
cmdInput.value = history[--idx] || "";
|
|
if (idx < -1)
|
|
idx = -1;
|
|
} else if (idx !== -1) {
|
|
resetHistory();
|
|
}
|
|
}
|
|
|
|
// src/js/shell.ts
|
|
async function runCommand(input) {
|
|
if (!input.trim())
|
|
return;
|
|
if (input.includes(";")) {
|
|
input.split(";").forEach(async (cmd2) => await runCommand(cmd2.trim()));
|
|
return;
|
|
}
|
|
const id = randomId();
|
|
addToHistory(input);
|
|
addInput(id, input);
|
|
const [cmd = "", ...args] = input.split(" ");
|
|
if (browserCommands[cmd]) {
|
|
const result = await browserCommands[cmd](...args);
|
|
if (result)
|
|
addOutput(id, result);
|
|
setStatus(id, "ok");
|
|
} else {
|
|
send({ id, type: "input", data: input });
|
|
}
|
|
}
|
|
|
|
// src/js/hyperlink.ts
|
|
function initHyperlink() {
|
|
window.addEventListener("click", handleClick);
|
|
}
|
|
async function handleClick(e) {
|
|
const target = e.target;
|
|
if (!(target instanceof HTMLElement))
|
|
return;
|
|
const a = target.closest("a");
|
|
if (!a)
|
|
return;
|
|
const href = a.getAttribute("href");
|
|
if (!href)
|
|
return;
|
|
if (href.startsWith("#")) {
|
|
e.preventDefault();
|
|
await runCommand(href.slice(1));
|
|
focusInput();
|
|
}
|
|
}
|
|
|
|
// src/js/input.ts
|
|
function initInput() {
|
|
cmdInput.addEventListener("keydown", inputHandler);
|
|
cmdInput.addEventListener("paste", pasteHandler);
|
|
}
|
|
async function inputHandler(event) {
|
|
const target = event.target;
|
|
if (target?.id !== cmdInput.id)
|
|
return;
|
|
if (event.key === "Escape" || event.ctrlKey && event.key === "c") {
|
|
clearInput();
|
|
resetHistory();
|
|
} else if (event.key === "Tab") {
|
|
event.preventDefault();
|
|
} else if (event.shiftKey && event.key === "Enter") {
|
|
cmdInput.rows += 1;
|
|
cmdLine.dataset.extended = "true";
|
|
} else if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
await runCommand(cmdInput.value);
|
|
clearInput();
|
|
}
|
|
setTimeout(() => {
|
|
if (cmdLine.dataset.extended && !cmdInput.value.includes(`
|
|
`)) {
|
|
cmdInput.rows = 1;
|
|
cmdInput.style.height = "auto";
|
|
delete cmdLine.dataset.extended;
|
|
}
|
|
}, 0);
|
|
}
|
|
function pasteHandler(event) {
|
|
const text = event.clipboardData?.getData("text") || "";
|
|
if (!text.includes(`
|
|
`)) {
|
|
delete cmdLine.dataset.extended;
|
|
return;
|
|
}
|
|
setTimeout(() => {
|
|
cmdInput.style.height = "auto";
|
|
cmdInput.style.height = cmdInput.scrollHeight + "px";
|
|
cmdLine.dataset.extended = "true";
|
|
}, 0);
|
|
}
|
|
function clearInput() {
|
|
cmdInput.value = "";
|
|
cmdInput.rows = 1;
|
|
cmdInput.style.height = "auto";
|
|
delete cmdLine.dataset.extended;
|
|
}
|
|
|
|
// src/js/vram.ts
|
|
var vramCounter = $("vram-size");
|
|
var startVramCounter = () => {
|
|
const timer = setInterval(() => {
|
|
const count = parseInt(vramCounter.textContent) + 1;
|
|
let val = count + "KB";
|
|
if (count < 10)
|
|
val = "0" + val;
|
|
vramCounter.textContent = val;
|
|
if (count >= 64) {
|
|
vramCounter.textContent += " OK";
|
|
clearInterval(timer);
|
|
}
|
|
}, 15);
|
|
};
|
|
|
|
// src/js/main.ts
|
|
initCompletion();
|
|
initCursor();
|
|
initDrop();
|
|
initFocus();
|
|
initForm();
|
|
initEditor();
|
|
initGamepad();
|
|
initHistory();
|
|
initHyperlink();
|
|
initInput();
|
|
initResize();
|
|
initScrollback();
|
|
startConnection();
|
|
startVramCounter();
|