12
\$\begingroup\$

I'm learning JavaScript and I've made a very simple little document-modifying script that prints a message as if it was someone typing into a console. It practices recursion and in my opinion, I feel a bit iffy about the number of if-cases in the code. My main concern is the JavaScript though I know there's still a lot I have to learn about CSS animations.

var text = document.getElementById('text');
var greeting = ['Hello. I am a console wannabe.',
                'All systems are functioning.',
                'I like pie.'];
text.innerHTML = '<i>▮</i>';

(function greet() {
    if (greeting.length > 0 && greeting.length < 3) {
        text.insertBefore(document.createElement('br'), text.lastChild);
    }
    var line = greeting.shift();
    if (!line) {
        return;
    }
    line = line.split('');
    (function type() {
        var character = line.shift();
        if (!character) {
            return setTimeout(greet, 2000);
        }

        text.insertBefore(document.createTextNode(character), text.lastChild);
        setTimeout(type, 300);
    }());
}());
body{
    background-color: #000000;
    color: #99ffcc;
    font-family: Courier;
}

i{
    font-style: unset;
    font-size: 1em;
    animation: blink 1100ms linear infinite;
}

@keyframes blink {
    49% {
        opacity: 1;
    }
    50% {
        opacity: 0;
    }
    89% {
        opacity: 0;
    }
    90% {
        opacity: 1;
    }
}
<div id="text"></div>

I would like as many opinions as you can have about my simple little script. Does it look like I have a small grasp of JavaScript?

\$\endgroup\$
10
\$\begingroup\$

Your code is really easy to understand, and there's not much wrong with it.

I promise I'll go easy on you.


  1. Right on the first lines, something bothers me...

You have this:

var text = document.getElementById('text');
var greeting = ['Hello. I am a console wannabe.',
                'All systems are functioning.',
                'I like pie.'];
text.innerHTML = '<i>▮</i>';

What's wrong? The names!

text implies some actual text, but you have a <div>. greeting is alright, but it is actually text. And that <i> is a prompt.

I propose the following rewrite:

var screen = document.getElementById('screen');
var text = ['Hello. I am a console wannabe.',
                'All systems are functioning.',
                'I like pie.'];
screen.innerHTML = '<b id="prompt" class="idle">&marker;</b>';

It all makes sense, except for the class, which I will explain later on.
And please, use the HTML entities, to avoid problems with file encodings.
Also, don't use <i> for this, since it will have problems when the animation is stopped.
The best would be to move the prompt to the HTML and ignore that line.

  1. Lets analyse the function greet().

You have the following code:

(function greet() {
    if (greeting.length > 0 && greeting.length < 3) {
        text.insertBefore(document.createElement('br'), text.lastChild);
    }
    var line = greeting.shift();
    if (!line) {
        return;
    }
    line = line.split('');
    (function type() {
        var character = line.shift();
        if (!character) {
            return setTimeout(greet, 2000);
        }

        text.insertBefore(document.createTextNode(character), text.lastChild);
        setTimeout(type, 300);
    }());
}());

Huh? The greet() function is preparing to type the text? And only works for this example? Well, let me re-write this for you.

function type(text, screen) {
    //You have to check for lines and if the screen is an element
    if(!text || !text.length || !(screen instanceof Element)) {
        return;
    }

    //if it is not a string, you will want to make it into one
    if('string' !== typeof text) {
        text = text.join('\n');
    }

    //normalize newlines, and split it to have a nice array
    text = text.replace(/\r\n?/g,'\n').split('');

    //the prompt is always the last child
    var prompt = screen.lastChild;
    prompt.className = 'typing';

    var typer = function(){
        var character = text.shift();
        screen.insertBefore(
            //newlines must be written as a `<br>`
            character === '\n'
                ? document.createElement('br')
                : document.createTextNode(character),
            prompt
        );

        //only run this again if there are letters
        if( text.length ) {
            setTimeout(typer, 300);
        } else {
            prompt.className = 'idle';
        }
    };
    setTimeout(typer, 300);
};

Wow, some fine German Overengeneering going on there!

You can pass it a string or an array of strings, and a 'screen', where the message will be added.

  1. Now, enough of Javascript, lets talk about CSS!

It is a pretty straightforward CSS, without many complications.
But, remember that it suffered a rewrite.

Replace your i CSS with this:

#prompt{
    font-style: unset;
    font-size: 1em;
}
#prompt.idle {
    animation: blink 1100ms linear infinite;
    -webkit-animation: blink 1100ms linear infinite;
}
  1. On your HTML, simply change the id.

Remember, we re-wrote it:

<div id="screen"><b id="prompt" class="idle">&marker;</b></div>

Final result:

And now, all together:

function type(text, screen) {
	//You have to check for lines and if the screen is an element
	if(!text || !text.length || !(screen instanceof Element)) {
		return;
	}
	
	//if it is not a string, you will want to make it into one
	if('string' !== typeof text) {
		text = text.join('\n');
	}
	
	//normalize newlines, and split it to have a nice array
	text = text.replace(/\r\n?/g,'\n').split('');
	
	//the prompt is always the last child
	var prompt = screen.lastChild;
	prompt.className = 'typing';
	
	var typer = function(){
		var character = text.shift();
		screen.insertBefore(
			//newlines must be written as a `<br>`
			character === '\n'
				? document.createElement('br')
				: document.createTextNode(character),
			prompt
		);
		
		//only run this again if there are letters
		if( text.length ) {
			setTimeout(typer, 300);
		} else {
			prompt.className = 'idle';
		}
	};
	setTimeout(typer, 300);
};

window.onload=function(){
	var screen = document.getElementById('screen');
	var text = [
		'Hello. I am a better console wannabe.',
		'All systems are functioning.',
		'I like pie.'
	];
  
	type(text, screen);
};
body {
	background-color: #000000;
	color: #99ffcc;
	font-family: Courier;
}
#prompt {
	font-style: unset;
	font-size: 1em;
}
#prompt.idle {
	animation: blink 1100ms linear infinite;
	-webkit-animation: blink 1100ms linear infinite;
}

@keyframes blink {
	49% {opacity: 1;}
	50% {opacity: 0;}
	89% {opacity: 0;}
	90% {opacity: 1;}
}
@-webkit-keyframes blink {
	49% {opacity: 1;}
	50% {opacity: 0;}
	89% {opacity: 0;}
	90% {opacity: 1;}
}
<div id="screen"><b id="prompt" class="idle">&marker;</b></div>


To ressemble a little more a console, the animation is only applyed when there's no typing.

With minor changes, it is possible to make it work with keypresses on the keyboard. And with a write queue. But that's left as an exercise for the O.P.


Also, one cool thing you can try is to use a random timeout, to create a varying effect on the writting, to simulate real typing! You can even add an extra interval to special characters, to be more human-like:

function type(text, screen) {
	//You have to check for lines and if the screen is an element
	if(!text || !text.length || !(screen instanceof Element)) {
		return;
	}
	
	//if it is not a string, you will want to make it into one
	if('string' !== typeof text) {
		text = text.join('\n');
	}
	
	//normalize newlines, and split it to have a nice array
	text = text.replace(/\r\n?/g,'\n').split('');
	
	//the prompt is always the last child
	var prompt = screen.lastChild;
	prompt.className = 'typing';
	
	var typer = function(){
		var character = text.shift();
		screen.insertBefore(
			//newlines must be written as a `<br>`
			character === '\n'
				? document.createElement('br')
				: document.createTextNode(character),
			prompt
		);
		
		//only run this again if there are letters
		if( text.length ) {
			var delay, next = text[0];
			//based on a querty pt-PT keyboard, there delays are subjective
			if(next.match(/[a-z\d\t\-\.,º]/)){
				//fastest characters
				delay = 50;
			} else if(next == ' ' || next == '\n' || next.match(/[\\\|\!\"\#\$\%\&\/\(\)\=\?\'»\*ª_:;>A-Z]/)) {
				//medium-slow keys
				delay = 100;
			} else if(next.match(/[\@\€\£\§\{\[\]\}\~\´]/)) {
				//slow keys
				delay = 150;
			} else {
				//Yes, that slow!
				delay = 250;
			}
			//repeated characters are types faster
			if(next == character) {
				delay -= 25; //reduces the delay by 50
			}
			
			setTimeout(typer, delay + (Math.random() * 50));
		} else {
			prompt.className = 'idle';
		}
	};
	setTimeout(typer, 50 + (Math.random() * 50));
};

window.onload=function(){
	var screen = document.getElementById('screen');
	var text = [
		'Hello. I am a better console wannabe.',
		'All systems are functioning.',
		'I like pie.',
		'É só pra teste (just for testing).',
		'ASCII PARTY!"#$%&/()=?!!!!!'
	];
  
	type(text, screen);
};
body {
	background-color: #000000;
	color: #99ffcc;
	font-family: Courier;
}
#prompt {
	font-style: unset;
	font-size: 1em;
}
#prompt.idle {
	animation: blink 1100ms linear infinite;
	-webkit-animation: blink 1100ms linear infinite;
}

@keyframes blink {
	49% {opacity: 1;}
	50% {opacity: 0;}
	89% {opacity: 0;}
	90% {opacity: 1;}
}
@-webkit-keyframes blink {
	49% {opacity: 1;}
	50% {opacity: 0;}
	89% {opacity: 0;}
	90% {opacity: 1;}
}
<div id="screen"><b id="prompt" class="idle">&marker;</b></div>

\$\endgroup\$
  • 1
    \$\begingroup\$ Actually, I didn't touch on the CSS, but you could use and :after element to represent the caret and omit it from the code altogether. As you are adding a class anyhow, it should be hard to make the after blink when the class is applied. \$\endgroup\$ – somethinghere Jul 16 '15 at 10:23
  • \$\begingroup\$ @somethinghere I can't control pseudo-elements over Javascript. As far as I know. \$\endgroup\$ – Ismael Miguel Jul 16 '15 at 10:25
  • \$\begingroup\$ No but you can define them in CSS (like you are doing anyhow) and use the class to modify the appearance of the pseudo element, aka: #prompt:after { content: 'UNICODE for &marker;'; } #prompt.idle:after { animation: blink infinite; } \$\endgroup\$ – somethinghere Jul 16 '15 at 10:26
  • \$\begingroup\$ @somethinghere But why on that way? What would make that way better than O.P.'s way? \$\endgroup\$ – Ismael Miguel Jul 16 '15 at 10:28
  • \$\begingroup\$ You could forget about the caret altogether, that's the adantage. It would eliminate the need to keep in mind adding it after the content etc... I just think it would be faster as you cut out JS code for faster native CSS. I just said you could. \$\endgroup\$ – somethinghere Jul 16 '15 at 10:30
4
\$\begingroup\$

I personally think the main issue with your code is that it can't really be re-used easily. If you want this to be more scalable, and even provide a text input you might want to mimic, you want to be able to print any message from anywhere. The below is a suggestion, and it might not look a lot better, but I believe its slightly clearer. The main thing here is closures and callbacks:

Instead of simply going through your predefined array I have made two functions. The first one is called printer and it will print any string you pass it and end it with printing a newline character (<br />). The second one is called printArray. You just pass an array to this function and it will print all the lines recursively.

A couple of notes since you are starting out on JS: I am using a shorter version of the if/else statement. This is opinion, but I like ommitting the {} when there is only one line to execute, simply because it makes the code less cluttered. But, by all means, add them if you like, since they are personal preference.

I am also using the ternary operator, which can be seen as a short-hand if/else operator:

line[index]
     ? document.createTextNode(line[index])
     : document.createElement('br')

Note: again, this can be on one line but looks clearer on three.

If line[index] does exists, it will use the value defined after ?, otherwise it will use the value after :. Look at it as: True? Then this : Otherwise this;.

var text = document.getElementById('text');
var greeting = ['Hello. I am a console wannabe.',
                'All systems are functioning.',
                'I like pie.'];
text.innerHTML = '<i>▮</i>';

function printer(line, callback){
   var index = 0, 
       delay = 50;

   function character(){
     
     text.insertBefore((line[index]
         ? document.createTextNode(line[index])
         : document.createElement('br')
     ), text.lastChild);
     
     index++;
     
     if(index === line.length + 1) callback();
     else setTimeout(character, delay);
     
   }
   character();
}

function printArray(array, startAtIndex){
    startAtIndex = startAtIndex || 0;
    printer(array[startAtIndex], function(){
      startAtIndex++;
      if(array[startAtIndex]) printArray(array, startAtIndex)
    })
}

// This function can be called from anywhere. Because of delays, though, a better implementation would be a constructor.
printArray(greeting);
body {
    background-color: #000000;
    color: #99ffcc;
    font-family: Courier, mono-space;
}

i {
    font-style: unset;
    font-size: 1em;
    -webkit-animation: blink 1100ms linear infinite;
            animation: blink 1100ms linear infinite;
}

@keyframes blink {
    49% { opacity: 1; }
    50% { opacity: 0; }
    89% { opacity: 0; }
    90% { opacity: 1; }
}

@-webkit-keyframes blink {
    49% { opacity: 1; }
    50% { opacity: 0; }
    89% { opacity: 0; }
    90% { opacity: 1; }
}
<pre id="text"></pre>

Just for the record, I changed div to pre as that's the more relevant element (its made for code display), I also added @webkit-keyframes as I am viewing this on Safari and I added mono-space as a fallback font. Details, but stuff I would do if I were writing it.

Update

I mentioned a better way would be to use a constructor or prototype. That would look like this:

// We will define all our variables in the basic Printer function using this.<var>
function Printer(){
  this.element   = document.createElement('div');
  this.carret    = '<i>▮</i>';
  this.busy      = false;
  this.queue     = [];
  this.index     = 0;
  this.charIndex = 0;
  this.appended  = false;
  this.delay     = 50;
  this.body      = ''
}

// Now we will create the prototype functions.
Printer.prototype = {
  Character: function(char){
    this.body += char;
    this.element.innerHTML = this.body + this.carret;
    return true;
  },
  Printing: function(){
    this.busy = true;
    if(this.queue[this.index][this.charIndex]){
      // Print the character if it exists
      this.Character(this.queue[this.index][this.charIndex]);
      this.charIndex++;
    } else if(
      this.queue[this.index] &&
      this.charIndex === this.queue[this.index].length
    ){
      // Otherwise, print a line break and move on to the next in queue
      this.Character('<br />');
      // Go the the next line in the queu and reset the character index
      this.index++;
      this.charIndex = 0;
    } else if(!this.queue[this.index]){
      // Otherwise, stop the printing.
      this.busy = false;
      return;
    }
    // You have to bind here, as otherwise it will assume 
    // the context of setTimeout() and error out.
    setTimeout(this.Printing.bind(this), this.delay);
  },
  Print: function(line){
    // Add to the existing queue
    this.queue.push(line);
    if(!this.busy) this.Printing();
  },
  PrintArray: function(lines){
    // Add each to the existing queue.
    lines.forEach(function(line){
      this.queue.push(line);
    }, this);
    if(!this.busy) this.Print();
  },
  DOM: function(e){
    // Only allow for one DOM element to be appended!
    if(this.appended) return;
    // Append to DOM element to the supplied element
    e.appendChild(this.element);
    this.appended  = true;
  }
}

// Lets test out printer!
var printer = new Printer();
    printer.DOM(document.body);
    printer.Print('Hello');
    printer.Print('World');
    printer.PrintArray(['This is a message: ', ' I like pie']);
/* Minimised because repeats previous. */
body { background-color: #000000; color: #99ffcc; font-family: Courier, mono-space; }
@keyframes blink { 49% { opacity: 1; } 50% { opacity: 0; } 89% { opacity: 0; } 90% { opacity: 1; }}
@-webkit-keyframes blink { 49% { opacity: 1; } 50% { opacity: 0; } 89% { opacity: 0; } 90% { opacity: 1; }}
i { font-style: unset; font-size: 1em; animation: blink 1100ms linear infinite; -webkit-animation: blink 1100ms linear infinite; }

The main advantage is it's reusable. The functions in the prototype mean you can make multiple printers! You can now do this:

var errorPrinter = new Printer();
var successPrinter = new Printer();
// Use the prototype's DOM function to append them to the <body>:
    errorPrinter.DOM(document.body);
    successPrinter.DOM(document.body);
// Print an error message:
    errorPrinter.Print('An error has occurred!');
// Print a success message:
    successPrinter.Print('This worked out great!');

The advantage is that each of these printers is completely independent from each other, which means it's easier to control them. The DOM elements are separate, their content is separate. But most importantly: when you add a message to the queue, it will not print until everything before it is printed. It also tracks the entire history of what you added to it (its available in, for example, errorPrinter.queue and in textual form in errorPrinter.body).

Update 2: CSS

I just want to mention that we could get rid of the caret altogether and add it using CSS:

div:after { content: '▮'; animation: blink 2000ms linear infinite; }

The advantage is you don't have to worry about adding a character right before the caret, the caret is always at the end because that's what the :after element is for.

Update 3: The Line Break

Just add the charIndex check to see if we are starting a new line here at the top, and also check if index isn't 0 so we don't start with a line break.

Printing: function(){
    this.busy = true;
    if(this.charIndex === 0 && this.index !== 0) this.Character('<br />');
    if(this.queue[this.index][this.charIndex]){
      this.Character(this.queue[this.index][this.charIndex]);
      this.charIndex++;
    } else if(
      this.queue[this.index] &&
      this.charIndex === this.queue[this.index].length
    ){
      this.index++;
      this.charIndex = 0;
    } else if(!this.queue[this.index]){
      this.busy = false;
      return;
    }
    setTimeout(this.Printing.bind(this), this.delay);
}
\$\endgroup\$
  • \$\begingroup\$ It's not that big of a deal, but how would you make it so that the square at the end does not go on a new line at the end? It's actually one of the things I was pondering, as in can you do it in a better manner. \$\endgroup\$ – Gemtastic Jul 16 '15 at 11:39
  • \$\begingroup\$ Instead of printing the line break when there a no characters left, print it before any other characters (aka when charIndex === 0 - at the beginning of the Printing() function. If you also add index != 0 to that if, it should work. I'll update when i'm able to. \$\endgroup\$ – somethinghere Jul 16 '15 at 11:46

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.