jspsych 7.3.3 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/css/jspsych.css +19 -11
  3. package/dist/index.browser.js +3082 -3399
  4. package/dist/index.browser.js.map +1 -1
  5. package/dist/index.browser.min.js +6 -2
  6. package/dist/index.browser.min.js.map +1 -1
  7. package/dist/index.cjs +2464 -3327
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.ts +990 -12
  10. package/dist/index.js +2463 -3325
  11. package/dist/index.js.map +1 -1
  12. package/package.json +6 -5
  13. package/src/ExtensionManager.spec.ts +123 -0
  14. package/src/ExtensionManager.ts +81 -0
  15. package/src/JsPsych.ts +195 -690
  16. package/src/ProgressBar.spec.ts +60 -0
  17. package/src/ProgressBar.ts +60 -0
  18. package/src/index.scss +29 -8
  19. package/src/index.ts +4 -9
  20. package/src/modules/data/DataCollection.ts +1 -1
  21. package/src/modules/data/DataColumn.ts +12 -1
  22. package/src/modules/data/index.ts +92 -103
  23. package/src/modules/extensions.ts +4 -0
  24. package/src/modules/plugin-api/AudioPlayer.ts +101 -0
  25. package/src/modules/plugin-api/KeyboardListenerAPI.ts +1 -1
  26. package/src/modules/plugin-api/MediaAPI.ts +48 -106
  27. package/src/modules/plugin-api/__mocks__/AudioPlayer.ts +38 -0
  28. package/src/modules/plugin-api/index.ts +11 -14
  29. package/src/modules/plugins.ts +26 -27
  30. package/src/modules/randomization.ts +1 -1
  31. package/src/timeline/Timeline.spec.ts +921 -0
  32. package/src/timeline/Timeline.ts +342 -0
  33. package/src/timeline/TimelineNode.ts +174 -0
  34. package/src/timeline/Trial.spec.ts +897 -0
  35. package/src/timeline/Trial.ts +419 -0
  36. package/src/timeline/index.ts +232 -0
  37. package/src/timeline/util.spec.ts +124 -0
  38. package/src/timeline/util.ts +146 -0
  39. package/dist/JsPsych.d.ts +0 -112
  40. package/dist/TimelineNode.d.ts +0 -34
  41. package/dist/migration.d.ts +0 -3
  42. package/dist/modules/data/DataCollection.d.ts +0 -46
  43. package/dist/modules/data/DataColumn.d.ts +0 -15
  44. package/dist/modules/data/index.d.ts +0 -25
  45. package/dist/modules/data/utils.d.ts +0 -3
  46. package/dist/modules/extensions.d.ts +0 -22
  47. package/dist/modules/plugin-api/HardwareAPI.d.ts +0 -15
  48. package/dist/modules/plugin-api/KeyboardListenerAPI.d.ts +0 -34
  49. package/dist/modules/plugin-api/MediaAPI.d.ts +0 -32
  50. package/dist/modules/plugin-api/SimulationAPI.d.ts +0 -44
  51. package/dist/modules/plugin-api/TimeoutAPI.d.ts +0 -17
  52. package/dist/modules/plugin-api/index.d.ts +0 -8
  53. package/dist/modules/plugins.d.ts +0 -136
  54. package/dist/modules/randomization.d.ts +0 -42
  55. package/dist/modules/turk.d.ts +0 -40
  56. package/dist/modules/utils.d.ts +0 -13
  57. package/src/TimelineNode.ts +0 -544
  58. package/src/modules/plugin-api/HardwareAPI.ts +0 -32
package/src/JsPsych.ts CHANGED
@@ -1,21 +1,27 @@
1
1
  import autoBind from "auto-bind";
2
2
 
3
3
  import { version } from "../package.json";
4
- import { MigrationError } from "./migration";
5
- import { JsPsychData } from "./modules/data";
4
+ import { ExtensionManager, ExtensionManagerDependencies } from "./ExtensionManager";
5
+ import { JsPsychData, JsPsychDataDependencies } from "./modules/data";
6
6
  import { PluginAPI, createJointPluginAPIObject } from "./modules/plugin-api";
7
- import { ParameterType, universalPluginParameters } from "./modules/plugins";
8
7
  import * as randomization from "./modules/randomization";
9
8
  import * as turk from "./modules/turk";
10
9
  import * as utils from "./modules/utils";
11
- import { TimelineNode } from "./TimelineNode";
12
-
13
- function delay(ms: number) {
14
- return new Promise((resolve) => setTimeout(resolve, ms));
15
- }
10
+ import { ProgressBar } from "./ProgressBar";
11
+ import {
12
+ SimulationMode,
13
+ SimulationOptionsParameter,
14
+ TimelineArray,
15
+ TimelineDescription,
16
+ TimelineNodeDependencies,
17
+ TimelineVariable,
18
+ TrialResult,
19
+ } from "./timeline";
20
+ import { Timeline } from "./timeline/Timeline";
21
+ import { Trial } from "./timeline/Trial";
22
+ import { PromiseWrapper } from "./timeline/util";
16
23
 
17
24
  export class JsPsych {
18
- extensions = <any>{};
19
25
  turk = turk;
20
26
  randomization = randomization;
21
27
  utils = utils;
@@ -26,75 +32,32 @@ export class JsPsych {
26
32
  return version;
27
33
  }
28
34
 
29
- //
30
- // private variables
31
- //
35
+ /** Options */
36
+ private options: any = {};
32
37
 
33
- /**
34
- * options
35
- */
36
- private opts: any = {};
38
+ /** Experiment timeline */
39
+ private timeline?: Timeline;
37
40
 
38
- /**
39
- * experiment timeline
40
- */
41
- private timeline: TimelineNode;
42
- private timelineDescription: any[];
41
+ /** Target DOM element */
42
+ private displayContainerElement: HTMLElement;
43
+ private displayElement: HTMLElement;
43
44
 
44
- // flow control
45
- private global_trial_index = 0;
46
- private current_trial: any = {};
47
- private current_trial_finished = false;
48
-
49
- // target DOM element
50
- private DOM_container: HTMLElement;
51
- private DOM_target: HTMLElement;
45
+ /** Time that the experiment began */
46
+ private experimentStartTime: Date;
52
47
 
53
48
  /**
54
- * time that the experiment began
49
+ * Whether the page is retrieved directly via the `file://` protocol (true) or hosted on a web
50
+ * server (false)
55
51
  */
56
- private exp_start_time;
52
+ private isFileProtocolUsed = false;
57
53
 
58
- /**
59
- * is the experiment paused?
60
- */
61
- private paused = false;
62
- private waiting = false;
54
+ /** The simulation mode (if the experiment is being simulated) */
55
+ private simulationMode?: SimulationMode;
63
56
 
64
- /**
65
- * is the page retrieved directly via file:// protocol (true) or hosted on a server (false)?
66
- */
67
- private file_protocol = false;
57
+ /** Simulation options passed in via `simulate()` */
58
+ private simulationOptions: Record<string, SimulationOptionsParameter>;
68
59
 
69
- /**
70
- * Promise that is resolved when `finishExperiment()` is called
71
- */
72
- private finished: Promise<void>;
73
- private resolveFinishedPromise: () => void;
74
-
75
- /**
76
- * is the experiment running in `simulate()` mode
77
- */
78
- private simulation_mode: "data-only" | "visual" = null;
79
-
80
- /**
81
- * simulation options passed in via `simulate()`
82
- */
83
- private simulation_options;
84
-
85
- // storing a single webaudio context to prevent problems with multiple inits
86
- // of jsPsych
87
- webaudio_context: AudioContext = null;
88
-
89
- internal = {
90
- /**
91
- * this flag is used to determine whether we are in a scope where
92
- * jsPsych.timelineVariable() should be executed immediately or
93
- * whether it should return a function to access the variable later.
94
- *
95
- **/
96
- call_immediate: false,
97
- };
60
+ private extensionManager: ExtensionManager;
98
61
 
99
62
  constructor(options?) {
100
63
  // override default options if user specifies an option
@@ -107,7 +70,6 @@ export class JsPsych {
107
70
  on_interaction_data_update: () => {},
108
71
  on_close: () => {},
109
72
  use_webaudio: true,
110
- exclusions: {},
111
73
  show_progress_bar: false,
112
74
  message_progress_bar: "Completion Progress",
113
75
  auto_update_progress_bar: true,
@@ -119,22 +81,18 @@ export class JsPsych {
119
81
  extensions: [],
120
82
  ...options,
121
83
  };
122
- this.opts = options;
84
+ this.options = options;
123
85
 
124
86
  autoBind(this); // so we can pass JsPsych methods as callbacks and `this` remains the JsPsych instance
125
87
 
126
- this.webaudio_context =
127
- typeof window !== "undefined" && typeof window.AudioContext !== "undefined"
128
- ? new AudioContext()
129
- : null;
130
-
131
- // detect whether page is running in browser as a local file, and if so, disable web audio and video preloading to prevent CORS issues
88
+ // detect whether page is running in browser as a local file, and if so, disable web audio and
89
+ // video preloading to prevent CORS issues
132
90
  if (
133
91
  window.location.protocol == "file:" &&
134
92
  (options.override_safe_mode === false || typeof options.override_safe_mode === "undefined")
135
93
  ) {
136
94
  options.use_webaudio = false;
137
- this.file_protocol = true;
95
+ this.isFileProtocolUsed = true;
138
96
  console.warn(
139
97
  "jsPsych detected that it is running via the file:// protocol and not on a web server. " +
140
98
  "To prevent issues with cross-origin requests, Web Audio and video preloading have been disabled. " +
@@ -144,27 +102,26 @@ export class JsPsych {
144
102
  }
145
103
 
146
104
  // initialize modules
147
- this.data = new JsPsychData(this);
105
+ this.data = new JsPsychData(this.dataDependencies);
148
106
  this.pluginAPI = createJointPluginAPIObject(this);
149
107
 
150
- // create instances of extensions
151
- for (const extension of options.extensions) {
152
- this.extensions[extension.type.info.name] = new extension.type(this);
153
- }
154
-
155
- // initialize audio context based on options and browser capabilities
156
- this.pluginAPI.initAudio();
108
+ this.extensionManager = new ExtensionManager(
109
+ this.extensionManagerDependencies,
110
+ options.extensions
111
+ );
157
112
  }
158
113
 
114
+ private endMessage?: string;
115
+
159
116
  /**
160
117
  * Starts an experiment using the provided timeline and returns a promise that is resolved when
161
118
  * the experiment is finished.
162
119
  *
163
120
  * @param timeline The timeline to be run
164
121
  */
165
- async run(timeline: any[]) {
122
+ async run(timeline: TimelineDescription | TimelineArray) {
166
123
  if (typeof timeline === "undefined") {
167
- console.error("No timeline declared in jsPsych.run. Cannot start experiment.");
124
+ console.error("No timeline declared in jsPsych.run(). Cannot start experiment.");
168
125
  }
169
126
 
170
127
  if (timeline.length === 0) {
@@ -174,17 +131,23 @@ export class JsPsych {
174
131
  }
175
132
 
176
133
  // create experiment timeline
177
- this.timelineDescription = timeline;
178
- this.timeline = new TimelineNode(this, { timeline });
134
+ this.timeline = new Timeline(this.timelineDependencies, timeline);
179
135
 
180
136
  await this.prepareDom();
181
- await this.checkExclusions(this.opts.exclusions);
182
- await this.loadExtensions(this.opts.extensions);
137
+ await this.extensionManager.initializeExtensions();
183
138
 
184
139
  document.documentElement.setAttribute("jspsych", "present");
185
140
 
186
- this.startExperiment();
187
- await this.finished;
141
+ this.experimentStartTime = new Date();
142
+
143
+ await this.timeline.run();
144
+ await Promise.resolve(this.options.on_finish(this.data.get()));
145
+
146
+ if (this.endMessage) {
147
+ this.getDisplayElement().innerHTML = this.endMessage;
148
+ }
149
+
150
+ this.data.removeInteractionListeners();
188
151
  }
189
152
 
190
153
  async simulate(
@@ -192,220 +155,110 @@ export class JsPsych {
192
155
  simulation_mode: "data-only" | "visual" = "data-only",
193
156
  simulation_options = {}
194
157
  ) {
195
- this.simulation_mode = simulation_mode;
196
- this.simulation_options = simulation_options;
158
+ this.simulationMode = simulation_mode;
159
+ this.simulationOptions = simulation_options;
197
160
  await this.run(timeline);
198
161
  }
199
162
 
163
+ public progressBar?: ProgressBar;
164
+
200
165
  getProgress() {
201
166
  return {
202
- total_trials: typeof this.timeline === "undefined" ? undefined : this.timeline.length(),
203
- current_trial_global: this.global_trial_index,
204
- percent_complete: typeof this.timeline === "undefined" ? 0 : this.timeline.percentComplete(),
167
+ total_trials: this.timeline?.getNaiveTrialCount(),
168
+ current_trial_global: this.timeline?.getLatestNode().index ?? 0,
169
+ percent_complete: this.timeline?.getNaiveProgress() * 100,
205
170
  };
206
171
  }
207
172
 
208
173
  getStartTime() {
209
- return this.exp_start_time;
174
+ return this.experimentStartTime; // TODO This seems inconsistent, given that `getTotalTime()` returns a number, not a `Date`
210
175
  }
211
176
 
212
177
  getTotalTime() {
213
- if (typeof this.exp_start_time === "undefined") {
178
+ if (!this.experimentStartTime) {
214
179
  return 0;
215
180
  }
216
- return new Date().getTime() - this.exp_start_time.getTime();
181
+ return new Date().getTime() - this.experimentStartTime.getTime();
217
182
  }
218
183
 
219
184
  getDisplayElement() {
220
- return this.DOM_target;
185
+ return this.displayElement;
221
186
  }
222
187
 
223
188
  getDisplayContainerElement() {
224
- return this.DOM_container;
189
+ return this.displayContainerElement;
225
190
  }
226
191
 
227
- finishTrial(data = {}) {
228
- if (this.current_trial_finished) {
229
- return;
230
- }
231
- this.current_trial_finished = true;
232
-
233
- // remove any CSS classes that were added to the DOM via css_classes parameter
234
- if (
235
- typeof this.current_trial.css_classes !== "undefined" &&
236
- Array.isArray(this.current_trial.css_classes)
237
- ) {
238
- this.DOM_target.classList.remove(...this.current_trial.css_classes);
239
- }
240
-
241
- // write the data from the trial
242
- this.data.write(data);
243
-
244
- // get back the data with all of the defaults in
245
- const trial_data = this.data.getLastTrialData();
246
-
247
- // for trial-level callbacks, we just want to pass in a reference to the values
248
- // of the DataCollection, for easy access and editing.
249
- const trial_data_values = trial_data.values()[0];
250
-
251
- const current_trial = this.current_trial;
252
-
253
- if (typeof current_trial.save_trial_parameters === "object") {
254
- for (const key of Object.keys(current_trial.save_trial_parameters)) {
255
- const key_val = current_trial.save_trial_parameters[key];
256
- if (key_val === true) {
257
- if (typeof current_trial[key] === "undefined") {
258
- console.warn(
259
- `Invalid parameter specified in save_trial_parameters. Trial has no property called "${key}".`
260
- );
261
- } else if (typeof current_trial[key] === "function") {
262
- trial_data_values[key] = current_trial[key].toString();
263
- } else {
264
- trial_data_values[key] = current_trial[key];
265
- }
266
- }
267
- if (key_val === false) {
268
- // we don't allow internal_node_id or trial_index to be deleted because it would break other things
269
- if (key !== "internal_node_id" && key !== "trial_index") {
270
- delete trial_data_values[key];
271
- }
272
- }
273
- }
274
- }
275
-
276
- // handle extension callbacks
277
-
278
- const extensionCallbackResults = ((current_trial.extensions ?? []) as any[]).map((extension) =>
279
- this.extensions[extension.type.info.name].on_finish(extension.params)
280
- );
281
-
282
- const onExtensionCallbacksFinished = () => {
283
- // about to execute lots of callbacks, so switch context.
284
- this.internal.call_immediate = true;
285
-
286
- // handle callback at plugin level
287
- if (typeof current_trial.on_finish === "function") {
288
- current_trial.on_finish(trial_data_values);
289
- }
290
-
291
- // handle callback at whole-experiment level
292
- this.opts.on_trial_finish(trial_data_values);
293
-
294
- // after the above callbacks are complete, then the data should be finalized
295
- // for this trial. call the on_data_update handler, passing in the same
296
- // data object that just went through the trial's finish handlers.
297
- this.opts.on_data_update(trial_data_values);
298
-
299
- // done with callbacks
300
- this.internal.call_immediate = false;
301
-
302
- // wait for iti
303
- if (this.simulation_mode === "data-only") {
304
- this.nextTrial();
305
- } else if (
306
- typeof current_trial.post_trial_gap === null ||
307
- typeof current_trial.post_trial_gap === "undefined"
308
- ) {
309
- if (this.opts.default_iti > 0) {
310
- setTimeout(this.nextTrial, this.opts.default_iti);
311
- } else {
312
- this.nextTrial();
313
- }
314
- } else {
315
- if (current_trial.post_trial_gap > 0) {
316
- setTimeout(this.nextTrial, current_trial.post_trial_gap);
317
- } else {
318
- this.nextTrial();
319
- }
320
- }
321
- };
322
-
323
- // Strictly using Promise.resolve to turn all values into promises would be cleaner here, but it
324
- // would require user test code to make the event loop tick after every simulated key press even
325
- // if there are no async `on_finish` methods. Hence, in order to avoid a breaking change, we
326
- // only rely on the event loop if at least one `on_finish` method returns a promise.
327
- if (extensionCallbackResults.some((result) => typeof result.then === "function")) {
328
- Promise.all(
329
- extensionCallbackResults.map((result) =>
330
- Promise.resolve(result).then((ext_data_values) => {
331
- Object.assign(trial_data_values, ext_data_values);
332
- })
333
- )
334
- ).then(onExtensionCallbacksFinished);
335
- } else {
336
- for (const values of extensionCallbackResults) {
337
- Object.assign(trial_data_values, values);
338
- }
339
- onExtensionCallbacksFinished();
340
- }
341
- }
342
-
343
- endExperiment(end_message = "", data = {}) {
344
- this.timeline.end_message = end_message;
345
- this.timeline.end();
192
+ abortExperiment(endMessage?: string, data = {}) {
193
+ this.endMessage = endMessage;
194
+ this.timeline.abort();
346
195
  this.pluginAPI.cancelAllKeyboardResponses();
347
196
  this.pluginAPI.clearAllTimeouts();
348
197
  this.finishTrial(data);
349
198
  }
350
199
 
351
- endCurrentTimeline() {
352
- this.timeline.endActiveNode();
353
- }
354
-
355
- getCurrentTrial() {
356
- return this.current_trial;
200
+ abortCurrentTimeline() {
201
+ let currentTimeline = this.timeline?.getLatestNode();
202
+ if (currentTimeline instanceof Trial) {
203
+ currentTimeline = currentTimeline.parent;
204
+ }
205
+ if (currentTimeline instanceof Timeline) {
206
+ currentTimeline.abort();
207
+ }
357
208
  }
358
209
 
359
- getInitSettings() {
360
- return this.opts;
210
+ /**
211
+ * Aborts a named timeline. The timeline must be currently running in order to abort it.
212
+ *
213
+ * @param name The name of the timeline to abort. Timelines can be given names by setting the `name` parameter in the description of the timeline.
214
+ */
215
+ abortTimelineByName(name: string): void {
216
+ const timeline = this.timeline?.getActiveTimelineByName(name);
217
+ if (timeline) {
218
+ timeline.abort();
219
+ }
361
220
  }
362
221
 
363
- getCurrentTimelineNodeID() {
364
- return this.timeline.activeID();
222
+ getCurrentTrial() {
223
+ const activeNode = this.timeline?.getLatestNode();
224
+ if (activeNode instanceof Trial) {
225
+ return activeNode.description;
226
+ }
227
+ return undefined;
365
228
  }
366
229
 
367
- timelineVariable(varname: string, immediate = false) {
368
- if (this.internal.call_immediate || immediate === true) {
369
- return this.timeline.timelineVariable(varname);
370
- } else {
371
- return {
372
- timelineVariablePlaceholder: true,
373
- timelineVariableFunction: () => this.timeline.timelineVariable(varname),
374
- };
375
- }
230
+ getInitSettings() {
231
+ return this.options;
376
232
  }
377
233
 
378
- getAllTimelineVariables() {
379
- return this.timeline.allTimelineVariables();
234
+ timelineVariable(variableName: string) {
235
+ return new TimelineVariable(variableName);
380
236
  }
381
237
 
382
- addNodeToEndOfTimeline(new_timeline, preload_callback?) {
383
- this.timeline.insert(new_timeline);
238
+ evaluateTimelineVariable(variableName: string) {
239
+ return this.timeline
240
+ ?.getLatestNode()
241
+ ?.evaluateTimelineVariable(new TimelineVariable(variableName));
384
242
  }
385
243
 
386
244
  pauseExperiment() {
387
- this.paused = true;
245
+ this.timeline?.pause();
388
246
  }
389
247
 
390
248
  resumeExperiment() {
391
- this.paused = false;
392
- if (this.waiting) {
393
- this.waiting = false;
394
- this.nextTrial();
395
- }
396
- }
397
-
398
- loadFail(message) {
399
- message = message || "<p>The experiment failed to load.</p>";
400
- this.DOM_target.innerHTML = message;
249
+ this.timeline?.resume();
401
250
  }
402
251
 
403
252
  getSafeModeStatus() {
404
- return this.file_protocol;
253
+ return this.isFileProtocolUsed;
405
254
  }
406
255
 
407
256
  getTimeline() {
408
- return this.timelineDescription;
257
+ return this.timeline?.description.timeline;
258
+ }
259
+
260
+ get extensions() {
261
+ return this.extensionManager?.extensions ?? {};
409
262
  }
410
263
 
411
264
  private async prepareDom() {
@@ -416,24 +269,25 @@ export class JsPsych {
416
269
  });
417
270
  }
418
271
 
419
- const options = this.opts;
272
+ const options = this.options;
420
273
 
421
274
  // set DOM element where jsPsych will render content
422
275
  // if undefined, then jsPsych will use the <body> tag and the entire page
423
276
  if (typeof options.display_element === "undefined") {
424
277
  // check if there is a body element on the page
425
- const body = document.querySelector("body");
426
- if (body === null) {
427
- document.documentElement.appendChild(document.createElement("body"));
278
+ let body = document.body;
279
+ if (!body) {
280
+ body = document.createElement("body");
281
+ document.documentElement.appendChild(body);
428
282
  }
429
- // using the full page, so we need the HTML element to
430
- // have 100% height, and body to be full width and height with
431
- // no margin
283
+ // using the full page, so we need the HTML element to have 100% height, and body to be full
284
+ // width and height with no margin
432
285
  document.querySelector("html").style.height = "100%";
433
- document.querySelector("body").style.margin = "0px";
434
- document.querySelector("body").style.height = "100%";
435
- document.querySelector("body").style.width = "100%";
436
- options.display_element = document.querySelector("body");
286
+
287
+ body.style.margin = "0px";
288
+ body.style.height = "100%";
289
+ body.style.width = "100%";
290
+ options.display_element = body;
437
291
  } else {
438
292
  // make sure that the display element exists on the page
439
293
  const display =
@@ -447,468 +301,119 @@ export class JsPsych {
447
301
  }
448
302
  }
449
303
 
450
- options.display_element.innerHTML =
451
- '<div class="jspsych-content-wrapper"><div id="jspsych-content"></div></div>';
452
- this.DOM_container = options.display_element;
453
- this.DOM_target = document.querySelector("#jspsych-content");
304
+ const contentElement = document.createElement("div");
305
+ contentElement.id = "jspsych-content";
306
+
307
+ const contentWrapperElement = document.createElement("div");
308
+ contentWrapperElement.className = "jspsych-content-wrapper";
309
+ contentWrapperElement.appendChild(contentElement);
310
+
311
+ this.displayContainerElement = options.display_element;
312
+ this.displayContainerElement.appendChild(contentWrapperElement);
313
+ this.displayElement = contentElement;
454
314
 
455
315
  // set experiment_width if not null
456
316
  if (options.experiment_width !== null) {
457
- this.DOM_target.style.width = options.experiment_width + "px";
317
+ this.displayElement.style.width = options.experiment_width + "px";
458
318
  }
459
319
 
460
320
  // add tabIndex attribute to scope event listeners
461
321
  options.display_element.tabIndex = 0;
462
322
 
463
- // add CSS class to DOM_target
464
- if (options.display_element.className.indexOf("jspsych-display-element") === -1) {
465
- options.display_element.className += " jspsych-display-element";
466
- }
467
- this.DOM_target.className += "jspsych-content";
323
+ // Add CSS classes to container and display elements
324
+ this.displayContainerElement.classList.add("jspsych-display-element");
325
+ this.displayElement.classList.add("jspsych-content");
468
326
 
469
327
  // create listeners for user browser interaction
470
328
  this.data.createInteractionListeners();
471
329
 
472
330
  // add event for closing window
473
331
  window.addEventListener("beforeunload", options.on_close);
474
- }
475
-
476
- private async loadExtensions(extensions) {
477
- // run the .initialize method of any extensions that are in use
478
- // these should return a Promise to indicate when loading is complete
479
-
480
- try {
481
- await Promise.all(
482
- extensions.map((extension) =>
483
- this.extensions[extension.type.info.name].initialize(extension.params || {})
484
- )
485
- );
486
- } catch (error_message) {
487
- console.error(error_message);
488
- throw new Error(error_message);
489
- }
490
- }
491
-
492
- private startExperiment() {
493
- this.finished = new Promise((resolve) => {
494
- this.resolveFinishedPromise = resolve;
495
- });
496
-
497
- // show progress bar if requested
498
- if (this.opts.show_progress_bar === true) {
499
- this.drawProgressBar(this.opts.message_progress_bar);
500
- }
501
332
 
502
- // record the start time
503
- this.exp_start_time = new Date();
333
+ if (this.options.show_progress_bar) {
334
+ const progressBarContainer = document.createElement("div");
335
+ progressBarContainer.id = "jspsych-progressbar-container";
504
336
 
505
- // begin!
506
- this.timeline.advance();
507
- this.doTrial(this.timeline.trial());
508
- }
509
-
510
- private finishExperiment() {
511
- const finish_result = this.opts.on_finish(this.data.get());
512
-
513
- const done_handler = () => {
514
- if (typeof this.timeline.end_message !== "undefined") {
515
- this.DOM_target.innerHTML = this.timeline.end_message;
516
- }
517
- this.resolveFinishedPromise();
518
- };
337
+ this.progressBar = new ProgressBar(progressBarContainer, this.options.message_progress_bar);
519
338
 
520
- if (finish_result) {
521
- Promise.resolve(finish_result).then(done_handler);
522
- } else {
523
- done_handler();
339
+ this.getDisplayContainerElement().insertAdjacentElement("afterbegin", progressBarContainer);
524
340
  }
525
341
  }
526
342
 
527
- private nextTrial() {
528
- // if experiment is paused, don't do anything.
529
- if (this.paused) {
530
- this.waiting = true;
531
- return;
532
- }
533
-
534
- this.global_trial_index++;
535
-
536
- // advance timeline
537
- this.timeline.markCurrentTrialComplete();
538
- const complete = this.timeline.advance();
539
-
540
- // update progress bar if shown
541
- if (this.opts.show_progress_bar === true && this.opts.auto_update_progress_bar === true) {
542
- this.updateProgressBar();
543
- }
544
-
545
- // check if experiment is over
546
- if (complete) {
547
- this.finishExperiment();
548
- return;
549
- }
550
-
551
- this.doTrial(this.timeline.trial());
343
+ private finishTrialPromise = new PromiseWrapper<TrialResult | void>();
344
+ finishTrial(data?: TrialResult) {
345
+ this.finishTrialPromise.resolve(data);
552
346
  }
553
347
 
554
- private doTrial(trial) {
555
- this.current_trial = trial;
556
- this.current_trial_finished = false;
348
+ private timelineDependencies: TimelineNodeDependencies = {
349
+ onTrialStart: (trial: Trial) => {
350
+ this.options.on_trial_start(trial.trialObject);
557
351
 
558
- // process all timeline variables for this trial
559
- this.evaluateTimelineVariables(trial);
352
+ // apply the focus to the element containing the experiment.
353
+ this.getDisplayContainerElement().focus();
354
+ // reset the scroll on the DOM target
355
+ this.getDisplayElement().scrollTop = 0;
356
+ },
560
357
 
561
- if (typeof trial.type === "string") {
562
- throw new MigrationError(
563
- "A string was provided as the trial's `type` parameter. Since jsPsych v7, the `type` parameter needs to be a plugin object."
564
- );
565
- }
566
-
567
- // instantiate the plugin for this trial
568
- trial.type = {
569
- // this is a hack to internally keep the old plugin object structure and prevent touching more
570
- // of the core jspsych code
571
- ...autoBind(new trial.type(this)),
572
- info: trial.type.info,
573
- };
574
-
575
- // evaluate variables that are functions
576
- this.evaluateFunctionParameters(trial);
577
-
578
- // get default values for parameters
579
- this.setDefaultValues(trial);
580
-
581
- // about to execute callbacks
582
- this.internal.call_immediate = true;
583
-
584
- // call experiment wide callback
585
- this.opts.on_trial_start(trial);
586
-
587
- // call trial specific callback if it exists
588
- if (typeof trial.on_start === "function") {
589
- trial.on_start(trial);
590
- }
591
-
592
- // call any on_start functions for extensions
593
- if (Array.isArray(trial.extensions)) {
594
- for (const extension of trial.extensions) {
595
- this.extensions[extension.type.info.name].on_start(extension.params);
358
+ onTrialResultAvailable: (trial: Trial) => {
359
+ const result = trial.getResult();
360
+ if (result) {
361
+ result.time_elapsed = this.getTotalTime();
362
+ this.data.write(trial);
596
363
  }
597
- }
598
-
599
- // apply the focus to the element containing the experiment.
600
- this.DOM_container.focus();
364
+ },
601
365
 
602
- // reset the scroll on the DOM target
603
- this.DOM_target.scrollTop = 0;
366
+ onTrialFinished: (trial: Trial) => {
367
+ const result = trial.getResult();
368
+ this.options.on_trial_finish(result);
604
369
 
605
- // add CSS classes to the DOM_target if they exist in trial.css_classes
606
- if (typeof trial.css_classes !== "undefined") {
607
- if (!Array.isArray(trial.css_classes) && typeof trial.css_classes === "string") {
608
- trial.css_classes = [trial.css_classes];
609
- }
610
- if (Array.isArray(trial.css_classes)) {
611
- this.DOM_target.classList.add(...trial.css_classes);
370
+ if (result) {
371
+ this.options.on_data_update(result);
612
372
  }
613
- }
614
373
 
615
- // setup on_load event callback
616
- const load_callback = () => {
617
- if (typeof trial.on_load === "function") {
618
- trial.on_load();
374
+ if (this.progressBar && this.options.auto_update_progress_bar) {
375
+ this.progressBar.progress = this.timeline.getNaiveProgress();
619
376
  }
377
+ },
620
378
 
621
- // call any on_load functions for extensions
622
- if (Array.isArray(trial.extensions)) {
623
- for (const extension of trial.extensions) {
624
- this.extensions[extension.type.info.name].on_load(extension.params);
625
- }
626
- }
627
- };
379
+ runOnStartExtensionCallbacks: (extensionsConfiguration) =>
380
+ this.extensionManager.onStart(extensionsConfiguration),
628
381
 
629
- let trial_complete;
630
- let trial_sim_opts;
631
- let trial_sim_opts_merged;
632
- if (!this.simulation_mode) {
633
- trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
634
- }
635
- if (this.simulation_mode) {
636
- // check if the trial supports simulation
637
- if (trial.type.simulate) {
638
- if (!trial.simulation_options) {
639
- trial_sim_opts = this.simulation_options.default;
640
- }
641
- if (trial.simulation_options) {
642
- if (typeof trial.simulation_options == "string") {
643
- if (this.simulation_options[trial.simulation_options]) {
644
- trial_sim_opts = this.simulation_options[trial.simulation_options];
645
- } else if (this.simulation_options.default) {
646
- console.log(
647
- `No matching simulation options found for "${trial.simulation_options}". Using "default" options.`
648
- );
649
- trial_sim_opts = this.simulation_options.default;
650
- } else {
651
- console.log(
652
- `No matching simulation options found for "${trial.simulation_options}" and no "default" options provided. Using the default values provided by the plugin.`
653
- );
654
- trial_sim_opts = {};
655
- }
656
- } else {
657
- trial_sim_opts = trial.simulation_options;
658
- }
659
- }
660
- // merge in default options that aren't overriden by the trial's simulation_options
661
- // including nested parameters in the simulation_options
662
- trial_sim_opts_merged = this.utils.deepMerge(
663
- this.simulation_options.default,
664
- trial_sim_opts
665
- );
666
-
667
- trial_sim_opts_merged = this.utils.deepCopy(trial_sim_opts_merged);
668
- trial_sim_opts_merged = this.replaceFunctionsWithValues(trial_sim_opts_merged, null);
669
-
670
- if (trial_sim_opts_merged?.simulate === false) {
671
- trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
672
- } else {
673
- trial_complete = trial.type.simulate(
674
- trial,
675
- trial_sim_opts_merged?.mode || this.simulation_mode,
676
- trial_sim_opts_merged,
677
- load_callback
678
- );
679
- }
680
- } else {
681
- // trial doesn't have a simulate method, so just run as usual
682
- trial_complete = trial.type.trial(this.DOM_target, trial, load_callback);
683
- }
684
- }
382
+ runOnLoadExtensionCallbacks: (extensionsConfiguration) =>
383
+ this.extensionManager.onLoad(extensionsConfiguration),
685
384
 
686
- // see if trial_complete is a Promise by looking for .then() function
687
- const is_promise = trial_complete && typeof trial_complete.then == "function";
385
+ runOnFinishExtensionCallbacks: (extensionsConfiguration) =>
386
+ this.extensionManager.onFinish(extensionsConfiguration),
688
387
 
689
- // in simulation mode we let the simulate function call the load_callback always,
690
- // so we don't need to call it here. however, if we are in simulation mode but not simulating
691
- // this particular trial we need to call it.
692
- if (
693
- !is_promise &&
694
- (!this.simulation_mode || (this.simulation_mode && trial_sim_opts_merged?.simulate === false))
695
- ) {
696
- load_callback();
697
- }
698
-
699
- // done with callbacks
700
- this.internal.call_immediate = false;
701
- }
702
-
703
- private evaluateTimelineVariables(trial) {
704
- for (const key of Object.keys(trial)) {
705
- if (
706
- typeof trial[key] === "object" &&
707
- trial[key] !== null &&
708
- typeof trial[key].timelineVariablePlaceholder !== "undefined"
709
- ) {
710
- trial[key] = trial[key].timelineVariableFunction();
711
- }
712
- // timeline variables that are nested in objects
713
- if (
714
- typeof trial[key] === "object" &&
715
- trial[key] !== null &&
716
- key !== "timeline" &&
717
- key !== "timeline_variables"
718
- ) {
719
- this.evaluateTimelineVariables(trial[key]);
720
- }
721
- }
722
- }
388
+ getSimulationMode: () => this.simulationMode,
723
389
 
724
- private evaluateFunctionParameters(trial) {
725
- // set a flag so that jsPsych.timelineVariable() is immediately executed in this context
726
- this.internal.call_immediate = true;
727
-
728
- // iterate over each parameter
729
- for (const key of Object.keys(trial)) {
730
- // check to make sure parameter is not "type", since that was eval'd above.
731
- if (key !== "type") {
732
- // 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.
733
- // the first line checks if the parameter is defined in the universalPluginParameters set
734
- // the second line checks the plugin-specific parameters
735
- if (
736
- typeof universalPluginParameters[key] !== "undefined" &&
737
- universalPluginParameters[key].type !== ParameterType.FUNCTION
738
- ) {
739
- trial[key] = this.replaceFunctionsWithValues(trial[key], null);
740
- }
741
- if (
742
- typeof trial.type.info.parameters[key] !== "undefined" &&
743
- trial.type.info.parameters[key].type !== ParameterType.FUNCTION
744
- ) {
745
- trial[key] = this.replaceFunctionsWithValues(trial[key], trial.type.info.parameters[key]);
746
- }
747
- }
748
- }
749
- // reset so jsPsych.timelineVariable() is no longer immediately executed
750
- this.internal.call_immediate = false;
751
- }
390
+ getGlobalSimulationOptions: () => this.simulationOptions,
752
391
 
753
- private replaceFunctionsWithValues(obj, info) {
754
- // null typeof is 'object' (?!?!), so need to run this first!
755
- if (obj === null) {
756
- return obj;
757
- }
758
- // arrays
759
- else if (Array.isArray(obj)) {
760
- for (let i = 0; i < obj.length; i++) {
761
- obj[i] = this.replaceFunctionsWithValues(obj[i], info);
762
- }
763
- }
764
- // objects
765
- else if (typeof obj === "object") {
766
- if (info === null || !info.nested) {
767
- for (const key of Object.keys(obj)) {
768
- if (key === "type" || key === "timeline" || key === "timeline_variables") {
769
- // Ignore the object's `type` field because it contains a plugin and we do not want to
770
- // call plugin functions. Also ignore `timeline` and `timeline_variables` because they
771
- // are used in the `trials` parameter of the preload plugin and we don't want to actually
772
- // evaluate those in that context.
773
- continue;
774
- }
775
- obj[key] = this.replaceFunctionsWithValues(obj[key], null);
776
- }
777
- } else {
778
- for (const key of Object.keys(obj)) {
779
- if (
780
- typeof info.nested[key] === "object" &&
781
- info.nested[key].type !== ParameterType.FUNCTION
782
- ) {
783
- obj[key] = this.replaceFunctionsWithValues(obj[key], info.nested[key]);
784
- }
785
- }
786
- }
787
- } else if (typeof obj === "function") {
788
- return obj();
789
- }
790
- return obj;
791
- }
392
+ instantiatePlugin: (pluginClass) => new pluginClass(this),
792
393
 
793
- private setDefaultValues(trial) {
794
- for (const param in trial.type.info.parameters) {
795
- // check if parameter is complex with nested defaults
796
- if (trial.type.info.parameters[param].type === ParameterType.COMPLEX) {
797
- // check if parameter is undefined and has a default value
798
- if (typeof trial[param] === "undefined" && trial.type.info.parameters[param].default) {
799
- trial[param] = trial.type.info.parameters[param].default;
800
- }
801
- // if parameter is an array, iterate over each entry after confirming that there are
802
- // entries to iterate over. this is common when some parameters in a COMPLEX type have
803
- // default values and others do not.
804
- if (trial.type.info.parameters[param].array === true && Array.isArray(trial[param])) {
805
- // iterate over each entry in the array
806
- trial[param].forEach(function (ip, i) {
807
- // check each parameter in the plugin description
808
- for (const p in trial.type.info.parameters[param].nested) {
809
- if (typeof trial[param][i][p] === "undefined" || trial[param][i][p] === null) {
810
- if (typeof trial.type.info.parameters[param].nested[p].default === "undefined") {
811
- console.error(
812
- `You must specify a value for the ${p} parameter (nested in the ${param} parameter) in the ${trial.type.info.name} plugin.`
813
- );
814
- } else {
815
- trial[param][i][p] = trial.type.info.parameters[param].nested[p].default;
816
- }
817
- }
818
- }
819
- });
820
- }
821
- }
822
- // if it's not nested, checking is much easier and do that here:
823
- else if (typeof trial[param] === "undefined" || trial[param] === null) {
824
- if (typeof trial.type.info.parameters[param].default === "undefined") {
825
- console.error(
826
- `You must specify a value for the ${param} parameter in the ${trial.type.info.name} plugin.`
827
- );
828
- } else {
829
- trial[param] = trial.type.info.parameters[param].default;
830
- }
831
- }
832
- }
833
- }
394
+ getDisplayElement: () => this.getDisplayElement(),
834
395
 
835
- private async checkExclusions(exclusions) {
836
- if (exclusions.min_width || exclusions.min_height || exclusions.audio) {
837
- console.warn(
838
- "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/."
839
- );
840
- }
841
- // MINIMUM SIZE
842
- if (exclusions.min_width || exclusions.min_height) {
843
- const mw = exclusions.min_width || 0;
844
- const mh = exclusions.min_height || 0;
845
-
846
- if (window.innerWidth < mw || window.innerHeight < mh) {
847
- this.getDisplayElement().innerHTML =
848
- "<p>Your browser window is too small to complete this experiment. " +
849
- "Please maximize the size of your browser window. If your browser window is already maximized, " +
850
- "you will not be able to complete this experiment.</p>" +
851
- "<p>The minimum width is " +
852
- mw +
853
- "px. Your current width is " +
854
- window.innerWidth +
855
- "px.</p>" +
856
- "<p>The minimum height is " +
857
- mh +
858
- "px. Your current height is " +
859
- window.innerHeight +
860
- "px.</p>";
861
-
862
- // Wait for window size to increase
863
- while (window.innerWidth < mw || window.innerHeight < mh) {
864
- await delay(100);
865
- }
866
-
867
- this.getDisplayElement().innerHTML = "";
868
- }
869
- }
396
+ getDefaultIti: () => this.getInitSettings().default_iti,
870
397
 
871
- // WEB AUDIO API
872
- if (typeof exclusions.audio !== "undefined" && exclusions.audio) {
873
- if (!window.hasOwnProperty("AudioContext") && !window.hasOwnProperty("webkitAudioContext")) {
874
- this.getDisplayElement().innerHTML =
875
- "<p>Your browser does not support the WebAudio API, which means that you will not " +
876
- "be able to complete the experiment.</p><p>Browsers that support the WebAudio API include " +
877
- "Chrome, Firefox, Safari, and Edge.</p>";
878
- throw new Error();
879
- }
880
- }
881
- }
398
+ finishTrialPromise: this.finishTrialPromise,
882
399
 
883
- private drawProgressBar(msg) {
884
- document
885
- .querySelector(".jspsych-display-element")
886
- .insertAdjacentHTML(
887
- "afterbegin",
888
- '<div id="jspsych-progressbar-container">' +
889
- "<span>" +
890
- msg +
891
- "</span>" +
892
- '<div id="jspsych-progressbar-outer">' +
893
- '<div id="jspsych-progressbar-inner"></div>' +
894
- "</div></div>"
895
- );
896
- }
400
+ clearAllTimeouts: () => this.pluginAPI.clearAllTimeouts(),
401
+ };
897
402
 
898
- private updateProgressBar() {
899
- this.setProgressBar(this.getProgress().percent_complete / 100);
900
- }
403
+ private extensionManagerDependencies: ExtensionManagerDependencies = {
404
+ instantiateExtension: (extensionClass) => new extensionClass(this),
405
+ };
901
406
 
902
- private progress_bar_amount = 0;
407
+ private dataDependencies: JsPsychDataDependencies = {
408
+ getProgress: () => ({
409
+ time: this.getTotalTime(),
410
+ trial: this.timeline?.getLatestNode().index ?? 0,
411
+ }),
903
412
 
904
- setProgressBar(proportion_complete) {
905
- proportion_complete = Math.max(Math.min(1, proportion_complete), 0);
906
- document.querySelector<HTMLElement>("#jspsych-progressbar-inner").style.width =
907
- proportion_complete * 100 + "%";
908
- this.progress_bar_amount = proportion_complete;
909
- }
413
+ onInteractionRecordAdded: (record) => {
414
+ this.options.on_interaction_data_update(record);
415
+ },
910
416
 
911
- getProgressBarCompleted() {
912
- return this.progress_bar_amount;
913
- }
417
+ getDisplayElement: () => this.getDisplayElement(),
418
+ };
914
419
  }