hello world!
' +} +``` + +You can also use a [dynamic parameter]() to combine inline CSS and trial-specific variables. This allows you to easily apply the same inline CSS to multiple trials. Here's an example using a dynamic stimulus parameter and [timeline variables](): + +```javascript +var trial = { + type: 'html-keyboard-response', + stimulus: function() { + var stim = ''+jsPsych.timelineVariable('text')+'
'; + return stim; + } +} +var trial_procedure = { + timeline: [trial], + timeline_variables: [ + {text: 'Welcome'}, + {text: 'to'}, + {text: 'the'}, + {text: 'experiment!'} + ] +} +``` + + + +## Adding CSS rules + +You may want to add a lot of different CSS changes to your experiment, re-use the same change(s) across lots of different trials, and/or separate the style/formatting from the HTML string. In these cases, you might find it useful to create CSS rules rather than using inline CSS. + +Creating CSS rules is a lot like using inline CSS, except that you also need to use a [CSS selector](https://www.w3schools.com/css/css_selectors.asp). This is because your CSS rules aren't attached to any specific HTML element (unlike inline CSS), so you need to tell the browser which element(s) the style rules should apply to. The syntax is "css-selector { \element. + +```css +p { + font-size: 30px; +} +``` + +You can make more specific changes using CSS rules. The specificity will depend on the CSS selectors that are used. In addition to using the [tag name](https://www.w3schools.com/cssref/sel_element.asp) (e.g. "p"), other common CSS selectors include the element's [ID](https://www.w3schools.com/html/html_id.asp) or [class](https://www.w3schools.com/html/html_classes.asp). If you are selecting an element using it's ID, then the CSS selector needs to have a \# in front of the ID, e.g. "\#stimulus". If you are selecting elements based on their class, then you need to include a . in front of the class, e.g. ".large-text". + +In the example below, the "#stimulus" CSS selector means that the width change will only affect elements with the "stimulus" ID, and the ".large-text" CSS selector means that the font size change will only affect elements that have the "large-text" class. + +```css +#stimulus + width: 300px; +} +.large-text { + font-size: 200%; +} +``` + +It is possible to create even more specific CSS selectors, for instance by combining tags, IDs, and/or classes. For example, let's say that you are showing feedback text to participants, and that this text is inside of a \
tag. You could add the ID "correct" to the \
element for correct response feedback, and the ID "incorrect" to the \
element for incorrect response feedback. Then you can define separate styles for correct and incorrect feedback text like this: + +```css +p#incorrect { + color: red; +} +p#correct { + color: green; +} +``` + +See [this page about CSS selectors](https://www.w3schools.com/cssref/css_selectors.asp) for a complete reference of CSS selector patterns and their meanings. + + + +### With style tags + +You can add CSS rules to your HTML page by putting them inside of \ + +``` + + + +### With a stylesheet + +CSS rules can also be applied to your experiment with a link to an external CSS file. This is the same method that is usually used to apply the style from jspsych.css to an experiment. These rules will be applied to your _whole experiment_. You may find it useful to use a custom stylesheet when you want to re-use the same CSS rules across _multiple experiments_ (HTML files). + +This example shows how to add a custom CSS file in addition to the styles provided in jspsych.css: + +```html +
+ + + + + +``` + +Below are the some example contents of an external CSS file, like the "my_experiment_style.css" from the example above. This CSS will (1) change the page background color to black, (2) change the default font to 25px and white, and (3) limit the width of the page content so that it can only take up to 80% of its normal width. + +```css +body { + background-color: black; +} +.jspsych-display-element { + font-size: 25px; + color: white; +} +.jspsych-content { + max-width: 80%; +} +``` + +Note that \ + + +``` + +You may want the `css_classes` parameter to vary across trials. If so, you can turn it into a [dynamic parameter]() or use [timeline variables]() (see examples below). + +One thing to note about the `css_classes` parameter is that it only adds the class(es) to the jspsych-content \"+jsPsych.timelineVariable('name', true)+"
"; + var html=""+jsPsych.timelineVariable('name')+"
"; return html; }, choices: jsPsych.NO_KEYS, diff --git a/docs/plugins/overview.md b/docs/plugins/overview.md index 68af3d6d..1779e2c7 100644 --- a/docs/plugins/overview.md +++ b/docs/plugins/overview.md @@ -46,6 +46,7 @@ on_finish | function | `function(){ return; }` | A callback function to execute on_start | function | `function(){ return; }` | A callback function to execute when the trial begins, before any loading has occurred. See [this page](../overview/callbacks.md) for more details. on_load | function | `function(){ return; }` | A callback function to execute when the trial has loaded, which typically happens after the initial display of the plugin has loaded. See [this page](../overview/callbacks.md) for more details. data | object | *undefined* | An object containing additional data to store for the trial. See [this page](../overview/data.md) for more details. +css_classes | string | null | A list of CSS classes to add to the jsPsych display element for the duration of this trial. This allows you to create custom formatting rules (CSS classes) that are only applied to specific trials. See jsPsych/examples/css-classes-parameter.html for examples. ## Data collected by plugins diff --git a/examples/css-classes-parameter.html b/examples/css-classes-parameter.html new file mode 100644 index 00000000..9eed7c26 --- /dev/null +++ b/examples/css-classes-parameter.html @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + diff --git a/examples/jspsych-audio-button-response.html b/examples/jspsych-audio-button-response.html index 0f5a4128..1c51aa5b 100644 --- a/examples/jspsych-audio-button-response.html +++ b/examples/jspsych-audio-button-response.html @@ -29,17 +29,17 @@ timeline.push({ type: 'audio-button-response', stimulus: 'sound/speech_red.mp3', - choices: ['Green', 'Blue', 'Red'], - trial_duration: 2000, - response_ends_trial: false, - prompt: "What word was said? (trial ends after 2s)
" + choices: ['#00ff00', '#0000ff', '#ff0000'], + response_allowed_while_playing: false, + button_html: '', + prompt: "Which color was said?
" }); timeline.push({ type: 'audio-button-response', stimulus: 'sound/speech_joke.mp3', choices: ['Not funny', 'Funny'], - prompt: 'How funny was the joke?
When the audio stops, click a button to end the trial.
Response buttons are disabled while the audio is playing.
', + prompt: 'Is the joke funny?
When the audio stops, click a button to end the trial.
Response buttons are disabled while the audio is playing.
', response_allowed_while_playing: false }) diff --git a/examples/jspsych-canvas-button-response.html b/examples/jspsych-canvas-button-response.html index 29940bae..4034cd78 100644 --- a/examples/jspsych-canvas-button-response.html +++ b/examples/jspsych-canvas-button-response.html @@ -67,12 +67,12 @@ }; // to use the canvas stimulus function with timeline variables, - // use the jsPsych.timelineVariable() function inside your stimulus function with the second 'true' argument + // the jsPsych.timelineVariable() function can be used inside your stimulus function var circle_procedure = { timeline: [{ type: 'canvas-button-response', stimulus: function(c) { - filledCirc(c, jsPsych.timelineVariable('radius', true), jsPsych.timelineVariable('color', true)); + filledCirc(c, jsPsych.timelineVariable('radius'), jsPsych.timelineVariable('color')); }, choices: ['Red', 'Green', 'Blue'], prompt: 'What color is the circle?
', diff --git a/examples/jspsych-canvas-keyboard-response.html b/examples/jspsych-canvas-keyboard-response.html index d12b4828..560546a8 100644 --- a/examples/jspsych-canvas-keyboard-response.html +++ b/examples/jspsych-canvas-keyboard-response.html @@ -41,19 +41,19 @@ } // to use the canvas stimulus function with timeline variables, - // use the jsPsych.timelineVariable() function inside your stimulus function with the second 'true' argument + // the jsPsych.timelineVariable() function can be used inside your stimulus function var trial_procedure = { timeline: [{ type: 'canvas-keyboard-response', stimulus: function(c) { var ctx = c.getContext('2d'); ctx.beginPath(); - ctx.fillStyle = jsPsych.timelineVariable('color', true); + ctx.fillStyle = jsPsych.timelineVariable('color'); ctx.fillRect( - jsPsych.timelineVariable('upper_left_x', true), - jsPsych.timelineVariable('upper_left_y', true), - jsPsych.timelineVariable('width', true), - jsPsych.timelineVariable('height', true) + jsPsych.timelineVariable('upper_left_x'), + jsPsych.timelineVariable('upper_left_y'), + jsPsych.timelineVariable('width'), + jsPsych.timelineVariable('height') ); ctx.stroke(); }, diff --git a/examples/jspsych-video-button-response.html b/examples/jspsych-video-button-response.html index cecb0ff9..2e8d02b3 100644 --- a/examples/jspsych-video-button-response.html +++ b/examples/jspsych-video-button-response.html @@ -18,8 +18,7 @@ var trial_1 = { type: 'video-button-response', stimulus: ['video/sample_video.mp4'], - choices: ['y','n'], - button_html: '', + choices: ['Y','N'], margin_vertical: '10px', margin_horizontal: '8px', prompt: 'Press Y or N', @@ -37,10 +36,11 @@ var trial_2 = { type: 'video-button-response', stimulus: ['video/sample_video.mp4'], - choices: ['Great','Not great'], + choices: ['😄','😁','🥱','😣','🤯'], + button_html: 'How great was the video?
When the video stops, click a button to end the trial.
Response buttons are disabled while the video is playing.
', + prompt: 'Click the emoji that best represents your reaction to the video
When the video stops, click a button to end the trial.
Response buttons are disabled while the video is playing.
', width: 600, autoplay: true, response_ends_trial: true, diff --git a/examples/lexical-decision.html b/examples/lexical-decision.html index c26b9f3f..527d1232 100644 --- a/examples/lexical-decision.html +++ b/examples/lexical-decision.html @@ -72,16 +72,18 @@ timeline: [ { type: 'html-keyboard-response', - stimulus: '+
', + stimulus: '+', choices: jsPsych.NO_KEYS, trial_duration: 500, - post_trial_gap: 0 + post_trial_gap: 0, + css_classes: ['stimulus'] }, { type: 'html-keyboard-response', - stimulus: function(){ return ""+jsPsych.timelineVariable('word', true)+"
"; }, + stimulus: jsPsych.timelineVariable('word'), choices: ['y','n'], post_trial_gap: 0, + css_classes: ['stimulus'], data: { word_validity: jsPsych.timelineVariable('word_validity'), word_frequency: jsPsych.timelineVariable('word_frequency') diff --git a/jspsych.js b/jspsych.js index 952eeea9..3924e466 100755 --- a/jspsych.js +++ b/jspsych.js @@ -9,6 +9,8 @@ window.jsPsych = (function() { var core = {}; + core.version = function() { return "6.3.0" }; + // // private variables // @@ -263,6 +265,11 @@ window.jsPsych = (function() { if(current_trial_finished){ return; } current_trial_finished = true; + // remove any CSS classes that were added to the DOM via css_classes parameter + if(typeof current_trial.css_classes !== 'undefined' && Array.isArray(current_trial.css_classes)){ + DOM_target.classList.remove(...current_trial.css_classes); + } + // write the data from the trial data = typeof data == 'undefined' ? {} : data; jsPsych.data.write(data); @@ -274,6 +281,9 @@ window.jsPsych = (function() { // of the DataCollection, for easy access and editing. var trial_data_values = trial_data.values()[0]; + // about to execute lots of callbacks, so switch context. + jsPsych.internal.call_immediate = true; + // handle callback at plugin level if (typeof current_trial.on_finish === 'function') { current_trial.on_finish(trial_data_values); @@ -287,6 +297,9 @@ window.jsPsych = (function() { // data object that just went through the trial's finish handlers. opts.on_data_update(trial_data_values); + // done with callbacks + jsPsych.internal.call_immediate = false; + // wait for iti if (typeof current_trial.post_trial_gap === null || typeof current_trial.post_trial_gap === 'undefined') { if (opts.default_iti > 0) { @@ -327,8 +340,9 @@ window.jsPsych = (function() { return timeline.activeID(); }; - core.timelineVariable = function(varname, execute){ - if(execute){ + core.timelineVariable = function(varname, immediate){ + if(typeof immediate == 'undefined'){ immediate = false; } + if(jsPsych.internal.call_immediate || immediate === true){ return timeline.timelineVariable(varname); } else { return function() { return timeline.timelineVariable(varname); } @@ -490,7 +504,9 @@ window.jsPsych = (function() { // check for conditonal function on nodes with timelines if (typeof timeline_parameters != 'undefined') { if (typeof timeline_parameters.conditional_function !== 'undefined') { + jsPsych.internal.call_immediate = true; var conditional_result = timeline_parameters.conditional_function(); + jsPsych.internal.call_immediate = false; // if the conditional_function() returns false, then the timeline // doesn't run and is marked as complete. if (conditional_result == false) { @@ -551,11 +567,14 @@ window.jsPsych = (function() { // if we're all done with the repetitions, check if there is a loop function. else if (typeof timeline_parameters.loop_function !== 'undefined') { + jsPsych.internal.call_immediate = true; if (timeline_parameters.loop_function(this.generatedData())) { this.reset(); + jsPsych.internal.call_immediate = false; return parent_node.advance(); } else { progress.done = true; + jsPsych.internal.call_immediate = false; return true; } } @@ -872,6 +891,9 @@ window.jsPsych = (function() { // get default values for parameters setDefaultValues(trial); + // about to execute callbacks + jsPsych.internal.call_immediate = true; + // call experiment wide callback opts.on_trial_start(trial); @@ -886,6 +908,16 @@ window.jsPsych = (function() { // reset the scroll on the DOM target DOM_target.scrollTop = 0; + // add CSS classes to the DOM_target if they exist in trial.css_classes + if(typeof trial.css_classes !== 'undefined'){ + if(!Array.isArray(trial.css_classes) && typeof trial.css_classes == 'string'){ + trial.css_classes = [trial.css_classes]; + } + if(Array.isArray(trial.css_classes)){ + DOM_target.classList.add(...trial.css_classes) + } + } + // execute trial method jsPsych.plugins[trial.type].trial(DOM_target, trial); @@ -893,6 +925,9 @@ window.jsPsych = (function() { if(typeof trial.on_load == 'function'){ trial.on_load(); } + + // done with callbacks + jsPsych.internal.call_immediate = false; } function evaluateTimelineVariables(trial){ @@ -912,6 +947,9 @@ window.jsPsych = (function() { function evaluateFunctionParameters(trial){ + // set a flag so that jsPsych.timelineVariable() is immediately executed in this context + jsPsych.internal.call_immediate = true; + // first, eval the trial type if it is a function // this lets users set the plugin type with a function if(typeof trial.type === 'function'){ @@ -950,6 +988,9 @@ window.jsPsych = (function() { } } } + + // reset so jsPsych.timelineVariable() is no longer immediately executed + jsPsych.internal.call_immediate = false; } function setDefaultValues(trial){ @@ -1067,6 +1108,17 @@ window.jsPsych = (function() { return core; })(); +jsPsych.internal = (function() { + var module = {}; + + // this flag is used to determine whether we are in a scope where + // jsPsych.timelineVariable() should be executed immediately or + // whether it should return a function to access the variable later. + module.call_immediate = false; + + return module; +})(); + jsPsych.plugins = (function() { var module = {}; @@ -1118,6 +1170,12 @@ jsPsych.plugins = (function() { pretty_name: 'Post trial gap', default: null, description: 'Length of gap between the end of this trial and the start of the next trial' + }, + css_classes: { + type: module.parameterType.STRING, + pretty_name: 'Custom CSS classes', + default: null, + description: 'A list of CSS classes to add to the jsPsych display element for the duration of this trial' } } diff --git a/plugins/jspsych-audio-button-response.js b/plugins/jspsych-audio-button-response.js index 8ce7a82d..bd4078fe 100644 --- a/plugins/jspsych-audio-button-response.js +++ b/plugins/jspsych-audio-button-response.js @@ -140,16 +140,12 @@ jsPsych.plugins["audio-button-response"] = (function() { html += trial.prompt; } - display_element.innerHTML = html; - - for (var i = 0; i < trial.choices.length; i++) { - display_element.querySelector('#jspsych-audio-button-response-button-' + i).addEventListener('click', function(e){ - var choice = e.currentTarget.getAttribute('data-choice'); // don't use dataset for jsdom compatibility - after_response(choice); - }); - if (!trial.response_allowed_while_playing) { - display_element.querySelector('#jspsych-audio-button-response-button-' + i).querySelector('button').disabled = true; - } + display_element.innerHTML = html; + + if(trial.response_allowed_while_playing){ + enable_buttons(); + } else { + disable_buttons(); } // store response @@ -172,11 +168,7 @@ jsPsych.plugins["audio-button-response"] = (function() { response.rt = rt; // disable all the buttons after a response - var btns = document.querySelectorAll('.jspsych-audio-button-response-button button'); - for(var i=0; ifoo
', + css_classes: 'foo' + } + + jsPsych.init({timeline:[trial]}); + + expect(jsPsych.getDisplayElement().classList.contains('foo')).toBe(true); + utils.pressKey(32); + }) + + test('Removes the added classes at the end of the trial', function(){ + var trial = { + type: 'html-keyboard-response', + stimulus: 'foo
', + css_classes: ['foo'] + } + + jsPsych.init({timeline:[trial]}); + + expect(jsPsych.getDisplayElement().classList.contains('foo')).toBe(true); + utils.pressKey(32); + expect(jsPsych.getDisplayElement().classList.contains('foo')).toBe(false); + + }) + + test('Class inherits in nested timelines', function(){ + var tm = { + timeline: [{ + type: 'html-keyboard-response', + stimulus: 'foo
', + }], + css_classes: ['foo'] + } + + jsPsych.init({timeline:[tm]}); + + expect(jsPsych.getDisplayElement().classList.contains('foo')).toBe(true); + utils.pressKey(32); + expect(jsPsych.getDisplayElement().classList.contains('foo')).toBe(false); + + }) + + test('Parameter works when defined as a function', function(){ + var trial = { + type: 'html-keyboard-response', + stimulus: 'foo
', + css_classes: function(){ + return ['foo'] + } + } + + jsPsych.init({timeline:[trial]}); + + expect(jsPsych.getDisplayElement().classList.contains('foo')).toBe(true); + utils.pressKey(32); + expect(jsPsych.getDisplayElement().classList.contains('foo')).toBe(false); + + }) + + test('Parameter works when defined as a timeline variable', function(){ + var trial = { + type: 'html-keyboard-response', + stimulus: 'foo
', + css_classes: jsPsych.timelineVariable('css') + } + + var t = { + timeline: [trial], + timeline_variables: [ + {css: ['foo']} + ] + } + + jsPsych.init({timeline:[t]}); + + expect(jsPsych.getDisplayElement().classList.contains('foo')).toBe(true); + utils.pressKey(32); + expect(jsPsych.getDisplayElement().classList.contains('foo')).toBe(false); + + }) +}) \ No newline at end of file diff --git a/tests/jsPsych/timeline-variables.test.js b/tests/jsPsych/timeline-variables.test.js index 3b18a09a..5f5f4852 100644 --- a/tests/jsPsych/timeline-variables.test.js +++ b/tests/jsPsych/timeline-variables.test.js @@ -250,5 +250,176 @@ describe('timeline variables are correctly evaluated', function(){ }); + test('when used inside a function', function(){ + var tvs = [ + {x: 'foo'}, + {x: 'bar'} + ] + + var trial = { + type: 'html-keyboard-response', + stimulus: function(){ + return jsPsych.timelineVariable('x'); + } + } + + var p = { + timeline: [trial], + timeline_variables: tvs + } + + jsPsych.init({ + timeline: [p] + }) + + expect(jsPsych.getDisplayElement().innerHTML).toMatch('foo'); + utils.pressKey(32); + expect(jsPsych.getDisplayElement().innerHTML).toMatch('bar'); + }); + + test('when used in a conditional_function', function(){ + var tvs = [ + {x: 'foo'} + ] + + var trial = { + type: 'html-keyboard-response', + stimulus: 'hello world' + } + + var x = null; + + var p = { + timeline: [trial], + timeline_variables: tvs, + conditional_function: function(){ + x = jsPsych.timelineVariable('x'); + return true; + } + } + + jsPsych.init({ + timeline: [p] + }) + + + utils.pressKey(32); + expect(x).toBe('foo'); + }) + + test('when used in a loop_function', function(){ + var tvs = [ + {x: 'foo'} + ] + + var trial = { + type: 'html-keyboard-response', + stimulus: 'hello world' + } + + var x = null; + + var p = { + timeline: [trial], + timeline_variables: tvs, + loop_function: function(){ + x = jsPsych.timelineVariable('x'); + return false; + } + } + + jsPsych.init({ + timeline: [p] + }) + + + utils.pressKey(32); + expect(x).toBe('foo'); + }) + + test('when used in on_finish', function(){ + var tvs = [ + {x: 'foo'} + ] + + var trial = { + type: 'html-keyboard-response', + stimulus: 'hello world', + on_finish: function(data){ + data.x = jsPsych.timelineVariable('x'); + } + } + + var t = { + timeline: [trial], + timeline_variables: tvs + } + + jsPsych.init({ + timeline: [t] + }) + + + utils.pressKey(32); + expect(jsPsych.data.get().values()[0].x).toBe('foo'); + }) + + test('when used in on_start', function(){ + var tvs = [ + {x: 'foo'} + ] + + var x = null; + + var trial = { + type: 'html-keyboard-response', + stimulus: 'hello world', + on_start: function(){ + x = jsPsych.timelineVariable('x'); + } + } + + var t = { + timeline: [trial], + timeline_variables: tvs + } + + jsPsych.init({ + timeline: [t] + }) + + + utils.pressKey(32); + expect(x).toBe('foo'); + }) + + test('when used in on_load', function(){ + var tvs = [ + {x: 'foo'} + ] + + var x = null; + + var trial = { + type: 'html-keyboard-response', + stimulus: 'hello world', + on_load: function(){ + x = jsPsych.timelineVariable('x'); + } + } + + var t = { + timeline: [trial], + timeline_variables: tvs + } + + jsPsych.init({ + timeline: [t] + }) + + + utils.pressKey(32); + expect(x).toBe('foo'); + }) }) diff --git a/tests/jsPsych/timelines.test.js b/tests/jsPsych/timelines.test.js index 68aac8d5..36082186 100644 --- a/tests/jsPsych/timelines.test.js +++ b/tests/jsPsych/timelines.test.js @@ -125,7 +125,7 @@ describe('loop function', function(){ stimulus: 'foo' }], loop_function: function(){ - if(jsPsych.timelineVariable('word', true) == 'b' && counter < 2){ + if(jsPsych.timelineVariable('word') == 'b' && counter < 2){ counter++; return true; } else { @@ -283,7 +283,7 @@ describe('conditional function', function(){ var innertimeline = { timeline: [trial], conditional_function: function(){ - if(jsPsych.timelineVariable('word', true) == 'b'){ + if(jsPsych.timelineVariable('word') == 'b'){ return false; } else { return true;