AutoIncorrect in Action

AutoIncorrect hosted on GitHub Pages

AutoIncorrect is a project that I made to practice using HTML, CSS, and JavaScript without any libraries like jQuery or React. It accepts an input string from the user and generates an output string that includes realistic typos. It models and displays a keyboard on the screen. It uses the keyboard model to create realistic typos based on the homerow keys, the error frequency and severity settings entered by the user, and the relative position and distance of keys from each other in the keyboard model. To give the feeling of working on an old computer it also animates through the input string, highlights keys on the keyboard display and shows a cursor highlight in both the input and output fields. And it uses the browser AudioContext to make beeping sounds.

The Set Up

AutoIncorrect has one HTML file, index.html, one CSS file, style.CSS, and many javascript files that are contained in the folder called modules.

index.html

The index.html file is pretty simple. The keyboard buttons for the keyboard display are added using JavaScript, so the HTML is just responsible for the basic layout.

  • Import the Roboto font
  • Import the CSS stylesheet
  • Header with the title in it
  • Div that will contain settings checkboxes/sliders
  • The GO button
  • The keyboard div
  • The input container
  • The div behind the input container that will contain the cursor
  • The output container
The index.html file
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
blockName: indexHTML
<!DOCTYPE html>

<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>AutoIncorrect</title>
        <meta name="description" content="Automatic Typos to save you time!" />
        <meta name="author" content="Jacob Wicks" />
        <link
            href="https://fonts.googleapis.com/css?family=Roboto&display=swap"
            rel="stylesheet"
        />
        <link rel="stylesheet" href="style.css" />
    </head>
    <body>
        <h1 class="header header--big">AutoIncorrect</h1>
        <div class="settings"></div>
        <br />
        <button id="addTyposButton">GO!</button>
        <div class="centering-div">
            <div class="container">
                <div class="big__character__display">
                    <div class="current__input">
                        <span
                            class="keyboard__key keyboard__key--big"
                            id="currentInputSpan"
                            >&nbsp;
                        </span>
                        <span class="big" id="arrow">🡆</span>
                        <span
                            class="keyboard__key keyboard__key--big"
                            id="currentOutputSpan"
                            >&nbsp;</span
                        >
                    </div>
                </div>
                <br />
                <div class="keyboard"></div>
            </div>
            <div class="input__output__container">
                <div class="left__side">
                    <div class="input__container">
                        <h3 class="header">Input</h3>
                        <div class="backdrop">
                            <div class="highlights">
                                <!-- cloned text with <mark> tags here -->
                            </div>
                        </div>
                        <textarea id="inputTextarea"></textarea>
                    </div>
                </div>
                <div class="right__side">
                    <h3 class="header">Output</h3>
                    <div class="output"></div>
                </div>
            </div>
        </div>
        <script type="module" src="setup.js"></script>
    </body>
</html>


Use the ‘Roboto’ font from Google.

Gets the stylesheet for autoIncorrect.

The current input and outpouts are displayed here.

The keyboard div will be filled up with keys generated from the model.

User types their input text into the textarea.

The ‘backdrop’ div floats behind the input text. We put a mark element inside the backdrop to make it appear as though the text in the input textarea is highlighted.

Import the JavaScript for autoIncorrect.

style.css

The css file uses the outset border to makes the keyboard keys look 3d. It sets the colors, including bright green for highlighting the current input and red to show when a typo has been made. It also has the custom styling for the slider and checkbox elements.

Keyboard keys with the 3d effect
1
2
3
4
5
6
7
8
9
10
11
12
blockName: keysCSS
/* a keyboard key displays a single letter 
    it will appear in the keyboard
    but also in the big display of inputs
*/
.keyboard__key {
    border-style: outset;
    display: inline-block;
    height: 25px;
    width: 25px;
    text-align: center;
}
Comment boxes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
blockName: commentsCSS
/* the content of the comment
max-height starts at three lines collapsed */
.line_comment_content {
    position: relative;
    max-height: calc(var(--lh) \* 3);
    transition: max-height 1s ease-out;
    overflow: hidden;
    padding-right: 1rem;
}

/* when hovered, max height becomes 100% of the viewport height */
.line_comment_content:hover {
    max-height: 100vh;
}

max-height is calculated using calc, the CSS calculation function. lh is a variable equal to the height of one line of text.

transition defines the animation to be used on a given property. This tells the CSS processor to animate transitions when max-height changes with the ‘ease-out’ animation. 1s makes the animation happen over 1 second.

When the comment is hovered, max-height becomes 100vh, or 100% of the viewport-height. In other words, the limit on the comment height changes from 3 lines of text to the full height of the window. This is used on desktop. Some mobile devices can do a hover input, but it is not convenient, consistent, or intuitive.

Big output display keys
1
2
3
4
5
6
7
/* big keyboard keys are used to show the current character */
.keyboard__key--big {
    width: 120px;
    height: 120px;
    font-size: 6em;
    background-color: #c0c0c0;
}
Cursor
1
2
3
4
5
6
7
8
9
10
11
/* the mark element is used for highlighting text
    We use it to simulate a computer cursor*/
mark {
    border-radius: 3px;
    color: black;
    background-color: #39ff14;
}

.mark__mistake {
    background-color: red;
}

Heres an example of mark element: See?

Highlight and mistake colors
1
2
3
4
5
6
7
8
9
10
blockName: cssColors
/* key is highlighted */
.highlighted {
    background-color: #39ff14;
}

/* the key is a mistake, so it is red */
.mistake {
    background-color: red;
}

Neon green.

Backdrop element
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* the backdrop div sits behind the input textarea
    We put mark elements inside it to fake like we have a cursor in the textarea*/
.backdrop {
    position: absolute;
    z-index: 1;
    border: 2px solid #50b818;
    background-color: #fff;
    overflow: auto;
    pointer-events: none;
}

/* dimensions of the textarea and backdrop must be the same
so that they overlap completely
output should match for aesthetics
*/
.backdrop,
textarea,
.output {
    width: 350px;
    height: 180px;
}
Slider and Checkbox styling
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/* slider */
.slider {
    -webkit-appearance: none;
    width: 90%;
    height: 25px;
    background: #c0c0c0;
    outline: none;
    opacity: 0.7;
    -webkit-transition: 0.2s;
    transition: opacity 0.2s;
}

/* darken (less transparent) when hovered */
.slider:hover {
    opacity: 1;
}

/* slider handle */
.slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 25px;
    height: 25px;
    background: black;
    cursor: pointer;
}

/* slider handle for firefox */
.slider::-moz-range-thumb {
    width: 25px;
    height: 25px;
    background: black;
    cursor: pointer;
}

/* Checkboxes */
/* Create a custom checkbox */
.checkbox__container {
    display: inline-block;
    position: relative;
    padding-left: 35px;
    margin-bottom: 12px;
    cursor: pointer;
    font-size: 22px;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
}

.float__right {
    float: right;
    margin-right: 35px;
}

/* Hide the browser's default checkbox */
.checkbox__container input {
    position: absolute;
    opacity: 0;
    cursor: pointer;
    height: 0;
    width: 0;
}

.checkmark {
    position: absolute;
    top: 0;
    left: 0;
    height: 25px;
    width: 25px;
    background-color: #eee;
}

/* On mouse-over, add a grey background color */
.checkbox__container:hover input ~ .checkmark {
    background-color: #ccc;
}

/* When the checkbox is checked, add a blue background */
.checkbox__container input:checked ~ .checkmark {
    background-color: black;
}

/* Create the checkmark/indicator (hidden when not checked) */
.checkmark:after {
    content: '';
    position: absolute;
    display: none;
}

/* Show the checkmark when checked */
.checkbox__container input:checked ~ .checkmark:after {
    display: block;
}

/* Style the checkmark/indicator */
.checkbox__container .checkmark:after {
    left: 9px;
    top: 5px;
    width: 5px;
    height: 10px;
    border: solid #50b818;
    border-width: 0 3px 3px 0;
    -webkit-transform: rotate(45deg);
    -ms-transform: rotate(45deg);
    transform: rotate(45deg);
}


setup.js

setup.js is imported into index.html as a module so that it can import javascript from the other files in the modules folder. Setup does the following:

Get references to commonly used page elements
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
blockName: display
 //the display object contains references to commonly accessed elements in the page
    //it will be passed as an argument to functions that need it
    const display = {
        //these are the big displays above the keyboard
        currentInputSpan: document.getElementById('currentInputSpan'),
        currentOutputSpan: document.getElementById('currentOutputSpan'),
        arrowSpan: document.getElementById('arrow'),

        //the text area where the user typed their input
        inputTextarea: document.getElementById('inputTextarea'),

        //the div where the output is displayed
        outputDiv: document.getElementsByClassName('output')[0],

        settingsDiv: document.getElementsByClassName('settings')[0],
    };

Getting the references during setup means we can just pass the display object instead of finding the objects on the page again when they need to be manipulated.

Creates the keyboard. This is how the keyboard is displayed on screen: keyboard

The onscreen display is made using a model in the form of an array of arrays of objects that contain an uppercase value and a lowercase value. The typos are also generated by looking at the relationship of keys inside this model.

Modeling the Keyboard

The getKeyBoard function creates the model of the keyboard. The model of the keyboard is created from these strings:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//string for the lowercase characters in row0
const row0Lower = '`1234567890-=';
//string for the uppercase characters in row0
const row0Upper = '~!@#\$%^&\*()\_+';

    const row1Lower = 'qwertyuiop[]';
    const row1Upper = 'QWERTYUIOP{}|';

    //double quotes because the single quote is in this row
    const row2Lower = "asdfghjkl;'";
    const row2Upper = 'ASDFGHJKL:"';

    const row3Lower = 'zxcvbnm,./';
    const row3Upper = 'ZXCVBNM<>?';

The makeRow function creates a keyboard row array from two strings.

1
2
3
4
5
6
7
8
9
//makeRow accepts two strings and returns an array of objects with lower and upper properties
//we'll use it to make row arrays for our keyboard model
const makeRow = (rowLowercase, rowUppercase) =>
    Array.from(rowLowercase).map((lower, index) => ({
        //the lowercase character
        lower,
        //the uppercase character
        upper: rowUppercase.charAt(index),
    }));

The getKeyboard function returns an array of arrays made using makeRow:

1
2
3
4
5
6
7
return [
    makeRow(row0Lower, row0Upper),
    makeRow(row1Lower, row1Upper),
    makeRow(row2Lower, row2Upper),
    makeRow(row3Lower, row3Upper),
    row4,
];

The offset values determine how many key-widths the start of a row is offset from the left side:

offsets: [0, 1, 1.5, 2, 4],

The offset values are used in both the display and when calculating the distance between keys.

Displaying the keyboard on the screen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
//displays all the keys from the keyboard on the screen
//as span elements inside the keyboard div
const setupKeyboardDisplay = ({ keyboard, offsets }) => {
    //use html api to select the keyboard div
    //reference this variable to add keys to the keyboard
    const keyboardDiv = document.querySelector('.keyboard');

    //add each key in keyboard to the keyboardDiv
    keyboard.forEach((row, rowIndex) => {
        //the div that represents this row
        const thisRowDiv = document.createElement('div');
        //we can find it again later by searching for id
        thisRowDiv.setAttribute('id', `row${rowIndex}`);
        //the css class for keyboard row
        thisRowDiv.setAttribute('class', 'keyboard__row');

        //if the keys are offset, make a blank key to put on the left
        if (!!offsets[rowIndex] && rowIndex < 4) {
            const blankKeySpan = document.createElement('span');

            //assign the css class
            blankKeySpan.setAttribute('class', 'keyboard__blank');

            //without a non breaking space the span will render with a height offset
            blankKeySpan.innerHTML = '&nbsp';

            //width of a key - 25 pixels - times the offset width
            blankKeySpan.style.width = `${Math.ceil(
                25 * offsets[rowIndex]
            ).toString()}px`;

            if (rowIndex === 3) {
                blankKeySpan.innerHTML = 'Shift';
                blankKeySpan.setAttribute('id', 'shiftKey');
            }

            //add the blank to the row
            thisRowDiv.appendChild(blankKeySpan);
        }

        if (rowIndex < 4) {
            row.forEach(key => {
                //the space breaks. the non-breaking space doesn't
                key.lower === ' ' && (key = '&nbsp');
                const thisKeySpan = document.createElement('span');
                thisKeySpan.setAttribute('id', `keyCharacter${key.lower}`);
                thisKeySpan.setAttribute('class', 'keyboard__key');
                thisKeySpan.innerHTML = key.lower;
                thisRowDiv.appendChild(thisKeySpan);
            });
        } else {
            //it's the spacebar, which gets special treatment
            //if you change the model, you may have to change this code
            const spacebarSpan = document.createElement('span');

            //keyCharacter followed by a space ' ';
            spacebarSpan.setAttribute('id', 'keyCharacter ');
            spacebarSpan.setAttribute('class', 'keyboard__spacebar');

            thisRowDiv.appendChild(spacebarSpan);
        }
        keyboardDiv.appendChild(thisRowDiv);
    });

};
Use the typo functions to add typos to the header
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
blockName: addTyposToHeader
//uses the typo generating code to introduce typos to the big title header
const addTyposToHeader = keyboardModel => {
    //get reference to the big title header
    const bigHeader = document.getElementsByClassName('header--big')[0];
    //grab the innerHTML as an inputString
    const inputString = bigHeader.innerHTML;

    //create a settings object
    const settings = {
        extraCharacters: 5,
        frequency: 10,
        missedCharacters: 15,
        severity: 1,
        transposition: 30,
    };

    //use the typo functions to add typos to the header
    const resultArray = getResults({
        inputString,
        keyboardModel,
        settings,
    });

    //reduce it to a string
    const headerWithTypos = resultArrayToString(resultArray);

    //set the innerhtml to the string with typos
    bigHeader.innerHTML = headerWithTypos;

};
Set initial Settings
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
blockName: settings 
    //the settings for the typos
    const settings = {
        //audioContext will store an AudioContext player after the createAudioContext function has been called
        audioContext: undefined,
        //if animation is true, the animation of input to output will play
        //if false, output will be displayed instantly
        animation: true,

        //if beeps is true, you'll hear some excellent beeping noises
        beeps: true,

        //%chance to add an extra character on a typo
        extraCharacters: 20,

        //frequency is a number 0-100
        //it is the % chance that a typo will occur
        frequency: 20,

        //%chance to miss a character on a typo
        missedCharacters: 20,
        //weightSeverity is a number 1-4
        //it changes how likely it is that typos are more 'severe', or farther from the intended key
        //the higher the number, the more likely it is that a typo will select a key that is far from the intended key
        //the lower the number, the more likely a typo will select a key close to the indended key
        severity: 2,

        //the likelihood of transposing the current character with the next character
        transposition: 40,
    };

The settings div is where the user can change the settings. This is what it looks like: settings div

Set up the settings div, where the user can change settings
1
2
3
4
5
6
7
8
9
10
11
12
13
    blockName: setUpSettingsDiv
    //sets up the settings div by adding sliders and checkboxes
    const setupSettingsDiv = ({ display, settings }) => {
        const { settingsDiv } = display;

        //add the sliders
        addSlidersToSettingsDiv({ settingsDiv, settings });

        //add the checkboxes
        addCheckboxesToSettingsDiv({ settingsDiv, settings });

    };
    
addSlidersToSettingsDiv
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
blockName: addSlidersToSettingsDiv
const addSlidersToSettingsDiv = ({ settingsDiv, settings }) => {
    //% chance that a typo will occur
    const frequencySlider = makeSlider({
        label: 'Typo Frequency: ',
        name: 'frequency',
        value: settings.frequency,
    });

    //weights how bad the typo will be
    //worse typos result in a character farther from the target character
    const severitySlider = makeSlider({
        label: 'Average Severity: ',
        name: 'severity',
        min: 1,
        max: 4,
        value: settings.severity,
    });

    //% chance that a typo will add an extra character
    //will not be called if the typo is a missed character
    //Examply: intended to type 'why?' h is typoed to j, becomes 'wjjy?'
    const extraCharactersSlider = makeSlider({
        label: 'Extra Characters: ',
        name: 'extraCharacters',
        value: settings.extraCharacters,
    });

    //% chance that a typo will be a missed character
    //nothing will be typed
    //Example: intended to type 'why?' h is missed, becomes 'wy?'
    const missedCharactersSlider = makeSlider({
        label: 'Missed Characters: ',
        name: 'missedCharacters',
        value: settings.missedCharacters,
    });

    //% chance that a typo will transpose the intended letters
    //Example: Intended to type 'why?', w and h transposed becomes 'hwy?'
    const transpositionSlider = makeSlider({
        label: 'Transposition: ',
        name: 'transposition',
        value: settings.transposition,
    });

    //add each slider to the settingsDiv
    settingsDiv.appendChild(frequencySlider);
    settingsDiv.appendChild(severitySlider);
    settingsDiv.appendChild(transpositionSlider);
    settingsDiv.appendChild(missedCharactersSlider);
    settingsDiv.appendChild(extraCharactersSlider);

    //the controlSlider function bound to the settings object
    const bindSlider = name => controlSlider.bind(null, name, settings)();

    //hook sliders to the settings object and value display elements
    bindSlider('frequency');
    bindSlider('severity');
    bindSlider('extraCharacters');
    bindSlider('missedCharacters');
    bindSlider('transposition');


controlSlider
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
blockName: controlSlider
//control slider accepts a name
//and hooks up the slider element to the value in the settings object
//and the value shown in the display element
const controlSlider = (name, settings) => {
    //find the slider
    const thisInput = document.getElementById(`${name}Slider`);

    // Update the current slider value (each time you drag the slider handle)
    thisInput.oninput = function() {
        const setting = this.value;
        const thisDisplay = document.getElementById(`${name}Display`);
        settings[name] = setting;
        thisDisplay.innerHTML = setting;
    };

};


makeSlider
1
2
3
4
5
6
7
8
9
10
11
12
blockName: makeSlider
//makes a slider element with the given label and value
const makeSlider = ({ label, max = 100, min = 0, name, value }) => {
    const slider = document.createElement('div');

    slider.setAttribute('class', 'slider__container');

    slider.innerHTML = `${label}<span id="${name}Display">${value}</span><input type="range" min="${min}" max="${max}" value="${value}" class="slider" id="${name}Slider"/>`;

    return slider;

};


Make the GO button work
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    blockName: addTyposEventListener 
        //make clicking the 'GO' button invoke the addTypos function
        document.getElementById('addTyposButton').addEventListener('click', () => {
            //if beeps are on and audioContext is undefined, call the makeAudioContext function
            //we don't do this until the user clicks
            //because you can't create an audio context without some action by the user
            settings.beeps && !settings.audioContext && makeAudioContext(settings);

            //use .bind to pass the keyboardModel and settings to the addTypos function
            addTypos.bind(null, {
                display,
                keyboardModel,
                settings,
            })();
        });

    


How the Typos Work

First, the program generates a model of a keyboard from strings that represent the uppercase and lowercase characters in each row. The model is an array of arrays, with each array representing a row of uppercase and lowercase characters. This model is used to generate the display and to generate typos by finding keys that are near the target key or on the path from the target key to the closest key in the homerow. There are functions for finding a specific key on the keyboard, finding the keys on the path from one key to another, and finding keys that are near to a key.

There is also an array of offset values that determine how many key-widths the start of a row is offset from the left side. The offset values are used in both the display and when calculating the distance between keys.

The Settings box contains inputs for each of the settings: frequency, severity, transposition, missedCharacters, and extraCharacters. The Settings box also lets the user turn animation and sound on or off.

The user types an input string into the input field. When the user hits the GO! button, the program reads the input string, generates an array of input and output characters, then displays the output in the output div. If the user has animations or sound turned on, then the animations and/or sound will play as the output is displayed.

Making a Typo

For each character in the input string, we first see if it is present in the keyboard model. The model just has the upper and lower case keys from my laptop keyboard, so it is possible that the user will enter a key that isn’t in the model. If it isn’t in the model string then we add the character to the output array without making a typo.

Use the Settings to Decide if a Typo has Been Made

If the character is present in the keyboard model then we use the settings to see if we should generate a typo. If a randomly generated integer from 1-100 is less than frequency, a typo has been made. The typo function is called with the keyboard model and the settings, and it returns the mistake.

The typo function uses the model of the keyboard to figure out which key has been hit by mistake. Reacll that the model of the keyboard is an array of arrays. The typo function finds the index of the target key. Then it finds all the keys that are within 1 or 2 keys of the target key. It also finds all the keys on the path from the target key to the closest key in the homerow. The array of keys on the path is sorted from closest to the target to farthest.

Once it has found all the possibly typos from the target key, the typo function calls a weighted random number generator. The weighted random number generator may return the full range from min to max, but tends to pick numbers towards the min value. The higher the value of the ‘severity’ setting, the more likely the weighted random number generator is to return a higher value. The weighted number generator is used to determine if a key on the path or one of the surrounding keys is selected, and then used to select the key on the path. Using the weighted number generator to choose from the array of keys on the path makes it more likely that the typo will be a key close to the target key, which makes the typos more realistic.

Displaying the Typos

If the user doesn’t have animation turned on, then the result array is reduced to an output string and put into the output container on the screen.

If the user has animation turned on, then the animation function is called with the result array. For each character in the input string the animation function highlights the target key on the keyboard, puts a cursor in the input textarea, puts a cursor in the output div, and highlights the result in red on the keyboard if a mistake is made. The input and output characters are also displayed in the large boxes above the keyboard.

Making Noise

If sound is turned on the web browser audioContext will be used to make a beeping noise for each input character. The tone of the beep corresponds to the row on the keyboard. If a typo is made then the tone changes to indicate a typo.

The Functions

Random Numbers

Generating random numbers is an important part of deciding whether to make a typo for any given input character. Random numbers are also used to select the character that results from the typo. Here is the getRandomInt function. It can generate random integers between any requested values. By default it will generate a number between and including 1-100. getRandomInt is used throughout the program wherever we need a random integer.

getRandomInt
1
2
3
4
5
6
7
8
9
10
11
12
13
blockName getRandomInt
//returns a random integer
//if no min and max, 1-100
const getRandomInt = (min = 1, max = 100) => {
    min = Math.ceil(min);

    max = Math.floor(max);

    max < min && (max = min + 1);

    return Math.floor(Math.random() * (max - min + 1)) + min;

};

Math.ceil rounds a number up to the nearest integer.

Math.floor rounds a number down to the nearest integer.

If max was less than min, set it to min + 1.

Math.random generates a decimal between 0 and 1. Multiply it by the result of max - min + 1. Then add min to the result to ensure that the minimum result is equal to the requested minimum value.

severityWeightedRandomNumber is another random number generating function. It will generate a random number inside the requested range, but is designed to be more likely to generate a lower number.

It is used to pick typos from the array of possible typos for a given input. The arrays are sorted from the most likely key to be struck to the least likely. So by picking from the low end of the range more often we generate the most likely typos more than the least likely.

Severity is a setting that the user can change. Severity makes the typos worse by making it more likely to pick keys that are farther away from the intended key on the keyboard. Increasing the value of severity will make severityWeightedRandomNumber more likely to pick a higher number.

severityWeightedRandomNumber
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
blockName severityWeightedRandomNumber
//returns a random integer betweeen/including min and max
//weighted toward returning min
//the higher the value of severity, the more likely to return max
const severityWeightedRandomInt = (min, max, severity) => {
    //increment min and max for rounded calculation
    min++;
    max++;

    //this generates a number that is weighted toward the min value
    //max divided by a random percentage of max plus min
    const rounded = Math.round(max / (Math.random() * max + min)) - 1;

    //value of severity may be 1-4
    //if severity is greater than a random int,
    if (getRandomInt(1, 3) < severity) {
        //decrement max back down to provided value
        max--;
        //add a random int up to the value of severity to the result
        const result = rounded + getRandomInt(0, severity);

        //if result is greater than max, return max
        return result > max ? max : result;
    } else return rounded;

};


To make realistic typographical errors, we first take the input character, locate the row and key index of the target character on the keyboard. Then we find the nearest key that is in the homerow. The keys on the path from the nearest homerow key to the target key are the most likely typos. To complete the set of possible typos we also find all keys within 1 key of the target key and all keys within 2 keys of the target key.

The keyboard navigation functions include

  • distanceFrom
  • getClosestHomeRow
  • getIndexOfCharacter
  • getKeysAround

Getting the Index of a Character on the Keyboard

To get the index of a character in the keyboard, loop through each row. In each row, loop through each column. If the uppercase or lowercase item is the target character, return the row and column indexes. If the match for the target character is found in the uppercase, return isUppercase = true. If the target character is not found, return undefined.

getIndexOfCharacter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//getIndexOfCharacter accepts a letter and finds it in the keyboard
//it will return an object with row, column, and isUppercase boolean
//if the character is not found, it will return undefined
const getIndexOfCharacter = (character, keyboard) => {
//if there's no keyboard or no character, we're not going to find an index
if (!keyboard || !character) return undefined;

    let returnIndex;

    //loop through each row
    for (let row = 0; row < keyboard.length; row++) {
        //the Array.findIndex method used below just returns a number, not an object
        //so create a variable to track if the character is uppercase out here
        let isUppercase = false;

        //pass a callback function to Array.findIndex
        //to examine each element in the array and return the index of the element that returns true
        const column = keyboard[row].findIndex(characters => {
            //if the character is not a space, and matches the upper case
            if (character !== ' ' && characters.upper === character) {
                //it's uppercase
                isUppercase = true;
            }

            //findIndex will return the index when this expression is true
            return (
                characters.upper === character || characters.lower === character
            );
        });

        //if no match is found, column will be -1
        //so if column > -1, a match has been found
        if (column > -1) {
            //set the values in returnIndex
            returnIndex = {
                row,
                column,
                isUppercase,
            };
            //and return it
            return returnIndex;
        }
    }

    return returnIndex;

};


Finding the Distance from One Key to Another

Finding the distance from one key to another is an important part of simulating the most likely typographical errors. The distanceFrom function accepts two indexes and the keyboardModel object. If the optional argument returnPath is true then the array of keys on the path from index1 to index2 will be returned in addition to the distance.

To find the distance from one key to another, we’ll decide which index is the ‘upper’ index and which is the ‘lower’ index. The upper index is the index with the lowest row number. If both indexes have the same row number, then the upper index is the index with the lowest column. We start at the lower index and move up the keyboard one key at a time to the upper index.

A single move goes from one key in the model to another adjacent key. A single move can either

  • decrease the row number by one- a vertical move
  • decrease or increase the column number by one - a horizontal move
  • decrease the row number by one and decrease or increase the column number by one - a diagonal move

We’ll increment the distance every time we move. Once we reach the upper index, we have determined the distance and are ready to return a result.

An interesting part of solving this problem is how to deal with the offsets. Each row of keys is offset from the left side of the keyboard by some amount. The offsets in the model are the same as on my laptop keyboard. This is what the model looks like on the screen:

keyboard

The offsets make some keys inaccessible by a diagonal move. Consider the diagonal move from ‘s’ to ‘c’. Row 2, column 2 is ‘s’. Row 3, column 3 is ‘c’. Without the offsets, you could make a single diagonal move up one row and over one column to move from ‘c’ to ‘s’. But if you look at the keyboard above, ‘s’ is not adjacent to ‘c’. The way we account for that in the model is whenever

distanceFrom
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
//accepts two indexes, returns a number and
//if returnPath === true, also an array of keys on the path from index1 to index2
//the first index is the target key
const distanceFrom = ({ index1, index2, keyboardModel, returnPath }) => {
//keyboard is the array of arrays with elements containing upper and lowercase characters
//the offsets are how far each row is shifted relative to others
const { keyboard, offsets } = keyboardModel;

    let distance = 0;
    //if returnPath, make an array of each key on the path
    //the path is an array of the keys on the path from index1 to index2
    //not including index1, because index1 is the target key
    const keysOnPath = [keyboard[index2.row][index2.column]];

    //if they are the same key, return
    if (index1.row === index2.row && index1.column === index2.column)
        return returnPath
            ? {
                  distance,
                  path: [],
              }
            : distance;

    //the upper index is the character closer to the top left of the keyboard
    //if index 1 and 2 are on the same row,
    // upperIndex will be the character with the lower column
    const index1IsUpper =
        index1.row === index2.row
            ? index1.column < index2.column
            : //else, upperIndex will be the character with the lower row
              index1.row < index2.row;

    const upperIndex = index1IsUpper ? index1 : index2;

    const lowerIndex = index1IsUpper ? index2 : index1;

    //the column of the index closer to the bottom of the keyboard (higher row number)
    //is offset from the from the column of the index closer to the top (lower row number)
    //by offsets[higherRow] - offsets[lowerRow]
    let offset = offsets[lowerIndex.row] - offsets[upperIndex.row];

    //start the currentPosition at the lower index
    //we'll loop and move 1 key at a time until we reach the upperIndex
    let currentPosition = {
        row: lowerIndex.row,
        column: lowerIndex.column,
    };

    //use while loop to move from currentPosition to upperIndex
    while (
        currentPosition.row !== upperIndex.row ||
        currentPosition.column !== upperIndex.column
    ) {
        //rowDifference will always be 0 or positive number
        const rowDifference = !!(currentPosition.row - upperIndex.row);

        //columnDifference may be 0, or positive or negative
        const columnDifference = currentPosition.column - upperIndex.column;

        if (rowDifference && columnDifference) {
            //large offsets make diagonals more costly
            //if the offset is greater than 0.5 when making a diagonal move
            //distance increases by one
            //this lets you correctly model the distance between keys that are staggered on the keyboard
            if (offset > 0.5) {
                offset--;
                distance++;
            }

            //make a diagonal move by decrement row and adjust column + or - as needed
            //decrement row
            currentPosition.row--;
            //if columnDifference is positive, decrement. if negative, increment
            columnDifference > 0
                ? currentPosition.column--
                : currentPosition.column++;
        } else if (rowDifference && !columnDifference) {
            //rowDifference is the difference between the lowerIndex.row and upperIndex.row
            //because lowerIndex is by definition below the upperIndex or on the same row in the keyboard model
            //rowDifference will always be positive or 0, so only decrement
            currentPosition.row--;
        } else if (columnDifference && !rowDifference) {
            //columnDifference is the difference between the columns
            //it can be positive or negative or 0,
            //depending on if the lowerIndex column is to the left or right of the upperIndex column
            columnDifference > 0
                ? currentPosition.column--
                : currentPosition.column++;
        }
        //if currentPosition is not index1
        //add currentPosition to keysOnPath array
        if (
            currentPosition.row !== index1.row ||
            currentPosition.column !== index1.column
        ) {
            //we want the array keysOnPath to be in the order Closest to index1, furthest from index1
            //if index1 is the upper index
            //then we are approaching closer to index1 with each move => use Array.unshift
            //if index1 is the lower index
            //then we are moving further away from it with each move => use Array.push
            index1IsUpper
                ? keysOnPath.unshift(
                      keyboard[currentPosition.row][currentPosition.column]
                  )
                : keysOnPath.push(
                      keyboard[currentPosition.row][currentPosition.column]
                  );
        }

        //increment distance by 1
        distance++;
    }

    //if returnPath, then return the distance and the array of keys on the path
    return returnPath
        ? {
              distance,
              path: keysOnPath,
          }
        : //else just return distance
          distance;

};


Adding Typos

Using the settings to decide whether to generate a typo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
blockName: getResults
//getResults takes an input string and returns a resultArray
//resultArray contains elements with { inputCharacter, resultCharacter }
const getResults = ({ inputString, keyboardModel, settings }) =>
    inputString &&
    //use Array.from to make an array
    //then use Array.reduce to return the result character
    //resultCharacters will either be === inputChracter,
    //a typo (different character or missed character),
    //or transposed with the next input
    Array.from(inputString).reduce((results, inputCharacter, index, array) => {
        //a transposition event generates two results, so if there is already an element at this index
        //this result is the transposition of the previous input character and we don't need to generate another result
        if (results[index]) return results;

        //if a random int is lower than the frequency setting, the resultCharacters is a typo
        if (getRandomInt() < settings.frequency) {
            //if a random int is lower than the transposition setting, then the typo is a transposition
            //this index will be the input character of the inputCharacter at inputString[index + 1]
            //results[index + 1] will be the inputCharacter from this index
            const nextInputCharacter = array[index + 1];

            if (
                getRandomInt() < settings.transposition &&
                nextInputCharacter !== inputCharacter
            ) {
                const result = {
                    inputCharacter,
                    resultCharacters: nextInputCharacter,
                };

                const nextResult = {
                    inputCharacter: nextInputCharacter,
                    resultCharacters: inputCharacter,
                };

                //push both result elements to the result array
                results.push(result);
                results.push(nextResult);
            } else {
                //resultCharacters are a typo
                //call the typo function to generate the typo
                const result = {
                    inputCharacter,
                    resultCharacters: typo({
                        character: inputCharacter,
                        keyboardModel,
                        settings,
                    }),
                };
                results.push(result);
            }
        } else {
            //if there's no typo, then the resultCharacters is the same as the inputCharacter
            results.push({ inputCharacter, resultCharacters: inputCharacter });
        }
        return results;
    }, []);


Generating a mistake calls the typo function. The typo function makes use of the functions discussed above, distanceFrom, getClosestHomeRow, getIndexOfCharacter, and getKeysAround. It also uses the getMistakeCharacter function.

typo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
blockName: typo
//the typo function is called when we want to make a typo
//it takes a single character as an input, and returns a single character
const typo = ({ character, keyboardModel, settings }) => {
    const { keyboard, offsets } = keyboardModel;

    //get the index of the character
    const inputCharacterIndex = getIndexOfCharacter(character, keyboard);

    //if no index, it's not a character on our keyboard model
    //return the character unaltered
    //you could change this to a random character if you want
    if (!inputCharacterIndex) return character;

    const { row, column, isUppercase } = inputCharacterIndex;

    //get a reference to the character element in the keyboard array
    const keyboardCharacter = keyboard[row][column];

    //could add a chance of making an error with the case of the result
    const desiredCase = isUppercase ? 'upper' : 'lower';

    //the character element may have stored typos if it has been generated before
    //if there's no stored typos, generate the values
    if (!keyboardCharacter.typos) {
        // get the closest homerow index
        const { index: homerowIndex } = getClosestHomeRow(
            inputCharacterIndex,
            keyboardModel
        );

        // get the path from the closest homerow to the character
        const path =
            //spacebar is not hit with the homerow fingers, so it gets an empty array
            character === ' '
                ? []
                : distanceFrom({
                      index1: inputCharacterIndex,
                      index2: homerowIndex,
                      keyboardModel,
                      returnPath: true,
                  }).path;

        // get the keys within 1
        // some of these errors are pretty bad, because they are on the opposite side of the target
        //from the homerow key
        const keysWithin1 =
            character === ' '
                ? //spacebar has multiple columns, so it's kind of a unique case
                  //easy enough to just special case it here
                  //grab a slice of row 4
                  [...keyboard[3].slice(1, 7)]
                : getKeysAround({
                      index: inputCharacterIndex,
                      keyboard,
                      offsets,
                  });

        // get the keys within 2
        //these are very bad errors - quite far from target key
        const keysWithin2 = getKeysAround({
            index: inputCharacterIndex,
            keyboard,
            offsets,
            distance: 2,
        });

        //store generated typo possibilities in keyboard model
        //so they don't have to be generated again
        keyboardCharacter.typos = {
            path,
            keysWithin1,
            keysWithin2,
        };
    }

    const { typos } = keyboardCharacter;

    //resultCharacters start out equal to the input character
    let resultCharacters = character;

    //if a random int is less than missedCharacters
    if (getRandomInt() < settings.missedCharacters) {
        //fail to type a character at all
        resultCharacters = undefined;
    } else {
        //didn't fail to type a character, so get a mistakeCharacter
        resultCharacters = getMistakeCharacter({
            desiredCase,
            typos,
            settings,
        });
    }

    //has a chance to generate a single extra character, for 2 total
    //if you wanted to, you could add a chance to generate multiple extra chararacters
    //all the other code should handle it fine
    if (
        resultCharacters !== undefined &&
        getRandomInt() < settings.extraCharacters
    ) {
        resultCharacters += getMistakeCharacter({
            desiredCase,
            typos,
            settings,
        });
    }

    return resultCharacters;

};


The getMistakeCharacter function uses the arrays of possible mistake keys, the settings, and the severityWeightedRandomInt function to choose the mistake character.

getMistakeCharacter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
blockName: getMistakeCharacter 
//needs settings, path, keyswithin1, keyswithin2, desiredcase
const getMistakeCharacter = ({
    //will return an upper or lower case character as desired
    desiredCase,
    //errors
    typos,
    //the error settings the user has chosen
    settings,
}) => {
    //get the arrays of possible typos
    const { path, keysWithin1, keysWithin2 } = typos;

    const { severity } = settings;
    //the rollTable defines the chances of selecting from the arrays
    //given the value of settings.severity
    //as severity gets larger, you become more likely to generate from the keysWithin2 array
    //when you exceed the keysWithin1 value
    const rollTable = {
        path: {
            1: 90,
            2: 75,
            3: 55,
            4: 35,
        },
        keysWithin1: {
            1: 100,
            2: 99,
            3: 90,
            4: 70,
        },
    };

    //roll is 1-100
    const roll = getRandomInt();

    if (roll < rollTable.path[severity]) {
        //75% chance key on path
        //if path.length = 0, choose from keyswithin1 instead
        return !!path.length
            ? path[severityWeightedRandomInt(0, path.length - 1, severity)][
                  desiredCase
              ]
            : keysWithin1[
                  severityWeightedRandomInt(0, keysWithin1.length - 1, severity)
              ][desiredCase];
    } else if (roll < rollTable.keysWithin1[severity]) {
        //return a key within 1 of the target key
        return keysWithin1[
            severityWeightedRandomInt(0, keysWithin1.length - 1, severity)
        ][desiredCase];
    } else {
        //return a key within 2 of the target key
        //use unweighted random, they are all dire
        return keysWithin2[getRandomInt(0, keysWithin2.length - 1)][
            desiredCase
        ];
    }

};


Animating output

If the user has animations turned on, then the output characters will be displayed one at a time as the cursor moves through the input and output boxes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 //if animation setting is true, start the animation loop by invoking it with 0
    if (settings.animation) {
        //get the keyboard out of the keyboardModel object
        const { keyboard } = keyboardModel;

        animationLoop({
            display,
            index: 0,
            inputString,
            keyboard,
            prev,
            resultArray,
            settings,
        });
    }

Using setTimeout and recursion to iterate through the results array

The animationLoop function uses setTimeout and calls itself recursively to iterate through the results array generated by getResults. It calls displayOutputAtIndex to do the work of displaying the output on the page.

animationLoop
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
blockName: animationLoop
//animation loop will call displayOutputAtIndex for each character in the inputString
//then it will wait 1 second and clear the highlights from the keyboard
const animationLoop = ({
    display,
    index,
    inputString,
    keyboard,
    prev,
    resultArray,
    settings,
}) => {
    //use setTimeout to delay execution of the displayOutput
    setTimeout(() => {
        //call the displayOutputAtIndex function
        //displayOutputAtIndex makes all the visible changes to the page
        displayOutputAtIndex({
            display,
            index,
            inputString,
            keyboard,
            prev,
            resultArray,
            settings,
        });

        //if there are still characters left in the input string, recursively call animationLoop with the next index
        if (index++ < inputString.length - 1) {
            animationLoop({
                display,
                index,
                inputString,
                keyboard,
                prev,
                resultArray,
                settings,
            });
        } else {
            //if we have done every character in the input string
            resetDisplay({
                display,
                inputString,
                keyboard,
                prev,
            });
        }
    }, 500);

};


displayOutputAtIndex makes all the changes visible on the page. It displays the current input and current output in the big fields at the top of the screen. It calls advanceOutputCursor to take care of cursor animation.

displayOutputAtIndex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
blockName: displayOutputAtIndex
//display output at index takes the result array and the index
//and displays the input character, the result character
//and highlights the intended key and the mistakes
//it also calls the advance output cursor function that moves the cursor
//in the input textarea and output div
const displayOutputAtIndex = ({
    //object containing references to display elements on the page
    display,

    //the target index in the result array
    index,

    //the complete input string, used to highlight the input textarea
    inputString,

    //array of arrays representing the keyboard
    keyboard,

    //object containing values from the previous loop
    prev,

    //the array of input characters and result characters
    resultArray,

    //the settings the user has chosen
    settings,

}) => {
//get reference to the elements on the page
const { arrowSpan, currentInputSpan, currentOutputSpan } = display;

    //remove the highlight from the previous key
    //prev.key is a reference to the <span> element
    if (!!prev.key) {
        prev.key.classList.remove('highlighted');
        prev.key.classList.remove('mistake');
    }

    //clear the currentMistakeSpan
    //and remove the mistake styling from the previous mistake key
    if (prev.mistakes) {
        prev.mistakes.forEach(
            prevMistake =>
                prevMistake && prevMistake.classList.remove('mistake')
        );
        arrowSpan.style.color = null;
    }

    //highlight the input field only at the given index
    highlightInputAt({ input: inputString, index });

    const { inputCharacter, resultCharacters } = resultArray[index];

    currentInputSpan.innerHTML = asInnerHTML(inputCharacter);
    currentOutputSpan.innerHTML = asInnerHTML(resultCharacters);

    const charIndex = getIndexOfCharacter(inputCharacter, keyboard);

    //no character index means it's not a character modeled in the keyboard
    if (!charIndex) {
        //we don't generate mistakes for characters not on the keyboard
        advanceOutputCursor({
            display,
            isMistake: false,
            prev,
            resultCharacters,
        });

        //record the resultCharacters for the next loop iteration
        prev.characters = resultCharacters;
        return;
    }

    const { row, column, isUppercase } = charIndex;

    //match the displayed case to the current character
    isUppercase
        ? displayCase('upper', keyboard)
        : displayCase('lower', keyboard);

    //all the key spans are labeled with the lowercase characters
    const inputCharacterLowercase = keyboard[row][column].lower;

    //get the span that represents the input character
    const key =
        inputCharacterLowercase &&
        document.getElementById(`keyCharacter${inputCharacterLowercase}`);

    //highlight the key
    key && key.classList.add('highlighted');

    //set the frequency of the beep noise
    //beep noise is lower if the row is farther down the keyboard
    let frequency = 600;
    !!row && (frequency = -row * 100);
    //duration is 50 milliseconds
    let duration = 50;

    //if the character in the result array doesn't match
    //the input character, then there's a typo, isMistake is true
    const isMistake = resultCharacters !== inputCharacter;

    if (isMistake) {
        //set frequency and duration to the unpleasant mistake noise
        frequency = 100;
        duration = 250;

        //turn the arrow red, indicating a mistake
        arrowSpan.style.color = 'red';

        //when resultCharacters undefined- the typo was failing to type a character
        if (!resultCharacters) {
            //highlight the input key as a mistake
            key && key.classList.add('mistake');

            advanceOutputCursor({
                display,
                isMistake,
                prev,
                resultCharacters,
            });

            prev.characters = undefined;
            //advance output cursor can look at prev.mistakes and see that a mistake was made
            //but also that the mistake was not a key
            prev.mistakes = [null];
        } else {
            //resultCharacters may have more than one character
            //so make an array out of the string and iterate through it
            //to get an array of references to the key <span>s
            const mistakeKeys = Array.from(resultCharacters).map(char => {
                const { row, column } = getIndexOfCharacter(char, keyboard);
                const mistakeCharacter = keyboard[row][column].lower;

                const mistakeKey = document.getElementById(
                    `keyCharacter${mistakeCharacter}`
                );

                return mistakeKey;
            });

            //highlight each mistake key in red
            mistakeKeys.forEach(mistakeKey =>
                mistakeKey.classList.add('mistake')
            );

            //advance the output cursor
            advanceOutputCursor({
                display,
                isMistake,
                prev,
                resultCharacters,
            });

            prev.mistakes = mistakeKeys;
        }
    } else {
        arrowSpan.style.color = '#39ff14';

        //no mistake was made
        //if there are result characters
        !!resultCharacters &&
            //advance the output cursor
            advanceOutputCursor({
                display,
                isMistake,
                prev,
                resultCharacters,
            });

        //next iteration, any function that examines the prev.mistakes
        //will see that there were no mistakes made
        prev.mistakes = undefined;
    }

    //set prev.key to the the span element for the current key
    prev.key = key;
    //set prev.characters to the resultCharacters for the current key
    prev.characters = resultCharacters;

    //if beeps are on
    //beep at the frequency for the duration
    const { audioContext, beeps } = settings;
    beeps && beep({ audioContext, duration, frequency });

};


Making the cursor appear

Call highlightInputAt to make the cursor appear in the input field.

highlightInputAt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
blockName: highlightInputAt
//adds a cursor to the 'highlights' div behind the input textarea
//at the requested index, highlighting the text in the input textarea
//if index is undefined, no mark will be added to the input, clearing the highlights
const highlightInputAt = ({ input, index }) => {
    //when clearhighlights is true, no mark element will be added to the input string
    if (!input.length) {
        return;
    } else if (index && input[index]) {
        //but if an index is defined, then a mark will be added, highlighting the index
        input = `${input.slice(0, index)}<mark>${input.charAt(
            index
        )}</mark>${input.slice(index, input.length)}`;
    }

    //get reference to the highlights div
    const highlights = document.getElementsByClassName('highlights')[0];
    //set innerHTML equal to the input text with a mark element
    //this will show through the textarea div in front
    highlights.innerHTML = input;

};


Call advanceOutputCursor to make the cursor appear in the next space in the output field.

advanceOutputCursor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
blockName: advanceOutputCursor 
//move the cursor forward one character
const advanceOutputCursor = ({
    display,
    isMistake,
    prev,
    resultCharacters,
}) => {
    //find the outputDiv
    const { outputDiv } = display;

    //get prevCharacters and prevInnerHTML out of prev
    let { characters: prevCharacters, innerHTML: prevInnerHTML } = prev;

    //break Tag
    const breakElement = '<br/>';

    //the mark element open tag
    const normalOpen = '<mark>';

    //if there's a mistake, add a <mark class="mark__mistake">
    const mistakeOpen = '<mark class="mark__mistake">';

    //if there is a mistake, use the mistake open tag
    //otherwise the normal open tag
    const markOpenTag = isMistake ? mistakeOpen : normalOpen;

    const markCloseTag = '</mark>';

    //if resultcharacters is a newline, add a br element
    isNewline(resultCharacters) && (resultCharacters = breakElement);

    //if prevCharacters were a newline, set prevCharacters equal to <br/>
    isNewline(prevCharacters) && (prevCharacters = breakElement);

    //newInnerHTML starts the same as prevInnderHTML
    let newInnerHTML = prevInnerHTML;

    //if there are characters to add
    if (resultCharacters) {
        //newInnerHTML prevInnerHTML with mark tags and the result characters
        newInnerHTML += `${markOpenTag}${resultCharacters}${markCloseTag}`;

        //referencing prev.innerHTML directly changes the prev object
        //prev.innerHTML gets the result characters added without mark tags
        //this will persist to the next loop
        prev.innerHTML += resultCharacters;
    }

    //display the innerHTML
    //the mark will highlight one letter and look like a moving cursor
    outputDiv.innerHTML = newInnerHTML;

};


Making noise

Clicking the Go button calls makeAudioContext
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
blockName: goButtonListener 
//make clicking the 'GO' button invoke the addTypos function
    document.getElementById('addTyposButton').addEventListener('click', () => {
        //if beeps are on and audioContext is undefined, call the makeAudioContext function
        //we don't do this until the user clicks
        //because you can't create an audio context without some action by the user
        settings.beeps && !settings.audioContext && makeAudioContext(settings);

        //use .bind to pass the keyboardModel and settings to the addTypos function
        addTypos.bind(null, {
            display,
            keyboardModel,
            settings,
        })();
    });


Making the audioContext
1
2
3
4
5
6
7
8
blockName: makeAudioContext
//if the audioContext hasn't been initialized yet, then initialize it
//audioContext allows browsers to play sounds
const makeAudioContext = settings =>
    !settings.audioContext &&
    //some browswers use audioContext, others use webkitAudioContext
    //set audioContext equal to a new instance of whichever returns true from the browswer
    (settings.audioContext = new (AudioContext || webkitAudioContext)());


beep uses audioContext to play tones. All functions can call beep when they need to play tones.

beep
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
blockName: beep
//plays a beep at the given frequency for the duration
const beep = ({ audioContext, duration, frequency }) => {
    //oscillator is an oscillatorNode => https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode
    const oscillator = audioContext.createOscillator();

    //the type of wave function is 'square'
    //there are a few types and they all sound different
    oscillator.type = 'square';

    //connect the oscillator to the audioContext destination
    //the destination is the device that makes sound, usually the speakers
    //https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/destination
    oscillator.connect(audioContext.destination);

    //set the oscillator to the requested frequency
    oscillator.frequency.value = frequency;

    //oscillatorNode.start starts playing the sound
    //if oscillator has the start method, invoke it
    if (oscillator.start) oscillator.start();
    //for webkit, invoke noteOn() method
    else oscillator.noteOn(0);

    //after starting the tone, run setTimeout to stop the tone
    setTimeout(
        () => {
            //oscillatorNode.stop specifies the time to stop playing
            //if oscillator has the stop method, invoke it when the setTimeout runs
            if (oscillator.stop) oscillator.stop(0);
            //for webkit, invoke noteOff() method
            else oscillator.noteOff(0);
        },
        //setTimeout will run the code inside after the duration in milliseconds
        duration
    );

};


The displayOutputAtIndex function plays a tone that is higher or lower depending on what keyboard row the output character is located in.

    //set the frequency of the beep noise
    //beep noise is lower if the row is farther down the keyboard
    let frequency = 600;
    !!row && (frequency = -row * 100);
    //duration is 50 milliseconds
    let duration = 50;

That’s it!

This was a fun project. I learned a lot about working with JavaScript without using advance frameworks.