import autoBind from "auto-bind" ; import { version } from "../package.json" ; import { MigrationError } from "./migration" ; import { JsPsychData } from "./modules/data" ; import { PluginAPI , createJointPluginAPIObject } from "./modules/plugin-api" ; import { ParameterType , universalPluginParameters } from "./modules/plugins" ; import * as randomization from "./modules/randomization" ; import * as turk from "./modules/turk" ; import * as utils from "./modules/utils" ; import { TimelineNode } from "./TimelineNode" ; function delay ( ms ) { return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ; } export class JsPsych { extensions = < any > { } ; turk = turk ; randomization = randomization ; utils = utils ; data : JsPsychData ; pluginAPI : PluginAPI ; version ( ) { return version ; } /**
     * options
     */ private opts : any = { } ; /**
     * experiment timeline
     */ private timeline : TimelineNode ; private timelineDescription : any [ ] ; private global_trial_index = 0 ; private current_trial : any = { } ; private current_trial_finished = false ; private DOM_container : HTMLElement ; private DOM_target : HTMLElement ; /**
     * time that the experiment began
     */ private exp_start_time ; /**
     * is the experiment paused?
     */ private paused = false ; private waiting = false ; /**
     * is the page retrieved directly via file:// protocol (true) or hosted on a server (false)?
     */ private file_protocol = false ; /**
     * Promise that is resolved when `finishExperiment()` is called
     */ private finished : Promise < void > ; private resolveFinishedPromise : ( ) => void ; /**
     * is the experiment running in `simulate()` mode
     */ private simulation_mode : "data-only" | "visual" = null ; /**
     * simulation options passed in via `simulate()`
     */ private simulation_options ; webaudio_context : AudioContext = null ; internal = { /**
         * this flag is used to determine whether we are in a scope where
         * jsPsych.timelineVariable() should be executed immediately or
         * whether it should return a function to access the variable later.
         *
         **/ call_immediate : false , } ; constructor ( options ? ) { options = { display_element : undefined , on_finish : ( ) => { } , on_trial_start : ( ) => { } , on_trial_finish : ( ) => { } , on_data_update : ( ) => { } , on_interaction_data_update : ( ) => { } , on_close : ( ) => { } , use_webaudio : true , exclusions : { } , show_progress_bar : false , message_progress_bar : "Completion Progress" , auto_update_progress_bar : true , default_iti : 0 , minimum_valid_rt : 0 , experiment_width : null , override_safe_mode : false , case_sensitive_responses : false , extensions : [ ] , ... options , } ; this . opts = options ; autoBind ( this ) ; this . webaudio_context = typeof window !== "undefined" && typeof window . AudioContext !== "undefined" ? new AudioContext ( ) : null ; if ( window . location . protocol == "file:" && ( options . override_safe_mode === false || typeof options . override_safe_mode === "undefined" ) ) { options . use_webaudio = false ; this . file_protocol = true ; console . warn ( "jsPsych detected that it is running via the file:// protocol and not on a web server. " + "To prevent issues with cross-origin requests, Web Audio and video preloading have been disabled. " + "If you would like to override this setting, you can set 'override_safe_mode' to 'true' in initJsPsych. " + "For more information, see: https://www.jspsych.org/overview/running-experiments" ) ; } this . data = new JsPsychData ( this ) ; this . pluginAPI = createJointPluginAPIObject ( this ) ; for ( const extension of options . extensions ) { this . extensions [ extension . type . info . name ] = new extension . type ( this ) ; } this . pluginAPI . initAudio ( ) ; } param timeline async run ( timeline ) { if ( typeof timeline === "undefined" ) { console . error ( "No timeline declared in jsPsych.run. Cannot start experiment." ) ; } if ( timeline . length === 0 ) { console . error ( "No trials have been added to the timeline (the timeline is an empty array). Cannot start experiment." ) ; } this . timelineDescription = timeline ; this . timeline = new TimelineNode ( this , { timeline } ) ; await this . prepareDom ( ) ; await this . checkExclusions ( this . opts . exclusions ) ; await this . loadExtensions ( this . opts . extensions ) ; document . documentElement . setAttribute ( "jspsych" , "present" ) ; this . startExperiment ( ) ; await this . finished ; } async simulate ( timeline , simulation_mode = "data-only" , simulation_options = { } ) { this . simulation_mode = simulation_mode ; this . simulation_options = simulation_options ; await this . run ( timeline ) ; } getProgress ( ) { return { total_trials : typeof this . timeline === "undefined" ? undefined : this . timeline . length ( ) , current_trial_global : this . global_trial_index , percent_complete : typeof this . timeline === "undefined" ? 0 : this . timeline . percentComplete ( ) , } ; } getStartTime ( ) { return this . exp_start_time ; } getTotalTime ( ) { if ( typeof this . exp_start_time === "undefined" ) { return 0 ; } return new Date ( ) . getTime ( ) - this . exp_start_time . getTime ( ) ; } getDisplayElement ( ) { return this . DOM_target ; } getDisplayContainerElement ( ) { return this . DOM_container ; } finishTrial ( data = { } ) { if ( this . current_trial_finished ) { return ; } this . current_trial_finished = true ; if ( typeof this . current_trial . css_classes !== "undefined" && Array . isArray ( this . current_trial . css_classes ) ) { this . DOM_target . classList . remove ( ... this . current_trial . css_classes ) ; } this . data . write ( data ) ; const trial_data = this . data . get ( ) . filter ( { trial_index : this . global_trial_index } ) ; const trial_data_values = trial_data . values ( ) [ 0 ] ; const current_trial = this . current_trial ; if ( typeof current_trial . save_trial_parameters === "object" ) { for ( const key of Object . keys ( current_trial . save_trial_parameters ) ) { const key_val = current_trial . save_trial_parameters [ key ] ; if ( key_val === true ) { if ( typeof current_trial [ key ] === "undefined" ) { console . warn ( `Invalid parameter specified in save_trial_parameters. Trial has no property called "${ key }".` ) ; } else if ( typeof current_trial [ key ] === "function" ) { trial_data_values [ key ] = current_trial [ key ] . toString ( ) ; } else { trial_data_values [ key ] = current_trial [ key ] ; } } if ( key_val === false ) { if ( key !== "internal_node_id" && key !== "trial_index" ) { delete trial_data_values [ key ] ; } } } } if ( Array . isArray ( current_trial . extensions ) ) { for ( const extension of current_trial . extensions ) { const ext_data_values = this . extensions [ extension . type . info . name ] . on_finish ( extension . params ) ; Object . assign ( trial_data_values , ext_data_values ) ; } } this . internal . call_immediate = true ; if ( typeof current_trial . on_finish === "function" ) { current_trial . on_finish ( trial_data_values ) ; } this . opts . on_trial_finish ( trial_data_values ) ; this . opts . on_data_update ( trial_data_values ) ; this . internal . call_immediate = false ; if ( typeof current_trial . post_trial_gap === null || typeof current_trial . post_trial_gap === "undefined" ) { if ( this . opts . default_iti > 0 ) { setTimeout ( this . nextTrial , this . opts . default_iti ) ; } else { this . nextTrial ( ) ; } } else { if ( current_trial . post_trial_gap > 0 ) { setTimeout ( this . nextTrial , current_trial . post_trial_gap ) ; } else { this . nextTrial ( ) ; } } } endExperiment ( end_message = "" , data = { } ) { this . timeline . end_message = end_message ; this . timeline . end ( ) ; this . pluginAPI . cancelAllKeyboardResponses ( ) ; this . pluginAPI . clearAllTimeouts ( ) ; this . finishTrial ( data ) ; } endCurrentTimeline ( ) { this . timeline . endActiveNode ( ) ; } getCurrentTrial ( ) { return this . current_trial ; } getInitSettings ( ) { return this . opts ; } getCurrentTimelineNodeID ( ) { return this . timeline . activeID ( ) ; } timelineVariable ( varname , immediate = false ) { if ( this . internal . call_immediate || immediate === true ) { return this . timeline . timelineVariable ( varname ) ; } else { return { timelineVariablePlaceholder : true , timelineVariableFunction : ( ) => this . timeline . timelineVariable ( varname ) , } ; } } getAllTimelineVariables ( ) { return this . timeline . allTimelineVariables ( ) ; } addNodeToEndOfTimeline ( new_timeline , preload_callback ? ) { this . timeline . insert ( new_timeline ) ; } pauseExperiment ( ) { this . paused = true ; } resumeExperiment ( ) { this . paused = false ; if ( this . waiting ) { this . waiting = false ; this . nextTrial ( ) ; } } loadFail ( message ) { message = message || "<p>The experiment failed to load.</p>" ; this . DOM_target . innerHTML = message ; } getSafeModeStatus ( ) { return this . file_protocol ; } getTimeline ( ) { return this . timelineDescription ; } private async prepareDom ( ) { if ( document . readyState !== "complete" ) { await new Promise ( ( resolve ) => { window . addEventListener ( "load" , resolve ) ; } ) ; } const options = this . opts ; if ( typeof options . display_element === "undefined" ) { const body = document . querySelector ( "body" ) ; if ( body === null ) { document . documentElement . appendChild ( document . createElement ( "body" ) ) ; } document . querySelector ( "html" ) . style . height = "100%" ; document . querySelector ( "body" ) . style . margin = "0px" ; document . querySelector ( "body" ) . style . height = "100%" ; document . querySelector ( "body" ) . style . width = "100%" ; options . display_element = document . querySelector ( "body" ) ; } else { const display = options . display_element instanceof Element ? options . display_element : document . querySelector ( "#" + options . display_element ) ; if ( display === null ) { console . error ( "The display_element specified in initJsPsych() does not exist in the DOM." ) ; } else { options . display_element = display ; } } options . display_element . innerHTML = '<div class="jspsych-content-wrapper"><div id="jspsych-content"></div></div>' ; this . DOM_container = options . display_element ; this . DOM_target = document . querySelector ( "#jspsych-content" ) ; if ( options . experiment_width !== null ) { this . DOM_target . style . width = options . experiment_width + "px" ; } options . display_element . tabIndex = 0 ; if ( options . display_element . className . indexOf ( "jspsych-display-element" ) === - 1 ) { options . display_element . className += " jspsych-display-element" ; } this . DOM_target . className += "jspsych-content" ; this . data . createInteractionListeners ( ) ; window . addEventListener ( "beforeunload" , options . on_close ) ; } private async loadExtensions ( extensions ) { try { await Promise . all ( extensions . map ( ( extension ) => this . extensions [ extension . type . info . name ] . initialize ( extension . params || { } ) ) ) ; } catch ( error_message ) { console . error ( error_message ) ; throw new Error ( error_message ) ; } } private startExperiment ( ) { this . finished = new Promise ( ( resolve ) => { this . resolveFinishedPromise = resolve ; } ) ; if ( this . opts . show_progress_bar === true ) { this . drawProgressBar ( this . opts . message_progress_bar ) ; } this . exp_start_time = new Date ( ) ; this . timeline . advance ( ) ; this . doTrial ( this . timeline . trial ( ) ) ; } private finishExperiment ( ) { const finish_result = this . opts . on_finish ( this . data . get ( ) ) ; const done_handler = ( ) => { if ( typeof this . timeline . end_message !== "undefined" ) { this . DOM_target . innerHTML = this . timeline . end_message ; } this . resolveFinishedPromise ( ) ; } ; if ( finish_result ) { Promise . resolve ( finish_result ) . then ( done_handler ) ; } else { done_handler ( ) ; } } private nextTrial ( ) { if ( this . paused ) { this . waiting = true ; return ; } this . global_trial_index ++ ; this . timeline . markCurrentTrialComplete ( ) ; const complete = this . timeline . advance ( ) ; if ( this . opts . show_progress_bar === true && this . opts . auto_update_progress_bar === true ) { this . updateProgressBar ( ) ; } if ( complete ) { this . finishExperiment ( ) ; return ; } this . doTrial ( this . timeline . trial ( ) ) ; } private doTrial ( trial ) { this . current_trial = trial ; this . current_trial_finished = false ; this . evaluateTimelineVariables ( trial ) ; if ( typeof trial . type === "string" ) { throw new MigrationError ( "A string was provided as the trial's `type` parameter. Since jsPsych v7, the `type` parameter needs to be a plugin object." ) ; } trial . type = { ... autoBind ( new trial . type ( this ) ) , info : trial . type . info , } ; this . evaluateFunctionParameters ( trial ) ; this . setDefaultValues ( trial ) ; this . internal . call_immediate = true ; this . opts . on_trial_start ( trial ) ; if ( typeof trial . on_start === "function" ) { trial . on_start ( trial ) ; } if ( Array . isArray ( trial . extensions ) ) { for ( const extension of trial . extensions ) { this . extensions [ extension . type . info . name ] . on_start ( extension . params ) ; } } this . DOM_container . focus ( ) ; this . DOM_target . scrollTop = 0 ; if ( typeof trial . css_classes !== "undefined" ) { if ( ! Array . isArray ( trial . css_classes ) && typeof trial . css_classes === "string" ) { trial . css_classes = [ trial . css_classes ] ; } if ( Array . isArray ( trial . css_classes ) ) { this . DOM_target . classList . add ( ... trial . css_classes ) ; } } const load_callback = ( ) => { if ( typeof trial . on_load === "function" ) { trial . on_load ( ) ; } if ( Array . isArray ( trial . extensions ) ) { for ( const extension of trial . extensions ) { this . extensions [ extension . type . info . name ] . on_load ( extension . params ) ; } } } ; let trial_complete ; if ( ! this . simulation_mode ) { trial_complete = trial . type . trial ( this . DOM_target , trial , load_callback ) ; } if ( this . simulation_mode ) { if ( trial . type . simulate ) { let trial_sim_opts ; if ( ! trial . simulation_options ) { trial_sim_opts = this . simulation_options . default ; } if ( trial . simulation_options ) { if ( typeof trial . simulation_options == "string" ) { if ( this . simulation_options [ trial . simulation_options ] ) { trial_sim_opts = this . simulation_options [ trial . simulation_options ] ; } else if ( this . simulation_options . default ) { console . log ( `No matching simulation options found for "${ trial . simulation_options }". Using "default" options.` ) ; trial_sim_opts = this . simulation_options . default ; } else { console . log ( `No matching simulation options found for "${ trial . simulation_options }" and no "default" options provided. Using the default values provided by the plugin.` ) ; trial_sim_opts = { } ; } } else { trial_sim_opts = trial . simulation_options ; } } trial_sim_opts = this . utils . deepCopy ( trial_sim_opts ) ; trial_sim_opts = this . replaceFunctionsWithValues ( trial_sim_opts , null ) ; if ( trial_sim_opts ?. simulate === false ) { trial_complete = trial . type . trial ( this . DOM_target , trial , load_callback ) ; } else { trial_complete = trial . type . simulate ( trial , trial_sim_opts ?. mode || this . simulation_mode , trial_sim_opts , load_callback ) ; } } else { trial_complete = trial . type . trial ( this . DOM_target , trial , load_callback ) ; } } const is_promise = trial_complete && typeof trial_complete . then == "function" ; if ( ! is_promise && ! this . simulation_mode ) { load_callback ( ) ; } this . internal . call_immediate = false ; } private evaluateTimelineVariables ( trial ) { for ( const key of Object . keys ( trial ) ) { if ( key === "type" ) { } if ( typeof trial [ key ] === "object" && trial [ key ] !== null && typeof trial [ key ] . timelineVariablePlaceholder !== "undefined" ) { trial [ key ] = trial [ key ] . timelineVariableFunction ( ) ; } if ( typeof trial [ key ] === "object" && trial [ key ] !== null ) { this . evaluateTimelineVariables ( trial [ key ] ) ; } } } private evaluateFunctionParameters ( trial ) { this . internal . call_immediate = true ; for ( const key of Object . keys ( trial ) ) { if ( key !== "type" ) { if ( typeof universalPluginParameters [ key ] !== "undefined" && universalPluginParameters [ key ] . type !== ParameterType . FUNCTION ) { trial [ key ] = this . replaceFunctionsWithValues ( trial [ key ] , null ) ; } if ( typeof trial . type . info . parameters [ key ] !== "undefined" && trial . type . info . parameters [ key ] . type !== ParameterType . FUNCTION ) { trial [ key ] = this . replaceFunctionsWithValues ( trial [ key ] , trial . type . info . parameters [ key ] ) ; } } } this . internal . call_immediate = false ; } private replaceFunctionsWithValues ( obj , info ) { if ( obj === null ) { return obj ; } else if ( Array . isArray ( obj ) ) { for ( let i = 0 ; i < obj . length ; i ++ ) { obj [ i ] = this . replaceFunctionsWithValues ( obj [ i ] , info ) ; } } else if ( typeof obj === "object" ) { if ( info === null || ! info . nested ) { for ( const key of Object . keys ( obj ) ) { if ( key === "type" ) { continue ; } obj [ key ] = this . replaceFunctionsWithValues ( obj [ key ] , null ) ; } } else { for ( const key of Object . keys ( obj ) ) { if ( typeof info . nested [ key ] === "object" && info . nested [ key ] . type !== ParameterType . FUNCTION ) { obj [ key ] = this . replaceFunctionsWithValues ( obj [ key ] , info . nested [ key ] ) ; } } } } else if ( typeof obj === "function" ) { return obj ( ) ; } return obj ; } private setDefaultValues ( trial ) { for ( const param in trial . type . info . parameters ) { if ( trial . type . info . parameters [ param ] . type === ParameterType . COMPLEX ) { if ( trial . type . info . parameters [ param ] . array === true ) { trial [ param ] . forEach ( function ( ip , i ) { for ( const p in trial . type . info . parameters [ param ] . nested ) { if ( typeof trial [ param ] [ i ] [ p ] === "undefined" || trial [ param ] [ i ] [ p ] === null ) { if ( typeof trial . type . info . parameters [ param ] . nested [ p ] . default === "undefined" ) { console . error ( "You must specify a value for the " + p + " parameter (nested in the " + param + " parameter) in the " + trial . type + " plugin." ) ; } else { trial [ param ] [ i ] [ p ] = trial . type . info . parameters [ param ] . nested [ p ] . default ; } } } } ) ; } } else if ( typeof trial [ param ] === "undefined" || trial [ param ] === null ) { if ( typeof trial . type . info . parameters [ param ] . default === "undefined" ) { console . error ( "You must specify a value for the " + param + " parameter in the " + trial . type . info . name + " plugin." ) ; } else { trial [ param ] = trial . type . info . parameters [ param ] . default ; } } } } private async checkExclusions ( exclusions ) { if ( exclusions . min_width || exclusions . min_height || exclusions . audio ) { console . warn ( "The exclusions option in `initJsPsych()` is deprecated and will be removed in a future version. We recommend using the browser-check plugin instead. See https://www.jspsych.org/latest/plugins/browser-check/." ) ; } if ( exclusions . min_width || exclusions . min_height ) { const mw = exclusions . min_width || 0 ; const mh = exclusions . min_height || 0 ; if ( window . innerWidth < mw || window . innerHeight < mh ) { this . getDisplayElement ( ) . innerHTML = "<p>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.</p>" + "<p>The minimum width is " + mw + "px. Your current width is " + window . innerWidth + "px.</p>" + "<p>The minimum height is " + mh + "px. Your current height is " + window . innerHeight + "px.</p>" ; while ( window . innerWidth < mw || window . innerHeight < mh ) { await delay ( 100 ) ; } this . getDisplayElement ( ) . innerHTML = "" ; } } if ( typeof exclusions . audio !== "undefined" && exclusions . audio ) { if ( ! window . hasOwnProperty ( "AudioContext" ) && ! window . hasOwnProperty ( "webkitAudioContext" ) ) { this . getDisplayElement ( ) . innerHTML = "<p>Your browser does not support the WebAudio API, which means that you will not " + "be able to complete the experiment.</p><p>Browsers that support the WebAudio API include " + "Chrome, Firefox, Safari, and Edge.</p>" ; throw new Error ( ) ; } } } private drawProgressBar ( msg ) { document . querySelector ( ".jspsych-display-element" ) . insertAdjacentHTML ( "afterbegin" , '<div id="jspsych-progressbar-container">' + "<span>" + msg + "</span>" + '<div id="jspsych-progressbar-outer">' + '<div id="jspsych-progressbar-inner"></div>' + "</div></div>" ) ; } private updateProgressBar ( ) { this . setProgressBar ( this . getProgress ( ) . percent_complete / 100 ) ; } private progress_bar_amount = 0 ; setProgressBar ( proportion_complete ) { proportion_complete = Math . max ( Math . min ( 1 , proportion_complete ) , 0 ) ; document . querySelector < HTMLElement > ( "#jspsych-progressbar-inner" ) . style . width = proportion_complete * 100 + "%" ; this . progress_bar_amount = proportion_complete ; } getProgressBarCompleted ( ) { return this . progress_bar_amount ; } }