Skip to content

Commit 861ff22

Browse files
Merge pull request #687 from coding-horror/74-html-terminal
javascript node.js scripts
2 parents 354c1f9 + a510d33 commit 861ff22

File tree

19 files changed

+724
-264
lines changed

19 files changed

+724
-264
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
:root {
2+
--terminal-font: 1rem "Lucida Console", "Courier New", monospace;
3+
--background-color: transparent;
4+
--text-color: var(--text);
5+
--prompt-char: '$ ';
6+
--cursor-char: '_';
7+
}
8+
9+
/* Basic terminal style.
10+
* If you wan t to overwrite them use custom properties (variables).
11+
*/
12+
.terminal {
13+
display: block;
14+
font: var(--terminal-font);
15+
background-color: var(--background-color);
16+
color: var(--text-color);
17+
18+
overflow-y: scroll;
19+
width: 100%;
20+
max-width: 60rem;
21+
margin: 0 auto;
22+
}
23+
24+
/* The terminal consits of multiple "line" elements
25+
* Because sometimes we want to add a simulates "prompt" at the end of a line
26+
* we need to make it an "inline" element and handle line-breaks
27+
* by adding <br> elements */
28+
.terminal pre.line {
29+
display: inline-block;
30+
font: var(--terminal-font);
31+
margin: 0;
32+
padding: 0;
33+
}
34+
35+
/* The "terminal" has one "prompt" element.
36+
* This prompt is not any kind of input, but just a simple <span>
37+
* with an id "prompt" and a
38+
*/
39+
@keyframes prompt-blink {
40+
100% {
41+
opacity: 0;
42+
}
43+
}
44+
.terminal #prompt {
45+
display: inline-block;
46+
}
47+
.terminal #prompt:before {
48+
display: inline-block;
49+
content: var(--prompt-char);
50+
font: var(--terminal-font);
51+
}
52+
.terminal #prompt:after {
53+
display: inline-block;
54+
content: var(--cursor-char);
55+
background: var(--text);
56+
animation: prompt-blink 1s steps(2) infinite;
57+
width: 0.75rem;
58+
opacity: 1;
59+
}
60+
61+
62+
/* Terminal scrollbar */
63+
::-webkit-scrollbar {
64+
width: 3px;
65+
height: 3px;
66+
}
67+
::-webkit-scrollbar-track {
68+
background: var(--background-color);
69+
}
70+
::-webkit-scrollbar-thumb {
71+
background: var(--text-color);
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* @class HtmlTerminal
3+
*
4+
* This class is a very basic implementation of a "terminal" in the browser.
5+
* It provides simple functions like "write" and an "input" Callback.
6+
*
7+
* @license AGPL-2.0
8+
* @author Alexaner Wunschik <https://github.com/mojoaxel>
9+
*/
10+
class HtmlTerminal {
11+
12+
/**
13+
* Input callback.
14+
* If the prompt is activated by calling the input function
15+
* a callback is defined. If this member is not set this means
16+
* the prompt is not active.
17+
*
18+
* @private
19+
* @type {function}
20+
*/
21+
#inputCallback = undefined;
22+
23+
/**
24+
* A html element to show a "prompt".
25+
*
26+
* @private
27+
* @type {HTMLElement}
28+
*/
29+
#$prompt = undefined;
30+
31+
/**
32+
* Constructor
33+
* Creates a basic terminal simulation on the provided HTMLElement.
34+
*
35+
* @param {HTMLElement} $output - a dom element
36+
*/
37+
constructor($output) {
38+
// Store the output DOM element in a local variable.
39+
this.$output = $output;
40+
41+
// Clear terminal.
42+
this.clear();
43+
44+
// Add the call "terminal" to the $output element.
45+
this.$output.classList.add('terminal');
46+
47+
// Create a prompt element.
48+
// This element gets added if input is needed
49+
this.#$prompt = document.createElement("span");
50+
this.#$prompt.setAttribute("id", "prompt");
51+
this.#$prompt.innerText = "";
52+
53+
//TODO: this handler shouls be only on the propt element and only active if cursor is visible
54+
document.addEventListener("keyup", this.#handleKey.bind(this));
55+
}
56+
57+
/**
58+
* Creates a new HTMLElement with the given text content.
59+
* This element than gets added to the $output as a new "line".
60+
*
61+
* @private
62+
* @memberof MinimalTerminal
63+
* @param {String} text - text that should be displayed in the new "line".
64+
* @returns {HTMLElement} return a new DOM Element <pre class="line"></pre>
65+
*/
66+
#newLine(text) {
67+
const $lineNode = document.createElement("pre");
68+
$lineNode.classList.add("line");
69+
$lineNode.innerText = text;
70+
return $lineNode;
71+
}
72+
73+
/**
74+
* TODO
75+
*
76+
* @private
77+
* @param {*} e
78+
*/
79+
#handleKey(e) {
80+
// if no input-callback is defined
81+
if (!this.#inputCallback) {
82+
return;
83+
}
84+
85+
if (e.keyCode === 13 /* ENTER */) {
86+
// create a new line with the text input and remove the prompt
87+
const text = this.#$prompt.innerText;
88+
this.write(text + "\n");
89+
this.#$prompt.innerText = "";
90+
this.#$prompt.remove();
91+
92+
// return the inputed text
93+
this.#inputCallback(text);
94+
95+
// remove the callback and the key handler
96+
this.#inputCallback = undefined;
97+
} else if (e.keyCode === 8 /* BACKSPACE */) {
98+
this.#$prompt.innerText = this.#$prompt.innerText.slice(0, -1);
99+
} else if (
100+
e.keyCode == 16 // "Shift"
101+
|| e.keyCode == 17 // "Control"
102+
|| e.keyCode == 20 // "CapsLock"
103+
|| !e.key.match(/^[a-z0-9!"§#$%&'()*+,.\/:;<=>?@\[\] ^_`{|}~-]$/i)
104+
) {
105+
// ignore non-visible characters
106+
return e;
107+
} else {
108+
this.#$prompt.innerHtml = '';
109+
const key = e.shiftKey ? e.key.toUpperCase() : e.key;
110+
this.#$prompt.innerText = this.#$prompt.innerText + key;
111+
}
112+
}
113+
114+
/**
115+
* Clear the terminal.
116+
* Remove all lines.
117+
*
118+
* @public
119+
*/
120+
clear() {
121+
this.$output.innerText = "";
122+
}
123+
124+
/**
125+
* TODO:
126+
*
127+
* @public
128+
* @param {*} htmlContent
129+
*/
130+
inserHtml(htmlContent) {
131+
const $htmlNode = document.createElement("div");
132+
$htmlNode.innerHTML = htmlContent;
133+
this.$output.appendChild($htmlNode);
134+
document.body.scrollTo(0, document.body.scrollHeight);
135+
}
136+
137+
/**
138+
* Write a text to the terminal.
139+
* By default there is no linebreak at the end of a new line
140+
* except the line ensd with a "\n".
141+
* If the given text has multible linebreaks, multibe lines are inserted.
142+
*
143+
* @public
144+
* @param {string} text
145+
*/
146+
write(text) {
147+
if (!text || text.length <= 0) {
148+
// empty line
149+
this.$output.appendChild(document.createElement("br"));
150+
} else if (text.endsWith("\n")) {
151+
// single line with linebrank
152+
const $lineNode = this.#newLine(text);
153+
this.$output.appendChild(this.#newLine(text));
154+
this.$output.appendChild(document.createElement("br"));
155+
} else if (text.includes("\n")) {
156+
// multible lines
157+
const lines = text.split("\n");
158+
lines.forEach((line) => {
159+
this.write(line);
160+
});
161+
} else {
162+
// single line
163+
this.$output.appendChild(this.#newLine(text));
164+
}
165+
166+
// scroll to the buttom of the page
167+
document.body.scrollTo(0, document.body.scrollHeight);
168+
}
169+
170+
/**
171+
* Like "write" but with a newline at the end.
172+
*
173+
* @public
174+
* @param {*} text
175+
*/
176+
writeln(text) {
177+
this.write(text + "\n");
178+
}
179+
180+
/**
181+
* Query from user input.
182+
* This is done by adding a input-element at the end of the terminal,
183+
* that showes a prompt and a blinking cursor.
184+
* If a key is pressed the input is added to the prompt element.
185+
* The input ends with a linebreak.
186+
*
187+
* @public
188+
* @param {*} callback
189+
*/
190+
input(callback) {
191+
// show prompt with a blinking prompt
192+
this.$output.appendChild(this.#$prompt);
193+
this.#inputCallback = callback;
194+
}
195+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<html>
2+
<head>
3+
<title>Minimal node.js terminal</title>
4+
<meta name="viewport" content="width=device-width, initial-scale=.75">
5+
<link
6+
rel="stylesheet"
7+
href="../../../00_Utilities/javascript/style_terminal.css"
8+
/>
9+
<link rel="stylesheet" href="HtmlTerminal.css" />
10+
<style>
11+
header {
12+
position: sticky;
13+
top: 0;
14+
left: 0;
15+
right: 0;
16+
border-bottom: 1px solid var(--text);
17+
padding: 0.25rem 0.5rem;
18+
margin: 0;
19+
margin-bottom: 1rem;
20+
background: black;
21+
display: flex;
22+
justify-content: space-between;
23+
}
24+
header h1 {
25+
font-size: small;
26+
color: var(--text),
27+
}
28+
header div {
29+
font-size: small;
30+
}
31+
</style>
32+
</head>
33+
<body>
34+
<header>
35+
<h1><a href="../../../">BASIC Computer Games</a></h1>
36+
</header>
37+
<main id="output"></main>
38+
<script src="HtmlTerminal.js" type="text/javascript"></script>
39+
<script>
40+
const $output = document.getElementById("output");
41+
const term = new HtmlTerminal($output);
42+
43+
function getGameScriptFromHash() {
44+
const hash = window.location.hash;
45+
46+
// if no game-script was provided redirect to the overview.
47+
if (!hash) {
48+
// show error message and link back to the index.html
49+
console.debug("[HtmlTerminal] No game script found!");
50+
term.writeln(`no game script found :-(\n`);
51+
term.inserHtml(`<a href="/">>> Back to game overview!</a>`);
52+
return;
53+
}
54+
55+
// remove the hash
56+
const gameFile = hash.replace("#", "");
57+
return gameFile;
58+
}
59+
60+
function addGitHubLink(gameFile) {
61+
const gameFolder = gameFile.split("/")[0];
62+
63+
$gitHubLink = document.createElement("a");
64+
$gitHubLink.href = `https://github.com/coding-horror/basic-computer-games/tree/main/${gameFolder}`;
65+
$gitHubLink.innerText = `show source-code`;
66+
67+
var $gitHubBanner = document.createElement("div");
68+
$gitHubBanner.classList.add("githublink");
69+
$gitHubBanner.appendChild($gitHubLink);
70+
71+
const $header = document.getElementsByTagName('header')[0];
72+
$header.append($gitHubBanner);
73+
}
74+
75+
function loadGameScript(gameFile) {
76+
// clear terminal
77+
term.clear();
78+
79+
// load game-script
80+
console.debug("[HtmlTerminal] Game script found: ", gameFile);
81+
const gameScript = `../../../${gameFile}`;
82+
var $scriptTag = document.createElement("script");
83+
$scriptTag.async = "async";
84+
$scriptTag.type = "module";
85+
$scriptTag.src = gameScript;
86+
$scriptTag.onerror = () => {
87+
term.clear();
88+
term.writeln(`Error loading game-script "${gameFile}" :-(\n`);
89+
term.inserHtml(`<a href="/">>> Back to game overview!</a>`);
90+
};
91+
$scriptTag.addEventListener("load", function () {
92+
console.log("[HtmlTerminal] Game script loaded!");
93+
});
94+
document.body.append($scriptTag);
95+
}
96+
97+
/**
98+
* Determine how much chars will fit in each terminal line.
99+
*/
100+
function getOutputColumns($element) {
101+
102+
const fontWidth = 10; //TODO: this width could be measured but it may be complicated!
103+
const columnWidth = Math.trunc($element.clientWidth / fontWidth);
104+
console.warn(`[terminal] document.body.clientWidth:${$element.clientWidth} fontsize:${fontWidth} columnWidth:${columnWidth}`);
105+
return columnWidth;
106+
}
107+
108+
/* Redirect stdin/stdout to the HtmlTerminal.
109+
* This is VERY hacky and should never be done in a serious project!
110+
* We can use this here because we know what we are doing and...
111+
* ...it's just simple games ;-) */
112+
window.process = {
113+
stdout: {
114+
write: (t) => term.write(t),
115+
columns: getOutputColumns($output)
116+
},
117+
stdin: {
118+
on: (event, callback) => term.input(callback),
119+
},
120+
exit: (code) => {},
121+
};
122+
123+
// let's play 🚀
124+
const gameFile = getGameScriptFromHash();
125+
addGitHubLink(gameFile);
126+
loadGameScript(gameFile);
127+
</script>
128+
</body>
129+
</html>

0 commit comments

Comments
 (0)