@squid-dev/cc-web-term
Version:
A ComputerCraft terminal for the internet
430 lines (429 loc) • 19.9 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import { Component, createRef, h } from "preact";
import { GIF } from "../files/gif";
import saveBlob from "../files/save";
import { Camera, Fullscreen, NoEntry, Off, On, Videocam, VideocamRecording } from "../font";
import logger from "../log";
import { actionButton, terminalBar, terminalButton, terminalButtonsRight, terminalCanvas, terminalInfo, terminalInput, terminalProgress, terminalView, } from "../styles.module.css";
import { convertKey, convertMouseButton, convertMouseButtons } from "./input";
import * as render from "./render";
const log = logger("Terminal");
const clamp = (value, min, max) => {
if (value < min)
return min;
if (value > max)
return max;
return value;
};
const labelElement = (id, label) => {
if (id === null && label === null)
return "Unlabeled computer";
if (id === null)
return `${label}`;
if (label === null)
return `Computer #${id}`;
return `${label} (Computer #${id})`;
};
export class Terminal extends Component {
constructor(props, context) {
super(props, context);
this.canvasElem = createRef();
this.inputElem = createRef();
this.wrapperElem = createRef();
this.changed = false;
this.lastBlink = false;
this.mounted = false;
this.drawQueued = false;
this.lastX = -1;
this.lastY = -1;
this.gif = null;
this.lastGifFrame = null;
this.onResized = () => {
this.changed = true;
this.queueDraw();
};
this.onPaste = (event) => {
this.onEventDefault(event);
this.paste(event.clipboardData);
};
this.onMouse = (event) => {
this.onEventDefault(event);
if (!this.canvasElem)
return;
// If we"re a mouse move and nobody is pressing anything, let"s
// skip for now.
if (event.type === "mousemove" && event.buttons === 0)
return;
const { x, y } = this.convertMousePos(event);
switch (event.type) {
case "mousedown": {
const button = convertMouseButton(event.button);
if (button) {
this.props.computer.queueEvent("mouse_click", [button, x, y]);
this.lastX = x;
this.lastY = y;
}
break;
}
case "mouseup": {
const button = convertMouseButton(event.button);
if (button) {
this.props.computer.queueEvent("mouse_up", [button, x, y]);
this.lastX = x;
this.lastY = y;
}
break;
}
case "mousemove": {
const button = convertMouseButtons(event.buttons);
if (button && (x !== this.lastX || y !== this.lastY)) {
this.props.computer.queueEvent("mouse_drag", [button, x, y]);
this.lastX = x;
this.lastY = y;
}
}
}
};
this.onMouseWheel = (event) => {
this.onEventDefault(event);
if (!this.canvasElem)
return;
const { x, y } = this.convertMousePos(event);
if (event.deltaY !== 0) {
this.props.computer.queueEvent("mouse_scroll", [Math.sign(event.deltaY), x, y]);
}
};
this.onEventDefault = (event) => {
var _a;
event.preventDefault();
(_a = this.inputElem.current) === null || _a === void 0 ? void 0 : _a.focus();
};
this.onKey = (event) => {
if (!this.canvasElem)
return;
// When receiving Ctrl+V (hopefully the keyboard shortcut to paste!), don't
// queue an event, but don't cancel the default either.
if (event.ctrlKey && event.code === "KeyV")
return;
// Try to pull the key number from the event. We first try the key code
// (ideal, as it's independent of layout), then the key itself, or the
// uppercase key (tacky shortcut to handle 'a' and 'A').
let code = convertKey(event.code);
if (code === undefined)
code = convertKey(event.key);
if (code === undefined)
code = convertKey(event.key.toUpperCase());
if (code !== undefined || event.key.length === 1)
this.onEventDefault(event);
if (event.type === "keydown") {
if (code !== undefined)
this.props.computer.keyDown(code, event.repeat);
if (!event.altKey && !event.ctrlKey && event.key.length === 1) {
this.props.computer.queueEvent("char", [event.key]);
}
}
else if (event.type === "keyup") {
if (code !== undefined)
this.props.computer.keyUp(code);
}
};
this.onInput = (event) => {
const target = event.target;
this.onEventDefault(event);
// Some browsers (*cough* Chrome *cough*) don't provide
// KeyboardEvent.{code, key} for printable characters. Let's scrape it from
// the input instead.
const value = target.value;
if (!value)
return;
target.value = "";
this.props.computer.queueEvent(value.length === 1 ? "char" : "paste", [value]);
};
this.onTerminate = (event) => {
this.onEventDefault(event);
this.props.computer.queueEvent("terminate", []);
};
this.onChanged = () => {
this.changed = true;
this.queueDraw();
};
this.onPowerOff = (event) => {
this.onEventDefault(event);
this.props.computer.shutdown();
};
this.onPowerOn = (event) => {
this.onEventDefault(event);
this.props.computer.turnOn();
};
this.onScreenshot = (event) => {
var _a;
this.onEventDefault(event);
if (!this.canvasElem)
return;
(_a = this.canvasElem.current) === null || _a === void 0 ? void 0 : _a.toBlob(blob => saveBlob("computer", "png", blob), "image/png", 1);
};
this.onRecord = (event) => {
this.onEventDefault(event);
if (!this.canvasElem)
return;
switch (this.state.recording) {
// Skip the cases when we've got no data
case 2 /* RecordingState.Rendering */:
break;
// If we're not recording, start recording.
case 0 /* RecordingState.None */:
this.gif = new GIF({
width: this.canvasElem.current.width,
height: this.canvasElem.current.height,
quality: 10,
});
this.lastGifFrame = Date.now();
this.setState({ recording: 1 /* RecordingState.Recording */ });
break;
case 1 /* RecordingState.Recording */:
if (!this.gif) {
this.setState({ recording: 0 /* RecordingState.None */ });
return;
}
this.setState({ recording: 2 /* RecordingState.Rendering */ });
this.addGifFrame(true);
this.gif.onFinished = blob => {
this.setState({ recording: 0 /* RecordingState.None */ });
saveBlob("computer", "gif", blob);
};
this.gif.onProgress = progress => this.setState({ progress });
this.gif.onAbort = () => {
this.setState({ recording: 0 /* RecordingState.None */ });
console.error("Rendering GIF failed");
};
this.gif.render();
this.gif = null;
this.lastGifFrame = null;
}
};
this.makeFullscreen = (event) => {
var _a;
this.onEventDefault(event);
(_a = this.base) === null || _a === void 0 ? void 0 : _a.requestFullscreen().catch(e => {
console.error("Cannot make full-screen", e);
});
};
this.onDrop = (e) => {
this.onEventDefault(e);
if (!e.dataTransfer)
return;
const files = [];
if (e.dataTransfer.items) {
const items = e.dataTransfer.items;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file")
files.push(item.getAsFile());
}
}
else {
const items = e.dataTransfer.files;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < items.length; i++)
files.push(items[i]);
}
if (files.length == 0)
return;
Promise.all(files.map((x) => __awaiter(this, void 0, void 0, function* () { return ({ name: x.name, contents: yield x.arrayBuffer() }); })))
.then(x => this.props.computer.transferFiles(x))
.catch(e => console.error("Error handling drop", e));
};
this.setState({
recording: 0 /* RecordingState.None */,
progress: 0,
});
this.vdom = [
h("canvas", { class: terminalCanvas, ref: this.canvasElem, onMouseDown: this.onMouse, onMouseUp: this.onMouse, onMouseMove: this.onMouse, onWheel: this.onMouseWheel, onContextMenu: this.onEventDefault, onDragOver: this.onEventDefault, onDrop: this.onDrop }),
h("input", { type: "text", class: terminalInput, ref: this.inputElem, onPaste: this.onPaste, onKeyDown: this.onKey, onKeyUp: this.onKey, onInput: this.onInput }),
];
}
componentDidMount() {
// Fetch the "key" elements
this.canvasContext = this.canvasElem.current.getContext("2d");
// Subscribe to some events to allow us to schedule a redraw
window.addEventListener("resize", this.onResized);
this.props.changed.attach(this.onChanged);
// Set some key properties
this.changed = true;
this.lastBlink = false;
this.mounted = true;
// Focus on the input element
if (this.props.focused)
this.inputElem.current.focus();
// And let's draw!
this.queueDraw();
}
componentWillUnmount() {
this.canvasContext = undefined;
this.props.changed.detach(this.onChanged);
window.removeEventListener("resize", this.onResized);
this.lastBlink = false;
this.mounted = false;
this.drawQueued = false;
}
render({ id, label, on }, { recording, progress }) {
const recordingDisabled = recording === 2 /* RecordingState.Rendering */;
return h("div", { class: terminalView },
h("div", { ref: this.wrapperElem },
...this.vdom,
h("div", { class: terminalBar },
h("button", { class: `${actionButton} ${terminalButton}`, type: "button", title: on ? "Turn this computer off" : "Turn this computer on", onClick: on ? this.onPowerOff : this.onPowerOn }, on ? h(On, null) : h(Off, null)),
h("span", { class: terminalInfo }, labelElement(id, label)),
h("span", { class: terminalButtonsRight },
h("button", { class: `${actionButton} ${terminalButton}`, type: "button", title: "Take a screenshot of the terminal.", onClick: this.onScreenshot },
h(Camera, null)),
h("button", { class: `${actionButton} ${terminalButton} ${recordingDisabled ? "disabled" : ""}`, type: "button", title: "Record the terminal to a GIF.", onClick: this.onRecord }, recording === 1 /* RecordingState.Recording */ ? h(VideocamRecording, null) : h(Videocam, null)),
h("button", { class: `${actionButton} ${terminalButton}`, type: "button", title: "Make the terminal full-screen", onClick: this.makeFullscreen },
h(Fullscreen, null)),
h("button", { class: `${actionButton} ${terminalButton}`, type: "button", title: "Send a `terminate' event to the computer.", onClick: this.onTerminate },
h(NoEntry, null)))),
h("div", { class: terminalProgress, style: `width: ${recording === 2 /* RecordingState.Rendering */ ? progress * 100 : 0}%` })));
}
componentDidUpdate() {
var _a;
this.changed = true;
this.queueDraw();
if (this.props.focused)
(_a = this.inputElem.current) === null || _a === void 0 ? void 0 : _a.focus();
}
queueDraw() {
if (this.mounted && !this.drawQueued) {
this.drawQueued = true;
window.requestAnimationFrame(time => {
this.drawQueued = false;
if (!this.mounted)
return;
// We push the previous frame before drawing the next one.
this.addGifFrame();
this.draw(time);
// Schedule another redraw to handle the cursor blink
if (this.props.terminal.cursorBlink)
this.queueDraw();
});
}
}
draw(time) {
if (!this.canvasElem || !this.canvasContext || !this.wrapperElem)
return;
const { terminal, font: fontPath } = this.props;
const sizeX = terminal.sizeX || 51;
const sizeY = terminal.sizeY || 19;
const font = render.loadFont(fontPath);
if (font.promise) {
void font.promise.then(() => this.queueDraw());
return;
}
const blink = Math.floor(time / 400) % 2 === 0;
const changed = this.changed;
if (!changed && (!terminal.cursorBlink || this.lastBlink === blink ||
terminal.cursorX < 0 || terminal.cursorX >= sizeX ||
terminal.cursorY < 0 || terminal.cursorY >= sizeY)) {
return;
}
this.lastBlink = blink;
this.changed = false;
const canvasElem = this.canvasElem.current;
const wrapperElem = this.wrapperElem.current;
// Calculate terminal scaling to fit the screen
const actualWidth = wrapperElem.parentElement.clientWidth - render.terminalMargin * 2;
/* [Note 'Padding']: 70px = 30px top-padding + action-bar + arbitrary bottom-padding. See styles.module.css too. */
const actualHeight = wrapperElem.parentElement.clientHeight - render.terminalMargin * 2 - 40;
const width = sizeX * render.cellWidth;
const height = sizeY * render.cellHeight;
// The scale has to be an integer (though converted within the renderer) to ensure pixels are integers.
// Otherwise you get texture issues.
const scale = Math.max(1, Math.min(Math.floor(actualHeight / height), Math.floor(actualWidth / width)));
const ctx = this.canvasContext;
// If we"re just redrawing the cursor. We"ve aborted earlier if the cursor is not visible/
// out of range and hasn"t changed.
if (!changed) {
if (blink) {
render.foreground(ctx, terminal.cursorX, terminal.cursorY, terminal.currentFore, "_", terminal.palette, scale, font);
}
else {
const x = terminal.cursorX;
const y = terminal.cursorY;
render.background(ctx, x, y, terminal.back[y].charAt(x), scale, sizeX, sizeY, terminal.palette);
render.foreground(ctx, x, y, terminal.fore[y].charAt(x), terminal.text[y].charAt(x), terminal.palette, scale, font);
}
return;
}
// Actually update the canvas dimensions.
const canvasWidth = width * scale + render.terminalMargin * 2;
const canvasHeight = height * scale + render.terminalMargin * 2;
if (canvasElem.height !== canvasHeight || canvasElem.width !== canvasWidth) {
canvasElem.height = canvasHeight;
canvasElem.width = canvasWidth;
canvasElem.style.height = `${canvasHeight}px`;
wrapperElem.style.width = canvasElem.style.width = `${canvasWidth}px`;
}
// Prevent blur when up/down-scaling
ctx.imageSmoothingEnabled = false;
// And render!
if (terminal.sizeX === 0 && terminal.sizeY === 0) {
render.bsod(ctx, sizeX, sizeY, "No terminal output", scale, font);
}
else {
render.terminal(ctx, terminal, blink, scale, font);
}
}
paste(clipboard) {
if (!clipboard)
return;
let content = clipboard.getData("text");
if (!content)
return;
// Limit to allowed characters (actually slightly more generous but
// there you go).
content = content.replace(/[^\x20-\xFF]/gi, "");
// Strip to the first newline
content = content.replace(/[\r\n].*/, "");
// Limit to 512 characters
content = content.substring(0, 512);
// Abort if we"re empty
if (!content)
return;
this.props.computer.queueEvent("paste", [content]);
}
addGifFrame(force) {
if (!this.gif || !this.canvasContext)
return;
if (!this.lastGifFrame) {
console.error("Pushing a frame, but no previous frame!!");
return;
}
// We limit ourselves to 20fps, just so we're not producing an insane number
// of frames.
const now = Date.now();
if (!force && now - this.lastGifFrame < 50)
return;
log(`Adding frame for ${now - this.lastGifFrame} seconds`);
this.gif.addFrame(this.canvasContext, { delay: now - this.lastGifFrame });
this.lastGifFrame = now;
}
convertMousePos(event) {
const canvasElem = this.canvasElem.current;
if (!canvasElem)
throw "impossible";
const box = canvasElem.getBoundingClientRect();
const x = clamp(Math.floor((event.clientX - box.left - render.terminalMargin)
/ (canvasElem.width - 2 * render.terminalMargin) * this.props.terminal.sizeX) + 1, 1, this.props.terminal.sizeX);
const y = clamp(Math.floor((event.clientY - box.top - render.terminalMargin)
/ (canvasElem.height - 2 * render.terminalMargin) * this.props.terminal.sizeY) + 1, 1, this.props.terminal.sizeY);
return { x, y };
}
}