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/README.md +10 -2
- package/css/jspsych.css +20 -0
- package/dist/index.browser.js +124 -4
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.min.js +4 -4
- package/dist/index.browser.min.js.map +1 -1
- package/dist/index.cjs +123 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +123 -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/src/modules/plugins.ts +1 -0
- package/src/timeline/Trial.spec.ts +105 -1
- package/src/timeline/Trial.ts +78 -0
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.
|
|
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
|
-
|
|
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(
|