1
0
mirror of https://github.com/psychopy/psychojs.git synced 2025-05-10 10:40:54 +00:00

Merge pull request #567 from apitiot/2023.2.0

2023.2.0
This commit is contained in:
Alain Pitiot 2023-07-19 16:21:51 +02:00 committed by GitHub
commit 36e6f8131a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1191 additions and 238 deletions

1002
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "psychojs", "name": "psychojs",
"version": "2022.3.1", "version": "2023.2.0",
"private": true, "private": true,
"description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments", "description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments",
"license": "MIT", "license": "MIT",
@ -34,6 +34,7 @@
"howler": "^2.2.1", "howler": "^2.2.1",
"log4javascript": "github:Ritzlgrmft/log4javascript", "log4javascript": "github:Ritzlgrmft/log4javascript",
"pako": "^1.0.10", "pako": "^1.0.10",
"pixi-filters": "^5.0.0",
"pixi.js-legacy": "^6.0.4", "pixi.js-legacy": "^6.0.4",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"tone": "^14.7.77", "tone": "^14.7.77",

View File

@ -302,7 +302,13 @@ export class EventManager
{ {
const timestamp = MonotonicClock.getReferenceTime(); const timestamp = MonotonicClock.getReferenceTime();
let code = event.code; // Note: we are using event.key since we are interested in the input character rather than
// the physical key position on the keyboard, i.e. we need to take into account the keyboard
// layout
// See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code for a comment regarding
// event.code's lack of suitability
let code = EventManager._pygletMap[event.key];
// let code = event.code;
// take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge): // take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge):
if (typeof code === "undefined") if (typeof code === "undefined")

View File

@ -50,6 +50,9 @@ export class GUI
{ {
this._psychoJS = psychoJS; this._psychoJS = psychoJS;
// info fields excluded from the GUI:
this._excludedInfo = {};
// gui listens to RESOURCE events from the server manager: // gui listens to RESOURCE events from the server manager:
psychoJS.serverManager.on(ServerManager.Event.RESOURCE, (signal) => psychoJS.serverManager.on(ServerManager.Event.RESOURCE, (signal) =>
{ {
@ -87,9 +90,6 @@ export class GUI
requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick
}) })
{ {
// get info from URL:
const infoFromUrl = util.getUrlParameters();
this._progressBarMax = 0; this._progressBarMax = 0;
this._allResourcesDownloaded = false; this._allResourcesDownloaded = false;
this._requiredKeys = []; this._requiredKeys = [];
@ -113,6 +113,19 @@ export class GUI
self._dialogComponent.tStart = t; self._dialogComponent.tStart = t;
self._dialogComponent.status = PsychoJS.Status.STARTED; self._dialogComponent.status = PsychoJS.Status.STARTED;
// prepare the info fields excluded from the GUI, including those from the URL:
const excludedInfo = {};
for (let key in self._excludedInfo)
{
excludedInfo[key.trim().toLowerCase()] = self._excludedInfo[key];
}
const infoFromUrl = util.getUrlParameters();
infoFromUrl.forEach((value, key) =>
{
excludedInfo[key.trim().toLowerCase()] = value;
});
// if the experiment is licensed, and running on the license rather than on credit, // if the experiment is licensed, and running on the license rather than on credit,
// we use the license logo: // we use the license logo:
if (self._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER if (self._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER
@ -130,7 +143,13 @@ export class GUI
markup += "<div class='dialog-content'>"; markup += "<div class='dialog-content'>";
// alert title and close button: // alert title and close button:
markup += `<div id='experiment-dialog-title' class='dialog-title'><p>${title}</p><button id='dialogClose' class='dialog-close' data-a11y-dialog-hide aria-label='Cancel Experiment'>&times;</button></div>`; markup += "<div id='experiment-dialog-title' class='dialog-title'>";
markup += `<p>${title}</p>`;
markup += "<button id='dialogClose' class='dialog-close' data-a11y-dialog-hide aria-label='Cancel Experiment'>&times;</button>";
markup += "</div>";
// everything above the buttons is in a scrollable container:
markup += "<div class='scrollable-container'>";
// logo, if need be: // logo, if need be:
if (typeof logoUrl === "string") if (typeof logoUrl === "string")
@ -139,14 +158,16 @@ export class GUI
} }
// add a combobox or text areas for each entry in the dictionary: // add a combobox or text areas for each entry in the dictionary:
let atLeastOneIncludedKey = false;
Object.keys(dictionary).forEach((key, keyIdx) => Object.keys(dictionary).forEach((key, keyIdx) =>
{ {
const value = dictionary[key]; const value = dictionary[key];
const keyId = "form-input-" + keyIdx; const keyId = "form-input-" + keyIdx;
// only create an input if the key is not in the URL: // only create an input if the key is not in the URL:
let inUrl = false;
const cleanedDictKey = key.trim().toLowerCase(); const cleanedDictKey = key.trim().toLowerCase();
const isIncluded = !(cleanedDictKey in excludedInfo);
/*let inUrl = false;
infoFromUrl.forEach((urlValue, urlKey) => infoFromUrl.forEach((urlValue, urlKey) =>
{ {
const cleanedUrlKey = urlKey.trim().toLowerCase(); const cleanedUrlKey = urlKey.trim().toLowerCase();
@ -155,10 +176,13 @@ export class GUI
inUrl = true; inUrl = true;
// break; // break;
} }
}); });*/
if (!inUrl) if (isIncluded)
// if (!inUrl)
{ {
atLeastOneIncludedKey = true;
markup += `<label for='${keyId}'> ${key} </label>`; markup += `<label for='${keyId}'> ${key} </label>`;
// if the field is required: // if the field is required:
@ -185,7 +209,7 @@ export class GUI
markup += "</select>"; markup += "</select>";
} }
// otherwise we use a single string input: // otherwise we use a single string input:
//if (typeof value === 'string') //if (typeof value === 'string')
else else
{ {
@ -199,17 +223,27 @@ export class GUI
markup += "<p class='validateTips'>Fields marked with an asterisk (*) are required.</p>"; markup += "<p class='validateTips'>Fields marked with an asterisk (*) are required.</p>";
} }
markup += "</div>"; // scrollable-container
// separator, if need be:
if (atLeastOneIncludedKey)
{
markup += "<hr>";
}
// progress bar: // progress bar:
markup += `<hr><div id='progressMsg' class='progress-msg'>${self._progressMessage}</div>`; markup += `<div id='progressMsg' class='progress-msg'>${self._progressMessage}</div>`;
markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>"; markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>";
// buttons: // buttons:
markup += "<hr>"; markup += "<hr>";
markup += "<div class='dialog-button-group'>";
markup += "<button id='dialogCancel' class='dialog-button' aria-label='Cancel Experiment'>Cancel</button>"; markup += "<button id='dialogCancel' class='dialog-button' aria-label='Cancel Experiment'>Cancel</button>";
if (self._requireParticipantClick) if (self._requireParticipantClick)
{ {
markup += "<button id='dialogOK' class='dialog-button disabled' aria-label='Start Experiment'>Ok</button>"; markup += "<button id='dialogOK' class='dialog-button disabled' aria-label='Start Experiment'>Ok</button>";
} }
markup += "</div>"; // button-group
markup += "</div></div>"; markup += "</div></div>";
@ -346,14 +380,18 @@ export class GUI
{ {
const error = this._userFriendlyError(errorCode); const error = this._userFriendlyError(errorCode);
markup += `<div id='experiment-dialog-title' class='dialog-title ${error.class}'><p>${error.title}</p></div>`; markup += `<div id='experiment-dialog-title' class='dialog-title ${error.class}'><p>${error.title}</p></div>`;
markup += "<div class='scrollable-container'>";
markup += `<p>${error.text}</p>`; markup += `<p>${error.text}</p>`;
markup += "</div>";
} }
else else
{ {
markup += `<div id='experiment-dialog-title' class='dialog-title dialog-error'><p>Error</p></div>`; markup += `<div id='experiment-dialog-title' class='dialog-title dialog-error'><p>Error</p></div>`;
markup += "<div class='scrollable-container'>";
markup += `<p>Unfortunately we encountered the following error:</p>`; markup += `<p>Unfortunately we encountered the following error:</p>`;
markup += stackCode; markup += stackCode;
markup += "<p>Try to run the experiment again. If the error persists, contact the experiment designer.</p>"; markup += "<p>Try to run the experiment again. If the error persists, contact the experiment designer.</p>";
markup += "</div>";
} }
} }
@ -361,27 +399,36 @@ export class GUI
else if (typeof warning !== "undefined") else if (typeof warning !== "undefined")
{ {
markup += `<div id='experiment-dialog-title' class='dialog-title dialog-warning'><p>Warning</p></div>`; markup += `<div id='experiment-dialog-title' class='dialog-title dialog-warning'><p>Warning</p></div>`;
markup += "<div class='scrollable-container'>";
markup += `<p>${warning}</p>`; markup += `<p>${warning}</p>`;
markup += "</div>";
} }
// we are displaying a message: // we are displaying a message:
else if (typeof message !== "undefined") else if (typeof message !== "undefined")
{ {
markup += `<div id='experiment-dialog-title' class='dialog-title'><p>Message</p></div>`; markup += "<div id='experiment-dialog-title' class='dialog-title'><p>Message</p></div>";
markup += "<div class='scrollable-container'>";
markup += `<p>${message}</p>`; markup += `<p>${message}</p>`;
markup += "</div>";
} }
if (showOK || showCancel) if (showOK || showCancel)
{ {
markup += "<hr>"; markup += "<hr>";
} }
if (showCancel) if (showCancel || showOK)
{ {
markup += "<button id='dialogCancel' class='dialog-button' aria-label='Close dialog'>Cancel</button>"; markup += "<div class='button-group'>";
} if (showCancel)
if (showOK) {
{ markup += "<button id='dialogCancel' class='dialog-button' aria-label='Close dialog'>Cancel</button>";
markup += "<button id='dialogOK' class='dialog-button' aria-label='Close dialog'>Ok</button>"; }
if (showOK)
{
markup += "<button id='dialogOK' class='dialog-button' aria-label='Close dialog'>Ok</button>";
}
markup += "</div>"; // button-group
} }
markup += "</div></div>"; markup += "</div></div>";

View File

@ -354,7 +354,13 @@ export class Keyboard extends PsychObject
*/ */
self._previousKeydownKey = event.key; self._previousKeydownKey = event.key;
let code = event.code; // Note: we are using event.key since we are interested in the input character rather than
// the physical key position on the keyboard, i.e. we need to take into account the keyboard
// layout
// See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code for a comment regarding
// event.code's lack of suitability
let code = EventManager._pygletMap[event.key];
// let code = event.code;
// take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge): // take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge):
if (typeof code === "undefined") if (typeof code === "undefined")
@ -394,7 +400,9 @@ export class Keyboard extends PsychObject
self._previousKeydownKey = undefined; self._previousKeydownKey = undefined;
let code = event.code; // Note: see above for explanation regarding the use of event.key in lieu of event.code
let code = EventManager._pygletMap[event.key];
// let code = event.code;
// take care of legacy Microsoft Edge: // take care of legacy Microsoft Edge:
if (typeof code === "undefined") if (typeof code === "undefined")

View File

@ -530,6 +530,7 @@ export class PsychoJS
const response = { origin: "PsychoJS.quit", context: "when terminating the experiment" }; const response = { origin: "PsychoJS.quit", context: "when terminating the experiment" };
this._experiment.experimentEnded = true; this._experiment.experimentEnded = true;
this._experiment.isCompleted = isCompleted;
this.status = PsychoJS.Status.STOPPED; this.status = PsychoJS.Status.STOPPED;
const isServerEnv = (this.getEnvironment() === ExperimentHandler.Environment.SERVER); const isServerEnv = (this.getEnvironment() === ExperimentHandler.Environment.SERVER);
@ -601,7 +602,7 @@ export class PsychoJS
if (showOK) if (showOK)
{ {
let text = "Thank you for your patience.<br/><br/>"; let text = "Thank you for your patience.";
text += (typeof message !== "undefined") ? message : "Goodbye!"; text += (typeof message !== "undefined") ? message : "Goodbye!";
this._gui.dialog({ this._gui.dialog({
message: text, message: text,

View File

@ -1293,7 +1293,7 @@ export class ServerManager extends PsychObject
} }
// font files: // font files:
else if (["ttf", "otf", "woff", "woff2"].indexOf(pathExtension) > -1) else if (["ttf", "otf", "woff", "woff2","eot"].indexOf(pathExtension) > -1)
{ {
fontResources.push(name); fontResources.push(name);
} }

View File

@ -13,6 +13,7 @@ import { MonotonicClock } from "../util/Clock.js";
import { Color } from "../util/Color.js"; import { Color } from "../util/Color.js";
import { PsychObject } from "../util/PsychObject.js"; import { PsychObject } from "../util/PsychObject.js";
import { Logger } from "./Logger.js"; import { Logger } from "./Logger.js";
import { hasTouchScreen } from "../util/Util.js";
/** /**
* <p>Window displays the various stimuli of the experiment.</p> * <p>Window displays the various stimuli of the experiment.</p>
@ -181,7 +182,7 @@ export class Window extends PsychObject
{ {
// gets updated frame by frame // gets updated frame by frame
const lastDelta = this.psychoJS.scheduler._lastDelta; const lastDelta = this.psychoJS.scheduler._lastDelta;
const fps = lastDelta === 0 ? 60.0 : 1000 / lastDelta; const fps = (lastDelta === 0) ? 60.0 : (1000.0 / lastDelta);
return fps; return fps;
} }
@ -493,6 +494,17 @@ export class Window extends PsychObject
// update the renderer size and the Window's stimuli whenever the browser's size or orientation change: // update the renderer size and the Window's stimuli whenever the browser's size or orientation change:
this._resizeCallback = (e) => this._resizeCallback = (e) =>
{ {
// if the user device is a mobile phone or tablet (we use the presence of a touch screen as a
// proxy), we need to detect whether the change in size is due to the appearance of a virtual keyboard
// in which case we do not want to resize the canvas. This is rather tricky and so we resort to
// the below trick. It would be better to use the VirtualKeyboard API, but it is not widely
// available just yet, as of 2023-06.
const keyboardHeight = 300;
if (hasTouchScreen() && (window.screen.height - window.visualViewport.height) > keyboardHeight)
{
return;
}
Window._resizePixiRenderer(this, e); Window._resizePixiRenderer(this, e);
this._backgroundSprite.width = this._size[0]; this._backgroundSprite.width = this._size[0];
this._backgroundSprite.height = this._size[1]; this._backgroundSprite.height = this._size[1];

View File

@ -276,6 +276,7 @@ export class ExperimentHandler extends PsychObject
} }
let data = this._trialsData; let data = this._trialsData;
// if the experiment data have to be cleared, we first make a copy of them: // if the experiment data have to be cleared, we first make a copy of them:
if (clear) if (clear)
{ {
@ -351,6 +352,19 @@ export class ExperimentHandler extends PsychObject
} }
} }
/**
* Get the results of the experiment as a .csv string, ready to be uploaded or stored.
*
* @return {string} a .csv representation of the experiment results.
*/
getResultAsCsv()
{
// note: we use the XLSX library as it automatically deals with header, takes care of quotes,
// newlines, etc.
const worksheet = XLSX.utils.json_to_sheet(this._trialsData);
return "\ufeff" + XLSX.utils.sheet_to_csv(worksheet);
}
/** /**
* Get the attribute names and values for the current trial of a given loop. * Get the attribute names and values for the current trial of a given loop.
* <p> Only info relating to the trial execution are returned.</p> * <p> Only info relating to the trial execution are returned.</p>

View File

@ -26,13 +26,12 @@ body {
/* Project and resource dialogs */ /* Project and resource dialogs */
.dialog-container label, .dialog-container label,
.dialog-container input, .dialog-container input,
.dialog-container select { .dialog-container select {
box-sizing: border-box; box-sizing: border-box;
display: block; display: block;
padding-bottom: 0.5em; padding-bottom: 0.5em;
} }
.dialog-container input.text, .dialog-container input.text,
@ -40,6 +39,13 @@ body {
margin-bottom: 1em; margin-bottom: 1em;
padding: 0.5em; padding: 0.5em;
width: 100%; width: 100%;
height: 34px;
border: 1px solid #767676;
border-radius: 2px;
background: #ffffff;
color: #333;
font-size: 14px;
} }
.dialog-container fieldset { .dialog-container fieldset {
@ -71,12 +77,19 @@ body {
} }
.dialog-content { .dialog-content {
display: flex;
flex-direction: column;
row-gap: 0;
margin: auto; margin: auto;
z-index: 2; z-index: 2;
position: relative; position: relative;
width: 500px; width: 500px;
max-width: 88vw; max-width: 88vw;
/*max-height: 90vh;*/
max-height: 93%;
padding: 0.5em; padding: 0.5em;
border-radius: 2px; border-radius: 2px;
@ -88,11 +101,24 @@ body {
box-shadow: 1px 1px 3px #555555; box-shadow: 1px 1px 3px #555555;
} }
.dialog-content .scrollable-container {
height: 100%;
padding: 0 0.5em;
overflow-x: hidden;
overflow-y: auto;
}
.dialog-content hr {
width: 100%;
}
.dialog-title { .dialog-title {
padding: 0.5em; padding: 0.5em;
margin-bottom: 1em; margin-bottom: 1em;
background-color: #009900; background-color: #00dd00;
/*background-color: #009900;*/
border-radius: 2px; border-radius: 2px;
} }
@ -111,6 +137,11 @@ body {
} }
.dialog-close { .dialog-close {
display: flex;
justify-content: center;
align-items: center;
line-height: 1.1em;
position: absolute; position: absolute;
top: 0.7em; top: 0.7em;
right: 0.7em; right: 0.7em;
@ -153,7 +184,7 @@ body {
.dialog-button { .dialog-button {
padding: 0.5em 1em 0.5em 1em; padding: 0.5em 1em 0.5em 1em;
margin: 0.5em 0.5em 0.5em 0; /*margin: 0.5em 0.5em 0.5em 0;*/
border: 1px solid #555555; border: 1px solid #555555;
border-radius: 2px; border-radius: 2px;
@ -176,6 +207,14 @@ body {
border: 1px solid #000000; border: 1px solid #000000;
} }
.dialog-button-group {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
column-gap: 0.5em;
}
.disabled { .disabled {
border: 1px solid #AAAAAA; border: 1px solid #AAAAAA;
color: #AAAAAA; color: #AAAAAA;
@ -186,10 +225,15 @@ body {
} }
.logo { .logo {
display: block; display: flex;
flex: 0 1 auto;
height: 100%;
width: auto;
/*display: block;
margin: 0 auto 1em; margin: 0 auto 1em;
max-height: 20vh; max-height: 20vh;
max-width: 100%; max-width: 100%;*/
} }
a, a,

View File

@ -117,9 +117,12 @@ export class Scheduler
* Start this scheduler. * Start this scheduler.
* *
* <p>Note: tasks are run after each animation frame.</p> * <p>Note: tasks are run after each animation frame.</p>
*
* @return {Promise<void>} a promise resolved when the scheduler stops, e.g. when the experiments finishes
*/ */
start() start()
{ {
let shedulerResolve;
const self = this; const self = this;
const update = async (timestamp) => const update = async (timestamp) =>
{ {
@ -127,6 +130,7 @@ export class Scheduler
if (self._stopAtNextUpdate) if (self._stopAtNextUpdate)
{ {
self._status = Scheduler.Status.STOPPED; self._status = Scheduler.Status.STOPPED;
shedulerResolve();
return; return;
} }
@ -137,6 +141,7 @@ export class Scheduler
if (state === Scheduler.Event.QUIT) if (state === Scheduler.Event.QUIT)
{ {
self._status = Scheduler.Status.STOPPED; self._status = Scheduler.Status.STOPPED;
shedulerResolve();
return; return;
} }
@ -155,6 +160,12 @@ export class Scheduler
// start the animation: // start the animation:
requestAnimationFrame(update); requestAnimationFrame(update);
// return a promise resolved when the scheduler is stopped:
return new Promise((resolve, _) =>
{
shedulerResolve = resolve;
});
} }
/** /**

View File

@ -647,6 +647,11 @@ export function toString(object)
return object.toString(); return object.toString();
} }
if (typeof object === "function")
{
return `<function ${object.name}>`;
}
try try
{ {
const symbolReplacer = (key, value) => const symbolReplacer = (key, value) =>
@ -1473,6 +1478,47 @@ export function loadCss(cssId, cssPath)
} }
} }
/**
* Whether the user device has a touchscreen, e.g. it is a mobile phone or tablet.
*
* @return {boolean} true if the user device has a touchscreen.
* @note the code below is directly adapted from MDN
*/
export function hasTouchScreen()
{
let hasTouchScreen = false;
if ("maxTouchPoints" in navigator)
{
hasTouchScreen = navigator.maxTouchPoints > 0;
}
else if ("msMaxTouchPoints" in navigator)
{
hasTouchScreen = navigator.msMaxTouchPoints > 0;
}
else
{
const mQ = matchMedia?.("(pointer:coarse)");
if (mQ?.media === "(pointer:coarse)")
{
hasTouchScreen = !!mQ.matches;
}
else if ("orientation" in window)
{
hasTouchScreen = true;
}
else
{
const UA = navigator.userAgent;
hasTouchScreen =
/\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
/\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA);
}
}
return hasTouchScreen;
}
/** /**
* Enum that stores possible text directions. * Enum that stores possible text directions.
* Note that Arabic is the same as RTL but added here to support PsychoPy's * Note that Arabic is the same as RTL but added here to support PsychoPy's

View File

@ -9,6 +9,7 @@
import { Mouse } from "../core/Mouse.js"; import { Mouse } from "../core/Mouse.js";
import { TextBox } from "./TextBox.js"; import { TextBox } from "./TextBox.js";
import * as util from "../util/Util";
/** /**
* <p>ButtonStim visual stimulus.</p> * <p>ButtonStim visual stimulus.</p>
@ -32,6 +33,7 @@ export class ButtonStim extends TextBox
* @param {Color} [options.borderColor= Color("white")] the border color * @param {Color} [options.borderColor= Color("white")] the border color
* @param {Color} [options.borderWidth= 0] the border width * @param {Color} [options.borderWidth= 0] the border width
* @param {number} [options.opacity= 1.0] - the opacity * @param {number} [options.opacity= 1.0] - the opacity
* @param {number} [options.depth= 0] - the depth (i.e. the z order)
* @param {number} [options.letterHeight= undefined] - the height of the text * @param {number} [options.letterHeight= undefined] - the height of the text
* @param {boolean} [options.bold= true] - whether or not the text is bold * @param {boolean} [options.bold= true] - whether or not the text is bold
* @param {boolean} [options.italic= false] - whether or not the text is italic * @param {boolean} [options.italic= false] - whether or not the text is italic
@ -54,11 +56,14 @@ export class ButtonStim extends TextBox
borderColor, borderColor,
borderWidth = 0, borderWidth = 0,
opacity, opacity,
depth,
letterHeight, letterHeight,
bold = true, bold = true,
italic, italic,
autoDraw, autoDraw,
autoLog, autoLog,
boxFn,
multiline
} = {}, } = {},
) )
{ {
@ -77,12 +82,15 @@ export class ButtonStim extends TextBox
borderColor, borderColor,
borderWidth, borderWidth,
opacity, opacity,
depth,
letterHeight, letterHeight,
multiline,
bold, bold,
italic, italic,
alignment: "center", alignment: "center",
autoDraw, autoDraw,
autoLog, autoLog,
boxFn
}); });
this.psychoJS.logger.debug("create a new Button with name: ", name); this.psychoJS.logger.debug("create a new Button with name: ", name);
@ -112,7 +120,7 @@ export class ButtonStim extends TextBox
if (this._autoLog) if (this._autoLog)
{ {
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${util.toString(this)}`);
} }
} }

View File

@ -47,7 +47,7 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
* @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip
* @param {boolean} [options.autoLog= false] - whether or not to log * @param {boolean} [options.autoLog= false] - whether or not to log
*/ */
constructor({ name, win, image, mask, pos, anchor, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, autoDraw, autoLog } = {}) constructor({ name, win, image, mask, pos, anchor, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, aspectRatio, autoDraw, autoLog } = {})
{ {
super({ name, win, units, ori, opacity, depth, pos, anchor, size, autoDraw, autoLog }); super({ name, win, units, ori, opacity, depth, pos, anchor, size, autoDraw, autoLog });
@ -94,6 +94,12 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
false, false,
this._onChange(false, false), this._onChange(false, false),
); );
this._addAttribute(
"aspectRatio",
aspectRatio,
ImageStim.AspectRatioStrategy.VARIABLE,
this._onChange(true, true),
);
// estimate the bounding box: // estimate the bounding box:
this._estimateBoundingBox(); this._estimateBoundingBox();
@ -309,7 +315,18 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
this._texture = new PIXI.Texture(new PIXI.BaseTexture(this._image, texOpts)); this._texture = new PIXI.Texture(new PIXI.BaseTexture(this._image, texOpts));
} }
this._pixi = PIXI.Sprite.from(this._texture); if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING)
{
const [width_px, _] = util.to_px([this.size[0], 0], this.units, this.win);
this._pixi = PIXI.TilingSprite.from(this._texture, 1, 1);
this._pixi.width = width_px;
this._pixi.height = this._texture.height;
}
else
{
this._pixi = PIXI.Sprite.from(this._texture);
}
// add a mask if need be: // add a mask if need be:
if (typeof this._mask !== "undefined") if (typeof this._mask !== "undefined")
@ -349,8 +366,24 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
// set the scale: // set the scale:
const displaySize = this._getDisplaySize(); const displaySize = this._getDisplaySize();
const size_px = util.to_px(displaySize, this.units, this.win); const size_px = util.to_px(displaySize, this.units, this.win);
const scaleX = size_px[0] / this._texture.width; let scaleX = size_px[0] / this._texture.width;
const scaleY = size_px[1] / this._texture.height; let scaleY = size_px[1] / this._texture.height;
if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_WIDTH)
{
scaleY = scaleX;
}
else if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_HEIGHT)
{
scaleX = scaleY;
}
else if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING)
{
scaleX = 1.0;
scaleY = 1.0;
}
// note: this calls VisualStim.setAnchor, which properly sets the PixiJS anchor
// from the PsychoPy text format
this.anchor = this._anchor; this.anchor = this._anchor;
this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX;
this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; this._pixi.scale.y = this.flipVert ? scaleY : -scaleY;
@ -383,7 +416,47 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
displaySize = util.to_unit(textureSize, "pix", this.win, this.units); displaySize = util.to_unit(textureSize, "pix", this.win, this.units);
} }
} }
else
{
if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_WIDTH)
{
// use the size of the texture, if we have access to it:
if (typeof this._texture !== "undefined" && this._texture.width > 0)
{
displaySize = [displaySize[0], displaySize[0] * this._texture.height / this._texture.width];
}
}
else if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_HEIGHT)
{
// use the size of the texture, if we have access to it:
if (typeof this._texture !== "undefined" && this._texture.width > 0)
{
displaySize = [displaySize[1] * this._texture.width / this._texture.height, displaySize[1]];
}
}
else if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING)
{
// use the size of the texture, if we have access to it:
if (typeof this._texture !== "undefined" && this._texture.width > 0)
{
displaySize = [displaySize[0], this._texture.height];
}
}
}
return displaySize; return displaySize;
} }
} }
/**
* ImageStim Aspect Ratio Strategy.
*
* @enum {Symbol}
* @readonly
*/
ImageStim.AspectRatioStrategy = {
FIT_TO_WIDTH: Symbol.for("FIT_TO_WIDTH"),
HORIZONTAL_TILING: Symbol.for("HORIZONTAL_TILING"),
FIT_TO_HEIGHT: Symbol.for("FIT_TO_HEIGHT"),
VARIABLE: Symbol.for("VARIABLE"),
};

View File

@ -86,7 +86,8 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
clipMask, clipMask,
autoDraw, autoDraw,
autoLog, autoLog,
fitToContent fitToContent,
boxFn
} = {}, } = {},
) )
{ {
@ -202,12 +203,14 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
// and setSize called from super class would not have a proper effect // and setSize called from super class would not have a proper effect
this.setSize(size); this.setSize(size);
this._addAttribute("boxFn", boxFn, null);
// estimate the bounding box: // estimate the bounding box:
this._estimateBoundingBox(); this._estimateBoundingBox();
if (this._autoLog) if (this._autoLog)
{ {
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${util.toString(this)}`);
} }
} }
@ -481,6 +484,26 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
alignmentStyles = ["center", "center"]; alignmentStyles = ["center", "center"];
} }
let box;
if (this._boxFn !== null)
{
box = this._boxFn;
}
else
{
// note: box style properties eventually become PIXI.Graphics settings, so same syntax applies
box = {
fill: new Color(this._fillColor).int,
alpha: this._fillColor === undefined || this._fillColor === null ? 0 : 1,
rounded: 5,
stroke: {
color: new Color(this._borderColor).int,
width: borderWidth_px,
alpha: this._borderColor === undefined || this._borderColor === null ? 0 : 1
}
};
}
return { return {
// input style properties eventually become CSS, so same syntax applies // input style properties eventually become CSS, so same syntax applies
input: { input: {
@ -504,41 +527,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
overflow: "hidden", overflow: "hidden",
pointerEvents: "none" pointerEvents: "none"
}, },
// box style properties eventually become PIXI.Graphics settings, so same syntax applies box
box: {
fill: new Color(this._fillColor).int,
alpha: this._fillColor === undefined || this._fillColor === null ? 0 : 1,
rounded: 5,
stroke: {
color: new Color(this._borderColor).int,
width: borderWidth_px,
alpha: this._borderColor === undefined || this._borderColor === null ? 0 : 1
},
/*default: {
fill: new Color(this._fillColor).int,
rounded: 5,
stroke: {
color: new Color(this._borderColor).int,
width: borderWidth_px
}
},
focused: {
fill: new Color(this._fillColor).int,
rounded: 5,
stroke: {
color: new Color(this._borderColor).int,
width: borderWidth_px
}
},
disabled: {
fill: new Color(this._fillColor).int,
rounded: 5,
stroke: {
color: new Color(this._borderColor).int,
width: borderWidth_px
}
}*/
},
}; };
} }

View File

@ -95,18 +95,11 @@ class MaxDiffMatrix
question.setCssRoot(rootClass); question.setCssRoot(rootClass);
question.cssClasses.mainRoot = rootClass; question.cssClasses.mainRoot = rootClass;
} }
let html;
let headerCells = "";
let subHeaderCells = "";
let bodyCells = "";
let bodyHTML = "";
let cellGenerator;
let i, j;
// Relying on a fact that there's always 2 columns. // Relying on a fact that there's always 2 columns.
// This is correct according current Qualtrics design for MaxDiff matrices. // This is correct according current Qualtrics design for MaxDiff matrices.
// Header generation // Header generation
headerCells = let headerCells =
`<th class="${CSS_CLASSES.TABLE_HEADER_CELL}">${question.columns[0].text}</th> `<th class="${CSS_CLASSES.TABLE_HEADER_CELL}">${question.columns[0].text}</th>
<td></td> <td></td>
<td></td> <td></td>
@ -114,9 +107,10 @@ class MaxDiffMatrix
<th class="${CSS_CLASSES.TABLE_HEADER_CELL}">${question.columns[1].text}</th>`; <th class="${CSS_CLASSES.TABLE_HEADER_CELL}">${question.columns[1].text}</th>`;
// Body generation // Body generation
for (i = 0; i < question.rows.length; i++) let bodyHTML = "";
for (let i = 0; i < question.rows.length; i++)
{ {
bodyCells = const bodyCells =
`<td class="${CSS_CLASSES.TABLE_CELL}"> `<td class="${CSS_CLASSES.TABLE_CELL}">
<label class="${CSS_CLASSES.LABEL}"> <label class="${CSS_CLASSES.LABEL}">
<input type="radio" class="${CSS_CLASSES.ITEM_VALUE}" name="${question.rows[i].value}" data-column=${question.columns[0].value}> <input type="radio" class="${CSS_CLASSES.ITEM_VALUE}" name="${question.rows[i].value}" data-column=${question.columns[0].value}>
@ -135,7 +129,7 @@ class MaxDiffMatrix
bodyHTML += `<tr class="${CSS_CLASSES.TABLE_ROW}">${bodyCells}</tr>`; bodyHTML += `<tr class="${CSS_CLASSES.TABLE_ROW}">${bodyCells}</tr>`;
} }
html = `<table class="${CSS_CLASSES.TABLE}"> let html = `<table class="${CSS_CLASSES.TABLE}">
<thead> <thead>
<tr>${headerCells}</tr> <tr>${headerCells}</tr>
</thead> </thead>
@ -147,14 +141,15 @@ class MaxDiffMatrix
let inputDOMS = el.querySelectorAll("input"); let inputDOMS = el.querySelectorAll("input");
for (i = 0; i < inputDOMS.length; i++) for (let i = 0; i < inputDOMS.length; i++)
{ {
inputDOMS[i].addEventListener("input", this._bindedHandlers._handleInput); inputDOMS[i].addEventListener("input", this._bindedHandlers._handleInput);
} }
} }
} }
export default function init (Survey) { export default function init (Survey)
{
var widget = { var widget = {
//the widget name. It should be unique and written in lowcase. //the widget name. It should be unique and written in lowcase.
name: "maxdiffmatrix", name: "maxdiffmatrix",