Merge pull request #2258 from jspsych/plugin-sketchpad

New plugin: Sketchpad
This commit is contained in:
Josh de Leeuw 2021-11-05 18:27:50 -04:00 committed by GitHub
commit ae30862842
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1285 additions and 0 deletions

View File

@ -0,0 +1,5 @@
---
"@jspsych/plugin-sketchpad": major
---
A plugin for drawing responses on a canvas element. Supports very basic drawing operations and recording both the final image and the sequential steps to generate that image.

View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/jspsych@7.0.0"></script>
<script src="https://unpkg.com/@jspsych/plugin-html-button-response@1.0.0"></script>
<script src="https://unpkg.com/@jspsych/plugin-sketchpad@1.0.0"></script>
<link
rel="stylesheet"
href="https://unpkg.com/jspsych@7.0.0/css/jspsych.css"
/>
<style>
.jspsych-btn {
margin-bottom: 10px;
}
</style>
</head>
<body></body>
<script>
var jsPsych = initJsPsych();
var start = {
type: jsPsychHtmlButtonResponse,
stimulus: '',
choices: ['Run demo']
};
var show_data = {
type: jsPsychHtmlButtonResponse,
stimulus: function() {
var trial_data = jsPsych.data.getLastTrialData().values();
var trial_json = JSON.stringify(trial_data, null, 2);
return `<p style="margin-bottom:0px;"><strong>Trial data:</strong></p>
<pre style="margin-top:0px;text-align:left;">${trial_json}</pre>`;
},
choices: ['Repeat demo']
};
var trial = {
type: jsPsychSketchpad,
prompt: '<p>Draw an apple!</p>',
prompt_location: 'abovecanvas',
canvas_width: 300,
canvas_height: 300,
canvas_border_width: 2
}
var trial_loop = {
timeline: [trial, show_data],
loop_function: function() {
return true;
}
};
if (typeof jsPsych !== "undefined") {
jsPsych.run([start, trial_loop]);
} else {
document.body.innerHTML = '<div style="text-align:center; margin-top:50%; transform:translate(0,-50%);">You must be online to view the plugin demo.</div>';
}
</script>
</html>

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/jspsych@7.0.0"></script>
<script src="https://unpkg.com/@jspsych/plugin-html-button-response@1.0.0"></script>
<script src="https://unpkg.com/@jspsych/plugin-sketchpad@1.0.0"></script>
<link
rel="stylesheet"
href="https://unpkg.com/jspsych@7.0.0/css/jspsych.css"
/>
<style>
.jspsych-btn {
margin-bottom: 10px;
}
</style>
</head>
<body></body>
<script>
var jsPsych = initJsPsych();
var start = {
type: jsPsychHtmlButtonResponse,
stimulus: '',
choices: ['Run demo']
};
var show_data = {
type: jsPsychHtmlButtonResponse,
stimulus: function() {
var trial_data = jsPsych.data.getLastTrialData().values();
var trial_json = JSON.stringify(trial_data, null, 2);
return `<p style="margin-bottom:0px;"><strong>Trial data:</strong></p>
<pre style="margin-top:0px;text-align:left;">${trial_json}</pre>`;
},
choices: ['Repeat demo']
};
var trial = {
type: jsPsychSketchpad,
prompt: '<p>Circle the mouth using red. Circle the eyes using blue.</p>',
prompt_location: 'abovecanvas',
stroke_color_palette: ['red', 'blue'],
stroke_color: 'red',
background_image: 'img/sad_face_4.jpg',
canvas_width: 380,
canvas_height: 252
}
var trial_loop = {
timeline: [trial, show_data],
loop_function: function() {
return true;
}
};
if (typeof jsPsych !== "undefined") {
jsPsych.run([start, trial_loop]);
} else {
document.body.innerHTML = '<div style="text-align:center; margin-top:50%; transform:translate(0,-50%);">You must be online to view the plugin demo.</div>';
}
</script>
</html>

View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/jspsych@7.0.0"></script>
<script src="https://unpkg.com/@jspsych/plugin-html-button-response@1.0.0"></script>
<script src="https://unpkg.com/@jspsych/plugin-sketchpad@1.0.0"></script>
<script src="https://unpkg.com/@jspsych/plugin-survey-text@1.0.0"></script>
<link
rel="stylesheet"
href="https://unpkg.com/jspsych@7.0.0/css/jspsych.css"
/>
<style>
.jspsych-btn {
margin-bottom: 10px;
}
</style>
</head>
<body></body>
<script>
var jsPsych = initJsPsych();
var start = {
type: jsPsychHtmlButtonResponse,
stimulus: '',
choices: ['Run demo']
};
var show_data = {
type: jsPsychHtmlButtonResponse,
stimulus: function() {
var trial_data = jsPsych.data.get().last(2).values();
var trial_json = JSON.stringify(trial_data, null, 2);
return `<p style="margin-bottom:0px;"><strong>Trial data:</strong></p>
<pre style="margin-top:0px;text-align:left;">${trial_json}</pre>`;
},
choices: ['Repeat demo']
};
var draw = {
type: jsPsychSketchpad,
prompt: '<p>Draw the first animal that comes to mind. You have 30 seconds!</p>',
prompt_location: 'belowcanvas',
trial_duration: 30000,
show_countdown_trial_duration: true,
}
var label = {
type: jsPsychSurveyText,
preamble: () => {
var imageData = jsPsych.data.get().last(1).values()[0].png;
return `<img src="${imageData}"></img>`;
},
questions: [
{prompt: 'What animal did you draw?'}
]
}
var trial_loop = {
timeline: [trial, show_data],
loop_function: function() {
return true;
}
};
if (typeof jsPsych !== "undefined") {
jsPsych.run([start, trial_loop]);
} else {
document.body.innerHTML = '<div style="text-align:center; margin-top:50%; transform:translate(0,-50%);">You must be online to view the plugin demo.</div>';
}
</script>
</html>

View File

@ -40,6 +40,7 @@ Plugin | Description
[same&#8209;different&#8209;image](same-different-image.md) | A same-different judgment task. An image is shown, followed by a brief gap, and then another stimulus is shown. The subject indicates whether the stimuli are the same or different.
[serial&#8209;reaction&#8209;time](serial-reaction-time.md) | A set of boxes are displayed on the screen and one of them changes color. The subject presses a key that corresponds to the different color box as fast as possible.
[serial&#8209;reaction&#8209;time&#8209;mouse](serial-reaction-time-mouse.md) | A set of boxes are displayed on the screen and one of them changes color. The subjects clicks the box that changed color as fast as possible.
[sketchpad](sketchpad.md) | Creates an interactive canvas that the participant can draw on using their mouse or touchscreen.
[survey&#8209;html&#8209;form](survey-html-form.md) | Renders a custom HTML form. Allows for mixing multiple kinds of form input.
[survey&#8209;likert](survey-likert.md) | Displays likert-style questions.
[survey&#8209;multi&#8209;choice](survey-multi-choice.md) | Displays multiple choice questions with one answer allowed per question.

140
docs/plugins/sketchpad.md Normal file
View File

@ -0,0 +1,140 @@
# sketchpad plugin
This plugin creates an interactive canvas that the participant can draw on using their mouse or touchscreen.
It can be used for sketching tasks, like asking the participant to draw a particular object.
It can also be used for some image segmentation or annotation tasks by setting the `background_image` parameter to render an image on the canvas.
The plugin stores a [base 64 data URL representation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) of the final image.
This can be converted to an image file using [online tools](https://www.google.com/search?q=base64+image+decoder) or short programs in [R](https://stackoverflow.com/q/58604195/3726673), [python](https://stackoverflow.com/q/2323128/3726673), or another language of your choice.
It also records all of the individual strokes that the participant made during the trial.
!!! warning
This plugin generates **a lot** of data. Each trial can easily add 500kb+ of data to a final JSON output.
You can reduce the amount of data generated by turning off storage of the individual stroke data (`save_strokes: false`) or storage of the final image (`save_final_image: false`) if your use case doesn't require that information.
If you are going to be collecting a lot of data with this plugin you may want to save your data to your server after each trial and not wait until the end of the experiment to perform a single bulk upload.
You can do this by putting data saving code inside the [`on_data_update` event handler](../overview/events.md#on_data_update).
## Parameters
In addition to the [parameters available in all plugins](../overview/plugins.md#parameters-available-in-all-plugins), this plugin accepts the following parameters.
| Parameter | Type | Default Value | Description |
| ------------------ | --------------- | ------------- | ---------------------------------------- |
| canvas_shape | `"rectangle"` or `"circle"` | `"rectangle"` | The shape of the canvas element. |
| canvas_width | int | 500 | Width of the canvas in pixels when `canvas_shape` is `"rectangle"` |
| canvas_height | int | 500 | Height of the canvas in pixels when `canvas_shape` is `"rectangle"` |
| canvas_diameter | int | 500 | Diameter of the canvas in pixels when `canvas_shape` is `"circle"` |
| canvas_border_width | int | 0 | Width of the canvas border |
| canvas_border_color | string | `"#000"` | Color of the canvas border |
| background_image | image path | `null` | Path to an image to render as the background of the canvas |
| background_color | string | `"#fff"` | Color of the canvas background. Note that a `background_image` will render on top of the color.
| stroke_width | int | 2 | Width of the stroke on the canvas |
| stroke_color | string | `"#000"` | Color of the stroke on the canvas |
| stroke_color_palette | array of strings | `[]` | Array of colors to render as a palette of choices for stroke color. Clicking on the corresponding color button will change the stroke color. |
| prompt | string | null | HTML content to render on the screen.
| prompt_location | `"abovecanvas"` or `"belowcanvas"` or `"belowbutton"` | `"abovecanvas"` | The location to render the prompt content. |
| save_final_image | bool | true | Whether to save the final image in the data as a base64 encoded data URL. |
| save_strokes | bool | true | Whether to save the individual stroke data that generated the final image. |
| key_to_draw | key string | null | If this key is held down then it is like the mouse button being held down. The "ink" will flow when the button is held and stop when it is lifted. Pass in the string representation of the key, e.g., `'a'` for the A key or `' '` for the spacebar. |
| show_finished_button | bool | true | Whether to show the button that ends the trial. |
| finished_button_label | string | `"Finished"` | The label for the button that ends the trial. |
| show_clear_button | bool | true | Whether to show the button that clears the entire drawing. |
| clear_button_label | string | `"Clear"` | The label for the button that clears the entire drawing. |
| show_undo_button | bool | true | Whether to show the button that enables an undo action. |
| undo_button_label | string | `"Undo"` | The label for the button that enables an undo action. |
| show_redo_button | bool | true | Whether to show the button that enables a redo action. Note that `show_undo_button` must be `true` for the redo button to show up. |
| redo_button_label | string | `"Redo"` | The label for the button that enables a redo action. |
| choices | array of keys | `"NO_KEYS"` | This array contains the key(s) that the subject is allowed to press in order to end the trial. Keys should be specified as characters (e.g., `'a'`, `'q'`, `' '`, `'Enter'`, `'ArrowDown'`) - see [this page](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) and [this page (event.key column)](https://www.freecodecamp.org/news/javascript-keycode-list-keypress-event-key-codes/) for more examples. Any key presses that are not listed in the array will be ignored. The default value of `"NO_KEYS"` means that no keys will be accepted as valid responses. Specifying `"ALL_KEYS"` will mean that all responses are allowed. |
| trial_duration | int | null | Length of time before the trial ends. If `null` the trial will continue indefinitely (until another way of ending the trial occurs). |
| show_countdown_trial_duration | bool | false | Whether to show a timer that counts down until the end of the trial when `trial_duration` is not `null`. |
| countdown_timer_html | string | `'<span id="sketchpad-timer"></span> remaining'` | The HTML to use for rendering the countdown timer. The element with `id="sketchpad-timer"` will have its content replaced by a countdown timer in the format `MM:SS`.
## Data Generated
In addition to the [default data collected by all plugins](../overview/plugins.md#data-collected-by-all-plugins), this plugin collects the following data for each trial.
| Name | Type | Value |
| -------------- | ----------- | ---------------------------------------- |
| rt | int | The length of time from the start of the trial to the end of the trial.
| response | string | If the trial was ended by clicking the finished button, then `"button"`. If the trial was ended by pressing a key, then the key that was pressed. If the trial timed out, then `null`. |
| png | base64 data URL string | If `save_image` is true, then this will contain the base64 encoded data URL for the image, in png format. |
| strokes | array of stroke objects | If `save_strokes` is true, then this will contain an array of stroke objects. Objects have an `action` property that is either `"start"`, `"move"`, or `"end"`. If `action` is `"start"` or `"move"` it will have an `x` and `y` property that report the coordinates of the action relative to the upper-left corner of the canvas. If `action` is `"start"` then the object will also have a `t` and `color` property, specifying the time of the action relative to the onset of the trial (ms) and the color of the stroke. If `action` is `"end"` then it will only have a `t` property. |
## Examples
???+ example "Basic sketchpad with a prompt"
=== "Code"
```javascript
var trial = {
type: jsPsychSketchpad,
prompt: '<p>Draw an apple!</p>',
prompt_location: 'abovecanvas',
canvas_width: 300,
canvas_height: 300,
canvas_border_width: 2
}
```
=== "Demo"
<div style="text-align:center;">
<iframe src="../../demos/jspsych-sketchpad-demo1.html" width="90%;" height="500px;" frameBorder="0"></iframe>
</div>
<a target="_blank" rel="noopener noreferrer" href="../../demos/jspsych-sketchpad-demo1.html">Open demo in new tab</a>
???+ example "Image segmentation with different colors"
=== "Code"
```javascript
var trial = {
type: jsPsychSketchpad,
prompt: '<p style="width:380px">Circle the mouth using red. Circle the eyes using blue.</p>',
prompt_location: 'abovecanvas',
stroke_color_palette: ['red', 'blue'],
stroke_color: 'red',
background_image: 'img/sad_face_4.jpg',
canvas_width: 380,
canvas_height: 252
}
```
=== "Demo"
<div style="text-align:center;">
<iframe src="../../demos/jspsych-sketchpad-demo2.html" width="90%;" height="500px;" frameBorder="0"></iframe>
</div>
<a target="_blank" rel="noopener noreferrer" href="../../demos/jspsych-sketchpad-demo2.html">Open demo in new tab</a>
???+ example "Draw an image in a time limit, then display the image and ask for a label."
=== "Code"
```javascript
var draw = {
type: jsPsychSketchpad,
prompt: '<p>Draw the first animal that comes to mind. You have 30 seconds!</p>',
prompt_location: 'belowcanvas',
trial_duration: 30000,
show_countdown_trial_duration: true,
}
var label = {
type: jsPsychSurveyText,
preamble: () => {
var imageData = jsPsych.data.get().last(1).values()[0].png;
return `<img src="${imageData}"></img>`;
},
questions: [
{prompt: 'What animal did you draw?'}
]
}
```
=== "Demo"
<div style="text-align:center;">
<iframe src="../../demos/jspsych-sketchpad-demo3.html" width="90%;" height="500px;" frameBorder="0"></iframe>
</div>
<a target="_blank" rel="noopener noreferrer" href="../../demos/jspsych-sketchpad-demo3.html">Open demo in new tab</a>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<script src="../packages/jspsych/dist/index.browser.js"></script>
<script src="../packages/plugin-sketchpad/dist/index.browser.js"></script>
<link rel="stylesheet" href="../packages/jspsych/css/jspsych.css">
</head>
<body></body>
<script>
var jsPsych = initJsPsych({
on_finish: () => {
jsPsych.data.displayData();
}
});
var trial = {
type: jsPsychSketchpad,
prompt: '<p>Draw an apple!</p>',
prompt_location: 'abovecanvas',
canvas_width: 300,
canvas_height: 300,
canvas_border_width: 2
}
var trial2 = {
type: jsPsychSketchpad,
prompt: '<p>Circle the mouth using red. Circle the eyes using blue.</p>',
prompt_location: 'abovecanvas',
stroke_color_palette: ['red', 'blue'],
stroke_color: 'red',
background_image: 'img/sad_face_4.jpg',
canvas_width: 380,
canvas_height: 252
}
jsPsych.run([trial, trial2]);
</script>
</html>

View File

@ -102,6 +102,7 @@ nav:
- 'same-different-image': 'plugins/same-different-image.md'
- 'serial-reaction-time': 'plugins/serial-reaction-time.md'
- 'serial-reaction-time-mouse': 'plugins/serial-reaction-time-mouse.md'
- 'sketchpad' : 'plugins/sketchpad.md'
- 'survey-html-form' : 'plugins/survey-html-form.md'
- 'survey-likert': 'plugins/survey-likert.md'
- 'survey-multi-choice': 'plugins/survey-multi-choice.md'

23
package-lock.json generated
View File

@ -2566,6 +2566,10 @@
"resolved": "packages/plugin-serial-reaction-time-mouse",
"link": true
},
"node_modules/@jspsych/plugin-sketchpad": {
"resolved": "packages/plugin-sketchpad",
"link": true
},
"node_modules/@jspsych/plugin-survey-html-form": {
"resolved": "packages/plugin-survey-html-form",
"link": true
@ -14965,6 +14969,18 @@
"jspsych": ">=7.0.0"
}
},
"packages/plugin-sketchpad": {
"name": "@jspsych/plugin-sketchpad",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@jspsych/config": "^1.0.0",
"@jspsych/test-utils": "^1.0.0"
},
"peerDependencies": {
"jspsych": ">=7.0.0"
}
},
"packages/plugin-survey-html-form": {
"name": "@jspsych/plugin-survey-html-form",
"version": "1.0.0",
@ -17163,6 +17179,13 @@
"@jspsych/test-utils": "^1.0.0"
}
},
"@jspsych/plugin-sketchpad": {
"version": "file:packages/plugin-sketchpad",
"requires": {
"@jspsych/config": "^1.0.0",
"@jspsych/test-utils": "^1.0.0"
}
},
"@jspsych/plugin-survey-html-form": {
"version": "file:packages/plugin-survey-html-form",
"requires": {

View File

@ -0,0 +1 @@
module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname);

View File

@ -0,0 +1,43 @@
{
"name": "@jspsych/plugin-sketchpad",
"version": "0.1.0",
"description": "jsPsych plugin for sketching a response",
"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.git",
"directory": "packages/plugin-sketchpad"
},
"author": "Josh de Leeuw",
"license": "MIT",
"bugs": {
"url": "https://github.com/jspsych/jsPsych/issues"
},
"homepage": "https://www.jspsych.org/latest/plugins/sketchpad",
"peerDependencies": {
"jspsych": ">=7.0.0"
},
"devDependencies": {
"@jspsych/config": "^1.0.0",
"@jspsych/test-utils": "^1.0.0"
}
}

View File

@ -0,0 +1,3 @@
import { makeRollupConfig } from "@jspsych/config/rollup";
export default makeRollupConfig("jsPsychSketchpad");

View File

@ -0,0 +1,151 @@
import { clickTarget, pressKey, startTimeline } from "@jspsych/test-utils";
import sketchpad from ".";
jest.useFakeTimers();
describe("sketchpad", () => {
test("basic load with defaults", async () => {
const { displayElement, getHTML, expectFinished } = await startTimeline([
{
type: sketchpad,
},
]);
const canvas = displayElement.querySelector("canvas");
expect(canvas).not.toBeNull();
expect(displayElement.querySelector("#sketchpad-clear")).not.toBeNull();
expect(displayElement.querySelector("#sketchpad-undo")).not.toBeNull();
expect(displayElement.querySelector("#sketchpad-redo")).not.toBeNull();
clickTarget(displayElement.querySelector("#sketchpad-end"));
await expectFinished();
});
test("displays canvas with different dimensions", async () => {
const { displayElement, getHTML, expectFinished } = await startTimeline([
{
type: sketchpad,
canvas_width: 800,
canvas_height: 300,
},
]);
const canvas = displayElement.querySelector("canvas");
expect(canvas.getAttribute("width")).toBe("800");
expect(canvas.getAttribute("height")).toBe("300");
clickTarget(displayElement.querySelector("#sketchpad-end"));
await expectFinished();
});
test("renders a circular canvas", async () => {
const { displayElement, getHTML, expectFinished } = await startTimeline([
{
type: sketchpad,
canvas_diameter: 300,
canvas_shape: "circle",
},
]);
const canvas: HTMLElement = displayElement.querySelector("canvas");
expect(canvas.className).toContain("sketchpad-circle");
expect(canvas.getAttribute("width")).toBe("300");
expect(canvas.getAttribute("height")).toBe("300");
clickTarget(displayElement.querySelector("#sketchpad-end"));
await expectFinished();
});
test("prompt shows abovecanvas", async () => {
const { displayElement, getHTML, expectFinished } = await startTimeline([
{
type: sketchpad,
prompt: '<p id="prompt">Foo</p>',
prompt_location: "abovecanvas",
},
]);
const display_content = Array.from(displayElement.children, (x) => {
return (x as HTMLElement).id;
});
expect(display_content.indexOf("prompt")).toBeLessThan(
display_content.indexOf("sketchpad-canvas")
);
clickTarget(displayElement.querySelector("#sketchpad-end"));
await expectFinished();
});
test("prompt shows belowcanvas", async () => {
const { displayElement, getHTML, expectFinished } = await startTimeline([
{
type: sketchpad,
prompt: '<p id="prompt">Foo</p>',
prompt_location: "belowcanvas",
},
]);
const display_content = Array.from(displayElement.children, (x) => {
return (x as HTMLElement).id;
});
expect(display_content.indexOf("prompt")).toBeGreaterThan(
display_content.indexOf("sketchpad-canvas")
);
expect(display_content.indexOf("prompt")).toBeGreaterThan(
display_content.indexOf("sketchpad-controls")
);
expect(display_content.indexOf("prompt")).toBeLessThan(display_content.indexOf("finish-btn"));
clickTarget(displayElement.querySelector("#sketchpad-end"));
await expectFinished();
});
test("prompt shows belowbutton", async () => {
const { displayElement, getHTML, expectFinished } = await startTimeline([
{
type: sketchpad,
prompt: '<p id="prompt">Foo</p>',
prompt_location: "belowbutton",
},
]);
const display_content = Array.from(displayElement.children, (x) => {
return (x as HTMLElement).id;
});
expect(display_content.indexOf("prompt")).toBeGreaterThan(
display_content.indexOf("sketchpad-canvas")
);
expect(display_content.indexOf("prompt")).toBeGreaterThan(
display_content.indexOf("sketchpad-controls")
);
expect(display_content.indexOf("prompt")).toBeGreaterThan(
display_content.indexOf("finish-btn")
);
clickTarget(displayElement.querySelector("#sketchpad-end"));
await expectFinished();
});
test("color palette generates correct buttons", async () => {
const { displayElement, getHTML, expectFinished } = await startTimeline([
{
type: sketchpad,
stroke_color_palette: ["#ff0000", "green", "#0000ff"],
},
]);
const buttons = displayElement.querySelectorAll("button.sketchpad-color-select");
expect(buttons.length).toBe(3);
expect(buttons[0].getAttribute("data-color")).toBe("#ff0000");
expect(buttons[1].getAttribute("data-color")).toBe("green");
expect(buttons[2].getAttribute("data-color")).toBe("#0000ff");
clickTarget(displayElement.querySelector("#sketchpad-end"));
await expectFinished();
});
});

View File

@ -0,0 +1,674 @@
import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
const info = <const>{
name: "sketchpad",
parameters: {
/**
* The shape of the canvas element. Accepts `'rectangle'` or `'circle'`
*/
canvas_shape: {
type: ParameterType.STRING,
default: "rectangle",
},
/**
* Width of the canvas in pixels.
*/
canvas_width: {
type: ParameterType.INT,
default: 500,
},
/**
* Width of the canvas in pixels.
*/
canvas_height: {
type: ParameterType.INT,
default: 500,
},
/**
* Diameter of the canvas (when `canvas_shape` is `'circle'`) in pixels.
*/
canvas_diameter: {
type: ParameterType.INT,
default: 500,
},
/**
* This width of the border around the canvas element
*/
canvas_border_width: {
type: ParameterType.INT,
default: 0,
},
/**
* The color of the border around the canvas element.
*/
canvas_border_color: {
type: ParameterType.STRING,
default: "#000",
},
/**
* Path to an image to render as the background of the canvas.
*/
background_image: {
type: ParameterType.IMAGE,
default: null,
},
/**
* Background color of the canvas.
*/
background_color: {
type: ParameterType.STRING,
default: "#ffffff",
},
/**
* The width of the strokes on the canvas.
*/
stroke_width: {
type: ParameterType.INT,
default: 2,
},
/**
* The color of the stroke on the canvas
*/
stroke_color: {
type: ParameterType.STRING,
default: "#000000",
},
/**
* An array of colors to render as a palette of options for stroke colors.
*/
stroke_color_palette: {
type: ParameterType.STRING,
array: true,
default: [],
},
/**
* HTML content to render above or below the canvas (use `prompt_location` parameter to change location).
*/
prompt: {
type: ParameterType.HTML_STRING,
default: null,
},
/**
* Location of the `prompt` content. Can be 'abovecanvas' or 'belowcanvas' or 'belowbutton'.
*/
prompt_location: {
type: ParameterType.STRING,
default: "abovecanvas",
},
/**
* Whether to save the final image in the data as dataURL
*/
save_final_image: {
type: ParameterType.BOOL,
default: true,
},
/**
* Whether to save the set of strokes that generated the image
*/
save_strokes: {
type: ParameterType.BOOL,
default: true,
},
/**
* If this key is held down then it is like the mouse button being clicked for controlling
* the flow of the "ink".
*/
key_to_draw: {
type: ParameterType.KEY,
default: null,
},
/**
* Whether to show the button that ends the trial
*/
show_finished_button: {
type: ParameterType.BOOL,
default: true,
},
/**
* The label for the button that ends the trial
*/
finished_button_label: {
type: ParameterType.STRING,
default: "Finished",
},
/**
* Whether to show the button that clears the entire drawing.
*/
show_clear_button: {
type: ParameterType.BOOL,
default: true,
},
/**
* The label for the button that clears the entire drawing.
*/
clear_button_label: {
type: ParameterType.STRING,
default: "Clear",
},
/**
* Whether to show the button that enables an undo action.
*/
show_undo_button: {
type: ParameterType.BOOL,
default: true,
},
/**
* The label for the button that performs an undo action.
*/
undo_button_label: {
type: ParameterType.STRING,
default: "Undo",
},
/**
* Whether to show the button that enables an redo action. `show_undo_button` must also
* be `true` for the redo button to show.
*/
show_redo_button: {
type: ParameterType.BOOL,
default: true,
},
/**
* The label for the button that performs an redo action.
*/
redo_button_label: {
type: ParameterType.STRING,
default: "Redo",
},
/**
* Array of keys that will end the trial when pressed.
*/
choices: {
type: ParameterType.KEYS,
default: "NO_KEYS",
},
/**
* Length of time before trial ends. If `null` the trial will not timeout.
*/
trial_duration: {
type: ParameterType.INT,
default: null,
},
/**
* Whether to show a countdown timer for the remaining trial duration
*/
show_countdown_trial_duration: {
type: ParameterType.BOOL,
default: false,
},
/**
* The html for the countdown timer.
*/
countdown_timer_html: {
type: ParameterType.HTML_STRING,
default: `<span id="sketchpad-timer"></span> remaining`,
},
},
};
type Info = typeof info;
/**
* **sketchpad**
*
* jsPsych plugin for displaying a canvas stimulus and getting a slider response
*
* @author Josh de Leeuw
* @see {@link https://www.jspsych.org/latest/plugins/sketchpad/ sketchpad plugin documentation on jspsych.org}
*/
class SketchpadPlugin implements JsPsychPlugin<Info> {
static info = info;
private display: HTMLElement;
private params: TrialType<Info>;
private sketchpad: HTMLCanvasElement;
private is_drawing = false;
private ctx: CanvasRenderingContext2D;
private trial_finished_handler;
private background_image;
private strokes = [];
private stroke = [];
private undo_history = [];
private current_stroke_color;
private start_time;
private mouse_position = { x: 0, y: 0 };
private draw_key_held = false;
private timer_interval;
constructor(private jsPsych: JsPsych) {}
trial(display_element: HTMLElement, trial: TrialType<Info>, on_load: () => void) {
this.display = display_element;
this.params = trial;
this.current_stroke_color = trial.stroke_color;
this.init_display();
this.setup_event_listeners();
this.add_background_color();
this.add_background_image().then(() => {
on_load();
});
this.start_time = performance.now();
this.set_trial_duration_timer();
return new Promise((resolve, reject) => {
this.trial_finished_handler = resolve;
});
}
private init_display() {
this.add_css();
let canvas_html;
if (this.params.canvas_shape == "rectangle") {
canvas_html = `
<canvas id="sketchpad-canvas"
width="${this.params.canvas_width}"
height="${this.params.canvas_height}"
class="sketchpad-rectangle"></canvas>
`;
} else if (this.params.canvas_shape == "circle") {
canvas_html = `
<canvas id="sketchpad-canvas"
width="${this.params.canvas_diameter}"
height="${this.params.canvas_diameter}"
class="sketchpad-circle">
</canvas>
`;
} else {
throw new Error(
'`canvas_shape` parameter in sketchpad plugin must be either "rectangle" or "circle"'
);
}
let sketchpad_controls = `<div id="sketchpad-controls">`;
sketchpad_controls += `<div id="sketchpad-color-palette">`;
for (const color of this.params.stroke_color_palette) {
sketchpad_controls += `<button class="sketchpad-color-select" data-color="${color}" style="background-color:${color};"></button>`;
}
sketchpad_controls += `</div>`;
sketchpad_controls += `<div id="sketchpad-actions">`;
if (this.params.show_clear_button) {
sketchpad_controls += `<button class="jspsych-btn" id="sketchpad-clear" disabled>${this.params.clear_button_label}</button>`;
}
if (this.params.show_undo_button) {
sketchpad_controls += `<button class="jspsych-btn" id="sketchpad-undo" disabled>${this.params.undo_button_label}</button>`;
if (this.params.show_redo_button) {
sketchpad_controls += `<button class="jspsych-btn" id="sketchpad-redo" disabled>${this.params.redo_button_label}</button>`;
}
}
sketchpad_controls += `</div></div>`;
canvas_html += sketchpad_controls;
let finish_button_html = "";
if (this.params.show_finished_button) {
finish_button_html = `<p id="finish-btn"><button class="jspsych-btn" id="sketchpad-end">Finished</button></p>`;
}
let timer_html = "";
if (this.params.show_countdown_trial_duration && this.params.trial_duration) {
timer_html = `<p id="countdown-timer">${this.params.countdown_timer_html}</p>`;
}
let display_html;
if (this.params.prompt !== null) {
if (this.params.prompt_location == "abovecanvas") {
display_html = this.params.prompt + timer_html + canvas_html + finish_button_html;
}
if (this.params.prompt_location == "belowcanvas") {
display_html = timer_html + canvas_html + this.params.prompt + finish_button_html;
}
if (this.params.prompt_location == "belowbutton") {
display_html = timer_html + canvas_html + finish_button_html + this.params.prompt;
}
} else {
display_html = timer_html + canvas_html + finish_button_html;
}
this.display.innerHTML = display_html;
this.sketchpad = this.display.querySelector("#sketchpad-canvas");
this.ctx = this.sketchpad.getContext("2d");
}
private setup_event_listeners() {
document.addEventListener("pointermove", (e) => {
this.mouse_position = { x: e.clientX, y: e.clientY };
});
if (this.params.show_finished_button) {
this.display.querySelector("#sketchpad-end").addEventListener("click", () => {
this.end_trial("button");
});
}
this.sketchpad.addEventListener("pointerdown", this.start_draw);
this.sketchpad.addEventListener("pointermove", this.move_draw);
this.sketchpad.addEventListener("pointerup", this.end_draw);
this.sketchpad.addEventListener("pointerleave", this.end_draw);
this.sketchpad.addEventListener("pointercancel", this.end_draw);
if (this.params.key_to_draw !== null) {
document.addEventListener("keydown", (e) => {
if (e.key == this.params.key_to_draw && !this.is_drawing && !this.draw_key_held) {
this.draw_key_held = true;
if (
document.elementFromPoint(this.mouse_position.x, this.mouse_position.y) ==
this.sketchpad
) {
this.sketchpad.dispatchEvent(
new PointerEvent("pointerdown", {
clientX: this.mouse_position.x,
clientY: this.mouse_position.y,
})
);
}
}
});
document.addEventListener("keyup", (e) => {
if (e.key == this.params.key_to_draw) {
this.draw_key_held = false;
if (
document.elementFromPoint(this.mouse_position.x, this.mouse_position.y) ==
this.sketchpad
) {
this.sketchpad.dispatchEvent(
new PointerEvent("pointerup", {
clientX: this.mouse_position.x,
clientY: this.mouse_position.y,
})
);
}
}
});
}
if (this.params.show_undo_button) {
this.display.querySelector("#sketchpad-undo").addEventListener("click", this.undo);
if (this.params.show_redo_button) {
this.display.querySelector("#sketchpad-redo").addEventListener("click", this.redo);
}
}
if (this.params.show_clear_button) {
this.display.querySelector("#sketchpad-clear").addEventListener("click", this.clear);
}
const color_btns = Array.from(this.display.querySelectorAll(".sketchpad-color-select"));
for (const btn of color_btns) {
btn.addEventListener("click", (e) => {
const target = e.target as HTMLButtonElement;
this.current_stroke_color = target.getAttribute("data-color");
});
}
this.jsPsych.pluginAPI.getKeyboardResponse({
callback_function: this.after_key_response,
valid_responses: this.params.choices,
persist: false,
allow_held_key: false,
});
}
private add_css() {
document.querySelector("head").insertAdjacentHTML(
"beforeend",
`<style id="sketchpad-styles">
#sketchpad-controls {
line-height: 1;
width:${
this.params.canvas_shape == "rectangle"
? this.params.canvas_width + this.params.canvas_border_width * 2
: this.params.canvas_diameter + this.params.canvas_border_width * 2
}px;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
margin: auto;
}
#sketchpad-color-palette {
display: inline-block; text-align:left; flex-grow: 1;
}
.sketchpad-color-select {
cursor: pointer; height: 33px; width: 33px; border-radius: 4px; padding: 0; border: 1px solid #ccc;
}
#sketchpad-actions {
display:inline-block; text-align:right; flex-grow: 1;
}
#sketchpad-actions button {
margin-left: 4px;
}
#sketchpad-canvas {
touch-action: none;
border: ${this.params.canvas_border_width}px solid ${this.params.canvas_border_color};
}
.sketchpad-circle {
border-radius: ${this.params.canvas_diameter / 2}px;
}
#countdown-timer {
width:${
this.params.canvas_shape == "rectangle"
? this.params.canvas_width + this.params.canvas_border_width * 2
: this.params.canvas_diameter + this.params.canvas_border_width * 2
}px;
text-align: right;
font-size: 12px;
margin-bottom: 0.2em;
}
</style>`
);
}
private add_background_color() {
this.ctx.fillStyle = this.params.background_color;
if (this.params.canvas_shape == "rectangle") {
this.ctx.fillRect(0, 0, this.params.canvas_width, this.params.canvas_height);
}
if (this.params.canvas_shape == "circle") {
this.ctx.fillRect(0, 0, this.params.canvas_diameter, this.params.canvas_diameter);
}
}
private add_background_image() {
return new Promise((resolve, reject) => {
if (this.params.background_image !== null) {
this.background_image = new Image();
this.background_image.src = this.params.background_image;
this.background_image.onload = () => {
this.ctx.drawImage(this.background_image, 0, 0);
resolve(true);
};
} else {
resolve(false);
}
});
}
private start_draw(e) {
this.is_drawing = true;
const x = Math.round(e.clientX - this.sketchpad.getBoundingClientRect().left);
const y = Math.round(e.clientY - this.sketchpad.getBoundingClientRect().top);
this.undo_history = [];
this.set_redo_btn_state(false);
this.ctx.beginPath();
this.ctx.moveTo(x, y);
this.ctx.strokeStyle = this.current_stroke_color;
this.ctx.lineJoin = "round";
this.ctx.lineWidth = this.params.stroke_width;
this.stroke = [];
this.stroke.push({
x: x,
y: y,
color: this.current_stroke_color,
action: "start",
t: Math.round(performance.now() - this.start_time),
});
this.sketchpad.releasePointerCapture(e.pointerId);
}
private move_draw(e) {
if (this.is_drawing) {
const x = Math.round(e.clientX - this.sketchpad.getBoundingClientRect().left);
const y = Math.round(e.clientY - this.sketchpad.getBoundingClientRect().top);
this.ctx.lineTo(x, y);
this.ctx.stroke();
this.stroke.push({
x: x,
y: y,
action: "move",
});
}
}
private end_draw(e) {
if (this.is_drawing) {
this.stroke.push({
action: "end",
t: Math.round(performance.now() - this.start_time),
});
this.strokes.push(this.stroke);
this.set_undo_btn_state(true);
this.set_clear_btn_state(true);
}
this.is_drawing = false;
}
private render_drawing() {
this.ctx.clearRect(0, 0, this.sketchpad.width, this.sketchpad.height);
this.add_background_color();
this.ctx.drawImage(this.background_image, 0, 0);
for (const stroke of this.strokes) {
for (const m of stroke) {
if (m.action == "start") {
this.ctx.beginPath();
this.ctx.moveTo(m.x, m.y);
this.ctx.strokeStyle = m.color;
this.ctx.lineJoin = "round";
this.ctx.lineWidth = this.params.stroke_width;
}
if (m.action == "move") {
this.ctx.lineTo(m.x, m.y);
this.ctx.stroke();
}
}
}
}
private undo() {
this.undo_history.push(this.strokes.pop());
this.set_redo_btn_state(true);
if (this.strokes.length == 0) {
this.set_undo_btn_state(false);
}
this.render_drawing();
}
private redo() {
this.strokes.push(this.undo_history.pop());
this.set_undo_btn_state(true);
if (this.undo_history.length == 0) {
this.set_redo_btn_state(false);
}
this.render_drawing();
}
private clear() {
this.strokes = [];
this.undo_history = [];
this.render_drawing();
this.set_redo_btn_state(false);
this.set_undo_btn_state(false);
this.set_clear_btn_state(false);
}
private set_undo_btn_state(enabled: boolean) {
if (this.params.show_undo_button) {
(this.display.querySelector("#sketchpad-undo") as HTMLButtonElement).disabled = !enabled;
}
}
private set_redo_btn_state(enabled: boolean) {
if (this.params.show_redo_button) {
(this.display.querySelector("#sketchpad-redo") as HTMLButtonElement).disabled = !enabled;
}
}
private set_clear_btn_state(enabled: boolean) {
if (this.params.show_redo_button) {
(this.display.querySelector("#sketchpad-clear") as HTMLButtonElement).disabled = !enabled;
}
}
private set_trial_duration_timer() {
if (this.params.trial_duration !== null) {
this.jsPsych.pluginAPI.setTimeout(() => {
this.end_trial();
}, this.params.trial_duration);
if (this.params.show_countdown_trial_duration) {
this.timer_interval = setInterval(() => {
const remaining = this.params.trial_duration - (performance.now() - this.start_time);
let minutes = Math.floor(remaining / 1000 / 60);
let seconds = Math.ceil((remaining - minutes * 1000 * 60) / 1000);
if (seconds == 60) {
seconds = 0;
minutes++;
}
const minutes_str = minutes.toString();
const seconds_str = seconds.toString().padStart(2, "0");
const timer_span = this.display.querySelector("#sketchpad-timer");
if (timer_span) {
timer_span.innerHTML = `${minutes_str}:${seconds_str}`;
}
if (remaining <= 0) {
if (timer_span) {
timer_span.innerHTML = `0:00`;
}
clearInterval(this.timer_interval);
}
}, 250);
}
}
}
private after_key_response(info) {
this.end_trial(info.key);
}
private end_trial(response = null) {
this.jsPsych.pluginAPI.clearAllTimeouts();
this.jsPsych.pluginAPI.cancelAllKeyboardResponses();
clearInterval(this.timer_interval);
const trial_data = <any>{};
trial_data.rt = Math.round(performance.now() - this.start_time);
trial_data.response = response;
if (this.params.save_final_image) {
trial_data.png = this.sketchpad.toDataURL();
}
if (this.params.save_strokes) {
trial_data.strokes = this.strokes;
}
this.display.innerHTML = "";
document.querySelector("#sketchpad-styles").remove();
this.jsPsych.finishTrial(trial_data);
this.trial_finished_handler();
}
}
export default SketchpadPlugin;

View File

@ -0,0 +1,7 @@
{
"extends": "@jspsych/config/tsconfig.core.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["src"]
}