From 46cf7d417b35178a2fc3568d98c71656dfc8d9f4 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Fri, 12 Jan 2024 13:53:20 -0500 Subject: [PATCH] Add html-keyboard-response-raf plugin --- .changeset/large-trains-cheat.md | 5 + .../README.md | 35 +++ .../docs/html-keyboard-response-raf.md | 68 ++++++ .../examples/example1.html | 27 +++ .../jest.config.cjs | 1 + .../package.json | 44 ++++ .../rollup.config.mjs | 3 + .../src/index.spec.ts | 19 ++ .../src/index.ts | 228 ++++++++++++++++++ .../tsconfig.json | 7 + 10 files changed, 437 insertions(+) create mode 100644 .changeset/large-trains-cheat.md create mode 100644 packages/plugin-html-keyboard-response-raf/README.md create mode 100644 packages/plugin-html-keyboard-response-raf/docs/html-keyboard-response-raf.md create mode 100644 packages/plugin-html-keyboard-response-raf/examples/example1.html create mode 100644 packages/plugin-html-keyboard-response-raf/jest.config.cjs create mode 100644 packages/plugin-html-keyboard-response-raf/package.json create mode 100644 packages/plugin-html-keyboard-response-raf/rollup.config.mjs create mode 100644 packages/plugin-html-keyboard-response-raf/src/index.spec.ts create mode 100644 packages/plugin-html-keyboard-response-raf/src/index.ts create mode 100644 packages/plugin-html-keyboard-response-raf/tsconfig.json diff --git a/.changeset/large-trains-cheat.md b/.changeset/large-trains-cheat.md new file mode 100644 index 00000000..023570b5 --- /dev/null +++ b/.changeset/large-trains-cheat.md @@ -0,0 +1,5 @@ +--- +"@jspsych-contrib/plugin-html-keyboard-response-raf": major +--- + +Added html-keyboard-response-raf, a drop in replacement for the html-keyboard-response plugin but using requestAnimationFrame for timing. diff --git a/packages/plugin-html-keyboard-response-raf/README.md b/packages/plugin-html-keyboard-response-raf/README.md new file mode 100644 index 00000000..67907dd9 --- /dev/null +++ b/packages/plugin-html-keyboard-response-raf/README.md @@ -0,0 +1,35 @@ +# html-keyboard-response-raf + +## Overview + +This plugin implements the same functionality as the html-keyboard-response plugin, but uses requestAnimationFrame internally for timing + +## Loading + +### In browser + +```js + +``` + +Using the JavaScript file downloaded from a GitHub release dist archive: + +```js + +``` + +Using NPM: + +``` +npm install @jspsych-contrib/plugin-html-keyboard-response-raf +``` + +```js +import HtmlKeyboardResponseRaf from '@jspsych-contrib/plugin-html-keyboard-response-raf'; +``` + +## Examples + +### Flicker a one-frame stimulus + +```javascript +const trial = { + type: jsPsychHtmlKeyboardResponseRaf, + stimulus: 'Hello world!', + stimulus_duration: 16.6, + trial_duration: 50, + choices: ['a', 'b', 'c'], +} + +const loop = { + timeline: [trial], + loop_function: () => true, +} +``` \ No newline at end of file diff --git a/packages/plugin-html-keyboard-response-raf/examples/example1.html b/packages/plugin-html-keyboard-response-raf/examples/example1.html new file mode 100644 index 00000000..476adad0 --- /dev/null +++ b/packages/plugin-html-keyboard-response-raf/examples/example1.html @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/packages/plugin-html-keyboard-response-raf/jest.config.cjs b/packages/plugin-html-keyboard-response-raf/jest.config.cjs new file mode 100644 index 00000000..6ac19d5c --- /dev/null +++ b/packages/plugin-html-keyboard-response-raf/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/plugin-html-keyboard-response-raf/package.json b/packages/plugin-html-keyboard-response-raf/package.json new file mode 100644 index 00000000..e6f84571 --- /dev/null +++ b/packages/plugin-html-keyboard-response-raf/package.json @@ -0,0 +1,44 @@ +{ + "name": "@jspsych-contrib/plugin-html-keyboard-response-raf", + "version": "0.0.1", + "description": "This plugin uses the same functionality as the html-keyboard-response plugin, but uses requestAnimationFrame internally for timing", + "type": "module", + "main": "dist/index.cjs", + "exports": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "typings": "dist/index.d.ts", + "unpkg": "dist/index.browser.min.js", + "files": [ + "src", + "dist" + ], + "source": "src/index.ts", + "scripts": { + "test": "jest", + "test:watch": "npm test -- --watch", + "tsc": "tsc", + "build": "rollup --config", + "build:watch": "npm run build -- --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jspsych/jspsych-contrib.git", + "directory": "packages/plugin-html-keyboard-response-raf" + }, + "author": "Josh de Leeuw", + "license": "MIT", + "bugs": { + "url": "https://github.com/jspsych/jspsych-contrib/issues" + }, + "homepage": "https://github.com/jspsych/jspsych-contrib/tree/main/packages/plugin-html-keyboard-response-raf", + "peerDependencies": { + "jspsych": ">=7.0.0" + }, + "devDependencies": { + "@jspsych/config": "^2.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.0.0" + } +} diff --git a/packages/plugin-html-keyboard-response-raf/rollup.config.mjs b/packages/plugin-html-keyboard-response-raf/rollup.config.mjs new file mode 100644 index 00000000..2f0fa1f3 --- /dev/null +++ b/packages/plugin-html-keyboard-response-raf/rollup.config.mjs @@ -0,0 +1,3 @@ +import { makeRollupConfig } from "@jspsych/config/rollup"; + +export default makeRollupConfig("jsPsychHtmlKeyboardResponseRaf"); diff --git a/packages/plugin-html-keyboard-response-raf/src/index.spec.ts b/packages/plugin-html-keyboard-response-raf/src/index.spec.ts new file mode 100644 index 00000000..4781ce12 --- /dev/null +++ b/packages/plugin-html-keyboard-response-raf/src/index.spec.ts @@ -0,0 +1,19 @@ +import { startTimeline } from "@jspsych/test-utils"; + +import jsPsychHtmlKeyboardResponseRaf from "."; + +jest.useFakeTimers(); + +describe("my plugin", () => { + it("should load", async () => { + const { expectFinished, getHTML, getData, displayElement, jsPsych } = await startTimeline([ + { + type: jsPsychHtmlKeyboardResponseRaf, + parameter_name: 1, + parameter_name2: "img.png", + }, + ]); + + await expectFinished(); + }); +}); diff --git a/packages/plugin-html-keyboard-response-raf/src/index.ts b/packages/plugin-html-keyboard-response-raf/src/index.ts new file mode 100644 index 00000000..badef6bf --- /dev/null +++ b/packages/plugin-html-keyboard-response-raf/src/index.ts @@ -0,0 +1,228 @@ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; + +const info = { + name: "html-keyboard-response-raf", + parameters: { + /** + * The HTML string to be displayed. + */ + stimulus: { + type: ParameterType.HTML_STRING, + pretty_name: "Stimulus", + default: undefined, + }, + /** + * Array containing the key(s) the subject is allowed to press to respond to the stimulus. + */ + choices: { + type: ParameterType.KEYS, + pretty_name: "Choices", + default: "ALL_KEYS", + }, + /** + * Any content here will be displayed below the stimulus. + */ + prompt: { + type: ParameterType.HTML_STRING, + pretty_name: "Prompt", + default: null, + }, + /** + * How long to show the stimulus. + */ + stimulus_duration: { + type: ParameterType.INT, + pretty_name: "Stimulus duration", + default: null, + }, + /** + * How long to show trial before it ends. + */ + trial_duration: { + type: ParameterType.INT, + pretty_name: "Trial duration", + default: null, + }, + /** + * If true, trial will end when subject makes a response. + */ + response_ends_trial: { + type: ParameterType.BOOL, + pretty_name: "Response ends trial", + default: true, + }, + }, +}; + +type Info = typeof info; + +/** + * **html-keyboard-response-raf** + * + * jsPsych plugin for displaying a stimulus and getting a keyboard response, using requestAnimationFrame for timing. + * + * @author Josh de Leeuw + */ +class HtmlKeyboardResponseRafPlugin implements JsPsychPlugin { + static info = info; + + private keyboardListener; + private hideStimulusTime: number = Infinity; + private endTrialTime: number = Infinity; + private stimulusIsHidden = false; + private currAnimationFrameHandler: number; + + constructor(private jsPsych: JsPsych) {} + + trial(display_element: HTMLElement, trial: TrialType) { + var new_html = '
' + trial.stimulus + "
"; + + // add prompt + if (trial.prompt !== null) { + new_html += trial.prompt; + } + + // store response + var response = { + rt: null, + key: null, + }; + + // draw + this.currAnimationFrameHandler = requestAnimationFrame(() => { + const initialDisplayTime = performance.now(); + + display_element.innerHTML = new_html; + + // start the response listener + if (trial.choices != "NO_KEYS") { + this.keyboardListener = this.jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: "performance", + persist: false, + allow_held_key: false, + }); + } + + // hide stimulus if stimulus_duration is set + if (trial.stimulus_duration !== null) { + this.hideStimulusTime = initialDisplayTime + trial.stimulus_duration; + } + + // end trial if trial_duration is set + if (trial.trial_duration !== null) { + this.endTrialTime = initialDisplayTime + trial.trial_duration; + } + + this.currAnimationFrameHandler = requestAnimationFrame(checkForEnd); + }); + + const checkForEnd = () => { + const currTime = performance.now(); + if (currTime >= this.hideStimulusTime && !this.stimulusIsHidden) { + this.stimulusIsHidden = true; + display_element.querySelector( + "#jspsych-html-keyboard-response-stimulus" + ).style.visibility = "hidden"; + console.log(currTime - this.hideStimulusTime); + } + if (currTime >= this.endTrialTime) { + console.log(currTime - this.endTrialTime); + end_trial(); + } else { + this.currAnimationFrameHandler = requestAnimationFrame(checkForEnd); + } + }; + + // function to end trial when it is time + const end_trial = () => { + cancelAnimationFrame(this.currAnimationFrameHandler); + + // kill keyboard listeners + if (typeof this.keyboardListener !== "undefined") { + this.jsPsych.pluginAPI.cancelKeyboardResponse(this.keyboardListener); + } + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + response: response.key, + }; + + // clear the display + display_element.innerHTML = ""; + + // move on to the next trial + this.jsPsych.finishTrial(trial_data); + }; + + // function to handle responses by the subject + const after_response = (info) => { + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector("#jspsych-html-keyboard-response-stimulus").className += + " responded"; + + // only record the first response + if (response.key == null) { + response = info; + } + + if (trial.response_ends_trial) { + end_trial(); + } + }; + } + + simulate( + trial: TrialType, + simulation_mode, + simulation_options: any, + load_callback: () => void + ) { + if (simulation_mode == "data-only") { + load_callback(); + this.simulate_data_only(trial, simulation_options); + } + if (simulation_mode == "visual") { + this.simulate_visual(trial, simulation_options, load_callback); + } + } + + private create_simulation_data(trial: TrialType, simulation_options) { + const default_data = { + stimulus: trial.stimulus, + rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true), + response: this.jsPsych.pluginAPI.getValidKey(trial.choices), + }; + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, simulation_options, load_callback: () => void) { + const data = this.create_simulation_data(trial, simulation_options); + + const display_element = this.jsPsych.getDisplayElement(); + + this.trial(display_element, trial); + load_callback(); + + if (data.rt !== null) { + this.jsPsych.pluginAPI.pressKey(data.response, data.rt); + } + } +} + +export default HtmlKeyboardResponseRafPlugin; diff --git a/packages/plugin-html-keyboard-response-raf/tsconfig.json b/packages/plugin-html-keyboard-response-raf/tsconfig.json new file mode 100644 index 00000000..3eabd0c2 --- /dev/null +++ b/packages/plugin-html-keyboard-response-raf/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@jspsych/config/tsconfig.contrib.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"] +}