jspsych 7.3.4 → 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 +18 -8
  3. package/dist/index.browser.js +3080 -4286
  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 +2314 -4066
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.ts +984 -6
  10. package/dist/index.js +2313 -4066
  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
@@ -1,5 +1,6 @@
1
1
  import { ParameterType } from "../../modules/plugins";
2
2
  import { unique } from "../utils";
3
+ import { AudioPlayer } from "./AudioPlayer";
3
4
 
4
5
  const preloadParameterTypes = <const>[
5
6
  ParameterType.AUDIO,
@@ -9,7 +10,15 @@ const preloadParameterTypes = <const>[
9
10
  type PreloadType = typeof preloadParameterTypes[number];
10
11
 
11
12
  export class MediaAPI {
12
- constructor(private useWebaudio: boolean, private webaudioContext?: AudioContext) {}
13
+ constructor(public useWebaudio: boolean) {
14
+ if (
15
+ this.useWebaudio &&
16
+ typeof window !== "undefined" &&
17
+ typeof window.AudioContext !== "undefined"
18
+ ) {
19
+ this.context = new AudioContext();
20
+ }
21
+ }
13
22
 
14
23
  // video //
15
24
  private video_buffers = {};
@@ -21,45 +30,27 @@ export class MediaAPI {
21
30
  }
22
31
 
23
32
  // audio //
24
- private context = null;
33
+ private context: AudioContext = null;
25
34
  private audio_buffers = [];
26
35
 
27
- initAudio() {
28
- this.context = this.useWebaudio ? this.webaudioContext : null;
29
- }
30
-
31
- audioContext() {
32
- if (this.context !== null) {
33
- if (this.context.state !== "running") {
34
- this.context.resume();
35
- }
36
+ audioContext(): AudioContext {
37
+ if (this.context && this.context.state !== "running") {
38
+ this.context.resume();
36
39
  }
37
40
  return this.context;
38
41
  }
39
42
 
40
- getAudioBuffer(audioID) {
41
- return new Promise((resolve, reject) => {
42
- // check whether audio file already preloaded
43
- if (
44
- typeof this.audio_buffers[audioID] == "undefined" ||
45
- this.audio_buffers[audioID] == "tmp"
46
- ) {
47
- // if audio is not already loaded, try to load it
48
- this.preloadAudio(
49
- [audioID],
50
- () => {
51
- resolve(this.audio_buffers[audioID]);
52
- },
53
- () => {},
54
- (e) => {
55
- reject(e.error);
56
- }
57
- );
58
- } else {
59
- // audio is already loaded
60
- resolve(this.audio_buffers[audioID]);
61
- }
62
- });
43
+ async getAudioPlayer(audioID: string): Promise<AudioPlayer> {
44
+ if (this.audio_buffers[audioID] instanceof AudioPlayer) {
45
+ return this.audio_buffers[audioID];
46
+ } else {
47
+ this.audio_buffers[audioID] = new AudioPlayer(audioID, {
48
+ useWebAudio: this.useWebaudio,
49
+ audioContext: this.context,
50
+ });
51
+ await this.audio_buffers[audioID].load();
52
+ return this.audio_buffers[audioID];
53
+ }
63
54
  }
64
55
 
65
56
  // preloading stimuli //
@@ -70,8 +61,8 @@ export class MediaAPI {
70
61
  preloadAudio(
71
62
  files,
72
63
  callback_complete = () => {},
73
- callback_load = (filepath) => {},
74
- callback_error = (error_msg) => {}
64
+ callback_load = (filepath: string) => {},
65
+ callback_error = (error) => {}
75
66
  ) {
76
67
  files = unique(files.flat());
77
68
 
@@ -82,80 +73,31 @@ export class MediaAPI {
82
73
  return;
83
74
  }
84
75
 
85
- const load_audio_file_webaudio = (source, count = 1) => {
86
- const request = new XMLHttpRequest();
87
- request.open("GET", source, true);
88
- request.responseType = "arraybuffer";
89
- request.onload = () => {
90
- this.context.decodeAudioData(
91
- request.response,
92
- (buffer) => {
93
- this.audio_buffers[source] = buffer;
94
- n_loaded++;
95
- callback_load(source);
96
- if (n_loaded == files.length) {
97
- callback_complete();
98
- }
99
- },
100
- (e) => {
101
- callback_error({ source: source, error: e });
102
- }
103
- );
104
- };
105
- request.onerror = (e) => {
106
- let err: ProgressEvent | string = e;
107
- if (request.status == 404) {
108
- err = "404";
109
- }
110
- callback_error({ source: source, error: err });
111
- };
112
- request.onloadend = (e) => {
113
- if (request.status == 404) {
114
- callback_error({ source: source, error: "404" });
115
- }
116
- };
117
- request.send();
118
- this.preload_requests.push(request);
119
- };
120
-
121
- const load_audio_file_html5audio = (source, count = 1) => {
122
- const audio = new Audio();
123
- const handleCanPlayThrough = () => {
124
- this.audio_buffers[source] = audio;
125
- n_loaded++;
126
- callback_load(source);
127
- if (n_loaded == files.length) {
128
- callback_complete();
129
- }
130
- audio.removeEventListener("canplaythrough", handleCanPlayThrough);
131
- };
132
- audio.addEventListener("canplaythrough", handleCanPlayThrough);
133
- audio.addEventListener("error", function handleError(e) {
134
- callback_error({ source: audio.src, error: e });
135
- audio.removeEventListener("error", handleError);
136
- });
137
- audio.addEventListener("abort", function handleAbort(e) {
138
- callback_error({ source: audio.src, error: e });
139
- audio.removeEventListener("abort", handleAbort);
140
- });
141
- audio.src = source;
142
- this.preload_requests.push(audio);
143
- };
144
-
145
76
  for (const file of files) {
146
- if (typeof this.audio_buffers[file] !== "undefined") {
77
+ // check if file was already loaded
78
+ if (this.audio_buffers[file] instanceof AudioPlayer) {
147
79
  n_loaded++;
148
80
  callback_load(file);
149
81
  if (n_loaded == files.length) {
150
82
  callback_complete();
151
83
  }
152
84
  } else {
153
- this.audio_buffers[file] = "tmp";
154
- if (this.audioContext() !== null) {
155
- load_audio_file_webaudio(file);
156
- } else {
157
- load_audio_file_html5audio(file);
158
- }
85
+ this.audio_buffers[file] = new AudioPlayer(file, {
86
+ useWebAudio: this.useWebaudio,
87
+ audioContext: this.context,
88
+ });
89
+ this.audio_buffers[file]
90
+ .load()
91
+ .then(() => {
92
+ n_loaded++;
93
+ callback_load(file);
94
+ if (n_loaded == files.length) {
95
+ callback_complete();
96
+ }
97
+ })
98
+ .catch((e) => {
99
+ callback_error(e);
100
+ });
159
101
  }
160
102
  }
161
103
  }
@@ -221,7 +163,7 @@ export class MediaAPI {
221
163
  const request = new XMLHttpRequest();
222
164
  request.open("GET", video, true);
223
165
  request.responseType = "blob";
224
- request.onload = () => {
166
+ request.onload = () => {
225
167
  if (request.status === 200 || request.status === 0) {
226
168
  const videoBlob = request.response;
227
169
  video_buffers[video] = URL.createObjectURL(videoBlob); // IE10+
@@ -232,14 +174,14 @@ export class MediaAPI {
232
174
  }
233
175
  }
234
176
  };
235
- request.onerror = (e) => {
177
+ request.onerror = (e) => {
236
178
  let err: ProgressEvent | string = e;
237
179
  if (request.status == 404) {
238
180
  err = "404";
239
181
  }
240
182
  callback_error({ source: video, error: err });
241
183
  };
242
- request.onloadend = (e) => {
184
+ request.onloadend = (e) => {
243
185
  if (request.status == 404) {
244
186
  callback_error({ source: video, error: "404" });
245
187
  }
@@ -0,0 +1,38 @@
1
+ import { AudioPlayerOptions } from "../AudioPlayer";
2
+
3
+ const actual = jest.requireActual("../AudioPlayer");
4
+
5
+ export const mockStop = jest.fn();
6
+
7
+ export const AudioPlayer = jest
8
+ .fn()
9
+ .mockImplementation((src: string, options: AudioPlayerOptions = { useWebAudio: false }) => {
10
+ let eventHandlers = {};
11
+
12
+ const mockInstance = Object.create(actual.AudioPlayer.prototype);
13
+
14
+ return Object.assign(mockInstance, {
15
+ load: jest.fn(),
16
+ play: jest.fn(() => {
17
+ setTimeout(() => {
18
+ if (eventHandlers["ended"]) {
19
+ for (const handler of eventHandlers["ended"]) {
20
+ handler();
21
+ }
22
+ }
23
+ }, 1000);
24
+ }),
25
+ stop: mockStop,
26
+ addEventListener: jest.fn((event, handler) => {
27
+ if (!eventHandlers[event]) {
28
+ eventHandlers[event] = [];
29
+ }
30
+ eventHandlers[event].push(handler);
31
+ }),
32
+ removeEventListener: jest.fn((event, handler) => {
33
+ if (eventHandlers[event] === handler) {
34
+ eventHandlers[event] = eventHandlers[event].filter((h) => h !== handler);
35
+ }
36
+ }),
37
+ });
38
+ });
@@ -1,7 +1,6 @@
1
1
  import autoBind from "auto-bind";
2
2
 
3
3
  import { JsPsych } from "../../JsPsych";
4
- import { HardwareAPI } from "./HardwareAPI";
5
4
  import { KeyboardListenerAPI } from "./KeyboardListenerAPI";
6
5
  import { MediaAPI } from "./MediaAPI";
7
6
  import { SimulationAPI } from "./SimulationAPI";
@@ -9,23 +8,21 @@ import { TimeoutAPI } from "./TimeoutAPI";
9
8
 
10
9
  export function createJointPluginAPIObject(jsPsych: JsPsych) {
11
10
  const settings = jsPsych.getInitSettings();
12
- const keyboardListenerAPI = autoBind(
13
- new KeyboardListenerAPI(
14
- jsPsych.getDisplayContainerElement,
15
- settings.case_sensitive_responses,
16
- settings.minimum_valid_rt
17
- )
11
+ const keyboardListenerAPI = new KeyboardListenerAPI(
12
+ jsPsych.getDisplayContainerElement,
13
+ settings.case_sensitive_responses,
14
+ settings.minimum_valid_rt
18
15
  );
19
- const timeoutAPI = autoBind(new TimeoutAPI());
20
- const mediaAPI = autoBind(new MediaAPI(settings.use_webaudio, jsPsych.webaudio_context));
21
- const hardwareAPI = autoBind(new HardwareAPI());
22
- const simulationAPI = autoBind(
23
- new SimulationAPI(jsPsych.getDisplayContainerElement, timeoutAPI.setTimeout)
16
+ const timeoutAPI = new TimeoutAPI();
17
+ const mediaAPI = new MediaAPI(settings.use_webaudio);
18
+ const simulationAPI = new SimulationAPI(
19
+ jsPsych.getDisplayContainerElement,
20
+ timeoutAPI.setTimeout.bind(timeoutAPI)
24
21
  );
25
22
  return Object.assign(
26
23
  {},
27
- ...[keyboardListenerAPI, timeoutAPI, mediaAPI, hardwareAPI, simulationAPI]
28
- ) as KeyboardListenerAPI & TimeoutAPI & MediaAPI & HardwareAPI & SimulationAPI;
24
+ ...[keyboardListenerAPI, timeoutAPI, mediaAPI, simulationAPI].map((object) => autoBind(object))
25
+ ) as KeyboardListenerAPI & TimeoutAPI & MediaAPI & SimulationAPI;
29
26
  }
30
27
 
31
28
  export type PluginAPI = ReturnType<typeof createJointPluginAPIObject>;
@@ -1,16 +1,6 @@
1
- /**
2
- Flatten the type output to improve type hints shown in editors.
3
- Borrowed from type-fest
4
- */
5
- type Simplify<T> = { [KeyType in keyof T]: T[KeyType] };
1
+ import { SetRequired } from "type-fest";
6
2
 
7
- /**
8
- Create a type that makes the given keys required. The remaining keys are kept as is.
9
- Borrowed from type-fest
10
- */
11
- type SetRequired<BaseType, Keys extends keyof BaseType> = Simplify<
12
- Omit<BaseType, Keys> & Required<Pick<BaseType, Keys>>
13
- >;
3
+ import { SimulationMode, SimulationOptions, TrialDescription, TrialResult } from "../timeline";
14
4
 
15
5
  /**
16
6
  * Parameter types for plugins
@@ -51,17 +41,19 @@ type ParameterTypeMap = {
51
41
  [ParameterType.TIMELINE]: any;
52
42
  };
53
43
 
54
- export interface ParameterInfo {
55
- type: ParameterType;
44
+ type PreloadParameterType = ParameterType.AUDIO | ParameterType.VIDEO | ParameterType.IMAGE;
45
+
46
+ export type ParameterInfo = (
47
+ | { type: Exclude<ParameterType, ParameterType.COMPLEX | PreloadParameterType> }
48
+ | { type: ParameterType.COMPLEX; nested?: ParameterInfos }
49
+ | { type: PreloadParameterType; preload?: boolean }
50
+ ) & {
56
51
  array?: boolean;
57
52
  pretty_name?: string;
58
53
  default?: any;
59
- preload?: boolean;
60
- }
54
+ };
61
55
 
62
- export interface ParameterInfos {
63
- [key: string]: ParameterInfo;
64
- }
56
+ export type ParameterInfos = Record<string, ParameterInfo>;
65
57
 
66
58
  type InferredParameter<I extends ParameterInfo> = I["array"] extends true
67
59
  ? Array<ParameterTypeMap[I["type"]]>
@@ -123,7 +115,7 @@ export const universalPluginParameters = <const>{
123
115
  post_trial_gap: {
124
116
  type: ParameterType.INT,
125
117
  pretty_name: "Post trial gap",
126
- default: null,
118
+ default: 0,
127
119
  },
128
120
  /**
129
121
  * A list of CSS classes to add to the jsPsych display element for the duration of this trial
@@ -131,14 +123,14 @@ export const universalPluginParameters = <const>{
131
123
  css_classes: {
132
124
  type: ParameterType.STRING,
133
125
  pretty_name: "Custom CSS classes",
134
- default: null,
126
+ default: "",
135
127
  },
136
128
  /**
137
129
  * Options to control simulation mode for the trial.
138
130
  */
139
131
  simulation_options: {
140
132
  type: ParameterType.COMPLEX,
141
- default: null,
133
+ default: {},
142
134
  },
143
135
  };
144
136
 
@@ -146,9 +138,9 @@ export type UniversalPluginParameters = InferredParameters<typeof universalPlugi
146
138
 
147
139
  export interface PluginInfo {
148
140
  name: string;
149
- parameters: {
150
- [key: string]: ParameterInfo;
151
- };
141
+ version?: string;
142
+ parameters: ParameterInfos;
143
+ data?: ParameterInfos;
152
144
  }
153
145
 
154
146
  export interface JsPsychPlugin<I extends PluginInfo> {
@@ -156,10 +148,17 @@ export interface JsPsychPlugin<I extends PluginInfo> {
156
148
  display_element: HTMLElement,
157
149
  trial: TrialType<I>,
158
150
  on_load?: () => void
159
- ): void | Promise<any>;
151
+ ): void | Promise<TrialResult | void>;
152
+
153
+ simulate?(
154
+ trial: TrialType<I>,
155
+ simulation_mode: SimulationMode,
156
+ simulation_options: SimulationOptions,
157
+ on_load?: () => void
158
+ ): void | Promise<TrialResult | void>;
160
159
  }
161
160
 
162
161
  export type TrialType<I extends PluginInfo> = InferredParameters<I["parameters"]> &
163
- UniversalPluginParameters;
162
+ TrialDescription;
164
163
 
165
164
  export type PluginParameters<I extends PluginInfo> = InferredParameters<I["parameters"]>;
@@ -264,7 +264,7 @@ export function randomID(length = 32) {
264
264
  */
265
265
  export function randomInt(lower: number, upper: number) {
266
266
  if (upper < lower) {
267
- throw new Error("Upper boundary must be less than or equal to lower boundary");
267
+ throw new Error("Upper boundary must be greater than or equal to lower boundary");
268
268
  }
269
269
  return lower + Math.floor(Math.random() * (upper - lower + 1));
270
270
  }