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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jspsych",
3
- "version": "8.2.3",
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.0",
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
- callback_function({ key: e.key, rt });
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) {