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: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } export class JsPsych { extensions = {}; turk = turk; randomization = randomization; utils = utils; data: JsPsychData; pluginAPI: PluginAPI; version() { return version; } // // private variables // /** * options */ private opts: any = {}; /** * experiment timeline */ private timeline: TimelineNode; private timelineDescription: any[]; // flow control private global_trial_index = 0; private current_trial: any = {}; private current_trial_finished = false; // target DOM element 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; 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; // storing a single webaudio context to prevent problems with multiple inits // of jsPsych 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?) { // override default options if user specifies an option 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); // so we can pass JsPsych methods as callbacks and `this` remains the JsPsych instance this.webaudio_context = typeof window !== "undefined" && typeof window.AudioContext !== "undefined" ? new AudioContext() : null; // detect whether page is running in browser as a local file, and if so, disable web audio and video preloading to prevent CORS issues 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" ); } // initialize modules this.data = new JsPsychData(this); this.pluginAPI = createJointPluginAPIObject(this); // create instances of extensions for (const extension of options.extensions) { this.extensions[extension.type.info.name] = new extension.type(this); } // initialize audio context based on options and browser capabilities this.pluginAPI.initAudio(); } /** * Starts an experiment using the provided timeline and returns a promise that is resolved when * the experiment is finished. * * @param timeline The timeline to be run */ async run(timeline: any[]) { 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." ); } // create experiment timeline 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: any[], simulation_mode: "data-only" | "visual" = "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; // remove any CSS classes that were added to the DOM via css_classes parameter 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); } // write the data from the trial this.data.write(data); // get back the data with all of the defaults in const trial_data = this.data.getLastTrialData(); // for trial-level callbacks, we just want to pass in a reference to the values // of the DataCollection, for easy access and editing. 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) { // we don't allow internal_node_id or trial_index to be deleted because it would break other things if (key !== "internal_node_id" && key !== "trial_index") { delete trial_data_values[key]; } } } } // handle extension callbacks const extensionCallbackResults = ((current_trial.extensions ?? []) as any[]).map((extension) => this.extensions[extension.type.info.name].on_finish(extension.params) ); const onExtensionCallbacksFinished = () => { // about to execute lots of callbacks, so switch context. this.internal.call_immediate = true; // handle callback at plugin level if (typeof current_trial.on_finish === "function") { current_trial.on_finish(trial_data_values); } // handle callback at whole-experiment level this.opts.on_trial_finish(trial_data_values); // after the above callbacks are complete, then the data should be finalized // for this trial. call the on_data_update handler, passing in the same // data object that just went through the trial's finish handlers. this.opts.on_data_update(trial_data_values); // done with callbacks this.internal.call_immediate = false; // wait for iti if (this.simulation_mode === "data-only") { this.nextTrial(); } else 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(); } } }; // Strictly using Promise.resolve to turn all values into promises would be cleaner here, but it // would require user test code to make the event loop tick after every simulated key press even // if there are no async `on_finish` methods. Hence, in order to avoid a breaking change, we // only rely on the event loop if at least one `on_finish` method returns a promise. if (extensionCallbackResults.some((result) => typeof result.then === "function")) { Promise.all( extensionCallbackResults.map((result) => Promise.resolve(result).then((ext_data_values) => { Object.assign(trial_data_values, ext_data_values); }) ) ).then(onExtensionCallbacksFinished); } else { for (const values of extensionCallbackResults) { Object.assign(trial_data_values, values); } onExtensionCallbacksFinished(); } } 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: string, 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 || "

The experiment failed to load.

"; this.DOM_target.innerHTML = message; } getSafeModeStatus() { return this.file_protocol; } getTimeline() { return this.timelineDescription; } private async prepareDom() { // Wait until the document is ready if (document.readyState !== "complete") { await new Promise((resolve) => { window.addEventListener("load", resolve); }); } const options = this.opts; // set DOM element where jsPsych will render content // if undefined, then jsPsych will use the tag and the entire page if (typeof options.display_element === "undefined") { // check if there is a body element on the page const body = document.querySelector("body"); if (body === null) { document.documentElement.appendChild(document.createElement("body")); } // using the full page, so we need the HTML element to // have 100% height, and body to be full width and height with // no margin 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 { // make sure that the display element exists on the page 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 = '
'; this.DOM_container = options.display_element; this.DOM_target = document.querySelector("#jspsych-content"); // set experiment_width if not null if (options.experiment_width !== null) { this.DOM_target.style.width = options.experiment_width + "px"; } // add tabIndex attribute to scope event listeners options.display_element.tabIndex = 0; // add CSS class to DOM_target if (options.display_element.className.indexOf("jspsych-display-element") === -1) { options.display_element.className += " jspsych-display-element"; } this.DOM_target.className += "jspsych-content"; // create listeners for user browser interaction this.data.createInteractionListeners(); // add event for closing window window.addEventListener("beforeunload", options.on_close); } private async loadExtensions(extensions) { // run the .initialize method of any extensions that are in use // these should return a Promise to indicate when loading is complete 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; }); // show progress bar if requested if (this.opts.show_progress_bar === true) { this.drawProgressBar(this.opts.message_progress_bar); } // record the start time this.exp_start_time = new Date(); // begin! 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 experiment is paused, don't do anything. if (this.paused) { this.waiting = true; return; } this.global_trial_index++; // advance timeline this.timeline.markCurrentTrialComplete(); const complete = this.timeline.advance(); // update progress bar if shown if (this.opts.show_progress_bar === true && this.opts.auto_update_progress_bar === true) { this.updateProgressBar(); } // check if experiment is over if (complete) { this.finishExperiment(); return; } this.doTrial(this.timeline.trial()); } private doTrial(trial) { this.current_trial = trial; this.current_trial_finished = false; // process all timeline variables for this trial 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." ); } // instantiate the plugin for this trial trial.type = { // this is a hack to internally keep the old plugin object structure and prevent touching more // of the core jspsych code ...autoBind(new trial.type(this)), info: trial.type.info, }; // evaluate variables that are functions this.evaluateFunctionParameters(trial); // get default values for parameters this.setDefaultValues(trial); // about to execute callbacks this.internal.call_immediate = true; // call experiment wide callback this.opts.on_trial_start(trial); // call trial specific callback if it exists if (typeof trial.on_start === "function") { trial.on_start(trial); } // call any on_start functions for extensions if (Array.isArray(trial.extensions)) { for (const extension of trial.extensions) { this.extensions[extension.type.info.name].on_start(extension.params); } } // apply the focus to the element containing the experiment. this.DOM_container.focus(); // reset the scroll on the DOM target this.DOM_target.scrollTop = 0; // add CSS classes to the DOM_target if they exist in trial.css_classes 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); } } // setup on_load event callback const load_callback = () => { if (typeof trial.on_load === "function") { trial.on_load(); } // call any on_load functions for extensions 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) { // check if the trial supports simulation 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 doesn't have a simulate method, so just run as usual trial_complete = trial.type.trial(this.DOM_target, trial, load_callback); } } // see if trial_complete is a Promise by looking for .then() function const is_promise = trial_complete && typeof trial_complete.then == "function"; // in simulation mode we let the simulate function call the load_callback always. if (!is_promise && !this.simulation_mode) { load_callback(); } // done with callbacks this.internal.call_immediate = false; } private evaluateTimelineVariables(trial) { for (const key of Object.keys(trial)) { if ( typeof trial[key] === "object" && trial[key] !== null && typeof trial[key].timelineVariablePlaceholder !== "undefined" ) { trial[key] = trial[key].timelineVariableFunction(); } // timeline variables that are nested in objects if ( typeof trial[key] === "object" && trial[key] !== null && key !== "timeline" && key !== "timeline_variables" ) { this.evaluateTimelineVariables(trial[key]); } } } private evaluateFunctionParameters(trial) { // set a flag so that jsPsych.timelineVariable() is immediately executed in this context this.internal.call_immediate = true; // iterate over each parameter for (const key of Object.keys(trial)) { // check to make sure parameter is not "type", since that was eval'd above. if (key !== "type") { // this if statement is checking to see if the parameter type is expected to be a function, in which case we should NOT evaluate it. // the first line checks if the parameter is defined in the universalPluginParameters set // the second line checks the plugin-specific parameters 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]); } } } // reset so jsPsych.timelineVariable() is no longer immediately executed this.internal.call_immediate = false; } private replaceFunctionsWithValues(obj, info) { // null typeof is 'object' (?!?!), so need to run this first! if (obj === null) { return obj; } // arrays else if (Array.isArray(obj)) { for (let i = 0; i < obj.length; i++) { obj[i] = this.replaceFunctionsWithValues(obj[i], info); } } // objects else if (typeof obj === "object") { if (info === null || !info.nested) { for (const key of Object.keys(obj)) { if (key === "type" || key === "timeline" || key === "timeline_variables") { // Ignore the object's `type` field because it contains a plugin and we do not want to // call plugin functions. Also ignore `timeline` and `timeline_variables` because they // are used in the `trials` parameter of the preload plugin and we don't want to actually // evaluate those in that context. 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) { // check if parameter is complex with nested defaults if (trial.type.info.parameters[param].type === ParameterType.COMPLEX) { // check if parameter is undefined and has a default value if (typeof trial[param] === "undefined" && trial.type.info.parameters[param].default) { trial[param] = trial.type.info.parameters[param].default; } // if parameter is an array, iterate over each entry after confirming that there are // entries to iterate over. this is common when some parameters in a COMPLEX type have // default values and others do not. if (trial.type.info.parameters[param].array === true && Array.isArray(trial[param])) { // iterate over each entry in the array trial[param].forEach(function (ip, i) { // check each parameter in the plugin description 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.info.name} plugin.` ); } else { trial[param][i][p] = trial.type.info.parameters[param].nested[p].default; } } } }); } } // if it's not nested, checking is much easier and do that here: 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/." ); } // MINIMUM SIZE 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 = "

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 " + window.innerWidth + "px.

" + "

The minimum height is " + mh + "px. Your current height is " + window.innerHeight + "px.

"; // Wait for window size to increase while (window.innerWidth < mw || window.innerHeight < mh) { await delay(100); } this.getDisplayElement().innerHTML = ""; } } // WEB AUDIO API if (typeof exclusions.audio !== "undefined" && exclusions.audio) { if (!window.hasOwnProperty("AudioContext") && !window.hasOwnProperty("webkitAudioContext")) { this.getDisplayElement().innerHTML = "

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.

"; throw new Error(); } } } private drawProgressBar(msg) { document .querySelector(".jspsych-display-element") .insertAdjacentHTML( "afterbegin", '
' + "" + msg + "" + '
' + '
' + "
" ); } 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("#jspsych-progressbar-inner").style.width = proportion_complete * 100 + "%"; this.progress_bar_amount = proportion_complete; } getProgressBarCompleted() { return this.progress_bar_amount; } }