jspsych 8.2.3 → 8.3.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 +9 -1
- package/css/jspsych.css +20 -0
- package/dist/index.browser.js +57 -4
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.min.js +6 -6
- package/dist/index.browser.min.js.map +1 -1
- package/dist/index.cjs +56 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.js +56 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.scss +21 -0
- package/src/modules/plugin-api/KeyboardListenerAPI.ts +90 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jspsych",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.3.0",
|
|
4
4
|
"description": "Behavioral experiments in a browser",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@fontsource/open-sans": "4.5.3",
|
|
51
|
-
"@jspsych/config": "^3.3.
|
|
51
|
+
"@jspsych/config": "^3.3.4",
|
|
52
52
|
"@types/dom-mediacapture-record": "^1.0.11",
|
|
53
53
|
"base64-inline-loader": "^2.0.1",
|
|
54
54
|
"css-loader": "^6.6.0",
|
package/src/index.scss
CHANGED
|
@@ -112,6 +112,27 @@
|
|
|
112
112
|
cursor: not-allowed;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/* Keyboard keys, e.g., press <kbd>space</kbd> in instructions */
|
|
116
|
+
|
|
117
|
+
.jspsych-display-element kbd,
|
|
118
|
+
.jspsych-display-element .jspsych-key {
|
|
119
|
+
display: inline-block;
|
|
120
|
+
min-width: 1em;
|
|
121
|
+
padding: 0.15em 0.5em;
|
|
122
|
+
margin: 0 0.1em 2px;
|
|
123
|
+
font-family: "Open Sans", "Arial", sans-serif;
|
|
124
|
+
font-size: 0.8em;
|
|
125
|
+
line-height: 1.4;
|
|
126
|
+
text-align: center;
|
|
127
|
+
white-space: nowrap;
|
|
128
|
+
vertical-align: middle;
|
|
129
|
+
color: #333;
|
|
130
|
+
background-color: #f7f7f7;
|
|
131
|
+
border: 1px solid #ccc;
|
|
132
|
+
border-radius: 4px;
|
|
133
|
+
box-shadow: 0 2px 0 #ccc;
|
|
134
|
+
}
|
|
135
|
+
|
|
115
136
|
/* custom style for input[type="range] (slider) to improve alignment between positions and labels */
|
|
116
137
|
|
|
117
138
|
.jspsych-slider {
|
|
@@ -13,6 +13,18 @@ export interface GetKeyboardResponseOptions {
|
|
|
13
13
|
audio_context_start_time?: number;
|
|
14
14
|
allow_held_key?: boolean;
|
|
15
15
|
minimum_valid_rt?: number;
|
|
16
|
+
wait_for_key_release?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PendingRelease {
|
|
20
|
+
/**
|
|
21
|
+
* physical key (`KeyboardEvent.code`) whose release is being awaited; falls back to the
|
|
22
|
+
* case-normalized key value for synthetic events that do not set `code`.
|
|
23
|
+
*/
|
|
24
|
+
code: string;
|
|
25
|
+
key: string;
|
|
26
|
+
rt: number;
|
|
27
|
+
callback_function: any;
|
|
16
28
|
}
|
|
17
29
|
|
|
18
30
|
export class KeyboardListenerAPI {
|
|
@@ -27,6 +39,8 @@ export class KeyboardListenerAPI {
|
|
|
27
39
|
|
|
28
40
|
private listeners = new Set<KeyboardListener>();
|
|
29
41
|
private heldKeys = new Set<string>();
|
|
42
|
+
private keyDownTimestamps = new Map<string, number>();
|
|
43
|
+
private pendingReleases = new Map<KeyboardListener, PendingRelease>();
|
|
30
44
|
|
|
31
45
|
private areRootListenersRegistered = false;
|
|
32
46
|
|
|
@@ -40,12 +54,21 @@ export class KeyboardListenerAPI {
|
|
|
40
54
|
if (rootElement) {
|
|
41
55
|
rootElement.addEventListener("keydown", this.rootKeydownListener);
|
|
42
56
|
rootElement.addEventListener("keyup", this.rootKeyupListener);
|
|
57
|
+
// When the window loses focus the browser stops delivering keyup events, so a key held at
|
|
58
|
+
// that moment would otherwise never be seen as released. Treat a blur as releasing all keys.
|
|
59
|
+
window.addEventListener("blur", this.rootBlurListener);
|
|
43
60
|
this.areRootListenersRegistered = true;
|
|
44
61
|
}
|
|
45
62
|
}
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
private rootKeydownListener(e: KeyboardEvent) {
|
|
66
|
+
const physicalKey = this.getPhysicalKey(e);
|
|
67
|
+
// Record the press timestamp only for the initial keydown, not for key-repeat events of a key
|
|
68
|
+
// that is already held down (the timestamp is only removed again by the key's keyup).
|
|
69
|
+
if (!this.keyDownTimestamps.has(physicalKey)) {
|
|
70
|
+
this.keyDownTimestamps.set(physicalKey, performance.now());
|
|
71
|
+
}
|
|
49
72
|
// Iterate over a static copy of the listeners set because listeners might add other listeners
|
|
50
73
|
// that we do not want to be included in the loop
|
|
51
74
|
for (const listener of [...this.listeners]) {
|
|
@@ -58,10 +81,53 @@ export class KeyboardListenerAPI {
|
|
|
58
81
|
return this.areResponsesCaseSensitive ? string : string.toLowerCase();
|
|
59
82
|
}
|
|
60
83
|
|
|
84
|
+
/**
|
|
85
|
+
* identifies physical key of a keyboard event, for matching a keydown event with
|
|
86
|
+
* its corresponding keyup event, in the case of a change of shift state while
|
|
87
|
+
* the key is being held.
|
|
88
|
+
*/
|
|
89
|
+
private getPhysicalKey(e: KeyboardEvent) {
|
|
90
|
+
return e.code || this.toLowerCaseIfInsensitive(e.key);
|
|
91
|
+
}
|
|
92
|
+
|
|
61
93
|
private rootKeyupListener(e: KeyboardEvent) {
|
|
94
|
+
const physicalKey = this.getPhysicalKey(e);
|
|
95
|
+
|
|
96
|
+
// match pending releases by physical key so that a change in shift state while the key is held
|
|
97
|
+
// (which changes `e.key`, but not `e.code`) cannot orphan a pending release.
|
|
98
|
+
for (const [listener, pending] of this.pendingReleases) {
|
|
99
|
+
if (pending.code === physicalKey) {
|
|
100
|
+
const pressTimestamp = this.keyDownTimestamps.get(physicalKey);
|
|
101
|
+
const rt_key_duration =
|
|
102
|
+
pressTimestamp === undefined ? null : Math.round(performance.now() - pressTimestamp);
|
|
103
|
+
this.pendingReleases.delete(listener);
|
|
104
|
+
// report the key as it was at keydown, so that the deferred and immediate paths record
|
|
105
|
+
// the same response for the same key press
|
|
106
|
+
pending.callback_function({ key: pending.key, rt: pending.rt, rt_key_duration });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.keyDownTimestamps.delete(physicalKey);
|
|
62
111
|
this.heldKeys.delete(this.toLowerCaseIfInsensitive(e.key));
|
|
63
112
|
}
|
|
64
113
|
|
|
114
|
+
/**
|
|
115
|
+
* When the window loses focus (`blur`), the browser will not deliver the `keyup` events for keys
|
|
116
|
+
* that are currently held, which would otherwise leave stale entries in `heldKeys` and
|
|
117
|
+
* `keyDownTimestamps` (making a key look permanently held) and orphan any pending releases. Treat
|
|
118
|
+
* the blur as a release of every held key: resolve each pending release with
|
|
119
|
+
* `rt_key_duration: null`, since the true hold duration cannot be measured once the release is
|
|
120
|
+
* never observed, then clear the tracked key state.
|
|
121
|
+
*/
|
|
122
|
+
private rootBlurListener() {
|
|
123
|
+
for (const [listener, pending] of this.pendingReleases) {
|
|
124
|
+
this.pendingReleases.delete(listener);
|
|
125
|
+
pending.callback_function({ key: pending.key, rt: pending.rt, rt_key_duration: null });
|
|
126
|
+
}
|
|
127
|
+
this.heldKeys.clear();
|
|
128
|
+
this.keyDownTimestamps.clear();
|
|
129
|
+
}
|
|
130
|
+
|
|
65
131
|
private isResponseValid(validResponses: ValidResponses, allowHeldKey: boolean, key: string) {
|
|
66
132
|
// check if key was already held down
|
|
67
133
|
if (!allowHeldKey && this.heldKeys.has(key)) {
|
|
@@ -87,6 +153,7 @@ export class KeyboardListenerAPI {
|
|
|
87
153
|
audio_context_start_time,
|
|
88
154
|
allow_held_key = false,
|
|
89
155
|
minimum_valid_rt = this.minimumValidRt,
|
|
156
|
+
wait_for_key_release = false,
|
|
90
157
|
}: GetKeyboardResponseOptions) {
|
|
91
158
|
if (rt_method !== "performance" && rt_method !== "audio") {
|
|
92
159
|
console.log(
|
|
@@ -125,7 +192,26 @@ export class KeyboardListenerAPI {
|
|
|
125
192
|
this.cancelKeyboardResponse(listener);
|
|
126
193
|
}
|
|
127
194
|
|
|
128
|
-
|
|
195
|
+
if (wait_for_key_release) {
|
|
196
|
+
// defer the callback until this key is released, keyed by the listener handle so that
|
|
197
|
+
// the pending release is cancelled together with the listener (see cancelKeyboardResponse
|
|
198
|
+
// and cancelAllKeyboardResponses).
|
|
199
|
+
//
|
|
200
|
+
// Because the pending release is keyed by listener, each listener tracks only one pending
|
|
201
|
+
// release at a time. With persist: true, if a second valid key is pressed before the first
|
|
202
|
+
// is released, this overwrites the earlier pending release: the superseded press is
|
|
203
|
+
// dropped and only the most recent press's release fires the callback. This is a
|
|
204
|
+
// deliberate trade-off of the single-slot design — note that it differs from the
|
|
205
|
+
// non-deferred persist path, which fires the callback for every valid press.
|
|
206
|
+
this.pendingReleases.set(listener, {
|
|
207
|
+
code: this.getPhysicalKey(e),
|
|
208
|
+
key: e.key,
|
|
209
|
+
rt,
|
|
210
|
+
callback_function,
|
|
211
|
+
});
|
|
212
|
+
} else {
|
|
213
|
+
callback_function({ key: e.key, rt });
|
|
214
|
+
}
|
|
129
215
|
}
|
|
130
216
|
};
|
|
131
217
|
|
|
@@ -136,10 +222,13 @@ export class KeyboardListenerAPI {
|
|
|
136
222
|
cancelKeyboardResponse(listener: KeyboardListener) {
|
|
137
223
|
// remove the listener from the set of listeners if it is contained
|
|
138
224
|
this.listeners.delete(listener);
|
|
225
|
+
// also drop any pending release so a deferred callback never fires after cancellation
|
|
226
|
+
this.pendingReleases.delete(listener);
|
|
139
227
|
}
|
|
140
228
|
|
|
141
229
|
cancelAllKeyboardResponses() {
|
|
142
230
|
this.listeners.clear();
|
|
231
|
+
this.pendingReleases.clear();
|
|
143
232
|
}
|
|
144
233
|
|
|
145
234
|
compareKeys(key1: string | null, key2: string | null) {
|