From 96903e0266d47727c9be617078169ee946a35497 Mon Sep 17 00:00:00 2001 From: RebeccaHirst <30597180+RebeccaHirst@users.noreply.github.com> Date: Thu, 6 Jan 2022 16:39:08 +0000 Subject: [PATCH 01/19] add linspace to util.js --- src/util/Util.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/util/Util.js b/src/util/Util.js index 2e01674..3d93bbf 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -358,6 +358,24 @@ export function shuffle(array, randomNumberGenerator = undefined) return array; } +/** + * linspace + * + * @name module:util.linspace + * @function + * @public + * @param {Object[]} startValue, stopValue, cardinality + * @return {Object[]} an array from startValue to stopValue with cardinality steps + */ +export function linspace(startValue, stopValue, cardinality) { + var arr = []; + var step = (stopValue - startValue) / (cardinality - 1); + for (var i = 0; i < cardinality; i++) { + arr.push(startValue + (step * i)); + } + return arr; +} + /** * Pick a random value from an array, uses `util.shuffle` to shuffle the array and returns the last value. * From 04669d28b3d484d2f016f1372f96d69851c6b747 Mon Sep 17 00:00:00 2001 From: Todd Parsons Date: Thu, 14 Jul 2022 11:08:53 +0100 Subject: [PATCH 02/19] NF: Add "arrow" as recognised named shape in ShapeStim --- src/visual/ShapeStim.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/visual/ShapeStim.js b/src/visual/ShapeStim.js index cb74cef..3439b70 100644 --- a/src/visual/ShapeStim.js +++ b/src/visual/ShapeStim.js @@ -371,4 +371,14 @@ ShapeStim.KnownShapes = { [-0.39, 0.31], [-0.09, 0.18], ], + + arrow: [ + [0.0, 0.5], + [-0.5, 0.0], + [-1/6, 0.0], + [-1/6, -0.5], + [1/6, -0.5], + [1/6, 0.0], + [0.5, 0.0], + ], }; From 11bddceb804517e4bf431a2a276ae854301d4855 Mon Sep 17 00:00:00 2001 From: Todd Parsons Date: Thu, 14 Jul 2022 11:09:12 +0100 Subject: [PATCH 03/19] ENH: Add other shapes from Python to ShapeStim --- src/visual/ShapeStim.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/visual/ShapeStim.js b/src/visual/ShapeStim.js index 3439b70..b5925e3 100644 --- a/src/visual/ShapeStim.js +++ b/src/visual/ShapeStim.js @@ -372,6 +372,19 @@ ShapeStim.KnownShapes = { [-0.09, 0.18], ], + triangle: [ + [+0.0, 0.5], // Point + [-0.5, -0.5], // Bottom left + [+0.5, -0.5], // Bottom right + ], + + rectangle: [ + [-.5, .5], // Top left + [ .5, .5], // Top right + [ .5, -.5], // Bottom left + [-.5, -.5], // Bottom right + ], + arrow: [ [0.0, 0.5], [-0.5, 0.0], From 2dbfc9c43c4a8e0c65c3273181f1b0671a064cb2 Mon Sep 17 00:00:00 2001 From: lgtst Date: Mon, 24 Oct 2022 13:19:44 +0100 Subject: [PATCH 04/19] xlsx upgrade due to a string parsing bug; --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7a9b92c..6477532 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "pixi.js-legacy": "^6.0.4", "seedrandom": "^3.0.5", "tone": "^14.7.77", - "xlsx": "^0.17.0" + "xlsx": "^0.18.5" }, "devDependencies": { "csslint": "^1.0.5", From c4c7cd8747374f85d60bb1de44d040f06544d59e Mon Sep 17 00:00:00 2001 From: tpronk Date: Fri, 9 Dec 2022 16:19:30 +0000 Subject: [PATCH 05/19] Update node version to 16 --- .github/workflows/Automated Test (short).yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Automated Test (short).yml b/.github/workflows/Automated Test (short).yml index 7d6001b..1c2ccdd 100644 --- a/.github/workflows/Automated Test (short).yml +++ b/.github/workflows/Automated Test (short).yml @@ -45,7 +45,7 @@ jobs: - name: Setup node uses: actions/setup-node@v1 with: - node-version: '12' + node-version: '16' # START: install psychojs_testing - name: Checkout psychojs_testing From 4e3d11ff6c894b6bb03bd81460f13eb8aae54d6e Mon Sep 17 00:00:00 2001 From: tpronk Date: Mon, 12 Dec 2022 16:17:50 +0000 Subject: [PATCH 06/19] BF: Downgrade node version to 14 --- .github/workflows/Automated Test (short).yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Automated Test (short).yml b/.github/workflows/Automated Test (short).yml index 1c2ccdd..68bc285 100644 --- a/.github/workflows/Automated Test (short).yml +++ b/.github/workflows/Automated Test (short).yml @@ -45,7 +45,7 @@ jobs: - name: Setup node uses: actions/setup-node@v1 with: - node-version: '16' + node-version: '14' # START: install psychojs_testing - name: Checkout psychojs_testing From 49512063a73aa784f5d3bf255f3d1255c26cd3ed Mon Sep 17 00:00:00 2001 From: tpronk Date: Mon, 12 Dec 2022 16:27:36 +0000 Subject: [PATCH 07/19] Upgrade to node 15 --- .github/workflows/Automated Test (short).yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Automated Test (short).yml b/.github/workflows/Automated Test (short).yml index 68bc285..8d25449 100644 --- a/.github/workflows/Automated Test (short).yml +++ b/.github/workflows/Automated Test (short).yml @@ -45,7 +45,7 @@ jobs: - name: Setup node uses: actions/setup-node@v1 with: - node-version: '14' + node-version: '15' # START: install psychojs_testing - name: Checkout psychojs_testing From 941b4425b980f430de569cc65948f1e6c6a8a3e4 Mon Sep 17 00:00:00 2001 From: tpronk Date: Tue, 13 Dec 2022 19:49:13 +0000 Subject: [PATCH 08/19] Upgrade to node 15 --- .github/workflows/Automated Test (full).yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Automated Test (full).yml b/.github/workflows/Automated Test (full).yml index cc81b62..3fc879a 100644 --- a/.github/workflows/Automated Test (full).yml +++ b/.github/workflows/Automated Test (full).yml @@ -53,7 +53,7 @@ jobs: - name: Setup node uses: actions/setup-node@v2 with: - node-version: '14' + node-version: '15' - name: Cache modules psychojs_testing uses: actions/cache@v2 env: From be5292480b10c47470cff865b74c4c15284b8935 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Thu, 5 Jan 2023 11:31:26 +0100 Subject: [PATCH 09/19] _ --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5ffb4ca..3cfae9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psychojs", - "version": "2022.3.0", + "version": "2022.2.5", "private": true, "description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments", "license": "MIT", From da9b892ead2abffd3bff12411d75a13cbe419d65 Mon Sep 17 00:00:00 2001 From: Todd Parsons Date: Fri, 13 Jan 2023 14:46:07 +0000 Subject: [PATCH 10/19] ENH: Alias "star" and "star7" --- src/visual/ShapeStim.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/visual/ShapeStim.js b/src/visual/ShapeStim.js index b5925e3..dad2807 100644 --- a/src/visual/ShapeStim.js +++ b/src/visual/ShapeStim.js @@ -395,3 +395,5 @@ ShapeStim.KnownShapes = { [0.5, 0.0], ], }; +// Alias some names for convenience +ShapeStim.KnownShapes['star'] = ShapeStim.KnownShapes['star7'] From c2be1a04ec74cb95ed54022e1f4a73aa7aa6c984 Mon Sep 17 00:00:00 2001 From: Todd Parsons Date: Thu, 26 Jan 2023 12:47:28 +0000 Subject: [PATCH 11/19] FF: Allow TextBox to accept "placeholder" as an input --- src/visual/TextBox.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 3930cbf..4d8e2bc 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -65,6 +65,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) opacity, depth, text, + placeholder, font, letterHeight, bold, @@ -98,7 +99,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) ); this._addAttribute( "placeholder", - text, + placeholder, "", this._onChange(true, true), ); From f87431e6af9ce3f8db73ced5f6a2e7fd52021f9d Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Thu, 2 Feb 2023 12:49:26 +0100 Subject: [PATCH 12/19] ENH: Survey super-flow --- src/visual/Survey.js | 845 +++++++++++++++--- src/visual/survey/MaxDiffMatrix.js | 307 ------- src/visual/survey/SelectBox.js | 119 --- src/visual/survey/SideBySideMatrix.js | 424 --------- src/visual/survey/SliderStar.js | 289 ------ src/visual/survey/components/MatrixBipolar.js | 7 +- src/visual/survey/widgets/MaxDiffMatrix.js | 27 +- src/visual/survey/widgets/SelectBox.js | 22 +- src/visual/survey/widgets/SideBySideMatrix.js | 43 +- src/visual/survey/widgets/SliderStar.js | 8 +- src/visual/survey/widgets/SliderWidget.js | 1 + 11 files changed, 817 insertions(+), 1275 deletions(-) delete mode 100644 src/visual/survey/MaxDiffMatrix.js delete mode 100644 src/visual/survey/SelectBox.js delete mode 100644 src/visual/survey/SideBySideMatrix.js delete mode 100644 src/visual/survey/SliderStar.js diff --git a/src/visual/Survey.js b/src/visual/Survey.js index f0261d1..d4cf781 100644 --- a/src/visual/Survey.js +++ b/src/visual/Survey.js @@ -3,13 +3,12 @@ * * @author Alain Pitiot and Nikita Agafonov * @version 2022.3 - * @copyright (c) 2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @copyright (c) 2023 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ import * as PIXI from "pixi.js-legacy"; import { VisualStim } from "./VisualStim.js"; -import {PsychoJS} from "../core/PsychoJS.js"; import * as util from "../util/Util.js"; import {Clock} from "../util/Clock.js"; import {ExperimentHandler} from "../data/ExperimentHandler.js"; @@ -21,7 +20,29 @@ import registerSideBySideMatrix from "./survey/widgets/SideBySideMatrix.js"; import registerMaxDiffMatrix from "./survey/widgets/MaxDiffMatrix.js"; import registerSliderStar from "./survey/widgets/SliderStar.js"; import MatrixBipolar from "./survey/components/MatrixBipolar.js"; +import DropdownExtensions from "./survey/components/DropdownExtensions.js"; +import customExpressionFunctionsArray from "./survey/extensions/customExpressionFunctions.js"; +const CAPTIONS = { + NEXT: "Next" +}; + +const SURVEY_SETTINGS = { + minWidth: "100px" +}; + +const SURVEY_COMPLETION_CODES = +{ + NORMAL: 0, + SKIP_TO_END_OF_BLOCK: 1, + SKIP_TO_END_OF_SURVEY: 2 +}; + +const NODE_EXIT_CODES = +{ + NORMAL: 0, + BREAK_FLOW: 1 +}; /** * Survey Stimulus. @@ -32,6 +53,16 @@ export class Survey extends VisualStim { static SURVEY_EXPERIMENT_PARAMETERS = ["surveyId", "showStartDialog", "showEndDialog", "completionUrl", "cancellationUrl", "quitOnEsc"]; + static SURVEY_FLOW_PLAYBACK_TYPES = + { + DIRECT: "QUESTION_BLOCK", + CONDITIONAL: "IF_THEN_ELSE_GROUP", + EMBEDDED_DATA: "VARIABLES", + RANDOMIZER: "RANDOM_GROUP", + SEQUENTIAL: "SEQUENTIAL_GROUP", + ENDSURVEY: "END" + }; + /** * @memberOf module:visual * @param {Object} options @@ -52,11 +83,40 @@ export class Survey extends VisualStim { super({ name, win, units, ori, depth, pos, size, autoDraw, autoLog }); - // default size: - if (typeof size === "undefined") - { - this.size = (this.unit === "norm") ? [2.0, 2.0] : [1.0, 1.0]; - } + // the default surveyId is an uuid based on the experiment id (or name) and the survey name: + // this way, it is always the same within a given experiment + this._hasSelfGeneratedSurveyId = (typeof surveyId === "undefined"); + const defaultSurveyId = (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER) ? + util.makeUuid(`${name}@${this._psychoJS.config.gitlab.projectId}`) : + util.makeUuid(`${name}@${this._psychoJS.config.experiment.name}`); + + // whether the user is done with the survey, independently of whether the survey is completed: + this.isFinished = false; + + // Accumulated completion flag that is being set after completion of one survey node. + // This flag allows to track completion progress while moving through the survey flow. + // Initially set to true and will be flipped if at least one of the survey nodes were not fully completed. + this._isCompletedAll = true; + + // timestamps associated to each question: + this._questionAnswerTimestamps = {}; + // timestamps clock: + this._questionAnswerTimestampClock = new Clock(); + + this._totalSurveyResults = {}; + this._surveyData = undefined; + this._surveyModel = undefined; + this._signaturePadRO = undefined; + this._expressionsRunner = undefined; + this._lastPageSwitchHandledIdx = -1; + this._variables = {}; + + this._surveyRunningPromise = undefined; + this._surveyRunningPromiseResolve = undefined; + this._surveyRunningPromiseReject = undefined; + + // callback triggered when the user is done with the survey: nothing to do by default + this._onFinishedCallback = () => {}; // init SurveyJS this._initSurveyJS(); @@ -65,30 +125,12 @@ export class Survey extends VisualStim "model", model ); - - // the default surveyId is an uuid based on the experiment id (or name) and the survey name: - // this way, it is always the same within a given experiment - this._hasSelfGeneratedSurveyId = (typeof surveyId === "undefined"); - const defaultSurveyId = (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER) ? - util.makeUuid(`${name}@${this._psychoJS.config.gitlab.projectId}`) : - util.makeUuid(`${name}@${this._psychoJS.config.experiment.name}`); this._addAttribute( "surveyId", surveyId, defaultSurveyId ); - // whether the user is done with the survey, independently of whether the survey is completed: - this.isFinished = false; - // whether the user completed the survey, i.e. answered all the questions: - this.isCompleted = false; - // timestamps associated to each question: - this._questionAnswerTimestamps = {}; - // timestamps clock: - this._questionAnswerTimestampClock = new Clock(); - // callback triggered when the user is done with the survey: nothing to do by default - this._onFinishedCallback = () => {}; - // estimate the bounding box: this._estimateBoundingBox(); @@ -98,6 +140,11 @@ export class Survey extends VisualStim } } + get isCompleted () + { + return this.isFinished && this._isCompletedAll; + } + /** * Setter for the model attribute. * @@ -130,19 +177,46 @@ export class Survey extends VisualStim model = JSON.parse(decodedModel); } - // items should now be an object: + // model should now be an object: if (typeof model !== "object") { throw "model is neither the name of a resource nor an object"; } - this._surveyModelJson = Object.assign({}, model); - this._surveyModel = new window.Survey.Model(this._surveyModelJson); - this._surveyModel.isInitialized = false; + // if model is a straight-forward SurveyJS model, instead of a Pavlovia Survey super-flow model, + // convert it: + if (!('surveyFlow' in model)) + { + model = { + surveys: [model], + embeddedData: [], + surveysMap: {}, + questionMapsBySurvey: {}, + surveyFlow: { + name: "root", + type: "SEQUENTIAL_GROUP", + nodes: [{ + type: "QUESTION_BLOCK", + surveyIdx: 0 + }] + }, - // custom css: - // see https://surveyjs.io/form-library/examples/survey-cssclasses/jquery#content-js + surveySettings: { showPrevButton: false }, + surveyRunLogic: {}, + inQuestionRandomization: {}, + questionsOrderRandomization: [], + questionSkipLogic: {}, + + questionsConverted: -1, + questionsTotal: -1, + logs: [] + }; + + this.psychoJS.logger.debug(`converted the old model to the new super-flow model: ${JSON.stringify(model)}`); + } + + this._surveyData = model; this._setAttribute("model", model, log); this._onChange(true, true)(); } @@ -163,18 +237,26 @@ export class Survey extends VisualStim setVariables(variables, excludedNames) { // filter the variables and set them: - const filteredVariables = {}; + // const filteredVariables = {}; + // for (const name in variables) + // { + // if (excludedNames.indexOf(name) === -1) + // { + // filteredVariables[name] = variables[name]; + // this._surveyModel.setVariable(name, variables[name]); + // } + // } + + // // set the values: + // this._surveyModel.mergeData(filteredVariables); + for (const name in variables) { if (excludedNames.indexOf(name) === -1) { - filteredVariables[name] = variables[name]; - this._surveyModel.setVariable(name, variables[name]); + this._surveyData.variables[name] = variables[name]; } } - - // set the values: - this._surveyModel.mergeData(filteredVariables); } /** @@ -224,7 +306,7 @@ export class Survey extends VisualStim */ onFinished(callback) { - if (typeof this._surveyModel === "undefined") + if (typeof this._surveyData === "undefined") { throw { origin: "Survey.onFinished", @@ -247,12 +329,14 @@ export class Survey extends VisualStim */ getResponse() { - if (typeof this._surveyModel === "undefined") - { - return {}; - } + // if (typeof this._surveyModel === "undefined") + // { + // return {}; + // } - return this._surveyModel.data; + // return this._surveyModel.data; + + return this._totalSurveyResults; } /** @@ -323,7 +407,7 @@ export class Survey extends VisualStim else { return this._psychoJS.serverManager.uploadSurveyResponse( - this._surveyId, sortedResponses, this.isCompleted, this._surveyModelJson + this._surveyId, sortedResponses, this.isCompleted, this._surveyData ); } } @@ -383,21 +467,19 @@ export class Survey extends VisualStim { this._needPixiUpdate = false; - // if a survey div already does not exist already, create it: - const surveyId = "_survey"; - let surveyDiv = document.getElementById(surveyId); - if (surveyDiv === null) + // if a survey div does not exist, create it: + if (document.getElementById("_survey") === null) { - surveyDiv = document.createElement("div"); - surveyDiv.id = surveyId; - surveyDiv.className = "survey"; - document.body.appendChild(surveyDiv); + document.body.insertAdjacentHTML("beforeend", "
") } - // start the survey: - if (typeof this._surveyModel !== "undefined") + // start the survey flow: + if (typeof this._surveyData !== "undefined") { - this._startSurvey(surveyId, this._surveyModel); + // this._startSurvey(surveyId, this._surveyModel); + // jQuery(`#${surveyId}`).Survey({model: this._surveyModel}); + + this._runSurveyFlow(this._surveyData.surveyFlow, this._surveyData); } } @@ -424,26 +506,18 @@ export class Survey extends VisualStim } /** - * Init the SurveyJS.io library. + * Register custom SurveyJS expression functions. * * @protected + * @return {void} */ - _initSurveyJS() + _registerCustomExpressionFunctions (Survey, customFuncs = []) { - // load the Survey.js libraries, if necessary: - // TODO - - // setup the survey theme: - window.Survey.StylesManager.applyTheme("defaultV2"); - - // load the PsychoJS SurveyJS extensions: - this._expressionsRunner = new window.Survey.ExpressionRunner(); - this._registerWidgets(); - this._registerCustomSurveyProperties(); - - // load the desired style: - // TODO - // util.loadCss("./survey/css/grey_style.css"); + let i; + for (i = 0; i < customFuncs.length; i++) + { + Survey.FunctionFactory.Instance.register(customFuncs[i].func.name, customFuncs[i].func, customFuncs[i].isAsync); + } } /** @@ -452,57 +526,39 @@ export class Survey extends VisualStim * @protected * @return {void} */ - _registerWidgets() + _registerWidgets(Survey) { - registerSelectBoxWidget(window.Survey); - registerSliderWidget(window.Survey); - registerSideBySideMatrix(window.Survey); - registerMaxDiffMatrix(window.Survey); - registerSliderStar(window.Survey); + registerSelectBoxWidget(Survey); + registerSliderWidget(Survey); + registerSideBySideMatrix(Survey); + registerMaxDiffMatrix(Survey); + registerSliderStar(Survey); // load the widget style: // TODO // util.loadCss("./survey/css/widgets.css"); } - _registerCustomSurveyProperties() + /** + * Register custom Survey properties. Usially these are relevant for different question types. + * + * @protected + * @return {void} + */ + _registerCustomSurveyProperties(Survey) { - MatrixBipolar.registerSurveyProperties(window.Survey); + MatrixBipolar.registerSurveyProperties(Survey); + Survey.Serializer.addProperty("signaturepad", { + name: "maxSignatureWidth", + type: "number", + default: 500 + }); } _registerCustomComponentCallbacks(surveyModel) { MatrixBipolar.registerModelCallbacks(surveyModel); - } - - /** - * Run the survey using flow data provided. This method runs recursively. - * - * @protected - * @param {string} surveyId - the id of the DOM div - * @param {Object} surveyData - surveyData / model. - * @param {Object} prevBlockResults - survey results gathered from running previous block of questions. - * @return {void} - */ - _startSurvey(surveyId, surveyData, prevBlockResults = {}) - { - // initialise the survey model is need be: - if (!this._surveyModel.isInitialized) - { - this._registerCustomComponentCallbacks(this._surveyModel); - this._surveyModel.onValueChanged.add(this._onQuestionValueChanged.bind(this)); - this._surveyModel.onCurrentPageChanging.add(this._onCurrentPageChanging.bind(this)); - this._surveyModel.onTextMarkdown.add(this._onTextMarkdown.bind(this)); - this._surveyModel.onComplete.add(this._onSurveyComplete.bind(this)); - this._surveyModel.isInitialized = true; - } - - jQuery(`#${surveyId}`).Survey({ - model: this._surveyModel, - showItemsInOrder: "column" - }); - - this._questionAnswerTimestampClock.reset(); + DropdownExtensions.registerModelCallbacks(surveyModel); } /** @@ -523,20 +579,315 @@ export class Survey extends VisualStim this._questionAnswerTimestamps[questionData.name].timestamp = this._questionAnswerTimestampClock.getTime(); } + // This probably needs to be moved to some kind of utils.js. + // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle + _FisherYatesShuffle (targetArray = []) + { + // Copying array to preserve initial data. + const out = Array.from(targetArray); + const len = targetArray.length; + let i, j, k; + for (i = len - 1; i >= 1; i--) + { + j = Math.floor(Math.random() * (i + 1)); + k = out[j]; + out[j] = out[i]; + out[i] = k; + } + + return out; + } + + // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle + _InPlaceFisherYatesShuffle (inOutArray = [], startIdx, endIdx) + { + // Shuffling right in the input array. + let i, j, k; + for (i = endIdx; i >= startIdx; i--) + { + j = Math.floor(Math.random() * (i + 1)); + k = inOutArray[j]; + inOutArray[j] = inOutArray[i]; + inOutArray[i] = k; + } + + return inOutArray; + } + + _composeModelWithRandomizedQuestions (surveyModel, inBlockRandomizationSettings) + { + let t = performance.now(); + // Qualtrics's in-block randomization ignores presense of page breaks within the block. + // Hence creating a fresh survey data object with shuffled question order. + let questions = []; + let questionsMap = {}; + let shuffledQuestions; + let newSurveyModel = + { + pages:[{ elements: new Array(inBlockRandomizationSettings.questionsPerPage) }] + }; + let i, j, k; + for (i = 0; i < surveyModel.pages.length; i++) + { + for (j = 0; j < surveyModel.pages[i].elements.length; j++) + { + questions.push(surveyModel.pages[i].elements[j]); + k = questions.length - 1; + questionsMap[questions[k].name] = questions[k]; + } + } + + if (inBlockRandomizationSettings.layout.length > 0) + { + j = 0; + k = 0; + let curPage = 0; + let curElement = 0; + const shuffledSet0 = this._FisherYatesShuffle(inBlockRandomizationSettings.set0); + const shuffledSet1 = this._FisherYatesShuffle(inBlockRandomizationSettings.set1); + for (i = 0; i < inBlockRandomizationSettings.layout.length; i++) + { + // Create new page if questionsPerPage reached. + if (curElement === inBlockRandomizationSettings.questionsPerPage) + { + newSurveyModel.pages.push({ elements: new Array(inBlockRandomizationSettings.questionsPerPage) }); + curPage++; + curElement = 0; + } + + if (inBlockRandomizationSettings.layout[i] === "set0") + { + newSurveyModel.pages[curPage].elements[curElement] = questionsMap[shuffledSet0[j]]; + j++; + } + else if (inBlockRandomizationSettings.layout[i] === "set1") + { + newSurveyModel.pages[curPage].elements[curElement] = questionsMap[shuffledSet1[k]]; + k++; + } + else + { + newSurveyModel.pages[curPage].elements[curElement] = questionsMap[inBlockRandomizationSettings.layout[i]]; + } + curElement++; + } + } + else if (inBlockRandomizationSettings.showOnly > 0) + { + // TODO: Check if there can be questionsPerPage applicable in this case. + shuffledQuestions = this._FisherYatesShuffle(questions); + newSurveyModel.pages[0].elements = shuffledQuestions.splice(0, inBlockRandomizationSettings.showOnly); + } + else { + // TODO: Check if there can be questionsPerPage applicable in this case. + newSurveyModel.pages[0].elements = this._FisherYatesShuffle(questions); + } + console.log("model recomposition took", performance.now() - t); + console.log("recomposed model:", newSurveyModel); + return newSurveyModel; + } + + _applyInQuestionRandomization (questionData, inQuestionRandomizationSettings, surveyData) + { + let t = performance.now(); + let choicesFieldName; + let valueFieldName; + if (questionData.rows !== undefined) + { + choicesFieldName = "rows"; + valueFieldName = "value"; + } + else if (questionData.choices !== undefined) + { + choicesFieldName = "choices"; + valueFieldName = "value"; + } + else if (questionData.items !== undefined) + { + choicesFieldName = "items"; + valueFieldName = "name"; + } + else + { + console.log("[Survey runner]: Uknown choicesFieldName for", questionData); + } + + if (inQuestionRandomizationSettings.randomizeAll) + { + questionData[choicesFieldName] = this._FisherYatesShuffle(questionData[choicesFieldName]); + // Handle dynamic choices. + } + else if (inQuestionRandomizationSettings.showOnly > 0) + { + questionData[choicesFieldName] = this._FisherYatesShuffle(questionData[choicesFieldName]).splice(0, inQuestionRandomizationSettings.showOnly); + } + else if (inQuestionRandomizationSettings.reverse) + { + questionData[choicesFieldName] = Math.round(Math.random()) === 1 ? questionData[choicesFieldName].reverse() : questionData[choicesFieldName]; + } + else if (inQuestionRandomizationSettings.layout.length > 0) + { + const initialChoices = questionData[choicesFieldName]; + let choicesMap = {}; + // TODO: generalize further i.e. figure out how to calculate the length of array based on availability of sets. + const setIndices = [0, 0, 0]; + let i; + for (i = 0; i < questionData[choicesFieldName].length; i++) + { + choicesMap[questionData[choicesFieldName][i][valueFieldName]] = questionData[choicesFieldName][i]; + } + + // Creating new array of choices to which we're going to write from randomized/reversed sets. + questionData[choicesFieldName] = new Array(inQuestionRandomizationSettings.layout.length); + const shuffledSet0 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set0); + const shuffledSet1 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set1); + const reversedSet = Math.round(Math.random()) === 1 ? inQuestionRandomizationSettings.reverseOrder.reverse() : inQuestionRandomizationSettings.reverseOrder; + for (i = 0; i < inQuestionRandomizationSettings.layout.length; i++) + { + if (inQuestionRandomizationSettings.layout[i] === "set0") + { + questionData[choicesFieldName][i] = choicesMap[shuffledSet0[ setIndices[0] ]]; + setIndices[0]++; + } + else if (inQuestionRandomizationSettings.layout[i] === "set1") + { + questionData[choicesFieldName][i] = choicesMap[shuffledSet1[ setIndices[1] ]]; + setIndices[1]++; + } + else if (inQuestionRandomizationSettings.layout[i] === "reverseOrder") + { + questionData[choicesFieldName][i] = choicesMap[reversedSet[ setIndices[2] ]]; + setIndices[2]++; + } + else + { + questionData[choicesFieldName][i] = choicesMap[inQuestionRandomizationSettings.layout[i]]; + } + } + + if (inQuestionRandomizationSettings.layout.length < initialChoices.length) + { + // Compose unused choices set. + // TODO: This is potentially how data loss can be avoided and thus no need to deepcopy model. + if (surveyData.unusedChoices === undefined) + { + surveyData.unusedChoices = {}; + } + surveyData.unusedChoices[questionData.name] = { + // All other sets are always used entirely. + set1: shuffledSet1.splice(setIndices[1], shuffledSet1.length) + }; + console.log("unused choices", questionData.name, surveyData.unusedChoices[questionData.name]); + } + } + + console.log("applying question randomization took", performance.now() - t); + // console.log(questionData); + } + + /** + * @desc: Go over required surveyModelData and apply randomization settings. + */ + _processSurveyData (surveyData, surveyIdx) + { + let t = performance.now(); + let i, j; + let newSurveyModel = undefined; + if (surveyData.questionsOrderRandomization[surveyIdx] !== undefined) + { + // Qualtrics's in-block randomization ignores presense of page breaks within the block. + // Hence creating a fresh survey data object with shuffled question order. + newSurveyModel = this._composeModelWithRandomizedQuestions(surveyData.surveys[surveyIdx], surveyData.questionsOrderRandomization[surveyIdx]); + } + + // Checking if there's in-question randomization that needs to be applied. + for (i = 0; i < surveyData.surveys[surveyIdx].pages.length; i++) + { + for (j = 0; j < surveyData.surveys[surveyIdx].pages[i].elements.length; j++) + { + if (surveyData.inQuestionRandomization[surveyData.surveys[surveyIdx].pages[i].elements[j].name] !== undefined) + { + if (newSurveyModel === undefined) + { + // Marking a deep copy of survey model input data, to avoid data loss if randomization returns a subset of choices. + // TODO: think of somehting more optimal. + newSurveyModel = JSON.parse(JSON.stringify(surveyData.surveys[surveyIdx])); + } + this._applyInQuestionRandomization( + newSurveyModel.pages[i].elements[j], + surveyData.inQuestionRandomization[newSurveyModel.pages[i].elements[j].name], + surveyData + ); + } + } + } + + if (newSurveyModel === undefined) + { + // No changes were made, just return original data. + newSurveyModel = surveyData.surveys[surveyIdx]; + } + console.log("survey model preprocessing took", performance.now() - t); + return newSurveyModel; + } + /** * Callback triggered when the participant changed the page. * * @protected */ - _onCurrentPageChanging() + _onCurrentPageChanging (surveyModel, options) { - // console.log(arguments); - } + if (this._lastPageSwitchHandledIdx === options.oldCurrentPage.visibleIndex) + { + // When surveyModel.currentPage is called from this handler, pagechange event gets triggered again. + // Hence returning if we already handled this pagechange to avoid max callstack exceeded errors. + return; + } + this._lastPageSwitchHandledIdx = options.oldCurrentPage.visibleIndex; + const questions = surveyModel.getCurrentPageQuestions(); - _onTextMarkdown(survey, options) - { - // TODO add sanitization / checks if required. - options.html = options.text; + // It is guaranteed that the question with skip logic is always last on the page. + const lastQuestion = questions[questions.length - 1]; + const skipLogic = this._surveyData.questionSkipLogic[lastQuestion.name]; + if (skipLogic !== undefined) + { + this._expressionsRunner.expressionExecutor.setExpression(skipLogic.expression); + const result = this._expressionsRunner.run(surveyModel.data); + if (result) + { + options.allowChanging = false; + + if (skipLogic.destination === "ENDOFSURVEY") + { + surveyModel.setCompleted(); + this._surveyRunningPromiseResolve(SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY); + } + else if (skipLogic.destination === "ENDOFBLOCK") + { + surveyModel.setCompleted(); + this._surveyRunningPromiseResolve(SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_BLOCK); + } + else + { + // skipLogic.destination is a question within the current survey (qualtrics block). + const targetQuestion = surveyModel.getQuestionByName(skipLogic.destination); + const page = surveyModel.getPageByQuestion(targetQuestion); + const pageQuestions = page.questions; + let i; + for (i = 0; i < pageQuestions.length; i++) + { + if (pageQuestions[i] === targetQuestion) + { + break; + } + pageQuestions[i].visible = false; + } + targetQuestion.focus(); + surveyModel.currentPage = page; + } + } + } } /** @@ -549,7 +900,33 @@ export class Survey extends VisualStim */ _onSurveyComplete(surveyModel, options) { - this.isFinished = true; + Object.assign(this._totalSurveyResults, surveyModel.data); + this._detachResizeObservers(); + let completionCode = SURVEY_COMPLETION_CODES.NORMAL; + const questions = surveyModel.getAllQuestions(); + + // It is guaranteed that the question with skip logic is always last on the page. + const lastQuestion = questions[questions.length - 1]; + const skipLogic = this._surveyData.questionSkipLogic[lastQuestion.name]; + if (skipLogic !== undefined) + { + this._expressionsRunner.expressionExecutor.setExpression(skipLogic.expression); + const result = this._expressionsRunner.run(surveyModel.data); + if (result) + { + if (skipLogic.destination === "ENDOFSURVEY") + { + completionCode = SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY; + surveyModel.setCompleted(); + } + else if (skipLogic.destination === "ENDOFBLOCK") + { + completionCode = SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_BLOCK; + } + } + } + + surveyModel.stopTimer(); // check whether the survey was completed: const surveyVisibleQuestions = this._surveyModel.getAllQuestions(true); @@ -574,9 +951,241 @@ export class Survey extends VisualStim }, 0 ); - this.isCompleted = (nbAnsweredQuestions === surveyVisibleQuestions.length); + this._isCompletedAll = this._isCompletedAll && (nbAnsweredQuestions === surveyVisibleQuestions.length); + if (this._isCompletedAll === false) + { + this.psychoJS.logger.warn(`Flag _isCompletedAll is false!`); + } + this._surveyRunningPromiseResolve(completionCode); + } + + _onFlowComplete () + { + this.isFinished = true; this._onFinishedCallback(); } + _onTextMarkdown(survey, options) + { + // TODO add sanitization / checks if required. + options.html = options.text; + } + + /** + * Run the survey using flow data provided. This method runs recursively. + * + * @protected + * @param {string} surveyId - the id of the DOM div + * @param {Object} surveyData - surveyData / model. + * @param {Object} prevBlockResults - survey results gathered from running previous block of questions. + * @return {void} + */ + _beginSurvey(surveyData, surveyFlowBlock) + { + let j; + let surveyIdx; + this._lastPageSwitchHandledIdx = -1; + surveyIdx = surveyFlowBlock.surveyIdx; + console.log("playing survey with idx", surveyIdx); + let surveyModelInput = this._processSurveyData(surveyData, surveyIdx); + + this._surveyModel = new window.Survey.Model(surveyModelInput); + for (j in this._variables) + { + // Adding variables directly to hash to get higher performance (this is instantaneous compared to .setVariable()). + // At this stage we don't care to trigger all the callbacks like .setVariable() does, since this is very beginning of survey presentation. + this._surveyModel.variablesHash[j] = this._variables[j]; + // this._surveyModel.setVariable(j, this._variables[j]); + } + + if (!this._surveyModel.isInitialized) + { + this._registerCustomComponentCallbacks(this._surveyModel); + this._surveyModel.onValueChanged.add(this._onQuestionValueChanged.bind(this)); + this._surveyModel.onCurrentPageChanging.add(this._onCurrentPageChanging.bind(this)); + this._surveyModel.onComplete.add(this._onSurveyComplete.bind(this)); + this._surveyModel.onTextMarkdown.add(this._onTextMarkdown.bind(this)); + this._surveyModel.isInitialized = true; + this._surveyModel.onAfterRenderQuestion.add(this._handleAfterQuestionRender.bind(this)); + } + + const completeText = surveyIdx < this._surveyData.surveys.length - 1 ? (this._surveyModel.pageNextText || CAPTIONS.NEXT) : undefined; + jQuery(".survey").Survey({ + model: this._surveyModel, + showItemsInOrder: "column", + completeText, + ...surveyData.surveySettings, + }); + + this._questionAnswerTimestampClock.reset(); + + // TODO: should this be conditional? + this._surveyModel.startTimer(); + + this._surveyRunningPromise = new Promise((res, rej) => { + this._surveyRunningPromiseResolve = res; + this._surveyRunningPromiseReject = rej; + }); + + return this._surveyRunningPromise; + } + + async _runSurveyFlow(surveyBlock, surveyData, prevBlockResults = {}) + { + // let surveyBlock; + let surveyIdx; + let surveyCompletionCode; + let nodeExitCode = NODE_EXIT_CODES.NORMAL; + let i, j; + + if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL) + { + const dataset = Object.assign({}, this._totalSurveyResults, this._variables); + this._expressionsRunner.expressionExecutor.setExpression(surveyBlock.condition); + if (this._expressionsRunner.run(dataset) && surveyBlock.nodes[0] !== undefined) + { + nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[0], surveyData, prevBlockResults); + } + else if (surveyBlock.nodes[1] !== undefined) + { + nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[1], surveyData, prevBlockResults); + } + } + else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.RANDOMIZER) + { + this._InPlaceFisherYatesShuffle(surveyBlock.nodes, 0, surveyBlock.nodes.length - 1); + } + else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.EMBEDDED_DATA) + { + let t = performance.now(); + const surveyBlockData = surveyData.embeddedData[surveyBlock.dataIdx]; + for (j = 0; j < surveyBlockData.length; j++) + { + // TODO: handle the rest data types. + if (surveyBlockData[j].type === "Custom") + { + // Variable value can be an expression. Check if so and if valid - run it. + // surveyBlockData is an array so all the variables in it are in order they were declared in Qualtrics. + // This means this._variables is saturated gradually with the data necessary to perform a computation. + // It's guaranteed to be there, unless there are declaration order mistakes. + this._expressionsRunner.expressionExecutor.setExpression(surveyBlockData[j].value); + if (this._expressionsRunner.expressionExecutor.canRun()) + { + this._variables[surveyBlockData[j].key] = this._expressionsRunner.run(this._variables); + } + else + { + this._variables[surveyBlockData[j].key] = surveyBlockData[j].value; + } + } + } + console.log("embedded data variables accumulation took", performance.now() - t); + } + else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.ENDSURVEY) + { + if (this._surveyModel) + { + this._surveyModel.setCompleted(); + } + console.log("EndSurvey block encountered, exiting."); + nodeExitCode = NODE_EXIT_CODES.BREAK_FLOW; + } + else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.DIRECT) + { + surveyCompletionCode = await this._beginSurvey(surveyData, surveyBlock); + Object.assign({}, prevBlockResults, this._surveyModel.data); + + // SkipLogic had destination set to ENDOFSURVEY. + if (surveyCompletionCode === SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY) + { + nodeExitCode = NODE_EXIT_CODES.BREAK_FLOW; + } + } + + if (nodeExitCode === NODE_EXIT_CODES.NORMAL && + surveyBlock.type !== Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL && + surveyBlock.nodes instanceof Array) + { + for (i = 0; i < surveyBlock.nodes.length; i++) + { + nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[i], surveyData, prevBlockResults); + if (nodeExitCode === NODE_EXIT_CODES.BREAK_FLOW) + { + break; + } + } + } + + if (surveyBlock.name === "root") + { + // At this point we went through the entire survey flow tree. + this._onFlowComplete(); + } + + return nodeExitCode; + } + + _resetState () + { + this._lastPageSwitchHandledIdx = -1; + } + + _handleSignaturePadResize (entries) + { + let signatureCanvas; + let q; + let i; + for (i = 0; i < entries.length; i++) + { + signatureCanvas = entries[i].target.querySelector("canvas"); + q = this._surveyModel.getQuestionByName(entries[i].target.dataset.name); + q.signatureWidth = Math.min(q.maxSignatureWidth, entries[i].contentBoxSize[0].inlineSize); + } + } + + _addEventListeners () + { + this._signaturePadRO = new ResizeObserver(this._handleSignaturePadResize.bind(this)); + } + + _handleAfterQuestionRender (sender, options) + { + if (options.question.getType() === "signaturepad") + { + this._signaturePadRO.observe(options.htmlElement); + } + } + + _detachResizeObservers () + { + this._signaturePadRO.disconnect(); + } + + /** + * Init the SurveyJS.io library. + * + * @protected + */ + _initSurveyJS() + { + // load the Survey.js libraries, if necessary: + // TODO + + // load the PsychoJS SurveyJS extensions: + this._expressionsRunner = new window.Survey.ExpressionRunner(); + this._registerCustomExpressionFunctions(window.Survey, customExpressionFunctionsArray); + this._registerWidgets(window.Survey); + this._registerCustomSurveyProperties(window.Survey); + this._addEventListeners(); + + // setup the survey theme: + window.Survey.Serializer.getProperty("expression", "minWidth").defaultValue = "100px"; + window.Survey.settings.minWidth = "100px"; + window.Survey.StylesManager.applyTheme("defaultV2"); + + // load the desired style: + // TODO + // util.loadCss("./survey/css/grey_style.css"); + } } diff --git a/src/visual/survey/MaxDiffMatrix.js b/src/visual/survey/MaxDiffMatrix.js deleted file mode 100644 index a539f54..0000000 --- a/src/visual/survey/MaxDiffMatrix.js +++ /dev/null @@ -1,307 +0,0 @@ -/** -* @desc "MaxDiff" matrix. -* */ - -class MaxDiffMatrix -{ - constructor (cfg = {}) - { - // surveyCSS contains css class names provided by the applied theme - // INCLUDING those added/modified by application's code. - const surveyCSS = cfg.question.css; - this._CSS_CLASSES = { - WRAPPER: `${surveyCSS.matrix.tableWrapper} matrix-maxdiff`, - TABLE: surveyCSS.matrix.root, - TABLE_ROW: surveyCSS.matrixdropdown.row, - TABLE_HEADER_CELL: surveyCSS.matrix.headerCell, - TABLE_CELL: surveyCSS.matrix.cell, - INPUT_TEXT: surveyCSS.text.root, - LABEL: surveyCSS.matrix.label, - ITEM_CHECKED: surveyCSS.matrix.itemChecked, - ITEM_VALUE: surveyCSS.matrix.itemValue, - ITEM_DECORATOR: surveyCSS.matrix.materialDecorator, - RADIO: surveyCSS.radiogroup.item, - SELECT: surveyCSS.dropdown.control, - CHECKBOX: surveyCSS.checkbox.item - }; - - // const CSS_CLASSES = { - // WRAPPER: "sv-matrix matrix-maxdiff", - // TABLE: "sv-table sv-matrix-root", - // TABLE_ROW: "sv-table__row", - // TABLE_HEADER_CELL: "sv-table__cell sv-table__cell--header", - // TABLE_CELL: "sv-table__cell sv-matrix__cell", - // INPUT_TEXT: "sv-text", - // RADIO: "sv-radio", - // SELECT: "sv-dropdown", - // CHECKBOX: "sv-checkbox" - // }; - this._question = cfg.question; - this._DOM = cfg.el; - this._DOM.classList.add(...this._CSS_CLASSES.WRAPPER.split(" ")); - - this._bindedHandlers = - { - _handleInput: this._handleInput.bind(this) - }; - - this._init(this._question, this._DOM); - } - - _handleInput (e) - { - const valueCoordinates = e.currentTarget.name.split("-"); - const row = valueCoordinates[0]; - const col = parseInt(e.currentTarget.dataset.column, 10); - const colRadioDOMS = this._DOM.querySelectorAll(`input[data-column="${col}"]`); - - if (this._question.value === undefined) - { - this._question.value = {}; - } - - const oldVal = this._question.value; - const newVal = {[row]: col}; - - // Handle case when exclusiveAnswer option is false? - let inputRow; - let i; - for (i = 0; i < colRadioDOMS.length; i++) - { - if (colRadioDOMS[i] !== e.currentTarget) - { - colRadioDOMS[i].checked = false; - inputRow = colRadioDOMS[i].name; - // Preserving previously ticked columns within other rows - if (oldVal[inputRow] !== undefined && oldVal[inputRow] !== col) - { - newVal[inputRow] = oldVal[inputRow]; - } - } - } - - this._question.value = newVal; - console.log(row, col, this._question.value); - } - - _init (question, el) - { - let t = performance.now(); - const CSS_CLASSES = this._CSS_CLASSES; - if (question.css.matrix.mainRoot) - { - // Replacing default mainRoot class with those used in matrix type questions, to achieve proper styling and overflow behavior - const rootClass = `${question.css.matrix.mainRoot} ${question.cssClasses.withFrame || ""}`; - question.setCssRoot(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. - // This is correct according current Qualtrics design for MaxDiff matrices. - // Header generation - headerCells = - `${question.columns[0].text} - - - - ${question.columns[1].text}`; - - // Body generation - for (i = 0; i < question.rows.length; i++) - { - bodyCells = - ` - - - - ${question.rows[i].text} - - - - `; - bodyHTML += `${bodyCells}`; - } - - html = ` - - ${headerCells} - - ${bodyHTML} -
`; - - console.log("maxdiff matrix generation took", performance.now() - t); - el.insertAdjacentHTML("beforeend", html); - - let inputDOMS = el.querySelectorAll("input"); - - for (i = 0; i < inputDOMS.length; i++) - { - inputDOMS[i].addEventListener("input", this._bindedHandlers._handleInput); - } - } -} - -export default function init (Survey) { - var widget = { - //the widget name. It should be unique and written in lowcase. - name: "maxdiffmatrix", - - //the widget title. It is how it will appear on the toolbox of the SurveyJS Editor/Builder - title: "MaxDiff matrix", - - //the name of the icon on the toolbox. We will leave it empty to use the standard one - iconName: "", - - //If the widgets depends on third-party library(s) then here you may check if this library(s) is loaded - widgetIsLoaded: function () { - //return typeof $ == "function" && !!$.fn.select2; //return true if jQuery and select2 widget are loaded on the page - return true; //we do not require anything so we just return true. - }, - - //SurveyJS library calls this function for every question to check, if it should use this widget instead of default rendering/behavior - isFit: function (question) { - //we return true if the type of question is maxdiffmatrix - return question.getType() === 'maxdiffmatrix'; - //the following code will activate the widget for a text question with inputType equals to date - //return question.getType() === 'text' && question.inputType === "date"; - }, - - //Use this function to create a new class or add new properties or remove unneeded properties from your widget - //activatedBy tells how your widget has been activated by: property, type or customType - //property - it means that it will activated if a property of the existing question type is set to particular value, for example inputType = "date" - //type - you are changing the behaviour of entire question type. For example render radiogroup question differently, have a fancy radio buttons - //customType - you are creating a new type, like in our example "maxdiffmatrix" - activatedByChanged: function (activatedBy) { - //we do not need to check acticatedBy parameter, since we will use our widget for customType only - //We are creating a new class and derived it from text question type. It means that text model (properties and fuctions) will be available to us - Survey.JsonObject.metaData.addClass("maxdiffmatrix", [], null, "text"); - //signaturepad is derived from "empty" class - basic question class - //Survey.JsonObject.metaData.addClass("signaturepad", [], null, "empty"); - - //Add new property(s) - //For more information go to https://surveyjs.io/Examples/Builder/?id=addproperties#content-docs - Survey.JsonObject.metaData.addProperties("maxdiffmatrix", [ - { - name: "rows", - default: [] - }, - { - name: "columns", - default: [] - } - ]); - }, - - //If you want to use the default question rendering then set this property to true. We do not need any default rendering, we will use our our htmlTemplate - isDefaultRender: false, - - //You should use it if your set the isDefaultRender to false - htmlTemplate: "
", - - //The main function, rendering and two-way binding - afterRender: function (question, el) { - console.log("MaxDiff mat", question.rows, question.columns); - new MaxDiffMatrix({ question, el }); - - // let containers = el.querySelectorAll(".srv-slider-container"); - // let inputDOMS = el.querySelectorAll(".srv-slider"); - // let sliderDisplayDOMS = el.querySelectorAll(".srv-slider-display"); - // if (!(question.value instanceof Array)) - // { - // question.value = new Array(inputDOMS.length); - // question.value.fill(0); - // } - - // for (i = 0; i < inputDOMS.length; i++) - // { - // inputDOMS[i].min = question.minVal; - // inputDOMS[i].max = question.maxVal; - // inputDOMS[i].addEventListener("input", (e) => { - // let idx = parseInt(e.currentTarget.dataset.idx, 10); - // question.value[idx] = parseFloat(e.currentTarget.value); - // // using .value setter to trigger update properly. - // // otherwise on survey competion it returns array of nulls. - // question.value = question.value; - // onValueChangedCallback(); - // }); - - // // Handle grid lines? - // } - - - // function positionSliderDisplay (v, min, max, displayDOM) - // { - // v = parseFloat(v); - // min = parseFloat(min); - // max = parseFloat(max); - // // Formula is (halfThumbWidth - v * (fullThumbWidth / 100)), taking into account that display has translate(-50%, 0). - // // Size of thumb is set in CSS. - // displayDOM.style.left = `calc(${(v - min) / (max - min) * 100}% + ${10 - v * 0.2}px)` - // } - - - // var onValueChangedCallback = function () { - // let i; - // let v; - // for (i = 0; i < question.choices.length; i++) - // { - // v = question.value[i] || 0; - // inputDOMS[i].value = v; - // sliderDisplayDOMS[i].innerText = v; - // positionSliderDisplay(v, question.minVal, question.maxVal, sliderDisplayDOMS[i]); - // } - // } - - // var onReadOnlyChangedCallback = function() { - // let i; - // if (question.isReadOnly) { - // for (i = 0; i < question.choices.length; i++) - // { - // inputDOMS[i].setAttribute('disabled', 'disabled'); - // } - // } else { - // for (i = 0; i < question.choices.length; i++) - // { - // inputDOMS[i].removeAttribute("disabled"); - // } - // } - // }; - - // if question becomes readonly/enabled add/remove disabled attribute - // question.readOnlyChangedCallback = onReadOnlyChangedCallback; - - // if the question value changed in the code, for example you have changed it in JavaScript - // question.valueChangedCallback = onValueChangedCallback; - - // set initial value - // onValueChangedCallback(); - - // make elements disabled if needed - // onReadOnlyChangedCallback(); - }, - - //Use it to destroy the widget. It is typically needed by jQuery widgets - willUnmount: function (question, el) { - //We do not need to clear anything in our simple example - //Here is the example to destroy the image picker - //var $el = $(el).find("select"); - //$el.data('picker').destroy(); - } - } - - //Register our widget in singleton custom widget collection - Survey.CustomWidgetCollection.Instance.addCustomWidget(widget, "customtype"); -} diff --git a/src/visual/survey/SelectBox.js b/src/visual/survey/SelectBox.js deleted file mode 100644 index 1402298..0000000 --- a/src/visual/survey/SelectBox.js +++ /dev/null @@ -1,119 +0,0 @@ -/** -* @desc SelectBox widget for surveyJS. -* @type: SurveyJS widget. -*/ - -export default function init (Survey) { - var widget = { - //the widget name. It should be unique and written in lowcase. - name: "selectbox", - - //the widget title. It is how it will appear on the toolbox of the SurveyJS Editor/Builder - title: "My custom widg", - - //the name of the icon on the toolbox. We will leave it empty to use the standard one - iconName: "", - - //If the widgets depends on third-party library(s) then here you may check if this library(s) is loaded - widgetIsLoaded: function () { - //return typeof $ == "function" && !!$.fn.select2; //return true if jQuery and select2 widget are loaded on the page - return true; //we do not require anything so we just return true. - }, - - //SurveyJS library calls this function for every question to check, if it should use this widget instead of default rendering/behavior - isFit: function (question) { - //we return true if the type of question is selectbox - return question.getType() === 'selectbox'; - //the following code will activate the widget for a text question with inputType equals to date - //return question.getType() === 'text' && question.inputType === "date"; - }, - - //Use this function to create a new class or add new properties or remove unneeded properties from your widget - //activatedBy tells how your widget has been activated by: property, type or customType - //property - it means that it will activated if a property of the existing question type is set to particular value, for example inputType = "date" - //type - you are changing the behaviour of entire question type. For example render radiogroup question differently, have a fancy radio buttons - //customType - you are creating a new type, like in our example "selectbox" - activatedByChanged: function (activatedBy) { - //we do not need to check acticatedBy parameter, since we will use our widget for customType only - //We are creating a new class and derived it from text question type. It means that text model (properties and fuctions) will be available to us - Survey.JsonObject.metaData.addClass("selectbox", [], null, "text"); - //signaturepad is derived from "empty" class - basic question class - //Survey.JsonObject.metaData.addClass("signaturepad", [], null, "empty"); - - //Add new property(s) - //For more information go to https://surveyjs.io/Examples/Builder/?id=addproperties#content-docs - Survey.JsonObject.metaData.addProperties("selectbox", [ - { - name: "choices", - default: [] - } - ]); - }, - - //If you want to use the default question rendering then set this property to true. We do not need any default rendering, we will use our our htmlTemplate - isDefaultRender: false, - - //You should use it if your set the isDefaultRender to false - htmlTemplate: `
`, - - //The main function, rendering and two-way binding - afterRender: function (question, el) { - let optionsHTML = ""; - let i; - for (i = 0; i < question.choices.length; i++) - { - optionsHTML += ``; - } - - let selectDOM = el.querySelector("select"); - selectDOM.innerHTML = optionsHTML; - - selectDOM.addEventListener('input', (e) => { - let i; - let opts = new Array(e.currentTarget.selectedOptions.length); - for (i = 0; i < e.currentTarget.selectedOptions.length; i++) - { - opts[i] = e.currentTarget.selectedOptions[i].value; - } - question.value = opts; - }); - - // var onValueChangedCallback = function () { - // text.value = question.value ? question.value : ""; - // } - - // var onReadOnlyChangedCallback = function() { - // if (question.isReadOnly) { - // text.setAttribute('disabled', 'disabled'); - // button.setAttribute('disabled', 'disabled'); - // } else { - // text.removeAttribute("disabled"); - // button.removeAttribute("disabled"); - // } - // }; - - //if question becomes readonly/enabled add/remove disabled attribute - // question.readOnlyChangedCallback = onReadOnlyChangedCallback; - - //if the question value changed in the code, for example you have changed it in JavaScript - // question.valueChangedCallback = onValueChangedCallback; - - //set initial value - // onValueChangedCallback(); - - //make elements disabled if needed - // onReadOnlyChangedCallback(); - }, - - //Use it to destroy the widget. It is typically needed by jQuery widgets - willUnmount: function (question, el) { - //We do not need to clear anything in our simple example - //Here is the example to destroy the image picker - //var $el = $(el).find("select"); - //$el.data('picker').destroy(); - } - } - - //Register our widget in singleton custom widget collection - Survey.CustomWidgetCollection.Instance.addCustomWidget(widget, "customtype"); -} diff --git a/src/visual/survey/SideBySideMatrix.js b/src/visual/survey/SideBySideMatrix.js deleted file mode 100644 index eb822fa..0000000 --- a/src/visual/survey/SideBySideMatrix.js +++ /dev/null @@ -1,424 +0,0 @@ -/** -* @desc Side By Side matrix. -* */ - -const CELL_TYPES = { - DROP_DOWN: "dropdown", - RADIO: "radio", - CHECKBOX: "checkbox", - TEXT: "text" -}; - -class SideBySideMatrix -{ - constructor (cfg = {}) - { - // surveyCSS contains css class names provided by the applied theme - // INCLUDING those added/modified by application's code. - const surveyCSS = cfg.question.css; - this._CSS_CLASSES = { - WRAPPER: surveyCSS.matrix.tableWrapper, - TABLE: surveyCSS.matrix.root, - TABLE_ROW: surveyCSS.matrixdropdown.row, - TABLE_HEADER_CELL: surveyCSS.matrix.headerCell, - TABLE_CELL: surveyCSS.matrix.cell, - INPUT_TEXT: surveyCSS.text.root, - LABEL: surveyCSS.matrix.label, - ITEM_CHECKED: surveyCSS.matrix.itemChecked, - ITEM_VALUE: surveyCSS.matrix.itemValue, - ITEM_DECORATOR: surveyCSS.matrix.materialDecorator, - RADIO: surveyCSS.radiogroup.item, - SELECT: surveyCSS.dropdown.control, - CHECKBOX: surveyCSS.checkbox.item, - CHECKBOX_CONTROL: surveyCSS.checkbox.itemControl, - CHECKBOX_DECORATOR: surveyCSS.checkbox.materialDecorator, - CHECKBOX_DECORATOR_SVG: surveyCSS.checkbox.itemDecorator - }; - this._question = cfg.question; - this._DOM = cfg.el; - this._DOM.classList.add(...this._CSS_CLASSES.WRAPPER.split(" ")); - - this._bindedHandlers = { - _handleInput: this._handleInput.bind(this), - _handleSelectChange: this._handleSelectChange.bind(this) - }; - - this._init(this._question, this._DOM); - } - - static CELL_GENERATORS = - { - [CELL_TYPES.DROP_DOWN]: "_generateDropdownCells", - [CELL_TYPES.RADIO]: "_generateRadioCells", - [CELL_TYPES.CHECKBOX]: "_generateCheckboxCells", - [CELL_TYPES.TEXT]: "_generateTextInputCells", - }; - - _generateDropdownCells (row, col, subColumns, CSS_CLASSES) - { - let bodyCells = ""; - let selectOptions = ""; - let i; - for (i = 0; i < subColumns.length; i++) - { - selectOptions += ``; - } - bodyCells = - ` - - `; - return bodyCells; - } - - _generateRadioCells (row, col, subColumns, CSS_CLASSES) - { - let bodyCells = ""; - let i; - for (i = 0; i < subColumns.length; i++) - { - bodyCells += - ` - - `; - } - return bodyCells; - } - - _generateCheckboxCells (row, col, subColumns, CSS_CLASSES) - { - let bodyCells = ""; - let i; - for (i = 0; i < subColumns.length; i++) - { - bodyCells += - ` - - `; - } - return bodyCells; - } - - _generateTextInputCells (row, col, subColumns, CSS_CLASSES) - { - let bodyCells = ""; - let i; - for (i = 0; i < subColumns.length; i++) - { - bodyCells += - ` - - `; - } - return bodyCells; - } - - _ensureQuestionValueFields (row, col) - { - if (this._question.value === undefined) - { - this._question.value = {}; - } - - if (this._question.value[row] === undefined) - { - this._question.value[row] = { - [col]: {} - } - } - - if (this._question.value[row][col] === undefined) - { - this._question.value[row][col] = {}; - } - } - - _handleInput (e) - { - const valueCoordinates = e.currentTarget.name.split("-"); - const row = valueCoordinates[0]; - const col = valueCoordinates[1]; - const subCol = valueCoordinates[2] !== undefined ? valueCoordinates[2] : e.currentTarget.value; - this._ensureQuestionValueFields(row, col); - - if (e.currentTarget.type === "text") - { - this._question.value[row][col][subCol] = e.currentTarget.value; - } - else if (e.currentTarget.type === "radio") - { - this._question.value[row][col] = e.currentTarget.value; - } - else if (e.currentTarget.type === "checkbox") - { - this._question.value[row][col][subCol] = e.currentTarget.checked; - } - - // Triggering internal SurveyJS mechanism for value update. - this._question.value = this._question.value; - } - - _handleSelectChange (e) - { - const valueCoordinates = e.currentTarget.name.split("-"); - const row = valueCoordinates[0]; - const col = valueCoordinates[1]; - this._ensureQuestionValueFields(row, col); - this._question.value[row][col]= e.currentTarget.value; - // Triggering internal SurveyJS mechanism for value update. - this._question.value = this._question.value; - } - - _init (question, el) - { - let t = performance.now(); - const CSS_CLASSES = this._CSS_CLASSES; - // TODO: Find out how it actually composed inside SurveyJS. - if (question.css.matrix.mainRoot) - { - // Replacing default mainRoot class with those used in matrix type questions, to achieve proper styling and overflow behavior - const rootClass = `${question.css.matrix.mainRoot} ${question.cssClasses.withFrame || ""}`; - question.setCssRoot(rootClass); - question.cssClasses.mainRoot = rootClass; - } - let html; - let headerCells = ""; - let subHeaderCells = ""; - let bodyCells = ""; - let bodyHTML = ""; - let cellGenerator; - let i, j; - - // Header generation - for (i = 0; i < question.columns.length; i++) - { - if (question.columns[i].cellType !== CELL_TYPES.DROP_DOWN) - { - headerCells += - ` - ${question.columns[i].title} - `; - for (j = 0; j < question.columns[i].subColumns.length; j++) - { - subHeaderCells += `${question.columns[i].subColumns[j].text}`; - } - } - else - { - headerCells += - ` - ${question.columns[i].title} - `; - subHeaderCells += ""; - } - headerCells += ""; - subHeaderCells += ""; - } - - // Body generation - for (i = 0; i < question.rows.length; i++) - { - bodyCells = ""; - for (j = 0; j < question.columns.length; j++) - { - cellGenerator = this[SideBySideMatrix.CELL_GENERATORS[question.columns[j].cellType]]; - if (typeof cellGenerator === "function") - { - // Passing rows, columns, subColumns as separate arguments - // to make generatorrs independent from table data-structure. - bodyCells += `${cellGenerator.call(this, question.rows[i], question.columns[j], question.columns[j].subColumns, CSS_CLASSES)}`; - } - else - { - console.log("No cell generator found for cellType", question.columns[j].cellType); - } - } - bodyHTML += `${question.rows[i].text}${bodyCells}`; - } - - html = ` - - ${headerCells} - ${subHeaderCells} - - ${bodyHTML} -
`; - - // console.log("sbs matrix generation took", performance.now() - t); - el.insertAdjacentHTML("beforeend", html); - - let inputDOMS = el.querySelectorAll("input"); - let selectDOMS = el.querySelectorAll("select"); - - for (i = 0; i < inputDOMS.length; i++) - { - inputDOMS[i].addEventListener("input", this._bindedHandlers._handleInput); - } - - for (i = 0; i < selectDOMS.length; i++) - { - selectDOMS[i].addEventListener("change", this._bindedHandlers._handleSelectChange) - } - } -} - -export default function init (Survey) { - var widget = { - //the widget name. It should be unique and written in lowcase. - name: "sidebysidematrix", - - //the widget title. It is how it will appear on the toolbox of the SurveyJS Editor/Builder - title: "Side by side matrix", - - //the name of the icon on the toolbox. We will leave it empty to use the standard one - iconName: "", - - //If the widgets depends on third-party library(s) then here you may check if this library(s) is loaded - widgetIsLoaded: function () { - //return typeof $ == "function" && !!$.fn.select2; //return true if jQuery and select2 widget are loaded on the page - return true; //we do not require anything so we just return true. - }, - - //SurveyJS library calls this function for every question to check, if it should use this widget instead of default rendering/behavior - isFit: function (question) { - //we return true if the type of question is sidebysidematrix - return question.getType() === 'sidebysidematrix'; - //the following code will activate the widget for a text question with inputType equals to date - //return question.getType() === 'text' && question.inputType === "date"; - }, - - //Use this function to create a new class or add new properties or remove unneeded properties from your widget - //activatedBy tells how your widget has been activated by: property, type or customType - //property - it means that it will activated if a property of the existing question type is set to particular value, for example inputType = "date" - //type - you are changing the behaviour of entire question type. For example render radiogroup question differently, have a fancy radio buttons - //customType - you are creating a new type, like in our example "sidebysidematrix" - activatedByChanged: function (activatedBy) { - //we do not need to check acticatedBy parameter, since we will use our widget for customType only - //We are creating a new class and derived it from text question type. It means that text model (properties and fuctions) will be available to us - Survey.JsonObject.metaData.addClass("sidebysidematrix", [], null, "text"); - //signaturepad is derived from "empty" class - basic question class - //Survey.JsonObject.metaData.addClass("signaturepad", [], null, "empty"); - - //Add new property(s) - //For more information go to https://surveyjs.io/Examples/Builder/?id=addproperties#content-docs - Survey.JsonObject.metaData.addProperties("sidebysidematrix", [ - { - name: "rows", - default: [] - }, - { - name: "columns", - default: [] - } - ]); - }, - - //If you want to use the default question rendering then set this property to true. We do not need any default rendering, we will use our our htmlTemplate - isDefaultRender: false, - - //You should use it if your set the isDefaultRender to false - htmlTemplate: "
", - - //The main function, rendering and two-way binding - afterRender: function (question, el) { - new SideBySideMatrix({ question, el }); - // TODO: add readonly and enabled/disabled handlers. - - // let containers = el.querySelectorAll(".srv-slider-container"); - // let inputDOMS = el.querySelectorAll(".srv-slider"); - // let sliderDisplayDOMS = el.querySelectorAll(".srv-slider-display"); - // if (!(question.value instanceof Array)) - // { - // question.value = new Array(inputDOMS.length); - // question.value.fill(0); - // } - - // for (i = 0; i < inputDOMS.length; i++) - // { - // inputDOMS[i].min = question.minVal; - // inputDOMS[i].max = question.maxVal; - // inputDOMS[i].addEventListener("input", (e) => { - // let idx = parseInt(e.currentTarget.dataset.idx, 10); - // question.value[idx] = parseFloat(e.currentTarget.value); - // // using .value setter to trigger update properly. - // // otherwise on survey competion it returns array of nulls. - // question.value = question.value; - // onValueChangedCallback(); - // }); - - // // Handle grid lines? - // } - - - // function positionSliderDisplay (v, min, max, displayDOM) - // { - // v = parseFloat(v); - // min = parseFloat(min); - // max = parseFloat(max); - // // Formula is (halfThumbWidth - v * (fullThumbWidth / 100)), taking into account that display has translate(-50%, 0). - // // Size of thumb is set in CSS. - // displayDOM.style.left = `calc(${(v - min) / (max - min) * 100}% + ${10 - v * 0.2}px)` - // } - - - // var onValueChangedCallback = function () { - // let i; - // let v; - // for (i = 0; i < question.choices.length; i++) - // { - // v = question.value[i] || 0; - // inputDOMS[i].value = v; - // sliderDisplayDOMS[i].innerText = v; - // positionSliderDisplay(v, question.minVal, question.maxVal, sliderDisplayDOMS[i]); - // } - // } - - // var onReadOnlyChangedCallback = function() { - // let i; - // if (question.isReadOnly) { - // for (i = 0; i < question.choices.length; i++) - // { - // inputDOMS[i].setAttribute('disabled', 'disabled'); - // } - // } else { - // for (i = 0; i < question.choices.length; i++) - // { - // inputDOMS[i].removeAttribute("disabled"); - // } - // } - // }; - - // if question becomes readonly/enabled add/remove disabled attribute - // question.readOnlyChangedCallback = onReadOnlyChangedCallback; - - // if the question value changed in the code, for example you have changed it in JavaScript - // question.valueChangedCallback = onValueChangedCallback; - - // set initial value - // onValueChangedCallback(); - - // make elements disabled if needed - // onReadOnlyChangedCallback(); - }, - - //Use it to destroy the widget. It is typically needed by jQuery widgets - willUnmount: function (question, el) { - //We do not need to clear anything in our simple example - //Here is the example to destroy the image picker - //var $el = $(el).find("select"); - //$el.data('picker').destroy(); - } - } - - //Register our widget in singleton custom widget collection - Survey.CustomWidgetCollection.Instance.addCustomWidget(widget, "customtype"); -} diff --git a/src/visual/survey/SliderStar.js b/src/visual/survey/SliderStar.js deleted file mode 100644 index 1ff1014..0000000 --- a/src/visual/survey/SliderStar.js +++ /dev/null @@ -1,289 +0,0 @@ -/** -* @desc Slider Star. -* */ - -class SliderStar -{ - constructor (cfg = {}) - { - const surveyCSS = cfg.question.css; - this._CSS_CLASSES = { - // INPUT_TEXT: `${surveyCSS.text.root} slider-star-text-input` - INPUT_TEXT: `slider-star-text-input` - }; - this._question = cfg.question; - this._DOM = cfg.el; - this._engagedInputIdx = undefined; - this._pdowns = {}; - - this._bindedHandlers = - { - _handleInput: this._handleInput.bind(this), - _handlePointerDown: this._handlePointerDown.bind(this), - _handlePointerUp: this._handlePointerUp.bind(this), - _handlePointerMove: this._handlePointerMove.bind(this) - }; - - this._init(this._question, this._DOM); - } - - _markStarsActive (n, qIdx) - { - let stars = this._DOM.querySelectorAll(`.stars-container[data-idx="${qIdx}"] .star-slider-star-input`); - let i; - for (i = 0; i < stars.length; i++) - { - stars[i].classList.remove("active"); - if (i <= n - 1) - { - stars[i].classList.add("active"); - } - } - } - - _handleIndividualValueUpdate (v, qIdx) - { - if (this._question.value === undefined) - { - this._question.value = {}; - } - if (this._question.value[qIdx] !== v) - { - this._question.value[qIdx] = v; - this._DOM.querySelector(`.slider-star-text-input[name="${qIdx}"]`).value = v; - this._markStarsActive(v, qIdx); - // Triggering internal SurveyJS mechanism for value update. - this._question.value = this._question.value; - } - } - - _handleInput (e) - { - let v = parseInt(e.currentTarget.value, 10) || 0; - v = Math.max(0, Math.min(this._question.starCount, v)); - const qIdx = e.currentTarget.name; - this._handleIndividualValueUpdate(v, qIdx); - } - - _handlePointerDown (e) - { - e.preventDefault(); - this._engagedInputIdx = e.currentTarget.dataset.idx; - this._pdowns[this._engagedInputIdx] = true; - const starIdx = [].indexOf.call(e.target.parentElement.children, e.target); - this._handleIndividualValueUpdate(starIdx + 1, this._engagedInputIdx); - } - - _handlePointerUp (e) - { - if (this._engagedInputIdx !== undefined) - { - this._pdowns[this._engagedInputIdx] = false; - } - this._engagedInputIdx = undefined; - } - - _handlePointerMove (e) - { - if (this._pdowns[this._engagedInputIdx]) - { - e.preventDefault(); - const starIdx = [].indexOf.call(e.target.parentElement.children, e.target); - this._handleIndividualValueUpdate(starIdx + 1, this._engagedInputIdx); - } - } - - _init (question, el) - { - let t = performance.now(); - let starsHTML = new Array(question.starCount).fill(`
★
`).join(""); - let html = ""; - let i; - for (i = 0; i < question.choices.length; i++) - { - html += - `
-
${question.choices[i].text}
-
-
${starsHTML}
- ${question.showValue ? - `` : - ""} -
-
`; - } - - el.insertAdjacentHTML("beforeend", html); - const inputDOMS = el.querySelectorAll(".slider-star-text-input"); - const starsContainers = el.querySelectorAll(".stars-container"); - - // Amount of inputDOMS and starsCointainer is the same. - // Also iterating over starContainers since text inputs might be absent. - for (i = 0; i < starsContainers.length; i++) - { - inputDOMS[i].addEventListener("input", this._bindedHandlers._handleInput); - starsContainers[i].addEventListener("pointerdown", this._bindedHandlers._handlePointerDown); - starsContainers[i].addEventListener("pointermove", this._bindedHandlers._handlePointerMove); - } - window.addEventListener("pointerup", this._bindedHandlers._handlePointerUp); - } -} - -export default function init (Survey) { - var widget = { - //the widget name. It should be unique and written in lowcase. - name: "sliderstar", - - //the widget title. It is how it will appear on the toolbox of the SurveyJS Editor/Builder - title: "Slider Star", - - //the name of the icon on the toolbox. We will leave it empty to use the standard one - iconName: "", - - //If the widgets depends on third-party library(s) then here you may check if this library(s) is loaded - widgetIsLoaded: function () { - //return typeof $ == "function" && !!$.fn.select2; //return true if jQuery and select2 widget are loaded on the page - return true; //we do not require anything so we just return true. - }, - - //SurveyJS library calls this function for every question to check, if it should use this widget instead of default rendering/behavior - isFit: function (question) { - //we return true if the type of question is sliderstar - return question.getType() === 'sliderstar'; - //the following code will activate the widget for a text question with inputType equals to date - //return question.getType() === 'text' && question.inputType === "date"; - }, - - //Use this function to create a new class or add new properties or remove unneeded properties from your widget - //activatedBy tells how your widget has been activated by: property, type or customType - //property - it means that it will activated if a property of the existing question type is set to particular value, for example inputType = "date" - //type - you are changing the behaviour of entire question type. For example render radiogroup question differently, have a fancy radio buttons - //customType - you are creating a new type, like in our example "sliderstar" - activatedByChanged: function (activatedBy) { - //we do not need to check acticatedBy parameter, since we will use our widget for customType only - //We are creating a new class and derived it from text question type. It means that text model (properties and fuctions) will be available to us - Survey.JsonObject.metaData.addClass("sliderstar", [], null, "text"); - //signaturepad is derived from "empty" class - basic question class - //Survey.JsonObject.metaData.addClass("signaturepad", [], null, "empty"); - - //Add new property(s) - //For more information go to https://surveyjs.io/Examples/Builder/?id=addproperties#content-docs - Survey.JsonObject.metaData.addProperties("sliderstar", [ - { - name: "choices", - default: [] - }, - { - name: "starCount", - default: 5 - }, - { - name: "showValue", - default: true - }, - { - name: "starType", - default: "descrete" - } - ]); - }, - - //If you want to use the default question rendering then set this property to true. We do not need any default rendering, we will use our our htmlTemplate - isDefaultRender: false, - - //You should use it if your set the isDefaultRender to false - htmlTemplate: "
", - - //The main function, rendering and two-way binding - afterRender: function (question, el) { - new SliderStar({ question, el }); - - // let containers = el.querySelectorAll(".srv-slider-container"); - // let inputDOMS = el.querySelectorAll(".srv-slider"); - // let sliderDisplayDOMS = el.querySelectorAll(".srv-slider-display"); - // if (!(question.value instanceof Array)) - // { - // question.value = new Array(inputDOMS.length); - // question.value.fill(0); - // } - - // for (i = 0; i < inputDOMS.length; i++) - // { - // inputDOMS[i].min = question.minVal; - // inputDOMS[i].max = question.maxVal; - // inputDOMS[i].addEventListener("input", (e) => { - // let idx = parseInt(e.currentTarget.dataset.idx, 10); - // question.value[idx] = parseFloat(e.currentTarget.value); - // // using .value setter to trigger update properly. - // // otherwise on survey competion it returns array of nulls. - // question.value = question.value; - // onValueChangedCallback(); - // }); - - // // Handle grid lines? - // } - - - // function positionSliderDisplay (v, min, max, displayDOM) - // { - // v = parseFloat(v); - // min = parseFloat(min); - // max = parseFloat(max); - // // Formula is (halfThumbWidth - v * (fullThumbWidth / 100)), taking into account that display has translate(-50%, 0). - // // Size of thumb is set in CSS. - // displayDOM.style.left = `calc(${(v - min) / (max - min) * 100}% + ${10 - v * 0.2}px)` - // } - - - // var onValueChangedCallback = function () { - // let i; - // let v; - // for (i = 0; i < question.choices.length; i++) - // { - // v = question.value[i] || 0; - // inputDOMS[i].value = v; - // sliderDisplayDOMS[i].innerText = v; - // positionSliderDisplay(v, question.minVal, question.maxVal, sliderDisplayDOMS[i]); - // } - // } - - // var onReadOnlyChangedCallback = function() { - // let i; - // if (question.isReadOnly) { - // for (i = 0; i < question.choices.length; i++) - // { - // inputDOMS[i].setAttribute('disabled', 'disabled'); - // } - // } else { - // for (i = 0; i < question.choices.length; i++) - // { - // inputDOMS[i].removeAttribute("disabled"); - // } - // } - // }; - - // if question becomes readonly/enabled add/remove disabled attribute - // question.readOnlyChangedCallback = onReadOnlyChangedCallback; - - // if the question value changed in the code, for example you have changed it in JavaScript - // question.valueChangedCallback = onValueChangedCallback; - - // set initial value - // onValueChangedCallback(); - - // make elements disabled if needed - // onReadOnlyChangedCallback(); - }, - - //Use it to destroy the widget. It is typically needed by jQuery widgets - willUnmount: function (question, el) { - //We do not need to clear anything in our simple example - //Here is the example to destroy the image picker - //var $el = $(el).find("select"); - //$el.data('picker').destroy(); - } - } - - //Register our widget in singleton custom widget collection - Survey.CustomWidgetCollection.Instance.addCustomWidget(widget, "customtype"); -} diff --git a/src/visual/survey/components/MatrixBipolar.js b/src/visual/survey/components/MatrixBipolar.js index 2b956b2..638ba09 100644 --- a/src/visual/survey/components/MatrixBipolar.js +++ b/src/visual/survey/components/MatrixBipolar.js @@ -22,7 +22,8 @@ function handleBipolarMatrixRendering (survey, options) let rowsDOM = options.htmlElement.querySelectorAll("tbody tr"); // let rowCaptionsDOM = options.htmlElement.querySelectorAll("tbody tr td:nth-child(1) .sv-string-viewer"); let rowCaptionsDOM = options.htmlElement.querySelectorAll("tbody tr td:nth-child(1) span"); - let captionsClassList = rowCaptionsDOM[0].classList.toString(); + let captionsClassList = rowCaptionsDOM[0].classList; + let cellClassList = rowsDOM[0].children[0].classList; let rowCaptions = new Array(options.question.rows.length); let rowCaptionOppositeHTML = ""; let i; @@ -30,7 +31,7 @@ function handleBipolarMatrixRendering (survey, options) { rowCaptions[i] = options.question.rows[i].text.split(":"); rowCaptionsDOM[i].innerText = rowCaptions[i][0]; - rowCaptionOppositeHTML = `${rowCaptions[i][1]}`; + rowCaptionOppositeHTML = `${rowCaptions[i][1]}`; rowsDOM[i].insertAdjacentHTML("beforeend", rowCaptionOppositeHTML); } } @@ -38,7 +39,7 @@ function handleBipolarMatrixRendering (survey, options) export default { registerSurveyProperties (Survey) { - Survey.Serializer.addProperty("question", + Survey.Serializer.addProperty("matrix", { name: "subType:text", default: "", diff --git a/src/visual/survey/widgets/MaxDiffMatrix.js b/src/visual/survey/widgets/MaxDiffMatrix.js index 8a9a8ec..d9958c5 100644 --- a/src/visual/survey/widgets/MaxDiffMatrix.js +++ b/src/visual/survey/widgets/MaxDiffMatrix.js @@ -16,6 +16,10 @@ class MaxDiffMatrix TABLE_HEADER_CELL: surveyCSS.matrix.headerCell, TABLE_CELL: surveyCSS.matrix.cell, INPUT_TEXT: surveyCSS.text.root, + LABEL: surveyCSS.matrix.label, + ITEM_CHECKED: surveyCSS.matrix.itemChecked, + ITEM_VALUE: surveyCSS.matrix.itemValue, + ITEM_DECORATOR: surveyCSS.matrix.materialDecorator, RADIO: surveyCSS.radiogroup.item, SELECT: surveyCSS.dropdown.control, CHECKBOX: surveyCSS.checkbox.item @@ -84,6 +88,13 @@ class MaxDiffMatrix { let t = performance.now(); const CSS_CLASSES = this._CSS_CLASSES; + if (question.css.matrix.mainRoot) + { + // Replacing default mainRoot class with those used in matrix type questions, to achieve proper styling and overflow behavior + const rootClass = `${question.css.matrix.mainRoot} ${question.cssClasses.withFrame || ""}`; + question.setCssRoot(rootClass); + question.cssClasses.mainRoot = rootClass; + } let html; let headerCells = ""; let subHeaderCells = ""; @@ -106,11 +117,21 @@ class MaxDiffMatrix for (i = 0; i < question.rows.length; i++) { bodyCells = - ` + ` + + ${question.rows[i].text} - `; + + + `; bodyHTML += `${bodyCells}`; } @@ -175,10 +196,12 @@ export default function init (Survey) { Survey.JsonObject.metaData.addProperties("maxdiffmatrix", [ { name: "rows", + isArray: true, default: [] }, { name: "columns", + isArray: true, default: [] } ]); diff --git a/src/visual/survey/widgets/SelectBox.js b/src/visual/survey/widgets/SelectBox.js index d57eeb0..18c2bec 100644 --- a/src/visual/survey/widgets/SelectBox.js +++ b/src/visual/survey/widgets/SelectBox.js @@ -45,7 +45,12 @@ export default function init (Survey) { Survey.JsonObject.metaData.addProperties("selectbox", [ { name: "choices", + isArray: true, default: [] + }, + { + name: "multipleAnswer", + default: true } ]); }, @@ -54,7 +59,7 @@ export default function init (Survey) { isDefaultRender: false, //You should use it if your set the isDefaultRender to false - htmlTemplate: "
", + htmlTemplate: `
`, //The main function, rendering and two-way binding afterRender: function (question, el) { @@ -65,9 +70,20 @@ export default function init (Survey) { optionsHTML += ``; } - let selectDOM = el.querySelector("select"); - selectDOM.innerHTML = optionsHTML; + let additionalAttr = ""; + if (question.multipleAnswer) + { + additionalAttr = "multiple"; + } + else + { + additionalAttr = "size=\"4\""; + } + let selectHTML = ``; + el.insertAdjacentHTML("beforeend", selectHTML); + + let selectDOM = el.querySelector("select"); selectDOM.addEventListener('input', (e) => { let i; let opts = new Array(e.currentTarget.selectedOptions.length); diff --git a/src/visual/survey/widgets/SideBySideMatrix.js b/src/visual/survey/widgets/SideBySideMatrix.js index 6a8159a..c389c95 100644 --- a/src/visual/survey/widgets/SideBySideMatrix.js +++ b/src/visual/survey/widgets/SideBySideMatrix.js @@ -17,15 +17,22 @@ class SideBySideMatrix // INCLUDING those added/modified by application's code. const surveyCSS = cfg.question.css; this._CSS_CLASSES = { - WRAPPER: surveyCSS.matrix.tableWrapper, + WRAPPER: `${surveyCSS.matrix.tableWrapper} sbs-matrix`, TABLE: surveyCSS.matrix.root, TABLE_ROW: surveyCSS.matrixdropdown.row, TABLE_HEADER_CELL: surveyCSS.matrix.headerCell, TABLE_CELL: surveyCSS.matrix.cell, INPUT_TEXT: surveyCSS.text.root, + LABEL: surveyCSS.matrix.label, + ITEM_CHECKED: surveyCSS.matrix.itemChecked, + ITEM_VALUE: surveyCSS.matrix.itemValue, + ITEM_DECORATOR: surveyCSS.matrix.materialDecorator, RADIO: surveyCSS.radiogroup.item, SELECT: surveyCSS.dropdown.control, - CHECKBOX: surveyCSS.checkbox.item + CHECKBOX: surveyCSS.checkbox.item, + CHECKBOX_CONTROL: surveyCSS.checkbox.itemControl, + CHECKBOX_DECORATOR: surveyCSS.checkbox.materialDecorator, + CHECKBOX_DECORATOR_SVG: surveyCSS.checkbox.itemDecorator }; this._question = cfg.question; this._DOM = cfg.el; @@ -71,7 +78,10 @@ class SideBySideMatrix { bodyCells += ` - + `; } return bodyCells; @@ -85,7 +95,14 @@ class SideBySideMatrix { bodyCells += ` - + `; } return bodyCells; @@ -168,7 +185,10 @@ class SideBySideMatrix // TODO: Find out how it actually composed inside SurveyJS. if (question.css.matrix.mainRoot) { - question.setCssRoot(`${question.css.matrix.mainRoot} ${question.cssClasses.withFrame || ""}`); + // Replacing default mainRoot class with those used in matrix type questions, to achieve proper styling and overflow behavior + const rootClass = `${question.css.matrix.mainRoot} ${question.cssClasses.withFrame || ""}`; + question.setCssRoot(rootClass); + question.cssClasses.mainRoot = rootClass; } let html; let headerCells = ""; @@ -189,7 +209,10 @@ class SideBySideMatrix `; for (j = 0; j < question.columns[i].subColumns.length; j++) { - subHeaderCells += `${question.columns[i].subColumns[j].text}`; + subHeaderCells += ` + ${question.columns[i].subColumns[j].text} + `; } } else @@ -198,7 +221,7 @@ class SideBySideMatrix ` ${question.columns[i].title} `; - subHeaderCells += ""; + subHeaderCells += ``; } headerCells += ""; subHeaderCells += ""; @@ -227,8 +250,8 @@ class SideBySideMatrix html = ` - ${headerCells} - ${subHeaderCells} + ${headerCells} + ${subHeaderCells}${bodyHTML}
`; @@ -293,10 +316,12 @@ export default function init (Survey) { Survey.JsonObject.metaData.addProperties("sidebysidematrix", [ { name: "rows", + isArray: true, default: [] }, { name: "columns", + isArray: true, default: [] } ]); diff --git a/src/visual/survey/widgets/SliderStar.js b/src/visual/survey/widgets/SliderStar.js index d9311e9..8c6c223 100644 --- a/src/visual/survey/widgets/SliderStar.js +++ b/src/visual/survey/widgets/SliderStar.js @@ -6,6 +6,11 @@ class SliderStar { constructor (cfg = {}) { + const surveyCSS = cfg.question.css; + this._CSS_CLASSES = { + // INPUT_TEXT: `${surveyCSS.text.root} slider-star-text-input` + INPUT_TEXT: `slider-star-text-input` + }; this._question = cfg.question; this._DOM = cfg.el; this._engagedInputIdx = undefined; @@ -102,7 +107,7 @@ class SliderStar
${starsHTML}
${question.showValue ? - `` : + `` : ""}
`; @@ -166,6 +171,7 @@ export default function init (Survey) { Survey.JsonObject.metaData.addProperties("sliderstar", [ { name: "choices", + isArray: true, default: [] }, { diff --git a/src/visual/survey/widgets/SliderWidget.js b/src/visual/survey/widgets/SliderWidget.js index 33896d8..1d71359 100644 --- a/src/visual/survey/widgets/SliderWidget.js +++ b/src/visual/survey/widgets/SliderWidget.js @@ -44,6 +44,7 @@ export default function init (Survey) { Survey.JsonObject.metaData.addProperties("slider", [ { name: "choices", + isArray: true, default: [] }, { From 61a2f4286a4205ff1cb056311e6e37987542c2d3 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Thu, 2 Feb 2023 12:49:53 +0100 Subject: [PATCH 13/19] _ --- .../survey/components/DropdownExtensions.js | 48 ++++++++++ .../extensions/customExpressionFunctions.js | 89 +++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/visual/survey/components/DropdownExtensions.js create mode 100644 src/visual/survey/extensions/customExpressionFunctions.js diff --git a/src/visual/survey/components/DropdownExtensions.js b/src/visual/survey/components/DropdownExtensions.js new file mode 100644 index 0000000..a5f8704 --- /dev/null +++ b/src/visual/survey/components/DropdownExtensions.js @@ -0,0 +1,48 @@ +/** + * @desc: Extensions for default dropdown component of SurveyJS to make it more nice to interact with on mobile devices. + * @type: SurveyJS component modification. + */ + +function handleValueChange (survey, options, e) +{ + options.question.value = e.currentTarget.value; +} + +function handleValueChangeForDOM (survey, options) +{ + options.htmlElement.querySelector("select").value = options.question.value; +} + +function handleDropdownRendering (survey, options) +{ + // Default SurveyJS drop down is actually an with customly built options list + // It works well on desktop, but not that convenient on mobile. + // Adding native ${optionsHTML}`; + options.htmlElement.querySelector('.sd-selectbase').insertAdjacentHTML("beforebegin", selectHTML); + + const selectDOM = options.htmlElement.querySelector("select"); + selectDOM.addEventListener("change", handleValueChange.bind(this, survey, options)); + + options.question.valueChangedCallback = handleValueChangeForDOM.bind(this, survey, options); +} + +export default { + registerModelCallbacks (surveyModel) + { + surveyModel.onAfterRenderQuestion.add((survey, options) => { + if (options.question.getType() === "dropdown") + { + handleDropdownRendering(survey, options); + } + }); + } +}; diff --git a/src/visual/survey/extensions/customExpressionFunctions.js b/src/visual/survey/extensions/customExpressionFunctions.js new file mode 100644 index 0000000..43bae5f --- /dev/null +++ b/src/visual/survey/extensions/customExpressionFunctions.js @@ -0,0 +1,89 @@ +// Wrapping everything in Class and defining as static methods to prevent esbuild from renaming when bundling. +// NOTE! Survey stim uses property .name of these methods on registering stage. +// Methods are available inside SurveyJS expressions using their actual names. +class ExpressionFunctions { + static rnd () + { + return Math.random(); + } + + static arrayContains (params) + { + if (params[0] instanceof Array) + { + let searchValue = params[1]; + let searchResult = params[0].indexOf(searchValue) !== -1; + + // If no results at first, trying conversion combinations, since array of values sometimes might + // contain both string and number data types. + if (searchResult === false) + { + if (typeof searchValue === "string") + { + searchValue = parseFloat(searchValue); + searchResult = params[0].indexOf(searchValue) !== -1; + } + else if (typeof searchValue === "number") + { + searchValue = searchValue.toString(); + searchResult = params[0].indexOf(searchValue) !== -1; + } + } + + return searchResult + } + return false; + } + + static stringContains (params) + { + if (typeof params[0] === "string") + { + return params[0].indexOf(params[1]) !== -1; + } + return false; + } + + static isEmpty (params) + { + let questionIsEmpty = false; + if (params[0] instanceof Array || typeof params[0] === "string") + { + questionIsEmpty = params[0].length === 0; + } + else + { + questionIsEmpty = params[0] === undefined || params[0] === null; + } + return questionIsEmpty; + } + + static isNotEmpty (params) + { + return !ExpressionFunctions.isEmpty(params); + } +} + + +export default [ + { + func: ExpressionFunctions.rnd, + isAsync: false + }, + { + func: ExpressionFunctions.arrayContains, + isAsync: false + }, + { + func: ExpressionFunctions.stringContains, + isAsync: false + }, + { + func: ExpressionFunctions.isEmpty, + isAsync: false + }, + { + func: ExpressionFunctions.isNotEmpty, + isAsync: false + } +]; From 0c578e26d0ca826e7b77c64f6e0b4e0650fe6dc0 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Thu, 2 Feb 2023 12:56:23 +0100 Subject: [PATCH 14/19] FF: Allow TextBox to accept placeholder as input --- package.json | 2 +- src/visual/TextBox.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3cfae9b..8c7e2cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psychojs", - "version": "2022.2.5", + "version": "2022.3.1", "private": true, "description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments", "license": "MIT", diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 3930cbf..4d8e2bc 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -65,6 +65,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) opacity, depth, text, + placeholder, font, letterHeight, bold, @@ -98,7 +99,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) ); this._addAttribute( "placeholder", - text, + placeholder, "", this._onChange(true, true), ); From d9f5cfae6375d6434ea31bb1eee28058dca8a06f Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Thu, 23 Mar 2023 15:19:08 +0100 Subject: [PATCH 15/19] ENH: improved super-flow survey; ENH: release of resources --- src/core/PsychoJS.js | 2 +- src/core/ServerManager.js | 38 +++++- src/index.css | 2 +- src/util/Clock.js | 1 + src/util/Util.js | 29 ++++- src/visual/Survey.js | 249 +++++++++++++++++++------------------- 6 files changed, 187 insertions(+), 134 deletions(-) diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index ef5ef7f..21d9f35 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -789,7 +789,7 @@ export class PsychoJS const self = this; window.onerror = function(message, source, lineno, colno, error) - {console.log('@@@', message) + { // check for ResizeObserver loop limit exceeded error: // ref: https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded if (message === "ResizeObserver loop limit exceeded" || diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index 1b59a99..2415c50 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -314,6 +314,34 @@ export class ServerManager extends PsychObject return pathStatusData.data; } + /** + * Release a resource. + * + * @param {string} name - the name of the resource to release + * @return {boolean} true if a resource with the given name was previously registered with the manager, + * false otherwise. + */ + releaseResource(name) + { + const response = { + origin: "ServerManager.releaseResource", + context: "when releasing resource: " + name, + }; + + const pathStatusData = this._resources.get(name); + + if (typeof pathStatusData === "undefined") + { + return false; + } + + // TODO check the current status: prevent the release of a resources currently downloading + + this._psychoJS.logger.debug(`releasing resource: ${name}`); + this._resources.delete(name); + return true; + } + /** * Get the status of a single resource or the reduced status of an array of resources. * @@ -506,18 +534,18 @@ export class ServerManager extends PsychObject // pre-process the resources: for (let r = 0; r < resources.length; ++r) { - const resource = resources[r]; - // convert those resources that are only a string to an object with name and path: - if (typeof resource === "string") + if (typeof resources[r] === "string") { resources[r] = { - name: resource, - path: resource, + name: resources[r], + path: resources[r], download: true }; } + const resource = resources[r]; + // deal with survey models: if ("surveyId" in resource) { diff --git a/src/index.css b/src/index.css index c903ea8..301aaa1 100644 --- a/src/index.css +++ b/src/index.css @@ -13,7 +13,7 @@ body { /* Initialisation message (which will disappear behind the canvas) */ #root::after { - content: "initialising the experiment..."; + content: "initialising..."; position: fixed; top: 50%; left: 50%; diff --git a/src/util/Clock.js b/src/util/Clock.js index 3e92b5d..f0e7874 100644 --- a/src/util/Clock.js +++ b/src/util/Clock.js @@ -90,6 +90,7 @@ export class MonotonicClock { // yyyy-mm-dd, hh:mm:ss.sss return MonotonicClock.getDate() + .replaceAll("/","-") // yyyy-mm-dd_hh:mm:ss.sss .replace(", ", "_") // yyyy-mm-dd_hh[h]mm:ss.sss diff --git a/src/util/Util.js b/src/util/Util.js index 02a6133..0845207 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -322,24 +322,43 @@ export function IsPointInsidePolygon(point, vertices) } /** - * Shuffle an array in place using the Fisher-Yastes's modern algorithm + * Shuffle an array, or a portion of that array, in place using the Fisher-Yastes's modern algorithm *

See details here: https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm

* * @param {Object[]} array - the input 1-D array - * @param {Function} [randomNumberGenerator = undefined] - A function used to generated random numbers in the interal [0, 1). Defaults to Math.random + * @param {Function} [randomNumberGenerator= undefined] - A function used to generated random numbers in the interval [0, 1). Defaults to Math.random + * @param [startIndex= undefined] - start index in the array + * @param [endIndex= undefined] - end index in the array * @return {Object[]} the shuffled array */ -export function shuffle(array, randomNumberGenerator = undefined) +export function shuffle(array, randomNumberGenerator = undefined, startIndex = undefined, endIndex = undefined) { - if (randomNumberGenerator === undefined) + // if array is not an array, we return it untouched rather than throwing an exception: + if (!array || !Array.isArray(array)) + { + return array; + } + + if (typeof startIndex === "undefined") + { + startIndex = 0; + } + if (typeof endIndex === "undefined") + { + endIndex = array.length - 1; + } + + if (typeof randomNumberGenerator === "undefined") { randomNumberGenerator = Math.random; } - for (let i = array.length - 1; i > 0; i--) + + for (let i = endIndex; i > startIndex; i--) { const j = Math.floor(randomNumberGenerator() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } + return array; } diff --git a/src/visual/Survey.js b/src/visual/Survey.js index d4cf781..b573e16 100644 --- a/src/visual/Survey.js +++ b/src/visual/Survey.js @@ -23,26 +23,7 @@ import MatrixBipolar from "./survey/components/MatrixBipolar.js"; import DropdownExtensions from "./survey/components/DropdownExtensions.js"; import customExpressionFunctionsArray from "./survey/extensions/customExpressionFunctions.js"; -const CAPTIONS = { - NEXT: "Next" -}; -const SURVEY_SETTINGS = { - minWidth: "100px" -}; - -const SURVEY_COMPLETION_CODES = -{ - NORMAL: 0, - SKIP_TO_END_OF_BLOCK: 1, - SKIP_TO_END_OF_SURVEY: 2 -}; - -const NODE_EXIT_CODES = -{ - NORMAL: 0, - BREAK_FLOW: 1 -}; /** * Survey Stimulus. @@ -63,6 +44,24 @@ export class Survey extends VisualStim ENDSURVEY: "END" }; + static CAPTIONS = + { + NEXT: "Next" + }; + + static SURVEY_COMPLETION_CODES = + { + NORMAL: 0, + SKIP_TO_END_OF_BLOCK: 1, + SKIP_TO_END_OF_SURVEY: 2 + }; + + static NODE_EXIT_CODES = + { + NORMAL: 0, + BREAK_FLOW: 1 + }; + /** * @memberOf module:visual * @param {Object} options @@ -83,19 +82,12 @@ export class Survey extends VisualStim { super({ name, win, units, ori, depth, pos, size, autoDraw, autoLog }); - // the default surveyId is an uuid based on the experiment id (or name) and the survey name: - // this way, it is always the same within a given experiment - this._hasSelfGeneratedSurveyId = (typeof surveyId === "undefined"); - const defaultSurveyId = (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER) ? - util.makeUuid(`${name}@${this._psychoJS.config.gitlab.projectId}`) : - util.makeUuid(`${name}@${this._psychoJS.config.experiment.name}`); - // whether the user is done with the survey, independently of whether the survey is completed: this.isFinished = false; - // Accumulated completion flag that is being set after completion of one survey node. - // This flag allows to track completion progress while moving through the survey flow. - // Initially set to true and will be flipped if at least one of the survey nodes were not fully completed. + // accumulated completion flag updated after each survey node is completed + // note: this make it possible to track completion as we move through the survey flow. + // _isCompletedAll will be flipped to false whenever a survey node is not completed this._isCompletedAll = true; // timestamps associated to each question: @@ -103,10 +95,9 @@ export class Survey extends VisualStim // timestamps clock: this._questionAnswerTimestampClock = new Clock(); - this._totalSurveyResults = {}; + this._overallSurveyResults = {}; this._surveyData = undefined; this._surveyModel = undefined; - this._signaturePadRO = undefined; this._expressionsRunner = undefined; this._lastPageSwitchHandledIdx = -1; this._variables = {}; @@ -114,23 +105,36 @@ export class Survey extends VisualStim this._surveyRunningPromise = undefined; this._surveyRunningPromiseResolve = undefined; this._surveyRunningPromiseReject = undefined; - // callback triggered when the user is done with the survey: nothing to do by default this._onFinishedCallback = () => {}; - // init SurveyJS + // init SurveyJS: this._initSurveyJS(); + // default size: + if (typeof size === "undefined") + { + this.size = (this.unit === "norm") ? [2.0, 2.0] : [1.0, 1.0]; + } + this._addAttribute( "model", model ); + + // the default surveyId is an uuid based on the experiment id (or name) and the survey name: + // this way, it is always the same within a given experiment + this._hasSelfGeneratedSurveyId = (typeof surveyId === "undefined"); + const defaultSurveyId = (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER) ? + util.makeUuid(`${name}@${this._psychoJS.config.gitlab.projectId}`) : + util.makeUuid(`${name}@${this._psychoJS.config.experiment.name}`); this._addAttribute( "surveyId", surveyId, defaultSurveyId ); + // estimate the bounding box: this._estimateBoundingBox(); @@ -213,7 +217,7 @@ export class Survey extends VisualStim logs: [] }; - this.psychoJS.logger.debug(`converted the old model to the new super-flow model: ${JSON.stringify(model)}`); + this.psychoJS.logger.debug(`converted the legacy model to the new super-flow model: ${JSON.stringify(model)}`); } this._surveyData = model; @@ -227,6 +231,24 @@ export class Survey extends VisualStim } } + /** + * Setter for the surveyId attribute. + * + * @param {string} surveyId - the survey Id + * @param {boolean} [log= false] - whether to log + * @return {void} + */ + setSurveyId(surveyId, log = false) + { + this._setAttribute("surveyId", surveyId, log); + + // only update the model if a genuine surveyId was given as parameter to the Survey: + if (!this._hasSelfGeneratedSurveyId) + { + this.setModel(`${surveyId}.sid`, log); + } + } + /** * Set survey variables. * @@ -254,7 +276,8 @@ export class Survey extends VisualStim { if (excludedNames.indexOf(name) === -1) { - this._surveyData.variables[name] = variables[name]; + this._variables[name] = variables[name]; + // this._surveyData.variables[name] = variables[name]; } } } @@ -282,22 +305,6 @@ export class Survey extends VisualStim return this._surveyModel.runExpression(expression); } - /** - * Setter for the surveyId attribute. - * - * @param {string} surveyId - the survey Id - * @param {boolean} [log= false] - whether to log - * @return {void} - */ - setSurveyId(surveyId, log = false) - { - this._setAttribute("surveyId", surveyId, log); - if (!this._hasSelfGeneratedSurveyId) - { - this.setModel(`${surveyId}.sid`, log); - } - } - /** * Add a callback that will be triggered when the participant finishes the survey. * @@ -336,7 +343,7 @@ export class Survey extends VisualStim // return this._surveyModel.data; - return this._totalSurveyResults; + return this._overallSurveyResults; } /** @@ -374,7 +381,6 @@ export class Survey extends VisualStim {} ); - // if the response cannot be uploaded, e.g. the experiment is running locally, or // if it is piloting mode, then we offer the response as a file for download: if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER || @@ -420,9 +426,7 @@ export class Survey extends VisualStim */ hide() { - // if a survey div already does not exist already, create it: - const surveyId = `survey-${this._name}`; - const surveyDiv = document.getElementById(surveyId); + const surveyDiv = document.getElementById(this._surveyDivId); if (surveyDiv !== null) { document.body.removeChild(surveyDiv); @@ -468,9 +472,9 @@ export class Survey extends VisualStim this._needPixiUpdate = false; // if a survey div does not exist, create it: - if (document.getElementById("_survey") === null) + if (document.getElementById(this._surveyDivId) === null) { - document.body.insertAdjacentHTML("beforeend", "
") + document.body.insertAdjacentHTML("beforeend", `
`) } // start the survey flow: @@ -513,8 +517,7 @@ export class Survey extends VisualStim */ _registerCustomExpressionFunctions (Survey, customFuncs = []) { - let i; - for (i = 0; i < customFuncs.length; i++) + for (let i = 0; i < customFuncs.length; i++) { Survey.FunctionFactory.Instance.register(customFuncs[i].func.name, customFuncs[i].func, customFuncs[i].isAsync); } @@ -579,6 +582,7 @@ export class Survey extends VisualStim this._questionAnswerTimestamps[questionData.name].timestamp = this._questionAnswerTimestampClock.getTime(); } +/* // This probably needs to be moved to some kind of utils.js. // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle _FisherYatesShuffle (targetArray = []) @@ -613,6 +617,7 @@ export class Survey extends VisualStim return inOutArray; } +*/ _composeModelWithRandomizedQuestions (surveyModel, inBlockRandomizationSettings) { @@ -621,31 +626,32 @@ export class Survey extends VisualStim // Hence creating a fresh survey data object with shuffled question order. let questions = []; let questionsMap = {}; - let shuffledQuestions; let newSurveyModel = { pages:[{ elements: new Array(inBlockRandomizationSettings.questionsPerPage) }] }; - let i, j, k; - for (i = 0; i < surveyModel.pages.length; i++) + for (let i = 0; i < surveyModel.pages.length; i++) { - for (j = 0; j < surveyModel.pages[i].elements.length; j++) + for (let j = 0; j < surveyModel.pages[i].elements.length; j++) { questions.push(surveyModel.pages[i].elements[j]); - k = questions.length - 1; + const k = questions.length - 1; questionsMap[questions[k].name] = questions[k]; } } if (inBlockRandomizationSettings.layout.length > 0) { - j = 0; - k = 0; + let j = 0; + let k = 0; let curPage = 0; let curElement = 0; - const shuffledSet0 = this._FisherYatesShuffle(inBlockRandomizationSettings.set0); - const shuffledSet1 = this._FisherYatesShuffle(inBlockRandomizationSettings.set1); - for (i = 0; i < inBlockRandomizationSettings.layout.length; i++) + + const shuffledSet0 = util.shuffle(Array.from(inBlockRandomizationSettings.set0)); + const shuffledSet1 = util.shuffle(Array.from(inBlockRandomizationSettings.set1)); + // const shuffledSet0 = this._FisherYatesShuffle(inBlockRandomizationSettings.set0); + // const shuffledSet1 = this._FisherYatesShuffle(inBlockRandomizationSettings.set1); + for (let i = 0; i < inBlockRandomizationSettings.layout.length; i++) { // Create new page if questionsPerPage reached. if (curElement === inBlockRandomizationSettings.questionsPerPage) @@ -675,12 +681,14 @@ export class Survey extends VisualStim else if (inBlockRandomizationSettings.showOnly > 0) { // TODO: Check if there can be questionsPerPage applicable in this case. - shuffledQuestions = this._FisherYatesShuffle(questions); + const shuffledQuestions = util.shuffle(Array.from(questions)); + // shuffledQuestions = this._FisherYatesShuffle(questions); newSurveyModel.pages[0].elements = shuffledQuestions.splice(0, inBlockRandomizationSettings.showOnly); } else { // TODO: Check if there can be questionsPerPage applicable in this case. - newSurveyModel.pages[0].elements = this._FisherYatesShuffle(questions); + newSurveyModel.pages[0].elements = util.shuffle(Array.from(questions)); + // newSurveyModel.pages[0].elements = this._FisherYatesShuffle(questions); } console.log("model recomposition took", performance.now() - t); console.log("recomposed model:", newSurveyModel); @@ -714,12 +722,14 @@ export class Survey extends VisualStim if (inQuestionRandomizationSettings.randomizeAll) { - questionData[choicesFieldName] = this._FisherYatesShuffle(questionData[choicesFieldName]); + questionData[choicesFieldName] = util.shuffle(Array.from(questionData[choicesFieldName])); + // questionData[choicesFieldName] = this._FisherYatesShuffle(questionData[choicesFieldName]); // Handle dynamic choices. } else if (inQuestionRandomizationSettings.showOnly > 0) { - questionData[choicesFieldName] = this._FisherYatesShuffle(questionData[choicesFieldName]).splice(0, inQuestionRandomizationSettings.showOnly); + questionData[choicesFieldName] = util.shuffle(Array.from(questionData[choicesFieldName]).splice(0, inQuestionRandomizationSettings.showOnly)); + // questionData[choicesFieldName] = this._FisherYatesShuffle(questionData[choicesFieldName]).splice(0, inQuestionRandomizationSettings.showOnly); } else if (inQuestionRandomizationSettings.reverse) { @@ -739,8 +749,10 @@ export class Survey extends VisualStim // Creating new array of choices to which we're going to write from randomized/reversed sets. questionData[choicesFieldName] = new Array(inQuestionRandomizationSettings.layout.length); - const shuffledSet0 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set0); - const shuffledSet1 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set1); + const shuffledSet0 = util.shuffle(Array.from(inQuestionRandomizationSettings.set0)); + const shuffledSet1 = util.shuffle(Array.from(inQuestionRandomizationSettings.set1)); + // const shuffledSet0 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set0); + // const shuffledSet1 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set1); const reversedSet = Math.round(Math.random()) === 1 ? inQuestionRandomizationSettings.reverseOrder.reverse() : inQuestionRandomizationSettings.reverseOrder; for (i = 0; i < inQuestionRandomizationSettings.layout.length; i++) { @@ -861,12 +873,12 @@ export class Survey extends VisualStim if (skipLogic.destination === "ENDOFSURVEY") { surveyModel.setCompleted(); - this._surveyRunningPromiseResolve(SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY); + this._surveyRunningPromiseResolve(Survey.SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY); } else if (skipLogic.destination === "ENDOFBLOCK") { surveyModel.setCompleted(); - this._surveyRunningPromiseResolve(SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_BLOCK); + this._surveyRunningPromiseResolve(Survey.SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_BLOCK); } else { @@ -896,13 +908,12 @@ export class Survey extends VisualStim * * @param surveyModel * @param options - * @private + * @protected */ _onSurveyComplete(surveyModel, options) { - Object.assign(this._totalSurveyResults, surveyModel.data); - this._detachResizeObservers(); - let completionCode = SURVEY_COMPLETION_CODES.NORMAL; + Object.assign(this._overallSurveyResults, surveyModel.data); + let completionCode = Survey.SURVEY_COMPLETION_CODES.NORMAL; const questions = surveyModel.getAllQuestions(); // It is guaranteed that the question with skip logic is always last on the page. @@ -916,12 +927,12 @@ export class Survey extends VisualStim { if (skipLogic.destination === "ENDOFSURVEY") { - completionCode = SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY; + completionCode = Survey.SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY; surveyModel.setCompleted(); } else if (skipLogic.destination === "ENDOFBLOCK") { - completionCode = SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_BLOCK; + completionCode = Survey.SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_BLOCK; } } } @@ -957,6 +968,8 @@ export class Survey extends VisualStim this.psychoJS.logger.warn(`Flag _isCompletedAll is false!`); } + this._detachResizeObservers(); + this._surveyRunningPromiseResolve(completionCode); } @@ -976,22 +989,18 @@ export class Survey extends VisualStim * Run the survey using flow data provided. This method runs recursively. * * @protected - * @param {string} surveyId - the id of the DOM div * @param {Object} surveyData - surveyData / model. - * @param {Object} prevBlockResults - survey results gathered from running previous block of questions. + * @param {Object} surveyFlowBlock - XXX * @return {void} */ _beginSurvey(surveyData, surveyFlowBlock) { - let j; - let surveyIdx; this._lastPageSwitchHandledIdx = -1; - surveyIdx = surveyFlowBlock.surveyIdx; - console.log("playing survey with idx", surveyIdx); + const surveyIdx = surveyFlowBlock.surveyIdx; let surveyModelInput = this._processSurveyData(surveyData, surveyIdx); this._surveyModel = new window.Survey.Model(surveyModelInput); - for (j in this._variables) + for (let j in this._variables) { // Adding variables directly to hash to get higher performance (this is instantaneous compared to .setVariable()). // At this stage we don't care to trigger all the callbacks like .setVariable() does, since this is very beginning of survey presentation. @@ -1010,7 +1019,7 @@ export class Survey extends VisualStim this._surveyModel.onAfterRenderQuestion.add(this._handleAfterQuestionRender.bind(this)); } - const completeText = surveyIdx < this._surveyData.surveys.length - 1 ? (this._surveyModel.pageNextText || CAPTIONS.NEXT) : undefined; + const completeText = surveyIdx < this._surveyData.surveys.length - 1 ? (this._surveyModel.pageNextText || Survey.CAPTIONS.NEXT) : undefined; jQuery(".survey").Survey({ model: this._surveyModel, showItemsInOrder: "column", @@ -1033,15 +1042,11 @@ export class Survey extends VisualStim async _runSurveyFlow(surveyBlock, surveyData, prevBlockResults = {}) { - // let surveyBlock; - let surveyIdx; - let surveyCompletionCode; - let nodeExitCode = NODE_EXIT_CODES.NORMAL; - let i, j; + let nodeExitCode = Survey.NODE_EXIT_CODES.NORMAL; if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL) { - const dataset = Object.assign({}, this._totalSurveyResults, this._variables); + const dataset = Object.assign({}, this._overallSurveyResults, this._variables); this._expressionsRunner.expressionExecutor.setExpression(surveyBlock.condition); if (this._expressionsRunner.run(dataset) && surveyBlock.nodes[0] !== undefined) { @@ -1054,13 +1059,14 @@ export class Survey extends VisualStim } else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.RANDOMIZER) { - this._InPlaceFisherYatesShuffle(surveyBlock.nodes, 0, surveyBlock.nodes.length - 1); + util.shuffle(surveyBlock.nodes, Math.random, 0, surveyBlock.nodes.length - 1); + // this._InPlaceFisherYatesShuffle(surveyBlock.nodes, 0, surveyBlock.nodes.length - 1); } else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.EMBEDDED_DATA) { let t = performance.now(); const surveyBlockData = surveyData.embeddedData[surveyBlock.dataIdx]; - for (j = 0; j < surveyBlockData.length; j++) + for (let j = 0; j < surveyBlockData.length; j++) { // TODO: handle the rest data types. if (surveyBlockData[j].type === "Custom") @@ -1089,28 +1095,28 @@ export class Survey extends VisualStim this._surveyModel.setCompleted(); } console.log("EndSurvey block encountered, exiting."); - nodeExitCode = NODE_EXIT_CODES.BREAK_FLOW; + nodeExitCode = Survey.NODE_EXIT_CODES.BREAK_FLOW; } else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.DIRECT) { - surveyCompletionCode = await this._beginSurvey(surveyData, surveyBlock); + const surveyCompletionCode = await this._beginSurvey(surveyData, surveyBlock); Object.assign({}, prevBlockResults, this._surveyModel.data); // SkipLogic had destination set to ENDOFSURVEY. - if (surveyCompletionCode === SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY) + if (surveyCompletionCode === Survey.SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY) { - nodeExitCode = NODE_EXIT_CODES.BREAK_FLOW; + nodeExitCode = Survey.NODE_EXIT_CODES.BREAK_FLOW; } } - if (nodeExitCode === NODE_EXIT_CODES.NORMAL && + if (nodeExitCode === Survey.NODE_EXIT_CODES.NORMAL && surveyBlock.type !== Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL && surveyBlock.nodes instanceof Array) { - for (i = 0; i < surveyBlock.nodes.length; i++) + for (let i = 0; i < surveyBlock.nodes.length; i++) { nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[i], surveyData, prevBlockResults); - if (nodeExitCode === NODE_EXIT_CODES.BREAK_FLOW) + if (nodeExitCode === Survey.NODE_EXIT_CODES.BREAK_FLOW) { break; } @@ -1131,20 +1137,17 @@ export class Survey extends VisualStim this._lastPageSwitchHandledIdx = -1; } - _handleSignaturePadResize (entries) + _handleSignaturePadResize(entries) { - let signatureCanvas; - let q; - let i; - for (i = 0; i < entries.length; i++) + for (let i = 0; i < entries.length; i++) { - signatureCanvas = entries[i].target.querySelector("canvas"); - q = this._surveyModel.getQuestionByName(entries[i].target.dataset.name); - q.signatureWidth = Math.min(q.maxSignatureWidth, entries[i].contentBoxSize[0].inlineSize); + // const signatureCanvas = entries[i].target.querySelector("canvas"); + const question = this._surveyModel.getQuestionByName(entries[i].target.dataset.name); + question.signatureWidth = Math.min(question.maxSignatureWidth, entries[i].contentBoxSize[0].inlineSize); } } - _addEventListeners () + _addEventListeners() { this._signaturePadRO = new ResizeObserver(this._handleSignaturePadResize.bind(this)); } @@ -1157,27 +1160,29 @@ export class Survey extends VisualStim } } - _detachResizeObservers () + _detachResizeObservers() { this._signaturePadRO.disconnect(); } /** - * Init the SurveyJS.io library. + * Init the SurveyJS.io library and various extensions, setup the theme. * * @protected */ _initSurveyJS() { - // load the Survey.js libraries, if necessary: - // TODO + // note: the Survey.js libraries must be added to the list of resources in PsychoJS.start: + // psychoJS.start({ resources: [ {'surveyLibrary': true}, ... ], ...}); + + // id of the SurveyJS html div: + this._surveyDivId = `survey-${this._name}`; - // load the PsychoJS SurveyJS extensions: - this._expressionsRunner = new window.Survey.ExpressionRunner(); this._registerCustomExpressionFunctions(window.Survey, customExpressionFunctionsArray); this._registerWidgets(window.Survey); this._registerCustomSurveyProperties(window.Survey); this._addEventListeners(); + this._expressionsRunner = new window.Survey.ExpressionRunner(); // setup the survey theme: window.Survey.Serializer.getProperty("expression", "minWidth").defaultValue = "100px"; From bff887b79311d569e340f233c1c53959a32b44ed Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 24 Mar 2023 13:20:34 +0000 Subject: [PATCH 16/19] src/visual --- src/visual/Survey.js | 58 +++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/src/visual/Survey.js b/src/visual/Survey.js index b573e16..d761224 100644 --- a/src/visual/Survey.js +++ b/src/visual/Survey.js @@ -82,6 +82,10 @@ export class Survey extends VisualStim { super({ name, win, units, ori, depth, pos, size, autoDraw, autoLog }); + // Storing all existing signaturePad questions to properly handle their resize. + // Unfortunately signaturepad question type can't handle resizing properly by itself. + this._signaturePads = []; + // whether the user is done with the survey, independently of whether the survey is completed: this.isFinished = false; @@ -968,8 +972,6 @@ export class Survey extends VisualStim this.psychoJS.logger.warn(`Flag _isCompletedAll is false!`); } - this._detachResizeObservers(); - this._surveyRunningPromiseResolve(completionCode); } @@ -1017,6 +1019,10 @@ export class Survey extends VisualStim this._surveyModel.onTextMarkdown.add(this._onTextMarkdown.bind(this)); this._surveyModel.isInitialized = true; this._surveyModel.onAfterRenderQuestion.add(this._handleAfterQuestionRender.bind(this)); + this._surveyModel.onQuestionRemoved.add(() => + { + console.log("question removed") + }) } const completeText = surveyIdx < this._surveyData.surveys.length - 1 ? (this._surveyModel.pageNextText || Survey.CAPTIONS.NEXT) : undefined; @@ -1137,34 +1143,58 @@ export class Survey extends VisualStim this._lastPageSwitchHandledIdx = -1; } - _handleSignaturePadResize(entries) + _getQuestionByNameIncludingInDesign(questionName = "") { - for (let i = 0; i < entries.length; i++) + const allQuestions = this._surveyModel.getAllQuestions(false, true); + for (const question of allQuestions) { - // const signatureCanvas = entries[i].target.querySelector("canvas"); - const question = this._surveyModel.getQuestionByName(entries[i].target.dataset.name); - question.signatureWidth = Math.min(question.maxSignatureWidth, entries[i].contentBoxSize[0].inlineSize); + if (question.name === questionName) + { + return question; + } + } + } + + _handleWindowResize(e) + { + if (this._surveyModel) + { + for (let i = this._signaturePads.length - 1; i >= 0; i--) + { + // As of writing this (24.03.2023). SurveyJS doesn't have a proper event + // for question being removed from nested locations, such as dynamic panel. + // However, surveyJS will set .signaturePad property to null once the question is removed. + // Utilising this knowledge to sync our lists. + if (this._signaturePads[ i ].question.signaturePad) + { + this._signaturePads[ i ].question.signatureWidth = Math.min( + this._signaturePads[i].question.maxSignatureWidth, + this._signaturePads[ i ].htmlElement.getBoundingClientRect().width + ); + } + else + { + // Signature pad was removed. Syncing list. + this._signaturePads.splice(i, 1); + } + } } } _addEventListeners() { - this._signaturePadRO = new ResizeObserver(this._handleSignaturePadResize.bind(this)); + window.addEventListener("resize", (e) => this._handleWindowResize(e)); } _handleAfterQuestionRender (sender, options) { if (options.question.getType() === "signaturepad") { - this._signaturePadRO.observe(options.htmlElement); + this._signaturePads.push(options); + options.question.signatureWidth = Math.min(options.question.maxSignatureWidth, options.htmlElement.getBoundingClientRect().width); } } - _detachResizeObservers() - { - this._signaturePadRO.disconnect(); - } - /** * Init the SurveyJS.io library and various extensions, setup the theme. * From 5f32881be273542d8dab1bf15c7347ff1d9c1c91 Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 24 Mar 2023 13:23:50 +0000 Subject: [PATCH 17/19] removed dead code. --- src/visual/Survey.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/visual/Survey.js b/src/visual/Survey.js index d761224..57bf41f 100644 --- a/src/visual/Survey.js +++ b/src/visual/Survey.js @@ -1019,10 +1019,6 @@ export class Survey extends VisualStim this._surveyModel.onTextMarkdown.add(this._onTextMarkdown.bind(this)); this._surveyModel.isInitialized = true; this._surveyModel.onAfterRenderQuestion.add(this._handleAfterQuestionRender.bind(this)); - this._surveyModel.onQuestionRemoved.add(() => - { - console.log("question removed") - }) } const completeText = surveyIdx < this._surveyData.surveys.length - 1 ? (this._surveyModel.pageNextText || Survey.CAPTIONS.NEXT) : undefined; @@ -1143,18 +1139,6 @@ export class Survey extends VisualStim this._lastPageSwitchHandledIdx = -1; } - _getQuestionByNameIncludingInDesign(questionName = "") - { - const allQuestions = this._surveyModel.getAllQuestions(false, true); - for (const question of allQuestions) - { - if (question.name === questionName) - { - return question; - } - } - } - _handleWindowResize(e) { if (this._surveyModel) From 5dba92ab37c03d5ce52274ca1625d352c1061fc5 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Wed, 19 Jul 2023 16:07:43 +0200 Subject: [PATCH 18/19] ENH: various enhancements to the dialog box, resource manager, saving of data, capture of keys, scheduler --- package-lock.json | 815 ++++++++++++++++++++- package.json | 3 +- src/core/EventManager.js | 8 +- src/core/GUI.js | 79 +- src/core/Keyboard.js | 12 +- src/core/PsychoJS.js | 3 +- src/core/ServerManager.js | 2 +- src/core/Window.js | 14 +- src/data/ExperimentHandler.js | 14 + src/index.css | 60 +- src/util/Scheduler.js | 11 + src/util/Util.js | 46 ++ src/visual/ButtonStim.js | 10 +- src/visual/ImageStim.js | 81 +- src/visual/TextBox.js | 63 +- src/visual/survey/widgets/MaxDiffMatrix.js | 21 +- 16 files changed, 1148 insertions(+), 94 deletions(-) diff --git a/package-lock.json b/package-lock.json index cb6071b..eae98bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psychojs", - "version": "2022.2.0", + "version": "2023.2.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "psychojs", - "version": "2022.2.0", + "version": "2023.2.1", "license": "MIT", "dependencies": { "@pixi/filter-adjustment": "^4.1.3", @@ -16,6 +16,7 @@ "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", + "pixi-filters": "^5.0.0", "pixi.js-legacy": "^6.0.4", "seedrandom": "^3.0.5", "tone": "^14.7.77", @@ -288,6 +289,15 @@ "@pixi/text": "6.0.4" } }, + "node_modules/@pixi/color": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.2.4.tgz", + "integrity": "sha512-B/+9JRcXe2uE8wQfsueFRPZVayF2VEMRB7XGeRAsWCryOX19nmWhv0Nt3nOU2rvzI0niz9XgugJXsB6vVmDFSg==", + "peer": true, + "dependencies": { + "colord": "^2.9.3" + } + }, "node_modules/@pixi/compressed-textures": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/compressed-textures/-/compressed-textures-6.0.4.tgz", @@ -331,6 +341,12 @@ "@pixi/utils": "6.0.4" } }, + "node_modules/@pixi/extensions": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.2.4.tgz", + "integrity": "sha512-Mnqv9scbL1ARD3QFKfOWs2aSVJJfP1dL8g5UiqGImYO3rZbz/9QCzXOeMVIZ5n3iaRyKMNhFFr84/zUja2H7Dw==", + "peer": true + }, "node_modules/@pixi/extract": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/extract/-/extract-6.0.4.tgz", @@ -639,11 +655,23 @@ "url": "^0.11.0" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "peer": true + }, "node_modules/@types/earcut": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.1.tgz", "integrity": "sha512-w8oigUCDjElRHRRrMvn/spybSMyX8MTkKA5Dv+tS1IE/TgmNZPqUYtvYBXGY8cieSE66gm+szeK+bnbxC2xHTQ==" }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.0.tgz", + "integrity": "sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg==", + "peer": true + }, "node_modules/a11y-dialog": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/a11y-dialog/-/a11y-dialog-7.5.0.tgz", @@ -869,6 +897,12 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "peer": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -957,9 +991,9 @@ } }, "node_modules/earcut": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.2.tgz", - "integrity": "sha512-eZoZPPJcUHnfRZ0PjLvx2qBordSiO8ofC3vt+qACLM95u+4DovnbYNpQtJh0DNsWj8RnxrQytD4WA8gj5cRIaQ==" + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -1954,6 +1988,409 @@ "node": ">=8" } }, + "node_modules/pixi-filters": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-5.0.0.tgz", + "integrity": "sha512-j90nvbiRpDozxalSUaQ2kTIyFNGAUKxJ2qhPs4ThmVLiR9lam5x+GpP+c1Yx5N+qc+u0tH5G3VRY1usB69atrw==", + "dependencies": { + "@pixi/filter-adjustment": "5.0.0", + "@pixi/filter-advanced-bloom": "5.0.0", + "@pixi/filter-ascii": "5.0.0", + "@pixi/filter-bevel": "5.0.0", + "@pixi/filter-bloom": "5.0.0", + "@pixi/filter-bulge-pinch": "5.0.0", + "@pixi/filter-color-map": "5.0.0", + "@pixi/filter-color-overlay": "5.0.0", + "@pixi/filter-color-replace": "5.0.0", + "@pixi/filter-convolution": "5.0.0", + "@pixi/filter-cross-hatch": "5.0.0", + "@pixi/filter-crt": "5.0.0", + "@pixi/filter-dot": "5.0.0", + "@pixi/filter-drop-shadow": "5.0.0", + "@pixi/filter-emboss": "5.0.0", + "@pixi/filter-glitch": "5.0.0", + "@pixi/filter-glow": "5.0.0", + "@pixi/filter-godray": "5.0.0", + "@pixi/filter-kawase-blur": "5.0.0", + "@pixi/filter-motion-blur": "5.0.0", + "@pixi/filter-multi-color-replace": "5.0.0", + "@pixi/filter-old-film": "5.0.0", + "@pixi/filter-outline": "5.0.0", + "@pixi/filter-pixelate": "5.0.0", + "@pixi/filter-radial-blur": "5.0.0", + "@pixi/filter-reflection": "5.0.0", + "@pixi/filter-rgb-split": "5.0.0", + "@pixi/filter-shockwave": "5.0.0", + "@pixi/filter-simple-lightmap": "5.0.0", + "@pixi/filter-tilt-shift": "5.0.0", + "@pixi/filter-twist": "5.0.0", + "@pixi/filter-zoom-blur": "5.0.0" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/constants": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.2.4.tgz", + "integrity": "sha512-hKuHBWR6N4Q0Sf5MGF3/9l+POg/G5rqhueHfzofiuelnKg7aBs3BVjjZ+6hZbd6M++vOUmxYelEX/NEFBxrheA==", + "peer": true + }, + "node_modules/pixi-filters/node_modules/@pixi/core": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/core/-/core-7.2.4.tgz", + "integrity": "sha512-0XtvrfxHlS2T+beBBSpo7GI8+QLyyTqMVQpNmPqB4woYxzrOEJ9JaUFBaBfCvycLeUkfVih1u6HAbtF+2d1EjQ==", + "peer": true, + "dependencies": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/extensions": "7.2.4", + "@pixi/math": "7.2.4", + "@pixi/runner": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/ticker": "7.2.4", + "@pixi/utils": "7.2.4", + "@types/offscreencanvas": "^2019.6.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-adjustment": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-5.0.0.tgz", + "integrity": "sha512-Epci8zSWCNWhFtnarvQqOcnmOqLfhXIJ7NNENEi2E1rom1Ar13RLM76CBGBbuDRK7flweqcWmZb0QZLxqwxTDg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-advanced-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-advanced-bloom/-/filter-advanced-bloom-5.0.0.tgz", + "integrity": "sha512-P5Xt65GLBEqjZVUkLe4ZZk4D1/j9UEXYnYFG3JrLPYkdcniwD4Y+NIyNCJ+eP91ivgoCmK/+SyBRv0P0AEQkTw==", + "dependencies": { + "@pixi/filter-kawase-blur": "5.0.0" + }, + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-alpha": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-7.2.4.tgz", + "integrity": "sha512-UTUMSGyktUr+I9vmigqJo9iUhb0nwGyqTTME2xBWZvVGCnl5z+/wHxvIBBCe5pNZ66IM15pGXQ4cDcfqCuP2kA==", + "peer": true, + "peerDependencies": { + "@pixi/core": "7.2.4" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-ascii": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-ascii/-/filter-ascii-5.0.0.tgz", + "integrity": "sha512-A49yNhiye/aFDOnI11zwEm/td2xho0td/Cvzvru8FUgi1MzJvZE03W/JoLl04ToZczw143wFPxutl6V/Ohw5bQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-bevel": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bevel/-/filter-bevel-5.0.0.tgz", + "integrity": "sha512-0Odat0tW/uoS/uyp0rigm07Q3YPgwKLTgkZZZSzIUVsPnwcJjiocSzWel73JkiY3m2ZjTrj+JZjkyGjkYH+2gQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bloom/-/filter-bloom-5.0.0.tgz", + "integrity": "sha512-vOSNJNV5y+ifwQWfzEmml3owcgoJAQIQtMR17SELBUwfYP60qxy5bNWBdYBlipSJVwX2AuGi8Xk5Ia9dijcqZQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X", + "@pixi/filter-alpha": "^7.0.0-X", + "@pixi/filter-blur": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-blur": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-blur/-/filter-blur-7.2.4.tgz", + "integrity": "sha512-aLyXIoxy14bTansCPtbY8x7Sdn2OrrqkF/pcKiRXHJGGhi7wPacvB/NcmYJdnI/n2ExQ6V5Njuj/nfrsejVwcA==", + "peer": true, + "peerDependencies": { + "@pixi/core": "7.2.4" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-bulge-pinch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bulge-pinch/-/filter-bulge-pinch-5.0.0.tgz", + "integrity": "sha512-j1feWsCpyTZk4aHbYNjax52lt0OtyYDbHvYaePYzGO/SBb1t/spDnHQEkAP7R3bZ7Ud/GI4RgefAFnvsYeSetQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-color-map": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-map/-/filter-color-map-5.0.0.tgz", + "integrity": "sha512-w77mRi89sLUMwjhl7qL/q1YrhEKyOk2MJZQdKBksvGEV/Mf5mV2h3+EOC62wB18Q4iUVQy1MS4sANyVaCctu2w==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-color-overlay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-overlay/-/filter-color-overlay-5.0.0.tgz", + "integrity": "sha512-AjxVN6gnZ+xCryQUmI+TVy3yVF+CcLgDPv+nSVPDlQowuqYhZjD6qSzgRCl3Kezdi3AxrL1vi1fnBudEnzdDJg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-replace/-/filter-color-replace-5.0.0.tgz", + "integrity": "sha512-u4VOtKbY6SSr2P9v5AL8/2MVsUcAH9z92c1eaqeE3PXCPNyCgZKuNHWl8+FjBIDl/1UMQVhXH2zNrC3Vuqo3JA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-convolution": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-convolution/-/filter-convolution-5.0.0.tgz", + "integrity": "sha512-SYjyKXODdHbjzBP9c5QGMOfowNwkNFi7zW1XzGwEadmv6mLHNanO3nm0PtRu/3B9B6AW1fvOaUecYmhjAZfQjg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-cross-hatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-cross-hatch/-/filter-cross-hatch-5.0.0.tgz", + "integrity": "sha512-J4bcI3MUc/Ol3nQIsXZldYEtiLAl3ktU28zlidwffkANyl/XjP76bLEgFBoc4RE2iP/FQ+9ZeEqpsN8DIg6vVg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-crt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-crt/-/filter-crt-5.0.0.tgz", + "integrity": "sha512-/kgjNW+BCCVtUa0s8Usk3WyxgBX8kelAiqkyVnM1g8xM19Dh2689gK2wjx0ibS0p74EHs42QpkJj7jTL+1MS7A==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-dot": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-dot/-/filter-dot-5.0.0.tgz", + "integrity": "sha512-kytardK58Ifl5D8Ss3kkfI29FMzV3+npJYr5GAKnA80R7XGOPOMoxrknhou8y+Dw9LUcOv8y643wryvL43P2vw==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-drop-shadow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-drop-shadow/-/filter-drop-shadow-5.0.0.tgz", + "integrity": "sha512-kz2eL+ikCLL7/2RICyIkw3pZXkyMY0Ji6skhnPj7JaZSjH4V+7TiKqYXp532gTbwSRj/mzLCvFfOL3WwTDgZ1w==", + "dependencies": { + "@pixi/filter-kawase-blur": "5.0.0" + }, + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-emboss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-emboss/-/filter-emboss-5.0.0.tgz", + "integrity": "sha512-wvrk9zB62lGaPcCWbTwoaO48FrLIE4+hi02BVS+exx5RvIniNUJD/ledGxdmUjcHX/2mDIIs7PH0kAs1L/ziZw==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-glitch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glitch/-/filter-glitch-5.0.0.tgz", + "integrity": "sha512-yK3plqExyQp9eo3dwV03dnSHpQgh0xeD112ieAsqefrAOLc5AXSfTelPvEQaZ07ZkcxSDE5eqKcRvcIVi2IgLQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-glow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glow/-/filter-glow-5.0.0.tgz", + "integrity": "sha512-D+YE9DGSJXtmZa6aoWJfuNu+6MnSw90GP7oRRzr7S1/4moeFZ7EWbvQehl9Y9j98idHG87Cvuh6mmsRqpgS6ow==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-godray": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-godray/-/filter-godray-5.0.0.tgz", + "integrity": "sha512-L4PD3cysUMjTSDYk5q5xUtal9q6kfH8NVIdNT3aTDJpR0VW4b/ClanmOTFpJVzN6Ld/JlJbdg8ogUpXBe1gVuw==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-kawase-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-kawase-blur/-/filter-kawase-blur-5.0.0.tgz", + "integrity": "sha512-dKSTaPUOvdVkfx9x+kp0TzYjGAl8CLxIRGz6Wh43NKx96nVqd/lWqvlda+zloHVgZyQoJNHZZ4Spjcw2mYoaWg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-motion-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-motion-blur/-/filter-motion-blur-5.0.0.tgz", + "integrity": "sha512-2av4dnVL1uyyCKF8RlZaMfeO8YnQwA893j24S15ubWHZaz4WlWH3lFIYmCMqlEqHPlFDBER4vLxpR1WjsUsX/Q==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-multi-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-multi-color-replace/-/filter-multi-color-replace-5.0.0.tgz", + "integrity": "sha512-hcmCKFFQ1baGDrZc/blK9zWpe3f02rqWGsPx5VRRgc1sk44UYXHCKZnDjF80/g0ls8U4Lj+/5Xb7HOQq2LyyDg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-old-film": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-old-film/-/filter-old-film-5.0.0.tgz", + "integrity": "sha512-XSHBz4JDbvYtUrf/NP5eKCw/wvaKTAKXQENDxk480tKYtDuteSCMg87ZjLrPlyKtGySW8KTmdzl58bZjSYpiyA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-outline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-outline/-/filter-outline-5.0.0.tgz", + "integrity": "sha512-efS3Or7VQFXo2ZyPFR2M/JlZrcLAxeVbOTPYvgKe574yUghSQbQ/pyqDWE16tRB/W7+osMrTV0+C4/N/9wIxhQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-pixelate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-pixelate/-/filter-pixelate-5.0.0.tgz", + "integrity": "sha512-3g1ajOLsYy+x0FCC67WhDcjixrcBlhK3Zo+JP9zlHSxh0W4yNzfhsw9EsIb9XP4WnMtMAUMg5T0MLTnjbsrK4g==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-radial-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-radial-blur/-/filter-radial-blur-5.0.0.tgz", + "integrity": "sha512-zafBJCAiqRtsTNGKiQ8iMt00KbG20qtBi71h286wWbr0na37iXsRcg4EN76eyNbpfAOX+1ylBgIuSd9hLyQBFA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-reflection": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-reflection/-/filter-reflection-5.0.0.tgz", + "integrity": "sha512-PuZe19XUq0gTdmAStu3hcyGKkNlKGrpblN4s6vJmV+vAKVcFv2OpfjtuGUXcP/oi2LmLakC/vKfEx4bDgZzz+w==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-rgb-split": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-rgb-split/-/filter-rgb-split-5.0.0.tgz", + "integrity": "sha512-zsWBrDkj9EdjJRPjGCt/0O2Vx/8Gt+8VTmjRA0ONoegcMD9slJdJMgL9EbH/1y5WHgmzGbgZIPvWULIqepVxBQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-shockwave": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-shockwave/-/filter-shockwave-5.0.0.tgz", + "integrity": "sha512-aL0ExAkJGcUo463Ktq4HXjZGlJDpoYcyZhwd87maJrFsBjQZl2gopse6bEsy7IJxbAKzlpUKFmAP9rxwZWqMVQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-simple-lightmap": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-simple-lightmap/-/filter-simple-lightmap-5.0.0.tgz", + "integrity": "sha512-0WIKQIGZ3aNafe2VZIbGQJWxSlBMbmjM9J+Tswjaeg8Z1dz6Qux5lYIC16wZOaIqVlWL5GTpfn8HU0BHCOvESA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-tilt-shift": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-tilt-shift/-/filter-tilt-shift-5.0.0.tgz", + "integrity": "sha512-nIxYoTU9kFDx3EE1fyoIEOfAia9Tvoj+sakTKCJZUvTk+5tjpZdAm+Ump42cnb6UxTR8AMTQiwH54C7I0pbA4Q==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-twist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-twist/-/filter-twist-5.0.0.tgz", + "integrity": "sha512-YVtz3ZPfvaz22gZRZo+cOC0/L6SgSZmr/HEa6Ir+BRNVqLff6CpPx6YBVJqPREh+HFZjDomSP0kf5JasQYhzSg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-zoom-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-zoom-blur/-/filter-zoom-blur-5.0.0.tgz", + "integrity": "sha512-Q1ftuY/KPgbVtJHCvl0p4hrwVWRMWZ/yX1YRjdLGSyOwMEN8u16MEEXFQUtixEHY7+MBRBWaPOaXBaQrd+Xq7A==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/math": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.2.4.tgz", + "integrity": "sha512-LJB+mozyEPllxa0EssFZrKNfVwysfaBun4b2dJKQQInp0DafgbA0j7A+WVg0oe51KhFULTJMpDqbLn/ITFc41A==", + "peer": true + }, + "node_modules/pixi-filters/node_modules/@pixi/runner": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.2.4.tgz", + "integrity": "sha512-YtyqPk1LA+0guEFKSFx6t/YSvbEQwajFwi4Ft8iDhioa6VK2MmTir1GjWwy7JQYLcDmYSAcQjnmFtVTZohyYSw==", + "peer": true + }, + "node_modules/pixi-filters/node_modules/@pixi/settings": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.2.4.tgz", + "integrity": "sha512-ZPKRar9EwibijGmH8EViu4Greq1I/O7V/xQx2rNqN23XA7g09Qo6yfaeQpufu5xl8+/lZrjuHtQSnuY7OgG1CA==", + "peer": true, + "dependencies": { + "@pixi/constants": "7.2.4", + "@types/css-font-loading-module": "^0.0.7", + "ismobilejs": "^1.1.0" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/ticker": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.2.4.tgz", + "integrity": "sha512-hQQHIHvGeFsP4GNezZqjzuhUgNQEVgCH9+qU05UX1Mc5UHC9l6OJnY4VTVhhcHxZjA6RnyaY+1zBxCnoXuazpg==", + "peer": true, + "dependencies": { + "@pixi/extensions": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/utils": "7.2.4" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/utils": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.2.4.tgz", + "integrity": "sha512-VUGQHBOINIS4ePzoqafwxaGPVRTa3oM/mEutIIHbNGI3b+QvSO+1Dnk40M0zcH6Bo+MxQZbOZK5X/wO9oU5+LQ==", + "peer": true, + "dependencies": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/settings": "7.2.4", + "@types/earcut": "^2.1.0", + "earcut": "^2.2.4", + "eventemitter3": "^4.0.0", + "url": "^0.11.0" + } + }, + "node_modules/pixi-filters/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "peer": true + }, "node_modules/pixi.js": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-6.0.4.tgz", @@ -2682,6 +3119,15 @@ "@pixi/text": "6.0.4" } }, + "@pixi/color": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.2.4.tgz", + "integrity": "sha512-B/+9JRcXe2uE8wQfsueFRPZVayF2VEMRB7XGeRAsWCryOX19nmWhv0Nt3nOU2rvzI0niz9XgugJXsB6vVmDFSg==", + "peer": true, + "requires": { + "colord": "^2.9.3" + } + }, "@pixi/compressed-textures": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/compressed-textures/-/compressed-textures-6.0.4.tgz", @@ -2721,6 +3167,12 @@ "@pixi/utils": "6.0.4" } }, + "@pixi/extensions": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.2.4.tgz", + "integrity": "sha512-Mnqv9scbL1ARD3QFKfOWs2aSVJJfP1dL8g5UiqGImYO3rZbz/9QCzXOeMVIZ5n3iaRyKMNhFFr84/zUja2H7Dw==", + "peer": true + }, "@pixi/extract": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/extract/-/extract-6.0.4.tgz", @@ -3026,11 +3478,23 @@ "url": "^0.11.0" } }, + "@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "peer": true + }, "@types/earcut": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.1.tgz", "integrity": "sha512-w8oigUCDjElRHRRrMvn/spybSMyX8MTkKA5Dv+tS1IE/TgmNZPqUYtvYBXGY8cieSE66gm+szeK+bnbxC2xHTQ==" }, + "@types/offscreencanvas": { + "version": "2019.7.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.0.tgz", + "integrity": "sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg==", + "peer": true + }, "a11y-dialog": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/a11y-dialog/-/a11y-dialog-7.5.0.tgz", @@ -3207,6 +3671,12 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "peer": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3268,9 +3738,9 @@ "dev": true }, "earcut": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.2.tgz", - "integrity": "sha512-eZoZPPJcUHnfRZ0PjLvx2qBordSiO8ofC3vt+qACLM95u+4DovnbYNpQtJh0DNsWj8RnxrQytD4WA8gj5cRIaQ==" + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" }, "emoji-regex": { "version": "8.0.0", @@ -4044,6 +4514,335 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, + "pixi-filters": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-5.0.0.tgz", + "integrity": "sha512-j90nvbiRpDozxalSUaQ2kTIyFNGAUKxJ2qhPs4ThmVLiR9lam5x+GpP+c1Yx5N+qc+u0tH5G3VRY1usB69atrw==", + "requires": { + "@pixi/filter-adjustment": "5.0.0", + "@pixi/filter-advanced-bloom": "5.0.0", + "@pixi/filter-ascii": "5.0.0", + "@pixi/filter-bevel": "5.0.0", + "@pixi/filter-bloom": "5.0.0", + "@pixi/filter-bulge-pinch": "5.0.0", + "@pixi/filter-color-map": "5.0.0", + "@pixi/filter-color-overlay": "5.0.0", + "@pixi/filter-color-replace": "5.0.0", + "@pixi/filter-convolution": "5.0.0", + "@pixi/filter-cross-hatch": "5.0.0", + "@pixi/filter-crt": "5.0.0", + "@pixi/filter-dot": "5.0.0", + "@pixi/filter-drop-shadow": "5.0.0", + "@pixi/filter-emboss": "5.0.0", + "@pixi/filter-glitch": "5.0.0", + "@pixi/filter-glow": "5.0.0", + "@pixi/filter-godray": "5.0.0", + "@pixi/filter-kawase-blur": "5.0.0", + "@pixi/filter-motion-blur": "5.0.0", + "@pixi/filter-multi-color-replace": "5.0.0", + "@pixi/filter-old-film": "5.0.0", + "@pixi/filter-outline": "5.0.0", + "@pixi/filter-pixelate": "5.0.0", + "@pixi/filter-radial-blur": "5.0.0", + "@pixi/filter-reflection": "5.0.0", + "@pixi/filter-rgb-split": "5.0.0", + "@pixi/filter-shockwave": "5.0.0", + "@pixi/filter-simple-lightmap": "5.0.0", + "@pixi/filter-tilt-shift": "5.0.0", + "@pixi/filter-twist": "5.0.0", + "@pixi/filter-zoom-blur": "5.0.0" + }, + "dependencies": { + "@pixi/constants": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.2.4.tgz", + "integrity": "sha512-hKuHBWR6N4Q0Sf5MGF3/9l+POg/G5rqhueHfzofiuelnKg7aBs3BVjjZ+6hZbd6M++vOUmxYelEX/NEFBxrheA==", + "peer": true + }, + "@pixi/core": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/core/-/core-7.2.4.tgz", + "integrity": "sha512-0XtvrfxHlS2T+beBBSpo7GI8+QLyyTqMVQpNmPqB4woYxzrOEJ9JaUFBaBfCvycLeUkfVih1u6HAbtF+2d1EjQ==", + "peer": true, + "requires": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/extensions": "7.2.4", + "@pixi/math": "7.2.4", + "@pixi/runner": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/ticker": "7.2.4", + "@pixi/utils": "7.2.4", + "@types/offscreencanvas": "^2019.6.4" + } + }, + "@pixi/filter-adjustment": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-5.0.0.tgz", + "integrity": "sha512-Epci8zSWCNWhFtnarvQqOcnmOqLfhXIJ7NNENEi2E1rom1Ar13RLM76CBGBbuDRK7flweqcWmZb0QZLxqwxTDg==", + "requires": {} + }, + "@pixi/filter-advanced-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-advanced-bloom/-/filter-advanced-bloom-5.0.0.tgz", + "integrity": "sha512-P5Xt65GLBEqjZVUkLe4ZZk4D1/j9UEXYnYFG3JrLPYkdcniwD4Y+NIyNCJ+eP91ivgoCmK/+SyBRv0P0AEQkTw==", + "requires": { + "@pixi/filter-kawase-blur": "5.0.0" + } + }, + "@pixi/filter-alpha": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-7.2.4.tgz", + "integrity": "sha512-UTUMSGyktUr+I9vmigqJo9iUhb0nwGyqTTME2xBWZvVGCnl5z+/wHxvIBBCe5pNZ66IM15pGXQ4cDcfqCuP2kA==", + "peer": true, + "requires": {} + }, + "@pixi/filter-ascii": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-ascii/-/filter-ascii-5.0.0.tgz", + "integrity": "sha512-A49yNhiye/aFDOnI11zwEm/td2xho0td/Cvzvru8FUgi1MzJvZE03W/JoLl04ToZczw143wFPxutl6V/Ohw5bQ==", + "requires": {} + }, + "@pixi/filter-bevel": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bevel/-/filter-bevel-5.0.0.tgz", + "integrity": "sha512-0Odat0tW/uoS/uyp0rigm07Q3YPgwKLTgkZZZSzIUVsPnwcJjiocSzWel73JkiY3m2ZjTrj+JZjkyGjkYH+2gQ==", + "requires": {} + }, + "@pixi/filter-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bloom/-/filter-bloom-5.0.0.tgz", + "integrity": "sha512-vOSNJNV5y+ifwQWfzEmml3owcgoJAQIQtMR17SELBUwfYP60qxy5bNWBdYBlipSJVwX2AuGi8Xk5Ia9dijcqZQ==", + "requires": {} + }, + "@pixi/filter-blur": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-blur/-/filter-blur-7.2.4.tgz", + "integrity": "sha512-aLyXIoxy14bTansCPtbY8x7Sdn2OrrqkF/pcKiRXHJGGhi7wPacvB/NcmYJdnI/n2ExQ6V5Njuj/nfrsejVwcA==", + "peer": true, + "requires": {} + }, + "@pixi/filter-bulge-pinch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bulge-pinch/-/filter-bulge-pinch-5.0.0.tgz", + "integrity": "sha512-j1feWsCpyTZk4aHbYNjax52lt0OtyYDbHvYaePYzGO/SBb1t/spDnHQEkAP7R3bZ7Ud/GI4RgefAFnvsYeSetQ==", + "requires": {} + }, + "@pixi/filter-color-map": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-map/-/filter-color-map-5.0.0.tgz", + "integrity": "sha512-w77mRi89sLUMwjhl7qL/q1YrhEKyOk2MJZQdKBksvGEV/Mf5mV2h3+EOC62wB18Q4iUVQy1MS4sANyVaCctu2w==", + "requires": {} + }, + "@pixi/filter-color-overlay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-overlay/-/filter-color-overlay-5.0.0.tgz", + "integrity": "sha512-AjxVN6gnZ+xCryQUmI+TVy3yVF+CcLgDPv+nSVPDlQowuqYhZjD6qSzgRCl3Kezdi3AxrL1vi1fnBudEnzdDJg==", + "requires": {} + }, + "@pixi/filter-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-replace/-/filter-color-replace-5.0.0.tgz", + "integrity": "sha512-u4VOtKbY6SSr2P9v5AL8/2MVsUcAH9z92c1eaqeE3PXCPNyCgZKuNHWl8+FjBIDl/1UMQVhXH2zNrC3Vuqo3JA==", + "requires": {} + }, + "@pixi/filter-convolution": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-convolution/-/filter-convolution-5.0.0.tgz", + "integrity": "sha512-SYjyKXODdHbjzBP9c5QGMOfowNwkNFi7zW1XzGwEadmv6mLHNanO3nm0PtRu/3B9B6AW1fvOaUecYmhjAZfQjg==", + "requires": {} + }, + "@pixi/filter-cross-hatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-cross-hatch/-/filter-cross-hatch-5.0.0.tgz", + "integrity": "sha512-J4bcI3MUc/Ol3nQIsXZldYEtiLAl3ktU28zlidwffkANyl/XjP76bLEgFBoc4RE2iP/FQ+9ZeEqpsN8DIg6vVg==", + "requires": {} + }, + "@pixi/filter-crt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-crt/-/filter-crt-5.0.0.tgz", + "integrity": "sha512-/kgjNW+BCCVtUa0s8Usk3WyxgBX8kelAiqkyVnM1g8xM19Dh2689gK2wjx0ibS0p74EHs42QpkJj7jTL+1MS7A==", + "requires": {} + }, + "@pixi/filter-dot": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-dot/-/filter-dot-5.0.0.tgz", + "integrity": "sha512-kytardK58Ifl5D8Ss3kkfI29FMzV3+npJYr5GAKnA80R7XGOPOMoxrknhou8y+Dw9LUcOv8y643wryvL43P2vw==", + "requires": {} + }, + "@pixi/filter-drop-shadow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-drop-shadow/-/filter-drop-shadow-5.0.0.tgz", + "integrity": "sha512-kz2eL+ikCLL7/2RICyIkw3pZXkyMY0Ji6skhnPj7JaZSjH4V+7TiKqYXp532gTbwSRj/mzLCvFfOL3WwTDgZ1w==", + "requires": { + "@pixi/filter-kawase-blur": "5.0.0" + } + }, + "@pixi/filter-emboss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-emboss/-/filter-emboss-5.0.0.tgz", + "integrity": "sha512-wvrk9zB62lGaPcCWbTwoaO48FrLIE4+hi02BVS+exx5RvIniNUJD/ledGxdmUjcHX/2mDIIs7PH0kAs1L/ziZw==", + "requires": {} + }, + "@pixi/filter-glitch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glitch/-/filter-glitch-5.0.0.tgz", + "integrity": "sha512-yK3plqExyQp9eo3dwV03dnSHpQgh0xeD112ieAsqefrAOLc5AXSfTelPvEQaZ07ZkcxSDE5eqKcRvcIVi2IgLQ==", + "requires": {} + }, + "@pixi/filter-glow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glow/-/filter-glow-5.0.0.tgz", + "integrity": "sha512-D+YE9DGSJXtmZa6aoWJfuNu+6MnSw90GP7oRRzr7S1/4moeFZ7EWbvQehl9Y9j98idHG87Cvuh6mmsRqpgS6ow==", + "requires": {} + }, + "@pixi/filter-godray": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-godray/-/filter-godray-5.0.0.tgz", + "integrity": "sha512-L4PD3cysUMjTSDYk5q5xUtal9q6kfH8NVIdNT3aTDJpR0VW4b/ClanmOTFpJVzN6Ld/JlJbdg8ogUpXBe1gVuw==", + "requires": {} + }, + "@pixi/filter-kawase-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-kawase-blur/-/filter-kawase-blur-5.0.0.tgz", + "integrity": "sha512-dKSTaPUOvdVkfx9x+kp0TzYjGAl8CLxIRGz6Wh43NKx96nVqd/lWqvlda+zloHVgZyQoJNHZZ4Spjcw2mYoaWg==", + "requires": {} + }, + "@pixi/filter-motion-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-motion-blur/-/filter-motion-blur-5.0.0.tgz", + "integrity": "sha512-2av4dnVL1uyyCKF8RlZaMfeO8YnQwA893j24S15ubWHZaz4WlWH3lFIYmCMqlEqHPlFDBER4vLxpR1WjsUsX/Q==", + "requires": {} + }, + "@pixi/filter-multi-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-multi-color-replace/-/filter-multi-color-replace-5.0.0.tgz", + "integrity": "sha512-hcmCKFFQ1baGDrZc/blK9zWpe3f02rqWGsPx5VRRgc1sk44UYXHCKZnDjF80/g0ls8U4Lj+/5Xb7HOQq2LyyDg==", + "requires": {} + }, + "@pixi/filter-old-film": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-old-film/-/filter-old-film-5.0.0.tgz", + "integrity": "sha512-XSHBz4JDbvYtUrf/NP5eKCw/wvaKTAKXQENDxk480tKYtDuteSCMg87ZjLrPlyKtGySW8KTmdzl58bZjSYpiyA==", + "requires": {} + }, + "@pixi/filter-outline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-outline/-/filter-outline-5.0.0.tgz", + "integrity": "sha512-efS3Or7VQFXo2ZyPFR2M/JlZrcLAxeVbOTPYvgKe574yUghSQbQ/pyqDWE16tRB/W7+osMrTV0+C4/N/9wIxhQ==", + "requires": {} + }, + "@pixi/filter-pixelate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-pixelate/-/filter-pixelate-5.0.0.tgz", + "integrity": "sha512-3g1ajOLsYy+x0FCC67WhDcjixrcBlhK3Zo+JP9zlHSxh0W4yNzfhsw9EsIb9XP4WnMtMAUMg5T0MLTnjbsrK4g==", + "requires": {} + }, + "@pixi/filter-radial-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-radial-blur/-/filter-radial-blur-5.0.0.tgz", + "integrity": "sha512-zafBJCAiqRtsTNGKiQ8iMt00KbG20qtBi71h286wWbr0na37iXsRcg4EN76eyNbpfAOX+1ylBgIuSd9hLyQBFA==", + "requires": {} + }, + "@pixi/filter-reflection": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-reflection/-/filter-reflection-5.0.0.tgz", + "integrity": "sha512-PuZe19XUq0gTdmAStu3hcyGKkNlKGrpblN4s6vJmV+vAKVcFv2OpfjtuGUXcP/oi2LmLakC/vKfEx4bDgZzz+w==", + "requires": {} + }, + "@pixi/filter-rgb-split": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-rgb-split/-/filter-rgb-split-5.0.0.tgz", + "integrity": "sha512-zsWBrDkj9EdjJRPjGCt/0O2Vx/8Gt+8VTmjRA0ONoegcMD9slJdJMgL9EbH/1y5WHgmzGbgZIPvWULIqepVxBQ==", + "requires": {} + }, + "@pixi/filter-shockwave": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-shockwave/-/filter-shockwave-5.0.0.tgz", + "integrity": "sha512-aL0ExAkJGcUo463Ktq4HXjZGlJDpoYcyZhwd87maJrFsBjQZl2gopse6bEsy7IJxbAKzlpUKFmAP9rxwZWqMVQ==", + "requires": {} + }, + "@pixi/filter-simple-lightmap": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-simple-lightmap/-/filter-simple-lightmap-5.0.0.tgz", + "integrity": "sha512-0WIKQIGZ3aNafe2VZIbGQJWxSlBMbmjM9J+Tswjaeg8Z1dz6Qux5lYIC16wZOaIqVlWL5GTpfn8HU0BHCOvESA==", + "requires": {} + }, + "@pixi/filter-tilt-shift": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-tilt-shift/-/filter-tilt-shift-5.0.0.tgz", + "integrity": "sha512-nIxYoTU9kFDx3EE1fyoIEOfAia9Tvoj+sakTKCJZUvTk+5tjpZdAm+Ump42cnb6UxTR8AMTQiwH54C7I0pbA4Q==", + "requires": {} + }, + "@pixi/filter-twist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-twist/-/filter-twist-5.0.0.tgz", + "integrity": "sha512-YVtz3ZPfvaz22gZRZo+cOC0/L6SgSZmr/HEa6Ir+BRNVqLff6CpPx6YBVJqPREh+HFZjDomSP0kf5JasQYhzSg==", + "requires": {} + }, + "@pixi/filter-zoom-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-zoom-blur/-/filter-zoom-blur-5.0.0.tgz", + "integrity": "sha512-Q1ftuY/KPgbVtJHCvl0p4hrwVWRMWZ/yX1YRjdLGSyOwMEN8u16MEEXFQUtixEHY7+MBRBWaPOaXBaQrd+Xq7A==", + "requires": {} + }, + "@pixi/math": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.2.4.tgz", + "integrity": "sha512-LJB+mozyEPllxa0EssFZrKNfVwysfaBun4b2dJKQQInp0DafgbA0j7A+WVg0oe51KhFULTJMpDqbLn/ITFc41A==", + "peer": true + }, + "@pixi/runner": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.2.4.tgz", + "integrity": "sha512-YtyqPk1LA+0guEFKSFx6t/YSvbEQwajFwi4Ft8iDhioa6VK2MmTir1GjWwy7JQYLcDmYSAcQjnmFtVTZohyYSw==", + "peer": true + }, + "@pixi/settings": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.2.4.tgz", + "integrity": "sha512-ZPKRar9EwibijGmH8EViu4Greq1I/O7V/xQx2rNqN23XA7g09Qo6yfaeQpufu5xl8+/lZrjuHtQSnuY7OgG1CA==", + "peer": true, + "requires": { + "@pixi/constants": "7.2.4", + "@types/css-font-loading-module": "^0.0.7", + "ismobilejs": "^1.1.0" + } + }, + "@pixi/ticker": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.2.4.tgz", + "integrity": "sha512-hQQHIHvGeFsP4GNezZqjzuhUgNQEVgCH9+qU05UX1Mc5UHC9l6OJnY4VTVhhcHxZjA6RnyaY+1zBxCnoXuazpg==", + "peer": true, + "requires": { + "@pixi/extensions": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/utils": "7.2.4" + } + }, + "@pixi/utils": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.2.4.tgz", + "integrity": "sha512-VUGQHBOINIS4ePzoqafwxaGPVRTa3oM/mEutIIHbNGI3b+QvSO+1Dnk40M0zcH6Bo+MxQZbOZK5X/wO9oU5+LQ==", + "peer": true, + "requires": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/settings": "7.2.4", + "@types/earcut": "^2.1.0", + "earcut": "^2.2.4", + "eventemitter3": "^4.0.0", + "url": "^0.11.0" + } + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "peer": true + } + } + }, "pixi.js": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-6.0.4.tgz", diff --git a/package.json b/package.json index 8c7e2cb..0e13461 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psychojs", - "version": "2022.3.1", + "version": "2023.2.1", "private": true, "description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments", "license": "MIT", @@ -34,6 +34,7 @@ "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", + "pixi-filters": "^5.0.0", "pixi.js-legacy": "^6.0.4", "seedrandom": "^3.0.5", "tone": "^14.7.77", diff --git a/src/core/EventManager.js b/src/core/EventManager.js index c9f8255..245b3a7 100644 --- a/src/core/EventManager.js +++ b/src/core/EventManager.js @@ -302,7 +302,13 @@ export class EventManager { 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): if (typeof code === "undefined") diff --git a/src/core/GUI.js b/src/core/GUI.js index 90bd3d1..e3cc571 100644 --- a/src/core/GUI.js +++ b/src/core/GUI.js @@ -50,6 +50,9 @@ export class GUI { this._psychoJS = psychoJS; + // info fields excluded from the GUI: + this._excludedInfo = {}; + // gui listens to RESOURCE events from the server manager: psychoJS.serverManager.on(ServerManager.Event.RESOURCE, (signal) => { @@ -87,9 +90,6 @@ export class GUI requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick }) { - // get info from URL: - const infoFromUrl = util.getUrlParameters(); - this._progressBarMax = 0; this._allResourcesDownloaded = false; this._requiredKeys = []; @@ -113,6 +113,19 @@ export class GUI self._dialogComponent.tStart = t; 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, // we use the license logo: if (self._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER @@ -130,7 +143,13 @@ export class GUI markup += "
"; // alert title and close button: - markup += `

${title}

`; + markup += "
"; + markup += `

${title}

`; + markup += ""; + markup += "
"; + + // everything above the buttons is in a scrollable container: + markup += "
"; // logo, if need be: if (typeof logoUrl === "string") @@ -139,14 +158,16 @@ export class GUI } // add a combobox or text areas for each entry in the dictionary: + let atLeastOneIncludedKey = false; Object.keys(dictionary).forEach((key, keyIdx) => { const value = dictionary[key]; const keyId = "form-input-" + keyIdx; // only create an input if the key is not in the URL: - let inUrl = false; const cleanedDictKey = key.trim().toLowerCase(); + const isIncluded = !(cleanedDictKey in excludedInfo); + /*let inUrl = false; infoFromUrl.forEach((urlValue, urlKey) => { const cleanedUrlKey = urlKey.trim().toLowerCase(); @@ -155,10 +176,13 @@ export class GUI inUrl = true; // break; } - }); + });*/ - if (!inUrl) + if (isIncluded) + // if (!inUrl) { + atLeastOneIncludedKey = true; + markup += ``; // if the field is required: @@ -185,7 +209,7 @@ export class GUI markup += ""; } - // otherwise we use a single string input: + // otherwise we use a single string input: //if (typeof value === 'string') else { @@ -199,17 +223,27 @@ export class GUI markup += "

Fields marked with an asterisk (*) are required.

"; } + markup += "
"; // scrollable-container + + // separator, if need be: + if (atLeastOneIncludedKey) + { + markup += "
"; + } + // progress bar: - markup += `
${self._progressMessage}
`; + markup += `
${self._progressMessage}
`; markup += "
"; // buttons: markup += "
"; + markup += "
"; markup += ""; if (self._requireParticipantClick) { markup += ""; } + markup += "
"; // button-group markup += "
"; @@ -346,14 +380,18 @@ export class GUI { const error = this._userFriendlyError(errorCode); markup += `

${error.title}

`; + markup += "
"; markup += `

${error.text}

`; + markup += "
"; } else { markup += `

Error

`; + markup += "
"; markup += `

Unfortunately we encountered the following error:

`; markup += stackCode; markup += "

Try to run the experiment again. If the error persists, contact the experiment designer.

"; + markup += "
"; } } @@ -361,27 +399,36 @@ export class GUI else if (typeof warning !== "undefined") { markup += `

Warning

`; + markup += "
"; markup += `

${warning}

`; + markup += "
"; } // we are displaying a message: else if (typeof message !== "undefined") { - markup += `

Message

`; + markup += "

Message

"; + markup += "
"; markup += `

${message}

`; + markup += "
"; } if (showOK || showCancel) { markup += "
"; } - if (showCancel) + if (showCancel || showOK) { - markup += ""; - } - if (showOK) - { - markup += ""; + markup += "
"; + if (showCancel) + { + markup += ""; + } + if (showOK) + { + markup += ""; + } + markup += "
"; // button-group } markup += ""; diff --git a/src/core/Keyboard.js b/src/core/Keyboard.js index dd2427d..56df760 100644 --- a/src/core/Keyboard.js +++ b/src/core/Keyboard.js @@ -354,7 +354,13 @@ export class Keyboard extends PsychObject */ 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): if (typeof code === "undefined") @@ -394,7 +400,9 @@ export class Keyboard extends PsychObject 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: if (typeof code === "undefined") diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index 21d9f35..c8ca9d1 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -530,6 +530,7 @@ export class PsychoJS const response = { origin: "PsychoJS.quit", context: "when terminating the experiment" }; this._experiment.experimentEnded = true; + this._experiment.isCompleted = isCompleted; this.status = PsychoJS.Status.STOPPED; const isServerEnv = (this.getEnvironment() === ExperimentHandler.Environment.SERVER); @@ -601,7 +602,7 @@ export class PsychoJS if (showOK) { - let text = "Thank you for your patience.

"; + let text = "Thank you for your patience."; text += (typeof message !== "undefined") ? message : "Goodbye!"; this._gui.dialog({ message: text, diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index 2415c50..d522f6f 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -1293,7 +1293,7 @@ export class ServerManager extends PsychObject } // font files: - else if (["ttf", "otf", "woff", "woff2"].indexOf(pathExtension) > -1) + else if (["ttf", "otf", "woff", "woff2","eot"].indexOf(pathExtension) > -1) { fontResources.push(name); } diff --git a/src/core/Window.js b/src/core/Window.js index cb6acbe..16761a0 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -13,6 +13,7 @@ import { MonotonicClock } from "../util/Clock.js"; import { Color } from "../util/Color.js"; import { PsychObject } from "../util/PsychObject.js"; import { Logger } from "./Logger.js"; +import { hasTouchScreen } from "../util/Util.js"; /** *

Window displays the various stimuli of the experiment.

@@ -181,7 +182,7 @@ export class Window extends PsychObject { // gets updated frame by frame 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; } @@ -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: 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); this._backgroundSprite.width = this._size[0]; this._backgroundSprite.height = this._size[1]; diff --git a/src/data/ExperimentHandler.js b/src/data/ExperimentHandler.js index 7a30578..97692b5 100644 --- a/src/data/ExperimentHandler.js +++ b/src/data/ExperimentHandler.js @@ -276,6 +276,7 @@ export class ExperimentHandler extends PsychObject } let data = this._trialsData; + // if the experiment data have to be cleared, we first make a copy of them: 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. *

Only info relating to the trial execution are returned.

diff --git a/src/index.css b/src/index.css index 301aaa1..8194d84 100644 --- a/src/index.css +++ b/src/index.css @@ -26,13 +26,12 @@ body { /* Project and resource dialogs */ - .dialog-container label, .dialog-container input, .dialog-container select { - box-sizing: border-box; - display: block; - padding-bottom: 0.5em; + box-sizing: border-box; + display: block; + padding-bottom: 0.5em; } .dialog-container input.text, @@ -40,6 +39,13 @@ body { margin-bottom: 1em; padding: 0.5em; width: 100%; + + height: 34px; + border: 1px solid #767676; + border-radius: 2px; + background: #ffffff; + color: #333; + font-size: 14px; } .dialog-container fieldset { @@ -71,12 +77,19 @@ body { } .dialog-content { + display: flex; + flex-direction: column; + row-gap: 0; + margin: auto; z-index: 2; position: relative; width: 500px; max-width: 88vw; + /*max-height: 90vh;*/ + max-height: 93%; + padding: 0.5em; border-radius: 2px; @@ -88,11 +101,24 @@ body { 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 { padding: 0.5em; margin-bottom: 1em; - background-color: #009900; + background-color: #00dd00; + /*background-color: #009900;*/ border-radius: 2px; } @@ -111,6 +137,11 @@ body { } .dialog-close { + display: flex; + justify-content: center; + align-items: center; + line-height: 1.1em; + position: absolute; top: 0.7em; right: 0.7em; @@ -153,7 +184,7 @@ body { .dialog-button { 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-radius: 2px; @@ -176,6 +207,14 @@ body { 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 { border: 1px solid #AAAAAA; color: #AAAAAA; @@ -186,10 +225,15 @@ body { } .logo { - display: block; + display: flex; + flex: 0 1 auto; + height: 100%; + width: auto; + + /*display: block; margin: 0 auto 1em; max-height: 20vh; - max-width: 100%; + max-width: 100%;*/ } a, diff --git a/src/util/Scheduler.js b/src/util/Scheduler.js index bad709c..4bbaf6e 100644 --- a/src/util/Scheduler.js +++ b/src/util/Scheduler.js @@ -117,9 +117,12 @@ export class Scheduler * Start this scheduler. * *

Note: tasks are run after each animation frame.

+ * + * @return {Promise} a promise resolved when the scheduler stops, e.g. when the experiments finishes */ start() { + let shedulerResolve; const self = this; const update = async (timestamp) => { @@ -127,6 +130,7 @@ export class Scheduler if (self._stopAtNextUpdate) { self._status = Scheduler.Status.STOPPED; + shedulerResolve(); return; } @@ -137,6 +141,7 @@ export class Scheduler if (state === Scheduler.Event.QUIT) { self._status = Scheduler.Status.STOPPED; + shedulerResolve(); return; } @@ -155,6 +160,12 @@ export class Scheduler // start the animation: requestAnimationFrame(update); + + // return a promise resolved when the scheduler is stopped: + return new Promise((resolve, _) => + { + shedulerResolve = resolve; + }); } /** diff --git a/src/util/Util.js b/src/util/Util.js index 0845207..72b8514 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -629,6 +629,11 @@ export function toString(object) return object.toString(); } + if (typeof object === "function") + { + return ``; + } + try { const symbolReplacer = (key, value) => @@ -1455,6 +1460,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. * Note that Arabic is the same as RTL but added here to support PsychoPy's diff --git a/src/visual/ButtonStim.js b/src/visual/ButtonStim.js index c007b51..5b4d34f 100644 --- a/src/visual/ButtonStim.js +++ b/src/visual/ButtonStim.js @@ -9,6 +9,7 @@ import { Mouse } from "../core/Mouse.js"; import { TextBox } from "./TextBox.js"; +import * as util from "../util/Util"; /** *

ButtonStim visual stimulus.

@@ -32,6 +33,7 @@ export class ButtonStim extends TextBox * @param {Color} [options.borderColor= Color("white")] the border color * @param {Color} [options.borderWidth= 0] the border width * @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 {boolean} [options.bold= true] - whether or not the text is bold * @param {boolean} [options.italic= false] - whether or not the text is italic @@ -54,11 +56,14 @@ export class ButtonStim extends TextBox borderColor, borderWidth = 0, opacity, + depth, letterHeight, bold = true, italic, autoDraw, autoLog, + boxFn, + multiline } = {}, ) { @@ -77,12 +82,15 @@ export class ButtonStim extends TextBox borderColor, borderWidth, opacity, + depth, letterHeight, + multiline, bold, italic, alignment: "center", autoDraw, autoLog, + boxFn }); this.psychoJS.logger.debug("create a new Button with name: ", name); @@ -112,7 +120,7 @@ export class ButtonStim extends TextBox if (this._autoLog) { - this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${util.toString(this)}`); } } diff --git a/src/visual/ImageStim.js b/src/visual/ImageStim.js index f043579..1b3da06 100644 --- a/src/visual/ImageStim.js +++ b/src/visual/ImageStim.js @@ -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.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 }); @@ -94,6 +94,12 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) false, this._onChange(false, false), ); + this._addAttribute( + "aspectRatio", + aspectRatio, + ImageStim.AspectRatioStrategy.VARIABLE, + this._onChange(true, true), + ); // estimate the bounding box: 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._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: if (typeof this._mask !== "undefined") @@ -349,8 +366,24 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) // set the scale: const displaySize = this._getDisplaySize(); const size_px = util.to_px(displaySize, this.units, this.win); - const scaleX = size_px[0] / this._texture.width; - const scaleY = size_px[1] / this._texture.height; + let scaleX = size_px[0] / this._texture.width; + 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._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; 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); } } + 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; } } + +/** + * 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"), +}; diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 4d8e2bc..ab06378 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -86,7 +86,8 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) clipMask, autoDraw, 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 this.setSize(size); + this._addAttribute("boxFn", boxFn, null); + // estimate the bounding box: this._estimateBoundingBox(); 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"]; } + 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 { // input style properties eventually become CSS, so same syntax applies input: { @@ -504,41 +527,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) overflow: "hidden", pointerEvents: "none" }, - // 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 - }, - /*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 - } - }*/ - }, + box }; } diff --git a/src/visual/survey/widgets/MaxDiffMatrix.js b/src/visual/survey/widgets/MaxDiffMatrix.js index d9958c5..a50c784 100644 --- a/src/visual/survey/widgets/MaxDiffMatrix.js +++ b/src/visual/survey/widgets/MaxDiffMatrix.js @@ -95,18 +95,11 @@ class MaxDiffMatrix question.setCssRoot(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. // This is correct according current Qualtrics design for MaxDiff matrices. // Header generation - headerCells = + let headerCells = `${question.columns[0].text} @@ -114,9 +107,10 @@ class MaxDiffMatrix ${question.columns[1].text}`; // Body generation - for (i = 0; i < question.rows.length; i++) + let bodyHTML = ""; + for (let i = 0; i < question.rows.length; i++) { - bodyCells = + const bodyCells = `