UNPKG

@squid-dev/cc-web-term

Version:

A ComputerCraft terminal for the internet

430 lines (429 loc) 19.9 kB
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 }; } }