/** * jspsych.js * Josh de Leeuw * * documentation: docs.jspsych.org * **/ var jsPsych = (function() { var core = {}; // // private variables // // options var opts = {}; // experiment timeline var timeline; // flow control var global_trial_index = 0; var current_trial = {}; // target DOM element var DOM_target; // time that the experiment began var exp_start_time; // is the experiment paused? var paused = false; var waiting = false; // done loading? var loaded = false; // enumerated variables for special parameter types core.ALL_KEYS = 'allkeys'; core.NO_KEYS = 'none'; // // public methods // core.init = function(options) { // reset variables timeline = null; global_trial_index = 0; current_trial = {}; paused = false; waiting = false; loaded = false; var defaults = { 'display_element': undefined, 'on_finish': function(data) { return undefined; }, 'on_trial_start': function() { return undefined; }, 'on_trial_finish': function() { return undefined; }, 'on_data_update': function(data) { return undefined; }, 'on_interaction_data_update': function(data){ return undefined; }, 'exclusions': {}, 'show_progress_bar': false, 'auto_preload': true, 'max_load_time': 60000, 'fullscreen': false, 'default_iti': 1000 }; // override default options if user specifies an option opts = $.extend({}, defaults, options); // set DOM element where jsPsych will render content // if undefined, then jsPsych will use the tag and the entire page if(typeof opts.display_element == 'undefined'){ // check if there is a body element on the page var body = $('body'); if (body.length === 0) { $(document.documentElement).append($('')); } // using the full page, so we need the HTML document to // have 100% height, and body to have no margin $('html').css('height','100%'); $('body').css('margin', '0px'); opts.display_element = $('body'); } opts.display_element.append('
') DOM_target = $('#jspsych-content'); // add CSS class to DOM_target opts.display_element.addClass('jspsych-display-element') DOM_target.addClass('jspsych-content'); // create experiment timeline timeline = new TimelineNode({ timeline: opts.timeline }); // create listeners for user browser interaction jsPsych.data.createInteractionListeners(); // check exclusions before continuing checkExclusions(opts.exclusions, function(){ // success! user can continue... // start experiment, with or without preloading if(opts.auto_preload){ jsPsych.pluginAPI.autoPreload(timeline, startExperiment); if(opts.max_load_time > 0){ setTimeout(function(){ if(!loaded){ loadFail(); } }, opts.max_load_time); } } else { startExperiment(); } }, function(){ // fail. incompatible user. } ); }; core.progress = function() { var percent_complete = typeof timeline == 'undefined' ? 0 : timeline.percentComplete(); var obj = { "total_trials": typeof timeline == 'undefined' ? undefined : timeline.length(), "current_trial_global": global_trial_index, "percent_complete": percent_complete }; return obj; }; core.startTime = function() { return exp_start_time; }; core.totalTime = function() { if(typeof exp_start_time == 'undefined'){ return 0; } return (new Date()).getTime() - exp_start_time.getTime(); }; core.getDisplayElement = function() { return DOM_target; }; core.finishTrial = function(data) { // write the data from the trial data = typeof data == 'undefined' ? {} : data; jsPsych.data.write(data); // get back the data with all of the defaults in var trial_data = jsPsych.data.getDataByTrialIndex(global_trial_index); // handle callback at plugin level if (typeof current_trial.on_finish === 'function') { current_trial.on_finish(trial_data); } // handle callback at whole-experiment level opts.on_trial_finish(trial_data); // wait for iti if (typeof current_trial.timing_post_trial == 'undefined') { if (opts.default_iti > 0) { setTimeout(nextTrial, opts.default_iti); } else { nextTrial(); } } else { if (current_trial.timing_post_trial > 0) { setTimeout(nextTrial, current_trial.timing_post_trial); } else { nextTrial(); } } } core.endExperiment = function(end_message) { timeline.end_message = end_message; timeline.end(); jsPsych.pluginAPI.cancelAllKeyboardResponses(); jsPsych.pluginAPI.clearAllTimeouts(); core.finishTrial(); } core.endCurrentTimeline = function() { timeline.endActiveNode(); } core.currentTrial = function() { return current_trial; }; core.initSettings = function() { return opts; }; core.currentTimelineNodeID = function() { return timeline.activeID(); }; core.timelineVariable = function(varname){ return timeline.timelineVariable(varname); } core.addNodeToEndOfTimeline = function(new_timeline, preload_callback){ timeline.insert(new_timeline); if(opts.auto_preload){ jsPsych.pluginAPI.autoPreload(timeline, preload_callback); } else { preload_callback(); } } core.pauseExperiment = function(){ paused = true; } core.resumeExperiment = function(){ paused = false; if(waiting){ waiting = false; nextTrial(); } } function TimelineNode(parameters, parent, relativeID) { // a unique ID for this node, relative to the parent var relative_id; // store the parent for this node var parent_node; // parameters for the trial if the node contains a trial var trial_parameters; // parameters for nodes that contain timelines var timeline_parameters; // stores trial information on a node that contains a timeline // used for adding new trials var node_trial_data; // track progress through the node var progress = { current_location: -1, // where on the timeline (which timelinenode) current_variable_set: 0, // which set of variables to use from timeline_variables current_repetition: 0, // how many times through the variable set on this run of the node current_iteration: 0, // how many times this node has been revisited done: false } // reference to self var self = this; // recursively get the next trial to run. // if this node is a leaf (trial), then return the trial. // otherwise, recursively find the next trial in the child timeline. this.trial = function() { if (typeof timeline_parameters == 'undefined') { // returns a clone of the trial_parameters to // protect functions. return $.extend(true, {}, trial_parameters); } else { if (progress.current_location >= timeline_parameters.timeline.length) { return null; } else { return timeline_parameters.timeline[progress.current_location].trial(); } } } this.markCurrentTrialComplete = function() { if(typeof timeline_parameters == 'undefined'){ progress.done = true; } else { timeline_parameters.timeline[progress.current_location].markCurrentTrialComplete(); } } this.nextRepetiton = function() { this.setTimelineVariablesOrder(); progress.current_location = -1; progress.current_variable_set = 0; progress.current_repetition++; for (var i = 0; i < timeline_parameters.timeline.length; i++) { timeline_parameters.timeline[i].reset(); } } // set the order for going through the timeline variables array // TODO: this is where all the sampling options can be implemented this.setTimelineVariablesOrder = function() { var order = []; for(var i=0; i

The experiment will launch in fullscreen mode when you click the button below.

'); $('#jspsych-fullscreen-btn').on('click', function() { var element = document.documentElement; if (element.requestFullscreen) { element.requestFullscreen(); } else if (element.mozRequestFullScreen) { element.mozRequestFullScreen(); } else if (element.webkitRequestFullscreen) { element.webkitRequestFullscreen(); } else if (element.msRequestFullscreen) { element.msRequestFullscreen(); } $('#jspsych-fullscreen-btn').off('click'); DOM_target.html(''); setTimeout(go, 1000); }); } } else { go(); } function go() { // show progress bar if requested if (opts.show_progress_bar === true) { drawProgressBar(); } // record the start time exp_start_time = new Date(); // begin! timeline.advance(); doTrial(timeline.trial()); } } function finishExperiment() { opts.on_finish(jsPsych.data.getData()); if(typeof timeline.end_message !== 'undefined'){ DOM_target.html(timeline.end_message); } if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.msExitFullscreen) { document.msExitFullscreen(); } else if (document.mozCancelFullScreen) { document.mozCancelFullScreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } } function nextTrial() { // if experiment is paused, don't do anything. if(paused) { waiting = true; return; } global_trial_index++; // advance timeline timeline.markCurrentTrialComplete(); var complete = timeline.advance(); // update progress bar if shown if (opts.show_progress_bar === true) { updateProgressBar(); } // check if experiment is over if (complete) { finishExperiment(); return; } doTrial(timeline.trial()); } function doTrial(trial) { current_trial = trial; // call experiment wide callback opts.on_trial_start(); // check if trial has it's own display element var display_element = DOM_target; if(typeof trial.display_element !== 'undefined'){ display_element = trial.display_element; } // execute trial method jsPsych.plugins[trial.type].trial(display_element, trial); } function loadFail(){ DOM_target.html('

The experiment failed to load.

'); } function checkExclusions(exclusions, success, fail){ var clear = true; // MINIMUM SIZE if(typeof exclusions.min_width !== 'undefined' || typeof exclusions.min_height !== 'undefined'){ var mw = typeof exclusions.min_width !== 'undefined' ? exclusions.min_width : 0; var mh = typeof exclusions.min_height !== 'undefined' ? exclusions.min_height : 0; var w = window.innerWidth; var h = window.innerHeight; if(w < mw || h < mh){ clear = false; var interval = setInterval(function(){ var w = window.innerWidth; var h = window.innerHeight; if(w < mw || h < mh){ var msg = '

Your browser window is too small to complete this experiment. '+ 'Please maximize the size of your browser window. If your browser window is already maximized, '+ 'you will not be able to complete this experiment.

'+ '

The minimum width is '+mw+'px. Your current width is '+w+'px.

'+ '

The minimum height is '+mh+'px. Your current height is '+h+'px.

'; core.getDisplayElement().html(msg); } else { clearInterval(interval); core.getDisplayElement().empty(); checkExclusions(exclusions, success, fail); } }, 100); return; // prevents checking other exclusions while this is being fixed } } // WEB AUDIO API if(typeof exclusions.audio !== 'undefined' && exclusions.audio) { if(window.hasOwnProperty('AudioContext') || window.hasOwnProperty('webkitAudioContext')){ // clear } else { clear = false; var msg = '

Your browser does not support the WebAudio API, which means that you will not '+ 'be able to complete the experiment.

Browsers that support the WebAudio API include '+ 'Chrome, Firefox, Safari, and Edge.

'; core.getDisplayElement().html(msg); fail(); return; } } // GO? if(clear){ success(); } } function drawProgressBar() { $('.jspsych-display-element').prepend( '
'+ 'Completion Progress'+ '
'+ '
'+ '
' ); } function updateProgressBar() { var progress = jsPsych.progress(); $('#jspsych-progressbar-inner').css('width', progress.percent_complete + "%"); } return core; })(); jsPsych.plugins = { // enumerate possible parameter types for plugins parameterType: { BOOL: 0, STRING: 1, INT: 2, FLOAT: 3, FUNCTION: 4, KEYCODE: 5, SELECT: 6 } }; jsPsych.data = (function() { var module = {}; // data storage object var allData = []; // browser interaction event data var interactionData = []; // data properties for all trials var dataProperties = {}; // ignored data fields var ignoredProperties = []; // cache the query_string var query_string; module.getData = function() { return $.extend(true, [], allData); // deep clone }; module.getInteractionData = function() { return $.extend(true, [], interactionData); } module.write = function(data_object) { var progress = jsPsych.progress(); var trial = jsPsych.currentTrial(); //var trial_opt_data = typeof trial.data == 'function' ? trial.data() : trial.data; var default_data = { 'trial_type': trial.type, 'trial_index': progress.current_trial_global, 'time_elapsed': jsPsych.totalTime(), 'internal_node_id': jsPsych.currentTimelineNodeID() }; var ext_data_object = $.extend({}, data_object, trial.data, default_data, dataProperties); for(var i in ignoredProperties){ delete ext_data_object[ignoredProperties[i]]; } allData.push(ext_data_object); var initSettings = jsPsych.initSettings(); initSettings.on_data_update(ext_data_object); }; module.addProperties = function(properties) { // first, add the properties to all data that's already stored for (var i = 0; i < allData.length; i++) { for (var key in properties) { allData[i][key] = properties[key]; } } // now add to list so that it gets appended to all future data dataProperties = $.extend({}, dataProperties, properties); }; module.ignore = function(properties) { // first, remove the properties from all data that's already stored for (var i = 0; i < allData.length; i++) { for (var j in properties) { delete allData[i][properties[j]]; } } // now add to list so that it gets appended to all future data ignoredProperties = ignoredProperties.concat(properties); }; module.addDataToLastTrial = function(data) { if (allData.length == 0) { throw new Error("Cannot add data to last trial - no data recorded so far"); } allData[allData.length - 1] = $.extend({}, allData[allData.length - 1], data); } module.dataAsCSV = function() { var dataObj = module.getData(); return JSON2CSV(dataObj); }; module.dataAsJSON = function() { var dataObj = module.getData(); return JSON.stringify(dataObj); }; module.localSave = function(filename, format) { var data_string; if (format == 'JSON' || format == 'json') { data_string = JSON.stringify(module.getData()); } else if (format == 'CSV' || format == 'csv') { data_string = module.dataAsCSV(); } else { throw new Error('invalid format specified for jsPsych.data.localSave'); } saveTextToFile(data_string, filename); }; module.getTrialsOfType = function(trial_type) { var data = module.getData(); data = flatten(data); var trials = []; for (var i = 0; i < data.length; i++) { if (data[i].trial_type == trial_type) { trials.push(data[i]); } } return trials; }; module.getDataByTimelineNode = function(node_id) { var data = module.getData(); data = flatten(data); var trials = []; for (var i = 0; i < data.length; i++) { if (data[i].internal_node_id.slice(0, node_id.length) === node_id) { trials.push(data[i]); } } return trials; }; module.getLastTrialData = function() { if (allData.length == 0) { return {}; } return allData[allData.length - 1]; }; module.getDataByTrialIndex = function(trial_index) { for (var i = 0; i < allData.length; i++) { if (allData[i].trial_index == trial_index) { return allData[i]; } } return undefined; } module.getLastTimelineData = function() { var lasttrial = module.getLastTrialData(); var node_id = lasttrial.internal_node_id; if (typeof node_id === 'undefined') { return []; } else { var parent_node_id = node_id.substr(0,node_id.lastIndexOf('-')); var lastnodedata = module.getDataByTimelineNode(parent_node_id); return lastnodedata; } } module.displayData = function(format) { format = (typeof format === 'undefined') ? "json" : format.toLowerCase(); if (format != "json" && format != "csv") { console.log('Invalid format declared for displayData function. Using json as default.'); format = "json"; } var data_string; if (format == 'json') { data_string = JSON.stringify(module.getData(), undefined, 1); } else { data_string = module.dataAsCSV(); } var display_element = jsPsych.getDisplayElement(); display_element.append($('
'));

    $('#jspsych-data-display').text(data_string);
  };

  module.urlVariables = function() {
    if(typeof query_string == 'undefined'){
      query_string = getQueryString();
    }
    return query_string;
  }

  module.getURLVariable = function(whichvar){
    if(typeof query_string == 'undefined'){
      query_string = getQueryString();
    }
    return query_string[whichvar];
  }

  module.createInteractionListeners = function(){
    // blur event capture
    window.addEventListener('blur', function(){
      var data = {
        event: 'blur',
        trial: jsPsych.progress().current_trial_global,
        time: jsPsych.totalTime()
      };
      interactionData.push(data);
      jsPsych.initSettings().on_interaction_data_update(data);
    });

    // focus event capture
    window.addEventListener('focus', function(){
      var data = {
        event: 'focus',
        trial: jsPsych.progress().current_trial_global,
        time: jsPsych.totalTime()
      };
      interactionData.push(data);
      jsPsych.initSettings().on_interaction_data_update(data);
    });

    // fullscreen change capture
    function fullscreenchange(){
      var type = (document.isFullScreen || document.webkitIsFullScreen || document.mozIsFullScreen) ? 'fullscreenenter' : 'fullscreenexit';
      var data = {
        event: type,
        trial: jsPsych.progress().current_trial_global,
        time: jsPsych.totalTime()
      };
      interactionData.push(data);
      jsPsych.initSettings().on_interaction_data_update(data);
    }

    document.addEventListener('fullscreenchange', fullscreenchange);
    document.addEventListener('mozfullscreenchange', fullscreenchange);
    document.addEventListener('webkitfullscreenchange', fullscreenchange);
  }

  // private function to save text file on local drive

  function saveTextToFile(textstr, filename) {
    var blobToSave = new Blob([textstr], {
      type: 'text/plain'
    });
    var blobURL = "";
    if (typeof window.webkitURL !== 'undefined') {
      blobURL = window.webkitURL.createObjectURL(blobToSave);
    } else {
      blobURL = window.URL.createObjectURL(blobToSave);
    }

    var display_element = jsPsych.getDisplayElement();

    display_element.append($('', {
      id: 'jspsych-download-as-text-link',
      href: blobURL,
      css: {
        display: 'none'
      },
      download: filename,
      html: 'download file'
    }));
    $('#jspsych-download-as-text-link')[0].click();
  }

  //
  // A few helper functions to handle data format conversion
  //

  // this function based on code suggested by StackOverflow users:
  // http://stackoverflow.com/users/64741/zachary
  // http://stackoverflow.com/users/317/joseph-sturtevant

  function JSON2CSV(objArray) {
    var array = typeof objArray != 'object' ? JSON.parse(objArray) : objArray;
    var line = '';
    var result = '';
    var columns = [];

    var i = 0;
    for (var j = 0; j < array.length; j++) {
      for (var key in array[j]) {
        var keyString = key + "";
        keyString = '"' + keyString.replace(/"/g, '""') + '",';
        if ($.inArray(key, columns) == -1) {
          columns[i] = key;
          line += keyString;
          i++;
        }
      }
    }

    line = line.slice(0, -1);
    result += line + '\r\n';

    for (var i = 0; i < array.length; i++) {
      var line = '';
      for (var j = 0; j < columns.length; j++) {
        var value = (typeof array[i][columns[j]] === 'undefined') ? '' : array[i][columns[j]];
        var valueString = value + "";
        line += '"' + valueString.replace(/"/g, '""') + '",';
      }

      line = line.slice(0, -1);
      result += line + '\r\n';
    }

    return result;
  }

  // this function is modified from StackOverflow:
  // http://stackoverflow.com/posts/3855394

  function getQueryString() {
    var a = window.location.search.substr(1).split('&');
    if (a == "") return {};
    var b = {};
    for (var i = 0; i < a.length; ++i)
    {
        var p=a[i].split('=', 2);
        if (p.length == 1)
            b[p[0]] = "";
        else
            b[p[0]] = decodeURIComponent(p[1].replace(/\+/g, " "));
    }
    return b;
  }

  return module;

})();

jsPsych.turk = (function() {

  var module = {};

  // core.turkInfo gets information relevant to mechanical turk experiments. returns an object
  // containing the workerID, assignmentID, and hitID, and whether or not the HIT is in
  // preview mode, meaning that they haven't accepted the HIT yet.
  module.turkInfo = function() {

    var turk = {};

    var param = function(url, name) {
      name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]");
      var regexS = "[\\?&]" + name + "=([^&#]*)";
      var regex = new RegExp(regexS);
      var results = regex.exec(url);
      return (results == null) ? "" : results[1];
    };

    var src = param(window.location.href, "assignmentId") ? window.location.href : document.referrer;

    var keys = ["assignmentId", "hitId", "workerId", "turkSubmitTo"];
    keys.map(

      function(key) {
        turk[key] = unescape(param(src, key));
      });

    turk.previewMode = (turk.assignmentId == "ASSIGNMENT_ID_NOT_AVAILABLE");

    turk.outsideTurk = (!turk.previewMode && turk.hitId === "" && turk.assignmentId == "" && turk.workerId == "")

    turk_info = turk;

    return turk;

  };

  // core.submitToTurk will submit a MechanicalTurk ExternalHIT type
  module.submitToTurk = function(data) {

    var turkInfo = jsPsych.turk.turkInfo();
    var assignmentId = turkInfo.assignmentId;
    var turkSubmitTo = turkInfo.turkSubmitTo;

    if (!assignmentId || !turkSubmitTo) return;

    var dataString = [];

    for (var key in data) {

      if (data.hasOwnProperty(key)) {
        dataString.push(key + "=" + escape(data[key]));
      }
    }

    dataString.push("assignmentId=" + assignmentId);

    var url = turkSubmitTo + "/mturk/externalSubmit?" + dataString.join("&");

    window.location.href = url;
  };

  return module;

})();

jsPsych.randomization = (function() {

  var module = {};

  module.repeat = function(array, repetitions, unpack) {

    var arr_isArray = Array.isArray(array);
    var rep_isArray = Array.isArray(repetitions);

    // if array is not an array, then we just repeat the item
    if (!arr_isArray) {
      if (!rep_isArray) {
        array = [array];
        repetitions = [repetitions];
      } else {
        repetitions = [repetitions[0]];
        console.log('Unclear parameters given to randomization.repeat. Multiple set sizes specified, but only one item exists to sample. Proceeding using the first set size.');
      }
    } else {
      if (!rep_isArray) {
        var reps = [];
        for (var i = 0; i < array.length; i++) {
          reps.push(repetitions);
        }
        repetitions = reps;
      } else {
        if (array.length != repetitions.length) {
          console.warning('Unclear parameters given to randomization.repeat. Items and repetitions are unequal lengths. Behavior may not be as expected.');
          // throw warning if repetitions is too short, use first rep ONLY.
          if (repetitions.length < array.length) {
            var reps = [];
            for (var i = 0; i < array.length; i++) {
              reps.push(repetitions);
            }
            repetitions = reps;
          } else {
            // throw warning if too long, and then use the first N
            repetitions = repetions.slice(0, array.length);
          }
        }
      }
    }

    // should be clear at this point to assume that array and repetitions are arrays with == length
    var allsamples = [];
    for (var i = 0; i < array.length; i++) {
      for (var j = 0; j < repetitions[i]; j++) {
        if(array[i] == null || typeof array[i] != 'object'){
          allsamples.push(array[i]);
        } else {
          allsamples.push($.extend(true, {}, array[i]));
        }

      }
    }

    var out = shuffle(allsamples);

    if (unpack) {
      out = unpackArray(out);
    }

    return out;
  }

  module.shuffle = function(arr) {
    return shuffle(arr);
  }

  module.shuffleNoRepeats = function(arr, equalityTest) {
    // define a default equalityTest
    if (typeof equalityTest == 'undefined') {
      equalityTest = function(a, b) {
        if (a === b) {
          return true;
        } else {
          return false;
        }
      }
    }

    var random_shuffle = shuffle(arr);
    for (var i = 0; i < random_shuffle.length - 2; i++) {
      if (equalityTest(random_shuffle[i], random_shuffle[i + 1])) {
        // neighbors are equal, pick a new random neighbor to swap (not the first or last element, to avoid edge cases)
        var random_pick = Math.floor(Math.random() * (random_shuffle.length - 2)) + 1;
        // test to make sure the new neighbor isn't equal to the old one
        while (
          equalityTest(random_shuffle[i + 1], random_shuffle[random_pick]) ||
          (equalityTest(random_shuffle[i + 1], random_shuffle[random_pick + 1]) || equalityTest(random_shuffle[i + 1], random_shuffle[random_pick - 1]))
        ) {
          random_pick = Math.floor(Math.random() * (random_shuffle.length - 2)) + 1;
        }
        var new_neighbor = random_shuffle[random_pick];
        random_shuffle[random_pick] = random_shuffle[i + 1];
        random_shuffle[i + 1] = new_neighbor;
      }
    }

    return random_shuffle;
  }

  module.sample = function(arr, size, withReplacement) {
    if (withReplacement == false) {
      if (size > arr.length) {
        console.error("jsPsych.randomization.sample cannot take a sample " +
          "larger than the size of the set of items to sample from when " +
          "sampling without replacement.");
      }
    }
    var samp = [];
    var shuff_arr = shuffle(arr);
    for (var i = 0; i < size; i++) {
      if (!withReplacement) {
        samp.push(shuff_arr.pop());
      } else {
        samp.push(shuff_arr[Math.floor(Math.random() * shuff_arr.length)]);
      }
    }
    return samp;
  }

  module.factorial = function(factors, repetitions, unpack) {

    var factorNames = Object.keys(factors);

    var factor_combinations = [];

    for (var i = 0; i < factors[factorNames[0]].length; i++) {
      factor_combinations.push({});
      factor_combinations[i][factorNames[0]] = factors[factorNames[0]][i];
    }

    for (var i = 1; i < factorNames.length; i++) {
      var toAdd = factors[factorNames[i]];
      var n = factor_combinations.length;
      for (var j = 0; j < n; j++) {
        var base = factor_combinations[j];
        for (var k = 0; k < toAdd.length; k++) {
          var newpiece = {};
          newpiece[factorNames[i]] = toAdd[k];
          factor_combinations.push($.extend({}, base, newpiece));
        }
      }
      factor_combinations.splice(0, n);
    }

    repetitions = (typeof repetitions === 'undefined') ? 1 : repetitions;
    var with_repetitions = module.repeat(factor_combinations, repetitions, unpack);

    return with_repetitions;
  }

  module.randomID = function(length){
    var result = '';
    var length = (typeof length == 'undefined') ? 32 : length;
    var chars = '0123456789abcdefghjklmnopqrstuvwxyz';
    for(var i = 0; i -1) {

          if (!parameters.persist) {
            // remove keyboard listener
            module.cancelKeyboardResponse(listener_id);
          }
        }

        var after_up = function(up) {

          if (up.which == e.which) {
            $(document).off('keyup', after_up);

            // mark key as released
            held_keys.splice($.inArray(e.which, held_keys), 1);
          }
        };

        $(document).keyup(after_up);
      }
    };

    $(document).keydown(listener_function);

    // create listener id object
    listener_id = {
      type: 'keydown',
      fn: listener_function
    };

    // add this keyboard listener to the list of listeners
    keyboard_listeners.push(listener_id);

    return listener_id;

  };

  module.cancelKeyboardResponse = function(listener) {
    // remove the listener from the doc
    $(document).off(listener.type, listener.fn);

    // remove the listener from the list of listeners
    if ($.inArray(listener, keyboard_listeners) > -1) {
      keyboard_listeners.splice($.inArray(listener, keyboard_listeners), 1);
    }
  };

  module.cancelAllKeyboardResponses = function() {
    for (var i = 0; i < keyboard_listeners.length; i++) {
      $(document).off(keyboard_listeners[i].type, keyboard_listeners[i].fn);
    }
    keyboard_listeners = [];
  };

  module.convertKeyCharacterToKeyCode = function(character) {
    var code;
    if (typeof keylookup[character] !== 'undefined') {
      code = keylookup[character];
    }
    return code;
  }

  var keylookup = {
    'backspace': 8,
    'tab': 9,
    'enter': 13,
    'shift': 16,
    'ctrl': 17,
    'alt': 18,
    'pause': 19,
    'capslock': 20,
    'esc': 27,
    'space': 32,
    'spacebar': 32,
    ' ': 32,
    'pageup': 33,
    'pagedown': 34,
    'end': 35,
    'home': 36,
    'leftarrow': 37,
    'uparrow': 38,
    'rightarrow': 39,
    'downarrow': 40,
    'insert': 45,
    'delete': 46,
    '0': 48,
    '1': 49,
    '2': 50,
    '3': 51,
    '4': 52,
    '5': 53,
    '6': 54,
    '7': 55,
    '8': 56,
    '9': 57,
    'a': 65,
    'b': 66,
    'c': 67,
    'd': 68,
    'e': 69,
    'f': 70,
    'g': 71,
    'h': 72,
    'i': 73,
    'j': 74,
    'k': 75,
    'l': 76,
    'm': 77,
    'n': 78,
    'o': 79,
    'p': 80,
    'q': 81,
    'r': 82,
    's': 83,
    't': 84,
    'u': 85,
    'v': 86,
    'w': 87,
    'x': 88,
    'y': 89,
    'z': 90,
    'A': 65,
    'B': 66,
    'C': 67,
    'D': 68,
    'E': 69,
    'F': 70,
    'G': 71,
    'H': 72,
    'I': 73,
    'J': 74,
    'K': 75,
    'L': 76,
    'M': 77,
    'N': 78,
    'O': 79,
    'P': 80,
    'Q': 81,
    'R': 82,
    'S': 83,
    'T': 84,
    'U': 85,
    'V': 86,
    'W': 87,
    'X': 88,
    'Y': 89,
    'Z': 90,
    '0numpad': 96,
    '1numpad': 97,
    '2numpad': 98,
    '3numpad': 99,
    '4numpad': 100,
    '5numpad': 101,
    '6numpad': 102,
    '7numpad': 103,
    '8numpad': 104,
    '9numpad': 105,
    'multiply': 106,
    'plus': 107,
    'minus': 109,
    'decimal': 110,
    'divide': 111,
    'F1': 112,
    'F2': 113,
    'F3': 114,
    'F4': 115,
    'F5': 116,
    'F6': 117,
    'F7': 118,
    'F8': 119,
    'F9': 120,
    'F10': 121,
    'F11': 122,
    'F12': 123,
    '=': 187,
    ',': 188,
    '.': 190,
    '/': 191,
    '`': 192,
    '[': 219,
    '\\': 220,
    ']': 221
  };

  // timeout registration

  var timeout_handlers = [];

  module.setTimeout = function(callback, delay){
    var handle = setTimeout(callback, delay);
    timeout_handlers.push(handle);
    return handle;
  }

  module.clearAllTimeouts = function(){
    for(var i=0;i