jspsych 8.2.2 → 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/dist/index.d.ts CHANGED
@@ -50,6 +50,7 @@ type ParameterInfo = ({
50
50
  array?: boolean;
51
51
  pretty_name?: string;
52
52
  default?: any;
53
+ options?: any;
53
54
  };
54
55
  type ParameterInfos = Record<string, ParameterInfo>;
55
56
  type InferredParameter<I extends ParameterInfo> = I["array"] extends true ? Array<ParameterTypeMap[I["type"]]> : ParameterTypeMap[I["type"]];
@@ -587,6 +588,7 @@ interface GetKeyboardResponseOptions {
587
588
  audio_context_start_time?: number;
588
589
  allow_held_key?: boolean;
589
590
  minimum_valid_rt?: number;
591
+ wait_for_key_release?: boolean;
590
592
  }
591
593
  declare class KeyboardListenerAPI {
592
594
  private getRootElement;
@@ -595,6 +597,8 @@ declare class KeyboardListenerAPI {
595
597
  constructor(getRootElement: () => Element | undefined, areResponsesCaseSensitive?: boolean, minimumValidRt?: number);
596
598
  private listeners;
597
599
  private heldKeys;
600
+ private keyDownTimestamps;
601
+ private pendingReleases;
598
602
  private areRootListenersRegistered;
599
603
  /**
600
604
  * If not previously done and `this.getRootElement()` returns an element, adds the root key
@@ -603,9 +607,24 @@ declare class KeyboardListenerAPI {
603
607
  private registerRootListeners;
604
608
  private rootKeydownListener;
605
609
  private toLowerCaseIfInsensitive;
610
+ /**
611
+ * identifies physical key of a keyboard event, for matching a keydown event with
612
+ * its corresponding keyup event, in the case of a change of shift state while
613
+ * the key is being held.
614
+ */
615
+ private getPhysicalKey;
606
616
  private rootKeyupListener;
617
+ /**
618
+ * When the window loses focus (`blur`), the browser will not deliver the `keyup` events for keys
619
+ * that are currently held, which would otherwise leave stale entries in `heldKeys` and
620
+ * `keyDownTimestamps` (making a key look permanently held) and orphan any pending releases. Treat
621
+ * the blur as a release of every held key: resolve each pending release with
622
+ * `rt_key_duration: null`, since the true hold duration cannot be measured once the release is
623
+ * never observed, then clear the tracked key state.
624
+ */
625
+ private rootBlurListener;
607
626
  private isResponseValid;
608
- getKeyboardResponse({ callback_function, valid_responses, rt_method, persist, audio_context, audio_context_start_time, allow_held_key, minimum_valid_rt, }: GetKeyboardResponseOptions): KeyboardListener;
627
+ getKeyboardResponse({ callback_function, valid_responses, rt_method, persist, audio_context, audio_context_start_time, allow_held_key, minimum_valid_rt, wait_for_key_release, }: GetKeyboardResponseOptions): KeyboardListener;
609
628
  cancelKeyboardResponse(listener: KeyboardListener): void;
610
629
  cancelAllKeyboardResponses(): void;
611
630
  compareKeys(key1: string | null, key2: string | null): boolean;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import autoBind from 'auto-bind';
2
2
  import rw from 'random-words';
3
3
  import seedrandom from 'seedrandom/lib/alea.js';
4
4
 
5
- var version = "8.2.2";
5
+ var version = "8.3.0";
6
6
 
7
7
  class ExtensionManager {
8
8
  constructor(dependencies, extensionsConfiguration) {
@@ -555,6 +555,8 @@ class KeyboardListenerAPI {
555
555
  this.minimumValidRt = minimumValidRt;
556
556
  this.listeners = /* @__PURE__ */ new Set();
557
557
  this.heldKeys = /* @__PURE__ */ new Set();
558
+ this.keyDownTimestamps = /* @__PURE__ */ new Map();
559
+ this.pendingReleases = /* @__PURE__ */ new Map();
558
560
  this.areRootListenersRegistered = false;
559
561
  autoBind(this);
560
562
  this.registerRootListeners();
@@ -569,11 +571,16 @@ class KeyboardListenerAPI {
569
571
  if (rootElement) {
570
572
  rootElement.addEventListener("keydown", this.rootKeydownListener);
571
573
  rootElement.addEventListener("keyup", this.rootKeyupListener);
574
+ window.addEventListener("blur", this.rootBlurListener);
572
575
  this.areRootListenersRegistered = true;
573
576
  }
574
577
  }
575
578
  }
576
579
  rootKeydownListener(e) {
580
+ const physicalKey = this.getPhysicalKey(e);
581
+ if (!this.keyDownTimestamps.has(physicalKey)) {
582
+ this.keyDownTimestamps.set(physicalKey, performance.now());
583
+ }
577
584
  for (const listener of [...this.listeners]) {
578
585
  listener(e);
579
586
  }
@@ -582,9 +589,43 @@ class KeyboardListenerAPI {
582
589
  toLowerCaseIfInsensitive(string) {
583
590
  return this.areResponsesCaseSensitive ? string : string.toLowerCase();
584
591
  }
592
+ /**
593
+ * identifies physical key of a keyboard event, for matching a keydown event with
594
+ * its corresponding keyup event, in the case of a change of shift state while
595
+ * the key is being held.
596
+ */
597
+ getPhysicalKey(e) {
598
+ return e.code || this.toLowerCaseIfInsensitive(e.key);
599
+ }
585
600
  rootKeyupListener(e) {
601
+ const physicalKey = this.getPhysicalKey(e);
602
+ for (const [listener, pending] of this.pendingReleases) {
603
+ if (pending.code === physicalKey) {
604
+ const pressTimestamp = this.keyDownTimestamps.get(physicalKey);
605
+ const rt_key_duration = pressTimestamp === void 0 ? null : Math.round(performance.now() - pressTimestamp);
606
+ this.pendingReleases.delete(listener);
607
+ pending.callback_function({ key: pending.key, rt: pending.rt, rt_key_duration });
608
+ }
609
+ }
610
+ this.keyDownTimestamps.delete(physicalKey);
586
611
  this.heldKeys.delete(this.toLowerCaseIfInsensitive(e.key));
587
612
  }
613
+ /**
614
+ * When the window loses focus (`blur`), the browser will not deliver the `keyup` events for keys
615
+ * that are currently held, which would otherwise leave stale entries in `heldKeys` and
616
+ * `keyDownTimestamps` (making a key look permanently held) and orphan any pending releases. Treat
617
+ * the blur as a release of every held key: resolve each pending release with
618
+ * `rt_key_duration: null`, since the true hold duration cannot be measured once the release is
619
+ * never observed, then clear the tracked key state.
620
+ */
621
+ rootBlurListener() {
622
+ for (const [listener, pending] of this.pendingReleases) {
623
+ this.pendingReleases.delete(listener);
624
+ pending.callback_function({ key: pending.key, rt: pending.rt, rt_key_duration: null });
625
+ }
626
+ this.heldKeys.clear();
627
+ this.keyDownTimestamps.clear();
628
+ }
588
629
  isResponseValid(validResponses, allowHeldKey, key) {
589
630
  if (!allowHeldKey && this.heldKeys.has(key)) {
590
631
  return false;
@@ -605,7 +646,8 @@ class KeyboardListenerAPI {
605
646
  audio_context,
606
647
  audio_context_start_time,
607
648
  allow_held_key = false,
608
- minimum_valid_rt = this.minimumValidRt
649
+ minimum_valid_rt = this.minimumValidRt,
650
+ wait_for_key_release = false
609
651
  }) {
610
652
  if (rt_method !== "performance" && rt_method !== "audio") {
611
653
  console.log(
@@ -632,7 +674,16 @@ class KeyboardListenerAPI {
632
674
  if (!persist) {
633
675
  this.cancelKeyboardResponse(listener);
634
676
  }
635
- callback_function({ key: e.key, rt });
677
+ if (wait_for_key_release) {
678
+ this.pendingReleases.set(listener, {
679
+ code: this.getPhysicalKey(e),
680
+ key: e.key,
681
+ rt,
682
+ callback_function
683
+ });
684
+ } else {
685
+ callback_function({ key: e.key, rt });
686
+ }
636
687
  }
637
688
  };
638
689
  this.listeners.add(listener);
@@ -640,9 +691,11 @@ class KeyboardListenerAPI {
640
691
  }
641
692
  cancelKeyboardResponse(listener) {
642
693
  this.listeners.delete(listener);
694
+ this.pendingReleases.delete(listener);
643
695
  }
644
696
  cancelAllKeyboardResponses() {
645
697
  this.listeners.clear();
698
+ this.pendingReleases.clear();
646
699
  }
647
700
  compareKeys(key1, key2) {
648
701
  if (typeof key1 !== "string" && key1 !== null || typeof key2 !== "string" && key2 !== null) {
@@ -2064,6 +2117,73 @@ class Trial extends TimelineNode {
2064
2117
  }
2065
2118
  }
2066
2119
  });
2120
+ if (!parameterConfig.array && parameterValue !== null) {
2121
+ switch (parameterConfig.type) {
2122
+ case ParameterType.BOOL:
2123
+ if (typeof parameterValue !== "boolean") {
2124
+ const parameterPathString = parameterPathArrayToString(parameterPath);
2125
+ console.warn(
2126
+ `A non-boolean value (\`${parameterValue}\`) was provided for the boolean parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.`
2127
+ );
2128
+ }
2129
+ break;
2130
+ // @ts-ignore falls through
2131
+ case ParameterType.KEYS:
2132
+ if (Array.isArray(parameterValue)) break;
2133
+ case ParameterType.STRING:
2134
+ case ParameterType.HTML_STRING:
2135
+ case ParameterType.KEY:
2136
+ case ParameterType.AUDIO:
2137
+ case ParameterType.VIDEO:
2138
+ case ParameterType.IMAGE:
2139
+ if (typeof parameterValue !== "string") {
2140
+ const parameterPathString = parameterPathArrayToString(parameterPath);
2141
+ console.warn(
2142
+ `A non-string value (\`${parameterValue}\`) was provided for the parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.`
2143
+ );
2144
+ }
2145
+ break;
2146
+ case ParameterType.FLOAT:
2147
+ case ParameterType.INT:
2148
+ if (typeof parameterValue !== "number") {
2149
+ const parameterPathString = parameterPathArrayToString(parameterPath);
2150
+ console.warn(
2151
+ `A non-numeric value (\`${parameterValue}\`) was provided for the numeric parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.`
2152
+ );
2153
+ }
2154
+ break;
2155
+ case ParameterType.FUNCTION:
2156
+ if (typeof parameterValue !== "function") {
2157
+ const parameterPathString = parameterPathArrayToString(parameterPath);
2158
+ console.warn(
2159
+ `A non-function value (\`${parameterValue}\`) was provided for the function parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.`
2160
+ );
2161
+ }
2162
+ break;
2163
+ case ParameterType.SELECT:
2164
+ if (!parameterConfig.options) {
2165
+ const parameterPathString = parameterPathArrayToString(parameterPath);
2166
+ console.warn(
2167
+ `The "options" array is required for the "select" parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin.`
2168
+ );
2169
+ }
2170
+ }
2171
+ if (parameterConfig.type === ParameterType.INT && parameterValue % 1 !== 0) {
2172
+ const parameterPathString = parameterPathArrayToString(parameterPath);
2173
+ console.warn(
2174
+ `A float value (\`${parameterValue}\`) was provided for the integer parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin. The value will be truncated to an integer.`
2175
+ );
2176
+ parameterValue = Math.trunc(parameterValue);
2177
+ }
2178
+ }
2179
+ if (parameterConfig.type === ParameterType.SELECT) {
2180
+ if (!parameterConfig.options.includes(parameterValue)) {
2181
+ const parameterPathString = parameterPathArrayToString(parameterPath);
2182
+ console.warn(
2183
+ `The value "${parameterValue}" is not a valid option for the parameter "${parameterPathString}" in the "${this.pluginInfo.name}" plugin. Valid options are: ${parameterConfig.options.join(", ")}.`
2184
+ );
2185
+ }
2186
+ }
2067
2187
  if (parameterConfig.array && !Array.isArray(parameterValue)) {
2068
2188
  const parameterPathString = parameterPathArrayToString(parameterPath);
2069
2189
  throw new Error(