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.
- package/README.md +1 -1
- package/css/jspsych.css +19 -11
- package/dist/index.browser.js +3082 -3399
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.min.js +6 -2
- package/dist/index.browser.min.js.map +1 -1
- package/dist/index.cjs +2464 -3327
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +990 -12
- package/dist/index.js +2463 -3325
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
- package/src/ExtensionManager.spec.ts +123 -0
- package/src/ExtensionManager.ts +81 -0
- package/src/JsPsych.ts +195 -690
- package/src/ProgressBar.spec.ts +60 -0
- package/src/ProgressBar.ts +60 -0
- package/src/index.scss +29 -8
- package/src/index.ts +4 -9
- package/src/modules/data/DataCollection.ts +1 -1
- package/src/modules/data/DataColumn.ts +12 -1
- package/src/modules/data/index.ts +92 -103
- package/src/modules/extensions.ts +4 -0
- package/src/modules/plugin-api/AudioPlayer.ts +101 -0
- package/src/modules/plugin-api/KeyboardListenerAPI.ts +1 -1
- package/src/modules/plugin-api/MediaAPI.ts +48 -106
- package/src/modules/plugin-api/__mocks__/AudioPlayer.ts +38 -0
- package/src/modules/plugin-api/index.ts +11 -14
- package/src/modules/plugins.ts +26 -27
- package/src/modules/randomization.ts +1 -1
- package/src/timeline/Timeline.spec.ts +921 -0
- package/src/timeline/Timeline.ts +342 -0
- package/src/timeline/TimelineNode.ts +174 -0
- package/src/timeline/Trial.spec.ts +897 -0
- package/src/timeline/Trial.ts +419 -0
- package/src/timeline/index.ts +232 -0
- package/src/timeline/util.spec.ts +124 -0
- package/src/timeline/util.ts +146 -0
- package/dist/JsPsych.d.ts +0 -112
- package/dist/TimelineNode.d.ts +0 -34
- package/dist/migration.d.ts +0 -3
- package/dist/modules/data/DataCollection.d.ts +0 -46
- package/dist/modules/data/DataColumn.d.ts +0 -15
- package/dist/modules/data/index.d.ts +0 -25
- package/dist/modules/data/utils.d.ts +0 -3
- package/dist/modules/extensions.d.ts +0 -22
- package/dist/modules/plugin-api/HardwareAPI.d.ts +0 -15
- package/dist/modules/plugin-api/KeyboardListenerAPI.d.ts +0 -34
- package/dist/modules/plugin-api/MediaAPI.d.ts +0 -32
- package/dist/modules/plugin-api/SimulationAPI.d.ts +0 -44
- package/dist/modules/plugin-api/TimeoutAPI.d.ts +0 -17
- package/dist/modules/plugin-api/index.d.ts +0 -8
- package/dist/modules/plugins.d.ts +0 -136
- package/dist/modules/randomization.d.ts +0 -42
- package/dist/modules/turk.d.ts +0 -40
- package/dist/modules/utils.d.ts +0 -13
- package/src/TimelineNode.ts +0 -544
- 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(
|
|
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
|
-
|
|
28
|
-
this.context
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
this.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 = (
|
|
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
|
|
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] =
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 =
|
|
20
|
-
const mediaAPI =
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
28
|
-
) as KeyboardListenerAPI & TimeoutAPI & MediaAPI &
|
|
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>;
|
package/src/modules/plugins.ts
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
}
|
|
54
|
+
};
|
|
61
55
|
|
|
62
|
-
export
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
150
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
}
|