I\'m finding this hard to even conceptualize. The easy way would be to have a large textarea
element taking up most of the screen, with a small text input
If you want to build a solution yourself instead of using a library, you could use a contenteditable
element and a fake square caret after it. If the caret moves to another position, then that fake caret is hidden and the real vertical line one is shown instead.
However, you could probably tweak this code to always select one character, even when overtype mode is disabled, so that the caret is always a one character wide square.
I'm only displaying the commands, but it would be trivial to handle them differently:
const history = document.getElementById('history');
const input = document.getElementById('input');
const cursor = document.getElementById('cursor');
function focusAndMoveCursorToTheEnd(e) {
input.focus();
const range = document.createRange();
const selection = window.getSelection();
const { childNodes } = input;
const lastChildNode = childNodes && childNodes.length - 1;
range.selectNodeContents(lastChildNode === -1 ? input : childNodes[lastChildNode]);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
function handleCommand(command) {
const line = document.createElement('DIV');
line.textContent = `> ${ command }`;
history.appendChild(line);
}
// Every time the selection changes, add or remove the .noCursor
// class to show or hide, respectively, the bug square cursor.
// Note this function could also be used to enforce showing always
// a big square cursor by always selecting 1 chracter from the current
// cursor position, unless it's already at the end, in which case the
// #cursor element should be displayed instead.
document.addEventListener('selectionchange', () => {
if (document.activeElement.id !== 'input') return;
const range = window.getSelection().getRangeAt(0);
const start = range.startOffset;
const end = range.endOffset;
const length = input.textContent.length;
if (end < length) {
input.classList.add('noCaret');
} else {
input.classList.remove('noCaret');
}
});
input.addEventListener('input', () => {
// If we paste HTML, format it as plain text and break it up
// input individual lines/commands:
if (input.childElementCount > 0) {
const lines = input.innerText.replace(/\n$/, '').split('\n');
const lastLine = lines[lines.length - 1];
for (let i = 0; i <= lines.length - 2; ++i) {
handleCommand(lines[i]);
}
input.textContent = lastLine;
focusAndMoveCursorToTheEnd();
}
// If we delete everything, display the square caret again:
if (input.innerText.length === 0) {
input.classList.remove('noCaret');
}
});
document.addEventListener('keydown', (e) => {
// If some key is pressed outside the input, focus it and move the cursor
// to the end:
if (e.target !== input) focusAndMoveCursorToTheEnd();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCommand(input.textContent);
input.textContent = '';
focusAndMoveCursorToTheEnd();
}
});
// Set the focus to the input so that you can start typing straigh away:
input.focus();
body {
background: #000;
color: #0F0;
font-family: monospace;
height: 100vh;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: scroll;
word-break: break-all;
margin: 0;
padding: 16px;
}
#input {
display: inline;
outline: none;
visibility: visible;
}
/*
If you press the Insert key, the vertical line caret will automatically
be replaced by a one-character selection.
*/
#input::selection {
color: #000;
background: #0F0;
}
#input:empty::before {
content: ' ';
}
@keyframes blink {
to {
visibility: hidden;
}
}
#input:focus + #caret {
animation: blink 1s steps(5, start) infinite;
}
#input.noCaret + #caret {
visibility: hidden;
}
#caret {
border: 0;
padding: 0;
outline: none;
background-color: #0F0;
display: inline-block;
font-family: monospace;
}
<div id="history"></div>
>
<div id="input" contenteditable="true"></div><button id="caret" for="input"> </button>
Note this solution relies mostly on the input
and selectionchange
events, rather than keyboard events (keydown
/ keypress
/ keyup
). It's usually a bad idea to use them to handle text input or cursors, as the value of the input can also be updated by pasting or dropping text into it and there are many edge cases, such as arrows, delete, escape, shortcuts such as select all, copy, paste... so trying to come up with an exhaustive list of all the keys we should take care of is probably not the best approach.
Moreover, that won't work on mobile, where most keys emit the same values e.key = 'Unidentified'
, e.which== 229
and e.keyCode = 229
.
Instead, it's usually better to rely on other events such as input and use KeyboardEvents
to handle very specific keys, like ↵ in this case.
If you need to check KeyboardEvent's properties values such as e.key
, e.code
, e.which
or e.keyCode
you can use https://keyjs.dev. I will add information about these kinds of cross-browser incompatibilities soon!
Disclaimer: I'm the author.
You can check out these JavaScript terminals found online via Google:
Also, some of my French friends are working on this: